atex 0.5__py3-none-any.whl → 0.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- atex/__init__.py +2 -12
- atex/cli/__init__.py +13 -13
- atex/cli/fmf.py +93 -0
- atex/cli/testingfarm.py +71 -61
- atex/connection/__init__.py +117 -0
- atex/connection/ssh.py +390 -0
- atex/executor/__init__.py +2 -0
- atex/executor/duration.py +60 -0
- atex/executor/executor.py +378 -0
- atex/executor/reporter.py +106 -0
- atex/executor/scripts.py +155 -0
- atex/executor/testcontrol.py +353 -0
- atex/fmf.py +217 -0
- atex/orchestrator/__init__.py +2 -0
- atex/orchestrator/aggregator.py +106 -0
- atex/orchestrator/orchestrator.py +324 -0
- atex/provision/__init__.py +101 -90
- atex/provision/libvirt/VM_PROVISION +8 -0
- atex/provision/libvirt/__init__.py +4 -4
- atex/provision/podman/README +59 -0
- atex/provision/podman/host_container.sh +74 -0
- atex/provision/testingfarm/__init__.py +2 -0
- atex/{testingfarm.py → provision/testingfarm/api.py} +170 -132
- atex/provision/testingfarm/testingfarm.py +236 -0
- atex/util/__init__.py +5 -10
- atex/util/dedent.py +1 -1
- atex/util/log.py +20 -12
- atex/util/path.py +16 -0
- atex/util/ssh_keygen.py +14 -0
- atex/util/subprocess.py +14 -13
- atex/util/threads.py +55 -0
- {atex-0.5.dist-info → atex-0.8.dist-info}/METADATA +97 -2
- atex-0.8.dist-info/RECORD +37 -0
- atex/cli/minitmt.py +0 -82
- atex/minitmt/__init__.py +0 -115
- atex/minitmt/fmf.py +0 -168
- atex/minitmt/report.py +0 -174
- atex/minitmt/scripts.py +0 -51
- atex/minitmt/testme.py +0 -3
- atex/orchestrator.py +0 -38
- atex/ssh.py +0 -320
- atex/util/lockable_class.py +0 -38
- atex-0.5.dist-info/RECORD +0 -26
- {atex-0.5.dist-info → atex-0.8.dist-info}/WHEEL +0 -0
- {atex-0.5.dist-info → atex-0.8.dist-info}/entry_points.txt +0 -0
- {atex-0.5.dist-info → atex-0.8.dist-info}/licenses/COPYING.txt +0 -0
|
@@ -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)
|
atex/executor/scripts.py
ADDED
|
@@ -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
|