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.
@@ -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 ctypes
20
- import ctypes.util
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) under a related
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, results_file, storage_dir):
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.results_file = Path(results_file)
27
+ self.csv_file = Path(csv_file)
74
28
  self.csv_writer = None
75
29
  self.results_gzip_handle = None
76
30
 
77
- def __enter__(self):
78
- if self.results_file.exists():
79
- raise FileExistsError(f"{self.results_file} already exists")
80
- f = gzip.open(self.results_file, "wt", newline="")
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
- return self
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 report(self, platform, status, name, note, *files):
100
- with self.lock:
101
- self.csv_writer.writerow((platform, status, name, note, *files))
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
- class ResultAggregator:
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 __init__(self, callback, storage_dir):
58
+ def ingest(self, platform, test_name, json_file, files_dir):
130
59
  """
131
- 'callback' is a function to call to record a result, with the
132
- result dict passed as an argument.
133
-
134
- 'storage_dir' is a directory for storing uploaded files.
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
- self.report = callback
137
- self.storage_dir = storage_dir
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
- @contextlib.contextmanager
140
- def open_tmpfile(self, open_mode=os.O_WRONLY):
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
- def link_tmpfile_to(self, result_name, file_name, fd):
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
- This function can be called multiple times with the same 'fd', and
158
- does not close or otherwise alter the descriptor.
159
- """
160
- # /path/to/all/logs / some/test/name / path/to/file.log
161
- file_path = self.storage_dir / result_name.lstrip("/") / _normalize_path(file_name)
162
- file_path.parent.mkdir(parents=True, exist_ok=True)
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))