atex 0.5__py3-none-any.whl → 0.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- atex/__init__.py +2 -12
- atex/cli/__init__.py +13 -13
- atex/cli/fmf.py +93 -0
- atex/cli/testingfarm.py +71 -61
- atex/connection/__init__.py +117 -0
- atex/connection/ssh.py +390 -0
- atex/executor/__init__.py +2 -0
- atex/executor/duration.py +60 -0
- atex/executor/executor.py +378 -0
- atex/executor/reporter.py +106 -0
- atex/executor/scripts.py +155 -0
- atex/executor/testcontrol.py +353 -0
- atex/fmf.py +217 -0
- atex/orchestrator/__init__.py +2 -0
- atex/orchestrator/aggregator.py +106 -0
- atex/orchestrator/orchestrator.py +324 -0
- atex/provision/__init__.py +101 -90
- atex/provision/libvirt/VM_PROVISION +8 -0
- atex/provision/libvirt/__init__.py +4 -4
- atex/provision/podman/README +59 -0
- atex/provision/podman/host_container.sh +74 -0
- atex/provision/testingfarm/__init__.py +2 -0
- atex/{testingfarm.py → provision/testingfarm/api.py} +170 -132
- atex/provision/testingfarm/testingfarm.py +236 -0
- atex/util/__init__.py +5 -10
- atex/util/dedent.py +1 -1
- atex/util/log.py +20 -12
- atex/util/path.py +16 -0
- atex/util/ssh_keygen.py +14 -0
- atex/util/subprocess.py +14 -13
- atex/util/threads.py +55 -0
- {atex-0.5.dist-info → atex-0.8.dist-info}/METADATA +97 -2
- atex-0.8.dist-info/RECORD +37 -0
- atex/cli/minitmt.py +0 -82
- atex/minitmt/__init__.py +0 -115
- atex/minitmt/fmf.py +0 -168
- atex/minitmt/report.py +0 -174
- atex/minitmt/scripts.py +0 -51
- atex/minitmt/testme.py +0 -3
- atex/orchestrator.py +0 -38
- atex/ssh.py +0 -320
- atex/util/lockable_class.py +0 -38
- atex-0.5.dist-info/RECORD +0 -26
- {atex-0.5.dist-info → atex-0.8.dist-info}/WHEEL +0 -0
- {atex-0.5.dist-info → atex-0.8.dist-info}/entry_points.txt +0 -0
- {atex-0.5.dist-info → atex-0.8.dist-info}/licenses/COPYING.txt +0 -0
|
@@ -1,41 +1,41 @@
|
|
|
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
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
-
from
|
|
12
|
+
from ... import util
|
|
13
13
|
|
|
14
14
|
import json
|
|
15
15
|
import urllib3
|
|
16
16
|
|
|
17
|
-
DEFAULT_API_URL =
|
|
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 =
|
|
20
|
+
API_QUERY_DELAY = 30
|
|
21
21
|
|
|
22
22
|
RESERVE_TASK = {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
"fmf": {
|
|
24
|
+
"url": "https://github.com/RHSecurityCompliance/atex",
|
|
25
|
+
"ref": "main",
|
|
26
|
+
"path": "tmt_tests",
|
|
27
|
+
"name": "/plans/reserve",
|
|
28
28
|
},
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
# final states of a request,
|
|
32
32
|
# https://gitlab.com/testing-farm/nucleus/-/blob/main/api/src/tft/nucleus/api/core/schemes/test_request.py
|
|
33
|
-
END_STATES = (
|
|
33
|
+
END_STATES = ("error", "complete", "canceled")
|
|
34
34
|
|
|
35
|
-
# always have at most
|
|
35
|
+
# always have at most 10 outstanding HTTP requests to every given API host,
|
|
36
36
|
# shared by all instances of all classes here, to avoid flooding the host
|
|
37
37
|
# by multi-threaded users
|
|
38
|
-
_http = urllib3.PoolManager(maxsize=
|
|
38
|
+
_http = urllib3.PoolManager(maxsize=10, block=True)
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
class TestingFarmError(Exception):
|
|
@@ -48,6 +48,8 @@ class APIError(TestingFarmError):
|
|
|
48
48
|
pass
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
# TODO docstrings for these:
|
|
52
|
+
|
|
51
53
|
class BadHTTPError(TestingFarmError):
|
|
52
54
|
pass
|
|
53
55
|
|
|
@@ -74,21 +76,21 @@ class TestingFarmAPI:
|
|
|
74
76
|
Note that token-less operation is supported, with limited functionality.
|
|
75
77
|
"""
|
|
76
78
|
self.api_url = url
|
|
77
|
-
self.api_token = token or os.environ.get(
|
|
79
|
+
self.api_token = token or os.environ.get("TESTING_FARM_API_TOKEN")
|
|
78
80
|
|
|
79
81
|
def _query(self, method, path, *args, headers=None, **kwargs):
|
|
80
|
-
url = f
|
|
82
|
+
url = f"{self.api_url}{path}"
|
|
81
83
|
if headers is not None:
|
|
82
|
-
headers[
|
|
84
|
+
headers["Authorization"] = f"Bearer {self.api_token}"
|
|
83
85
|
else:
|
|
84
|
-
headers = {
|
|
86
|
+
headers = {"Authorization": f"Bearer {self.api_token}"}
|
|
85
87
|
|
|
86
88
|
reply = _http.request(method, url, *args, headers=headers, preload_content=False, **kwargs)
|
|
87
89
|
|
|
88
90
|
if reply.status != 200 and not reply.data:
|
|
89
91
|
raise APIError(f"got HTTP {reply.status} on {method} {url}", reply)
|
|
90
92
|
|
|
91
|
-
if reply.headers.get(
|
|
93
|
+
if reply.headers.get("Content-Type") != "application/json":
|
|
92
94
|
raise BadHTTPError(
|
|
93
95
|
f"HTTP {reply.status} on {method} {url} is not application/json",
|
|
94
96
|
reply,
|
|
@@ -97,7 +99,10 @@ class TestingFarmAPI:
|
|
|
97
99
|
try:
|
|
98
100
|
decoded = reply.json()
|
|
99
101
|
except json.decoder.JSONDecodeError:
|
|
100
|
-
raise BadHTTPError(
|
|
102
|
+
raise BadHTTPError(
|
|
103
|
+
f"failed to decode JSON for {method} {url}: {reply.data}",
|
|
104
|
+
reply,
|
|
105
|
+
) from None
|
|
101
106
|
|
|
102
107
|
if reply.status != 200:
|
|
103
108
|
raise APIError(f"got HTTP {reply.status} on {method} {url}: {decoded}", reply)
|
|
@@ -107,14 +112,14 @@ class TestingFarmAPI:
|
|
|
107
112
|
def whoami(self):
|
|
108
113
|
if not self.api_token:
|
|
109
114
|
raise ValueError("whoami() requires an auth token")
|
|
110
|
-
if hasattr(self,
|
|
115
|
+
if hasattr(self, "_whoami_cached"):
|
|
111
116
|
return self._whoami_cached
|
|
112
117
|
else:
|
|
113
|
-
self._whoami_cached = self._query(
|
|
118
|
+
self._whoami_cached = self._query("GET", "/whoami")
|
|
114
119
|
return self._whoami_cached
|
|
115
120
|
|
|
116
121
|
def about(self):
|
|
117
|
-
return self._query(
|
|
122
|
+
return self._query("GET", "/about")
|
|
118
123
|
|
|
119
124
|
def composes(self, ranch=None):
|
|
120
125
|
"""
|
|
@@ -123,45 +128,55 @@ class TestingFarmAPI:
|
|
|
123
128
|
if not ranch:
|
|
124
129
|
if not self.api_token:
|
|
125
130
|
raise ValueError("composes() requires an auth token to identify ranch")
|
|
126
|
-
ranch = self.whoami()[
|
|
127
|
-
return self._query(
|
|
131
|
+
ranch = self.whoami()["token"]["ranch"]
|
|
132
|
+
return self._query("GET", f"/composes/{ranch}")
|
|
128
133
|
|
|
129
134
|
def search_requests(
|
|
130
|
-
self, state,
|
|
135
|
+
self, *, state, ranch=None,
|
|
136
|
+
mine=True, user_id=None, token_id=None,
|
|
137
|
+
created_before=None, created_after=None,
|
|
131
138
|
):
|
|
132
139
|
"""
|
|
133
140
|
'state' is one of 'running', 'queued', etc., and is required by the API.
|
|
134
141
|
|
|
142
|
+
'ranch' is 'public' or 'redhat', or (probably?) all if left empty.
|
|
143
|
+
|
|
135
144
|
If 'mine' is True and a token was given, return only requests for that
|
|
136
145
|
token (user), otherwise return *all* requests (use extra filters pls).
|
|
137
146
|
|
|
138
|
-
'
|
|
147
|
+
'user_id' and 'token_id' are search API parameters - if not given and
|
|
148
|
+
'mine' is True, these are extracted from a user-provided token.
|
|
139
149
|
|
|
140
150
|
'created_*' take ISO 8601 formatted strings, as returned by the API
|
|
141
151
|
elsewhere, ie. 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM:SS' (or with '.MS'),
|
|
142
152
|
without timezone.
|
|
143
153
|
"""
|
|
144
|
-
fields = {
|
|
154
|
+
fields = {"state": state}
|
|
145
155
|
if ranch:
|
|
146
|
-
fields[
|
|
156
|
+
fields["ranch"] = ranch
|
|
147
157
|
if created_before:
|
|
148
|
-
fields[
|
|
158
|
+
fields["created_before"] = created_before
|
|
149
159
|
if created_after:
|
|
150
|
-
fields[
|
|
151
|
-
|
|
152
|
-
if
|
|
160
|
+
fields["created_after"] = created_after
|
|
161
|
+
|
|
162
|
+
if user_id or token_id:
|
|
163
|
+
if user_id:
|
|
164
|
+
fields["user_id"] = user_id
|
|
165
|
+
if token_id:
|
|
166
|
+
fields["token_id"] = token_id
|
|
167
|
+
elif mine:
|
|
153
168
|
if not self.api_token:
|
|
154
169
|
raise ValueError("search_requests(mine=True) requires an auth token")
|
|
155
|
-
fields[
|
|
156
|
-
fields[
|
|
170
|
+
fields["token_id"] = self.whoami()["token"]["id"]
|
|
171
|
+
fields["user_id"] = self.whoami()["user"]["id"]
|
|
157
172
|
|
|
158
|
-
return self._query(
|
|
173
|
+
return self._query("GET", "/requests", fields=fields)
|
|
159
174
|
|
|
160
175
|
def get_request(self, request_id):
|
|
161
176
|
"""
|
|
162
177
|
'request_id' is the UUID (string) of the request.
|
|
163
178
|
"""
|
|
164
|
-
return self._query(
|
|
179
|
+
return self._query("GET", f"/requests/{request_id}")
|
|
165
180
|
|
|
166
181
|
def submit_request(self, spec):
|
|
167
182
|
"""
|
|
@@ -170,13 +185,13 @@ class TestingFarmAPI:
|
|
|
170
185
|
"""
|
|
171
186
|
if not self.api_token:
|
|
172
187
|
raise ValueError("submit_request() requires an auth token")
|
|
173
|
-
return self._query(
|
|
188
|
+
return self._query("POST", "/requests", json=spec)
|
|
174
189
|
|
|
175
190
|
def cancel_request(self, request_id):
|
|
176
191
|
"""
|
|
177
192
|
'request_id' is the UUID (string) of the request.
|
|
178
193
|
"""
|
|
179
|
-
return self._query(
|
|
194
|
+
return self._query("DELETE", f"/requests/{request_id}")
|
|
180
195
|
|
|
181
196
|
|
|
182
197
|
class Request:
|
|
@@ -185,6 +200,9 @@ class Request:
|
|
|
185
200
|
request.
|
|
186
201
|
"""
|
|
187
202
|
|
|
203
|
+
# TODO: maintain internal time.monotonic() clock and call .update() from
|
|
204
|
+
# functions like .alive() if last update is > API_QUERY_DELAY
|
|
205
|
+
|
|
188
206
|
def __init__(self, id=None, api=None, initial_data=None):
|
|
189
207
|
"""
|
|
190
208
|
'id' is a Testing Farm request UUID
|
|
@@ -204,14 +222,17 @@ class Request:
|
|
|
204
222
|
if self.id:
|
|
205
223
|
raise ValueError("this Request instance already has 'id', refusing submit")
|
|
206
224
|
self.data = self.api.submit_request(spec)
|
|
207
|
-
self.id = self.data[
|
|
225
|
+
self.id = self.data["id"]
|
|
208
226
|
|
|
209
227
|
def update(self):
|
|
210
228
|
"""
|
|
211
229
|
Query Testing Farm API to get a more up-to-date version of the request
|
|
212
|
-
metadata
|
|
230
|
+
metadata. Do not call too frequently.
|
|
231
|
+
This function is also used internally by others, you do not need to
|
|
232
|
+
always call it manually.
|
|
213
233
|
"""
|
|
214
234
|
self.data = self.api.get_request(self.id)
|
|
235
|
+
# TODO: refresh internal time.monotonic() timer
|
|
215
236
|
return self.data
|
|
216
237
|
|
|
217
238
|
def cancel(self):
|
|
@@ -223,26 +244,26 @@ class Request:
|
|
|
223
244
|
return data
|
|
224
245
|
|
|
225
246
|
def alive(self):
|
|
226
|
-
if
|
|
247
|
+
if "state" not in self.data:
|
|
227
248
|
self.update()
|
|
228
|
-
return self.data[
|
|
249
|
+
return self.data["state"] not in END_STATES
|
|
229
250
|
|
|
230
251
|
def assert_alive(self):
|
|
231
252
|
if not self.alive():
|
|
232
|
-
state = self.data[
|
|
253
|
+
state = self.data["state"]
|
|
233
254
|
raise GoneAwayError(f"request {self.data['id']} not alive anymore, entered: {state}")
|
|
234
255
|
|
|
235
256
|
def wait_for_state(self, state):
|
|
236
|
-
if
|
|
257
|
+
if "state" not in self.data:
|
|
237
258
|
self.update()
|
|
238
259
|
self.assert_alive()
|
|
239
|
-
while self.data[
|
|
260
|
+
while self.data["state"] != state:
|
|
240
261
|
time.sleep(API_QUERY_DELAY)
|
|
241
262
|
self.update()
|
|
242
263
|
self.assert_alive()
|
|
243
264
|
|
|
244
265
|
def __repr__(self):
|
|
245
|
-
return f
|
|
266
|
+
return f"Request(id={self.id})"
|
|
246
267
|
|
|
247
268
|
def __str__(self):
|
|
248
269
|
# python has no better dict-pretty-printing logic
|
|
@@ -266,25 +287,30 @@ class PipelineLogStreamer:
|
|
|
266
287
|
|
|
267
288
|
def _wait_for_entry(self):
|
|
268
289
|
while True:
|
|
269
|
-
self.request.wait_for_state(
|
|
290
|
+
self.request.wait_for_state("running")
|
|
270
291
|
|
|
271
292
|
try:
|
|
272
|
-
if
|
|
293
|
+
if "run" not in self.request or "artifacts" not in self.request["run"]:
|
|
273
294
|
continue
|
|
274
295
|
|
|
275
|
-
artifacts = self.request[
|
|
296
|
+
artifacts = self.request["run"]["artifacts"]
|
|
276
297
|
if not artifacts:
|
|
277
298
|
continue
|
|
278
299
|
|
|
279
|
-
log = f
|
|
280
|
-
reply = _http.request(
|
|
281
|
-
# TF has a race condition of adding the .log entry without
|
|
282
|
-
|
|
283
|
-
|
|
300
|
+
log = f"{artifacts}/pipeline.log"
|
|
301
|
+
reply = _http.request("HEAD", log)
|
|
302
|
+
# 404: TF has a race condition of adding the .log entry without
|
|
303
|
+
# it being created
|
|
304
|
+
# 403: happens on internal OSCI artifacts server, probably
|
|
305
|
+
# due to similar reasons (folder exists without log)
|
|
306
|
+
if reply.status in (404,403):
|
|
307
|
+
util.debug(f"got {reply.status} for {log}, retrying")
|
|
284
308
|
continue
|
|
285
309
|
elif reply.status != 200:
|
|
286
310
|
raise APIError(f"got HTTP {reply.status} on HEAD {log}", reply)
|
|
287
311
|
|
|
312
|
+
util.info(f"artifacts: {artifacts}")
|
|
313
|
+
|
|
288
314
|
return log
|
|
289
315
|
|
|
290
316
|
finally:
|
|
@@ -293,17 +319,17 @@ class PipelineLogStreamer:
|
|
|
293
319
|
|
|
294
320
|
def __iter__(self):
|
|
295
321
|
url = self._wait_for_entry()
|
|
296
|
-
buffer =
|
|
322
|
+
buffer = ""
|
|
297
323
|
bytes_read = 0
|
|
298
324
|
while True:
|
|
299
325
|
self.request.assert_alive()
|
|
300
326
|
|
|
301
327
|
try:
|
|
302
|
-
headers = {
|
|
328
|
+
headers = {"Range": f"bytes={bytes_read}-"}
|
|
303
329
|
# load all returned data via .decode() rather than streaming it
|
|
304
330
|
# in chunks, because we don't want to leave the connection open
|
|
305
331
|
# (blocking others) while the user code runs between __next__ calls
|
|
306
|
-
reply = _http.request(
|
|
332
|
+
reply = _http.request("GET", url, headers=headers)
|
|
307
333
|
|
|
308
334
|
# 416=Range Not Satisfiable, typically meaning "no new data to send"
|
|
309
335
|
if reply.status == 416:
|
|
@@ -313,9 +339,9 @@ class PipelineLogStreamer:
|
|
|
313
339
|
raise BadHTTPError(f"got {reply.status} when trying to GET {url}", reply)
|
|
314
340
|
|
|
315
341
|
bytes_read += len(reply.data)
|
|
316
|
-
buffer += reply.data.decode(errors=
|
|
342
|
+
buffer += reply.data.decode(errors="ignore")
|
|
317
343
|
|
|
318
|
-
while (index := buffer.find(
|
|
344
|
+
while (index := buffer.find("\n")) != -1:
|
|
319
345
|
yield buffer[:index]
|
|
320
346
|
buffer = buffer[index+1:]
|
|
321
347
|
|
|
@@ -333,14 +359,17 @@ class Reserve:
|
|
|
333
359
|
When used in a context manager, it produces a ReservedMachine tuple with
|
|
334
360
|
connection details for an ssh client:
|
|
335
361
|
|
|
336
|
-
with Reserve(compose=
|
|
337
|
-
subprocess.run([
|
|
362
|
+
with Reserve(compose="CentOS-Stream-9", timeout=720) as m:
|
|
363
|
+
subprocess.run(["ssh", "-i", m.ssh_key, f"{m.user}@{m.host}", "ls /"])
|
|
338
364
|
"""
|
|
339
365
|
|
|
340
|
-
Reserved = collections.namedtuple(
|
|
366
|
+
Reserved = collections.namedtuple(
|
|
367
|
+
"ReservedMachine",
|
|
368
|
+
("host", "port", "user", "ssh_key", "request"),
|
|
369
|
+
)
|
|
341
370
|
|
|
342
371
|
def __init__(
|
|
343
|
-
self, compose
|
|
372
|
+
self, *, compose, arch="x86_64", pool=None, hardware=None, kickstart=None,
|
|
344
373
|
timeout=60, ssh_key=None, source_host=None, api=None,
|
|
345
374
|
):
|
|
346
375
|
"""
|
|
@@ -377,81 +406,77 @@ class Reserve:
|
|
|
377
406
|
'api' is a TestingFarmAPI instance - if unspecified, a sensible default
|
|
378
407
|
will be used.
|
|
379
408
|
"""
|
|
409
|
+
util.info(f"Will reserve compose:{compose} on arch:{arch} for {timeout}min")
|
|
380
410
|
spec = {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
411
|
+
"test": RESERVE_TASK,
|
|
412
|
+
"environments": [{
|
|
413
|
+
"arch": arch,
|
|
414
|
+
"os": {
|
|
415
|
+
"compose": compose,
|
|
386
416
|
},
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
417
|
+
"pool": pool,
|
|
418
|
+
"settings": {
|
|
419
|
+
"pipeline": {
|
|
420
|
+
"skip_guest_setup": True,
|
|
391
421
|
},
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
422
|
+
"provisioning": {
|
|
423
|
+
"tags": {
|
|
424
|
+
"ArtemisUseSpot": "false",
|
|
395
425
|
},
|
|
396
|
-
|
|
426
|
+
"security_group_rules_ingress": [],
|
|
397
427
|
},
|
|
398
428
|
},
|
|
399
|
-
|
|
429
|
+
"secrets": {},
|
|
400
430
|
}],
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
431
|
+
"settings": {
|
|
432
|
+
"pipeline": {
|
|
433
|
+
"timeout": timeout,
|
|
404
434
|
},
|
|
405
435
|
},
|
|
406
436
|
}
|
|
407
437
|
if hardware:
|
|
408
|
-
spec[
|
|
438
|
+
spec["environments"][0]["hardware"] = hardware
|
|
409
439
|
if kickstart:
|
|
410
|
-
spec[
|
|
440
|
+
spec["environments"][0]["kickstart"] = kickstart
|
|
411
441
|
|
|
412
442
|
self._spec = spec
|
|
413
443
|
self._ssh_key = Path(ssh_key) if ssh_key else None
|
|
414
444
|
self._source_host = source_host
|
|
415
445
|
self.api = api or TestingFarmAPI()
|
|
416
446
|
|
|
447
|
+
self.lock = threading.RLock()
|
|
417
448
|
self.request = None
|
|
418
449
|
self._tmpdir = None
|
|
419
450
|
|
|
420
451
|
@staticmethod
|
|
421
452
|
def _guess_host_ipv4():
|
|
422
|
-
curl_agent = {
|
|
453
|
+
curl_agent = {"User-Agent": "curl/1.2.3"}
|
|
423
454
|
try:
|
|
424
|
-
r = _http.request(
|
|
455
|
+
r = _http.request("GET", "https://ifconfig.me", headers=curl_agent)
|
|
425
456
|
if r.status != 200:
|
|
426
457
|
raise ConnectionError()
|
|
427
458
|
except (ConnectionError, urllib3.exceptions.RequestError):
|
|
428
|
-
r = _http.request(
|
|
459
|
+
r = _http.request("GET", "https://ifconfig.co", headers=curl_agent)
|
|
429
460
|
return r.data.decode().strip()
|
|
430
461
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
['ssh-keygen', '-t', 'rsa', '-N', '', '-f', tmpdir / 'key_rsa'],
|
|
436
|
-
stdout=subprocess.DEVNULL,
|
|
437
|
-
check=True,
|
|
438
|
-
)
|
|
439
|
-
return (tmpdir / 'key_rsa', tmpdir / 'key_rsa.pub')
|
|
462
|
+
def reserve(self):
|
|
463
|
+
with self.lock:
|
|
464
|
+
if self.request:
|
|
465
|
+
raise RuntimeError("reservation already in progress")
|
|
440
466
|
|
|
441
|
-
def __enter__(self):
|
|
442
467
|
spec = self._spec.copy()
|
|
443
468
|
|
|
444
469
|
try:
|
|
445
470
|
# add source_host firewall filter
|
|
446
|
-
source_host = self._source_host or f
|
|
471
|
+
source_host = self._source_host or f"{self._guess_host_ipv4()}/32"
|
|
447
472
|
ingress = \
|
|
448
|
-
spec[
|
|
473
|
+
spec["environments"][0]["settings"]["provisioning"]["security_group_rules_ingress"]
|
|
449
474
|
ingress.append({
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
475
|
+
"type": "ingress",
|
|
476
|
+
"protocol": "-1",
|
|
477
|
+
"cidr": source_host,
|
|
478
|
+
"port_min": 0,
|
|
479
|
+
"port_max": 65535,
|
|
455
480
|
})
|
|
456
481
|
|
|
457
482
|
# read user-provided ssh key, or generate one
|
|
@@ -459,42 +484,46 @@ class Reserve:
|
|
|
459
484
|
if ssh_key:
|
|
460
485
|
if not ssh_key.exists():
|
|
461
486
|
raise FileNotFoundError(f"{ssh_key} specified, but does not exist")
|
|
462
|
-
ssh_pubkey = Path(f
|
|
487
|
+
ssh_pubkey = Path(f"{ssh_key}.pub")
|
|
463
488
|
else:
|
|
464
|
-
self.
|
|
465
|
-
|
|
489
|
+
with self.lock:
|
|
490
|
+
self._tmpdir = tempfile.TemporaryDirectory()
|
|
491
|
+
ssh_key, ssh_pubkey = util.ssh_keygen(self._tmpdir.name)
|
|
466
492
|
|
|
467
493
|
pubkey_contents = ssh_pubkey.read_text().strip()
|
|
468
|
-
secrets = spec[
|
|
469
|
-
secrets[
|
|
494
|
+
secrets = spec["environments"][0]["secrets"]
|
|
495
|
+
secrets["RESERVE_SSH_PUBKEY"] = pubkey_contents
|
|
470
496
|
|
|
471
|
-
|
|
472
|
-
|
|
497
|
+
with self.lock:
|
|
498
|
+
self.request = Request(api=self.api)
|
|
499
|
+
self.request.submit(spec)
|
|
473
500
|
util.debug(f"submitted request:\n{textwrap.indent(str(self.request), ' ')}")
|
|
474
501
|
|
|
475
502
|
# wait for user/host to ssh to
|
|
476
503
|
ssh_user = ssh_host = None
|
|
477
504
|
for line in PipelineLogStreamer(self.request):
|
|
478
|
-
|
|
505
|
+
# the '\033[0m' is to reset colors sometimes left in a bad
|
|
506
|
+
# state by pipeline.log
|
|
507
|
+
util.debug(f"pipeline: {line}\033[0m")
|
|
479
508
|
# find hidden login details
|
|
480
|
-
m = re.search(r
|
|
509
|
+
m = re.search(r"\] Guest is ready: ArtemisGuest\([^,]+, (\w+)@([0-9\.]+), ", line)
|
|
481
510
|
if m:
|
|
482
511
|
ssh_user, ssh_host = m.groups()
|
|
483
512
|
continue
|
|
484
513
|
# but wait until much later despite having login, at least until
|
|
485
514
|
# the test starts running (and we get closer to it inserting our
|
|
486
515
|
# ~/.ssh/authorized_keys entry)
|
|
487
|
-
if ssh_user and re.search(r
|
|
516
|
+
if ssh_user and re.search(r"\] starting tests execution", line):
|
|
488
517
|
break
|
|
489
518
|
|
|
490
519
|
# wait for a successful connection over ssh
|
|
491
520
|
# (it will be failing to login for a while, until the reserve test
|
|
492
521
|
# installs our ssh pubkey into authorized_keys)
|
|
493
|
-
ssh_attempt_cmd =
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
f
|
|
497
|
-
|
|
522
|
+
ssh_attempt_cmd = (
|
|
523
|
+
"ssh", "-q", "-i", ssh_key, f"-oConnectionAttempts={API_QUERY_DELAY}",
|
|
524
|
+
"-oStrictHostKeyChecking=no", "-oUserKnownHostsFile=/dev/null",
|
|
525
|
+
f"{ssh_user}@{ssh_host}", "exit 123",
|
|
526
|
+
)
|
|
498
527
|
while True:
|
|
499
528
|
# wait for API_QUERY_DELAY between ssh retries, seems like GEFN sleep time
|
|
500
529
|
time.sleep(API_QUERY_DELAY)
|
|
@@ -508,25 +537,34 @@ class Reserve:
|
|
|
508
537
|
if proc.returncode == 123:
|
|
509
538
|
break
|
|
510
539
|
|
|
511
|
-
return self.Reserved(
|
|
540
|
+
return self.Reserved(
|
|
541
|
+
host=ssh_host,
|
|
542
|
+
port=22,
|
|
543
|
+
user=ssh_user,
|
|
544
|
+
ssh_key=ssh_key,
|
|
545
|
+
request=self.request,
|
|
546
|
+
)
|
|
512
547
|
|
|
513
548
|
except:
|
|
514
|
-
self.
|
|
549
|
+
self.release()
|
|
515
550
|
raise
|
|
516
551
|
|
|
517
|
-
def
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
552
|
+
def release(self):
|
|
553
|
+
with self.lock:
|
|
554
|
+
if self.request:
|
|
555
|
+
try:
|
|
556
|
+
self.request.cancel()
|
|
557
|
+
except APIError:
|
|
558
|
+
pass
|
|
559
|
+
finally:
|
|
560
|
+
self.request = None
|
|
561
|
+
|
|
562
|
+
if self._tmpdir:
|
|
563
|
+
self._tmpdir.cleanup()
|
|
564
|
+
self._tmpdir = None
|
|
525
565
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
self._tmpdir = None
|
|
566
|
+
def __enter__(self):
|
|
567
|
+
return self.reserve()
|
|
529
568
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
pass
|
|
569
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
570
|
+
self.release()
|