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 HOME /composer
|
||||||
ENV TESTGROUND_HOME /testground
|
ENV TESTGROUND_HOME /testground
|
||||||
ENV LISTEN_PORT 5006
|
ENV LISTEN_PORT 5006
|
||||||
|
ENV TESTGROUND_DAEMON_HOST host.docker.internal
|
||||||
|
|
||||||
VOLUME /testground/plans
|
VOLUME /testground/plans
|
||||||
|
|
||||||
|
@ -3,10 +3,11 @@ import panel as pn
|
|||||||
import toml
|
import toml
|
||||||
from .util import get_plans, get_manifest
|
from .util import get_plans, get_manifest
|
||||||
from .composition import Composition
|
from .composition import Composition
|
||||||
|
from .runner import TestRunner
|
||||||
|
|
||||||
STAGE_WELCOME = 'Welcome'
|
STAGE_WELCOME = 'Welcome'
|
||||||
STAGE_CONFIG_COMPOSITION = 'Configure'
|
STAGE_CONFIG_COMPOSITION = 'Configure'
|
||||||
|
STAGE_RUN_TEST = 'Run'
|
||||||
|
|
||||||
class Welcome(param.Parameterized):
|
class Welcome(param.Parameterized):
|
||||||
composition = param.Parameter()
|
composition = param.Parameter()
|
||||||
@ -45,7 +46,7 @@ class Welcome(param.Parameterized):
|
|||||||
return
|
return
|
||||||
print('plan selected: {}'.format(evt.new))
|
print('plan selected: {}'.format(evt.new))
|
||||||
manifest = get_manifest(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)
|
print('new composition: ', self.composition)
|
||||||
self.ready = True
|
self.ready = True
|
||||||
|
|
||||||
@ -65,7 +66,8 @@ class WorkflowPipeline(object):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
stages = [
|
stages = [
|
||||||
(STAGE_WELCOME, Welcome(), dict(ready_parameter='ready')),
|
(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)
|
self.pipeline = pn.pipeline.Pipeline(debug=True, stages=stages)
|
||||||
@ -79,6 +81,7 @@ class WorkflowPipeline(object):
|
|||||||
self.pipeline.next_button,
|
self.pipeline.next_button,
|
||||||
),
|
),
|
||||||
self.pipeline.stage,
|
self.pipeline.stage,
|
||||||
|
sizing_mode='stretch_width',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -188,11 +188,13 @@ class Composition(param.Parameterized):
|
|||||||
|
|
||||||
groups_ui = None
|
groups_ui = None
|
||||||
|
|
||||||
def __init__(self, manifest=None, **params):
|
def __init__(self, manifest=None, add_default_group=False, **params):
|
||||||
super(Composition, self).__init__(**params)
|
super(Composition, self).__init__(**params)
|
||||||
self.manifest = manifest
|
self.manifest = manifest
|
||||||
self.testcase_param_classes = dict()
|
self.testcase_param_classes = dict()
|
||||||
self._set_manifest(manifest)
|
self._set_manifest(manifest)
|
||||||
|
if add_default_group:
|
||||||
|
self._add_group()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, d, manifest=None):
|
def from_dict(cls, d, manifest=None):
|
||||||
@ -262,7 +264,7 @@ class Composition(param.Parameterized):
|
|||||||
print_err("No testcase found in manifest named " + case)
|
print_err("No testcase found in manifest named " + case)
|
||||||
return cls
|
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())
|
g = Group(id='New Group', params_class=self._params_class_for_current_testcase())
|
||||||
groups = self.param['groups'].objects
|
groups = self.param['groups'].objects
|
||||||
groups.append(g)
|
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
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
get_manifest_paths() {
|
update_plans() {
|
||||||
find -L $tg_home/plans -name manifest.toml
|
|
||||||
}
|
|
||||||
|
|
||||||
update_manifests() {
|
|
||||||
local dest_dir=$1
|
local dest_dir=$1
|
||||||
mkdir -p $dest_dir
|
rsync -avzh --quiet --copy-links "${tg_home}/plans/" ${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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch_manifests() {
|
watch_plans() {
|
||||||
local manifest_dest=$1
|
local plans_dest=$1
|
||||||
while true; do
|
while true; do
|
||||||
update_manifests ${manifest_dest}
|
update_plans ${plans_dest}
|
||||||
sleep $poll_interval
|
sleep $poll_interval
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
@ -71,16 +56,23 @@ cleanup () {
|
|||||||
docker stop ${container_id} >/dev/null
|
docker stop ${container_id} >/dev/null
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -d "$temp_manifest_dir" ]]; then
|
if [[ -d "$temp_plans_dir" ]]; then
|
||||||
rm -rf ${temp_manifest_dir}
|
rm -rf ${temp_plans_dir}
|
||||||
fi
|
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
|
# run cleanup on exit
|
||||||
trap "{ cleanup; }" EXIT
|
trap "{ cleanup; }" EXIT
|
||||||
|
|
||||||
# make sure we have the commands we need
|
# make sure we have the commands we need
|
||||||
require_cmds jq docker
|
require_cmds jq docker rsync
|
||||||
|
|
||||||
# make temp dir for manifests
|
# make temp dir for manifests
|
||||||
temp_base="/tmp"
|
temp_base="/tmp"
|
||||||
@ -88,14 +80,19 @@ if [[ "$TEMP" != "" ]]; then
|
|||||||
temp_base=$TEMP
|
temp_base=$TEMP
|
||||||
fi
|
fi
|
||||||
|
|
||||||
temp_manifest_dir="$(mktemp -d ${temp_base}/testground-composer-XXXX)"
|
temp_plans_dir="$(mktemp -d ${temp_base}/testground-composer-XXXX)"
|
||||||
echo "temp manifest dir: $temp_manifest_dir"
|
echo "temp plans dir: $temp_plans_dir"
|
||||||
|
|
||||||
# copy the manifests to the temp dir
|
# copy testplans from $TESTGROUND_HOME/plans to the temp dir
|
||||||
update_manifests ${temp_manifest_dir}
|
update_plans ${temp_plans_dir}
|
||||||
|
|
||||||
# run the container in detached mode and grab the id
|
# 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"
|
echo "container $container_id started"
|
||||||
# print the log output
|
# print the log output
|
||||||
@ -108,5 +105,5 @@ sleep 2
|
|||||||
panel_url="http://localhost:${panel_port}"
|
panel_url="http://localhost:${panel_port}"
|
||||||
open_url $panel_url
|
open_url $panel_url
|
||||||
|
|
||||||
# poll & check for manifest changes every few seconds
|
# poll & sync testplan changes every few seconds
|
||||||
watch_manifests ${temp_manifest_dir}
|
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
|
jupyter
|
||||||
panel
|
panel
|
||||||
holoviews
|
holoviews
|
||||||
|
ansi2html
|
||||||
|
Loading…
Reference in New Issue
Block a user