atex 0.7__py3-none-any.whl → 0.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- atex/cli/fmf.py +143 -0
- atex/cli/libvirt.py +127 -0
- atex/cli/testingfarm.py +35 -13
- atex/connection/__init__.py +13 -19
- atex/connection/podman.py +63 -0
- atex/connection/ssh.py +34 -52
- atex/executor/__init__.py +2 -0
- atex/executor/duration.py +60 -0
- atex/executor/executor.py +402 -0
- atex/executor/reporter.py +101 -0
- atex/{minitmt → executor}/scripts.py +37 -25
- atex/{minitmt → executor}/testcontrol.py +54 -42
- atex/fmf.py +237 -0
- atex/orchestrator/__init__.py +3 -59
- atex/orchestrator/aggregator.py +82 -134
- atex/orchestrator/orchestrator.py +385 -0
- atex/provision/__init__.py +74 -105
- atex/provision/libvirt/__init__.py +2 -24
- atex/provision/libvirt/libvirt.py +465 -0
- atex/provision/libvirt/locking.py +168 -0
- atex/provision/libvirt/setup-libvirt.sh +21 -1
- atex/provision/podman/__init__.py +1 -0
- atex/provision/podman/podman.py +274 -0
- atex/provision/testingfarm/__init__.py +2 -29
- atex/provision/testingfarm/api.py +123 -65
- atex/provision/testingfarm/testingfarm.py +234 -0
- atex/util/__init__.py +1 -6
- atex/util/libvirt.py +18 -0
- atex/util/log.py +31 -8
- atex/util/named_mapping.py +158 -0
- atex/util/path.py +16 -0
- atex/util/ssh_keygen.py +14 -0
- atex/util/threads.py +99 -0
- atex-0.9.dist-info/METADATA +178 -0
- atex-0.9.dist-info/RECORD +43 -0
- atex/cli/minitmt.py +0 -175
- atex/minitmt/__init__.py +0 -23
- atex/minitmt/executor.py +0 -348
- atex/minitmt/fmf.py +0 -202
- atex/provision/nspawn/README +0 -74
- atex/provision/podman/README +0 -59
- atex/provision/podman/host_container.sh +0 -74
- atex/provision/testingfarm/foo.py +0 -1
- atex-0.7.dist-info/METADATA +0 -102
- atex-0.7.dist-info/RECORD +0 -32
- {atex-0.7.dist-info → atex-0.9.dist-info}/WHEEL +0 -0
- {atex-0.7.dist-info → atex-0.9.dist-info}/entry_points.txt +0 -0
- {atex-0.7.dist-info → atex-0.9.dist-info}/licenses/COPYING.txt +0 -0
atex/connection/ssh.py
CHANGED
|
@@ -18,6 +18,7 @@ import os
|
|
|
18
18
|
import time
|
|
19
19
|
import shlex
|
|
20
20
|
import tempfile
|
|
21
|
+
import threading
|
|
21
22
|
import subprocess
|
|
22
23
|
from pathlib import Path
|
|
23
24
|
|
|
@@ -38,7 +39,7 @@ DEFAULT_OPTIONS = {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
|
|
41
|
-
class SSHError(
|
|
42
|
+
class SSHError(ConnectionError):
|
|
42
43
|
pass
|
|
43
44
|
|
|
44
45
|
|
|
@@ -68,7 +69,7 @@ def _shell_cmd(command, sudo=None):
|
|
|
68
69
|
"""
|
|
69
70
|
Make a command line for running 'command' on the target system.
|
|
70
71
|
"""
|
|
71
|
-
quoted_args = (shlex.quote(arg) for arg in command)
|
|
72
|
+
quoted_args = (shlex.quote(str(arg)) for arg in command)
|
|
72
73
|
if sudo:
|
|
73
74
|
return " ".join((
|
|
74
75
|
"exec", "sudo", "--no-update", "--non-interactive", "--user", sudo, "--", *quoted_args,
|
|
@@ -154,7 +155,6 @@ class StatelessSSHConn(Connection):
|
|
|
154
155
|
If 'sudo' specifies a username, call sudo(8) on the remote shell
|
|
155
156
|
to run under a different user on the remote host.
|
|
156
157
|
"""
|
|
157
|
-
super().__init__()
|
|
158
158
|
self.options = DEFAULT_OPTIONS.copy()
|
|
159
159
|
self.options.update(options)
|
|
160
160
|
self.password = password
|
|
@@ -162,26 +162,24 @@ class StatelessSSHConn(Connection):
|
|
|
162
162
|
self._tmpdir = None
|
|
163
163
|
self._master_proc = None
|
|
164
164
|
|
|
165
|
-
def connect(self):
|
|
165
|
+
def connect(self, block=True):
|
|
166
166
|
"""
|
|
167
167
|
Optional, .cmd() and .rsync() work without it, but it is provided here
|
|
168
168
|
for compatibility with the Connection API.
|
|
169
169
|
"""
|
|
170
|
-
# TODO: just wait until .cmd(['true']) starts responding
|
|
170
|
+
# TODO: just wait until .cmd(['true']) starts responding ?
|
|
171
171
|
pass
|
|
172
172
|
|
|
173
173
|
def disconnect(self):
|
|
174
174
|
pass
|
|
175
175
|
|
|
176
|
-
# def alive(self):
|
|
177
|
-
# return True
|
|
178
|
-
|
|
179
176
|
# have options as kwarg to be compatible with other functions here
|
|
180
177
|
def cmd(self, command, options=None, func=util.subprocess_run, **func_args):
|
|
181
178
|
unified_options = self.options.copy()
|
|
182
179
|
if options:
|
|
183
180
|
unified_options.update(options)
|
|
184
|
-
|
|
181
|
+
if command:
|
|
182
|
+
unified_options["RemoteCommand"] = _shell_cmd(command, sudo=self.sudo)
|
|
185
183
|
return func(
|
|
186
184
|
_options_to_ssh(unified_options, password=self.password),
|
|
187
185
|
skip_frames=1,
|
|
@@ -231,7 +229,7 @@ class ManagedSSHConn(Connection):
|
|
|
231
229
|
to manage this complexity.
|
|
232
230
|
"""
|
|
233
231
|
|
|
234
|
-
# TODO: thread safety and locking via self.lock
|
|
232
|
+
# TODO: thread safety and locking via self.lock ?
|
|
235
233
|
|
|
236
234
|
def __init__(self, options, *, password=None, sudo=None):
|
|
237
235
|
"""
|
|
@@ -243,7 +241,7 @@ class ManagedSSHConn(Connection):
|
|
|
243
241
|
If 'sudo' specifies a username, call sudo(8) on the remote shell
|
|
244
242
|
to run under a different user on the remote host.
|
|
245
243
|
"""
|
|
246
|
-
|
|
244
|
+
self.lock = threading.RLock()
|
|
247
245
|
self.options = DEFAULT_OPTIONS.copy()
|
|
248
246
|
self.options.update(options)
|
|
249
247
|
self.password = password
|
|
@@ -251,12 +249,6 @@ class ManagedSSHConn(Connection):
|
|
|
251
249
|
self._tmpdir = None
|
|
252
250
|
self._master_proc = None
|
|
253
251
|
|
|
254
|
-
# def __copy__(self):
|
|
255
|
-
# return type(self)(self.options, password=self.password)
|
|
256
|
-
#
|
|
257
|
-
# def copy(self):
|
|
258
|
-
# return self.__copy__()
|
|
259
|
-
|
|
260
252
|
def assert_master(self):
|
|
261
253
|
proc = self._master_proc
|
|
262
254
|
if not proc:
|
|
@@ -272,19 +264,13 @@ class ManagedSSHConn(Connection):
|
|
|
272
264
|
f"SSH ControlMaster on {self._tmpdir} exited with {code}{out}",
|
|
273
265
|
)
|
|
274
266
|
|
|
275
|
-
# def alive(self):
|
|
276
|
-
# try:
|
|
277
|
-
# self.assert_master()
|
|
278
|
-
# return True
|
|
279
|
-
# except (NotConnectedError, DisconnectedError):
|
|
280
|
-
# return False
|
|
281
|
-
|
|
282
267
|
def disconnect(self):
|
|
283
268
|
proc = self._master_proc
|
|
284
269
|
if not proc:
|
|
285
270
|
return
|
|
271
|
+
util.debug(f"disconnecting: {self.options}")
|
|
286
272
|
proc.kill()
|
|
287
|
-
# don
|
|
273
|
+
# don't zombie forever, return EPIPE on any attempts to write to us
|
|
288
274
|
proc.stdout.close()
|
|
289
275
|
proc.wait()
|
|
290
276
|
(self._tmpdir / "control.sock").unlink(missing_ok=True)
|
|
@@ -302,19 +288,26 @@ class ManagedSSHConn(Connection):
|
|
|
302
288
|
sock = self._tmpdir / "control.sock"
|
|
303
289
|
|
|
304
290
|
if not self._master_proc:
|
|
291
|
+
util.debug(f"connecting: {self.options}")
|
|
305
292
|
options = self.options.copy()
|
|
306
293
|
options["SessionType"] = "none"
|
|
307
294
|
options["ControlMaster"] = "yes"
|
|
308
295
|
options["ControlPath"] = sock
|
|
309
296
|
self._master_proc = util.subprocess_Popen(
|
|
310
|
-
_options_to_ssh(options),
|
|
297
|
+
_options_to_ssh(options, password=self.password),
|
|
311
298
|
stdin=subprocess.DEVNULL,
|
|
312
299
|
stdout=subprocess.PIPE,
|
|
313
300
|
stderr=subprocess.STDOUT,
|
|
314
301
|
cwd=str(self._tmpdir),
|
|
302
|
+
start_new_session=True, # resist Ctrl-C
|
|
315
303
|
)
|
|
316
304
|
os.set_blocking(self._master_proc.stdout.fileno(), False)
|
|
317
305
|
|
|
306
|
+
# NOTE: ideally, we would .read() before checking .poll() because
|
|
307
|
+
# if the process writes a lot, it gets stuck in the pipe
|
|
308
|
+
# (in kernel) and the process never ends; but output-appending
|
|
309
|
+
# code would be obscure, and ssh(1) never outputs that much ..
|
|
310
|
+
|
|
318
311
|
proc = self._master_proc
|
|
319
312
|
if block:
|
|
320
313
|
while proc.poll() is None:
|
|
@@ -325,7 +318,6 @@ class ManagedSSHConn(Connection):
|
|
|
325
318
|
code = proc.poll()
|
|
326
319
|
out = proc.stdout.read()
|
|
327
320
|
self._master_proc = None
|
|
328
|
-
# TODO: ConnectError should probably be generalized for Connection
|
|
329
321
|
raise ConnectError(
|
|
330
322
|
f"SSH ControlMaster failed to start on {self._tmpdir} with {code}:\n{out}",
|
|
331
323
|
)
|
|
@@ -334,54 +326,45 @@ class ManagedSSHConn(Connection):
|
|
|
334
326
|
if code is not None:
|
|
335
327
|
out = proc.stdout.read()
|
|
336
328
|
self._master_proc = None
|
|
337
|
-
# TODO: ConnectError should probably be generalized for Connection
|
|
338
329
|
raise ConnectError(
|
|
339
330
|
f"SSH ControlMaster failed to start on {self._tmpdir} with {code}:\n{out}",
|
|
340
331
|
)
|
|
341
332
|
elif not sock.exists():
|
|
342
333
|
raise BlockingIOError("SSH ControlMaster not yet ready")
|
|
343
334
|
|
|
344
|
-
def
|
|
335
|
+
def forward(self, forward_type, *spec, cancel=False):
|
|
345
336
|
"""
|
|
346
337
|
Add (one or more) ssh forwarding specifications as 'spec' to an
|
|
347
338
|
already-connected instance. Each specification has to follow the
|
|
348
|
-
format of
|
|
349
|
-
"""
|
|
350
|
-
self.assert_master()
|
|
351
|
-
options = self.options.copy()
|
|
352
|
-
options["LocalForward"] = spec
|
|
353
|
-
options["ControlPath"] = self._tmpdir / "control.sock"
|
|
354
|
-
util.subprocess_run(
|
|
355
|
-
_options_to_ssh(options, extra_cli_flags=("-O", "forward")),
|
|
356
|
-
skip_frames=1,
|
|
357
|
-
check=True,
|
|
358
|
-
)
|
|
339
|
+
format of LocalForward or RemoteForward (see ssh_config(5)).
|
|
340
|
+
Ie. "1234 1.2.3.4:22" or "0.0.0.0:1234 1.2.3.4:22".
|
|
359
341
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
already-connected instance. Each specification has to follow the
|
|
364
|
-
format of ssh client's RemoteForward option (see ssh_config(5)).
|
|
342
|
+
'forward_type' must be either LocalForward or RemoteForward.
|
|
343
|
+
|
|
344
|
+
If 'cancel' is True, cancel the forwarding instead of adding it.
|
|
365
345
|
"""
|
|
346
|
+
assert forward_type in ("LocalForward", "RemoteForward")
|
|
366
347
|
self.assert_master()
|
|
367
|
-
options =
|
|
368
|
-
options[
|
|
348
|
+
options = DEFAULT_OPTIONS.copy()
|
|
349
|
+
options[forward_type] = spec
|
|
369
350
|
options["ControlPath"] = self._tmpdir / "control.sock"
|
|
351
|
+
action = "forward" if not cancel else "cancel"
|
|
370
352
|
util.subprocess_run(
|
|
371
|
-
_options_to_ssh(options, extra_cli_flags=("-O",
|
|
353
|
+
_options_to_ssh(options, extra_cli_flags=("-O", action)),
|
|
372
354
|
skip_frames=1,
|
|
373
355
|
check=True,
|
|
374
356
|
)
|
|
375
357
|
|
|
376
|
-
def cmd(self, command, options=None, func=util.subprocess_run, **func_args):
|
|
358
|
+
def cmd(self, command, *, options=None, func=util.subprocess_run, **func_args):
|
|
377
359
|
self.assert_master()
|
|
378
360
|
unified_options = self.options.copy()
|
|
379
361
|
if options:
|
|
380
362
|
unified_options.update(options)
|
|
381
|
-
|
|
363
|
+
if command:
|
|
364
|
+
unified_options["RemoteCommand"] = _shell_cmd(command, sudo=self.sudo)
|
|
382
365
|
unified_options["ControlPath"] = self._tmpdir / "control.sock"
|
|
383
366
|
return func(
|
|
384
|
-
_options_to_ssh(unified_options
|
|
367
|
+
_options_to_ssh(unified_options),
|
|
385
368
|
skip_frames=1,
|
|
386
369
|
**func_args,
|
|
387
370
|
)
|
|
@@ -396,7 +379,6 @@ class ManagedSSHConn(Connection):
|
|
|
396
379
|
_rsync_host_cmd(
|
|
397
380
|
*args,
|
|
398
381
|
options=unified_options,
|
|
399
|
-
password=self.password,
|
|
400
382
|
sudo=self.sudo,
|
|
401
383
|
),
|
|
402
384
|
skip_frames=1,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Duration:
|
|
6
|
+
"""
|
|
7
|
+
A helper for parsing, keeping and manipulating test run time based on
|
|
8
|
+
FMF-defined 'duration' attribute.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, fmf_duration):
|
|
12
|
+
"""
|
|
13
|
+
'fmf_duration' is the string specified as 'duration' in FMF metadata.
|
|
14
|
+
"""
|
|
15
|
+
duration = self._fmf_to_seconds(fmf_duration)
|
|
16
|
+
self.end = time.monotonic() + duration
|
|
17
|
+
# keep track of only the first 'save' and the last 'restore',
|
|
18
|
+
# ignore any nested ones (as tracked by '_count')
|
|
19
|
+
self.saved = None
|
|
20
|
+
self.saved_count = 0
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def _fmf_to_seconds(string):
|
|
24
|
+
match = re.fullmatch(r"([0-9]+)([a-z]*)", string)
|
|
25
|
+
if not match:
|
|
26
|
+
raise RuntimeError(f"'duration' has invalid format: {string}")
|
|
27
|
+
length, unit = match.groups()
|
|
28
|
+
if unit == "m":
|
|
29
|
+
return int(length)*60
|
|
30
|
+
elif unit == "h":
|
|
31
|
+
return int(length)*60*60
|
|
32
|
+
elif unit == "d":
|
|
33
|
+
return int(length)*60*60*24
|
|
34
|
+
else:
|
|
35
|
+
return int(length)
|
|
36
|
+
|
|
37
|
+
def set(self, to):
|
|
38
|
+
self.end = time.monotonic() + self._fmf_to_seconds(to)
|
|
39
|
+
|
|
40
|
+
def increment(self, by):
|
|
41
|
+
self.end += self._fmf_to_seconds(by)
|
|
42
|
+
|
|
43
|
+
def decrement(self, by):
|
|
44
|
+
self.end -= self._fmf_to_seconds(by)
|
|
45
|
+
|
|
46
|
+
def save(self):
|
|
47
|
+
if self.saved_count == 0:
|
|
48
|
+
self.saved = self.end - time.monotonic()
|
|
49
|
+
self.saved_count += 1
|
|
50
|
+
|
|
51
|
+
def restore(self):
|
|
52
|
+
if self.saved_count > 1:
|
|
53
|
+
self.saved_count -= 1
|
|
54
|
+
elif self.saved_count == 1:
|
|
55
|
+
self.end = time.monotonic() + self.saved
|
|
56
|
+
self.saved_count = 0
|
|
57
|
+
self.saved = None
|
|
58
|
+
|
|
59
|
+
def out_of_time(self):
|
|
60
|
+
return time.monotonic() > self.end
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import enum
|
|
3
|
+
import time
|
|
4
|
+
import select
|
|
5
|
+
import threading
|
|
6
|
+
import contextlib
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .. import util, fmf
|
|
11
|
+
from . import testcontrol, scripts
|
|
12
|
+
from .duration import Duration
|
|
13
|
+
from .reporter import Reporter
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestAbortedError(Exception):
|
|
17
|
+
"""
|
|
18
|
+
Raised when an infrastructure-related issue happened while running a test.
|
|
19
|
+
"""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Executor:
|
|
24
|
+
"""
|
|
25
|
+
Logic for running tests on a remote system and processing results
|
|
26
|
+
and uploaded files by those tests.
|
|
27
|
+
|
|
28
|
+
tests_repo = "path/to/cloned/tests"
|
|
29
|
+
tests_data = atex.fmf.FMFTests(tests_repo, "/plans/default")
|
|
30
|
+
|
|
31
|
+
with Executor(tests_data, conn) as e:
|
|
32
|
+
e.upload_tests()
|
|
33
|
+
e.plan_prepare()
|
|
34
|
+
Path("output_here").mkdir()
|
|
35
|
+
e.run_test("/some/test", "output_here")
|
|
36
|
+
e.run_test(...)
|
|
37
|
+
e.plan_finish()
|
|
38
|
+
|
|
39
|
+
One Executor instance may be used to run multiple tests sequentially.
|
|
40
|
+
In addition, multiple Executor instances can run in parallel on the same
|
|
41
|
+
host, provided each receives a unique class Connection instance to it.
|
|
42
|
+
|
|
43
|
+
conn.cmd(["mkdir", "-p", "/shared"])
|
|
44
|
+
|
|
45
|
+
with Executor(tests_data, conn, state_dir="/shared") as e:
|
|
46
|
+
e.upload_tests()
|
|
47
|
+
e.plan_prepare()
|
|
48
|
+
|
|
49
|
+
# in parallel (ie. threading or multiprocessing)
|
|
50
|
+
with Executor(tests_data, unique_conn, state_dir="/shared") as e:
|
|
51
|
+
e.run_test(...)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, fmf_tests, connection, *, env=None, state_dir=None):
|
|
55
|
+
"""
|
|
56
|
+
'fmf_tests' is a class FMFTests instance with (discovered) tests.
|
|
57
|
+
|
|
58
|
+
'connection' is a class Connection instance, already fully connected.
|
|
59
|
+
|
|
60
|
+
'env' is a dict of extra environment variables to pass to the
|
|
61
|
+
plan prepare/finish scripts and to all tests.
|
|
62
|
+
|
|
63
|
+
'state_dir' is a string or Path specifying path on the remote system for
|
|
64
|
+
storing additional data, such as tests, execution wrappers, temporary
|
|
65
|
+
plan-exported variables, etc. If left as None, a tmpdir is used.
|
|
66
|
+
"""
|
|
67
|
+
self.lock = threading.RLock()
|
|
68
|
+
self.fmf_tests = fmf_tests
|
|
69
|
+
self.conn = connection
|
|
70
|
+
self.env = env or {}
|
|
71
|
+
self.state_dir = state_dir
|
|
72
|
+
self.work_dir = None
|
|
73
|
+
self.tests_dir = None
|
|
74
|
+
self.plan_env_file = None
|
|
75
|
+
self.cancelled = False
|
|
76
|
+
|
|
77
|
+
def setup(self):
|
|
78
|
+
with self.lock:
|
|
79
|
+
state_dir = self.state_dir
|
|
80
|
+
|
|
81
|
+
# if user defined a state dir, have shared tests, but use per-instance
|
|
82
|
+
# work_dir for test wrappers, etc., identified by this instance's id(),
|
|
83
|
+
# which should be unique as long as this instance exists
|
|
84
|
+
if state_dir:
|
|
85
|
+
state_dir = Path(state_dir)
|
|
86
|
+
work_dir = state_dir / f"atex-{id(self)}"
|
|
87
|
+
self.conn.cmd(("mkdir", work_dir), check=True)
|
|
88
|
+
with self.lock:
|
|
89
|
+
self.tests_dir = state_dir / "tests"
|
|
90
|
+
self.plan_env_file = state_dir / "plan_env"
|
|
91
|
+
self.work_dir = work_dir
|
|
92
|
+
|
|
93
|
+
# else just create a tmpdir
|
|
94
|
+
else:
|
|
95
|
+
tmp_dir = self.conn.cmd(
|
|
96
|
+
# /var is not cleaned up by bootc, /var/tmp is
|
|
97
|
+
("mktemp", "-d", "-p", "/var", "atex-XXXXXXXXXX"),
|
|
98
|
+
func=util.subprocess_output,
|
|
99
|
+
)
|
|
100
|
+
tmp_dir = Path(tmp_dir)
|
|
101
|
+
with self.lock:
|
|
102
|
+
self.tests_dir = tmp_dir / "tests"
|
|
103
|
+
self.plan_env_file = tmp_dir / "plan_env"
|
|
104
|
+
# use the tmpdir as work_dir, avoid extra mkdir over conn
|
|
105
|
+
self.work_dir = tmp_dir
|
|
106
|
+
|
|
107
|
+
# create / truncate the TMT_PLAN_ENVIRONMENT_FILE
|
|
108
|
+
self.conn.cmd(("truncate", "-s", "0", self.plan_env_file), check=True)
|
|
109
|
+
|
|
110
|
+
def cleanup(self):
|
|
111
|
+
with self.lock:
|
|
112
|
+
work_dir = self.work_dir
|
|
113
|
+
|
|
114
|
+
if work_dir:
|
|
115
|
+
self.conn.cmd(("rm", "-rf", work_dir), check=True)
|
|
116
|
+
|
|
117
|
+
with self.lock:
|
|
118
|
+
self.work_dir = None
|
|
119
|
+
self.tests_dir = None
|
|
120
|
+
self.plan_env_file = None
|
|
121
|
+
|
|
122
|
+
def __enter__(self):
|
|
123
|
+
try:
|
|
124
|
+
self.setup()
|
|
125
|
+
return self
|
|
126
|
+
except Exception:
|
|
127
|
+
self.cleanup()
|
|
128
|
+
raise
|
|
129
|
+
|
|
130
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
131
|
+
self.cleanup()
|
|
132
|
+
|
|
133
|
+
def cancel(self):
|
|
134
|
+
with self.lock:
|
|
135
|
+
self.cancelled = True
|
|
136
|
+
|
|
137
|
+
def upload_tests(self):
|
|
138
|
+
"""
|
|
139
|
+
Upload a directory of all tests, the location of which was provided to
|
|
140
|
+
__init__() inside 'fmf_tests', to the remote host.
|
|
141
|
+
"""
|
|
142
|
+
self.conn.rsync(
|
|
143
|
+
"-rv" if util.in_debug_mode() else "-rq",
|
|
144
|
+
"--delete", "--exclude=.git/",
|
|
145
|
+
f"{self.fmf_tests.root}/",
|
|
146
|
+
f"remote:{self.tests_dir}",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def _run_prepare_scripts(self, scripts):
|
|
150
|
+
# make envionment for 'prepare' scripts
|
|
151
|
+
env = {
|
|
152
|
+
**self.fmf_tests.plan_env,
|
|
153
|
+
**self.env,
|
|
154
|
+
"TMT_PLAN_ENVIRONMENT_FILE": self.plan_env_file,
|
|
155
|
+
}
|
|
156
|
+
env_args = (f"{k}={v}" for k, v in env.items())
|
|
157
|
+
# run the scripts
|
|
158
|
+
for script in scripts:
|
|
159
|
+
self.conn.cmd(
|
|
160
|
+
("env", *env_args, "bash"),
|
|
161
|
+
input=script,
|
|
162
|
+
text=True,
|
|
163
|
+
check=True,
|
|
164
|
+
stdout=None if util.in_debug_mode() else subprocess.DEVNULL,
|
|
165
|
+
stderr=subprocess.STDOUT,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def plan_prepare(self):
|
|
169
|
+
"""
|
|
170
|
+
Install packages and run scripts extracted from a TMT plan by a FMFTests
|
|
171
|
+
instance given during class initialization.
|
|
172
|
+
|
|
173
|
+
Also run additional scripts specified under the 'prepare' step inside
|
|
174
|
+
the fmf metadata of a plan.
|
|
175
|
+
"""
|
|
176
|
+
# install packages from the plan
|
|
177
|
+
if self.fmf_tests.prepare_pkgs:
|
|
178
|
+
self.conn.cmd(
|
|
179
|
+
(
|
|
180
|
+
"dnf", "-y", "--setopt=install_weak_deps=False",
|
|
181
|
+
"install", *self.fmf_tests.prepare_pkgs,
|
|
182
|
+
),
|
|
183
|
+
check=True,
|
|
184
|
+
stdout=None if util.in_debug_mode() else subprocess.DEVNULL,
|
|
185
|
+
stderr=subprocess.STDOUT,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# run 'prepare' scripts from the plan
|
|
189
|
+
if scripts := self.fmf_tests.prepare_scripts:
|
|
190
|
+
self._run_prepare_scripts(scripts)
|
|
191
|
+
|
|
192
|
+
def plan_finish(self):
|
|
193
|
+
"""
|
|
194
|
+
Run any scripts specified under the 'finish' step inside
|
|
195
|
+
the fmf metadata of a plan.
|
|
196
|
+
"""
|
|
197
|
+
if scripts := self.fmf_tests.finish_scripts:
|
|
198
|
+
self._run_prepare_scripts(scripts)
|
|
199
|
+
|
|
200
|
+
class State(enum.Enum):
|
|
201
|
+
STARTING_TEST = enum.auto()
|
|
202
|
+
READING_CONTROL = enum.auto()
|
|
203
|
+
WAITING_FOR_EXIT = enum.auto()
|
|
204
|
+
RECONNECTING = enum.auto()
|
|
205
|
+
|
|
206
|
+
def run_test(self, test_name, output_dir, *, env=None):
|
|
207
|
+
"""
|
|
208
|
+
Run one test on the remote system.
|
|
209
|
+
|
|
210
|
+
'test_name' is a string with test name.
|
|
211
|
+
|
|
212
|
+
'output_dir' is a destination dir (string or Path) for results reported
|
|
213
|
+
and files uploaded by the test. Results are always stored in a line-JSON
|
|
214
|
+
format in a file named 'results', files are always uploaded to directory
|
|
215
|
+
named 'files', both inside 'output_dir'.
|
|
216
|
+
The path for 'output_dir' must already exist and be an empty directory
|
|
217
|
+
(ie. typically a tmpdir).
|
|
218
|
+
|
|
219
|
+
'env' is a dict of extra environment variables to pass to the test.
|
|
220
|
+
|
|
221
|
+
Returns an integer exit code of the test script.
|
|
222
|
+
"""
|
|
223
|
+
output_dir = Path(output_dir)
|
|
224
|
+
test_data = self.fmf_tests.tests[test_name]
|
|
225
|
+
|
|
226
|
+
# run a setup script, preparing wrapper + test scripts
|
|
227
|
+
setup_script = scripts.test_setup(
|
|
228
|
+
test=scripts.Test(test_name, test_data, self.fmf_tests.test_dirs[test_name]),
|
|
229
|
+
tests_dir=self.tests_dir,
|
|
230
|
+
wrapper_exec=f"{self.work_dir}/wrapper.sh",
|
|
231
|
+
test_exec=f"{self.work_dir}/test.sh",
|
|
232
|
+
test_yaml=f"{self.work_dir}/metadata.yaml",
|
|
233
|
+
)
|
|
234
|
+
self.conn.cmd(("bash",), input=setup_script, text=True, check=True)
|
|
235
|
+
|
|
236
|
+
# start with fmf-plan-defined environment
|
|
237
|
+
env_vars = {
|
|
238
|
+
**self.fmf_tests.plan_env,
|
|
239
|
+
"TMT_PLAN_ENVIRONMENT_FILE": self.plan_env_file,
|
|
240
|
+
"TMT_TEST_NAME": test_name,
|
|
241
|
+
"TMT_TEST_METADATA": f"{self.work_dir}/metadata.yaml",
|
|
242
|
+
}
|
|
243
|
+
# append fmf-test-defined environment into it
|
|
244
|
+
for item in fmf.listlike(test_data, "environment"):
|
|
245
|
+
env_vars.update(item)
|
|
246
|
+
# append the Executor-wide environment passed to __init__()
|
|
247
|
+
env_vars.update(self.env)
|
|
248
|
+
# append variables given to this function call
|
|
249
|
+
if env:
|
|
250
|
+
env_vars.update(env)
|
|
251
|
+
|
|
252
|
+
with contextlib.ExitStack() as stack:
|
|
253
|
+
reporter = stack.enter_context(Reporter(output_dir, "results", "files"))
|
|
254
|
+
duration = Duration(test_data.get("duration", "5m"))
|
|
255
|
+
control = testcontrol.TestControl(reporter=reporter, duration=duration)
|
|
256
|
+
|
|
257
|
+
test_proc = None
|
|
258
|
+
control_fd = None
|
|
259
|
+
stack.callback(lambda: os.close(control_fd) if control_fd else None)
|
|
260
|
+
|
|
261
|
+
reconnects = 0
|
|
262
|
+
|
|
263
|
+
def abort(msg):
|
|
264
|
+
if test_proc:
|
|
265
|
+
test_proc.kill()
|
|
266
|
+
test_proc.wait()
|
|
267
|
+
raise TestAbortedError(msg) from None
|
|
268
|
+
|
|
269
|
+
exception = None
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
state = self.State.STARTING_TEST
|
|
273
|
+
while not duration.out_of_time():
|
|
274
|
+
with self.lock:
|
|
275
|
+
if self.cancelled:
|
|
276
|
+
abort("cancel requested")
|
|
277
|
+
|
|
278
|
+
if state == self.State.STARTING_TEST:
|
|
279
|
+
control_fd, pipe_w = os.pipe()
|
|
280
|
+
os.set_blocking(control_fd, False)
|
|
281
|
+
control.reassign(control_fd)
|
|
282
|
+
# reconnect/reboot count (for compatibility)
|
|
283
|
+
env_vars["TMT_REBOOT_COUNT"] = str(reconnects)
|
|
284
|
+
env_vars["TMT_TEST_RESTART_COUNT"] = str(reconnects)
|
|
285
|
+
# run the test in the background, letting it log output directly to
|
|
286
|
+
# an opened file (we don't handle it, cmd client sends it to kernel)
|
|
287
|
+
env_args = (f"{k}={v}" for k, v in env_vars.items())
|
|
288
|
+
test_proc = self.conn.cmd(
|
|
289
|
+
("env", *env_args, f"{self.work_dir}/wrapper.sh"),
|
|
290
|
+
stdout=pipe_w,
|
|
291
|
+
stderr=reporter.testout_fobj.fileno(),
|
|
292
|
+
func=util.subprocess_Popen,
|
|
293
|
+
)
|
|
294
|
+
os.close(pipe_w)
|
|
295
|
+
state = self.State.READING_CONTROL
|
|
296
|
+
|
|
297
|
+
elif state == self.State.READING_CONTROL:
|
|
298
|
+
rlist, _, xlist = select.select((control_fd,), (), (control_fd,), 0.1)
|
|
299
|
+
if xlist:
|
|
300
|
+
abort(f"got exceptional condition on control_fd {control_fd}")
|
|
301
|
+
elif rlist:
|
|
302
|
+
control.process()
|
|
303
|
+
if control.eof:
|
|
304
|
+
os.close(control_fd)
|
|
305
|
+
control_fd = None
|
|
306
|
+
state = self.State.WAITING_FOR_EXIT
|
|
307
|
+
|
|
308
|
+
elif state == self.State.WAITING_FOR_EXIT:
|
|
309
|
+
# control stream is EOF and it has nothing for us to read,
|
|
310
|
+
# we're now just waiting for proc to cleanly terminate
|
|
311
|
+
try:
|
|
312
|
+
code = test_proc.wait(0.1)
|
|
313
|
+
if code == 0:
|
|
314
|
+
# wrapper exited cleanly, testing is done
|
|
315
|
+
break
|
|
316
|
+
else:
|
|
317
|
+
# unexpected error happened (crash, disconnect, etc.)
|
|
318
|
+
self.conn.disconnect()
|
|
319
|
+
# if reconnect was requested, do so, otherwise abort
|
|
320
|
+
if control.reconnect:
|
|
321
|
+
state = self.State.RECONNECTING
|
|
322
|
+
if control.reconnect != "always":
|
|
323
|
+
control.reconnect = None
|
|
324
|
+
else:
|
|
325
|
+
abort(
|
|
326
|
+
f"test wrapper unexpectedly exited with {code} and "
|
|
327
|
+
"reconnect was not sent via test control",
|
|
328
|
+
)
|
|
329
|
+
test_proc = None
|
|
330
|
+
except subprocess.TimeoutExpired:
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
elif state == self.State.RECONNECTING:
|
|
334
|
+
try:
|
|
335
|
+
self.conn.connect(block=False)
|
|
336
|
+
reconnects += 1
|
|
337
|
+
state = self.State.STARTING_TEST
|
|
338
|
+
except BlockingIOError:
|
|
339
|
+
pass
|
|
340
|
+
except ConnectionError:
|
|
341
|
+
# can happen when ie. ssh is connecting over a LocalForward port,
|
|
342
|
+
# causing 'read: Connection reset by peer' instead of timeout
|
|
343
|
+
# - just retry again after a short delay
|
|
344
|
+
time.sleep(0.5)
|
|
345
|
+
|
|
346
|
+
else:
|
|
347
|
+
raise AssertionError("reached unexpected state")
|
|
348
|
+
|
|
349
|
+
else:
|
|
350
|
+
abort("test duration timeout reached")
|
|
351
|
+
|
|
352
|
+
# testing successful
|
|
353
|
+
|
|
354
|
+
# test wrapper hasn't provided exitcode
|
|
355
|
+
if control.exit_code is None:
|
|
356
|
+
abort("exitcode not reported, wrapper bug?")
|
|
357
|
+
|
|
358
|
+
return control.exit_code
|
|
359
|
+
|
|
360
|
+
except Exception as e:
|
|
361
|
+
exception = e
|
|
362
|
+
raise
|
|
363
|
+
|
|
364
|
+
finally:
|
|
365
|
+
# partial results that were never reported
|
|
366
|
+
if control.partial_results:
|
|
367
|
+
for result in control.partial_results.values():
|
|
368
|
+
name = result.get("name")
|
|
369
|
+
if not name:
|
|
370
|
+
# partial result is also a result
|
|
371
|
+
control.nameless_result_seen = True
|
|
372
|
+
if testout := result.get("testout"):
|
|
373
|
+
try:
|
|
374
|
+
reporter.link_testout(testout, name)
|
|
375
|
+
except FileExistsError:
|
|
376
|
+
raise testcontrol.BadReportJSONError(
|
|
377
|
+
f"file '{testout}' already exists",
|
|
378
|
+
) from None
|
|
379
|
+
reporter.report(result)
|
|
380
|
+
|
|
381
|
+
# if an unexpected infrastructure-related exception happened
|
|
382
|
+
if exception:
|
|
383
|
+
try:
|
|
384
|
+
reporter.link_testout("output.txt")
|
|
385
|
+
except FileExistsError:
|
|
386
|
+
pass
|
|
387
|
+
reporter.report({
|
|
388
|
+
"status": "infra",
|
|
389
|
+
"note": repr(exception),
|
|
390
|
+
"testout": "output.txt",
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
# if the test hasn't reported a result for itself
|
|
394
|
+
elif not control.nameless_result_seen:
|
|
395
|
+
try:
|
|
396
|
+
reporter.link_testout("output.txt")
|
|
397
|
+
except FileExistsError:
|
|
398
|
+
pass
|
|
399
|
+
reporter.report({
|
|
400
|
+
"status": "pass" if control.exit_code == 0 else "fail",
|
|
401
|
+
"testout": "output.txt",
|
|
402
|
+
})
|