atex 0.7__py3-none-any.whl → 0.9__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/cli/fmf.py +143 -0
- atex/cli/libvirt.py +127 -0
- atex/cli/testingfarm.py +35 -13
- atex/connection/__init__.py +13 -19
- atex/connection/podman.py +63 -0
- atex/connection/ssh.py +34 -52
- atex/executor/__init__.py +2 -0
- atex/executor/duration.py +60 -0
- atex/executor/executor.py +402 -0
- atex/executor/reporter.py +101 -0
- atex/{minitmt → executor}/scripts.py +37 -25
- atex/{minitmt → executor}/testcontrol.py +54 -42
- atex/fmf.py +237 -0
- atex/orchestrator/__init__.py +3 -59
- atex/orchestrator/aggregator.py +82 -134
- atex/orchestrator/orchestrator.py +385 -0
- atex/provision/__init__.py +74 -105
- atex/provision/libvirt/__init__.py +2 -24
- atex/provision/libvirt/libvirt.py +465 -0
- atex/provision/libvirt/locking.py +168 -0
- atex/provision/libvirt/setup-libvirt.sh +21 -1
- atex/provision/podman/__init__.py +1 -0
- atex/provision/podman/podman.py +274 -0
- atex/provision/testingfarm/__init__.py +2 -29
- atex/provision/testingfarm/api.py +123 -65
- atex/provision/testingfarm/testingfarm.py +234 -0
- atex/util/__init__.py +1 -6
- atex/util/libvirt.py +18 -0
- atex/util/log.py +31 -8
- atex/util/named_mapping.py +158 -0
- atex/util/path.py +16 -0
- atex/util/ssh_keygen.py +14 -0
- atex/util/threads.py +99 -0
- atex-0.9.dist-info/METADATA +178 -0
- atex-0.9.dist-info/RECORD +43 -0
- atex/cli/minitmt.py +0 -175
- atex/minitmt/__init__.py +0 -23
- atex/minitmt/executor.py +0 -348
- atex/minitmt/fmf.py +0 -202
- atex/provision/nspawn/README +0 -74
- atex/provision/podman/README +0 -59
- atex/provision/podman/host_container.sh +0 -74
- atex/provision/testingfarm/foo.py +0 -1
- atex-0.7.dist-info/METADATA +0 -102
- atex-0.7.dist-info/RECORD +0 -32
- {atex-0.7.dist-info → atex-0.9.dist-info}/WHEEL +0 -0
- {atex-0.7.dist-info → atex-0.9.dist-info}/entry_points.txt +0 -0
- {atex-0.7.dist-info → atex-0.9.dist-info}/licenses/COPYING.txt +0 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .. import util
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Reporter:
|
|
9
|
+
"""
|
|
10
|
+
Collects reported results (in a format specified by RESULTS.md) for
|
|
11
|
+
a specific test, storing them persistently.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
# internal name, stored inside 'output_dir' and hardlinked to
|
|
15
|
+
# 'testout'-JSON-key-specified result entries; deleted on exit
|
|
16
|
+
TESTOUT = "testout.temp"
|
|
17
|
+
|
|
18
|
+
def __init__(self, output_dir, results_file, files_dir):
|
|
19
|
+
"""
|
|
20
|
+
'output_dir' is a destination dir (string or Path) for results reported
|
|
21
|
+
and files uploaded.
|
|
22
|
+
|
|
23
|
+
'results_file' is a file name inside 'output_dir' the results will be
|
|
24
|
+
reported into.
|
|
25
|
+
|
|
26
|
+
'files_dir' is a dir name inside 'output_dir' any files will be
|
|
27
|
+
uploaded to.
|
|
28
|
+
"""
|
|
29
|
+
output_dir = Path(output_dir)
|
|
30
|
+
self.testout_file = output_dir / self.TESTOUT
|
|
31
|
+
self.results_file = output_dir / results_file
|
|
32
|
+
self.files_dir = output_dir / files_dir
|
|
33
|
+
self.output_dir = output_dir
|
|
34
|
+
self.results_fobj = None
|
|
35
|
+
self.testout_fobj = None
|
|
36
|
+
|
|
37
|
+
def start(self):
|
|
38
|
+
if self.testout_file.exists():
|
|
39
|
+
raise FileExistsError(f"{self.testout_file} already exists")
|
|
40
|
+
self.testout_fobj = open(self.testout_file, "wb")
|
|
41
|
+
|
|
42
|
+
if self.results_file.exists():
|
|
43
|
+
raise FileExistsError(f"{self.results_file} already exists")
|
|
44
|
+
self.results_fobj = open(self.results_file, "w", newline="\n")
|
|
45
|
+
|
|
46
|
+
if self.files_dir.exists():
|
|
47
|
+
raise FileExistsError(f"{self.files_dir} already exists")
|
|
48
|
+
self.files_dir.mkdir()
|
|
49
|
+
|
|
50
|
+
def stop(self):
|
|
51
|
+
if self.results_fobj:
|
|
52
|
+
self.results_fobj.close()
|
|
53
|
+
self.results_fobj = None
|
|
54
|
+
|
|
55
|
+
if self.testout_fobj:
|
|
56
|
+
self.testout_fobj.close()
|
|
57
|
+
self.testout_fobj = None
|
|
58
|
+
Path(self.testout_file).unlink()
|
|
59
|
+
|
|
60
|
+
def __enter__(self):
|
|
61
|
+
try:
|
|
62
|
+
self.start()
|
|
63
|
+
return self
|
|
64
|
+
except Exception:
|
|
65
|
+
self.stop()
|
|
66
|
+
raise
|
|
67
|
+
|
|
68
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
69
|
+
self.stop()
|
|
70
|
+
|
|
71
|
+
def report(self, result_line):
|
|
72
|
+
"""
|
|
73
|
+
Persistently record a test result.
|
|
74
|
+
|
|
75
|
+
'result_line' is a dict in the format specified by RESULTS.md.
|
|
76
|
+
"""
|
|
77
|
+
json.dump(result_line, self.results_fobj, indent=None)
|
|
78
|
+
self.results_fobj.write("\n")
|
|
79
|
+
self.results_fobj.flush()
|
|
80
|
+
|
|
81
|
+
def _dest_path(self, file_name, result_name=None):
|
|
82
|
+
result_name = util.normalize_path(result_name) if result_name else "."
|
|
83
|
+
# /path/to/files_dir / path/to/subtest / path/to/file.log
|
|
84
|
+
file_path = self.files_dir / result_name / util.normalize_path(file_name)
|
|
85
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
return file_path
|
|
87
|
+
|
|
88
|
+
def open_file(self, file_name, result_name=None, mode="wb"):
|
|
89
|
+
"""
|
|
90
|
+
Open a file named 'file_name' in a directory relevant to 'result_name'.
|
|
91
|
+
Returns an opened file-like object that can be used in a context manager
|
|
92
|
+
just like with regular open().
|
|
93
|
+
|
|
94
|
+
If 'result_name' (typically a subtest) is not given, open the file
|
|
95
|
+
for the test (name) itself.
|
|
96
|
+
"""
|
|
97
|
+
return open(self._dest_path(file_name, result_name), mode)
|
|
98
|
+
|
|
99
|
+
def link_testout(self, file_name, result_name=None):
|
|
100
|
+
# TODO: docstring
|
|
101
|
+
os.link(self.testout_file, self._dest_path(file_name, result_name))
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
import collections
|
|
2
|
+
import yaml
|
|
1
3
|
from pathlib import Path
|
|
2
4
|
|
|
3
|
-
from .. import util
|
|
5
|
+
from .. import util, fmf
|
|
6
|
+
|
|
7
|
+
# name: fmf path to the test as string, ie. /some/test
|
|
8
|
+
# data: dict of the parsed fmf metadata (ie. {'tag': ... , 'environment': ...})
|
|
9
|
+
# dir: relative pathlib.Path of the test .fmf to repo root, ie. some/test
|
|
10
|
+
# (may be different from name for "virtual" tests that share the same dir)
|
|
11
|
+
Test = collections.namedtuple("Test", ["name", "data", "dir"])
|
|
4
12
|
|
|
5
|
-
from . import fmf
|
|
6
13
|
|
|
7
14
|
# NOTE that we split test execution into 3 scripts:
|
|
8
15
|
# - "setup script" (package installs, etc.)
|
|
@@ -17,8 +24,7 @@ from . import fmf
|
|
|
17
24
|
# passed by an argument to 'ssh', leaving stdin/out/err untouched,
|
|
18
25
|
# allowing the user to interact with it (if run interactively)
|
|
19
26
|
|
|
20
|
-
|
|
21
|
-
def test_wrapper(*, test, tests_dir, test_exec, debug=False):
|
|
27
|
+
def test_wrapper(*, test, tests_dir, test_exec):
|
|
22
28
|
"""
|
|
23
29
|
Generate a bash script that runs a user-specified test, preparing
|
|
24
30
|
a test control channel for it, and reporting its exit code.
|
|
@@ -26,14 +32,12 @@ def test_wrapper(*, test, tests_dir, test_exec, debug=False):
|
|
|
26
32
|
is considered as test output and any unintended environment changes
|
|
27
33
|
will impact the test itself.
|
|
28
34
|
|
|
29
|
-
'test' is a
|
|
35
|
+
'test' is a class Test instance.
|
|
30
36
|
|
|
31
37
|
'test_dir' is a remote directory (repository) of all the tests,
|
|
32
38
|
a.k.a. FMF metadata root.
|
|
33
39
|
|
|
34
40
|
'test_exec' is a remote path to the actual test to run.
|
|
35
|
-
|
|
36
|
-
'debug' specifies whether to include wrapper output inside test output.
|
|
37
41
|
"""
|
|
38
42
|
out = "#!/bin/bash\n"
|
|
39
43
|
|
|
@@ -46,7 +50,7 @@ def test_wrapper(*, test, tests_dir, test_exec, debug=False):
|
|
|
46
50
|
# doing it here avoids unnecessary traffic (reading stdin) via ssh,
|
|
47
51
|
# even if it is fed from subprocess.DEVNULL on the runner
|
|
48
52
|
|
|
49
|
-
if
|
|
53
|
+
if util.in_debug_mode():
|
|
50
54
|
out += "set -x\n"
|
|
51
55
|
|
|
52
56
|
# use a subshell to limit the scope of the CWD change
|
|
@@ -88,7 +92,18 @@ def test_wrapper(*, test, tests_dir, test_exec, debug=False):
|
|
|
88
92
|
return out
|
|
89
93
|
|
|
90
94
|
|
|
91
|
-
def
|
|
95
|
+
def _install_packages(pkgs, extra_opts=None):
|
|
96
|
+
pkgs_str = " ".join(pkgs)
|
|
97
|
+
extra_opts = extra_opts or ()
|
|
98
|
+
dnf = ["dnf", "-y", "--setopt=install_weak_deps=False", "install", *extra_opts]
|
|
99
|
+
dnf_str = " ".join(dnf)
|
|
100
|
+
return util.dedent(fr"""
|
|
101
|
+
not_installed=$(rpm -q --qf '' {pkgs_str} | sed -nr 's/^package ([^ ]+) is not installed$/\1/p')
|
|
102
|
+
[[ $not_installed ]] && {dnf_str} $not_installed
|
|
103
|
+
""") # noqa: E501
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_setup(*, test, wrapper_exec, test_exec, test_yaml, **kwargs):
|
|
92
107
|
"""
|
|
93
108
|
Generate a bash script that should prepare the remote end for test
|
|
94
109
|
execution.
|
|
@@ -97,42 +112,39 @@ def test_setup(*, test, wrapper_exec, test_exec, debug=False, **kwargs):
|
|
|
97
112
|
scripts: a test script (contents of 'test' from FMF) and a wrapper script
|
|
98
113
|
to run the test script.
|
|
99
114
|
|
|
115
|
+
'test' is a class Test instance.
|
|
116
|
+
|
|
100
117
|
'wrapper_exec' is the remote path where the wrapper script should be put.
|
|
101
118
|
|
|
102
119
|
'test_exec' is the remote path where the test script should be put.
|
|
103
120
|
|
|
104
|
-
'test' is a atex.minitmt.fmf.FMFTest instance.
|
|
105
|
-
|
|
106
|
-
'debug' specifies whether to make the setup script extra verbose.
|
|
107
|
-
|
|
108
121
|
Any 'kwargs' are passed to test_wrapper().
|
|
109
122
|
"""
|
|
110
123
|
out = "#!/bin/bash\n"
|
|
111
124
|
|
|
112
|
-
|
|
113
|
-
# also avoid any accidental stdout output, we use it for wrapper path
|
|
114
|
-
if debug:
|
|
115
|
-
out += "exec {orig_stdout}>&1 1>&2\n"
|
|
125
|
+
if util.in_debug_mode():
|
|
116
126
|
out += "set -xe\n"
|
|
117
127
|
else:
|
|
118
|
-
out += "exec
|
|
128
|
+
out += "exec 1>/dev/null\n"
|
|
119
129
|
out += "set -e\n"
|
|
120
130
|
|
|
121
131
|
# install test dependencies
|
|
122
132
|
# - only strings (package names) in require/recommend are supported
|
|
123
|
-
if require :=
|
|
124
|
-
out +=
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
133
|
+
if require := list(fmf.test_pkg_requires(test.data, "require")):
|
|
134
|
+
out += _install_packages(require) + "\n"
|
|
135
|
+
if recommend := list(fmf.test_pkg_requires(test.data, "recommend")):
|
|
136
|
+
out += _install_packages(recommend, ("--skip-broken",)) + "\n"
|
|
137
|
+
|
|
138
|
+
# write out test data
|
|
139
|
+
out += f"cat > '{test_yaml}' <<'ATEX_SETUP_EOF'\n"
|
|
140
|
+
out += yaml.dump(test.data).rstrip("\n") # don't rely on trailing \n
|
|
141
|
+
out += "\nATEX_SETUP_EOF\n"
|
|
129
142
|
|
|
130
143
|
# make the wrapper script
|
|
131
144
|
out += f"cat > '{wrapper_exec}' <<'ATEX_SETUP_EOF'\n"
|
|
132
145
|
out += test_wrapper(
|
|
133
146
|
test=test,
|
|
134
147
|
test_exec=test_exec,
|
|
135
|
-
debug=debug,
|
|
136
148
|
**kwargs,
|
|
137
149
|
)
|
|
138
150
|
out += "ATEX_SETUP_EOF\n"
|
|
@@ -95,30 +95,43 @@ class TestControl:
|
|
|
95
95
|
processing test-issued commands, results and uploaded files.
|
|
96
96
|
"""
|
|
97
97
|
|
|
98
|
-
def __init__(self, *,
|
|
98
|
+
def __init__(self, *, reporter, duration, control_fd=None):
|
|
99
99
|
"""
|
|
100
100
|
'control_fd' is a non-blocking file descriptor to be read.
|
|
101
101
|
|
|
102
|
-
'
|
|
103
|
-
|
|
102
|
+
'reporter' is an instance of class Reporter all the results
|
|
103
|
+
and uploaded files will be written to.
|
|
104
104
|
|
|
105
105
|
'duration' is a class Duration instance.
|
|
106
|
-
|
|
107
|
-
'testout_fd' is an optional file descriptor handle which the test uses
|
|
108
|
-
to write its output to - useful here for the 'result' control word and
|
|
109
|
-
its protocol, which allows "hardlinking" the fd to a real file name.
|
|
110
106
|
"""
|
|
111
|
-
self.
|
|
112
|
-
self.stream = NonblockLineReader(control_fd)
|
|
113
|
-
self.aggregator = aggregator
|
|
107
|
+
self.reporter = reporter
|
|
114
108
|
self.duration = duration
|
|
115
|
-
|
|
109
|
+
if control_fd:
|
|
110
|
+
self.control_fd = control_fd
|
|
111
|
+
self.stream = NonblockLineReader(control_fd)
|
|
112
|
+
else:
|
|
113
|
+
self.control_fd = None
|
|
114
|
+
self.stream = None
|
|
116
115
|
self.eof = False
|
|
117
116
|
self.in_progress = None
|
|
118
117
|
self.partial_results = collections.defaultdict(dict)
|
|
119
|
-
self.result_seen = False
|
|
120
118
|
self.exit_code = None
|
|
121
119
|
self.reconnect = None
|
|
120
|
+
self.nameless_result_seen = False
|
|
121
|
+
|
|
122
|
+
def reassign(self, new_fd):
|
|
123
|
+
"""
|
|
124
|
+
Assign a new control file descriptor to read test control from,
|
|
125
|
+
replacing a previous one. Useful on test reconnect.
|
|
126
|
+
"""
|
|
127
|
+
err = "tried to assign new control fd while"
|
|
128
|
+
if self.in_progress:
|
|
129
|
+
raise BadControlError(f"{err} old one is reading non-control binary data")
|
|
130
|
+
elif self.stream and self.stream.bytes_read != 0:
|
|
131
|
+
raise BadControlError(f"{err} old one is in the middle of reading a control line")
|
|
132
|
+
self.eof = False
|
|
133
|
+
self.control_fd = new_fd
|
|
134
|
+
self.stream = NonblockLineReader(new_fd)
|
|
122
135
|
|
|
123
136
|
def process(self):
|
|
124
137
|
"""
|
|
@@ -143,7 +156,7 @@ class TestControl:
|
|
|
143
156
|
except BufferFullError as e:
|
|
144
157
|
raise BadControlError(str(e)) from None
|
|
145
158
|
|
|
146
|
-
util.debug(f"got control line: {line}")
|
|
159
|
+
util.debug(f"got control line: {line} // eof: {self.stream.eof}")
|
|
147
160
|
|
|
148
161
|
if self.stream.eof:
|
|
149
162
|
self.eof = True
|
|
@@ -238,9 +251,10 @@ class TestControl:
|
|
|
238
251
|
except json.decoder.JSONDecodeError as e:
|
|
239
252
|
raise BadReportJSONError(f"JSON decode: {str(e)} caused by: {json_data}") from None
|
|
240
253
|
|
|
254
|
+
# note that this may be None (result for the test itself)
|
|
241
255
|
name = result.get("name")
|
|
242
256
|
if not name:
|
|
243
|
-
|
|
257
|
+
self.nameless_result_seen = True
|
|
244
258
|
|
|
245
259
|
# upload files
|
|
246
260
|
for entry in result.get("files", ()):
|
|
@@ -253,30 +267,28 @@ class TestControl:
|
|
|
253
267
|
except ValueError as e:
|
|
254
268
|
raise BadReportJSONError(f"file entry {file_name} length: {str(e)}") from None
|
|
255
269
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
270
|
+
try:
|
|
271
|
+
with self.reporter.open_file(file_name, name) as f:
|
|
272
|
+
fd = f.fileno()
|
|
273
|
+
while file_length > 0:
|
|
260
274
|
try:
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
275
|
+
# try a more universal sendfile first, fall back to splice
|
|
276
|
+
try:
|
|
277
|
+
written = os.sendfile(fd, self.control_fd, None, file_length)
|
|
278
|
+
except OSError as e:
|
|
279
|
+
if e.errno == 22: # EINVAL
|
|
280
|
+
written = os.splice(self.control_fd, fd, file_length)
|
|
281
|
+
else:
|
|
282
|
+
raise
|
|
283
|
+
except BlockingIOError:
|
|
284
|
+
yield
|
|
285
|
+
continue
|
|
286
|
+
if written == 0:
|
|
287
|
+
raise BadControlError("EOF when reading data")
|
|
288
|
+
file_length -= written
|
|
268
289
|
yield
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
raise BadControlError("EOF when reading data")
|
|
272
|
-
file_length -= written
|
|
273
|
-
yield
|
|
274
|
-
try:
|
|
275
|
-
self.aggregator.link_tmpfile_to(name, file_name, fd)
|
|
276
|
-
except FileExistsError:
|
|
277
|
-
raise BadReportJSONError(
|
|
278
|
-
f"file '{file_name}' for '{name}' already exists",
|
|
279
|
-
) from None
|
|
290
|
+
except FileExistsError:
|
|
291
|
+
raise BadReportJSONError(f"file '{file_name}' already exists") from None
|
|
280
292
|
|
|
281
293
|
# either store partial result + return,
|
|
282
294
|
# or load previous partial result and merge into it
|
|
@@ -284,6 +296,8 @@ class TestControl:
|
|
|
284
296
|
if partial:
|
|
285
297
|
# do not store the 'partial' key in the result
|
|
286
298
|
del result["partial"]
|
|
299
|
+
# note that nameless result will get None as dict key,
|
|
300
|
+
# which is perfectly fine
|
|
287
301
|
self._merge(self.partial_results[name], result)
|
|
288
302
|
# partial = do nothing
|
|
289
303
|
return
|
|
@@ -295,7 +309,7 @@ class TestControl:
|
|
|
295
309
|
if name in self.partial_results:
|
|
296
310
|
partial_result = self.partial_results[name]
|
|
297
311
|
del self.partial_results[name]
|
|
298
|
-
self.
|
|
312
|
+
self._merge(partial_result, result)
|
|
299
313
|
result = partial_result
|
|
300
314
|
|
|
301
315
|
if "testout" in result:
|
|
@@ -303,13 +317,11 @@ class TestControl:
|
|
|
303
317
|
if not testout:
|
|
304
318
|
raise BadReportJSONError("'testout' specified, but empty")
|
|
305
319
|
try:
|
|
306
|
-
self.
|
|
320
|
+
self.reporter.link_testout(testout, name)
|
|
307
321
|
except FileExistsError:
|
|
308
|
-
raise BadReportJSONError(f"file '{testout}'
|
|
309
|
-
|
|
310
|
-
self.aggregator.report(result)
|
|
322
|
+
raise BadReportJSONError(f"file '{testout}' already exists") from None
|
|
311
323
|
|
|
312
|
-
self.
|
|
324
|
+
self.reporter.report(result)
|
|
313
325
|
|
|
314
326
|
def _parser_duration(self, arg):
|
|
315
327
|
if not arg:
|
atex/fmf.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import collections
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
# from system-wide sys.path
|
|
6
|
+
import fmf
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def listlike(data, key):
|
|
10
|
+
"""
|
|
11
|
+
Get a piece of fmf metadata as an iterable regardless of whether it was
|
|
12
|
+
defined as a dict or a list.
|
|
13
|
+
|
|
14
|
+
This is needed because many fmf metadata keys can be used either as
|
|
15
|
+
some_key: 123
|
|
16
|
+
or as lists via YAML syntax
|
|
17
|
+
some_key:
|
|
18
|
+
- 123
|
|
19
|
+
- 456
|
|
20
|
+
and, for simplicity, we want to always deal with lists (iterables).
|
|
21
|
+
"""
|
|
22
|
+
if value := data.get(key):
|
|
23
|
+
return value if isinstance(value, list) else (value,)
|
|
24
|
+
else:
|
|
25
|
+
return ()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FMFTests:
|
|
29
|
+
"""
|
|
30
|
+
FMF test metadata parsed from on-disk metadata using a specific plan name,
|
|
31
|
+
with all metadata dictionaries for all nodes being adjusted by that plan
|
|
32
|
+
and (optionally) a specified context.
|
|
33
|
+
"""
|
|
34
|
+
# TODO: usage example ^^^^
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self, fmf_tree, plan_name=None, *,
|
|
38
|
+
names=None, filters=None, conditions=None, excludes=None,
|
|
39
|
+
context=None,
|
|
40
|
+
):
|
|
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. If None, a dummy (empty) plan is used.
|
|
47
|
+
|
|
48
|
+
'names', 'filters', 'conditions' and 'exclude' (all tuple/list)
|
|
49
|
+
are fmf tree filters (resolved by the fmf module), overriding any
|
|
50
|
+
existing tree filters in the plan's discover phase specifies, where:
|
|
51
|
+
|
|
52
|
+
'names' are test regexes like ["/some/test", "/another/test"]
|
|
53
|
+
|
|
54
|
+
'filters' are fmf-style filter expressions, as documented on
|
|
55
|
+
https://fmf.readthedocs.io/en/stable/modules.html#fmf.filter
|
|
56
|
+
|
|
57
|
+
'conditions' are python expressions whose namespace locals()
|
|
58
|
+
are set up to be a dictionary of the fmf tree. When any of the
|
|
59
|
+
expressions returns True, the tree is returned, ie.
|
|
60
|
+
["environment['FOO'] == 'BAR'"]
|
|
61
|
+
["'enabled' not in locals() or enabled"]
|
|
62
|
+
Note that KeyError is silently ignored and treated as False.
|
|
63
|
+
|
|
64
|
+
'excludes' are test regexes to exclude, format same as 'names'
|
|
65
|
+
|
|
66
|
+
'context' is a dict like {'distro': 'rhel-9.6'} used for additional
|
|
67
|
+
adjustment of the discovered fmf metadata.
|
|
68
|
+
"""
|
|
69
|
+
# list of packages to install, as extracted from plan
|
|
70
|
+
self.prepare_pkgs = []
|
|
71
|
+
# list of scripts to run, as extracted from plan
|
|
72
|
+
self.prepare_scripts = []
|
|
73
|
+
self.finish_scripts = []
|
|
74
|
+
# dict of environment, as extracted from plan
|
|
75
|
+
self.plan_env = {}
|
|
76
|
+
# dict indexed by test name, value is dict with fmf-parsed metadata
|
|
77
|
+
self.tests = {}
|
|
78
|
+
# dict indexed by test name, value is pathlib.Path of relative path
|
|
79
|
+
# of the fmf metadata root towards the test metadata location
|
|
80
|
+
self.test_dirs = {}
|
|
81
|
+
|
|
82
|
+
# fmf.Context instance, as used for test discovery
|
|
83
|
+
context = fmf.Context(**context) if context else fmf.Context()
|
|
84
|
+
# allow the user to pass fmf.Tree directly, greatly speeding up the
|
|
85
|
+
# instantiation of multiple FMFTests instances
|
|
86
|
+
tree = fmf_tree.copy() if isinstance(fmf_tree, fmf.Tree) else fmf.Tree(fmf_tree)
|
|
87
|
+
tree.adjust(context=context)
|
|
88
|
+
|
|
89
|
+
# Path of the metadata root
|
|
90
|
+
self.root = Path(tree.root)
|
|
91
|
+
|
|
92
|
+
# lookup the plan first
|
|
93
|
+
if plan_name:
|
|
94
|
+
plan = tree.find(plan_name)
|
|
95
|
+
if not plan:
|
|
96
|
+
raise ValueError(f"plan {plan_name} not found in {tree.root}")
|
|
97
|
+
if "test" in plan.data:
|
|
98
|
+
raise ValueError(f"plan {plan_name} appears to be a test")
|
|
99
|
+
# fall back to a dummy plan
|
|
100
|
+
else:
|
|
101
|
+
class plan: # noqa: N801
|
|
102
|
+
data = {}
|
|
103
|
+
|
|
104
|
+
# gather and merge plan-defined environment variables
|
|
105
|
+
#
|
|
106
|
+
# environment:
|
|
107
|
+
# - FOO: BAR
|
|
108
|
+
# BAR: BAZ
|
|
109
|
+
for entry in listlike(plan.data, "environment"):
|
|
110
|
+
self.plan_env.update(entry)
|
|
111
|
+
|
|
112
|
+
# gather all prepare scripts / packages
|
|
113
|
+
#
|
|
114
|
+
# prepare:
|
|
115
|
+
# - how: install
|
|
116
|
+
# package:
|
|
117
|
+
# - some-rpm-name
|
|
118
|
+
# - how: shell
|
|
119
|
+
# script:
|
|
120
|
+
# - some-command
|
|
121
|
+
for entry in listlike(plan.data, "prepare"):
|
|
122
|
+
if entry.get("how") == "install":
|
|
123
|
+
self.prepare_pkgs += listlike(entry, "package")
|
|
124
|
+
elif entry.get("how") == "shell":
|
|
125
|
+
self.prepare_scripts += listlike(entry, "script")
|
|
126
|
+
|
|
127
|
+
# gather all finish scripts, same as prepare scripts
|
|
128
|
+
for entry in listlike(plan.data, "finish"):
|
|
129
|
+
if entry.get("how") == "shell":
|
|
130
|
+
self.finish_scripts += listlike(entry, "script")
|
|
131
|
+
|
|
132
|
+
# gather all tests selected by the plan
|
|
133
|
+
#
|
|
134
|
+
# discover:
|
|
135
|
+
# - how: fmf
|
|
136
|
+
# filter:
|
|
137
|
+
# - tag:some_tag
|
|
138
|
+
# test:
|
|
139
|
+
# - some-test-regex
|
|
140
|
+
# exclude:
|
|
141
|
+
# - some-test-regex
|
|
142
|
+
plan_filters = collections.defaultdict(list)
|
|
143
|
+
for entry in listlike(plan.data, "discover"):
|
|
144
|
+
if entry.get("how") != "fmf":
|
|
145
|
+
continue
|
|
146
|
+
for meta_name in ("filter", "test", "exclude"):
|
|
147
|
+
if value := listlike(entry, meta_name):
|
|
148
|
+
plan_filters[meta_name] += value
|
|
149
|
+
|
|
150
|
+
prune_kwargs = {}
|
|
151
|
+
if names:
|
|
152
|
+
prune_kwargs["names"] = names
|
|
153
|
+
elif "test" in plan_filters:
|
|
154
|
+
prune_kwargs["names"] = plan_filters["test"]
|
|
155
|
+
if filters:
|
|
156
|
+
prune_kwargs["filters"] = filters
|
|
157
|
+
elif "filter" in plan_filters:
|
|
158
|
+
prune_kwargs["filters"] = plan_filters["filter"]
|
|
159
|
+
if conditions:
|
|
160
|
+
prune_kwargs["conditions"] = conditions
|
|
161
|
+
if not excludes:
|
|
162
|
+
excludes = plan_filters.get("exclude")
|
|
163
|
+
|
|
164
|
+
# actually discover the tests
|
|
165
|
+
for child in tree.prune(**prune_kwargs):
|
|
166
|
+
# excludes not supported by .prune(), we have to do it here
|
|
167
|
+
if excludes and any(re.match(x, child.name) for x in excludes):
|
|
168
|
+
continue
|
|
169
|
+
# only tests
|
|
170
|
+
if "test" not in child.data:
|
|
171
|
+
continue
|
|
172
|
+
# only enabled tests
|
|
173
|
+
if "enabled" in child.data and not child.data["enabled"]:
|
|
174
|
+
continue
|
|
175
|
+
# no manual tests and no stories
|
|
176
|
+
if child.data.get("manual") or child.data.get("story"):
|
|
177
|
+
continue
|
|
178
|
+
# after adjusting above, any adjusts are useless, free some space
|
|
179
|
+
if "adjust" in child.data:
|
|
180
|
+
del child.data["adjust"]
|
|
181
|
+
|
|
182
|
+
self.tests[child.name] = child.data
|
|
183
|
+
# child.sources ie. ['/abs/path/to/some.fmf', '/abs/path/to/some/node.fmf']
|
|
184
|
+
self.test_dirs[child.name] = \
|
|
185
|
+
Path(child.sources[-1]).parent.relative_to(self.root)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_pkg_requires(data, key="require"):
|
|
189
|
+
"""
|
|
190
|
+
Yield RPM package names specified by test 'data' (fmf metadata dict)
|
|
191
|
+
in the metadata 'key' (require or recommend), ignoring any non-RPM-package
|
|
192
|
+
requires/recommends.
|
|
193
|
+
"""
|
|
194
|
+
for entry in listlike(data, key):
|
|
195
|
+
# skip type:library and type:path
|
|
196
|
+
if not isinstance(entry, str):
|
|
197
|
+
continue
|
|
198
|
+
# skip "fake RPMs" that begin with 'library('
|
|
199
|
+
if entry.startswith("library("):
|
|
200
|
+
continue
|
|
201
|
+
yield entry
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def all_pkg_requires(fmf_tests, key="require"):
|
|
205
|
+
"""
|
|
206
|
+
Yield RPM package names from the plan and all tests discovered by
|
|
207
|
+
a class FMFTests instance 'fmf_tests', ignoring any non-RPM-package
|
|
208
|
+
requires/recommends.
|
|
209
|
+
"""
|
|
210
|
+
# use a set to avoid duplicates
|
|
211
|
+
pkgs = set()
|
|
212
|
+
pkgs.update(fmf_tests.prepare_pkgs)
|
|
213
|
+
for data in fmf_tests.tests.values():
|
|
214
|
+
pkgs.update(test_pkg_requires(data, key))
|
|
215
|
+
yield from pkgs
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# Some extra notes for fmf.prune() arguments:
|
|
219
|
+
#
|
|
220
|
+
# Set 'names' to filter by a list of fmf node names, ie.
|
|
221
|
+
# ['/some/test', '/another/test']
|
|
222
|
+
#
|
|
223
|
+
# Set 'filters' to filter by a list of fmf-style filter expressions, see
|
|
224
|
+
# https://fmf.readthedocs.io/en/stable/modules.html#fmf.filter
|
|
225
|
+
#
|
|
226
|
+
# Set 'conditions' to filter by a list of python expressions whose namespace
|
|
227
|
+
# locals() are set up to be a dictionary of the tree. When any of the
|
|
228
|
+
# expressions returns True, the tree is returned, ie.
|
|
229
|
+
# ['environment["FOO"] == "BAR"']
|
|
230
|
+
# ['"enabled" not in locals() or enabled']
|
|
231
|
+
# Note that KeyError is silently ignored and treated as False.
|
|
232
|
+
#
|
|
233
|
+
# Set 'context' to a dictionary to post-process the tree metadata with
|
|
234
|
+
# adjust expressions (that may be present in a tree) using the specified
|
|
235
|
+
# context. Any other filters are applied afterwards to allow modification
|
|
236
|
+
# of tree metadata by the adjust expressions. Ie.
|
|
237
|
+
# {'distro': 'rhel-9.6.0', 'arch': 'x86_64'}
|