atex 0.5__py3-none-any.whl → 0.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. atex/__init__.py +2 -12
  2. atex/cli/__init__.py +13 -13
  3. atex/cli/fmf.py +93 -0
  4. atex/cli/testingfarm.py +71 -61
  5. atex/connection/__init__.py +117 -0
  6. atex/connection/ssh.py +390 -0
  7. atex/executor/__init__.py +2 -0
  8. atex/executor/duration.py +60 -0
  9. atex/executor/executor.py +378 -0
  10. atex/executor/reporter.py +106 -0
  11. atex/executor/scripts.py +155 -0
  12. atex/executor/testcontrol.py +353 -0
  13. atex/fmf.py +217 -0
  14. atex/orchestrator/__init__.py +2 -0
  15. atex/orchestrator/aggregator.py +106 -0
  16. atex/orchestrator/orchestrator.py +324 -0
  17. atex/provision/__init__.py +101 -90
  18. atex/provision/libvirt/VM_PROVISION +8 -0
  19. atex/provision/libvirt/__init__.py +4 -4
  20. atex/provision/podman/README +59 -0
  21. atex/provision/podman/host_container.sh +74 -0
  22. atex/provision/testingfarm/__init__.py +2 -0
  23. atex/{testingfarm.py → provision/testingfarm/api.py} +170 -132
  24. atex/provision/testingfarm/testingfarm.py +236 -0
  25. atex/util/__init__.py +5 -10
  26. atex/util/dedent.py +1 -1
  27. atex/util/log.py +20 -12
  28. atex/util/path.py +16 -0
  29. atex/util/ssh_keygen.py +14 -0
  30. atex/util/subprocess.py +14 -13
  31. atex/util/threads.py +55 -0
  32. {atex-0.5.dist-info → atex-0.8.dist-info}/METADATA +97 -2
  33. atex-0.8.dist-info/RECORD +37 -0
  34. atex/cli/minitmt.py +0 -82
  35. atex/minitmt/__init__.py +0 -115
  36. atex/minitmt/fmf.py +0 -168
  37. atex/minitmt/report.py +0 -174
  38. atex/minitmt/scripts.py +0 -51
  39. atex/minitmt/testme.py +0 -3
  40. atex/orchestrator.py +0 -38
  41. atex/ssh.py +0 -320
  42. atex/util/lockable_class.py +0 -38
  43. atex-0.5.dist-info/RECORD +0 -26
  44. {atex-0.5.dist-info → atex-0.8.dist-info}/WHEEL +0 -0
  45. {atex-0.5.dist-info → atex-0.8.dist-info}/entry_points.txt +0 -0
  46. {atex-0.5.dist-info → atex-0.8.dist-info}/licenses/COPYING.txt +0 -0
atex/connection/ssh.py ADDED
@@ -0,0 +1,390 @@
1
+ """
2
+ Connection API implementation using the OpenSSH ssh(1) client.
3
+
4
+ Any SSH options are passed via dictionaries of options, and later translated
5
+ to '-o' client CLI options, incl. Hostname, User, Port, IdentityFile, etc.
6
+ No "typical" ssh CLI switches are used.
7
+
8
+ This allows for a nice flexibility from Python code - this module provides
9
+ some sensible option defaults (for scripted use), but you are free to
10
+ overwrite any options via class or function arguments (where appropriate).
11
+
12
+ Note that .cmd() quotes arguments to really execute individual arguments
13
+ as individual arguments in the remote shell, so you need to give it a proper
14
+ iterable (like for other Connections), not a single string with spaces.
15
+ """
16
+
17
+ import os
18
+ import time
19
+ import shlex
20
+ import tempfile
21
+ import subprocess
22
+ from pathlib import Path
23
+
24
+ from .. import util
25
+ from . import Connection
26
+
27
+
28
+ DEFAULT_OPTIONS = {
29
+ "LogLevel": "ERROR",
30
+ "StrictHostKeyChecking": "no",
31
+ "UserKnownHostsFile": "/dev/null",
32
+ "ConnectionAttempts": "3",
33
+ "ServerAliveCountMax": "4",
34
+ "ServerAliveInterval": "5",
35
+ "TCPKeepAlive": "no",
36
+ "EscapeChar": "none",
37
+ "ExitOnForwardFailure": "yes",
38
+ }
39
+
40
+
41
+ class SSHError(Exception):
42
+ pass
43
+
44
+
45
+ class DisconnectedError(SSHError):
46
+ """
47
+ Raised when an already-connected ssh session goes away (breaks connection).
48
+ """
49
+ pass
50
+
51
+
52
+ class NotConnectedError(SSHError):
53
+ """
54
+ Raised when an operation on ssh connection is requested, but the connection
55
+ is not yet open (or has been closed/disconnected).
56
+ """
57
+ pass
58
+
59
+
60
+ class ConnectError(SSHError):
61
+ """
62
+ Raised when a to-be-opened ssh connection fails to open.
63
+ """
64
+ pass
65
+
66
+
67
+ def _shell_cmd(command, sudo=None):
68
+ """
69
+ Make a command line for running 'command' on the target system.
70
+ """
71
+ quoted_args = (shlex.quote(str(arg)) for arg in command)
72
+ if sudo:
73
+ return " ".join((
74
+ "exec", "sudo", "--no-update", "--non-interactive", "--user", sudo, "--", *quoted_args,
75
+ ))
76
+ else:
77
+ return " ".join(("exec", *quoted_args))
78
+
79
+
80
+ def _options_to_cli(options):
81
+ """
82
+ Assemble an ssh(1) or sshpass(1) command line with -o options.
83
+ """
84
+ list_opts = []
85
+ for key, value in options.items():
86
+ if isinstance(value, (list, tuple, set)):
87
+ list_opts += (f"-o{key}={v}" for v in value)
88
+ else:
89
+ list_opts.append(f"-o{key}={value}")
90
+ return list_opts
91
+
92
+
93
+ def _options_to_ssh(options, password=None, extra_cli_flags=()):
94
+ """
95
+ Assemble an ssh(1) or sshpass(1) command line with -o options.
96
+ """
97
+ cli_opts = _options_to_cli(options)
98
+ if password:
99
+ return (
100
+ "sshpass", "-p", password,
101
+ "ssh", *extra_cli_flags, "-oBatchMode=no", *cli_opts,
102
+ "ignored_arg",
103
+ )
104
+ else:
105
+ # let cli_opts override BatchMode if specified
106
+ return ("ssh", *extra_cli_flags, *cli_opts, "-oBatchMode=yes", "ignored_arg")
107
+
108
+
109
+ # return a string usable for rsync -e
110
+ def _options_to_rsync_e(options, password=None):
111
+ """
112
+ Return a string usable for the rsync -e argument.
113
+ """
114
+ cli_opts = _options_to_cli(options)
115
+ batch_mode = "-oBatchMode=no" if password else "-oBatchMode=yes"
116
+ return " ".join(("ssh", *cli_opts, batch_mode)) # no ignored_arg inside -e
117
+
118
+
119
+ def _rsync_host_cmd(*args, options, password=None, sudo=None):
120
+ """
121
+ Assemble a rsync command line, noting that
122
+ - 'sshpass' must be before 'rsync', not inside the '-e' argument
123
+ - 'ignored_arg' must be passed by user as destination, not inside '-e'
124
+ - 'sudo' is part of '--rsync-path', yet another argument
125
+ """
126
+ return (
127
+ *(("sshpass", "-p", password) if password else ()),
128
+ "rsync",
129
+ "-e", _options_to_rsync_e(options, password=password),
130
+ "--rsync-path", _shell_cmd(("rsync",), sudo=sudo),
131
+ *args,
132
+ )
133
+
134
+
135
+ class StatelessSSHConn(Connection):
136
+ """
137
+ Implements the Connection API using a ssh(1) client using "standalone"
138
+ (stateless) logic - connect() and disconnect() are no-op, .cmd() simply
139
+ executes the ssh client and .rsync() executes 'rsync -e ssh'.
140
+
141
+ Compared to ManagedSSHConn, this may be slow for many .cmd() calls,
142
+ but every call is stateless, there is no persistent connection.
143
+
144
+ If you need only one .cmd(), this will be faster than ManagedSSHConn.
145
+ """
146
+
147
+ def __init__(self, options, *, password=None, sudo=None):
148
+ """
149
+ Prepare to connect to an SSH server specified in 'options'.
150
+
151
+ If 'password' is given, spawn the ssh(1) command via 'sshpass' and
152
+ pass the password to it.
153
+
154
+ If 'sudo' specifies a username, call sudo(8) on the remote shell
155
+ to run under a different user on the remote host.
156
+ """
157
+ super().__init__()
158
+ self.options = DEFAULT_OPTIONS.copy()
159
+ self.options.update(options)
160
+ self.password = password
161
+ self.sudo = sudo
162
+ self._tmpdir = None
163
+ self._master_proc = None
164
+
165
+ def connect(self):
166
+ """
167
+ Optional, .cmd() and .rsync() work without it, but it is provided here
168
+ for compatibility with the Connection API.
169
+ """
170
+ # TODO: just wait until .cmd(['true']) starts responding ?
171
+ pass
172
+
173
+ def disconnect(self):
174
+ pass
175
+
176
+ # have options as kwarg to be compatible with other functions here
177
+ def cmd(self, command, options=None, func=util.subprocess_run, **func_args):
178
+ unified_options = self.options.copy()
179
+ if options:
180
+ unified_options.update(options)
181
+ unified_options["RemoteCommand"] = _shell_cmd(command, sudo=self.sudo)
182
+ return func(
183
+ _options_to_ssh(unified_options, password=self.password),
184
+ skip_frames=1,
185
+ **func_args,
186
+ )
187
+
188
+ def rsync(self, *args, options=None, func=util.subprocess_run, **func_args):
189
+ unified_options = self.options.copy()
190
+ if options:
191
+ unified_options.update(options)
192
+ return func(
193
+ _rsync_host_cmd(
194
+ *args,
195
+ options=unified_options,
196
+ password=self.password,
197
+ sudo=self.sudo,
198
+ ),
199
+ skip_frames=1,
200
+ check=True,
201
+ stdin=subprocess.DEVNULL,
202
+ **func_args,
203
+ )
204
+
205
+
206
+ # Note that when ControlMaster goes away (connection breaks), any ssh clients
207
+ # connected through it will time out after a combination of
208
+ # ServerAliveCountMax + ServerAliveInterval + ConnectionAttempts
209
+ # identical to the ControlMaster process.
210
+ # Specifying different values for the clients, to make them exit faster when
211
+ # the ControlMaster dies, has no effect. They seem to ignore the options.
212
+ #
213
+ # If you need to kill the clients quickly after ControlMaster disconnects,
214
+ # you need to set up an independent polling logic (ie. every 0.1sec) that
215
+ # checks .assert_master() and manually signals the running clients
216
+ # when it gets DisconnectedError from it.
217
+
218
+ class ManagedSSHConn(Connection):
219
+ """
220
+ Implements the Connection API using one persistently-running ssh(1) client
221
+ started in a 'ControlMaster' mode, with additional ssh clients using that
222
+ session to execute remote commands. Similarly, .rsync() uses it too.
223
+
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.
229
+ """
230
+
231
+ # TODO: thread safety and locking via self.lock ?
232
+
233
+ def __init__(self, options, *, password=None, sudo=None):
234
+ """
235
+ Prepare to connect to an SSH server specified in 'options'.
236
+
237
+ If 'password' is given, spawn the ssh(1) command via 'sshpass' and
238
+ pass the password to it.
239
+
240
+ If 'sudo' specifies a username, call sudo(8) on the remote shell
241
+ to run under a different user on the remote host.
242
+ """
243
+ super().__init__()
244
+ self.options = DEFAULT_OPTIONS.copy()
245
+ self.options.update(options)
246
+ self.password = password
247
+ self.sudo = sudo
248
+ self._tmpdir = None
249
+ self._master_proc = None
250
+
251
+ def assert_master(self):
252
+ proc = self._master_proc
253
+ if not proc:
254
+ raise NotConnectedError("SSH ControlMaster is not running")
255
+ # we need to consume any potential proc output for the process to
256
+ # actually terminate (stop being a zombie) if it crashes
257
+ out = proc.stdout.read()
258
+ code = proc.poll()
259
+ if code is not None:
260
+ self._master_proc = None
261
+ out = f":\n{out.decode()}" if out else ""
262
+ raise DisconnectedError(
263
+ f"SSH ControlMaster on {self._tmpdir} exited with {code}{out}",
264
+ )
265
+
266
+ def disconnect(self):
267
+ proc = self._master_proc
268
+ if not proc:
269
+ return
270
+ proc.kill()
271
+ # don"t zombie forever, return EPIPE on any attempts to write to us
272
+ proc.stdout.close()
273
+ proc.wait()
274
+ (self._tmpdir / "control.sock").unlink(missing_ok=True)
275
+ self._master_proc = None
276
+
277
+ def connect(self, block=True):
278
+ if not self._tmpdir:
279
+ # _tmpdir_handle just prevents the TemporaryDirectory instance
280
+ # from being garbage collected (and removed on disk)
281
+ # TODO: create/remove it explicitly in connect/disconnect
282
+ # so the removal happens immediately, even if GC delays cleaning
283
+ self._tmpdir_handle = tempfile.TemporaryDirectory(prefix="atex-ssh-")
284
+ self._tmpdir = Path(self._tmpdir_handle.name)
285
+
286
+ sock = self._tmpdir / "control.sock"
287
+
288
+ if not self._master_proc:
289
+ options = self.options.copy()
290
+ options["SessionType"] = "none"
291
+ options["ControlMaster"] = "yes"
292
+ options["ControlPath"] = sock
293
+ self._master_proc = util.subprocess_Popen(
294
+ _options_to_ssh(options),
295
+ stdin=subprocess.DEVNULL,
296
+ stdout=subprocess.PIPE,
297
+ stderr=subprocess.STDOUT,
298
+ cwd=str(self._tmpdir),
299
+ )
300
+ os.set_blocking(self._master_proc.stdout.fileno(), False)
301
+
302
+ proc = self._master_proc
303
+ if block:
304
+ while proc.poll() is None:
305
+ if sock.exists():
306
+ break
307
+ time.sleep(0.1)
308
+ else:
309
+ code = proc.poll()
310
+ out = proc.stdout.read()
311
+ self._master_proc = None
312
+ # TODO: ConnectError should probably be generalized for Connection
313
+ raise ConnectError(
314
+ f"SSH ControlMaster failed to start on {self._tmpdir} with {code}:\n{out}",
315
+ )
316
+ else:
317
+ code = proc.poll()
318
+ if code is not None:
319
+ out = proc.stdout.read()
320
+ self._master_proc = None
321
+ # TODO: ConnectError should probably be generalized for Connection
322
+ raise ConnectError(
323
+ f"SSH ControlMaster failed to start on {self._tmpdir} with {code}:\n{out}",
324
+ )
325
+ elif not sock.exists():
326
+ raise BlockingIOError("SSH ControlMaster not yet ready")
327
+
328
+ def add_local_forward(self, *spec):
329
+ """
330
+ Add (one or more) ssh forwarding specifications as 'spec' to an
331
+ 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
+ )
343
+
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)).
349
+ """
350
+ self.assert_master()
351
+ options = self.options.copy()
352
+ options["RemoteForward"] = 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
+ )
359
+
360
+ def cmd(self, command, options=None, func=util.subprocess_run, **func_args):
361
+ self.assert_master()
362
+ unified_options = self.options.copy()
363
+ if options:
364
+ unified_options.update(options)
365
+ unified_options["RemoteCommand"] = _shell_cmd(command, sudo=self.sudo)
366
+ unified_options["ControlPath"] = self._tmpdir / "control.sock"
367
+ return func(
368
+ _options_to_ssh(unified_options, password=self.password),
369
+ skip_frames=1,
370
+ **func_args,
371
+ )
372
+
373
+ def rsync(self, *args, options=None, func=util.subprocess_run, **func_args):
374
+ self.assert_master()
375
+ unified_options = self.options.copy()
376
+ if options:
377
+ unified_options.update(options)
378
+ unified_options["ControlPath"] = self._tmpdir / "control.sock"
379
+ return func(
380
+ _rsync_host_cmd(
381
+ *args,
382
+ options=unified_options,
383
+ password=self.password,
384
+ sudo=self.sudo,
385
+ ),
386
+ skip_frames=1,
387
+ check=True,
388
+ stdin=subprocess.DEVNULL,
389
+ **func_args,
390
+ )
@@ -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