atex 0.8__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.
- atex/cli/fmf.py +73 -23
- atex/cli/libvirt.py +127 -0
- atex/cli/testingfarm.py +12 -0
- atex/connection/__init__.py +13 -11
- atex/connection/podman.py +63 -0
- atex/connection/ssh.py +31 -33
- atex/executor/executor.py +131 -107
- atex/executor/reporter.py +66 -71
- atex/executor/scripts.py +9 -3
- atex/executor/testcontrol.py +43 -30
- atex/fmf.py +94 -74
- atex/orchestrator/__init__.py +3 -2
- atex/orchestrator/aggregator.py +63 -58
- atex/orchestrator/orchestrator.py +194 -133
- atex/provision/__init__.py +11 -11
- atex/provision/libvirt/__init__.py +2 -24
- atex/provision/libvirt/libvirt.py +465 -0
- atex/provision/libvirt/locking.py +168 -0
- atex/provision/libvirt/setup-libvirt.sh +21 -1
- atex/provision/podman/__init__.py +1 -0
- atex/provision/podman/podman.py +274 -0
- atex/provision/testingfarm/api.py +69 -26
- atex/provision/testingfarm/testingfarm.py +29 -31
- atex/util/libvirt.py +18 -0
- atex/util/log.py +23 -8
- atex/util/named_mapping.py +158 -0
- atex/util/threads.py +64 -20
- {atex-0.8.dist-info → atex-0.9.dist-info}/METADATA +27 -46
- atex-0.9.dist-info/RECORD +43 -0
- atex/provision/podman/README +0 -59
- atex/provision/podman/host_container.sh +0 -74
- atex-0.8.dist-info/RECORD +0 -37
- {atex-0.8.dist-info → atex-0.9.dist-info}/WHEEL +0 -0
- {atex-0.8.dist-info → atex-0.9.dist-info}/entry_points.txt +0 -0
- {atex-0.8.dist-info → atex-0.9.dist-info}/licenses/COPYING.txt +0 -0
|
@@ -0,0 +1,274 @@
|
|
|
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
|
+
)
|
|
@@ -19,13 +19,11 @@ DEFAULT_API_URL = "https://api.testing-farm.io/v0.1"
|
|
|
19
19
|
# how many seconds to sleep for during API polling
|
|
20
20
|
API_QUERY_DELAY = 30
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"name": "/plans/reserve",
|
|
28
|
-
},
|
|
22
|
+
DEFAULT_RESERVE_TEST = {
|
|
23
|
+
"url": "https://github.com/RHSecurityCompliance/atex-reserve",
|
|
24
|
+
"ref": "v0.9",
|
|
25
|
+
"path": ".",
|
|
26
|
+
"name": "/plans/reserve",
|
|
29
27
|
}
|
|
30
28
|
|
|
31
29
|
# final states of a request,
|
|
@@ -35,7 +33,19 @@ END_STATES = ("error", "complete", "canceled")
|
|
|
35
33
|
# always have at most 10 outstanding HTTP requests to every given API host,
|
|
36
34
|
# shared by all instances of all classes here, to avoid flooding the host
|
|
37
35
|
# by multi-threaded users
|
|
38
|
-
_http = urllib3.PoolManager(
|
|
36
|
+
_http = urllib3.PoolManager(
|
|
37
|
+
maxsize=10,
|
|
38
|
+
block=True,
|
|
39
|
+
retries=urllib3.Retry(
|
|
40
|
+
total=10,
|
|
41
|
+
# account for API restarts / short outages
|
|
42
|
+
backoff_factor=60,
|
|
43
|
+
backoff_max=600,
|
|
44
|
+
# retry on API server errors too, not just connection issues
|
|
45
|
+
status=10,
|
|
46
|
+
status_forcelist={403,404,408,429,500,502,503,504},
|
|
47
|
+
),
|
|
48
|
+
)
|
|
39
49
|
|
|
40
50
|
|
|
41
51
|
class TestingFarmError(Exception):
|
|
@@ -370,7 +380,9 @@ class Reserve:
|
|
|
370
380
|
|
|
371
381
|
def __init__(
|
|
372
382
|
self, *, compose, arch="x86_64", pool=None, hardware=None, kickstart=None,
|
|
373
|
-
timeout=60, ssh_key=None, source_host=None,
|
|
383
|
+
timeout=60, ssh_key=None, source_host=None,
|
|
384
|
+
reserve_test=None, variables=None, secrets=None,
|
|
385
|
+
api=None,
|
|
374
386
|
):
|
|
375
387
|
"""
|
|
376
388
|
'compose' (str) is the OS to install, chosen from the composes supported
|
|
@@ -403,18 +415,31 @@ class Reserve:
|
|
|
403
415
|
facing address of the current system.
|
|
404
416
|
Ignored on the 'redhat' ranch.
|
|
405
417
|
|
|
418
|
+
'reserve_test' is a dict with a fmf test specification to be run on the
|
|
419
|
+
target system to reserve it, ie.:
|
|
420
|
+
{
|
|
421
|
+
"url": "https://some-host/path/to/repo",
|
|
422
|
+
"ref": "main",
|
|
423
|
+
"name": "/plans/reserve",
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
'variables' and 'secrets' are dicts with environment variable key/values
|
|
427
|
+
exported for the reserve test - variables are visible via TF API,
|
|
428
|
+
secrets are not (but can still be extracted from pipeline log).
|
|
429
|
+
|
|
406
430
|
'api' is a TestingFarmAPI instance - if unspecified, a sensible default
|
|
407
431
|
will be used.
|
|
408
432
|
"""
|
|
409
|
-
util.info(f"
|
|
433
|
+
util.info(f"will reserve compose:{compose} on arch:{arch} for {timeout}min")
|
|
410
434
|
spec = {
|
|
411
|
-
"test":
|
|
435
|
+
"test": {
|
|
436
|
+
"fmf": reserve_test or DEFAULT_RESERVE_TEST,
|
|
437
|
+
},
|
|
412
438
|
"environments": [{
|
|
413
439
|
"arch": arch,
|
|
414
440
|
"os": {
|
|
415
441
|
"compose": compose,
|
|
416
442
|
},
|
|
417
|
-
"pool": pool,
|
|
418
443
|
"settings": {
|
|
419
444
|
"pipeline": {
|
|
420
445
|
"skip_guest_setup": True,
|
|
@@ -423,10 +448,8 @@ class Reserve:
|
|
|
423
448
|
"tags": {
|
|
424
449
|
"ArtemisUseSpot": "false",
|
|
425
450
|
},
|
|
426
|
-
"security_group_rules_ingress": [],
|
|
427
451
|
},
|
|
428
452
|
},
|
|
429
|
-
"secrets": {},
|
|
430
453
|
}],
|
|
431
454
|
"settings": {
|
|
432
455
|
"pipeline": {
|
|
@@ -434,10 +457,16 @@ class Reserve:
|
|
|
434
457
|
},
|
|
435
458
|
},
|
|
436
459
|
}
|
|
460
|
+
spec_env = spec["environments"][0]
|
|
461
|
+
if pool:
|
|
462
|
+
spec_env["pool"] = pool
|
|
437
463
|
if hardware:
|
|
438
|
-
|
|
464
|
+
spec_env["hardware"] = hardware
|
|
439
465
|
if kickstart:
|
|
440
|
-
|
|
466
|
+
spec_env["kickstart"] = kickstart
|
|
467
|
+
if variables:
|
|
468
|
+
spec_env["variables"] = variables
|
|
469
|
+
spec_env["secrets"] = secrets.copy() if secrets else {} # we need it for ssh pubkey
|
|
441
470
|
|
|
442
471
|
self._spec = spec
|
|
443
472
|
self._ssh_key = Path(ssh_key) if ssh_key else None
|
|
@@ -465,20 +494,25 @@ class Reserve:
|
|
|
465
494
|
raise RuntimeError("reservation already in progress")
|
|
466
495
|
|
|
467
496
|
spec = self._spec.copy()
|
|
497
|
+
spec_env = spec["environments"][0]
|
|
468
498
|
|
|
469
|
-
|
|
470
|
-
|
|
499
|
+
# add source_host firewall filter on the public ranch
|
|
500
|
+
if self.api.whoami()["token"]["ranch"] == "public":
|
|
471
501
|
source_host = self._source_host or f"{self._guess_host_ipv4()}/32"
|
|
472
|
-
|
|
473
|
-
spec["environments"][0]["settings"]["provisioning"]["security_group_rules_ingress"]
|
|
474
|
-
ingress.append({
|
|
502
|
+
ingress_rule = {
|
|
475
503
|
"type": "ingress",
|
|
476
504
|
"protocol": "-1",
|
|
477
505
|
"cidr": source_host,
|
|
478
506
|
"port_min": 0,
|
|
479
507
|
"port_max": 65535,
|
|
480
|
-
}
|
|
508
|
+
}
|
|
509
|
+
provisioning = spec_env["settings"]["provisioning"]
|
|
510
|
+
if "security_group_rules_ingress" in provisioning:
|
|
511
|
+
provisioning["security_group_rules_ingress"].append(ingress_rule)
|
|
512
|
+
else:
|
|
513
|
+
provisioning["security_group_rules_ingress"] = [ingress_rule]
|
|
481
514
|
|
|
515
|
+
try:
|
|
482
516
|
# read user-provided ssh key, or generate one
|
|
483
517
|
ssh_key = self._ssh_key
|
|
484
518
|
if ssh_key:
|
|
@@ -491,8 +525,9 @@ class Reserve:
|
|
|
491
525
|
ssh_key, ssh_pubkey = util.ssh_keygen(self._tmpdir.name)
|
|
492
526
|
|
|
493
527
|
pubkey_contents = ssh_pubkey.read_text().strip()
|
|
494
|
-
|
|
495
|
-
|
|
528
|
+
# TODO: split ^^^ into 3 parts (key type, hash, comment), assert it,
|
|
529
|
+
# and anonymize comment in case it contains a secret user/hostname
|
|
530
|
+
spec_env["secrets"]["RESERVE_SSH_PUBKEY"] = pubkey_contents
|
|
496
531
|
|
|
497
532
|
with self.lock:
|
|
498
533
|
self.request = Request(api=self.api)
|
|
@@ -506,7 +541,11 @@ class Reserve:
|
|
|
506
541
|
# state by pipeline.log
|
|
507
542
|
util.debug(f"pipeline: {line}\033[0m")
|
|
508
543
|
# find hidden login details
|
|
509
|
-
m = re.search(
|
|
544
|
+
m = re.search(
|
|
545
|
+
# host address can be an IP address or a hostname
|
|
546
|
+
r"\] Guest is ready: ArtemisGuest\([^,]+, (\w+)@([^,]+), arch=",
|
|
547
|
+
line,
|
|
548
|
+
)
|
|
510
549
|
if m:
|
|
511
550
|
ssh_user, ssh_host = m.groups()
|
|
512
551
|
continue
|
|
@@ -564,7 +603,11 @@ class Reserve:
|
|
|
564
603
|
self._tmpdir = None
|
|
565
604
|
|
|
566
605
|
def __enter__(self):
|
|
567
|
-
|
|
606
|
+
try:
|
|
607
|
+
return self.reserve()
|
|
608
|
+
except Exception:
|
|
609
|
+
self.release()
|
|
610
|
+
raise
|
|
568
611
|
|
|
569
612
|
def __exit__(self, exc_type, exc_value, traceback):
|
|
570
613
|
self.release()
|
|
@@ -14,47 +14,45 @@ class TestingFarmRemote(Remote, connection.ssh.ManagedSSHConn):
|
|
|
14
14
|
as implemented by ManagedSSHConn.
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
|
-
def __init__(self, ssh_options, *, release_hook
|
|
17
|
+
def __init__(self, request_id, ssh_options, *, release_hook):
|
|
18
18
|
"""
|
|
19
|
+
'request_id' is a string with Testing Farm request UUID (for printouts).
|
|
20
|
+
|
|
19
21
|
'ssh_options' are a dict, passed to ManagedSSHConn __init__().
|
|
20
22
|
|
|
21
23
|
'release_hook' is a callable called on .release() in addition
|
|
22
24
|
to disconnecting the connection.
|
|
23
25
|
"""
|
|
24
|
-
#
|
|
26
|
+
# NOTE: self.lock inherited from ManagedSSHConn
|
|
25
27
|
super().__init__(options=ssh_options)
|
|
28
|
+
self.request_id = request_id
|
|
26
29
|
self.release_hook = release_hook
|
|
27
|
-
self.provisioner = provisioner
|
|
28
|
-
self.lock = threading.RLock()
|
|
29
30
|
self.release_called = False
|
|
30
31
|
|
|
31
32
|
def release(self):
|
|
32
33
|
with self.lock:
|
|
33
|
-
if
|
|
34
|
-
self.release_called = True
|
|
35
|
-
else:
|
|
34
|
+
if self.release_called:
|
|
36
35
|
return
|
|
36
|
+
else:
|
|
37
|
+
self.release_called = True
|
|
37
38
|
self.release_hook(self)
|
|
38
39
|
self.disconnect()
|
|
39
40
|
|
|
40
41
|
# not /technically/ a valid repr(), but meh
|
|
41
42
|
def __repr__(self):
|
|
42
43
|
class_name = self.__class__.__name__
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
# return self.valid
|
|
49
|
-
|
|
50
|
-
# TODO: def __str__(self): as root@1.2.3.4 and arch, ranch, etc.
|
|
44
|
+
ssh_user = self.options.get("User", "unknown")
|
|
45
|
+
ssh_host = self.options.get("Hostname", "unknown")
|
|
46
|
+
ssh_port = self.options.get("Port", "unknown")
|
|
47
|
+
ssh_key = self.options.get("IdentityFile", "unknown")
|
|
48
|
+
return f"{class_name}({ssh_user}@{ssh_host}:{ssh_port}@{ssh_key}, {self.request_id})"
|
|
51
49
|
|
|
52
50
|
|
|
53
51
|
class TestingFarmProvisioner(Provisioner):
|
|
54
52
|
# TODO: have max_systems as (min,default,max) tuple; have an algorithm that
|
|
55
53
|
# starts at default and scales up/down as needed
|
|
56
54
|
|
|
57
|
-
def __init__(self, compose, arch="x86_64", *, max_systems=1,
|
|
55
|
+
def __init__(self, compose, arch="x86_64", *, max_systems=1, max_retries=10, **reserve_kwargs):
|
|
58
56
|
"""
|
|
59
57
|
'compose' is a Testing Farm compose to prepare.
|
|
60
58
|
|
|
@@ -63,18 +61,16 @@ class TestingFarmProvisioner(Provisioner):
|
|
|
63
61
|
'max_systems' is an int of how many systems to reserve (and keep
|
|
64
62
|
reserved) in an internal pool.
|
|
65
63
|
|
|
66
|
-
'timeout' is the maximum Testing Farm pipeline timeout (waiting for
|
|
67
|
-
a system + OS installation + reservation time).
|
|
68
|
-
|
|
69
64
|
'max_retries' is a maximum number of provisioning (Testing Farm) errors
|
|
70
65
|
that will be reprovisioned before giving up.
|
|
71
66
|
"""
|
|
72
|
-
|
|
73
|
-
self.compose = compose
|
|
67
|
+
self.lock = threading.RLock()
|
|
68
|
+
self.compose = compose
|
|
74
69
|
self.arch = arch
|
|
75
70
|
self.max_systems = max_systems
|
|
76
|
-
self.
|
|
71
|
+
self.reserve_kwargs = reserve_kwargs
|
|
77
72
|
self.retries = max_retries
|
|
73
|
+
|
|
78
74
|
self._tmpdir = None
|
|
79
75
|
self.ssh_key = self.ssh_pubkey = None
|
|
80
76
|
self.queue = util.ThreadQueue(daemon=True)
|
|
@@ -105,6 +101,8 @@ class TestingFarmProvisioner(Provisioner):
|
|
|
105
101
|
"User": machine.user,
|
|
106
102
|
"Port": machine.port,
|
|
107
103
|
"IdentityFile": machine.ssh_key,
|
|
104
|
+
"ConnectionAttempts": "1000",
|
|
105
|
+
"Compression": "yes",
|
|
108
106
|
}
|
|
109
107
|
|
|
110
108
|
def release_hook(remote):
|
|
@@ -118,9 +116,9 @@ class TestingFarmProvisioner(Provisioner):
|
|
|
118
116
|
tf_reserve.release()
|
|
119
117
|
|
|
120
118
|
remote = TestingFarmRemote(
|
|
119
|
+
tf_reserve.request.id,
|
|
121
120
|
ssh_options,
|
|
122
121
|
release_hook=release_hook,
|
|
123
|
-
provisioner=self,
|
|
124
122
|
)
|
|
125
123
|
remote.connect()
|
|
126
124
|
|
|
@@ -139,9 +137,9 @@ class TestingFarmProvisioner(Provisioner):
|
|
|
139
137
|
tf_reserve = api.Reserve(
|
|
140
138
|
compose=self.compose,
|
|
141
139
|
arch=self.arch,
|
|
142
|
-
timeout=self.timeout,
|
|
143
140
|
ssh_key=self.ssh_key,
|
|
144
141
|
api=self.tf_api,
|
|
142
|
+
**self.reserve_kwargs,
|
|
145
143
|
)
|
|
146
144
|
|
|
147
145
|
# add it to self.reserving even before we schedule a provision,
|
|
@@ -152,7 +150,7 @@ class TestingFarmProvisioner(Provisioner):
|
|
|
152
150
|
# start a background wait
|
|
153
151
|
self.queue.start_thread(
|
|
154
152
|
target=self._wait_for_reservation,
|
|
155
|
-
|
|
153
|
+
target_args=(tf_reserve, initial_delay),
|
|
156
154
|
)
|
|
157
155
|
|
|
158
156
|
def start(self):
|
|
@@ -168,13 +166,13 @@ class TestingFarmProvisioner(Provisioner):
|
|
|
168
166
|
def stop(self):
|
|
169
167
|
with self.lock:
|
|
170
168
|
# abort reservations in progress
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
169
|
+
while self.reserving:
|
|
170
|
+
# testingfarm api.Reserve instances
|
|
171
|
+
self.reserving.pop().release()
|
|
174
172
|
# cancel/release all Remotes ever created by us
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
173
|
+
while self.remotes:
|
|
174
|
+
# TestingFarmRemote instances
|
|
175
|
+
self.remotes.pop().release()
|
|
178
176
|
# explicitly remove the tmpdir rather than relying on destructor
|
|
179
177
|
self._tmpdir.cleanup()
|
|
180
178
|
self._tmpdir = None
|
atex/util/libvirt.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def import_libvirt():
|
|
5
|
+
try:
|
|
6
|
+
libvirt = importlib.import_module("libvirt")
|
|
7
|
+
except ModuleNotFoundError:
|
|
8
|
+
raise ModuleNotFoundError(
|
|
9
|
+
"No module named 'libvirt', you need to install it from the package"
|
|
10
|
+
" manager of your distro, ie. 'dnf install python3-libvirt' as it"
|
|
11
|
+
" requires distro-wide headers to compile. It won't work from PyPI."
|
|
12
|
+
" If using venv, create it with '--system-site-packages'.",
|
|
13
|
+
) from None
|
|
14
|
+
|
|
15
|
+
# suppress console error printing, behave like a good python module
|
|
16
|
+
libvirt.registerErrorHandler(lambda _ctx, _err: None, None)
|
|
17
|
+
|
|
18
|
+
return libvirt
|
atex/util/log.py
CHANGED
|
@@ -9,6 +9,7 @@ def in_debug_mode():
|
|
|
9
9
|
"""
|
|
10
10
|
Return True if the root logger is using the DEBUG (or more verbose) level.
|
|
11
11
|
"""
|
|
12
|
+
# TODO: use _logger.isEnabledFor() ?
|
|
12
13
|
root_level = logging.getLogger().level
|
|
13
14
|
return root_level > 0 and root_level <= logging.DEBUG
|
|
14
15
|
|
|
@@ -31,11 +32,16 @@ def _format_msg(msg, *, skip_frames=0):
|
|
|
31
32
|
|
|
32
33
|
# if the function has 'self' and it looks like a class instance,
|
|
33
34
|
# prepend it to the function name
|
|
34
|
-
|
|
35
|
-
if
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
argvals = inspect.getargvalues(parent.frame)
|
|
36
|
+
if argvals.args:
|
|
37
|
+
if argvals.args[0] == "self":
|
|
38
|
+
self = argvals.locals["self"]
|
|
39
|
+
if hasattr(self, "__class__") and inspect.isclass(self.__class__):
|
|
40
|
+
function = f"{self.__class__.__name__}.{function}"
|
|
41
|
+
elif argvals.args[0] == "cls":
|
|
42
|
+
cls = argvals.locals["cls"]
|
|
43
|
+
if inspect.isclass(cls):
|
|
44
|
+
function = f"{cls.__name__}.{function}"
|
|
39
45
|
|
|
40
46
|
# don't report module name of a function if it's the same as running module
|
|
41
47
|
if parent.filename != module.filename:
|
|
@@ -50,12 +56,21 @@ def _format_msg(msg, *, skip_frames=0):
|
|
|
50
56
|
|
|
51
57
|
|
|
52
58
|
def debug(msg, *, skip_frames=0):
|
|
53
|
-
|
|
59
|
+
if in_debug_mode():
|
|
60
|
+
_logger.debug(_format_msg(msg, skip_frames=skip_frames+1))
|
|
61
|
+
else:
|
|
62
|
+
_logger.debug(msg)
|
|
54
63
|
|
|
55
64
|
|
|
56
65
|
def info(msg, *, skip_frames=0):
|
|
57
|
-
|
|
66
|
+
if in_debug_mode():
|
|
67
|
+
_logger.info(_format_msg(msg, skip_frames=skip_frames+1))
|
|
68
|
+
else:
|
|
69
|
+
_logger.info(msg)
|
|
58
70
|
|
|
59
71
|
|
|
60
72
|
def warning(msg, *, skip_frames=0):
|
|
61
|
-
|
|
73
|
+
if in_debug_mode():
|
|
74
|
+
_logger.warning(_format_msg(msg, skip_frames=skip_frames+1))
|
|
75
|
+
else:
|
|
76
|
+
_logger.warning(msg)
|