atex 0.8__py3-none-any.whl → 0.10__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/aggregator/__init__.py +60 -0
- atex/aggregator/json.py +96 -0
- atex/cli/__init__.py +11 -1
- atex/cli/fmf.py +73 -23
- atex/cli/libvirt.py +128 -0
- atex/cli/testingfarm.py +60 -3
- atex/connection/__init__.py +13 -11
- atex/connection/podman.py +61 -0
- atex/connection/ssh.py +38 -47
- atex/executor/executor.py +144 -119
- atex/executor/reporter.py +66 -71
- atex/executor/scripts.py +13 -5
- atex/executor/testcontrol.py +43 -30
- atex/fmf.py +94 -74
- atex/orchestrator/__init__.py +76 -2
- atex/orchestrator/adhoc.py +465 -0
- atex/{provision → provisioner}/__init__.py +54 -42
- atex/provisioner/libvirt/__init__.py +2 -0
- atex/provisioner/libvirt/libvirt.py +472 -0
- atex/provisioner/libvirt/locking.py +170 -0
- atex/{provision → provisioner}/libvirt/setup-libvirt.sh +21 -1
- atex/provisioner/podman/__init__.py +2 -0
- atex/provisioner/podman/podman.py +169 -0
- atex/{provision → provisioner}/testingfarm/api.py +121 -69
- atex/{provision → provisioner}/testingfarm/testingfarm.py +44 -52
- atex/util/libvirt.py +18 -0
- atex/util/log.py +53 -43
- atex/util/named_mapping.py +158 -0
- atex/util/subprocess.py +46 -12
- atex/util/threads.py +71 -20
- atex-0.10.dist-info/METADATA +86 -0
- atex-0.10.dist-info/RECORD +44 -0
- atex/orchestrator/aggregator.py +0 -106
- atex/orchestrator/orchestrator.py +0 -324
- atex/provision/libvirt/__init__.py +0 -24
- atex/provision/podman/README +0 -59
- atex/provision/podman/host_container.sh +0 -74
- atex-0.8.dist-info/METADATA +0 -197
- atex-0.8.dist-info/RECORD +0 -37
- /atex/{provision → provisioner}/libvirt/VM_PROVISION +0 -0
- /atex/{provision → provisioner}/testingfarm/__init__.py +0 -0
- {atex-0.8.dist-info → atex-0.10.dist-info}/WHEEL +0 -0
- {atex-0.8.dist-info → atex-0.10.dist-info}/entry_points.txt +0 -0
- {atex-0.8.dist-info → atex-0.10.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
|
|
|
@@ -132,16 +133,16 @@ def _rsync_host_cmd(*args, options, password=None, sudo=None):
|
|
|
132
133
|
)
|
|
133
134
|
|
|
134
135
|
|
|
135
|
-
class
|
|
136
|
+
class StatelessSSHConnection(Connection):
|
|
136
137
|
"""
|
|
137
138
|
Implements the Connection API using a ssh(1) client using "standalone"
|
|
138
139
|
(stateless) logic - connect() and disconnect() are no-op, .cmd() simply
|
|
139
140
|
executes the ssh client and .rsync() executes 'rsync -e ssh'.
|
|
140
141
|
|
|
141
|
-
Compared to
|
|
142
|
+
Compared to ManagedSSHConnection, this may be slow for many .cmd() calls,
|
|
142
143
|
but every call is stateless, there is no persistent connection.
|
|
143
144
|
|
|
144
|
-
If you need only one .cmd(), this will be faster than
|
|
145
|
+
If you need only one .cmd(), this will be faster than ManagedSSHConnection.
|
|
145
146
|
"""
|
|
146
147
|
|
|
147
148
|
def __init__(self, options, *, password=None, sudo=None):
|
|
@@ -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,7 +162,7 @@ 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.
|
|
@@ -178,10 +178,10 @@ class StatelessSSHConn(Connection):
|
|
|
178
178
|
unified_options = self.options.copy()
|
|
179
179
|
if options:
|
|
180
180
|
unified_options.update(options)
|
|
181
|
-
|
|
181
|
+
if command:
|
|
182
|
+
unified_options["RemoteCommand"] = _shell_cmd(command, sudo=self.sudo)
|
|
182
183
|
return func(
|
|
183
184
|
_options_to_ssh(unified_options, password=self.password),
|
|
184
|
-
skip_frames=1,
|
|
185
185
|
**func_args,
|
|
186
186
|
)
|
|
187
187
|
|
|
@@ -196,7 +196,6 @@ class StatelessSSHConn(Connection):
|
|
|
196
196
|
password=self.password,
|
|
197
197
|
sudo=self.sudo,
|
|
198
198
|
),
|
|
199
|
-
skip_frames=1,
|
|
200
199
|
check=True,
|
|
201
200
|
stdin=subprocess.DEVNULL,
|
|
202
201
|
**func_args,
|
|
@@ -215,17 +214,15 @@ class StatelessSSHConn(Connection):
|
|
|
215
214
|
# checks .assert_master() and manually signals the running clients
|
|
216
215
|
# when it gets DisconnectedError from it.
|
|
217
216
|
|
|
218
|
-
class
|
|
217
|
+
class ManagedSSHConnection(Connection):
|
|
219
218
|
"""
|
|
220
219
|
Implements the Connection API using one persistently-running ssh(1) client
|
|
221
220
|
started in a 'ControlMaster' mode, with additional ssh clients using that
|
|
222
221
|
session to execute remote commands. Similarly, .rsync() uses it too.
|
|
223
222
|
|
|
224
|
-
This is much faster than
|
|
225
|
-
but contains a complex internal state (what if ControlMaster
|
|
226
|
-
|
|
227
|
-
Hence why this implementation provides extra non-standard-Connection methods
|
|
228
|
-
to manage this complexity.
|
|
223
|
+
This is much faster than StatelessSSHConnection when executing multiple
|
|
224
|
+
commands, but contains a complex internal state (what if ControlMaster
|
|
225
|
+
disconnects?).
|
|
229
226
|
"""
|
|
230
227
|
|
|
231
228
|
# TODO: thread safety and locking via self.lock ?
|
|
@@ -240,7 +237,7 @@ class ManagedSSHConn(Connection):
|
|
|
240
237
|
If 'sudo' specifies a username, call sudo(8) on the remote shell
|
|
241
238
|
to run under a different user on the remote host.
|
|
242
239
|
"""
|
|
243
|
-
|
|
240
|
+
self.lock = threading.RLock()
|
|
244
241
|
self.options = DEFAULT_OPTIONS.copy()
|
|
245
242
|
self.options.update(options)
|
|
246
243
|
self.password = password
|
|
@@ -267,8 +264,9 @@ class ManagedSSHConn(Connection):
|
|
|
267
264
|
proc = self._master_proc
|
|
268
265
|
if not proc:
|
|
269
266
|
return
|
|
267
|
+
util.debug(f"disconnecting: {self.options}")
|
|
270
268
|
proc.kill()
|
|
271
|
-
# don
|
|
269
|
+
# don't zombie forever, return EPIPE on any attempts to write to us
|
|
272
270
|
proc.stdout.close()
|
|
273
271
|
proc.wait()
|
|
274
272
|
(self._tmpdir / "control.sock").unlink(missing_ok=True)
|
|
@@ -286,19 +284,26 @@ class ManagedSSHConn(Connection):
|
|
|
286
284
|
sock = self._tmpdir / "control.sock"
|
|
287
285
|
|
|
288
286
|
if not self._master_proc:
|
|
287
|
+
util.debug(f"connecting: {self.options}")
|
|
289
288
|
options = self.options.copy()
|
|
290
289
|
options["SessionType"] = "none"
|
|
291
290
|
options["ControlMaster"] = "yes"
|
|
292
291
|
options["ControlPath"] = sock
|
|
293
292
|
self._master_proc = util.subprocess_Popen(
|
|
294
|
-
_options_to_ssh(options),
|
|
293
|
+
_options_to_ssh(options, password=self.password),
|
|
295
294
|
stdin=subprocess.DEVNULL,
|
|
296
295
|
stdout=subprocess.PIPE,
|
|
297
296
|
stderr=subprocess.STDOUT,
|
|
298
297
|
cwd=str(self._tmpdir),
|
|
298
|
+
start_new_session=True, # resist Ctrl-C
|
|
299
299
|
)
|
|
300
300
|
os.set_blocking(self._master_proc.stdout.fileno(), False)
|
|
301
301
|
|
|
302
|
+
# NOTE: ideally, we would .read() before checking .poll() because
|
|
303
|
+
# if the process writes a lot, it gets stuck in the pipe
|
|
304
|
+
# (in kernel) and the process never ends; but output-appending
|
|
305
|
+
# code would be obscure, and ssh(1) never outputs that much ..
|
|
306
|
+
|
|
302
307
|
proc = self._master_proc
|
|
303
308
|
if block:
|
|
304
309
|
while proc.poll() is None:
|
|
@@ -309,7 +314,6 @@ class ManagedSSHConn(Connection):
|
|
|
309
314
|
code = proc.poll()
|
|
310
315
|
out = proc.stdout.read()
|
|
311
316
|
self._master_proc = None
|
|
312
|
-
# TODO: ConnectError should probably be generalized for Connection
|
|
313
317
|
raise ConnectError(
|
|
314
318
|
f"SSH ControlMaster failed to start on {self._tmpdir} with {code}:\n{out}",
|
|
315
319
|
)
|
|
@@ -318,55 +322,44 @@ class ManagedSSHConn(Connection):
|
|
|
318
322
|
if code is not None:
|
|
319
323
|
out = proc.stdout.read()
|
|
320
324
|
self._master_proc = None
|
|
321
|
-
# TODO: ConnectError should probably be generalized for Connection
|
|
322
325
|
raise ConnectError(
|
|
323
326
|
f"SSH ControlMaster failed to start on {self._tmpdir} with {code}:\n{out}",
|
|
324
327
|
)
|
|
325
328
|
elif not sock.exists():
|
|
326
329
|
raise BlockingIOError("SSH ControlMaster not yet ready")
|
|
327
330
|
|
|
328
|
-
def
|
|
331
|
+
def forward(self, forward_type, *spec, cancel=False):
|
|
329
332
|
"""
|
|
330
333
|
Add (one or more) ssh forwarding specifications as 'spec' to an
|
|
331
334
|
already-connected instance. Each specification has to follow the
|
|
332
|
-
format of
|
|
333
|
-
"""
|
|
334
|
-
self.assert_master()
|
|
335
|
-
options = self.options.copy()
|
|
336
|
-
options["LocalForward"] = spec
|
|
337
|
-
options["ControlPath"] = self._tmpdir / "control.sock"
|
|
338
|
-
util.subprocess_run(
|
|
339
|
-
_options_to_ssh(options, extra_cli_flags=("-O", "forward")),
|
|
340
|
-
skip_frames=1,
|
|
341
|
-
check=True,
|
|
342
|
-
)
|
|
335
|
+
format of LocalForward or RemoteForward (see ssh_config(5)).
|
|
336
|
+
Ie. "1234 1.2.3.4:22" or "0.0.0.0:1234 1.2.3.4:22".
|
|
343
337
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
already-connected instance. Each specification has to follow the
|
|
348
|
-
format of ssh client's RemoteForward option (see ssh_config(5)).
|
|
338
|
+
'forward_type' must be either LocalForward or RemoteForward.
|
|
339
|
+
|
|
340
|
+
If 'cancel' is True, cancel the forwarding instead of adding it.
|
|
349
341
|
"""
|
|
342
|
+
assert forward_type in ("LocalForward", "RemoteForward")
|
|
350
343
|
self.assert_master()
|
|
351
|
-
options =
|
|
352
|
-
options[
|
|
344
|
+
options = DEFAULT_OPTIONS.copy()
|
|
345
|
+
options[forward_type] = spec
|
|
353
346
|
options["ControlPath"] = self._tmpdir / "control.sock"
|
|
347
|
+
action = "forward" if not cancel else "cancel"
|
|
354
348
|
util.subprocess_run(
|
|
355
|
-
_options_to_ssh(options, extra_cli_flags=("-O",
|
|
356
|
-
skip_frames=1,
|
|
349
|
+
_options_to_ssh(options, extra_cli_flags=("-O", action)),
|
|
357
350
|
check=True,
|
|
358
351
|
)
|
|
359
352
|
|
|
360
|
-
def cmd(self, command, options=None, func=util.subprocess_run, **func_args):
|
|
353
|
+
def cmd(self, command, *, options=None, func=util.subprocess_run, **func_args):
|
|
361
354
|
self.assert_master()
|
|
362
355
|
unified_options = self.options.copy()
|
|
363
356
|
if options:
|
|
364
357
|
unified_options.update(options)
|
|
365
|
-
|
|
358
|
+
if command:
|
|
359
|
+
unified_options["RemoteCommand"] = _shell_cmd(command, sudo=self.sudo)
|
|
366
360
|
unified_options["ControlPath"] = self._tmpdir / "control.sock"
|
|
367
361
|
return func(
|
|
368
|
-
_options_to_ssh(unified_options
|
|
369
|
-
skip_frames=1,
|
|
362
|
+
_options_to_ssh(unified_options),
|
|
370
363
|
**func_args,
|
|
371
364
|
)
|
|
372
365
|
|
|
@@ -380,10 +373,8 @@ class ManagedSSHConn(Connection):
|
|
|
380
373
|
_rsync_host_cmd(
|
|
381
374
|
*args,
|
|
382
375
|
options=unified_options,
|
|
383
|
-
password=self.password,
|
|
384
376
|
sudo=self.sudo,
|
|
385
377
|
),
|
|
386
|
-
skip_frames=1,
|
|
387
378
|
check=True,
|
|
388
379
|
stdin=subprocess.DEVNULL,
|
|
389
380
|
**func_args,
|
atex/executor/executor.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import enum
|
|
3
|
+
import time
|
|
2
4
|
import select
|
|
3
5
|
import threading
|
|
4
6
|
import contextlib
|
|
@@ -24,13 +26,15 @@ class Executor:
|
|
|
24
26
|
and uploaded files by those tests.
|
|
25
27
|
|
|
26
28
|
tests_repo = "path/to/cloned/tests"
|
|
27
|
-
|
|
29
|
+
fmf_tests = atex.fmf.FMFTests(tests_repo, "/plans/default")
|
|
28
30
|
|
|
29
|
-
with Executor(
|
|
30
|
-
e.upload_tests(
|
|
31
|
-
e.
|
|
32
|
-
|
|
31
|
+
with Executor(fmf_tests, conn) as e:
|
|
32
|
+
e.upload_tests()
|
|
33
|
+
e.plan_prepare()
|
|
34
|
+
Path("output_here").mkdir()
|
|
35
|
+
e.run_test("/some/test", "output_here")
|
|
33
36
|
e.run_test(...)
|
|
37
|
+
e.plan_finish()
|
|
34
38
|
|
|
35
39
|
One Executor instance may be used to run multiple tests sequentially.
|
|
36
40
|
In addition, multiple Executor instances can run in parallel on the same
|
|
@@ -38,35 +42,39 @@ class Executor:
|
|
|
38
42
|
|
|
39
43
|
conn.cmd(["mkdir", "-p", "/shared"])
|
|
40
44
|
|
|
41
|
-
with Executor(
|
|
42
|
-
e.upload_tests(
|
|
43
|
-
e.
|
|
45
|
+
with Executor(fmf_tests, conn, state_dir="/shared") as e:
|
|
46
|
+
e.upload_tests()
|
|
47
|
+
e.plan_prepare()
|
|
44
48
|
|
|
45
49
|
# in parallel (ie. threading or multiprocessing)
|
|
46
|
-
with Executor(
|
|
50
|
+
with Executor(fmf_tests, unique_conn, state_dir="/shared") as e:
|
|
47
51
|
e.run_test(...)
|
|
48
52
|
"""
|
|
49
53
|
|
|
50
|
-
def __init__(self, fmf_tests, connection, *, state_dir=None):
|
|
54
|
+
def __init__(self, fmf_tests, connection, *, env=None, state_dir=None):
|
|
51
55
|
"""
|
|
52
56
|
'fmf_tests' is a class FMFTests instance with (discovered) tests.
|
|
53
57
|
|
|
54
58
|
'connection' is a class Connection instance, already fully connected.
|
|
55
59
|
|
|
60
|
+
'env' is a dict of extra environment variables to pass to the
|
|
61
|
+
plan prepare/finish scripts and to all tests.
|
|
62
|
+
|
|
56
63
|
'state_dir' is a string or Path specifying path on the remote system for
|
|
57
64
|
storing additional data, such as tests, execution wrappers, temporary
|
|
58
65
|
plan-exported variables, etc. If left as None, a tmpdir is used.
|
|
59
66
|
"""
|
|
60
67
|
self.lock = threading.RLock()
|
|
61
|
-
self.conn = connection
|
|
62
68
|
self.fmf_tests = fmf_tests
|
|
69
|
+
self.conn = connection
|
|
70
|
+
self.env = env or {}
|
|
63
71
|
self.state_dir = state_dir
|
|
64
72
|
self.work_dir = None
|
|
65
73
|
self.tests_dir = None
|
|
66
74
|
self.plan_env_file = None
|
|
67
75
|
self.cancelled = False
|
|
68
76
|
|
|
69
|
-
def
|
|
77
|
+
def start(self):
|
|
70
78
|
with self.lock:
|
|
71
79
|
state_dir = self.state_dir
|
|
72
80
|
|
|
@@ -96,7 +104,10 @@ class Executor:
|
|
|
96
104
|
# use the tmpdir as work_dir, avoid extra mkdir over conn
|
|
97
105
|
self.work_dir = tmp_dir
|
|
98
106
|
|
|
99
|
-
|
|
107
|
+
# create / truncate the TMT_PLAN_ENVIRONMENT_FILE
|
|
108
|
+
self.conn.cmd(("truncate", "-s", "0", self.plan_env_file), check=True)
|
|
109
|
+
|
|
110
|
+
def stop(self):
|
|
100
111
|
with self.lock:
|
|
101
112
|
work_dir = self.work_dir
|
|
102
113
|
|
|
@@ -109,11 +120,15 @@ class Executor:
|
|
|
109
120
|
self.plan_env_file = None
|
|
110
121
|
|
|
111
122
|
def __enter__(self):
|
|
112
|
-
|
|
113
|
-
|
|
123
|
+
try:
|
|
124
|
+
self.start()
|
|
125
|
+
return self
|
|
126
|
+
except Exception:
|
|
127
|
+
self.stop()
|
|
128
|
+
raise
|
|
114
129
|
|
|
115
130
|
def __exit__(self, exc_type, exc_value, traceback):
|
|
116
|
-
self.
|
|
131
|
+
self.stop()
|
|
117
132
|
|
|
118
133
|
def cancel(self):
|
|
119
134
|
with self.lock:
|
|
@@ -125,19 +140,37 @@ class Executor:
|
|
|
125
140
|
__init__() inside 'fmf_tests', to the remote host.
|
|
126
141
|
"""
|
|
127
142
|
self.conn.rsync(
|
|
128
|
-
"-
|
|
129
|
-
"--delete", "--exclude=.git/",
|
|
143
|
+
"-r", "--delete", "--exclude=.git/",
|
|
130
144
|
f"{self.fmf_tests.root}/",
|
|
131
145
|
f"remote:{self.tests_dir}",
|
|
146
|
+
func=util.subprocess_log,
|
|
132
147
|
)
|
|
133
148
|
|
|
134
|
-
def
|
|
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
|
+
func=util.subprocess_log,
|
|
162
|
+
stderr=subprocess.STDOUT,
|
|
163
|
+
input=script,
|
|
164
|
+
check=True,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def plan_prepare(self):
|
|
135
168
|
"""
|
|
136
169
|
Install packages and run scripts extracted from a TMT plan by a FMFTests
|
|
137
170
|
instance given during class initialization.
|
|
138
171
|
|
|
139
|
-
Also
|
|
140
|
-
|
|
172
|
+
Also run additional scripts specified under the 'prepare' step inside
|
|
173
|
+
the fmf metadata of a plan.
|
|
141
174
|
"""
|
|
142
175
|
# install packages from the plan
|
|
143
176
|
if self.fmf_tests.prepare_pkgs:
|
|
@@ -146,70 +179,79 @@ class Executor:
|
|
|
146
179
|
"dnf", "-y", "--setopt=install_weak_deps=False",
|
|
147
180
|
"install", *self.fmf_tests.prepare_pkgs,
|
|
148
181
|
),
|
|
149
|
-
|
|
150
|
-
stdout=None if util.in_debug_mode() else subprocess.DEVNULL,
|
|
182
|
+
func=util.subprocess_log,
|
|
151
183
|
stderr=subprocess.STDOUT,
|
|
184
|
+
check=True,
|
|
152
185
|
)
|
|
153
186
|
|
|
154
|
-
#
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
env["TMT_PLAN_ENVIRONMENT_FILE"] = self.plan_env_file
|
|
158
|
-
env_args = (f"{k}={v}" for k, v in env.items())
|
|
187
|
+
# run 'prepare' scripts from the plan
|
|
188
|
+
if scripts := self.fmf_tests.prepare_scripts:
|
|
189
|
+
self._run_prepare_scripts(scripts)
|
|
159
190
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
stdout=None if util.in_debug_mode() else subprocess.DEVNULL,
|
|
168
|
-
stderr=subprocess.STDOUT,
|
|
169
|
-
)
|
|
191
|
+
def plan_finish(self):
|
|
192
|
+
"""
|
|
193
|
+
Run any scripts specified under the 'finish' step inside
|
|
194
|
+
the fmf metadata of a plan.
|
|
195
|
+
"""
|
|
196
|
+
if scripts := self.fmf_tests.finish_scripts:
|
|
197
|
+
self._run_prepare_scripts(scripts)
|
|
170
198
|
|
|
171
|
-
|
|
199
|
+
class State(enum.Enum):
|
|
200
|
+
STARTING_TEST = enum.auto()
|
|
201
|
+
READING_CONTROL = enum.auto()
|
|
202
|
+
WAITING_FOR_EXIT = enum.auto()
|
|
203
|
+
RECONNECTING = enum.auto()
|
|
204
|
+
|
|
205
|
+
def run_test(self, test_name, output_dir, *, env=None):
|
|
172
206
|
"""
|
|
173
207
|
Run one test on the remote system.
|
|
174
208
|
|
|
175
209
|
'test_name' is a string with test name.
|
|
176
210
|
|
|
177
|
-
'
|
|
178
|
-
|
|
179
|
-
|
|
211
|
+
'output_dir' is a destination dir (string or Path) for results reported
|
|
212
|
+
and files uploaded by the test. Results are always stored in a line-JSON
|
|
213
|
+
format in a file named 'results', files are always uploaded to directory
|
|
214
|
+
named 'files', both inside 'output_dir'.
|
|
215
|
+
The path for 'output_dir' must already exist and be an empty directory
|
|
216
|
+
(ie. typically a tmpdir).
|
|
180
217
|
|
|
181
218
|
'env' is a dict of extra environment variables to pass to the test.
|
|
182
219
|
|
|
183
220
|
Returns an integer exit code of the test script.
|
|
184
221
|
"""
|
|
222
|
+
output_dir = Path(output_dir)
|
|
185
223
|
test_data = self.fmf_tests.tests[test_name]
|
|
186
224
|
|
|
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
225
|
# run a setup script, preparing wrapper + test scripts
|
|
201
226
|
setup_script = scripts.test_setup(
|
|
202
227
|
test=scripts.Test(test_name, test_data, self.fmf_tests.test_dirs[test_name]),
|
|
203
228
|
tests_dir=self.tests_dir,
|
|
204
229
|
wrapper_exec=f"{self.work_dir}/wrapper.sh",
|
|
205
230
|
test_exec=f"{self.work_dir}/test.sh",
|
|
231
|
+
test_yaml=f"{self.work_dir}/metadata.yaml",
|
|
206
232
|
)
|
|
207
233
|
self.conn.cmd(("bash",), input=setup_script, text=True, check=True)
|
|
208
234
|
|
|
235
|
+
# start with fmf-plan-defined environment
|
|
236
|
+
env_vars = {
|
|
237
|
+
**self.fmf_tests.plan_env,
|
|
238
|
+
"TMT_PLAN_ENVIRONMENT_FILE": self.plan_env_file,
|
|
239
|
+
"TMT_TEST_NAME": test_name,
|
|
240
|
+
"TMT_TEST_METADATA": f"{self.work_dir}/metadata.yaml",
|
|
241
|
+
}
|
|
242
|
+
# append fmf-test-defined environment into it
|
|
243
|
+
for item in fmf.listlike(test_data, "environment"):
|
|
244
|
+
env_vars.update(item)
|
|
245
|
+
# append the Executor-wide environment passed to __init__()
|
|
246
|
+
env_vars.update(self.env)
|
|
247
|
+
# append variables given to this function call
|
|
248
|
+
if env:
|
|
249
|
+
env_vars.update(env)
|
|
250
|
+
|
|
209
251
|
with contextlib.ExitStack() as stack:
|
|
210
|
-
reporter = stack.enter_context(Reporter(
|
|
211
|
-
testout_fd = stack.enter_context(reporter.open_tmpfile())
|
|
252
|
+
reporter = stack.enter_context(Reporter(output_dir, "results", "files"))
|
|
212
253
|
duration = Duration(test_data.get("duration", "5m"))
|
|
254
|
+
control = testcontrol.TestControl(reporter=reporter, duration=duration)
|
|
213
255
|
|
|
214
256
|
test_proc = None
|
|
215
257
|
control_fd = None
|
|
@@ -223,23 +265,19 @@ class Executor:
|
|
|
223
265
|
test_proc.wait()
|
|
224
266
|
raise TestAbortedError(msg) from None
|
|
225
267
|
|
|
268
|
+
exception = None
|
|
269
|
+
|
|
226
270
|
try:
|
|
227
|
-
|
|
228
|
-
state = "starting_test"
|
|
271
|
+
state = self.State.STARTING_TEST
|
|
229
272
|
while not duration.out_of_time():
|
|
230
273
|
with self.lock:
|
|
231
274
|
if self.cancelled:
|
|
232
275
|
abort("cancel requested")
|
|
233
276
|
|
|
234
|
-
if state ==
|
|
277
|
+
if state == self.State.STARTING_TEST:
|
|
235
278
|
control_fd, pipe_w = os.pipe()
|
|
236
279
|
os.set_blocking(control_fd, False)
|
|
237
|
-
control
|
|
238
|
-
control_fd=control_fd,
|
|
239
|
-
reporter=reporter,
|
|
240
|
-
duration=duration,
|
|
241
|
-
testout_fd=testout_fd,
|
|
242
|
-
)
|
|
280
|
+
control.reassign(control_fd)
|
|
243
281
|
# reconnect/reboot count (for compatibility)
|
|
244
282
|
env_vars["TMT_REBOOT_COUNT"] = str(reconnects)
|
|
245
283
|
env_vars["TMT_TEST_RESTART_COUNT"] = str(reconnects)
|
|
@@ -249,13 +287,13 @@ class Executor:
|
|
|
249
287
|
test_proc = self.conn.cmd(
|
|
250
288
|
("env", *env_args, f"{self.work_dir}/wrapper.sh"),
|
|
251
289
|
stdout=pipe_w,
|
|
252
|
-
stderr=
|
|
290
|
+
stderr=reporter.testout_fobj.fileno(),
|
|
253
291
|
func=util.subprocess_Popen,
|
|
254
292
|
)
|
|
255
293
|
os.close(pipe_w)
|
|
256
|
-
state =
|
|
294
|
+
state = self.State.READING_CONTROL
|
|
257
295
|
|
|
258
|
-
elif state ==
|
|
296
|
+
elif state == self.State.READING_CONTROL:
|
|
259
297
|
rlist, _, xlist = select.select((control_fd,), (), (control_fd,), 0.1)
|
|
260
298
|
if xlist:
|
|
261
299
|
abort(f"got exceptional condition on control_fd {control_fd}")
|
|
@@ -264,9 +302,9 @@ class Executor:
|
|
|
264
302
|
if control.eof:
|
|
265
303
|
os.close(control_fd)
|
|
266
304
|
control_fd = None
|
|
267
|
-
state =
|
|
305
|
+
state = self.State.WAITING_FOR_EXIT
|
|
268
306
|
|
|
269
|
-
elif state ==
|
|
307
|
+
elif state == self.State.WAITING_FOR_EXIT:
|
|
270
308
|
# control stream is EOF and it has nothing for us to read,
|
|
271
309
|
# we're now just waiting for proc to cleanly terminate
|
|
272
310
|
try:
|
|
@@ -279,7 +317,7 @@ class Executor:
|
|
|
279
317
|
self.conn.disconnect()
|
|
280
318
|
# if reconnect was requested, do so, otherwise abort
|
|
281
319
|
if control.reconnect:
|
|
282
|
-
state =
|
|
320
|
+
state = self.State.RECONNECTING
|
|
283
321
|
if control.reconnect != "always":
|
|
284
322
|
control.reconnect = None
|
|
285
323
|
else:
|
|
@@ -291,13 +329,20 @@ class Executor:
|
|
|
291
329
|
except subprocess.TimeoutExpired:
|
|
292
330
|
pass
|
|
293
331
|
|
|
294
|
-
elif state ==
|
|
332
|
+
elif state == self.State.RECONNECTING:
|
|
295
333
|
try:
|
|
296
334
|
self.conn.connect(block=False)
|
|
297
335
|
reconnects += 1
|
|
298
|
-
state =
|
|
336
|
+
state = self.State.STARTING_TEST
|
|
299
337
|
except BlockingIOError:
|
|
300
|
-
|
|
338
|
+
# avoid 100% CPU spinning if the connection it too slow
|
|
339
|
+
# to come up (ie. ssh ControlMaster socket file not created)
|
|
340
|
+
time.sleep(0.5)
|
|
341
|
+
except ConnectionError:
|
|
342
|
+
# can happen when ie. ssh is connecting over a LocalForward port,
|
|
343
|
+
# causing 'read: Connection reset by peer' instead of timeout
|
|
344
|
+
# - just retry again after a short delay
|
|
345
|
+
time.sleep(0.5)
|
|
301
346
|
|
|
302
347
|
else:
|
|
303
348
|
raise AssertionError("reached unexpected state")
|
|
@@ -305,12 +350,19 @@ class Executor:
|
|
|
305
350
|
else:
|
|
306
351
|
abort("test duration timeout reached")
|
|
307
352
|
|
|
308
|
-
# testing successful
|
|
353
|
+
# testing successful
|
|
309
354
|
|
|
310
355
|
# test wrapper hasn't provided exitcode
|
|
311
356
|
if control.exit_code is None:
|
|
312
357
|
abort("exitcode not reported, wrapper bug?")
|
|
313
358
|
|
|
359
|
+
return control.exit_code
|
|
360
|
+
|
|
361
|
+
except Exception as e:
|
|
362
|
+
exception = e
|
|
363
|
+
raise
|
|
364
|
+
|
|
365
|
+
finally:
|
|
314
366
|
# partial results that were never reported
|
|
315
367
|
if control.partial_results:
|
|
316
368
|
for result in control.partial_results.values():
|
|
@@ -320,59 +372,32 @@ class Executor:
|
|
|
320
372
|
control.nameless_result_seen = True
|
|
321
373
|
if testout := result.get("testout"):
|
|
322
374
|
try:
|
|
323
|
-
reporter.
|
|
375
|
+
reporter.link_testout(testout, name)
|
|
324
376
|
except FileExistsError:
|
|
325
377
|
raise testcontrol.BadReportJSONError(
|
|
326
378
|
f"file '{testout}' already exists",
|
|
327
379
|
) from None
|
|
328
380
|
reporter.report(result)
|
|
329
381
|
|
|
330
|
-
#
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
382
|
+
# if an unexpected infrastructure-related exception happened
|
|
383
|
+
if exception:
|
|
384
|
+
try:
|
|
385
|
+
reporter.link_testout("output.txt")
|
|
386
|
+
except FileExistsError:
|
|
387
|
+
pass
|
|
335
388
|
reporter.report({
|
|
336
|
-
"status": "
|
|
389
|
+
"status": "infra",
|
|
390
|
+
"note": repr(exception),
|
|
337
391
|
"testout": "output.txt",
|
|
338
392
|
})
|
|
339
393
|
|
|
340
|
-
|
|
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:
|
|
394
|
+
# if the test hasn't reported a result for itself
|
|
395
|
+
elif not control.nameless_result_seen:
|
|
347
396
|
try:
|
|
348
|
-
reporter.
|
|
349
|
-
reporter.report({
|
|
350
|
-
"status": "infra",
|
|
351
|
-
"testout": "output.txt",
|
|
352
|
-
})
|
|
353
|
-
# in case outout.txt exists as a directory
|
|
397
|
+
reporter.link_testout("output.txt")
|
|
354
398
|
except FileExistsError:
|
|
355
399
|
pass
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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}'")
|
|
400
|
+
reporter.report({
|
|
401
|
+
"status": "pass" if control.exit_code == 0 else "fail",
|
|
402
|
+
"testout": "output.txt",
|
|
403
|
+
})
|