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.
Files changed (48) hide show
  1. atex/cli/fmf.py +143 -0
  2. atex/cli/libvirt.py +127 -0
  3. atex/cli/testingfarm.py +35 -13
  4. atex/connection/__init__.py +13 -19
  5. atex/connection/podman.py +63 -0
  6. atex/connection/ssh.py +34 -52
  7. atex/executor/__init__.py +2 -0
  8. atex/executor/duration.py +60 -0
  9. atex/executor/executor.py +402 -0
  10. atex/executor/reporter.py +101 -0
  11. atex/{minitmt → executor}/scripts.py +37 -25
  12. atex/{minitmt → executor}/testcontrol.py +54 -42
  13. atex/fmf.py +237 -0
  14. atex/orchestrator/__init__.py +3 -59
  15. atex/orchestrator/aggregator.py +82 -134
  16. atex/orchestrator/orchestrator.py +385 -0
  17. atex/provision/__init__.py +74 -105
  18. atex/provision/libvirt/__init__.py +2 -24
  19. atex/provision/libvirt/libvirt.py +465 -0
  20. atex/provision/libvirt/locking.py +168 -0
  21. atex/provision/libvirt/setup-libvirt.sh +21 -1
  22. atex/provision/podman/__init__.py +1 -0
  23. atex/provision/podman/podman.py +274 -0
  24. atex/provision/testingfarm/__init__.py +2 -29
  25. atex/provision/testingfarm/api.py +123 -65
  26. atex/provision/testingfarm/testingfarm.py +234 -0
  27. atex/util/__init__.py +1 -6
  28. atex/util/libvirt.py +18 -0
  29. atex/util/log.py +31 -8
  30. atex/util/named_mapping.py +158 -0
  31. atex/util/path.py +16 -0
  32. atex/util/ssh_keygen.py +14 -0
  33. atex/util/threads.py +99 -0
  34. atex-0.9.dist-info/METADATA +178 -0
  35. atex-0.9.dist-info/RECORD +43 -0
  36. atex/cli/minitmt.py +0 -175
  37. atex/minitmt/__init__.py +0 -23
  38. atex/minitmt/executor.py +0 -348
  39. atex/minitmt/fmf.py +0 -202
  40. atex/provision/nspawn/README +0 -74
  41. atex/provision/podman/README +0 -59
  42. atex/provision/podman/host_container.sh +0 -74
  43. atex/provision/testingfarm/foo.py +0 -1
  44. atex-0.7.dist-info/METADATA +0 -102
  45. atex-0.7.dist-info/RECORD +0 -32
  46. {atex-0.7.dist-info → atex-0.9.dist-info}/WHEEL +0 -0
  47. {atex-0.7.dist-info → atex-0.9.dist-info}/entry_points.txt +0 -0
  48. {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(Exception):
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
- unified_options["RemoteCommand"] = _shell_cmd(command, sudo=self.sudo)
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
- super().__init__()
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"t zombie forever, return EPIPE on any attempts to write to us
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 add_local_forward(self, *spec):
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 ssh client's LocalForward option (see ssh_config(5)).
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
- def add_remote_forward(self, *spec):
361
- """
362
- Add (one or more) ssh forwarding specifications as 'spec' to an
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 = self.options.copy()
368
- options["RemoteForward"] = spec
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", "forward")),
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
- unified_options["RemoteCommand"] = _shell_cmd(command, sudo=self.sudo)
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, password=self.password),
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,2 @@
1
+ from . import testcontrol # noqa: F401
2
+ from .executor import Executor # noqa: F401
@@ -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
+ })