atex 0.7__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/cli/fmf.py +93 -0
- atex/cli/testingfarm.py +23 -13
- atex/connection/__init__.py +0 -8
- atex/connection/ssh.py +3 -19
- 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/{minitmt → executor}/scripts.py +30 -24
- atex/{minitmt → executor}/testcontrol.py +16 -17
- atex/{minitmt/fmf.py → fmf.py} +49 -34
- atex/orchestrator/__init__.py +2 -59
- atex/orchestrator/aggregator.py +66 -123
- atex/orchestrator/orchestrator.py +324 -0
- atex/provision/__init__.py +68 -99
- atex/provision/testingfarm/__init__.py +2 -29
- atex/provision/testingfarm/api.py +55 -40
- atex/provision/testingfarm/testingfarm.py +236 -0
- atex/util/__init__.py +1 -6
- atex/util/log.py +8 -0
- atex/util/path.py +16 -0
- atex/util/ssh_keygen.py +14 -0
- atex/util/threads.py +55 -0
- {atex-0.7.dist-info → atex-0.8.dist-info}/METADATA +97 -2
- atex-0.8.dist-info/RECORD +37 -0
- atex/cli/minitmt.py +0 -175
- atex/minitmt/__init__.py +0 -23
- atex/minitmt/executor.py +0 -348
- atex/provision/nspawn/README +0 -74
- atex/provision/testingfarm/foo.py +0 -1
- atex-0.7.dist-info/RECORD +0 -32
- {atex-0.7.dist-info → atex-0.8.dist-info}/WHEEL +0 -0
- {atex-0.7.dist-info → atex-0.8.dist-info}/entry_points.txt +0 -0
- {atex-0.7.dist-info → atex-0.8.dist-info}/licenses/COPYING.txt +0 -0
atex/orchestrator/aggregator.py
CHANGED
|
@@ -1,83 +1,37 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Functions and utilities for persistently storing test results and files (logs).
|
|
3
|
-
|
|
4
|
-
There is a global aggregator (ie. CSVAggregator) that handles all the results
|
|
5
|
-
from all platforms (arches and distros), and several per-platform aggregators
|
|
6
|
-
that are used by test execution logic.
|
|
7
|
-
|
|
8
|
-
with CSVAggregator("results.csv.gz", "file/storage/dir") as global_aggr:
|
|
9
|
-
reporter = global_aggr.for_platform("rhel-9@x86_64")
|
|
10
|
-
reporter.report({"name": "/some/test", "status": "pass"})
|
|
11
|
-
with reporter.open_tmpfile() as fd:
|
|
12
|
-
os.write(fd, "some contents")
|
|
13
|
-
reporter.link_tmpfile_to("/some/test", "test.log", fd)
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
import os
|
|
17
1
|
import csv
|
|
18
2
|
import gzip
|
|
19
|
-
import
|
|
20
|
-
import
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
21
5
|
import threading
|
|
22
|
-
import contextlib
|
|
23
6
|
from pathlib import Path
|
|
24
7
|
|
|
25
8
|
|
|
26
|
-
libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
|
|
27
|
-
|
|
28
|
-
# int linkat(int olddirfd, const char *oldpath, int newdirfd, const char *newpath, int flags)
|
|
29
|
-
libc.linkat.argtypes = (
|
|
30
|
-
ctypes.c_int,
|
|
31
|
-
ctypes.c_char_p,
|
|
32
|
-
ctypes.c_int,
|
|
33
|
-
ctypes.c_char_p,
|
|
34
|
-
ctypes.c_int,
|
|
35
|
-
)
|
|
36
|
-
libc.linkat.restype = ctypes.c_int
|
|
37
|
-
|
|
38
|
-
# fcntl.h:#define AT_EMPTY_PATH 0x1000 /* Allow empty relative pathname */
|
|
39
|
-
AT_EMPTY_PATH = 0x1000
|
|
40
|
-
|
|
41
|
-
# fcntl.h:#define AT_FDCWD -100 /* Special value used to indicate
|
|
42
|
-
AT_FDCWD = -100
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def linkat(*args):
|
|
46
|
-
if (ret := libc.linkat(*args)) == -1:
|
|
47
|
-
errno = ctypes.get_errno()
|
|
48
|
-
raise OSError(errno, os.strerror(errno))
|
|
49
|
-
return ret
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _normalize_path(path):
|
|
53
|
-
# the magic here is to treat any dangerous path as starting at /
|
|
54
|
-
# and resolve any weird constructs relative to /, and then simply
|
|
55
|
-
# strip off the leading / and use it as a relative path
|
|
56
|
-
path = path.lstrip("/")
|
|
57
|
-
path = os.path.normpath(f"/{path}")
|
|
58
|
-
return path[1:]
|
|
59
|
-
|
|
60
|
-
|
|
61
9
|
class CSVAggregator:
|
|
62
10
|
"""
|
|
63
|
-
Collects reported results as a GZIP-ed CSV and files (logs)
|
|
64
|
-
directory.
|
|
11
|
+
Collects reported results as a GZIP-ed CSV and files (logs) from multiple
|
|
12
|
+
test runs under a shared directory.
|
|
65
13
|
"""
|
|
66
14
|
|
|
67
15
|
class _ExcelWithUnixNewline(csv.excel):
|
|
68
16
|
lineterminator = "\n"
|
|
69
17
|
|
|
70
|
-
def __init__(self,
|
|
18
|
+
def __init__(self, csv_file, storage_dir):
|
|
19
|
+
"""
|
|
20
|
+
'csv_file' is a string/Path to a .csv.gz file with aggregated results.
|
|
21
|
+
|
|
22
|
+
'storage_dir' is a string/Path of the top-level parent for all
|
|
23
|
+
per-platform / per-test files uploaded by tests.
|
|
24
|
+
"""
|
|
71
25
|
self.lock = threading.RLock()
|
|
72
26
|
self.storage_dir = Path(storage_dir)
|
|
73
|
-
self.
|
|
27
|
+
self.csv_file = Path(csv_file)
|
|
74
28
|
self.csv_writer = None
|
|
75
29
|
self.results_gzip_handle = None
|
|
76
30
|
|
|
77
|
-
def
|
|
78
|
-
if self.
|
|
79
|
-
raise FileExistsError(f"{self.
|
|
80
|
-
f = gzip.open(self.
|
|
31
|
+
def open(self):
|
|
32
|
+
if self.csv_file.exists():
|
|
33
|
+
raise FileExistsError(f"{self.csv_file} already exists")
|
|
34
|
+
f = gzip.open(self.csv_file, "wt", newline="")
|
|
81
35
|
try:
|
|
82
36
|
self.csv_writer = csv.writer(f, dialect=self._ExcelWithUnixNewline)
|
|
83
37
|
except:
|
|
@@ -89,75 +43,64 @@ class CSVAggregator:
|
|
|
89
43
|
raise FileExistsError(f"{self.storage_dir} already exists")
|
|
90
44
|
self.storage_dir.mkdir()
|
|
91
45
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def __exit__(self, exc_type, exc_value, traceback):
|
|
46
|
+
def close(self):
|
|
95
47
|
self.results_gzip_handle.close()
|
|
96
48
|
self.results_gzip_handle = None
|
|
97
49
|
self.csv_writer = None
|
|
98
50
|
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def for_platform(self, platform_string):
|
|
104
|
-
"""
|
|
105
|
-
Return a ResultAggregator instance that writes results into this
|
|
106
|
-
CSVAgreggator instance.
|
|
107
|
-
"""
|
|
108
|
-
def report(result_line):
|
|
109
|
-
file_names = []
|
|
110
|
-
if "testout" in result_line:
|
|
111
|
-
file_names.append(result_line["testout"])
|
|
112
|
-
if "files" in result_line:
|
|
113
|
-
file_names += (f["name"] for f in result_line["files"])
|
|
114
|
-
self.report(
|
|
115
|
-
platform_string, result_line["status"], result_line["name"],
|
|
116
|
-
result_line.get("note", ""), *file_names,
|
|
117
|
-
)
|
|
118
|
-
platform_dir = self.storage_dir / platform_string
|
|
119
|
-
platform_dir.mkdir(exist_ok=True)
|
|
120
|
-
return ResultAggregator(report, platform_dir)
|
|
121
|
-
|
|
51
|
+
def __enter__(self):
|
|
52
|
+
self.open()
|
|
53
|
+
return self
|
|
122
54
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
Collects reported results (in a format specified by RESULTS.md) for
|
|
126
|
-
a specific platform, storing them persistently.
|
|
127
|
-
"""
|
|
55
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
56
|
+
self.close()
|
|
128
57
|
|
|
129
|
-
def
|
|
58
|
+
def ingest(self, platform, test_name, json_file, files_dir):
|
|
130
59
|
"""
|
|
131
|
-
'
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
60
|
+
Process 'json_file' (string/Path) for reported results and append them
|
|
61
|
+
to the overall aggregated CSV file, recursively copying over the dir
|
|
62
|
+
structure under 'files_dir' (string/Path) under the respective platform
|
|
63
|
+
and test name in the aggregated files storage dir.
|
|
135
64
|
"""
|
|
136
|
-
|
|
137
|
-
|
|
65
|
+
# parse the JSON separately, before writing any CSV lines, to ensure
|
|
66
|
+
# that either all results from the test are ingested, or none at all
|
|
67
|
+
# (if one of the lines contains JSON errors)
|
|
68
|
+
csv_lines = []
|
|
69
|
+
with open(json_file) as json_fobj:
|
|
70
|
+
for raw_line in json_fobj:
|
|
71
|
+
result_line = json.loads(raw_line)
|
|
72
|
+
|
|
73
|
+
result_name = result_line.get("name")
|
|
74
|
+
if result_name:
|
|
75
|
+
# sub-result; prefix test name
|
|
76
|
+
result_name = f"{test_name}/{result_name}"
|
|
77
|
+
else:
|
|
78
|
+
# result for test itself; use test name
|
|
79
|
+
result_name = test_name
|
|
80
|
+
|
|
81
|
+
file_names = []
|
|
82
|
+
if "testout" in result_line:
|
|
83
|
+
file_names.append(result_line["testout"])
|
|
84
|
+
if "files" in result_line:
|
|
85
|
+
file_names += (f["name"] for f in result_line["files"])
|
|
86
|
+
|
|
87
|
+
csv_lines.append((
|
|
88
|
+
platform,
|
|
89
|
+
result_line["status"],
|
|
90
|
+
result_name,
|
|
91
|
+
result_line.get("note", ""),
|
|
92
|
+
*file_names,
|
|
93
|
+
))
|
|
138
94
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
Open an anonymous (name-less) file for writing and yield its file
|
|
143
|
-
descriptor (int) as context, closing it when the context is exited.
|
|
144
|
-
"""
|
|
145
|
-
flags = open_mode | os.O_TMPFILE
|
|
146
|
-
fd = os.open(self.storage_dir, flags, 0o644)
|
|
147
|
-
try:
|
|
148
|
-
yield fd
|
|
149
|
-
finally:
|
|
150
|
-
os.close(fd)
|
|
95
|
+
with self.lock:
|
|
96
|
+
self.csv_writer.writerows(csv_lines)
|
|
97
|
+
self.results_gzip_handle.flush()
|
|
151
98
|
|
|
152
|
-
|
|
153
|
-
"""
|
|
154
|
-
Store a file named 'file_name' in a directory relevant to 'result_name'
|
|
155
|
-
whose 'fd' (a file descriptor) was created by .open_tmpfile().
|
|
99
|
+
Path(json_file).unlink()
|
|
156
100
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
""
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
linkat(fd, b"", AT_FDCWD, bytes(file_path), AT_EMPTY_PATH)
|
|
101
|
+
platform_dir = self.storage_dir / platform
|
|
102
|
+
platform_dir.mkdir(exist_ok=True)
|
|
103
|
+
test_dir = platform_dir / test_name.lstrip("/")
|
|
104
|
+
if test_dir.exists():
|
|
105
|
+
raise FileExistsError(f"{test_dir} already exists for {test_name}")
|
|
106
|
+
shutil.move(files_dir, test_dir, copy_function=shutil.copy)
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import tempfile
|
|
3
|
+
import traceback
|
|
4
|
+
import concurrent
|
|
5
|
+
import collections
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .. import util, executor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Orchestrator:
|
|
12
|
+
"""
|
|
13
|
+
A scheduler for parallel execution on multiple resources (machines/systems).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
SetupInfo = collections.namedtuple(
|
|
17
|
+
"SetupInfo",
|
|
18
|
+
(
|
|
19
|
+
# class Provisioner instance this machine is provided by
|
|
20
|
+
# (for logging purposes)
|
|
21
|
+
"provisioner",
|
|
22
|
+
# class Remote instance returned by the Provisioner
|
|
23
|
+
"remote",
|
|
24
|
+
# class Executor instance uploading tests / running setup or tests
|
|
25
|
+
"executor",
|
|
26
|
+
),
|
|
27
|
+
)
|
|
28
|
+
RunningInfo = collections.namedtuple(
|
|
29
|
+
"RunningInfo",
|
|
30
|
+
(
|
|
31
|
+
# "inherit" from SetupInfo
|
|
32
|
+
*SetupInfo._fields,
|
|
33
|
+
# string with /test/name
|
|
34
|
+
"test_name",
|
|
35
|
+
# class tempfile.TemporaryDirectory instance with 'json_file' and 'files_dir'
|
|
36
|
+
"tmp_dir",
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
FinishedInfo = collections.namedtuple(
|
|
40
|
+
"FinishedInfo",
|
|
41
|
+
(
|
|
42
|
+
# "inherit" from RunningInfo
|
|
43
|
+
*RunningInfo._fields,
|
|
44
|
+
# integer with exit code of the test
|
|
45
|
+
# (None if exception happened)
|
|
46
|
+
"exit_code",
|
|
47
|
+
# exception class instance if running the test failed
|
|
48
|
+
# (None if no exception happened (exit_code is defined))
|
|
49
|
+
"exception",
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def __init__(self, platform, fmf_tests, provisioners, aggregator, tmp_dir, *, max_reruns=2):
|
|
54
|
+
"""
|
|
55
|
+
'platform' is a string with platform name.
|
|
56
|
+
|
|
57
|
+
'fmf_tests' is a class FMFTests instance of the tests to run.
|
|
58
|
+
|
|
59
|
+
'provisioners' is an iterable of class Provisioner instances.
|
|
60
|
+
|
|
61
|
+
'aggregator' is a class CSVAggregator instance.
|
|
62
|
+
|
|
63
|
+
'tmp_dir' is a string/Path to a temporary directory, to be used for
|
|
64
|
+
storing per-test results and uploaded files before being ingested
|
|
65
|
+
by the aggregator. Can be safely shared by Orchestrator instances.
|
|
66
|
+
"""
|
|
67
|
+
self.platform = platform
|
|
68
|
+
self.fmf_tests = fmf_tests
|
|
69
|
+
self.provisioners = tuple(provisioners)
|
|
70
|
+
self.aggregator = aggregator
|
|
71
|
+
self.tmp_dir = tmp_dir
|
|
72
|
+
# tests still waiting to be run
|
|
73
|
+
self.to_run = set(fmf_tests.tests)
|
|
74
|
+
# running setup functions, as a list of SetupInfo items
|
|
75
|
+
self.running_setups = []
|
|
76
|
+
# running tests as a dict, indexed by test name, with RunningInfo values
|
|
77
|
+
self.running_tests = {}
|
|
78
|
+
# indexed by test name, value being integer of how many times
|
|
79
|
+
self.reruns = collections.defaultdict(lambda: max_reruns)
|
|
80
|
+
# thread queue for actively running tests
|
|
81
|
+
self.test_queue = util.ThreadQueue(daemon=False)
|
|
82
|
+
# thread queue for remotes being set up (uploading tests, etc.)
|
|
83
|
+
self.setup_queue = util.ThreadQueue(daemon=True)
|
|
84
|
+
# NOTE: running_setups and test_running are just for debugging and
|
|
85
|
+
# cancellation, the execution flow itself uses ThreadQueues
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _run_setup(sinfo):
|
|
89
|
+
sinfo.executor.setup()
|
|
90
|
+
sinfo.executor.upload_tests()
|
|
91
|
+
sinfo.executor.setup_plan()
|
|
92
|
+
# NOTE: we never run executor.cleanup() anywhere - instead, we assume
|
|
93
|
+
# the remote (and its connection) was invalidated by the test,
|
|
94
|
+
# so we just rely on remote.release() destroying the system
|
|
95
|
+
return sinfo
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def _wrap_test(cls, rinfo, func, *args, **kwargs):
|
|
99
|
+
"""
|
|
100
|
+
Wrap 'func' (test execution function) to preserve extra metadata
|
|
101
|
+
('rinfo') and return it with the function return value.
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
return cls.FinishedInfo(*rinfo, func(*args, **kwargs), None)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
return cls.FinishedInfo(*rinfo, None, e)
|
|
107
|
+
|
|
108
|
+
def _run_new_test(self, sinfo):
|
|
109
|
+
"""
|
|
110
|
+
'sinfo' is a SetupInfo instance.
|
|
111
|
+
"""
|
|
112
|
+
next_test_name = self.next_test(self.to_run, self.fmf_tests)
|
|
113
|
+
assert next_test_name in self.to_run, "next_test() returned valid test name"
|
|
114
|
+
|
|
115
|
+
self.to_run.remove(next_test_name)
|
|
116
|
+
|
|
117
|
+
rinfo = self.RunningInfo(
|
|
118
|
+
*sinfo,
|
|
119
|
+
test_name=next_test_name,
|
|
120
|
+
tmp_dir=tempfile.TemporaryDirectory(
|
|
121
|
+
prefix=next_test_name.strip("/").replace("/","-") + "-",
|
|
122
|
+
dir=self.tmp_dir,
|
|
123
|
+
delete=False,
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
tmp_dir_path = Path(rinfo.tmp_dir.name)
|
|
128
|
+
self.test_queue.start_thread(
|
|
129
|
+
target=self._wrap_test,
|
|
130
|
+
args=(
|
|
131
|
+
rinfo,
|
|
132
|
+
sinfo.executor.run_test,
|
|
133
|
+
next_test_name,
|
|
134
|
+
tmp_dir_path / "json_file",
|
|
135
|
+
tmp_dir_path / "files_dir",
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
self.running_tests[next_test_name] = rinfo
|
|
140
|
+
|
|
141
|
+
def _process_finished_test(self, finfo):
|
|
142
|
+
"""
|
|
143
|
+
'finfo' is a FinishedInfo instance.
|
|
144
|
+
"""
|
|
145
|
+
test_id = f"'{finfo.test_name}' on '{finfo.remote}'"
|
|
146
|
+
tmp_dir_path = Path(finfo.tmp_dir.name)
|
|
147
|
+
|
|
148
|
+
# NOTE: document that we intentionally don't .cleanup() executioner below,
|
|
149
|
+
# we rely on remote .release() destroying the OS, because we don't
|
|
150
|
+
# want to risk .cleanup() blocking on dead ssh into the remote after
|
|
151
|
+
# executing a destructive test
|
|
152
|
+
|
|
153
|
+
destructive = False
|
|
154
|
+
|
|
155
|
+
# if executor (or test) threw exception, schedule a re-run
|
|
156
|
+
if finfo.exception:
|
|
157
|
+
destructive = True
|
|
158
|
+
exc_str = "".join(traceback.format_exception(finfo.exception)).rstrip("\n")
|
|
159
|
+
util.info(f"unexpected exception happened while running {test_id}:\n{exc_str}")
|
|
160
|
+
finfo.remote.release()
|
|
161
|
+
if self.reruns[finfo.test_name] > 0:
|
|
162
|
+
self.reruns[finfo.test_name] -= 1
|
|
163
|
+
self.to_run.add(finfo.test_name)
|
|
164
|
+
else:
|
|
165
|
+
util.info(f"reruns for {test_id} exceeded, ignoring it")
|
|
166
|
+
|
|
167
|
+
# if the test exited as non-0, try a re-run
|
|
168
|
+
elif finfo.exit_code != 0:
|
|
169
|
+
destructive = True
|
|
170
|
+
finfo.remote.release()
|
|
171
|
+
if self.reruns[finfo.test_name] > 0:
|
|
172
|
+
util.info(
|
|
173
|
+
f"{test_id} exited with non-zero: {finfo.exit_code}, re-running "
|
|
174
|
+
f"({self.reruns[finfo.test_name]} reruns left)",
|
|
175
|
+
)
|
|
176
|
+
self.reruns[finfo.test_name] -= 1
|
|
177
|
+
self.to_run.add(finfo.test_name)
|
|
178
|
+
else:
|
|
179
|
+
util.info(
|
|
180
|
+
f"{test_id} exited with non-zero: {finfo.exit_code}, "
|
|
181
|
+
"all reruns exceeded, giving up",
|
|
182
|
+
)
|
|
183
|
+
# record the final result anyway
|
|
184
|
+
self.aggregator.ingest(
|
|
185
|
+
self.platform,
|
|
186
|
+
finfo.test_name,
|
|
187
|
+
tmp_dir_path / "json_file",
|
|
188
|
+
tmp_dir_path / "files_dir",
|
|
189
|
+
)
|
|
190
|
+
finfo.tmp_dir.cleanup()
|
|
191
|
+
|
|
192
|
+
# test finished successfully - ingest its results
|
|
193
|
+
else:
|
|
194
|
+
util.info(f"{test_id} finished successfully")
|
|
195
|
+
self.aggregator.ingest(
|
|
196
|
+
self.platform,
|
|
197
|
+
finfo.test_name,
|
|
198
|
+
tmp_dir_path / "json_file",
|
|
199
|
+
tmp_dir_path / "files_dir",
|
|
200
|
+
)
|
|
201
|
+
finfo.tmp_dir.cleanup()
|
|
202
|
+
|
|
203
|
+
# if the remote was not destroyed by traceback / failing test,
|
|
204
|
+
# check if the test always destroys it (even on success)
|
|
205
|
+
if not destructive:
|
|
206
|
+
test_data = self.fmf_tests.tests[finfo.test_name]
|
|
207
|
+
destructive = test_data.get("extra-atex", {}).get("destructive", False)
|
|
208
|
+
|
|
209
|
+
# if destroyed, release the remote
|
|
210
|
+
if destructive:
|
|
211
|
+
util.debug(f"{test_id} was destructive, releasing remote")
|
|
212
|
+
finfo.remote.release()
|
|
213
|
+
|
|
214
|
+
# if still not destroyed, run another test on it
|
|
215
|
+
# (without running plan setup, re-using already set up remote)
|
|
216
|
+
elif self.to_run:
|
|
217
|
+
sinfo = self.SetupInfo(
|
|
218
|
+
provisioner=finfo.provisioner,
|
|
219
|
+
remote=finfo.remote,
|
|
220
|
+
executor=finfo.executor,
|
|
221
|
+
)
|
|
222
|
+
util.debug(f"{test_id} was non-destructive, running next test")
|
|
223
|
+
self._run_new_test(sinfo)
|
|
224
|
+
|
|
225
|
+
def serve_once(self):
|
|
226
|
+
"""
|
|
227
|
+
Run the orchestration logic, processing any outstanding requests
|
|
228
|
+
(for provisioning, new test execution, etc.) and returning once these
|
|
229
|
+
are taken care of.
|
|
230
|
+
|
|
231
|
+
Returns True to indicate that it should be called again by the user
|
|
232
|
+
(more work to be done), False once all testing is concluded.
|
|
233
|
+
"""
|
|
234
|
+
util.debug(
|
|
235
|
+
f"to_run: {len(self.to_run)} tests / "
|
|
236
|
+
f"running: {len(self.running_tests)} tests, {len(self.running_setups)} setups",
|
|
237
|
+
)
|
|
238
|
+
# all done
|
|
239
|
+
if not self.to_run and not self.running_tests:
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
# process all finished tests, potentially reusing remotes for executing
|
|
243
|
+
# further tests
|
|
244
|
+
while True:
|
|
245
|
+
try:
|
|
246
|
+
finfo = self.test_queue.get(block=False)
|
|
247
|
+
except util.ThreadQueue.Empty:
|
|
248
|
+
break
|
|
249
|
+
del self.running_tests[finfo.test_name]
|
|
250
|
+
self._process_finished_test(finfo)
|
|
251
|
+
|
|
252
|
+
# process any remotes with finished plan setup (uploaded tests,
|
|
253
|
+
# plan-defined pkgs / prepare scripts), start executing tests on them
|
|
254
|
+
while True:
|
|
255
|
+
try:
|
|
256
|
+
sinfo = self.setup_queue.get(block=False)
|
|
257
|
+
except util.ThreadQueue.Empty:
|
|
258
|
+
break
|
|
259
|
+
util.debug(f"setup finished for '{sinfo.remote}', running first test")
|
|
260
|
+
self.running_setups.remove(sinfo)
|
|
261
|
+
self._run_new_test(sinfo)
|
|
262
|
+
|
|
263
|
+
# try to get new remotes from Provisioners - if we get some, start
|
|
264
|
+
# running setup on them
|
|
265
|
+
for provisioner in self.provisioners:
|
|
266
|
+
while (remote := provisioner.get_remote(block=False)) is not None:
|
|
267
|
+
ex = executor.Executor(self.fmf_tests, remote)
|
|
268
|
+
sinfo = self.SetupInfo(
|
|
269
|
+
provisioner=provisioner,
|
|
270
|
+
remote=remote,
|
|
271
|
+
executor=ex,
|
|
272
|
+
)
|
|
273
|
+
self.setup_queue.start_thread(
|
|
274
|
+
target=self._run_setup,
|
|
275
|
+
args=(sinfo,),
|
|
276
|
+
)
|
|
277
|
+
self.running_setups.append(sinfo)
|
|
278
|
+
util.debug(f"got remote '{remote}' from '{provisioner}', running setup")
|
|
279
|
+
|
|
280
|
+
return True
|
|
281
|
+
|
|
282
|
+
def serve_forever(self):
|
|
283
|
+
"""
|
|
284
|
+
Run the orchestration logic, blocking until all testing is concluded.
|
|
285
|
+
"""
|
|
286
|
+
while self.serve_once():
|
|
287
|
+
time.sleep(1)
|
|
288
|
+
|
|
289
|
+
def __enter__(self):
|
|
290
|
+
# start all provisioners
|
|
291
|
+
for prov in self.provisioners:
|
|
292
|
+
prov.start()
|
|
293
|
+
return self
|
|
294
|
+
|
|
295
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
296
|
+
# cancel all running tests and wait for them to clean up (up to 0.1sec)
|
|
297
|
+
for rinfo in self.running_tests.values():
|
|
298
|
+
rinfo.executor.cancel()
|
|
299
|
+
self.test_queue.join() # also ignore any exceptions raised
|
|
300
|
+
|
|
301
|
+
# stop all provisioners, also releasing all remotes
|
|
302
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as ex:
|
|
303
|
+
for provisioner in self.provisioners:
|
|
304
|
+
for func in provisioner.stop_defer():
|
|
305
|
+
ex.submit(func)
|
|
306
|
+
|
|
307
|
+
def next_test(self, tests, fmf_tests): # noqa: ARG002, PLR6301
|
|
308
|
+
"""
|
|
309
|
+
Return a test name (string) from a set of 'tests' (set of test name
|
|
310
|
+
strings) to be run next.
|
|
311
|
+
|
|
312
|
+
'fmf_tests' is a class FMFTests instance with additional test metadata.
|
|
313
|
+
|
|
314
|
+
This method is user-overridable, ie. by subclassing Orchestrator:
|
|
315
|
+
|
|
316
|
+
class CustomOrchestrator(Orchestrator):
|
|
317
|
+
@staticmethod
|
|
318
|
+
def next_test(tests):
|
|
319
|
+
...
|
|
320
|
+
"""
|
|
321
|
+
# TODO: more advanced algorithm
|
|
322
|
+
#
|
|
323
|
+
# simple:
|
|
324
|
+
return next(iter(tests))
|