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.
Files changed (46) hide show
  1. atex/__init__.py +2 -12
  2. atex/cli/__init__.py +13 -13
  3. atex/cli/fmf.py +93 -0
  4. atex/cli/testingfarm.py +71 -61
  5. atex/connection/__init__.py +117 -0
  6. atex/connection/ssh.py +390 -0
  7. atex/executor/__init__.py +2 -0
  8. atex/executor/duration.py +60 -0
  9. atex/executor/executor.py +378 -0
  10. atex/executor/reporter.py +106 -0
  11. atex/executor/scripts.py +155 -0
  12. atex/executor/testcontrol.py +353 -0
  13. atex/fmf.py +217 -0
  14. atex/orchestrator/__init__.py +2 -0
  15. atex/orchestrator/aggregator.py +106 -0
  16. atex/orchestrator/orchestrator.py +324 -0
  17. atex/provision/__init__.py +101 -90
  18. atex/provision/libvirt/VM_PROVISION +8 -0
  19. atex/provision/libvirt/__init__.py +4 -4
  20. atex/provision/podman/README +59 -0
  21. atex/provision/podman/host_container.sh +74 -0
  22. atex/provision/testingfarm/__init__.py +2 -0
  23. atex/{testingfarm.py → provision/testingfarm/api.py} +170 -132
  24. atex/provision/testingfarm/testingfarm.py +236 -0
  25. atex/util/__init__.py +5 -10
  26. atex/util/dedent.py +1 -1
  27. atex/util/log.py +20 -12
  28. atex/util/path.py +16 -0
  29. atex/util/ssh_keygen.py +14 -0
  30. atex/util/subprocess.py +14 -13
  31. atex/util/threads.py +55 -0
  32. {atex-0.5.dist-info → atex-0.8.dist-info}/METADATA +97 -2
  33. atex-0.8.dist-info/RECORD +37 -0
  34. atex/cli/minitmt.py +0 -82
  35. atex/minitmt/__init__.py +0 -115
  36. atex/minitmt/fmf.py +0 -168
  37. atex/minitmt/report.py +0 -174
  38. atex/minitmt/scripts.py +0 -51
  39. atex/minitmt/testme.py +0 -3
  40. atex/orchestrator.py +0 -38
  41. atex/ssh.py +0 -320
  42. atex/util/lockable_class.py +0 -38
  43. atex-0.5.dist-info/RECORD +0 -26
  44. {atex-0.5.dist-info → atex-0.8.dist-info}/WHEEL +0 -0
  45. {atex-0.5.dist-info → atex-0.8.dist-info}/entry_points.txt +0 -0
  46. {atex-0.5.dist-info → atex-0.8.dist-info}/licenses/COPYING.txt +0 -0
@@ -0,0 +1,378 @@
1
+ import os
2
+ import select
3
+ import threading
4
+ import contextlib
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from .. import util, fmf
9
+ from . import testcontrol, scripts
10
+ from .duration import Duration
11
+ from .reporter import Reporter
12
+
13
+
14
+ class TestAbortedError(Exception):
15
+ """
16
+ Raised when an infrastructure-related issue happened while running a test.
17
+ """
18
+ pass
19
+
20
+
21
+ class Executor:
22
+ """
23
+ Logic for running tests on a remote system and processing results
24
+ and uploaded files by those tests.
25
+
26
+ tests_repo = "path/to/cloned/tests"
27
+ tests_data = atex.fmf.FMFTests(tests_repo, "/plans/default")
28
+
29
+ with Executor(tests_data, conn) as e:
30
+ e.upload_tests(tests_repo)
31
+ e.setup_plan()
32
+ e.run_test("/some/test", "results/here.json", "uploaded/files/here")
33
+ e.run_test(...)
34
+
35
+ One Executor instance may be used to run multiple tests sequentially.
36
+ In addition, multiple Executor instances can run in parallel on the same
37
+ host, provided each receives a unique class Connection instance to it.
38
+
39
+ conn.cmd(["mkdir", "-p", "/shared"])
40
+
41
+ with Executor(tests_data, conn, state_dir="/shared") as e:
42
+ e.upload_tests(tests_repo)
43
+ e.setup_plan()
44
+
45
+ # in parallel (ie. threading or multiprocessing)
46
+ with Executor(tests_data, unique_conn, state_dir="/shared") as e:
47
+ e.run_test(...)
48
+ """
49
+
50
+ def __init__(self, fmf_tests, connection, *, state_dir=None):
51
+ """
52
+ 'fmf_tests' is a class FMFTests instance with (discovered) tests.
53
+
54
+ 'connection' is a class Connection instance, already fully connected.
55
+
56
+ 'state_dir' is a string or Path specifying path on the remote system for
57
+ storing additional data, such as tests, execution wrappers, temporary
58
+ plan-exported variables, etc. If left as None, a tmpdir is used.
59
+ """
60
+ self.lock = threading.RLock()
61
+ self.conn = connection
62
+ self.fmf_tests = fmf_tests
63
+ self.state_dir = state_dir
64
+ self.work_dir = None
65
+ self.tests_dir = None
66
+ self.plan_env_file = None
67
+ self.cancelled = False
68
+
69
+ def setup(self):
70
+ with self.lock:
71
+ state_dir = self.state_dir
72
+
73
+ # if user defined a state dir, have shared tests, but use per-instance
74
+ # work_dir for test wrappers, etc., identified by this instance's id(),
75
+ # which should be unique as long as this instance exists
76
+ if state_dir:
77
+ state_dir = Path(state_dir)
78
+ work_dir = state_dir / f"atex-{id(self)}"
79
+ self.conn.cmd(("mkdir", work_dir), check=True)
80
+ with self.lock:
81
+ self.tests_dir = state_dir / "tests"
82
+ self.plan_env_file = state_dir / "plan_env"
83
+ self.work_dir = work_dir
84
+
85
+ # else just create a tmpdir
86
+ else:
87
+ tmp_dir = self.conn.cmd(
88
+ # /var is not cleaned up by bootc, /var/tmp is
89
+ ("mktemp", "-d", "-p", "/var", "atex-XXXXXXXXXX"),
90
+ func=util.subprocess_output,
91
+ )
92
+ tmp_dir = Path(tmp_dir)
93
+ with self.lock:
94
+ self.tests_dir = tmp_dir / "tests"
95
+ self.plan_env_file = tmp_dir / "plan_env"
96
+ # use the tmpdir as work_dir, avoid extra mkdir over conn
97
+ self.work_dir = tmp_dir
98
+
99
+ def cleanup(self):
100
+ with self.lock:
101
+ work_dir = self.work_dir
102
+
103
+ if work_dir:
104
+ self.conn.cmd(("rm", "-rf", work_dir), check=True)
105
+
106
+ with self.lock:
107
+ self.work_dir = None
108
+ self.tests_dir = None
109
+ self.plan_env_file = None
110
+
111
+ def __enter__(self):
112
+ self.setup()
113
+ return self
114
+
115
+ def __exit__(self, exc_type, exc_value, traceback):
116
+ self.cleanup()
117
+
118
+ def cancel(self):
119
+ with self.lock:
120
+ self.cancelled = True
121
+
122
+ def upload_tests(self):
123
+ """
124
+ Upload a directory of all tests, the location of which was provided to
125
+ __init__() inside 'fmf_tests', to the remote host.
126
+ """
127
+ self.conn.rsync(
128
+ "-rv" if util.in_debug_mode() else "-rq",
129
+ "--delete", "--exclude=.git/",
130
+ f"{self.fmf_tests.root}/",
131
+ f"remote:{self.tests_dir}",
132
+ )
133
+
134
+ def setup_plan(self):
135
+ """
136
+ Install packages and run scripts extracted from a TMT plan by a FMFTests
137
+ instance given during class initialization.
138
+
139
+ Also prepare additional environment for tests, ie. create and export
140
+ a path to TMT_PLAN_ENVIRONMENT_FILE.
141
+ """
142
+ # install packages from the plan
143
+ if self.fmf_tests.prepare_pkgs:
144
+ self.conn.cmd(
145
+ (
146
+ "dnf", "-y", "--setopt=install_weak_deps=False",
147
+ "install", *self.fmf_tests.prepare_pkgs,
148
+ ),
149
+ check=True,
150
+ stdout=None if util.in_debug_mode() else subprocess.DEVNULL,
151
+ stderr=subprocess.STDOUT,
152
+ )
153
+
154
+ # make envionment for 'prepare' scripts
155
+ self.conn.cmd(("truncate", "-s", "0", self.plan_env_file), check=True)
156
+ env = self.fmf_tests.plan_env.copy()
157
+ env["TMT_PLAN_ENVIRONMENT_FILE"] = self.plan_env_file
158
+ env_args = (f"{k}={v}" for k, v in env.items())
159
+
160
+ # run the prepare scripts
161
+ for script in self.fmf_tests.prepare_scripts:
162
+ self.conn.cmd(
163
+ ("env", *env_args, "bash"),
164
+ input=script,
165
+ text=True,
166
+ check=True,
167
+ stdout=None if util.in_debug_mode() else subprocess.DEVNULL,
168
+ stderr=subprocess.STDOUT,
169
+ )
170
+
171
+ def run_test(self, test_name, json_file, files_dir, *, env=None):
172
+ """
173
+ Run one test on the remote system.
174
+
175
+ 'test_name' is a string with test name.
176
+
177
+ 'json_file' is a destination file (string or Path) for results.
178
+
179
+ 'files_dir' is a destination dir (string or Path) for uploaded files.
180
+
181
+ 'env' is a dict of extra environment variables to pass to the test.
182
+
183
+ Returns an integer exit code of the test script.
184
+ """
185
+ test_data = self.fmf_tests.tests[test_name]
186
+
187
+ # start with fmf-plan-defined environment
188
+ env_vars = self.fmf_tests.plan_env.copy()
189
+ # append fmf-test-defined environment into it
190
+ for item in fmf.listlike(test_data, "environment"):
191
+ env_vars.update(item)
192
+ # append additional variables typically exported by tmt
193
+ env_vars["TMT_PLAN_ENVIRONMENT_FILE"] = self.plan_env_file
194
+ env_vars["TMT_TEST_NAME"] = test_name
195
+ env_vars["ATEX_TEST_NAME"] = test_name
196
+ # append variables given to this function call
197
+ if env:
198
+ env_vars.update(env)
199
+
200
+ # run a setup script, preparing wrapper + test scripts
201
+ setup_script = scripts.test_setup(
202
+ test=scripts.Test(test_name, test_data, self.fmf_tests.test_dirs[test_name]),
203
+ tests_dir=self.tests_dir,
204
+ wrapper_exec=f"{self.work_dir}/wrapper.sh",
205
+ test_exec=f"{self.work_dir}/test.sh",
206
+ )
207
+ self.conn.cmd(("bash",), input=setup_script, text=True, check=True)
208
+
209
+ with contextlib.ExitStack() as stack:
210
+ reporter = stack.enter_context(Reporter(json_file, files_dir))
211
+ testout_fd = stack.enter_context(reporter.open_tmpfile())
212
+ duration = Duration(test_data.get("duration", "5m"))
213
+
214
+ test_proc = None
215
+ control_fd = None
216
+ stack.callback(lambda: os.close(control_fd) if control_fd else None)
217
+
218
+ reconnects = 0
219
+
220
+ def abort(msg):
221
+ if test_proc:
222
+ test_proc.kill()
223
+ test_proc.wait()
224
+ raise TestAbortedError(msg) from None
225
+
226
+ try:
227
+ # TODO: probably enum
228
+ state = "starting_test"
229
+ while not duration.out_of_time():
230
+ with self.lock:
231
+ if self.cancelled:
232
+ abort("cancel requested")
233
+
234
+ if state == "starting_test":
235
+ control_fd, pipe_w = os.pipe()
236
+ os.set_blocking(control_fd, False)
237
+ control = testcontrol.TestControl(
238
+ control_fd=control_fd,
239
+ reporter=reporter,
240
+ duration=duration,
241
+ testout_fd=testout_fd,
242
+ )
243
+ # reconnect/reboot count (for compatibility)
244
+ env_vars["TMT_REBOOT_COUNT"] = str(reconnects)
245
+ env_vars["TMT_TEST_RESTART_COUNT"] = str(reconnects)
246
+ # run the test in the background, letting it log output directly to
247
+ # an opened file (we don't handle it, cmd client sends it to kernel)
248
+ env_args = (f"{k}={v}" for k, v in env_vars.items())
249
+ test_proc = self.conn.cmd(
250
+ ("env", *env_args, f"{self.work_dir}/wrapper.sh"),
251
+ stdout=pipe_w,
252
+ stderr=testout_fd,
253
+ func=util.subprocess_Popen,
254
+ )
255
+ os.close(pipe_w)
256
+ state = "reading_control"
257
+
258
+ elif state == "reading_control":
259
+ rlist, _, xlist = select.select((control_fd,), (), (control_fd,), 0.1)
260
+ if xlist:
261
+ abort(f"got exceptional condition on control_fd {control_fd}")
262
+ elif rlist:
263
+ control.process()
264
+ if control.eof:
265
+ os.close(control_fd)
266
+ control_fd = None
267
+ state = "waiting_for_exit"
268
+
269
+ elif state == "waiting_for_exit":
270
+ # control stream is EOF and it has nothing for us to read,
271
+ # we're now just waiting for proc to cleanly terminate
272
+ try:
273
+ code = test_proc.wait(0.1)
274
+ if code == 0:
275
+ # wrapper exited cleanly, testing is done
276
+ break
277
+ else:
278
+ # unexpected error happened (crash, disconnect, etc.)
279
+ self.conn.disconnect()
280
+ # if reconnect was requested, do so, otherwise abort
281
+ if control.reconnect:
282
+ state = "reconnecting"
283
+ if control.reconnect != "always":
284
+ control.reconnect = None
285
+ else:
286
+ abort(
287
+ f"test wrapper unexpectedly exited with {code} and "
288
+ "reconnect was not sent via test control",
289
+ )
290
+ test_proc = None
291
+ except subprocess.TimeoutExpired:
292
+ pass
293
+
294
+ elif state == "reconnecting":
295
+ try:
296
+ self.conn.connect(block=False)
297
+ reconnects += 1
298
+ state = "starting_test"
299
+ except BlockingIOError:
300
+ pass
301
+
302
+ else:
303
+ raise AssertionError("reached unexpected state")
304
+
305
+ else:
306
+ abort("test duration timeout reached")
307
+
308
+ # testing successful, do post-testing tasks
309
+
310
+ # test wrapper hasn't provided exitcode
311
+ if control.exit_code is None:
312
+ abort("exitcode not reported, wrapper bug?")
313
+
314
+ # partial results that were never reported
315
+ if control.partial_results:
316
+ for result in control.partial_results.values():
317
+ name = result.get("name")
318
+ if not name:
319
+ # partial result is also a result
320
+ control.nameless_result_seen = True
321
+ if testout := result.get("testout"):
322
+ try:
323
+ reporter.link_tmpfile_to(testout_fd, testout, name)
324
+ except FileExistsError:
325
+ raise testcontrol.BadReportJSONError(
326
+ f"file '{testout}' already exists",
327
+ ) from None
328
+ reporter.report(result)
329
+
330
+ # test hasn't reported a result for itself, add an automatic one
331
+ # as specified in RESULTS.md
332
+ # {"status": "pass", "testout": "output.txt"}
333
+ if not control.nameless_result_seen:
334
+ reporter.link_tmpfile_to(testout_fd, "output.txt")
335
+ reporter.report({
336
+ "status": "pass" if control.exit_code == 0 else "fail",
337
+ "testout": "output.txt",
338
+ })
339
+
340
+ return control.exit_code
341
+
342
+ except Exception:
343
+ # if the test hasn't reported a result for itself, but still
344
+ # managed to break something, provide at least the default log
345
+ # for manual investigation - otherwise test output disappears
346
+ if not control.nameless_result_seen:
347
+ try:
348
+ reporter.link_tmpfile_to(testout_fd, "output.txt")
349
+ reporter.report({
350
+ "status": "infra",
351
+ "testout": "output.txt",
352
+ })
353
+ # in case outout.txt exists as a directory
354
+ except FileExistsError:
355
+ pass
356
+ raise
357
+
358
+
359
+ #__all__ = [
360
+ # info.name for info in _pkgutil.iter_modules(__spec__.submodule_search_locations)
361
+ #]
362
+ #
363
+ #
364
+ #import importlib as _importlib
365
+ #import pkgutil as _pkgutil
366
+ #
367
+ #
368
+ #def __dir__():
369
+ # return __all__
370
+ #
371
+ #
372
+ ## lazily import submodules
373
+ #def __getattr__(attr):
374
+ # # importing a module known to exist
375
+ # if attr in __all__:
376
+ # return _importlib.import_module(f".{attr}", __name__)
377
+ # else:
378
+ # raise AttributeError(f"module '{__name__}' has no attribute '{attr}'")
@@ -0,0 +1,106 @@
1
+ import os
2
+ import json
3
+ import ctypes
4
+ import ctypes.util
5
+ import contextlib
6
+ from pathlib import Path
7
+
8
+ from .. import util
9
+
10
+
11
+ libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
12
+
13
+ # int linkat(int olddirfd, const char *oldpath, int newdirfd, const char *newpath, int flags)
14
+ libc.linkat.argtypes = (
15
+ ctypes.c_int,
16
+ ctypes.c_char_p,
17
+ ctypes.c_int,
18
+ ctypes.c_char_p,
19
+ ctypes.c_int,
20
+ )
21
+ libc.linkat.restype = ctypes.c_int
22
+
23
+ # fcntl.h:#define AT_EMPTY_PATH 0x1000 /* Allow empty relative pathname */
24
+ AT_EMPTY_PATH = 0x1000
25
+
26
+ # fcntl.h:#define AT_FDCWD -100 /* Special value used to indicate
27
+ AT_FDCWD = -100
28
+
29
+
30
+ def linkat(*args):
31
+ if (ret := libc.linkat(*args)) == -1:
32
+ errno = ctypes.get_errno()
33
+ raise OSError(errno, os.strerror(errno))
34
+ return ret
35
+
36
+
37
+ class Reporter:
38
+ """
39
+ Collects reported results (in a format specified by RESULTS.md) for
40
+ a specific test, storing them persistently.
41
+ """
42
+
43
+ def __init__(self, json_file, files_dir):
44
+ """
45
+ 'json_file' is a destination file (string or Path) for results.
46
+
47
+ 'files_dir' is a destination dir (string or Path) for uploaded files.
48
+ """
49
+ self.json_file = json_file
50
+ self.files_dir = Path(files_dir)
51
+ self.json_fobj = None
52
+
53
+ def __enter__(self):
54
+ if self.json_file.exists():
55
+ raise FileExistsError(f"{self.json_file} already exists")
56
+ self.json_fobj = open(self.json_file, "w")
57
+
58
+ if self.files_dir.exists():
59
+ raise FileExistsError(f"{self.files_dir} already exists")
60
+ self.files_dir.mkdir()
61
+
62
+ return self
63
+
64
+ def __exit__(self, exc_type, exc_value, traceback):
65
+ if self.json_fobj:
66
+ self.json_fobj.close()
67
+ self.json_fobj = None
68
+
69
+ def report(self, result_line):
70
+ """
71
+ Persistently record a test result.
72
+
73
+ 'result_line' is a dict in the format specified by RESULTS.md.
74
+ """
75
+ json.dump(result_line, self.json_fobj, indent=None)
76
+ self.json_fobj.write("\n")
77
+ self.json_fobj.flush()
78
+
79
+ @contextlib.contextmanager
80
+ def open_tmpfile(self, open_mode=os.O_WRONLY):
81
+ """
82
+ Open an anonymous (name-less) file for writing and yield its file
83
+ descriptor (int) as context, closing it when the context is exited.
84
+ """
85
+ flags = open_mode | os.O_TMPFILE
86
+ fd = os.open(self.files_dir, flags, 0o644)
87
+ try:
88
+ yield fd
89
+ finally:
90
+ os.close(fd)
91
+
92
+ def link_tmpfile_to(self, fd, file_name, result_name=None):
93
+ """
94
+ Store a file named 'file_name' in a directory relevant to 'result_name'
95
+ whose 'fd' (a file descriptor) was created by .open_tmpfile().
96
+
97
+ This function can be called multiple times with the same 'fd', and
98
+ does not close or otherwise alter the descriptor.
99
+
100
+ If 'result_name' is not given, link files to the test (name) itself.
101
+ """
102
+ result_name = util.normalize_path(result_name) if result_name else "."
103
+ # /path/to/files_dir / path/to/subresult / path/to/file.log
104
+ file_path = self.files_dir / result_name / util.normalize_path(file_name)
105
+ file_path.parent.mkdir(parents=True, exist_ok=True)
106
+ linkat(fd, b"", AT_FDCWD, bytes(file_path), AT_EMPTY_PATH)
@@ -0,0 +1,155 @@
1
+ import collections
2
+ from pathlib import Path
3
+
4
+ from .. import util, fmf
5
+
6
+ # name: fmf path to the test as string, ie. /some/test
7
+ # data: dict of the parsed fmf metadata (ie. {'tag': ... , 'environment': ...})
8
+ # dir: relative pathlib.Path of the test .fmf to repo root, ie. some/test
9
+ # (may be different from name for "virtual" tests that share the same dir)
10
+ Test = collections.namedtuple("Test", ["name", "data", "dir"])
11
+
12
+
13
+ # NOTE that we split test execution into 3 scripts:
14
+ # - "setup script" (package installs, etc.)
15
+ # - "wrapper script" (runs test script)
16
+ # - "test script" (exact contents of the 'test:' FMF metadata key)
17
+ #
18
+ # this is to allow interactive test execution - the setup script
19
+ # can run in 'bash' via stdin pipe into 'ssh', creating the wrapper
20
+ # script somewhere on the disk, making it executable,
21
+ #
22
+ # then the "wrapper" script can run via a separate 'ssh' execution,
23
+ # passed by an argument to 'ssh', leaving stdin/out/err untouched,
24
+ # allowing the user to interact with it (if run interactively)
25
+
26
+ def test_wrapper(*, test, tests_dir, test_exec):
27
+ """
28
+ Generate a bash script that runs a user-specified test, preparing
29
+ a test control channel for it, and reporting its exit code.
30
+ The script must be as "transparent" as possible, since any output
31
+ is considered as test output and any unintended environment changes
32
+ will impact the test itself.
33
+
34
+ 'test' is a class Test instance.
35
+
36
+ 'test_dir' is a remote directory (repository) of all the tests,
37
+ a.k.a. FMF metadata root.
38
+
39
+ 'test_exec' is a remote path to the actual test to run.
40
+ """
41
+ out = "#!/bin/bash\n"
42
+
43
+ # stdout-over-ssh is used as Test Control (see TEST_CONTROL.md),
44
+ # so duplicate stderr to stdout, and then open a new fd pointing to the
45
+ # original stdout
46
+ out += "exec {orig_stdout}>&1 1>&2\n"
47
+
48
+ # TODO: if interactive, keep original stdin, else exec 0</dev/null ,
49
+ # doing it here avoids unnecessary traffic (reading stdin) via ssh,
50
+ # even if it is fed from subprocess.DEVNULL on the runner
51
+
52
+ if util.in_debug_mode():
53
+ out += "set -x\n"
54
+
55
+ # use a subshell to limit the scope of the CWD change
56
+ out += "(\n"
57
+
58
+ # if TMT_PLAN_ENVIRONMENT_FILE exists, export everything from it
59
+ # (limited by the subshell, so it doesn't leak)
60
+ out += util.dedent("""
61
+ if [[ -f $TMT_PLAN_ENVIRONMENT_FILE ]]; then
62
+ set -o allexport
63
+ . "$TMT_PLAN_ENVIRONMENT_FILE"
64
+ set +o allexport
65
+ fi
66
+ """) + "\n"
67
+
68
+ # TODO: custom PATH with tmt-* style commands?
69
+
70
+ # join the directory with all tests and nested path of our test inside it
71
+ test_cwd = Path(tests_dir) / test.dir
72
+ out += f"cd '{test_cwd}' || exit 1\n"
73
+
74
+ # run the test script
75
+ # - the '-e -o pipefail' is to mimic what full fat tmt uses
76
+ out += (
77
+ "ATEX_TEST_CONTROL=$orig_stdout"
78
+ f" exec -a 'bash: atex running {test.name}'"
79
+ f" bash -e -o pipefail '{test_exec}'\n"
80
+ )
81
+
82
+ # subshell end
83
+ out += ")\n"
84
+
85
+ # write test exitcode to test control stream
86
+ out += "echo exitcode $? >&$orig_stdout\n"
87
+
88
+ # always exit the wrapper with 0 if test execution was normal
89
+ out += "exit 0\n"
90
+
91
+ return out
92
+
93
+
94
+ def _install_packages(pkgs, extra_opts=None):
95
+ pkgs_str = " ".join(pkgs)
96
+ extra_opts = extra_opts or ()
97
+ dnf = ["dnf", "-y", "--setopt=install_weak_deps=False", "install", *extra_opts]
98
+ dnf_str = " ".join(dnf)
99
+ return util.dedent(fr"""
100
+ not_installed=$(rpm -q --qf '' {pkgs_str} | sed -nr 's/^package ([^ ]+) is not installed$/\1/p')
101
+ [[ $not_installed ]] && {dnf_str} $not_installed
102
+ """) # noqa: E501
103
+
104
+
105
+ def test_setup(*, test, wrapper_exec, test_exec, **kwargs):
106
+ """
107
+ Generate a bash script that should prepare the remote end for test
108
+ execution.
109
+
110
+ The bash script itself will (among other things) generate two more bash
111
+ scripts: a test script (contents of 'test' from FMF) and a wrapper script
112
+ to run the test script.
113
+
114
+ 'wrapper_exec' is the remote path where the wrapper script should be put.
115
+
116
+ 'test_exec' is the remote path where the test script should be put.
117
+
118
+ 'test' is a class Test instance.
119
+
120
+ Any 'kwargs' are passed to test_wrapper().
121
+ """
122
+ out = "#!/bin/bash\n"
123
+
124
+ if util.in_debug_mode():
125
+ out += "set -xe\n"
126
+ else:
127
+ out += "exec 1>/dev/null\n"
128
+ out += "set -e\n"
129
+
130
+ # install test dependencies
131
+ # - only strings (package names) in require/recommend are supported
132
+ if require := list(fmf.test_pkg_requires(test.data, "require")):
133
+ out += _install_packages(require) + "\n"
134
+ if recommend := list(fmf.test_pkg_requires(test.data, "recommend")):
135
+ out += _install_packages(recommend, ("--skip-broken",)) + "\n"
136
+
137
+ # make the wrapper script
138
+ out += f"cat > '{wrapper_exec}' <<'ATEX_SETUP_EOF'\n"
139
+ out += test_wrapper(
140
+ test=test,
141
+ test_exec=test_exec,
142
+ **kwargs,
143
+ )
144
+ out += "ATEX_SETUP_EOF\n"
145
+ # make the test script
146
+ out += f"cat > '{test_exec}' <<'ATEX_SETUP_EOF'\n"
147
+ out += test.data["test"]
148
+ out += "\n" # for safety, in case 'test' doesn't have a newline
149
+ out += "ATEX_SETUP_EOF\n"
150
+ # make both executable
151
+ out += f"chmod 0755 '{wrapper_exec}' '{test_exec}'\n"
152
+
153
+ out += "exit 0\n"
154
+
155
+ return out