atex 0.7__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 +143 -0
- atex/cli/libvirt.py +127 -0
- atex/cli/testingfarm.py +35 -13
- atex/connection/__init__.py +13 -19
- atex/connection/podman.py +63 -0
- atex/connection/ssh.py +34 -52
- atex/executor/__init__.py +2 -0
- atex/executor/duration.py +60 -0
- atex/executor/executor.py +402 -0
- atex/executor/reporter.py +101 -0
- atex/{minitmt → executor}/scripts.py +37 -25
- atex/{minitmt → executor}/testcontrol.py +54 -42
- atex/fmf.py +237 -0
- atex/orchestrator/__init__.py +3 -59
- atex/orchestrator/aggregator.py +82 -134
- atex/orchestrator/orchestrator.py +385 -0
- atex/provision/__init__.py +74 -105
- 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/__init__.py +2 -29
- atex/provision/testingfarm/api.py +123 -65
- atex/provision/testingfarm/testingfarm.py +234 -0
- atex/util/__init__.py +1 -6
- atex/util/libvirt.py +18 -0
- atex/util/log.py +31 -8
- atex/util/named_mapping.py +158 -0
- atex/util/path.py +16 -0
- atex/util/ssh_keygen.py +14 -0
- atex/util/threads.py +99 -0
- atex-0.9.dist-info/METADATA +178 -0
- atex-0.9.dist-info/RECORD +43 -0
- atex/cli/minitmt.py +0 -175
- atex/minitmt/__init__.py +0 -23
- atex/minitmt/executor.py +0 -348
- atex/minitmt/fmf.py +0 -202
- atex/provision/nspawn/README +0 -74
- atex/provision/podman/README +0 -59
- atex/provision/podman/host_container.sh +0 -74
- atex/provision/testingfarm/foo.py +0 -1
- atex-0.7.dist-info/METADATA +0 -102
- atex-0.7.dist-info/RECORD +0 -32
- {atex-0.7.dist-info → atex-0.9.dist-info}/WHEEL +0 -0
- {atex-0.7.dist-info → atex-0.9.dist-info}/entry_points.txt +0 -0
- {atex-0.7.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
|
+
)
|
|
@@ -1,29 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
from .. import Provisioner, Remote
|
|
4
|
-
|
|
5
|
-
#from . import api
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class TestingFarmRemote(Remote):
|
|
9
|
-
def __init__(self, connection, request):
|
|
10
|
-
"""
|
|
11
|
-
'connection' is a class Connection instance.
|
|
12
|
-
|
|
13
|
-
'request' is a testing farm Request class instance.
|
|
14
|
-
"""
|
|
15
|
-
super().__init__(connection)
|
|
16
|
-
self.request = request
|
|
17
|
-
self.valid = True
|
|
18
|
-
|
|
19
|
-
def release(self):
|
|
20
|
-
self.disconnect()
|
|
21
|
-
self.request.cancel()
|
|
22
|
-
self.valid = False
|
|
23
|
-
|
|
24
|
-
def alive(self):
|
|
25
|
-
return self.valid
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class TestingFarmProvisioner(Provisioner):
|
|
29
|
-
pass
|
|
1
|
+
from . import api # noqa: F401
|
|
2
|
+
from .testingfarm import TestingFarmProvisioner, TestingFarmRemote # noqa: F401
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import os
|
|
2
|
-
import sys
|
|
3
2
|
import re
|
|
4
3
|
import time
|
|
5
4
|
import tempfile
|
|
6
5
|
import textwrap
|
|
6
|
+
import threading
|
|
7
7
|
import subprocess
|
|
8
8
|
import collections
|
|
9
9
|
|
|
@@ -17,25 +17,35 @@ import urllib3
|
|
|
17
17
|
DEFAULT_API_URL = "https://api.testing-farm.io/v0.1"
|
|
18
18
|
|
|
19
19
|
# how many seconds to sleep for during API polling
|
|
20
|
-
API_QUERY_DELAY =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"name": "/plans/reserve",
|
|
28
|
-
},
|
|
20
|
+
API_QUERY_DELAY = 30
|
|
21
|
+
|
|
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,
|
|
32
30
|
# https://gitlab.com/testing-farm/nucleus/-/blob/main/api/src/tft/nucleus/api/core/schemes/test_request.py
|
|
33
31
|
END_STATES = ("error", "complete", "canceled")
|
|
34
32
|
|
|
35
|
-
# always have at most
|
|
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):
|
|
@@ -132,15 +142,20 @@ class TestingFarmAPI:
|
|
|
132
142
|
return self._query("GET", f"/composes/{ranch}")
|
|
133
143
|
|
|
134
144
|
def search_requests(
|
|
135
|
-
self, state,
|
|
145
|
+
self, *, state, ranch=None,
|
|
146
|
+
mine=True, user_id=None, token_id=None,
|
|
147
|
+
created_before=None, created_after=None,
|
|
136
148
|
):
|
|
137
149
|
"""
|
|
138
150
|
'state' is one of 'running', 'queued', etc., and is required by the API.
|
|
139
151
|
|
|
152
|
+
'ranch' is 'public' or 'redhat', or (probably?) all if left empty.
|
|
153
|
+
|
|
140
154
|
If 'mine' is True and a token was given, return only requests for that
|
|
141
155
|
token (user), otherwise return *all* requests (use extra filters pls).
|
|
142
156
|
|
|
143
|
-
'
|
|
157
|
+
'user_id' and 'token_id' are search API parameters - if not given and
|
|
158
|
+
'mine' is True, these are extracted from a user-provided token.
|
|
144
159
|
|
|
145
160
|
'created_*' take ISO 8601 formatted strings, as returned by the API
|
|
146
161
|
elsewhere, ie. 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM:SS' (or with '.MS'),
|
|
@@ -154,7 +169,12 @@ class TestingFarmAPI:
|
|
|
154
169
|
if created_after:
|
|
155
170
|
fields["created_after"] = created_after
|
|
156
171
|
|
|
157
|
-
if
|
|
172
|
+
if user_id or token_id:
|
|
173
|
+
if user_id:
|
|
174
|
+
fields["user_id"] = user_id
|
|
175
|
+
if token_id:
|
|
176
|
+
fields["token_id"] = token_id
|
|
177
|
+
elif mine:
|
|
158
178
|
if not self.api_token:
|
|
159
179
|
raise ValueError("search_requests(mine=True) requires an auth token")
|
|
160
180
|
fields["token_id"] = self.whoami()["token"]["id"]
|
|
@@ -289,9 +309,12 @@ class PipelineLogStreamer:
|
|
|
289
309
|
|
|
290
310
|
log = f"{artifacts}/pipeline.log"
|
|
291
311
|
reply = _http.request("HEAD", log)
|
|
292
|
-
# TF has a race condition of adding the .log entry without
|
|
293
|
-
|
|
294
|
-
|
|
312
|
+
# 404: TF has a race condition of adding the .log entry without
|
|
313
|
+
# it being created
|
|
314
|
+
# 403: happens on internal OSCI artifacts server, probably
|
|
315
|
+
# due to similar reasons (folder exists without log)
|
|
316
|
+
if reply.status in (404,403):
|
|
317
|
+
util.debug(f"got {reply.status} for {log}, retrying")
|
|
295
318
|
continue
|
|
296
319
|
elif reply.status != 200:
|
|
297
320
|
raise APIError(f"got HTTP {reply.status} on HEAD {log}", reply)
|
|
@@ -357,7 +380,9 @@ class Reserve:
|
|
|
357
380
|
|
|
358
381
|
def __init__(
|
|
359
382
|
self, *, compose, arch="x86_64", pool=None, hardware=None, kickstart=None,
|
|
360
|
-
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,
|
|
361
386
|
):
|
|
362
387
|
"""
|
|
363
388
|
'compose' (str) is the OS to install, chosen from the composes supported
|
|
@@ -390,18 +415,31 @@ class Reserve:
|
|
|
390
415
|
facing address of the current system.
|
|
391
416
|
Ignored on the 'redhat' ranch.
|
|
392
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
|
+
|
|
393
430
|
'api' is a TestingFarmAPI instance - if unspecified, a sensible default
|
|
394
431
|
will be used.
|
|
395
432
|
"""
|
|
396
|
-
util.info(f"
|
|
433
|
+
util.info(f"will reserve compose:{compose} on arch:{arch} for {timeout}min")
|
|
397
434
|
spec = {
|
|
398
|
-
"test":
|
|
435
|
+
"test": {
|
|
436
|
+
"fmf": reserve_test or DEFAULT_RESERVE_TEST,
|
|
437
|
+
},
|
|
399
438
|
"environments": [{
|
|
400
439
|
"arch": arch,
|
|
401
440
|
"os": {
|
|
402
441
|
"compose": compose,
|
|
403
442
|
},
|
|
404
|
-
"pool": pool,
|
|
405
443
|
"settings": {
|
|
406
444
|
"pipeline": {
|
|
407
445
|
"skip_guest_setup": True,
|
|
@@ -410,10 +448,8 @@ class Reserve:
|
|
|
410
448
|
"tags": {
|
|
411
449
|
"ArtemisUseSpot": "false",
|
|
412
450
|
},
|
|
413
|
-
"security_group_rules_ingress": [],
|
|
414
451
|
},
|
|
415
452
|
},
|
|
416
|
-
"secrets": {},
|
|
417
453
|
}],
|
|
418
454
|
"settings": {
|
|
419
455
|
"pipeline": {
|
|
@@ -421,16 +457,23 @@ class Reserve:
|
|
|
421
457
|
},
|
|
422
458
|
},
|
|
423
459
|
}
|
|
460
|
+
spec_env = spec["environments"][0]
|
|
461
|
+
if pool:
|
|
462
|
+
spec_env["pool"] = pool
|
|
424
463
|
if hardware:
|
|
425
|
-
|
|
464
|
+
spec_env["hardware"] = hardware
|
|
426
465
|
if kickstart:
|
|
427
|
-
|
|
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
|
|
428
470
|
|
|
429
471
|
self._spec = spec
|
|
430
472
|
self._ssh_key = Path(ssh_key) if ssh_key else None
|
|
431
473
|
self._source_host = source_host
|
|
432
474
|
self.api = api or TestingFarmAPI()
|
|
433
475
|
|
|
476
|
+
self.lock = threading.RLock()
|
|
434
477
|
self.request = None
|
|
435
478
|
self._tmpdir = None
|
|
436
479
|
|
|
@@ -445,32 +488,31 @@ class Reserve:
|
|
|
445
488
|
r = _http.request("GET", "https://ifconfig.co", headers=curl_agent)
|
|
446
489
|
return r.data.decode().strip()
|
|
447
490
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
("ssh-keygen", "-t", "rsa", "-N", "", "-f", tmpdir / "key_rsa"),
|
|
453
|
-
stdout=subprocess.DEVNULL,
|
|
454
|
-
check=True,
|
|
455
|
-
)
|
|
456
|
-
return (tmpdir / "key_rsa", tmpdir / "key_rsa.pub")
|
|
491
|
+
def reserve(self):
|
|
492
|
+
with self.lock:
|
|
493
|
+
if self.request:
|
|
494
|
+
raise RuntimeError("reservation already in progress")
|
|
457
495
|
|
|
458
|
-
def __enter__(self):
|
|
459
496
|
spec = self._spec.copy()
|
|
497
|
+
spec_env = spec["environments"][0]
|
|
460
498
|
|
|
461
|
-
|
|
462
|
-
|
|
499
|
+
# add source_host firewall filter on the public ranch
|
|
500
|
+
if self.api.whoami()["token"]["ranch"] == "public":
|
|
463
501
|
source_host = self._source_host or f"{self._guess_host_ipv4()}/32"
|
|
464
|
-
|
|
465
|
-
spec["environments"][0]["settings"]["provisioning"]["security_group_rules_ingress"]
|
|
466
|
-
ingress.append({
|
|
502
|
+
ingress_rule = {
|
|
467
503
|
"type": "ingress",
|
|
468
504
|
"protocol": "-1",
|
|
469
505
|
"cidr": source_host,
|
|
470
506
|
"port_min": 0,
|
|
471
507
|
"port_max": 65535,
|
|
472
|
-
}
|
|
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]
|
|
473
514
|
|
|
515
|
+
try:
|
|
474
516
|
# read user-provided ssh key, or generate one
|
|
475
517
|
ssh_key = self._ssh_key
|
|
476
518
|
if ssh_key:
|
|
@@ -478,23 +520,32 @@ class Reserve:
|
|
|
478
520
|
raise FileNotFoundError(f"{ssh_key} specified, but does not exist")
|
|
479
521
|
ssh_pubkey = Path(f"{ssh_key}.pub")
|
|
480
522
|
else:
|
|
481
|
-
self.
|
|
482
|
-
|
|
523
|
+
with self.lock:
|
|
524
|
+
self._tmpdir = tempfile.TemporaryDirectory()
|
|
525
|
+
ssh_key, ssh_pubkey = util.ssh_keygen(self._tmpdir.name)
|
|
483
526
|
|
|
484
527
|
pubkey_contents = ssh_pubkey.read_text().strip()
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|
487
531
|
|
|
488
|
-
|
|
489
|
-
|
|
532
|
+
with self.lock:
|
|
533
|
+
self.request = Request(api=self.api)
|
|
534
|
+
self.request.submit(spec)
|
|
490
535
|
util.debug(f"submitted request:\n{textwrap.indent(str(self.request), ' ')}")
|
|
491
536
|
|
|
492
537
|
# wait for user/host to ssh to
|
|
493
538
|
ssh_user = ssh_host = None
|
|
494
539
|
for line in PipelineLogStreamer(self.request):
|
|
495
|
-
|
|
540
|
+
# the '\033[0m' is to reset colors sometimes left in a bad
|
|
541
|
+
# state by pipeline.log
|
|
542
|
+
util.debug(f"pipeline: {line}\033[0m")
|
|
496
543
|
# find hidden login details
|
|
497
|
-
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
|
+
)
|
|
498
549
|
if m:
|
|
499
550
|
ssh_user, ssh_host = m.groups()
|
|
500
551
|
continue
|
|
@@ -534,22 +585,29 @@ class Reserve:
|
|
|
534
585
|
)
|
|
535
586
|
|
|
536
587
|
except:
|
|
537
|
-
self.
|
|
588
|
+
self.release()
|
|
538
589
|
raise
|
|
539
590
|
|
|
540
|
-
def
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
591
|
+
def release(self):
|
|
592
|
+
with self.lock:
|
|
593
|
+
if self.request:
|
|
594
|
+
try:
|
|
595
|
+
self.request.cancel()
|
|
596
|
+
except APIError:
|
|
597
|
+
pass
|
|
598
|
+
finally:
|
|
599
|
+
self.request = None
|
|
548
600
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
601
|
+
if self._tmpdir:
|
|
602
|
+
self._tmpdir.cleanup()
|
|
603
|
+
self._tmpdir = None
|
|
552
604
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
605
|
+
def __enter__(self):
|
|
606
|
+
try:
|
|
607
|
+
return self.reserve()
|
|
608
|
+
except Exception:
|
|
609
|
+
self.release()
|
|
610
|
+
raise
|
|
611
|
+
|
|
612
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
613
|
+
self.release()
|