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.
Files changed (44) hide show
  1. atex/aggregator/__init__.py +60 -0
  2. atex/aggregator/json.py +96 -0
  3. atex/cli/__init__.py +11 -1
  4. atex/cli/fmf.py +73 -23
  5. atex/cli/libvirt.py +128 -0
  6. atex/cli/testingfarm.py +60 -3
  7. atex/connection/__init__.py +13 -11
  8. atex/connection/podman.py +61 -0
  9. atex/connection/ssh.py +38 -47
  10. atex/executor/executor.py +144 -119
  11. atex/executor/reporter.py +66 -71
  12. atex/executor/scripts.py +13 -5
  13. atex/executor/testcontrol.py +43 -30
  14. atex/fmf.py +94 -74
  15. atex/orchestrator/__init__.py +76 -2
  16. atex/orchestrator/adhoc.py +465 -0
  17. atex/{provision → provisioner}/__init__.py +54 -42
  18. atex/provisioner/libvirt/__init__.py +2 -0
  19. atex/provisioner/libvirt/libvirt.py +472 -0
  20. atex/provisioner/libvirt/locking.py +170 -0
  21. atex/{provision → provisioner}/libvirt/setup-libvirt.sh +21 -1
  22. atex/provisioner/podman/__init__.py +2 -0
  23. atex/provisioner/podman/podman.py +169 -0
  24. atex/{provision → provisioner}/testingfarm/api.py +121 -69
  25. atex/{provision → provisioner}/testingfarm/testingfarm.py +44 -52
  26. atex/util/libvirt.py +18 -0
  27. atex/util/log.py +53 -43
  28. atex/util/named_mapping.py +158 -0
  29. atex/util/subprocess.py +46 -12
  30. atex/util/threads.py +71 -20
  31. atex-0.10.dist-info/METADATA +86 -0
  32. atex-0.10.dist-info/RECORD +44 -0
  33. atex/orchestrator/aggregator.py +0 -106
  34. atex/orchestrator/orchestrator.py +0 -324
  35. atex/provision/libvirt/__init__.py +0 -24
  36. atex/provision/podman/README +0 -59
  37. atex/provision/podman/host_container.sh +0 -74
  38. atex-0.8.dist-info/METADATA +0 -197
  39. atex-0.8.dist-info/RECORD +0 -37
  40. /atex/{provision → provisioner}/libvirt/VM_PROVISION +0 -0
  41. /atex/{provision → provisioner}/testingfarm/__init__.py +0 -0
  42. {atex-0.8.dist-info → atex-0.10.dist-info}/WHEEL +0 -0
  43. {atex-0.8.dist-info → atex-0.10.dist-info}/entry_points.txt +0 -0
  44. {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(Exception):
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 StatelessSSHConn(Connection):
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 ManagedSSHConn, this may be slow for many .cmd() calls,
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 ManagedSSHConn.
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
- unified_options["RemoteCommand"] = _shell_cmd(command, sudo=self.sudo)
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 ManagedSSHConn(Connection):
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 StatelessSSHConn when executing multiple commands,
225
- but contains a complex internal state (what if ControlMaster disconnects?).
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
- super().__init__()
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"t zombie forever, return EPIPE on any attempts to write to us
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 add_local_forward(self, *spec):
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 ssh client's LocalForward option (see ssh_config(5)).
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
- def add_remote_forward(self, *spec):
345
- """
346
- Add (one or more) ssh forwarding specifications as 'spec' to an
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 = self.options.copy()
352
- options["RemoteForward"] = spec
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", "forward")),
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
- unified_options["RemoteCommand"] = _shell_cmd(command, sudo=self.sudo)
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, password=self.password),
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
- tests_data = atex.fmf.FMFTests(tests_repo, "/plans/default")
29
+ fmf_tests = atex.fmf.FMFTests(tests_repo, "/plans/default")
28
30
 
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")
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(tests_data, conn, state_dir="/shared") as e:
42
- e.upload_tests(tests_repo)
43
- e.setup_plan()
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(tests_data, unique_conn, state_dir="/shared") as e:
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 setup(self):
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
- def cleanup(self):
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
- self.setup()
113
- return self
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.cleanup()
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
- "-rv" if util.in_debug_mode() else "-rq",
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 setup_plan(self):
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 prepare additional environment for tests, ie. create and export
140
- a path to TMT_PLAN_ENVIRONMENT_FILE.
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
- check=True,
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
- # 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())
187
+ # run 'prepare' scripts from the plan
188
+ if scripts := self.fmf_tests.prepare_scripts:
189
+ self._run_prepare_scripts(scripts)
159
190
 
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
- )
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
- def run_test(self, test_name, json_file, files_dir, *, env=None):
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
- '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.
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(json_file, files_dir))
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
- # TODO: probably enum
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 == "starting_test":
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 = testcontrol.TestControl(
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=testout_fd,
290
+ stderr=reporter.testout_fobj.fileno(),
253
291
  func=util.subprocess_Popen,
254
292
  )
255
293
  os.close(pipe_w)
256
- state = "reading_control"
294
+ state = self.State.READING_CONTROL
257
295
 
258
- elif state == "reading_control":
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 = "waiting_for_exit"
305
+ state = self.State.WAITING_FOR_EXIT
268
306
 
269
- elif state == "waiting_for_exit":
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 = "reconnecting"
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 == "reconnecting":
332
+ elif state == self.State.RECONNECTING:
295
333
  try:
296
334
  self.conn.connect(block=False)
297
335
  reconnects += 1
298
- state = "starting_test"
336
+ state = self.State.STARTING_TEST
299
337
  except BlockingIOError:
300
- pass
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, do post-testing tasks
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.link_tmpfile_to(testout_fd, testout, name)
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
- # 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")
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": "pass" if control.exit_code == 0 else "fail",
389
+ "status": "infra",
390
+ "note": repr(exception),
337
391
  "testout": "output.txt",
338
392
  })
339
393
 
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:
394
+ # if the test hasn't reported a result for itself
395
+ elif not control.nameless_result_seen:
347
396
  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
397
+ reporter.link_testout("output.txt")
354
398
  except FileExistsError:
355
399
  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}'")
400
+ reporter.report({
401
+ "status": "pass" if control.exit_code == 0 else "fail",
402
+ "testout": "output.txt",
403
+ })