test runner working in docker
This commit is contained in:
parent
c9dbf908b5
commit
9e348c049b
@ -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
|
||||
|
||||
|
@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
@ -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
111
composer/app/runner.py
Normal 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
@ -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}
|
||||
|
@ -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 }
|
||||
|
14
composer/fixtures/ping-pong-local.toml
Normal file
14
composer/fixtures/ping-pong-local.toml
Normal 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 }
|
@ -3,3 +3,4 @@ toml
|
||||
jupyter
|
||||
panel
|
||||
holoviews
|
||||
ansi2html
|
||||
|
Loading…
Reference in New Issue
Block a user