atex 0.9__py3-none-any.whl → 0.11__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 +62 -0
- atex/aggregator/json.py +279 -0
- atex/cli/__init__.py +14 -1
- atex/cli/fmf.py +7 -7
- atex/cli/libvirt.py +3 -2
- atex/cli/testingfarm.py +74 -3
- atex/connection/podman.py +2 -4
- atex/connection/ssh.py +7 -14
- atex/executor/executor.py +21 -20
- atex/executor/scripts.py +5 -3
- atex/executor/testcontrol.py +1 -1
- atex/orchestrator/__init__.py +76 -3
- atex/orchestrator/{orchestrator.py → adhoc.py} +246 -108
- atex/orchestrator/contest.py +94 -0
- atex/{provision → provisioner}/__init__.py +48 -52
- atex/{provision → provisioner}/libvirt/libvirt.py +34 -15
- atex/{provision → provisioner}/libvirt/locking.py +3 -1
- atex/provisioner/podman/__init__.py +2 -0
- atex/provisioner/podman/podman.py +169 -0
- atex/{provision → provisioner}/testingfarm/api.py +56 -48
- atex/{provision → provisioner}/testingfarm/testingfarm.py +43 -45
- atex/util/log.py +62 -67
- atex/util/subprocess.py +46 -12
- atex/util/threads.py +7 -0
- atex-0.11.dist-info/METADATA +86 -0
- atex-0.11.dist-info/RECORD +45 -0
- {atex-0.9.dist-info → atex-0.11.dist-info}/WHEEL +1 -1
- atex/orchestrator/aggregator.py +0 -111
- atex/provision/podman/__init__.py +0 -1
- atex/provision/podman/podman.py +0 -274
- atex-0.9.dist-info/METADATA +0 -178
- atex-0.9.dist-info/RECORD +0 -43
- /atex/{provision → provisioner}/libvirt/VM_PROVISION +0 -0
- /atex/{provision → provisioner}/libvirt/__init__.py +0 -0
- /atex/{provision → provisioner}/libvirt/setup-libvirt.sh +0 -0
- /atex/{provision → provisioner}/testingfarm/__init__.py +0 -0
- {atex-0.9.dist-info → atex-0.11.dist-info}/entry_points.txt +0 -0
- {atex-0.9.dist-info → atex-0.11.dist-info}/licenses/COPYING.txt +0 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: atex
|
|
3
|
+
Version: 0.11
|
|
4
|
+
Summary: Ad-hoc Test EXecutor
|
|
5
|
+
Project-URL: Homepage, https://github.com/RHSecurityCompliance/atex
|
|
6
|
+
License-Expression: GPL-3.0-or-later
|
|
7
|
+
License-File: COPYING.txt
|
|
8
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Software Development :: Testing
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Requires-Dist: fmf>=1.6
|
|
13
|
+
Requires-Dist: pyyaml
|
|
14
|
+
Requires-Dist: urllib3<3,>=2
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# ATEX = Ad-hoc Test EXecutor
|
|
18
|
+
|
|
19
|
+
A collections of Python APIs to provision operating systems, collect
|
|
20
|
+
and execute [FMF](https://github.com/teemtee/fmf/)-style tests, gather
|
|
21
|
+
and organize their results and generate reports from those results.
|
|
22
|
+
|
|
23
|
+
The name comes from a (fairly unique to FMF/TMT ecosystem) approach that
|
|
24
|
+
allows provisioning a pool of systems and scheduling tests on them as one would
|
|
25
|
+
on an ad-hoc pool of thread/process workers - once a worker becomes free,
|
|
26
|
+
it receives a test to run.
|
|
27
|
+
This is in contrast to splitting a large list of N tests onto M workers
|
|
28
|
+
like N/M, which yields significant time penalties due to tests having
|
|
29
|
+
very varies runtimes.
|
|
30
|
+
|
|
31
|
+
Above all, this project is meant to be a toolbox, not a silver-plate solution.
|
|
32
|
+
Use its Python APIs to build a CLI tool for your specific use case.
|
|
33
|
+
The CLI tool provided here is just for demonstration / testing, not for serious
|
|
34
|
+
use - we want to avoid huge modular CLIs for Every Possible Scenario. That's
|
|
35
|
+
the job of the Python API. Any CLI should be simple by nature.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## License
|
|
40
|
+
|
|
41
|
+
Unless specified otherwise, any content within this repository is distributed
|
|
42
|
+
under the GNU GPLv3 license, see the [COPYING.txt](COPYING.txt) file for more.
|
|
43
|
+
|
|
44
|
+
## Environment variables
|
|
45
|
+
|
|
46
|
+
- `ATEX_DEBUG_TEST`
|
|
47
|
+
- Set to `1` to print out detailed runner-related trace within the test output
|
|
48
|
+
stream (as if it was printed out by the test).
|
|
49
|
+
|
|
50
|
+
## Testing this project
|
|
51
|
+
|
|
52
|
+
There are some limited sanity tests provided via `pytest`, although:
|
|
53
|
+
|
|
54
|
+
- Some require additional variables (ie. Testing Farm) and will ERROR
|
|
55
|
+
without them.
|
|
56
|
+
- Some take a long time (ie. Testing Farm) due to system provisioning
|
|
57
|
+
taking a long time, so install `pytest-xdist` and run with a large `-n`.
|
|
58
|
+
|
|
59
|
+
Currently, the recommended approach is to split the execution:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
# synchronously, because podman CLI has concurrency issues
|
|
63
|
+
pytest tests/provision/test_podman.py
|
|
64
|
+
|
|
65
|
+
# in parallel, because provisioning takes a long time
|
|
66
|
+
export TESTING_FARM_API_TOKEN=...
|
|
67
|
+
export TESTING_FARM_COMPOSE=...
|
|
68
|
+
pytest -n 20 tests/provision/test_podman.py
|
|
69
|
+
|
|
70
|
+
# fast enough for synchronous execution
|
|
71
|
+
pytest tests/fmf
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Unsorted notes
|
|
75
|
+
|
|
76
|
+
TODO: codestyle from contest
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
- this is not tmt, the goal is to make a python toolbox *for* making runcontest
|
|
80
|
+
style tools easily, not to replace those tools with tmt-style CLI syntax
|
|
81
|
+
|
|
82
|
+
- the whole point is to make usecase-targeted easy-to-use tools that don't
|
|
83
|
+
intimidate users with 1 KB long command line, and runcontest is a nice example
|
|
84
|
+
|
|
85
|
+
- TL;DR - use a modular pythonic approach, not a gluetool-style long CLI
|
|
86
|
+
```
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
atex/__init__.py,sha256=LdX67gprtHYeAkjLhFPKzpc7ECv2rHxUbHKDGbGXO1c,517
|
|
2
|
+
atex/fmf.py,sha256=gkJXIaRO7_KvwJR-V6Tc1NVn4a9Hq7hoBLQLhxYIdbg,8834
|
|
3
|
+
atex/aggregator/__init__.py,sha256=8mN-glHdzR4icKAUGO4JPodsTrLMdJoeuZsO2CTbhyU,1773
|
|
4
|
+
atex/aggregator/json.py,sha256=tpoUZoZM8EMYhZKwVr4LRtgEIDjRxC11BIKVXZKYPOs,10441
|
|
5
|
+
atex/cli/__init__.py,sha256=Ew2z-gC0jvOmU_DqYgXVQla3p1rTnrz64I63q52aHv4,2899
|
|
6
|
+
atex/cli/fmf.py,sha256=pvj_OIp6XT_nVUwziL7-v_HNbyAtuUmb7k_Ey_KkFJc,3616
|
|
7
|
+
atex/cli/libvirt.py,sha256=6tt5ANb8XBBRXOQsYPTWILThKqf-gvt5AZh5Dctg2PA,3782
|
|
8
|
+
atex/cli/testingfarm.py,sha256=ovgoogmIM2TglS7iQD3liMiEYYtcykS_HRRKbltpW2I,10131
|
|
9
|
+
atex/connection/__init__.py,sha256=dj8ZBcEspom7Z_UjecfLGBRNvLZ3dyGR9q19i_B4xpY,3880
|
|
10
|
+
atex/connection/podman.py,sha256=1T56gh1TgbcQWpTIJHL4NaxZOI6aMg7Xp7sn6PQQyBk,1911
|
|
11
|
+
atex/connection/ssh.py,sha256=9A57b9YR_HI-kIu06Asic1y__JPVXEheDZxjbG2Qcsc,13460
|
|
12
|
+
atex/executor/__init__.py,sha256=XCfhi7QDELjey7N1uzhMjc46Kp1Jsd5bOCf52I27SCE,85
|
|
13
|
+
atex/executor/duration.py,sha256=x06sItKOZi6XA8KszQwZGpIb1Z_L-HWqIwZKo2SDo0s,1759
|
|
14
|
+
atex/executor/executor.py,sha256=toyLVQCDzfw381iEGrvOXoKPsd4SqxMZHwlDSTJGqKk,15792
|
|
15
|
+
atex/executor/reporter.py,sha256=MceFmHFt0bTEClBZbRI1WnFbfMhR0e1noOzcu7gjKuQ,3403
|
|
16
|
+
atex/executor/scripts.py,sha256=riJAQWsV-BFGkJwR2Dmf3R0ZRRZJs9w9iYnPpYaQNaE,5618
|
|
17
|
+
atex/executor/testcontrol.py,sha256=mVrLwQUnDRfUq-5diz-80UvCWWxn1TkcBgmAKhKNb5E,12696
|
|
18
|
+
atex/orchestrator/__init__.py,sha256=8Q1YknyibilXLjWRYkHm_Mr2HMm0SRw8Zv39KypeASM,2059
|
|
19
|
+
atex/orchestrator/adhoc.py,sha256=QpYoPeyQzYFDBM1zgFJKMXH1RtdJixbH5whVX0OP-14,21003
|
|
20
|
+
atex/orchestrator/contest.py,sha256=ADmRlsZPQx-MJ6fWHmBcJOIy3DSPnvwVheVL9Upwtg0,3703
|
|
21
|
+
atex/provisioner/__init__.py,sha256=6hZxQlvTQ0yWWqCRCPqWMoYuim5wDMCcDIYHF-nIfMs,4013
|
|
22
|
+
atex/provisioner/libvirt/VM_PROVISION,sha256=7pkZ-ozgTyK4qNGC-E-HUznr4IhbosWSASbB72Gknl8,2664
|
|
23
|
+
atex/provisioner/libvirt/__init__.py,sha256=pKG5IpZSC2IHs5wL2ecQx_fd9AzAXEbZmDzA7RyZsfM,119
|
|
24
|
+
atex/provisioner/libvirt/libvirt.py,sha256=ZKctK2B51olvWvLxz2pZ2s6LtX_7EJ43LvlyJHnI1Ho,18955
|
|
25
|
+
atex/provisioner/libvirt/locking.py,sha256=AXtDyidZNmUoMmrit26g9iTHDqInrzL_RSQEoc_EAXw,5669
|
|
26
|
+
atex/provisioner/libvirt/setup-libvirt.sh,sha256=oCMy9SCnbC_QuAzO2sFwvB5ui1kMQ6uviHsgdXyoFXc,2428
|
|
27
|
+
atex/provisioner/podman/__init__.py,sha256=dM0JzQXWX7edtWSc0KH0cMFXAjArFn2Vme4j_ZMsdYA,138
|
|
28
|
+
atex/provisioner/podman/podman.py,sha256=ztRypoakSf-jF04iER58tEMUZ4Y6AuzIpNpFXp44bB4,4997
|
|
29
|
+
atex/provisioner/testingfarm/__init__.py,sha256=kZncgLGdRCR4FMaRQr2GTwJ8vjlA-24ri8JO2ueZJuw,113
|
|
30
|
+
atex/provisioner/testingfarm/api.py,sha256=dlXe9brzHERawIx2UTv34u2tOSskdZtXD68-u1MnOHk,21726
|
|
31
|
+
atex/provisioner/testingfarm/testingfarm.py,sha256=yvQzWat92B4UnJNZzCLI8mpAKf_QvHUKyKbjlk5123Q,8573
|
|
32
|
+
atex/util/__init__.py,sha256=cWHFbtQ4mDlKe6lXyPDWRmWJOTcHDGfVuW_-GYa8hB0,1473
|
|
33
|
+
atex/util/dedent.py,sha256=SEuJMtLzqz3dQ7g7qyZzEJ9VYynVlk52tQCJY-FveXo,603
|
|
34
|
+
atex/util/libvirt.py,sha256=kDZmT6xLYEZkQNLZY98gJ2M48DDWXxHF8rQY9PnjB3U,660
|
|
35
|
+
atex/util/log.py,sha256=KVR7ep8n5wtghsvBFCtHiPsMAQBdAmK83E_Jec5t4cU,2230
|
|
36
|
+
atex/util/named_mapping.py,sha256=UBMe9TetjV-DGPhjYjJ42YtC40FVPKAAEROXl9MA5fo,4700
|
|
37
|
+
atex/util/path.py,sha256=x-kXqiWCVodfZWbEwtC5A8LFvutpDIPYv2m0boZSlXU,504
|
|
38
|
+
atex/util/ssh_keygen.py,sha256=9yuSl2yBV7pG3Qfsf9tossVC00nbIUrAeLdbwTykpjk,384
|
|
39
|
+
atex/util/subprocess.py,sha256=_oQN8CNgGoH9GAR6nZlpujYe2HjXFBcCuIkLPw-IxJ4,2971
|
|
40
|
+
atex/util/threads.py,sha256=c8hsEc-8SqJGodInorv_6JxpiHiSkGFGob4qbMmOD2M,3531
|
|
41
|
+
atex-0.11.dist-info/METADATA,sha256=3fRMLBrkoRIHwbY2GheyNkrx4mNVmL_95wlxGjZsORc,3050
|
|
42
|
+
atex-0.11.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
43
|
+
atex-0.11.dist-info/entry_points.txt,sha256=pLqJdcfeyQTgup2h6dWb6SvkHhtOl-W5Eg9zV8moK0o,39
|
|
44
|
+
atex-0.11.dist-info/licenses/COPYING.txt,sha256=oEuj51jdmbXcCUy7pZ-KE0BNcJTR1okudRp5zQ0yWnU,670
|
|
45
|
+
atex-0.11.dist-info/RECORD,,
|
atex/orchestrator/aggregator.py
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
import gzip
|
|
2
|
-
import json
|
|
3
|
-
import shutil
|
|
4
|
-
import threading
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class JSONAggregator:
|
|
9
|
-
"""
|
|
10
|
-
Collects reported results as a GZIP-ed line-JSON and files (logs) from
|
|
11
|
-
multiple test runs under a shared directory.
|
|
12
|
-
|
|
13
|
-
Note that the aggregated JSON file *does not* use the test-based JSON format
|
|
14
|
-
described by executor/RESULTS.md - both use JSON, but are very different.
|
|
15
|
-
|
|
16
|
-
This aggergated format uses a top-level array (on each line) with a fixed
|
|
17
|
-
field order:
|
|
18
|
-
|
|
19
|
-
platform, status, test name, subtest name, files, note
|
|
20
|
-
|
|
21
|
-
All these are strings except 'files', which is another (nested) array
|
|
22
|
-
of strings.
|
|
23
|
-
|
|
24
|
-
If a field is missing in the source result, it is translated to a null
|
|
25
|
-
value.
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
def __init__(self, json_file, storage_dir):
|
|
29
|
-
"""
|
|
30
|
-
'json_file' is a string/Path to a .json.gz file with aggregated results.
|
|
31
|
-
|
|
32
|
-
'storage_dir' is a string/Path of the top-level parent for all
|
|
33
|
-
per-platform / per-test files uploaded by tests.
|
|
34
|
-
"""
|
|
35
|
-
self.lock = threading.RLock()
|
|
36
|
-
self.storage_dir = Path(storage_dir)
|
|
37
|
-
self.json_file = Path(json_file)
|
|
38
|
-
self.json_gzip_fobj = None
|
|
39
|
-
|
|
40
|
-
def open(self):
|
|
41
|
-
if self.json_file.exists():
|
|
42
|
-
raise FileExistsError(f"{self.json_file} already exists")
|
|
43
|
-
self.json_gzip_fobj = gzip.open(self.json_file, "wt", newline="\n")
|
|
44
|
-
|
|
45
|
-
if self.storage_dir.exists():
|
|
46
|
-
raise FileExistsError(f"{self.storage_dir} already exists")
|
|
47
|
-
self.storage_dir.mkdir()
|
|
48
|
-
|
|
49
|
-
def close(self):
|
|
50
|
-
if self.json_gzip_fobj:
|
|
51
|
-
self.json_gzip_fobj.close()
|
|
52
|
-
self.json_gzip_fobj = None
|
|
53
|
-
|
|
54
|
-
def __enter__(self):
|
|
55
|
-
try:
|
|
56
|
-
self.open()
|
|
57
|
-
return self
|
|
58
|
-
except Exception:
|
|
59
|
-
self.close()
|
|
60
|
-
raise
|
|
61
|
-
|
|
62
|
-
def __exit__(self, exc_type, exc_value, traceback):
|
|
63
|
-
self.close()
|
|
64
|
-
|
|
65
|
-
def ingest(self, platform, test_name, results_file, files_dir):
|
|
66
|
-
"""
|
|
67
|
-
Process 'results_file' (string/Path) for reported results and append
|
|
68
|
-
them to the overall aggregated line-JSON file, recursively copying over
|
|
69
|
-
the dir structure under 'files_dir' (string/Path) under the respective
|
|
70
|
-
platform and test name in the aggregated storage dir.
|
|
71
|
-
"""
|
|
72
|
-
platform_dir = self.storage_dir / platform
|
|
73
|
-
test_dir = platform_dir / test_name.lstrip("/")
|
|
74
|
-
if test_dir.exists():
|
|
75
|
-
raise FileExistsError(f"{test_dir} already exists for {test_name}")
|
|
76
|
-
|
|
77
|
-
# parse the results separately, before writing any aggregated output,
|
|
78
|
-
# to ensure that either all results from the test are ingested, or none
|
|
79
|
-
# at all (ie. if one of the result lines contains JSON errors)
|
|
80
|
-
output_lines = []
|
|
81
|
-
with open(results_file) as results_fobj:
|
|
82
|
-
for raw_line in results_fobj:
|
|
83
|
-
result_line = json.loads(raw_line)
|
|
84
|
-
|
|
85
|
-
file_names = []
|
|
86
|
-
if "testout" in result_line:
|
|
87
|
-
file_names.append(result_line["testout"])
|
|
88
|
-
if "files" in result_line:
|
|
89
|
-
file_names += (f["name"] for f in result_line["files"])
|
|
90
|
-
|
|
91
|
-
output_line = (
|
|
92
|
-
platform,
|
|
93
|
-
result_line["status"],
|
|
94
|
-
test_name,
|
|
95
|
-
result_line.get("name"),
|
|
96
|
-
file_names,
|
|
97
|
-
result_line.get("note"),
|
|
98
|
-
)
|
|
99
|
-
encoded = json.dumps(output_line, indent=None)
|
|
100
|
-
output_lines.append(encoded)
|
|
101
|
-
|
|
102
|
-
output_str = "\n".join(output_lines) + "\n"
|
|
103
|
-
|
|
104
|
-
with self.lock:
|
|
105
|
-
self.json_gzip_fobj.write(output_str)
|
|
106
|
-
self.json_gzip_fobj.flush()
|
|
107
|
-
|
|
108
|
-
Path(results_file).unlink()
|
|
109
|
-
|
|
110
|
-
platform_dir.mkdir(exist_ok=True)
|
|
111
|
-
shutil.move(files_dir, test_dir)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
from .podman import PodmanProvisioner, PodmanRemote # noqa: F401
|
atex/provision/podman/podman.py
DELETED
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import time
|
|
3
|
-
import enum
|
|
4
|
-
import threading
|
|
5
|
-
import subprocess
|
|
6
|
-
|
|
7
|
-
from ... import connection, util
|
|
8
|
-
from .. import Provisioner, Remote
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class PodmanRemote(Remote, connection.podman.PodmanConn):
|
|
12
|
-
"""
|
|
13
|
-
Built on the official Remote API, pulling in the Connection API
|
|
14
|
-
as implemented by ManagedSSHConn.
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
def __init__(self, image, container, *, release_hook):
|
|
18
|
-
"""
|
|
19
|
-
'image' is an image tag (used for repr()).
|
|
20
|
-
|
|
21
|
-
'container' is a podman container id / name.
|
|
22
|
-
|
|
23
|
-
'release_hook' is a callable called on .release() in addition
|
|
24
|
-
to disconnecting the connection.
|
|
25
|
-
"""
|
|
26
|
-
super().__init__(container=container)
|
|
27
|
-
self.lock = threading.RLock()
|
|
28
|
-
self.image = image
|
|
29
|
-
self.container = container
|
|
30
|
-
self.release_called = False
|
|
31
|
-
self.release_hook = release_hook
|
|
32
|
-
|
|
33
|
-
def release(self):
|
|
34
|
-
with self.lock:
|
|
35
|
-
if self.release_called:
|
|
36
|
-
return
|
|
37
|
-
else:
|
|
38
|
-
self.release_called = True
|
|
39
|
-
self.release_hook(self)
|
|
40
|
-
self.disconnect()
|
|
41
|
-
util.subprocess_run(
|
|
42
|
-
("podman", "container", "rm", "-f", "-t", "0", self.container),
|
|
43
|
-
check=False, # ignore if it fails
|
|
44
|
-
stdout=subprocess.DEVNULL,
|
|
45
|
-
)
|
|
46
|
-
|
|
47
|
-
# not /technically/ a valid repr(), but meh
|
|
48
|
-
def __repr__(self):
|
|
49
|
-
class_name = self.__class__.__name__
|
|
50
|
-
|
|
51
|
-
if "/" in self.image:
|
|
52
|
-
image = self.image.rsplit("/",1)[1]
|
|
53
|
-
elif len(self.image) > 20:
|
|
54
|
-
image = f"{self.image[:17]}..."
|
|
55
|
-
else:
|
|
56
|
-
image = self.image
|
|
57
|
-
|
|
58
|
-
name = f"{self.container[:17]}..." if len(self.container) > 20 else self.container
|
|
59
|
-
|
|
60
|
-
return f"{class_name}({image}, {name})"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
class PodmanProvisioner(Provisioner):
|
|
64
|
-
class State(enum.Enum):
|
|
65
|
-
WAITING_FOR_PULL = enum.auto()
|
|
66
|
-
CREATING_CONTAINER = enum.auto()
|
|
67
|
-
WAITING_FOR_CREATION = enum.auto()
|
|
68
|
-
SETTING_UP_CONTAINER = enum.auto()
|
|
69
|
-
WAITING_FOR_SETUP = enum.auto()
|
|
70
|
-
|
|
71
|
-
# NOTE: this uses a single Popen process to run podman commands,
|
|
72
|
-
# to avoid double downloads/pulls, but also to avoid SQLite errors
|
|
73
|
-
# when creating multiple containers in parallel
|
|
74
|
-
|
|
75
|
-
def __init__(self, image, run_options=None, *, pull=True, max_systems=1):
|
|
76
|
-
"""
|
|
77
|
-
'image' is a string of image tag/id to create containers from.
|
|
78
|
-
It can be a local identifier or an URL.
|
|
79
|
-
|
|
80
|
-
'run_options' is an iterable with additional CLI options passed
|
|
81
|
-
to 'podman container run'.
|
|
82
|
-
|
|
83
|
-
'pull' specifies whether to attempt 'podman image pull' on the specified
|
|
84
|
-
image tag/id before any container creation.
|
|
85
|
-
|
|
86
|
-
'max_systems' is a maximum number of containers running at any one time.
|
|
87
|
-
"""
|
|
88
|
-
self.lock = threading.RLock()
|
|
89
|
-
self.image = image
|
|
90
|
-
self.run_options = run_options or ()
|
|
91
|
-
self.pull = pull
|
|
92
|
-
self.max_systems = max_systems
|
|
93
|
-
|
|
94
|
-
self.image_id = None
|
|
95
|
-
self.container_id = None
|
|
96
|
-
self.worker = None
|
|
97
|
-
self.worker_output = bytearray()
|
|
98
|
-
self.state = None
|
|
99
|
-
# created PodmanRemote instances, ready to be handed over to the user,
|
|
100
|
-
# or already in use by the user
|
|
101
|
-
self.remotes = []
|
|
102
|
-
|
|
103
|
-
@staticmethod
|
|
104
|
-
def _spawn_proc(cmd):
|
|
105
|
-
proc = util.subprocess_Popen(
|
|
106
|
-
cmd,
|
|
107
|
-
stdin=subprocess.DEVNULL,
|
|
108
|
-
stdout=subprocess.PIPE,
|
|
109
|
-
stderr=subprocess.STDOUT,
|
|
110
|
-
)
|
|
111
|
-
os.set_blocking(proc.stdout.fileno(), False)
|
|
112
|
-
return proc
|
|
113
|
-
|
|
114
|
-
# @staticmethod
|
|
115
|
-
# def _poll_proc(proc):
|
|
116
|
-
# # read from the process to un-block any kernel buffers
|
|
117
|
-
# try:
|
|
118
|
-
# out = proc.stdout.read() # non-blocking
|
|
119
|
-
# except BlockingIOError:
|
|
120
|
-
# out = ""
|
|
121
|
-
# return (proc.poll(), out)
|
|
122
|
-
|
|
123
|
-
def _make_remote(self, container):
|
|
124
|
-
def release_hook(remote):
|
|
125
|
-
# remove from the list of remotes inside this Provisioner
|
|
126
|
-
with self.lock:
|
|
127
|
-
try:
|
|
128
|
-
self.remotes.remove(remote)
|
|
129
|
-
except ValueError:
|
|
130
|
-
pass
|
|
131
|
-
|
|
132
|
-
remote = PodmanRemote(
|
|
133
|
-
self.image,
|
|
134
|
-
container,
|
|
135
|
-
release_hook=release_hook,
|
|
136
|
-
)
|
|
137
|
-
self.remotes.append(remote)
|
|
138
|
-
return remote
|
|
139
|
-
|
|
140
|
-
def start(self):
|
|
141
|
-
if not self.image:
|
|
142
|
-
raise ValueError("image cannot be empty")
|
|
143
|
-
|
|
144
|
-
if not self.pull:
|
|
145
|
-
self.image_id = self.image
|
|
146
|
-
self.state = self.State.CREATING_CONTAINER
|
|
147
|
-
else:
|
|
148
|
-
self.worker = self._spawn_proc(
|
|
149
|
-
("podman", "image", "pull", "--quiet", self.image),
|
|
150
|
-
)
|
|
151
|
-
self.state = self.State.WAITING_FOR_PULL
|
|
152
|
-
|
|
153
|
-
def stop(self):
|
|
154
|
-
with self.lock:
|
|
155
|
-
while self.remotes:
|
|
156
|
-
self.remotes.pop().release()
|
|
157
|
-
worker = self.worker
|
|
158
|
-
self.worker = None
|
|
159
|
-
|
|
160
|
-
if worker:
|
|
161
|
-
worker.kill()
|
|
162
|
-
# don"t zombie forever, return EPIPE on any attempts to write to us
|
|
163
|
-
worker.stdout.close()
|
|
164
|
-
worker.wait()
|
|
165
|
-
|
|
166
|
-
def stop_defer(self):
|
|
167
|
-
# avoid SQLite errors by removing containers sequentially
|
|
168
|
-
return self.stop
|
|
169
|
-
|
|
170
|
-
@staticmethod
|
|
171
|
-
def _nonblock_read(fobj):
|
|
172
|
-
"""Return b'' if there was nothing to read, instead of None."""
|
|
173
|
-
data = fobj.read()
|
|
174
|
-
return b"" if data is None else data
|
|
175
|
-
|
|
176
|
-
def _get_remote_nonblock(self):
|
|
177
|
-
if self.state is None:
|
|
178
|
-
raise RuntimeError("the provisioner is in an invalid state")
|
|
179
|
-
|
|
180
|
-
# NOTE: these are not 'elif' statements explicitly to allow a next block
|
|
181
|
-
# to follow a previous one (if the condition is met)
|
|
182
|
-
if self.state is self.State.WAITING_FOR_PULL:
|
|
183
|
-
self.worker_output += self._nonblock_read(self.worker.stdout)
|
|
184
|
-
rc = self.worker.poll()
|
|
185
|
-
if rc is None:
|
|
186
|
-
return None # still running
|
|
187
|
-
elif rc != 0:
|
|
188
|
-
out = self.worker_output.decode().rstrip("\n")
|
|
189
|
-
self.worker_output.clear()
|
|
190
|
-
self.worker = None
|
|
191
|
-
self.state = None
|
|
192
|
-
raise RuntimeError(f"podman image pull failed with {rc}:\n{out}")
|
|
193
|
-
else:
|
|
194
|
-
self.image_id = self.worker_output.decode().rstrip("\n")
|
|
195
|
-
self.worker_output.clear()
|
|
196
|
-
self.worker = None
|
|
197
|
-
self.state = self.State.CREATING_CONTAINER
|
|
198
|
-
|
|
199
|
-
if self.state is self.State.CREATING_CONTAINER:
|
|
200
|
-
if len(self.remotes) < self.max_systems:
|
|
201
|
-
self.worker = self._spawn_proc(
|
|
202
|
-
(
|
|
203
|
-
"podman", "container", "run", "--quiet", "--detach", "--pull", "never",
|
|
204
|
-
*self.run_options, self.image_id, "sleep", "inf",
|
|
205
|
-
),
|
|
206
|
-
)
|
|
207
|
-
self.state = self.State.WAITING_FOR_CREATION
|
|
208
|
-
else:
|
|
209
|
-
# too many remotes requested
|
|
210
|
-
return None
|
|
211
|
-
|
|
212
|
-
if self.state is self.State.WAITING_FOR_CREATION:
|
|
213
|
-
self.worker_output += self._nonblock_read(self.worker.stdout)
|
|
214
|
-
rc = self.worker.poll()
|
|
215
|
-
if rc is None:
|
|
216
|
-
return None # still running
|
|
217
|
-
elif rc != 0:
|
|
218
|
-
out = self.worker_output.decode().rstrip("\n")
|
|
219
|
-
self.worker_output.clear()
|
|
220
|
-
self.worker = None
|
|
221
|
-
self.state = None
|
|
222
|
-
raise RuntimeError(f"podman run failed with {rc}:\n{out}")
|
|
223
|
-
else:
|
|
224
|
-
self.container_id = self.worker_output.decode().rstrip("\n")
|
|
225
|
-
self.worker_output.clear()
|
|
226
|
-
self.worker = None
|
|
227
|
-
self.state = self.State.SETTING_UP_CONTAINER
|
|
228
|
-
|
|
229
|
-
if self.state is self.State.SETTING_UP_CONTAINER:
|
|
230
|
-
cmd = ("dnf", "install", "-y", "-q", "--setopt=install_weak_deps=False", "rsync")
|
|
231
|
-
self.worker = self._spawn_proc(
|
|
232
|
-
("podman", "container", "exec", self.container_id, *cmd),
|
|
233
|
-
)
|
|
234
|
-
self.state = self.State.WAITING_FOR_SETUP
|
|
235
|
-
|
|
236
|
-
if self.state is self.State.WAITING_FOR_SETUP:
|
|
237
|
-
self.worker_output += self._nonblock_read(self.worker.stdout)
|
|
238
|
-
rc = self.worker.poll()
|
|
239
|
-
if rc is None:
|
|
240
|
-
return None # still running
|
|
241
|
-
elif rc != 0:
|
|
242
|
-
out = self.worker_output.decode().rstrip("\n")
|
|
243
|
-
self.worker_output.clear()
|
|
244
|
-
self.worker = None
|
|
245
|
-
self.state = None
|
|
246
|
-
raise RuntimeError(f"setting up failed with {rc}:\n{out}")
|
|
247
|
-
else:
|
|
248
|
-
# everything ready, give the Remote to the caller and reset
|
|
249
|
-
remote = self._make_remote(self.container_id)
|
|
250
|
-
self.worker_output.clear()
|
|
251
|
-
self.worker = None
|
|
252
|
-
self.state = self.State.CREATING_CONTAINER
|
|
253
|
-
return remote
|
|
254
|
-
|
|
255
|
-
raise AssertionError(f"reached end (invalid state {self.state}?)")
|
|
256
|
-
|
|
257
|
-
def get_remote(self, block=True):
|
|
258
|
-
if not block:
|
|
259
|
-
with self.lock:
|
|
260
|
-
return self._get_remote_nonblock()
|
|
261
|
-
else:
|
|
262
|
-
while True:
|
|
263
|
-
with self.lock:
|
|
264
|
-
if remote := self._get_remote_nonblock():
|
|
265
|
-
return remote
|
|
266
|
-
time.sleep(0.1)
|
|
267
|
-
|
|
268
|
-
# not /technically/ a valid repr(), but meh
|
|
269
|
-
def __repr__(self):
|
|
270
|
-
class_name = self.__class__.__name__
|
|
271
|
-
return (
|
|
272
|
-
f"{class_name}({self.image}, {len(self.remotes)}/{self.max_systems} remotes, "
|
|
273
|
-
f"{hex(id(self))})"
|
|
274
|
-
)
|