test runner working in docker

This commit is contained in:
Yusef Napora 2020-06-22 14:24:27 -04:00
parent c9dbf908b5
commit 9e348c049b
9 changed files with 173 additions and 114 deletions

View File

@ -17,6 +17,7 @@ RUN mkdir /testground && chmod 777 /testground
ENV HOME /composer
ENV TESTGROUND_HOME /testground
ENV LISTEN_PORT 5006
ENV TESTGROUND_DAEMON_HOST host.docker.internal
VOLUME /testground/plans

View File

@ -3,10 +3,11 @@ import panel as pn
import toml
from .util import get_plans, get_manifest
from .composition import Composition
from .runner import TestRunner
STAGE_WELCOME = 'Welcome'
STAGE_CONFIG_COMPOSITION = 'Configure'
STAGE_RUN_TEST = 'Run'
class Welcome(param.Parameterized):
composition = param.Parameter()
@ -45,7 +46,7 @@ class Welcome(param.Parameterized):
return
print('plan selected: {}'.format(evt.new))
manifest = get_manifest(evt.new)
self.composition = Composition(manifest=manifest)
self.composition = Composition(manifest=manifest, add_default_group=True)
print('new composition: ', self.composition)
self.ready = True
@ -65,7 +66,8 @@ class WorkflowPipeline(object):
def __init__(self):
stages = [
(STAGE_WELCOME, Welcome(), dict(ready_parameter='ready')),
(STAGE_CONFIG_COMPOSITION, ConfigureComposition())
(STAGE_CONFIG_COMPOSITION, ConfigureComposition()),
(STAGE_RUN_TEST, TestRunner()),
]
self.pipeline = pn.pipeline.Pipeline(debug=True, stages=stages)
@ -79,6 +81,7 @@ class WorkflowPipeline(object):
self.pipeline.next_button,
),
self.pipeline.stage,
sizing_mode='stretch_width',
)

View File

@ -188,11 +188,13 @@ class Composition(param.Parameterized):
groups_ui = None
def __init__(self, manifest=None, **params):
def __init__(self, manifest=None, add_default_group=False, **params):
super(Composition, self).__init__(**params)
self.manifest = manifest
self.testcase_param_classes = dict()
self._set_manifest(manifest)
if add_default_group:
self._add_group()
@classmethod
def from_dict(cls, d, manifest=None):
@ -262,7 +264,7 @@ class Composition(param.Parameterized):
print_err("No testcase found in manifest named " + case)
return cls
def _add_group(self, evt):
def _add_group(self, *args):
g = Group(id='New Group', params_class=self._params_class_for_current_testcase())
groups = self.param['groups'].objects
groups.append(g)

111
composer/app/runner.py Normal file
View File

@ -0,0 +1,111 @@
import os
import panel as pn
import param
from panel.io.server import unlocked
from tornado.ioloop import IOLoop, PeriodicCallback
from tornado.process import Subprocess
from subprocess import STDOUT
from bokeh.models.widgets import Div
from ansi2html import Ansi2HTMLConverter
from .composition import Composition
TESTGROUND = 'testground'
class AnsiColorText(pn.widgets.Widget):
style = param.Dict(default=None, doc="""
Dictionary of CSS property:value pairs to apply to this Div.""")
value = param.Parameter(default=None)
_format = '<div>{value}</div>'
_rename = {'name': None, 'value': 'text'}
# _target_transforms = {'value': 'target.text.split(": ")[0]+": "+value'}
#
# _source_transforms = {'value': 'value.split(": ")[1]'}
_widget_type = Div
_converter = Ansi2HTMLConverter(inline=True)
def _process_param_change(self, msg):
msg = super(AnsiColorText, self)._process_property_change(msg)
if 'value' in msg:
text = str(msg.pop('value'))
text = self._converter.convert(text)
msg['text'] = text
return msg
def scroll_down(self):
# TODO: figure out how to automatically scroll down as text is added
pass
class CommandRunner(param.Parameterized):
command_output = param.String()
def __init__(self, **params):
super().__init__(**params)
self._output_lines = []
self.proc = None
self._updater = PeriodicCallback(self._refresh_output, callback_time=1000)
@pn.depends('command_output')
def panel(self):
return pn.Param(self.param, show_name=False, sizing_mode='stretch_width', widgets={
'command_output': dict(
type=AnsiColorText,
sizing_mode='stretch_width',
height=800)
})
def run(self, *cmd):
self.command_output = ''
self._output_lines = []
self.proc = Subprocess(cmd, stdout=Subprocess.STREAM, stderr=STDOUT)
self._get_next_line()
self._updater.start()
def _get_next_line(self):
if self.proc is None:
return
loop = IOLoop.current()
loop.add_future(self.proc.stdout.read_until(bytes('\n', encoding='utf8')), self._append_output)
def _append_output(self, future):
self._output_lines.append(future.result().decode('utf8'))
self._get_next_line()
def _refresh_output(self):
text = ''.join(self._output_lines)
if len(text) != len(self.command_output):
with unlocked():
self.command_output = text
class TestRunner(param.Parameterized):
composition = param.ClassSelector(class_=Composition, precedence=-1)
testground_daemon_endpoint = param.String(default="{}:8042".format(os.environ.get('TESTGROUND_DAEMON_HOST', 'localhost')))
run_test = param.Action(lambda self: self.run())
runner = CommandRunner()
def __init__(self, **params):
super().__init__(**params)
def run(self):
# TODO: temp file management - maybe we should mount a volume and save there?
filename = '/tmp/composition.toml'
self.composition.write_to_file(filename)
self.runner.run(TESTGROUND, '--endpoint', self.testground_daemon_endpoint, 'run', 'composition', '-f', filename)
def panel(self):
return pn.Column(
self.param['testground_daemon_endpoint'],
self.param['run_test'],
self.runner.panel(),
sizing_mode='stretch_width',
)

File diff suppressed because one or more lines are too long

View File

@ -24,30 +24,15 @@ require_cmds() {
done
}
get_manifest_paths() {
find -L $tg_home/plans -name manifest.toml
}
update_manifests() {
update_plans() {
local dest_dir=$1
mkdir -p $dest_dir
for m in $(get_manifest_paths); do
local plan=$(basename $(dirname $m))
local dest="$dest_dir/$plan/manifest.toml"
mkdir -p "$dest_dir/$plan"
# only copy if source manifest is newer than dest (or dest doesn't exist)
if [[ ! -e $dest || $m -nt $dest ]]; then
cp $m $dest
fi
done
rsync -avzh --quiet --copy-links "${tg_home}/plans/" ${dest_dir}
}
watch_manifests() {
local manifest_dest=$1
watch_plans() {
local plans_dest=$1
while true; do
update_manifests ${manifest_dest}
update_plans ${plans_dest}
sleep $poll_interval
done
}
@ -71,16 +56,23 @@ cleanup () {
docker stop ${container_id} >/dev/null
fi
if [[ -d "$temp_manifest_dir" ]]; then
rm -rf ${temp_manifest_dir}
if [[ -d "$temp_plans_dir" ]]; then
rm -rf ${temp_plans_dir}
fi
}
get_host_ip() {
# get interface of default route
local net_if=$(netstat -rn | awk '/^0.0.0.0/ {thif=substr($0,74,10); print thif;} /^default.*UG/ {thif=substr($0,65,10); print thif;}')
# use ifconfig to get addr of that interface
ifconfig ${net_if} | grep -Eo 'inet (addr:)?([0-9]*\.){3}[0-9]*' | grep -Eo '([0-9]*\.){3}[0-9]*' | grep -v '127.0.0.1'
}
# run cleanup on exit
trap "{ cleanup; }" EXIT
# make sure we have the commands we need
require_cmds jq docker
require_cmds jq docker rsync
# make temp dir for manifests
temp_base="/tmp"
@ -88,14 +80,19 @@ if [[ "$TEMP" != "" ]]; then
temp_base=$TEMP
fi
temp_manifest_dir="$(mktemp -d ${temp_base}/testground-composer-XXXX)"
echo "temp manifest dir: $temp_manifest_dir"
temp_plans_dir="$(mktemp -d ${temp_base}/testground-composer-XXXX)"
echo "temp plans dir: $temp_plans_dir"
# copy the manifests to the temp dir
update_manifests ${temp_manifest_dir}
# copy testplans from $TESTGROUND_HOME/plans to the temp dir
update_plans ${temp_plans_dir}
# run the container in detached mode and grab the id
container_id=$(docker run -d --user $(id -u):$(id -g) -p ${panel_port}:5006 -v ${temp_manifest_dir}:${container_plans_dir}:ro $image_full_name)
container_id=$(docker run -d \
-e TESTGROUND_DAEMON_HOST=$(get_host_ip) \
--user $(id -u):$(id -g) \
-p ${panel_port}:5006 \
-v ${temp_plans_dir}:${container_plans_dir}:ro \
$image_full_name)
echo "container $container_id started"
# print the log output
@ -108,5 +105,5 @@ sleep 2
panel_url="http://localhost:${panel_port}"
open_url $panel_url
# poll & check for manifest changes every few seconds
watch_manifests ${temp_manifest_dir}
# poll & sync testplan changes every few seconds
watch_plans ${temp_plans_dir}

View File

@ -1,63 +0,0 @@
name = "example"
[defaults]
builder = "exec:go"
runner = "local:exec"
[builders."docker:generic"]
enabled = true
[builders."docker:generic".build_args]
build_image = "golang:alpine"
run_image = "scratch"
[builders."docker:go"]
enabled = true
[builders."exec:go"]
enabled = true
[runners."local:docker"]
enabled = true
[runners."local:exec"]
enabled = true
[runners."cluster:swarm"]
enabled = true
[runners."cluster:k8s"]
enabled = true
[[testcases]]
name = "output"
instances = { min = 1, max = 200, default = 1 }
[[testcases]]
name = "failure"
instances = { min = 1, max = 200, default = 1 }
[[testcases]]
name = "panic"
instances = { min = 1, max = 200, default = 1 }
[[testcases]]
name = "params"
instances = { min = 1, max = 200, default = 1 }
[testcases.params]
param1 = { type = "int", desc = "some param 1", unit = "widgets", default=1 }
param2 = { type = "int", desc = "some param 2", unit = "widgets", default=2 }
param3 = { type = "int", desc = "some param 3", unit = "widgets", default=3 }
[[testcases]]
name = "sync"
instances = { min = 2, max = 200, default = 5 }
[[testcases]]
name = "metrics"
instances = { min = 1, max = 200, default = 5 }
[[testcases]]
name = "artifact"
instances = { min = 1, max = 200, default = 5 }

View File

@ -0,0 +1,14 @@
[metadata]
name = "ping-pong-local"
author = "yusef"
[global]
plan = "network"
case = "ping-pong"
total_instances = 2
builder = "docker:go"
runner = "local:docker"
[[groups]]
id = "nodes"
instances = { count = 2 }

View File

@ -3,3 +3,4 @@ toml
jupyter
panel
holoviews
ansi2html