atex 0.5__py3-none-any.whl → 0.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- atex/__init__.py +2 -12
- atex/cli/__init__.py +13 -13
- atex/cli/fmf.py +93 -0
- atex/cli/testingfarm.py +71 -61
- atex/connection/__init__.py +117 -0
- atex/connection/ssh.py +390 -0
- atex/executor/__init__.py +2 -0
- atex/executor/duration.py +60 -0
- atex/executor/executor.py +378 -0
- atex/executor/reporter.py +106 -0
- atex/executor/scripts.py +155 -0
- atex/executor/testcontrol.py +353 -0
- atex/fmf.py +217 -0
- atex/orchestrator/__init__.py +2 -0
- atex/orchestrator/aggregator.py +106 -0
- atex/orchestrator/orchestrator.py +324 -0
- atex/provision/__init__.py +101 -90
- atex/provision/libvirt/VM_PROVISION +8 -0
- atex/provision/libvirt/__init__.py +4 -4
- atex/provision/podman/README +59 -0
- atex/provision/podman/host_container.sh +74 -0
- atex/provision/testingfarm/__init__.py +2 -0
- atex/{testingfarm.py → provision/testingfarm/api.py} +170 -132
- atex/provision/testingfarm/testingfarm.py +236 -0
- atex/util/__init__.py +5 -10
- atex/util/dedent.py +1 -1
- atex/util/log.py +20 -12
- atex/util/path.py +16 -0
- atex/util/ssh_keygen.py +14 -0
- atex/util/subprocess.py +14 -13
- atex/util/threads.py +55 -0
- {atex-0.5.dist-info → atex-0.8.dist-info}/METADATA +97 -2
- atex-0.8.dist-info/RECORD +37 -0
- atex/cli/minitmt.py +0 -82
- atex/minitmt/__init__.py +0 -115
- atex/minitmt/fmf.py +0 -168
- atex/minitmt/report.py +0 -174
- atex/minitmt/scripts.py +0 -51
- atex/minitmt/testme.py +0 -3
- atex/orchestrator.py +0 -38
- atex/ssh.py +0 -320
- atex/util/lockable_class.py +0 -38
- atex-0.5.dist-info/RECORD +0 -26
- {atex-0.5.dist-info → atex-0.8.dist-info}/WHEEL +0 -0
- {atex-0.5.dist-info → atex-0.8.dist-info}/entry_points.txt +0 -0
- {atex-0.5.dist-info → atex-0.8.dist-info}/licenses/COPYING.txt +0 -0
atex/cli/minitmt.py
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import re
|
|
2
|
-
import pprint
|
|
3
|
-
|
|
4
|
-
#from .. import util
|
|
5
|
-
from ..minitmt import fmf
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def _get_context(args):
|
|
9
|
-
context = {}
|
|
10
|
-
if args.context:
|
|
11
|
-
for c in args.context:
|
|
12
|
-
key, value = c.split('=', 1)
|
|
13
|
-
context[key] = value
|
|
14
|
-
return context or None
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def discover(args):
|
|
18
|
-
result = fmf.FMFData(args.root, args.plan, context=_get_context(args))
|
|
19
|
-
for test in result.tests:
|
|
20
|
-
print(test.name)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def show(args):
|
|
24
|
-
result = fmf.FMFData(args.root, args.plan, context=_get_context(args))
|
|
25
|
-
for test in result.tests:
|
|
26
|
-
if re.match(args.test, test.name):
|
|
27
|
-
pprint.pprint(test.data)
|
|
28
|
-
break
|
|
29
|
-
else:
|
|
30
|
-
print(f"Not reachable via {args.plan} discovery: {args.test}")
|
|
31
|
-
raise SystemExit(1)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def parse_args(parser):
|
|
35
|
-
parser.add_argument('--root', default='.', help="path to directory with fmf tests")
|
|
36
|
-
parser.add_argument('--context', '-c', help="tmt style key=value context", action='append')
|
|
37
|
-
cmds = parser.add_subparsers(
|
|
38
|
-
dest='_cmd', help="minitmt feature", metavar='<cmd>', required=True,
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
cmd = cmds.add_parser(
|
|
42
|
-
'discover', aliases=('di',),
|
|
43
|
-
help="list tests, post-processed by tmt plans",
|
|
44
|
-
)
|
|
45
|
-
cmd.add_argument('plan', help="tmt plan to use for discovery")
|
|
46
|
-
|
|
47
|
-
cmd = cmds.add_parser(
|
|
48
|
-
'show',
|
|
49
|
-
help="show fmf data of a test",
|
|
50
|
-
)
|
|
51
|
-
cmd.add_argument('plan', help="tmt plan to use for discovery")
|
|
52
|
-
cmd.add_argument('test', help="fmf style test regex")
|
|
53
|
-
|
|
54
|
-
cmd = cmds.add_parser(
|
|
55
|
-
'execute', aliases=('ex',),
|
|
56
|
-
help="run a plan (or test) on a remote system",
|
|
57
|
-
)
|
|
58
|
-
grp = cmd.add_mutually_exclusive_group()
|
|
59
|
-
grp.add_argument('--test', '-t', help="fmf style test regex")
|
|
60
|
-
grp.add_argument('--plan', '-p', help="tmt plan name (path) inside metadata root")
|
|
61
|
-
cmd.add_argument('--ssh-identity', '-i', help="path to a ssh keyfile for login")
|
|
62
|
-
cmd.add_argument('user_host', help="ssh style user@host of the remote")
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def main(args):
|
|
66
|
-
if args._cmd in ('discover', 'di'):
|
|
67
|
-
discover(args)
|
|
68
|
-
elif args._cmd == 'show':
|
|
69
|
-
show(args)
|
|
70
|
-
elif args._cmd in ('execute', 'ex'):
|
|
71
|
-
#execute(args)
|
|
72
|
-
print("not implemented yet")
|
|
73
|
-
else:
|
|
74
|
-
raise RuntimeError(f"unknown args: {args}")
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
CLI_SPEC = {
|
|
78
|
-
'aliases': ('tmt',),
|
|
79
|
-
'help': "simple test executor using atex.minitmt",
|
|
80
|
-
'args': parse_args,
|
|
81
|
-
'main': main,
|
|
82
|
-
}
|
atex/minitmt/__init__.py
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import random
|
|
3
|
-
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
# TODO: TMT_PLAN_ENVIRONMENT_FILE
|
|
7
|
-
|
|
8
|
-
# TODO: install rsync on the guest as part of setup
|
|
9
|
-
|
|
10
|
-
# TODO: in Orchestrator, when a Provisioner becomes free, have it pick a test
|
|
11
|
-
# from the appropriate tests[platform] per the Provisioner's platform
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def _random_string(length):
|
|
15
|
-
return ''.join(
|
|
16
|
-
random.choices('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', k=length),
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class Preparator:
|
|
21
|
-
"""
|
|
22
|
-
Set of utilities for preparing a newly acquired/reserved machine for
|
|
23
|
-
running tests, by installing global package requirements, copying all
|
|
24
|
-
tests over, executing tmt plan 'prepare' step, etc.
|
|
25
|
-
"""
|
|
26
|
-
def __init__(self, ssh_conn):
|
|
27
|
-
self.conn = ssh_conn
|
|
28
|
-
|
|
29
|
-
def copy_tests(self):
|
|
30
|
-
pass
|
|
31
|
-
|
|
32
|
-
def run_prepare_scripts(self):
|
|
33
|
-
pass
|
|
34
|
-
|
|
35
|
-
def __enter__(self):
|
|
36
|
-
self.conn.connect()
|
|
37
|
-
return self
|
|
38
|
-
|
|
39
|
-
def __exit__(self, exc_type, exc_value, traceback):
|
|
40
|
-
self.conn.disconnect()
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
# TODO: have Executor take a finished Preparator instance as input?
|
|
44
|
-
# - for extracting copied tests location
|
|
45
|
-
# - for extracting TMT_PLAN_ENVIRONMENT_FILE location
|
|
46
|
-
# - etc.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class Executor:
|
|
50
|
-
"""
|
|
51
|
-
Helper for running one test on a remote system and processing results
|
|
52
|
-
and uploaded files by that test.
|
|
53
|
-
"""
|
|
54
|
-
def __init__(self, fmf_test, ssh_conn):
|
|
55
|
-
self.fmf_test = fmf_test
|
|
56
|
-
self.conn = ssh_conn
|
|
57
|
-
self.remote_socket = self.local_socket = None
|
|
58
|
-
|
|
59
|
-
def __enter__(self):
|
|
60
|
-
# generate a (hopefully) unique test control socket name
|
|
61
|
-
# and modify the SSHConn instance to use it
|
|
62
|
-
rand_name = f'atex-control-{_random_string(50)}.sock'
|
|
63
|
-
self.local_socket = Path(os.environ.get('TMPDIR', '/tmp')) / rand_name
|
|
64
|
-
self.remote_socket = f'/tmp/{rand_name}'
|
|
65
|
-
self.conn.options['RemoteForward'] = f'{self.remote_socket} {self.local_socket}'
|
|
66
|
-
self.conn.connect()
|
|
67
|
-
return self
|
|
68
|
-
|
|
69
|
-
def __exit__(self, exc_type, exc_value, traceback):
|
|
70
|
-
self.conn.ssh(f'rm -f {self.remote_socket}')
|
|
71
|
-
self.local_socket.unlink()
|
|
72
|
-
self.remote_socket = self.local_socket = None
|
|
73
|
-
self.conn.disconnect()
|
|
74
|
-
|
|
75
|
-
# execute all prepares (how:install and how:shell) via ssh
|
|
76
|
-
def prepare(self):
|
|
77
|
-
# TODO: check via __some_attr (named / prefixed after our class)
|
|
78
|
-
# whether this reserved system has been prepared already ... ?
|
|
79
|
-
# ^^^^ in Orchestrator
|
|
80
|
-
#
|
|
81
|
-
# TODO: copy root of fmf metadata to some /var/tmp/somedir to run tests from
|
|
82
|
-
#
|
|
83
|
-
# TODO: move prepare out, possibly to class-less function,
|
|
84
|
-
# we don't want it running over an SSHConn that would set up socket forwarding
|
|
85
|
-
# only to tear it back down, when executed from Orchestrator for setup only
|
|
86
|
-
#
|
|
87
|
-
# TODO: install rsync
|
|
88
|
-
pass
|
|
89
|
-
|
|
90
|
-
def run_script(self, script, duration=None, shell='/bin/bash', **kwargs):
|
|
91
|
-
self.conn.ssh(shell, input=script.encode())
|
|
92
|
-
|
|
93
|
-
# run one test via ssh and parse its results on-the-fly,
|
|
94
|
-
# write out logs
|
|
95
|
-
def run_test(self, fmf_test, reporter):
|
|
96
|
-
# TODO: pass environment from test fmf metadata
|
|
97
|
-
# TODO: watch for test duration, etc. metadata
|
|
98
|
-
# TODO: logging of stdout+stderr to hidden file, doing 'ln' from it to
|
|
99
|
-
# test-named 'testout' files
|
|
100
|
-
# - generate hidden name suffix via:
|
|
101
|
-
# ''.join(random.choices('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', k=20))
|
|
102
|
-
output_logfile = \
|
|
103
|
-
reporter.files_dir(fmf_test.name) / f'.test_output_{self._random_string(50)}.log'
|
|
104
|
-
output_logfile = os.open(reporter.files_dir(fmf_test.name), os.O_WRONLY | os.O_CREAT)
|
|
105
|
-
try:
|
|
106
|
-
#self.conn.ssh(
|
|
107
|
-
pass
|
|
108
|
-
finally:
|
|
109
|
-
os.close(output_logfile)
|
|
110
|
-
# TODO: create temp dir on remote via 'mktemp -d', then call
|
|
111
|
-
# self.conn.add_remote_forward(...) with socket path inside that tmpdir
|
|
112
|
-
|
|
113
|
-
# TODO: run tests by passing stdout/stderr via pre-opened fd so we don't handle it in code
|
|
114
|
-
|
|
115
|
-
# TODO: read unix socket as nonblocking, check test subprocess.Popen proc status every 0.1sec
|
atex/minitmt/fmf.py
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
import re
|
|
2
|
-
import collections
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
# from system-wide sys.path
|
|
6
|
-
import fmf
|
|
7
|
-
|
|
8
|
-
# name: fmf path to the test as string, ie. /some/test
|
|
9
|
-
# data: dict of the parsed fmf metadata (ie. {'tag': ... , 'environment': ...})
|
|
10
|
-
# dir: relative pathlib.Path of the test .fmf to repo root, ie. some/test
|
|
11
|
-
# (may be different from name for "virtual" tests that share the same dir)
|
|
12
|
-
FMFTest = collections.namedtuple('FMFTest', ['name', 'data', 'dir'])
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class FMFData:
|
|
16
|
-
"""
|
|
17
|
-
Helper class for reading and querying fmf metadata from the filesystem.
|
|
18
|
-
"""
|
|
19
|
-
# TODO: usage example ^^^^
|
|
20
|
-
|
|
21
|
-
@staticmethod
|
|
22
|
-
def _listlike(data, key):
|
|
23
|
-
"""
|
|
24
|
-
Get a piece of fmf metadata as an iterable regardless of whether it was
|
|
25
|
-
defined as a dict or a list.
|
|
26
|
-
|
|
27
|
-
This is needed because many fmf metadata keys can be used either as
|
|
28
|
-
some_key: 123
|
|
29
|
-
or as lists via YAML syntax
|
|
30
|
-
some_key:
|
|
31
|
-
- 123
|
|
32
|
-
- 456
|
|
33
|
-
and, for simplicity, we want to always deal with lists (iterables).
|
|
34
|
-
"""
|
|
35
|
-
if value := data.get(key):
|
|
36
|
-
return value if isinstance(value, list) else (value,)
|
|
37
|
-
else:
|
|
38
|
-
return ()
|
|
39
|
-
|
|
40
|
-
def __init__(self, fmf_tree, plan_name, context=None):
|
|
41
|
-
"""
|
|
42
|
-
'fmf_tree' is filesystem path somewhere inside fmf metadata tree,
|
|
43
|
-
or a root fmf.Tree instance.
|
|
44
|
-
|
|
45
|
-
'plan_name' is fmf identifier (like /some/thing) of a tmt plan
|
|
46
|
-
to use for discovering tests.
|
|
47
|
-
|
|
48
|
-
'context' is a dict like {'distro': 'rhel-9.6'} used for filtering
|
|
49
|
-
discovered tests.
|
|
50
|
-
"""
|
|
51
|
-
self.prepare_pkgs = []
|
|
52
|
-
self.prepare_scripts = []
|
|
53
|
-
self.tests = []
|
|
54
|
-
|
|
55
|
-
tree = fmf_tree.copy() if isinstance(fmf_tree, fmf.Tree) else fmf.Tree(fmf_tree)
|
|
56
|
-
ctx = fmf.Context(**context) if context else fmf.Context()
|
|
57
|
-
tree.adjust(context=ctx)
|
|
58
|
-
|
|
59
|
-
self.fmf_root = tree.root
|
|
60
|
-
|
|
61
|
-
# lookup the plan first
|
|
62
|
-
plan = tree.find(plan_name)
|
|
63
|
-
if not plan:
|
|
64
|
-
raise ValueError(f"plan {plan_name} not found in {tree.root}")
|
|
65
|
-
if 'test' in plan.data:
|
|
66
|
-
raise ValueError(f"plan {plan_name} appears to be a test")
|
|
67
|
-
|
|
68
|
-
# gather all prepare scripts / packages
|
|
69
|
-
#
|
|
70
|
-
# prepare:
|
|
71
|
-
# - how: install
|
|
72
|
-
# package:
|
|
73
|
-
# - some-rpm-name
|
|
74
|
-
# - how: shell
|
|
75
|
-
# script:
|
|
76
|
-
# - some-command
|
|
77
|
-
for entry in self._listlike(plan.data, 'prepare'):
|
|
78
|
-
if 'how' not in entry:
|
|
79
|
-
continue
|
|
80
|
-
if entry['how'] == 'install':
|
|
81
|
-
self.prepare_pkgs += self._listlike(entry, 'package')
|
|
82
|
-
elif entry['how'] == 'shell':
|
|
83
|
-
self.prepare_scripts += self._listlike(entry, 'script')
|
|
84
|
-
|
|
85
|
-
# gather all tests selected by the plan
|
|
86
|
-
#
|
|
87
|
-
# discover:
|
|
88
|
-
# - how: fmf
|
|
89
|
-
# filter:
|
|
90
|
-
# - tag:some_tag
|
|
91
|
-
# test:
|
|
92
|
-
# - some-test-regex
|
|
93
|
-
# exclude:
|
|
94
|
-
# - some-test-regex
|
|
95
|
-
if 'discover' in plan.data:
|
|
96
|
-
discover = plan.data['discover']
|
|
97
|
-
if not isinstance(discover, list):
|
|
98
|
-
discover = (discover,)
|
|
99
|
-
|
|
100
|
-
for entry in discover:
|
|
101
|
-
if entry.get('how') != 'fmf':
|
|
102
|
-
continue
|
|
103
|
-
|
|
104
|
-
filtering = {}
|
|
105
|
-
for meta_name in ('filter', 'test', 'exclude'):
|
|
106
|
-
if value := self._listlike(entry, meta_name):
|
|
107
|
-
filtering[meta_name] = value
|
|
108
|
-
|
|
109
|
-
children = tree.prune(
|
|
110
|
-
names=filtering.get('test'),
|
|
111
|
-
filters=filtering.get('filter'),
|
|
112
|
-
)
|
|
113
|
-
for child in children:
|
|
114
|
-
# excludes not supported by .prune(), we have to do it here
|
|
115
|
-
excludes = filtering.get('exclude')
|
|
116
|
-
if excludes and any(re.match(x, child.name) for x in excludes):
|
|
117
|
-
continue
|
|
118
|
-
# only enabled tests
|
|
119
|
-
if 'enabled' in child.data and not child.data['enabled']:
|
|
120
|
-
continue
|
|
121
|
-
# no manual tests
|
|
122
|
-
if child.data.get('manual'):
|
|
123
|
-
continue
|
|
124
|
-
# after adjusting above, any adjusts are useless, free some space
|
|
125
|
-
if 'adjust' in child.data:
|
|
126
|
-
del child.data['adjust']
|
|
127
|
-
# ie. ['/abs/path/to/some.fmf', '/abs/path/to/some/node.fmf']
|
|
128
|
-
source_dir = Path(child.sources[-1]).parent.relative_to(self.fmf_root)
|
|
129
|
-
self.tests.append(
|
|
130
|
-
FMFTest(name=child.name, data=child.data, dir=source_dir),
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
# Some extra notes for fmf.prune() arguments:
|
|
135
|
-
#
|
|
136
|
-
# Set 'names' to filter by a list of fmf node names, ie.
|
|
137
|
-
# ['/some/test', '/another/test']
|
|
138
|
-
#
|
|
139
|
-
# Set 'filters' to filter by a list of fmf-style filter expressions, see
|
|
140
|
-
# https://fmf.readthedocs.io/en/stable/modules.html#fmf.filter
|
|
141
|
-
#
|
|
142
|
-
# Set 'conditions' to filter by a list of python expressions whose namespace
|
|
143
|
-
# locals() are set up to be a dictionary of the tree. When any of the
|
|
144
|
-
# expressions returns True, the tree is returned, ie.
|
|
145
|
-
# ['environment["FOO"] == "BAR"']
|
|
146
|
-
# ['"enabled" not in locals() or enabled']
|
|
147
|
-
# Note that KeyError is silently ignored and treated as False.
|
|
148
|
-
#
|
|
149
|
-
# Set 'context' to a dictionary to post-process the tree metadata with
|
|
150
|
-
# adjust expressions (that may be present in a tree) using the specified
|
|
151
|
-
# context. Any other filters are applied afterwards to allow modification
|
|
152
|
-
# of tree metadata by the adjust expressions. Ie.
|
|
153
|
-
# {'distro': 'rhel-9.6.0', 'arch': 'x86_64'}
|
|
154
|
-
|
|
155
|
-
Platform = collections.namedtuple('Platform', ['distro', 'arch'])
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def combine_platforms(fmf_path, plan_name, platforms):
|
|
159
|
-
# TODO: document
|
|
160
|
-
fmf_datas = {}
|
|
161
|
-
tree = fmf.Tree(fmf_path)
|
|
162
|
-
for platform in platforms:
|
|
163
|
-
context = {'distro': platform.distro, 'arch': platform.arch}
|
|
164
|
-
fmf_datas[platform] = FMFData(tree, plan_name, context=context)
|
|
165
|
-
return fmf_datas
|
|
166
|
-
|
|
167
|
-
# TODO: in Orchestrator, when a Provisioner becomes free, have it pick a test
|
|
168
|
-
# from the appropriate tests[platform] per the Provisioner's platform
|
atex/minitmt/report.py
DELETED
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import csv
|
|
3
|
-
import gzip
|
|
4
|
-
import ctypes
|
|
5
|
-
import ctypes.util
|
|
6
|
-
import contextlib
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
from .. import util
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
libc = ctypes.CDLL(ctypes.util.find_library('c'))
|
|
13
|
-
|
|
14
|
-
# int linkat(int olddirfd, const char *oldpath, int newdirfd, const char *newpath, int flags)
|
|
15
|
-
libc.linkat.argtypes = (
|
|
16
|
-
ctypes.c_int,
|
|
17
|
-
ctypes.c_char_p,
|
|
18
|
-
ctypes.c_int,
|
|
19
|
-
ctypes.c_char_p,
|
|
20
|
-
ctypes.c_int,
|
|
21
|
-
)
|
|
22
|
-
libc.linkat.restype = ctypes.c_int
|
|
23
|
-
|
|
24
|
-
# fcntl.h:#define AT_EMPTY_PATH 0x1000 /* Allow empty relative pathname */
|
|
25
|
-
AT_EMPTY_PATH = 0x1000
|
|
26
|
-
|
|
27
|
-
# fcntl.h:#define AT_FDCWD -100 /* Special value used to indicate
|
|
28
|
-
AT_FDCWD = -100
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def linkat(*args):
|
|
32
|
-
if (ret := libc.linkat(*args)) == -1:
|
|
33
|
-
errno = ctypes.get_errno()
|
|
34
|
-
raise OSError(errno, os.strerror(errno))
|
|
35
|
-
return ret
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
class CSVReporter(util.LockableClass):
|
|
39
|
-
"""
|
|
40
|
-
Stores reported results as a GZIP-ed CSV and files (logs) under a related
|
|
41
|
-
directory.
|
|
42
|
-
|
|
43
|
-
with CSVReporter('file/storage/dir', 'results.csv.gz') as reporter:
|
|
44
|
-
sub = reporter.make_subreporter('rhel-9', 'x86_64')
|
|
45
|
-
sub({'name': '/some/test', 'status': 'pass'})
|
|
46
|
-
sub({'name': '/another/test', 'status': 'pass'})
|
|
47
|
-
...
|
|
48
|
-
sub = reporter.make_subreporter('rhel-9', 'ppc64le')
|
|
49
|
-
...
|
|
50
|
-
sock = accept_unix_connection()
|
|
51
|
-
reporter.store_file('/some/test', 'debug.log', sock, 1234)
|
|
52
|
-
"""
|
|
53
|
-
class _ExcelWithUnixNewline(csv.excel):
|
|
54
|
-
lineterminator = '\n'
|
|
55
|
-
|
|
56
|
-
def __init__(self, storage_dir, results_file):
|
|
57
|
-
super().__init__()
|
|
58
|
-
self.storage_dir = Path(storage_dir)
|
|
59
|
-
if self.storage_dir.exists():
|
|
60
|
-
raise FileExistsError(f"{storage_dir} already exists")
|
|
61
|
-
self.results_file = Path(results_file)
|
|
62
|
-
if self.results_file.exists():
|
|
63
|
-
raise FileExistsError(f"{self.results_file} already exists")
|
|
64
|
-
self.storage_dir.mkdir()
|
|
65
|
-
self.csv_writer = None
|
|
66
|
-
self.results_gzip_handle = None
|
|
67
|
-
|
|
68
|
-
def __enter__(self):
|
|
69
|
-
f = gzip.open(self.results_file, 'wt', newline='')
|
|
70
|
-
try:
|
|
71
|
-
self.csv_writer = csv.writer(f, dialect=self._ExcelWithUnixNewline)
|
|
72
|
-
except:
|
|
73
|
-
f.close()
|
|
74
|
-
raise
|
|
75
|
-
self.results_gzip_handle = f
|
|
76
|
-
return self
|
|
77
|
-
|
|
78
|
-
def __exit__(self, exc_type, exc_value, traceback):
|
|
79
|
-
self.results_gzip_handle.close()
|
|
80
|
-
self.results_gzip_handle = None
|
|
81
|
-
self.csv_writer = None
|
|
82
|
-
|
|
83
|
-
def report(self, distro, arch, status, name, note, *files):
|
|
84
|
-
"""
|
|
85
|
-
Persistently write out details of a test result.
|
|
86
|
-
"""
|
|
87
|
-
with self.lock:
|
|
88
|
-
self.csv_writer.writerow((distro, arch, status, name, note, *files))
|
|
89
|
-
|
|
90
|
-
@staticmethod
|
|
91
|
-
def _normalize_path(path):
|
|
92
|
-
# the magic here is to treat any dangerous path as starting at /
|
|
93
|
-
# and resolve any weird constructs relative to /, and then simply
|
|
94
|
-
# strip off the leading / and use it as a relative path
|
|
95
|
-
path = path.lstrip('/')
|
|
96
|
-
path = os.path.normpath(f'/{path}')
|
|
97
|
-
return path[1:]
|
|
98
|
-
|
|
99
|
-
def make_subreporter(self, distro, arch):
|
|
100
|
-
"""
|
|
101
|
-
Return a preconfigured reporter instance, suitable for use
|
|
102
|
-
by an Executor.
|
|
103
|
-
"""
|
|
104
|
-
def reporter(result_line):
|
|
105
|
-
if 'files' in result_line:
|
|
106
|
-
files = (self._normalize_path(x['name']) for x in result_line['files'])
|
|
107
|
-
else:
|
|
108
|
-
files = ()
|
|
109
|
-
self.report(
|
|
110
|
-
distro, arch, result_line['status'], result_line['name'],
|
|
111
|
-
result_line.get('note', ''), *files,
|
|
112
|
-
)
|
|
113
|
-
return reporter
|
|
114
|
-
|
|
115
|
-
def _files_dir(self, result_name):
|
|
116
|
-
dir_path = self.storage_dir / result_name.lstrip('/')
|
|
117
|
-
dir_path.mkdir(parents=True, exist_ok=True)
|
|
118
|
-
return dir_path
|
|
119
|
-
|
|
120
|
-
def _files_file(self, result_name, file_name):
|
|
121
|
-
file_name = self._normalize_path(file_name)
|
|
122
|
-
return self._files_dir(result_name) / file_name
|
|
123
|
-
|
|
124
|
-
@contextlib.contextmanager
|
|
125
|
-
def open_tmpfile(self, open_mode=os.O_WRONLY):
|
|
126
|
-
flags = open_mode | os.O_TMPFILE
|
|
127
|
-
fd = os.open(self.storage_dir, flags, 0o644)
|
|
128
|
-
try:
|
|
129
|
-
yield fd
|
|
130
|
-
finally:
|
|
131
|
-
os.close(fd)
|
|
132
|
-
# def open_tmpfile(self, result_name, open_mode=os.O_WRONLY):
|
|
133
|
-
# """
|
|
134
|
-
# Open an anonymous (name-less) file for writing, in a directory relevant
|
|
135
|
-
# to 'result_name' and yield its file descriptor (int) as context, closing
|
|
136
|
-
# it when the context is exited.
|
|
137
|
-
# """
|
|
138
|
-
# flags = open_mode | os.O_TMPFILE
|
|
139
|
-
# fd = os.open(self._files_dir(result_name), flags, 0o644)
|
|
140
|
-
# try:
|
|
141
|
-
# yield fd
|
|
142
|
-
# finally:
|
|
143
|
-
# os.close(fd)
|
|
144
|
-
|
|
145
|
-
def link_tmpfile_to(self, result_name, file_name, fd):
|
|
146
|
-
"""
|
|
147
|
-
Store a file named 'file_name' in a directory relevant to 'result_name'
|
|
148
|
-
whose 'fd' (a file descriptor) was created by open_tmpfile().
|
|
149
|
-
|
|
150
|
-
This function can be called multiple times with the same 'fd', and
|
|
151
|
-
does not close or otherwise alter the descriptor.
|
|
152
|
-
"""
|
|
153
|
-
final_path = self._files_file(result_name, file_name)
|
|
154
|
-
linkat(fd, b'', AT_FDCWD, bytes(final_path), AT_EMPTY_PATH)
|
|
155
|
-
|
|
156
|
-
def store_file(self, result_name, file_name, in_fd, count):
|
|
157
|
-
"""
|
|
158
|
-
Read 'count' bytes of binary data from an OS file descriptor 'in_fd'
|
|
159
|
-
and store them under 'result_name' as a file (or relative path)
|
|
160
|
-
named 'file_name', creating it.
|
|
161
|
-
"""
|
|
162
|
-
final_path = self._files_file(result_name, file_name)
|
|
163
|
-
# be as efficient as possible, let the kernel handle big data
|
|
164
|
-
out_fd = None
|
|
165
|
-
try:
|
|
166
|
-
out_fd = os.open(final_path, os.O_WRONLY | os.O_CREAT)
|
|
167
|
-
while count > 0:
|
|
168
|
-
written = os.sendfile(out_fd, in_fd, None, count)
|
|
169
|
-
if written == 0:
|
|
170
|
-
raise RuntimeError(f"got unexpected EOF when receiving {final_path}")
|
|
171
|
-
count -= written
|
|
172
|
-
finally:
|
|
173
|
-
if out_fd:
|
|
174
|
-
os.close(out_fd)
|
atex/minitmt/scripts.py
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
#from .. import util
|
|
2
|
-
|
|
3
|
-
#run_test = util.dedent(fr'''
|
|
4
|
-
# # create a temp dir for everything, send it to the controller
|
|
5
|
-
# tmpdir=$(mktemp -d /var/tmp/atex-XXXXXXXXX)
|
|
6
|
-
# echo "tmpdir=$tmpdir"
|
|
7
|
-
#
|
|
8
|
-
# # remove transient files if interrupted
|
|
9
|
-
# trap "rm -rf \"$tmpdir\"" INT
|
|
10
|
-
#
|
|
11
|
-
# # wait for result reporting unix socket to be created by sshd
|
|
12
|
-
# socket=$tmpdir/results.sock
|
|
13
|
-
# while [[ ! -e $socket ]]; do sleep 0.1; done
|
|
14
|
-
# echo "socket=$socket"
|
|
15
|
-
#
|
|
16
|
-
# # tell the controller to start logging test output
|
|
17
|
-
# echo ---
|
|
18
|
-
#
|
|
19
|
-
# # install test dependencies
|
|
20
|
-
# rpms=( {' '.join(requires)} )
|
|
21
|
-
# to_install=()
|
|
22
|
-
# for rpm in "${{rpms[@]}}"; do
|
|
23
|
-
# rpm -q --quiet "$rpm" || to_install+=("$rpm")
|
|
24
|
-
# done
|
|
25
|
-
# dnf -y --setopt=install_weak_deps=False install "${{to_install[@]}}"
|
|
26
|
-
#
|
|
27
|
-
# # run the test
|
|
28
|
-
# ...
|
|
29
|
-
# rc=$?
|
|
30
|
-
#
|
|
31
|
-
# # test finished, clean up
|
|
32
|
-
# rm -rf "$tmpdir"
|
|
33
|
-
#
|
|
34
|
-
# exit $rc
|
|
35
|
-
#''')
|
|
36
|
-
|
|
37
|
-
# TODO: have another version of ^^^^ for re-execution of test after a reboot
|
|
38
|
-
# or disconnect that sets tmpdir= from us (reusing on-disk test CWD)
|
|
39
|
-
# rather than creating a new one
|
|
40
|
-
# - the second script needs to rm -f the unix socket before echoing
|
|
41
|
-
# something back to let us re-create it via a new ssh channel open
|
|
42
|
-
# because StreamLocalBindUnlink doesn't seem to work
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
# TODO: call ssh with -oStreamLocalBindUnlink=yes to re-initialize
|
|
46
|
-
# the listening socket after guest reboot
|
|
47
|
-
#
|
|
48
|
-
# -R /var/tmp/atex-BlaBla/results.sock:/var/tmp/controller.sock
|
|
49
|
-
#
|
|
50
|
-
# (make sure to start listening on /var/tmp/controller.sock before
|
|
51
|
-
# calling ssh to run the test)
|
atex/minitmt/testme.py
DELETED
atex/orchestrator.py
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
from . import util
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
class Orchestrator:
|
|
5
|
-
"""
|
|
6
|
-
A scheduler for parallel execution on multiple resources (machines/systems).
|
|
7
|
-
|
|
8
|
-
Given a list of Provisioner-derived class instances, it attempts to reserve
|
|
9
|
-
resources and uses them on-demand as they become available, calling run()
|
|
10
|
-
on each.
|
|
11
|
-
|
|
12
|
-
Note that run() and report() always run in a separate threads (are allowed
|
|
13
|
-
to block), and may access instance attributes, which are transparently
|
|
14
|
-
guarded by a thread-aware mutex.
|
|
15
|
-
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
def __init__(self):
|
|
19
|
-
pass
|
|
20
|
-
# TODO: configure via args, max workers, etc.
|
|
21
|
-
|
|
22
|
-
# def reserve(self, provisioner):
|
|
23
|
-
# # call provisioner.reserve(), return its return
|
|
24
|
-
# ...
|
|
25
|
-
|
|
26
|
-
def add_provisioner(self, provisioner):
|
|
27
|
-
# add to a self.* list of provisioners to be used for getting machines
|
|
28
|
-
...
|
|
29
|
-
|
|
30
|
-
def run(self, provisioner):
|
|
31
|
-
# run tests, if destructive, call provisioner.release()
|
|
32
|
-
# returns anything
|
|
33
|
-
...
|
|
34
|
-
|
|
35
|
-
def report(self):
|
|
36
|
-
# gets return from run
|
|
37
|
-
# writes it out to somewhere else
|
|
38
|
-
...
|