atex 0.7__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.
atex/util/log.py CHANGED
@@ -5,6 +5,14 @@ from pathlib import Path
5
5
  _logger = logging.getLogger("atex")
6
6
 
7
7
 
8
+ def in_debug_mode():
9
+ """
10
+ Return True if the root logger is using the DEBUG (or more verbose) level.
11
+ """
12
+ root_level = logging.getLogger().level
13
+ return root_level > 0 and root_level <= logging.DEBUG
14
+
15
+
8
16
  def _format_msg(msg, *, skip_frames=0):
9
17
  stack = inspect.stack()
10
18
  if len(stack)-1 <= skip_frames:
atex/util/path.py ADDED
@@ -0,0 +1,16 @@
1
+ import os
2
+
3
+
4
+ def normalize_path(path):
5
+ """
6
+ Transform a potentially dangerous path (leading slash, relative ../../../
7
+ leading beyond parent, etc.) to a safe one.
8
+
9
+ Always returns a relative path.
10
+ """
11
+ # the magic here is to treat any dangerous path as starting at /
12
+ # and resolve any weird constructs relative to /, and then simply
13
+ # strip off the leading / and use it as a relative path
14
+ path = path.lstrip("/")
15
+ path = os.path.normpath(f"/{path}")
16
+ return path[1:]
@@ -0,0 +1,14 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+ from .subprocess import subprocess_run
5
+
6
+
7
+ def ssh_keygen(dest_dir, key_type="rsa"):
8
+ dest_dir = Path(dest_dir)
9
+ subprocess_run(
10
+ ("ssh-keygen", "-t", key_type, "-N", "", "-f", dest_dir / f"key_{key_type}"),
11
+ stdout=subprocess.DEVNULL,
12
+ check=True,
13
+ )
14
+ return (dest_dir / "key_rsa", dest_dir / "key_rsa.pub")
atex/util/threads.py ADDED
@@ -0,0 +1,55 @@
1
+ import collections
2
+ import queue
3
+ import threading
4
+
5
+ # TODO: documentation; this is like concurrent.futures, but with daemon=True support
6
+
7
+
8
+ class ThreadQueue:
9
+ ThreadReturn = collections.namedtuple("ThreadReturn", ("thread", "returned", "exception"))
10
+ Empty = queue.Empty
11
+
12
+ def __init__(self, daemon=False):
13
+ self.queue = queue.SimpleQueue()
14
+ self.daemon = daemon
15
+ self.threads = set()
16
+
17
+ def _wrapper(self, func, *args, **kwargs):
18
+ current_thread = threading.current_thread()
19
+ try:
20
+ ret = func(*args, **kwargs)
21
+ result = self.ThreadReturn(current_thread, ret, None)
22
+ except Exception as e:
23
+ result = self.ThreadReturn(current_thread, None, e)
24
+ self.queue.put(result)
25
+
26
+ def start_thread(self, target, name=None, args=None, kwargs=None):
27
+ args = args or ()
28
+ kwargs = kwargs or {}
29
+ t = threading.Thread(
30
+ target=self._wrapper,
31
+ name=name,
32
+ args=(target, *args),
33
+ kwargs=kwargs,
34
+ daemon=self.daemon,
35
+ )
36
+ t.start()
37
+ self.threads.add(t)
38
+
39
+ # get one return value from any thread's function, like .as_completed()
40
+ # or concurrent.futures.FIRST_COMPLETED
41
+ def get(self, block=True, timeout=None):
42
+ if block and timeout is None and not self.threads:
43
+ raise AssertionError("no threads are running, would block forever")
44
+ treturn = self.queue.get(block=block, timeout=timeout)
45
+ self.threads.remove(treturn.thread)
46
+ if treturn.exception is not None:
47
+ raise treturn.exception
48
+ else:
49
+ return treturn.returned
50
+
51
+ # wait for all threads to finish (ignoring queue contents)
52
+ def join(self):
53
+ while self.threads:
54
+ t = self.threads.pop()
55
+ t.join()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atex
3
- Version: 0.7
3
+ Version: 0.8
4
4
  Summary: Ad-hoc Test EXecutor
5
5
  Project-URL: Homepage, https://github.com/RHSecurityCompliance/atex
6
6
  License-Expression: GPL-3.0-or-later
@@ -8,7 +8,7 @@ License-File: COPYING.txt
8
8
  Classifier: Operating System :: POSIX :: Linux
9
9
  Classifier: Programming Language :: Python :: 3
10
10
  Classifier: Topic :: Software Development :: Testing
11
- Requires-Python: >=3.9
11
+ Requires-Python: >=3.11
12
12
  Requires-Dist: fmf>=1.6
13
13
  Requires-Dist: urllib3<3,>=2
14
14
  Description-Content-Type: text/markdown
@@ -45,8 +45,103 @@ BREAK. DO NOT USE IT (for now).
45
45
  Unless specified otherwise, any content within this repository is distributed
46
46
  under the GNU GPLv3 license, see the [COPYING.txt](COPYING.txt) file for more.
47
47
 
48
+ ## Parallelism and cleanup
49
+
50
+ There are effectively 3 methods of running things in parallel in Python:
51
+
52
+ - `threading.Thread` (and related `concurrent.futures` classes)
53
+ - `multiprocessing.Process` (and related `concurrent.futures` classes)
54
+ - `asyncio`
55
+
56
+ and there is no clear winner (in terms of cleanup on `SIGTERM` or Ctrl-C):
57
+
58
+ - `Thread` has signal handlers only in the main thread and is unable to
59
+ interrupt any running threads without super ugly workarounds like `sleep(1)`
60
+ in every thread, checking some "pls exit" variable
61
+ - `Process` is too heavyweight and makes sharing native Python objects hard,
62
+ but it does handle signals in each process individually
63
+ - `asyncio` handles interrupting perfectly (every `try`/`except`/`finally`
64
+ completes just fine, `KeyboardInterrupt` is raised in every async context),
65
+ but async python is still (3.14) too weird and unsupported
66
+ - `asyncio` effectively re-implements `subprocess` with a slightly different
67
+ API, same with `asyncio.Transport` and derivatives reimplementing `socket`
68
+ - 3rd party libraries like `requests` or `urllib3` don't support it, one needs
69
+ to resort to spawning these in separate threads anyway
70
+ - same with `os.*` functions and syscalls
71
+ - every thing exposed via API needs to have 2 copies - async and non-async,
72
+ making it unbearable
73
+ - other stdlib bugs, ie. "large" reads returning BlockingIOError sometimes
74
+
75
+ The approach chosen by this project was to use `threading.Thread`, and
76
+ implement thread safety for classes and their functions that need it.
77
+ For example:
78
+
79
+ ```python
80
+ class MachineReserver:
81
+ def __init__(self):
82
+ self.lock = threading.RLock()
83
+ self.job = None
84
+ self.proc = None
85
+
86
+ def reserve(self, ...):
87
+ try:
88
+ ...
89
+ job = schedule_new_job_on_external_service()
90
+ with self.lock:
91
+ self.job = job
92
+ ...
93
+ while not reserved(self.job):
94
+ time.sleep(60)
95
+ ...
96
+ with self.lock:
97
+ self.proc = subprocess.Popen(["ssh", f"{user}@{host}", ...)
98
+ ...
99
+ return machine
100
+ except Exception:
101
+ self.abort()
102
+ raise
103
+
104
+ def abort(self):
105
+ with self.lock:
106
+ if self.job:
107
+ cancel_external_service(self.job)
108
+ self.job = None
109
+ if self.proc:
110
+ self.proc.kill()
111
+ self.proc = None
112
+ ```
113
+
114
+ Here, it is expected for `.reserve()` to be called in a long-running thread that
115
+ provisions a new machine on some external service, waits for it to be installed
116
+ and reserved, connects an ssh session to it and returns it back.
117
+
118
+ But equally, `.abort()` can be called from an external thread and clean up any
119
+ non-pythonic resources (external jobs, processes, temporary files, etc.) at
120
+ which point **we don't care what happens to .reserve()**, it will probably fail
121
+ with some exception, but doesn't do any harm.
122
+
123
+ Here is where `daemon=True` threads come in handy - we can simply call `.abort()`
124
+ from a `KeyboardInterrupt` (or `SIGTERM`) handle in the main thread, and just
125
+ exit, automatically killing any leftover threads that are uselessly sleeping.
126
+ (Realistically, we might want to spawn new threads to run many `.abort()`s in
127
+ parallel, but the main thread can wait for those just fine.)
128
+
129
+ It is not perfect, but it's probably the best Python can do.
130
+
131
+ Note that races can still occur between a resource being reserved and written
132
+ to `self.*` for `.abort()` to free, so resource de-allocation is not 100%
133
+ guaranteed, but single-threaded interrupting has the same issue.
134
+ Do have fallbacks (ie. max reserve times on the external service).
135
+
136
+ Also note that `.reserve()` and `.abort()` could be also called by a context
137
+ manager as `__enter__` and `__exit__`, ie. by a non-threaded caller (running
138
+ everything in the main thread).
139
+
140
+
48
141
  ## Unsorted notes
49
142
 
143
+ TODO: codestyle from contest
144
+
50
145
  ```
51
146
  - this is not tmt, the goal is to make a python toolbox *for* making runcontest
52
147
  style tools easily, not to replace those tools with tmt-style CLI syntax
@@ -0,0 +1,37 @@
1
+ atex/__init__.py,sha256=LdX67gprtHYeAkjLhFPKzpc7ECv2rHxUbHKDGbGXO1c,517
2
+ atex/fmf.py,sha256=ofbrJx2362qHAxERS-WulK4TMpbp0C4HQ-Js917Ll9w,7871
3
+ atex/cli/__init__.py,sha256=erHv68SsybRbdgJ60013y9jVqY1ec-cb9T9ThPCJ_HY,2408
4
+ atex/cli/fmf.py,sha256=5DbA-3rfbFZ41fJ5z7Tiz5FmuZhXNC7gRAQfIGX7pXc,2516
5
+ atex/cli/testingfarm.py,sha256=wdN26TE9jZ0ozet-JBQQgIcRi0WIV3u_i-7_YYi_SUg,7248
6
+ atex/connection/__init__.py,sha256=xFwGOvlFez1lIt1AD6WXgEEIbsF22pSpQFv41GEAGAI,3798
7
+ atex/connection/ssh.py,sha256=vrrSfVdQoz5kWiZbiPuM8KGneMl2Tlb0VeJIHTFSSYs,13626
8
+ atex/executor/__init__.py,sha256=XCfhi7QDELjey7N1uzhMjc46Kp1Jsd5bOCf52I27SCE,85
9
+ atex/executor/duration.py,sha256=x06sItKOZi6XA8KszQwZGpIb1Z_L-HWqIwZKo2SDo0s,1759
10
+ atex/executor/executor.py,sha256=QYYSlEfBZIm95NhM1gwd2ROeshSAavYu2DP_4TTHlQs,14770
11
+ atex/executor/reporter.py,sha256=nW_Uls3R4Ev80a2ZNJl3nxAYrcYhXk5Cy9nAUMlYPrc,3326
12
+ atex/executor/scripts.py,sha256=yE4Lbfu-TPkBcB5t15-t-tF79H8pBJWbWP6MKRSvKsw,5356
13
+ atex/executor/testcontrol.py,sha256=-rfihfE6kryIGurFrHBPSS8ANaIJkzX-zfpOO8To-9o,12204
14
+ atex/orchestrator/__init__.py,sha256=eF-6ix5rFEu85fBFzgSdTYau7bNTkIQndAU7QqeI-FA,105
15
+ atex/orchestrator/aggregator.py,sha256=5-8nHVeW6kwImoEYOsQqsx6UBdbKc5xuj6qlg7dtOF8,3642
16
+ atex/orchestrator/orchestrator.py,sha256=tQu_d8_9y3rOLHskb694NJKNvxplQWAZ2R452Sy3AXw,12056
17
+ atex/provision/__init__.py,sha256=2d_hRVPxXF5BVbQ_Gn1OR-F2xuqRn8O0yyVbvSrtTIg,4043
18
+ atex/provision/libvirt/VM_PROVISION,sha256=7pkZ-ozgTyK4qNGC-E-HUznr4IhbosWSASbB72Gknl8,2664
19
+ atex/provision/libvirt/__init__.py,sha256=mAkGtciZsXdR9MVVrjm3OWNXZqTs_33-J1qAszFA0k4,768
20
+ atex/provision/libvirt/setup-libvirt.sh,sha256=CXrEFdrj8CSHXQZCd2RWuRvTmw7QYFTVhZeLuhhXooI,1855
21
+ atex/provision/podman/README,sha256=kgP3vcTfWW9gcQzmXnyucjgWbqjNqm_ZM--pnqNTXRg,1345
22
+ atex/provision/podman/host_container.sh,sha256=buCNz0BlsHY5I64sMSTGQHkvzEK0aeIhpGJXWCQVMXk,2283
23
+ atex/provision/testingfarm/__init__.py,sha256=kZncgLGdRCR4FMaRQr2GTwJ8vjlA-24ri8JO2ueZJuw,113
24
+ atex/provision/testingfarm/api.py,sha256=jiEJhYxMTzRihayceHcnDnGKNZJisYWn2o_TAdCI2Xo,19943
25
+ atex/provision/testingfarm/testingfarm.py,sha256=wp8W3bwOmQdO-UUOdqu_JLtOZTGaNg-wERFfLySwZmI,8587
26
+ atex/util/__init__.py,sha256=cWHFbtQ4mDlKe6lXyPDWRmWJOTcHDGfVuW_-GYa8hB0,1473
27
+ atex/util/dedent.py,sha256=SEuJMtLzqz3dQ7g7qyZzEJ9VYynVlk52tQCJY-FveXo,603
28
+ atex/util/log.py,sha256=KZkuw4jl8YTUOHZ4wNBrfDeg16VpLa82-IZYFHfqwgk,1995
29
+ atex/util/path.py,sha256=x-kXqiWCVodfZWbEwtC5A8LFvutpDIPYv2m0boZSlXU,504
30
+ atex/util/ssh_keygen.py,sha256=9yuSl2yBV7pG3Qfsf9tossVC00nbIUrAeLdbwTykpjk,384
31
+ atex/util/subprocess.py,sha256=IQT9QHe2kMaaO_XPSry-DwObYstGsq6_QdwdbhYDjko,1826
32
+ atex/util/threads.py,sha256=bezDIEIMcQinmG7f5E2K6_mHJQOlwx7W3I9CKkCYAYA,1830
33
+ atex-0.8.dist-info/METADATA,sha256=dvXW146ZvIfyzqPqGbKmhTNScLTZM7C5K0FLrGNGIJ0,8981
34
+ atex-0.8.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
35
+ atex-0.8.dist-info/entry_points.txt,sha256=pLqJdcfeyQTgup2h6dWb6SvkHhtOl-W5Eg9zV8moK0o,39
36
+ atex-0.8.dist-info/licenses/COPYING.txt,sha256=oEuj51jdmbXcCUy7pZ-KE0BNcJTR1okudRp5zQ0yWnU,670
37
+ atex-0.8.dist-info/RECORD,,
atex/cli/minitmt.py DELETED
@@ -1,175 +0,0 @@
1
- import sys
2
- #import re
3
- import pprint
4
- #import subprocess
5
- from pathlib import Path
6
-
7
- from .. import connection, provision, minitmt
8
- from ..orchestrator import aggregator
9
-
10
-
11
- def _fatal(msg):
12
- print(msg, file=sys.stderr)
13
- sys.exit(1)
14
-
15
-
16
- def _get_context(args):
17
- context = {}
18
- if args.context:
19
- for c in args.context:
20
- key, value = c.split("=", 1)
21
- context[key] = value
22
- return context or None
23
-
24
-
25
- def discover(args):
26
- result = minitmt.fmf.FMFTests(args.root, args.plan, context=_get_context(args))
27
- for name in result.tests:
28
- print(name)
29
-
30
-
31
- def show(args):
32
- result = minitmt.fmf.FMFTests(args.root, args.plan, context=_get_context(args))
33
- if tests := list(result.match(args.test)):
34
- for test in tests:
35
- print(f"\n--- {test.name} ---")
36
- pprint.pprint(test.data)
37
- else:
38
- _fatal(f"Not reachable via {args.plan} discovery: {args.test}")
39
-
40
-
41
- def execute(args):
42
- # remote system connection
43
- ssh_keypath = Path(args.ssh_identity)
44
- if not ssh_keypath.exists():
45
- _fatal(f"SSH Identity {args.ssh_identity} does not exist")
46
- ssh_options = {
47
- "User": args.user,
48
- "Hostname": args.host,
49
- "IdentityFile": ssh_keypath,
50
- }
51
- env = dict(x.split("=",1) for x in args.env)
52
-
53
- # dummy Remote that just wraps the connection
54
- class DummyRemote(provision.Remote, connection.ssh.ManagedSSHConn):
55
- @staticmethod
56
- def release():
57
- return
58
-
59
- @staticmethod
60
- def alive():
61
- return True
62
-
63
- # result aggregation
64
- with aggregator.CSVAggregator(args.results_csv, args.results_dir) as csv_aggregator:
65
- platform_aggregator = csv_aggregator.for_platform(args.platform)
66
-
67
- # tests discovery and selection
68
- result = minitmt.fmf.FMFTests(args.root, args.plan, context=_get_context(args))
69
- if args.test:
70
- tests = list(result.match(args.test))
71
- if not tests:
72
- _fatal(f"Not reachable via plan {args.plan} discovery: {args.test}")
73
- else:
74
- tests = list(result.as_fmftests())
75
- if not tests:
76
- _fatal(f"No tests found for plan {args.plan}")
77
-
78
- # test run
79
- with DummyRemote(ssh_options) as remote:
80
- executor = minitmt.executor.Executor(remote, platform_aggregator, env=env)
81
- executor.upload_tests(args.root)
82
- executor.setup_plan(result)
83
- for test in tests:
84
- executor.run_test(test)
85
-
86
-
87
- def setup_script(args):
88
- result = minitmt.fmf.FMFTests(args.root, args.plan, context=_get_context(args))
89
- try:
90
- test = result.as_fmftest(args.test)
91
- except KeyError:
92
- print(f"Not reachable via {args.plan} discovery: {args.test}")
93
- raise SystemExit(1) from None
94
- output = minitmt.scripts.test_setup(
95
- test=test,
96
- tests_dir=args.remote_root,
97
- debug=args.script_debug,
98
- )
99
- print(output, end="")
100
-
101
-
102
- def parse_args(parser):
103
- parser.add_argument("--root", help="path to directory with fmf tests", default=".")
104
- parser.add_argument("--context", "-c", help="tmt style key=value context", action="append")
105
- cmds = parser.add_subparsers(
106
- dest="_cmd", help="minitmt feature", metavar="<cmd>", required=True,
107
- )
108
-
109
- cmd = cmds.add_parser(
110
- "discover", aliases=("di",),
111
- help="list tests, post-processed by tmt plans",
112
- )
113
- cmd.add_argument("plan", help="tmt plan to use for discovery")
114
-
115
- cmd = cmds.add_parser(
116
- "show",
117
- help="show fmf data of a test",
118
- )
119
- cmd.add_argument("plan", help="tmt plan to use for discovery")
120
- cmd.add_argument("test", help="fmf style test regex")
121
-
122
- cmd = cmds.add_parser(
123
- "execute", aliases=("ex",),
124
- help="run a plan (or test) on a remote system",
125
- )
126
- #grp = cmd.add_mutually_exclusive_group()
127
- #grp.add_argument("--test", "-t", help="fmf style test regex")
128
- #grp.add_argument("--plan", "-p", help="tmt plan name (path) inside metadata root")
129
- cmd.add_argument("--env", "-e", help="environment to pass to prepare/test", action="append")
130
- cmd.add_argument("--test", "-t", help="fmf style test regex")
131
- cmd.add_argument(
132
- "--plan", "-p", help="tmt plan name (path) inside metadata root", required=True,
133
- )
134
- cmd.add_argument("--platform", help="platform name, ie. rhel9@x86_64", required=True)
135
- cmd.add_argument("--user", help="ssh user to connect via", required=True)
136
- cmd.add_argument("--host", help="ssh host to connect to", required=True)
137
- cmd.add_argument(
138
- "--ssh-identity", help="path to a ssh keyfile for login", required=True,
139
- )
140
- cmd.add_argument(
141
- "--results-csv", help="path to would-be-created .csv.gz results", required=True,
142
- )
143
- cmd.add_argument(
144
- "--results-dir", help="path to would-be-created dir for uploaded files", required=True,
145
- )
146
-
147
- cmd = cmds.add_parser(
148
- "setup-script",
149
- help="generate a script prepping tests for run",
150
- )
151
- cmd.add_argument("--remote-root", help="path to tests repo on the remote", required=True)
152
- cmd.add_argument("--script-debug", help="do 'set -x' in the script", action="store_true")
153
- cmd.add_argument("plan", help="tmt plan to use for discovery")
154
- cmd.add_argument("test", help="full fmf test name (not regex)")
155
-
156
-
157
- def main(args):
158
- if args._cmd in ("discover", "di"):
159
- discover(args)
160
- elif args._cmd == "show":
161
- show(args)
162
- elif args._cmd in ("execute", "ex"):
163
- execute(args)
164
- elif args._cmd == "setup-script":
165
- setup_script(args)
166
- else:
167
- raise RuntimeError(f"unknown args: {args}")
168
-
169
-
170
- CLI_SPEC = {
171
- "aliases": ("tmt",),
172
- "help": "simple test executor using atex.minitmt",
173
- "args": parse_args,
174
- "main": main,
175
- }
atex/minitmt/__init__.py DELETED
@@ -1,23 +0,0 @@
1
- """
2
- TODO Minitmt documentation - reference README, etc.
3
- """
4
-
5
- import importlib as _importlib
6
- import pkgutil as _pkgutil
7
-
8
- __all__ = [
9
- info.name for info in _pkgutil.iter_modules(__spec__.submodule_search_locations)
10
- ]
11
-
12
-
13
- def __dir__():
14
- return __all__
15
-
16
-
17
- # lazily import submodules
18
- def __getattr__(attr):
19
- # importing a module known to exist
20
- if attr in __all__:
21
- return _importlib.import_module(f".{attr}", __name__)
22
- else:
23
- raise AttributeError(f"module '{__name__}' has no attribute '{attr}'")