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.
Files changed (46) hide show
  1. atex/__init__.py +2 -12
  2. atex/cli/__init__.py +13 -13
  3. atex/cli/fmf.py +93 -0
  4. atex/cli/testingfarm.py +71 -61
  5. atex/connection/__init__.py +117 -0
  6. atex/connection/ssh.py +390 -0
  7. atex/executor/__init__.py +2 -0
  8. atex/executor/duration.py +60 -0
  9. atex/executor/executor.py +378 -0
  10. atex/executor/reporter.py +106 -0
  11. atex/executor/scripts.py +155 -0
  12. atex/executor/testcontrol.py +353 -0
  13. atex/fmf.py +217 -0
  14. atex/orchestrator/__init__.py +2 -0
  15. atex/orchestrator/aggregator.py +106 -0
  16. atex/orchestrator/orchestrator.py +324 -0
  17. atex/provision/__init__.py +101 -90
  18. atex/provision/libvirt/VM_PROVISION +8 -0
  19. atex/provision/libvirt/__init__.py +4 -4
  20. atex/provision/podman/README +59 -0
  21. atex/provision/podman/host_container.sh +74 -0
  22. atex/provision/testingfarm/__init__.py +2 -0
  23. atex/{testingfarm.py → provision/testingfarm/api.py} +170 -132
  24. atex/provision/testingfarm/testingfarm.py +236 -0
  25. atex/util/__init__.py +5 -10
  26. atex/util/dedent.py +1 -1
  27. atex/util/log.py +20 -12
  28. atex/util/path.py +16 -0
  29. atex/util/ssh_keygen.py +14 -0
  30. atex/util/subprocess.py +14 -13
  31. atex/util/threads.py +55 -0
  32. {atex-0.5.dist-info → atex-0.8.dist-info}/METADATA +97 -2
  33. atex-0.8.dist-info/RECORD +37 -0
  34. atex/cli/minitmt.py +0 -82
  35. atex/minitmt/__init__.py +0 -115
  36. atex/minitmt/fmf.py +0 -168
  37. atex/minitmt/report.py +0 -174
  38. atex/minitmt/scripts.py +0 -51
  39. atex/minitmt/testme.py +0 -3
  40. atex/orchestrator.py +0 -38
  41. atex/ssh.py +0 -320
  42. atex/util/lockable_class.py +0 -38
  43. atex-0.5.dist-info/RECORD +0 -26
  44. {atex-0.5.dist-info → atex-0.8.dist-info}/WHEEL +0 -0
  45. {atex-0.5.dist-info → atex-0.8.dist-info}/entry_points.txt +0 -0
  46. {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 . import util
12
+ from ... import util
13
13
 
14
14
  import json
15
15
  import urllib3
16
16
 
17
- DEFAULT_API_URL = 'https://api.testing-farm.io/v0.1'
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 = 10
20
+ API_QUERY_DELAY = 30
21
21
 
22
22
  RESERVE_TASK = {
23
- 'fmf': {
24
- 'url': 'https://github.com/RHSecurityCompliance/atex',
25
- 'ref': 'main',
26
- 'path': 'tmt_tests',
27
- 'name': "/plans/reserve",
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 = ('error', 'complete', 'canceled')
33
+ END_STATES = ("error", "complete", "canceled")
34
34
 
35
- # always have at most 3 outstanding HTTP requests to every given API host,
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=3, block=True)
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('TESTING_FARM_API_TOKEN')
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'{self.api_url}{path}'
82
+ url = f"{self.api_url}{path}"
81
83
  if headers is not None:
82
- headers['Authorization'] = f'Bearer {self.api_token}'
84
+ headers["Authorization"] = f"Bearer {self.api_token}"
83
85
  else:
84
- headers = {'Authorization': f'Bearer {self.api_token}'}
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('Content-Type') != 'application/json':
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(f"failed to decode JSON for {method} {url}: {reply.data}", reply)
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, '_whoami_cached'):
115
+ if hasattr(self, "_whoami_cached"):
111
116
  return self._whoami_cached
112
117
  else:
113
- self._whoami_cached = self._query('GET', '/whoami')
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('GET', '/about')
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()['token']['ranch']
127
- return self._query('GET', f'/composes/{ranch}')
131
+ ranch = self.whoami()["token"]["ranch"]
132
+ return self._query("GET", f"/composes/{ranch}")
128
133
 
129
134
  def search_requests(
130
- self, state, mine=True, ranch=None, created_before=None, created_after=None,
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
- 'ranch' is 'public' or 'redhat', or (probably?) all if left empty.
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 = {'state': state}
154
+ fields = {"state": state}
145
155
  if ranch:
146
- fields['ranch'] = ranch
156
+ fields["ranch"] = ranch
147
157
  if created_before:
148
- fields['created_before'] = created_before
158
+ fields["created_before"] = created_before
149
159
  if created_after:
150
- fields['created_after'] = created_after
151
-
152
- if mine:
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['token_id'] = self.whoami()['token']['id']
156
- fields['user_id'] = self.whoami()['user']['id']
170
+ fields["token_id"] = self.whoami()["token"]["id"]
171
+ fields["user_id"] = self.whoami()["user"]["id"]
157
172
 
158
- return self._query('GET', '/requests', fields=fields)
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('GET', f'/requests/{request_id}')
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('POST', '/requests', json=spec)
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('DELETE', f'/requests/{request_id}')
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['id']
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 accessible via __str__().
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 'state' not in self.data:
247
+ if "state" not in self.data:
227
248
  self.update()
228
- return self.data['state'] not in END_STATES
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['state']
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 'state' not in self.data:
257
+ if "state" not in self.data:
237
258
  self.update()
238
259
  self.assert_alive()
239
- while self.data['state'] != state:
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'Request(id={self.id})'
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('running')
290
+ self.request.wait_for_state("running")
270
291
 
271
292
  try:
272
- if 'run' not in self.request or 'artifacts' not in self.request['run']:
293
+ if "run" not in self.request or "artifacts" not in self.request["run"]:
273
294
  continue
274
295
 
275
- artifacts = self.request['run']['artifacts']
296
+ artifacts = self.request["run"]["artifacts"]
276
297
  if not artifacts:
277
298
  continue
278
299
 
279
- log = f'{artifacts}/pipeline.log'
280
- reply = _http.request('HEAD', log)
281
- # TF has a race condition of adding the .log entry without it being created
282
- if reply.status == 404:
283
- util.debug(f"got 404 for {log}, retrying")
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 = {'Range': f'bytes={bytes_read}-'}
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('GET', url, headers=headers)
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='ignore')
342
+ buffer += reply.data.decode(errors="ignore")
317
343
 
318
- while (index := buffer.find('\n')) != -1:
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='CentOS-Stream-9', timeout=720) as m:
337
- subprocess.run(['ssh', '-i', m.ssh_key, f'{m.user}@{m.host}', 'ls /'])
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('ReservedMachine', ['user', 'host', 'ssh_key', 'request'])
366
+ Reserved = collections.namedtuple(
367
+ "ReservedMachine",
368
+ ("host", "port", "user", "ssh_key", "request"),
369
+ )
341
370
 
342
371
  def __init__(
343
- self, compose=None, arch='x86_64', pool=None, hardware=None, kickstart=None,
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
- 'test': RESERVE_TASK,
382
- 'environments': [{
383
- 'arch': arch,
384
- 'os': {
385
- 'compose': compose,
411
+ "test": RESERVE_TASK,
412
+ "environments": [{
413
+ "arch": arch,
414
+ "os": {
415
+ "compose": compose,
386
416
  },
387
- 'pool': pool,
388
- 'settings': {
389
- 'pipeline': {
390
- 'skip_guest_setup': True,
417
+ "pool": pool,
418
+ "settings": {
419
+ "pipeline": {
420
+ "skip_guest_setup": True,
391
421
  },
392
- 'provisioning': {
393
- 'tags': {
394
- 'ArtemisUseSpot': 'false',
422
+ "provisioning": {
423
+ "tags": {
424
+ "ArtemisUseSpot": "false",
395
425
  },
396
- 'security_group_rules_ingress': [],
426
+ "security_group_rules_ingress": [],
397
427
  },
398
428
  },
399
- 'secrets': {},
429
+ "secrets": {},
400
430
  }],
401
- 'settings': {
402
- 'pipeline': {
403
- 'timeout': timeout,
431
+ "settings": {
432
+ "pipeline": {
433
+ "timeout": timeout,
404
434
  },
405
435
  },
406
436
  }
407
437
  if hardware:
408
- spec['environments'][0]['hardware'] = hardware
438
+ spec["environments"][0]["hardware"] = hardware
409
439
  if kickstart:
410
- spec['environments'][0]['kickstart'] = kickstart
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 = {'User-Agent': 'curl/1.2.3'}
453
+ curl_agent = {"User-Agent": "curl/1.2.3"}
423
454
  try:
424
- r = _http.request('GET', 'https://ifconfig.me', headers=curl_agent)
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('GET', 'https://ifconfig.co', headers=curl_agent)
459
+ r = _http.request("GET", "https://ifconfig.co", headers=curl_agent)
429
460
  return r.data.decode().strip()
430
461
 
431
- @staticmethod
432
- def _gen_ssh_keypair(tmpdir):
433
- tmpdir = Path(tmpdir)
434
- subprocess.run(
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'{self._guess_host_ipv4()}/32'
471
+ source_host = self._source_host or f"{self._guess_host_ipv4()}/32"
447
472
  ingress = \
448
- spec['environments'][0]['settings']['provisioning']['security_group_rules_ingress']
473
+ spec["environments"][0]["settings"]["provisioning"]["security_group_rules_ingress"]
449
474
  ingress.append({
450
- 'type': 'ingress',
451
- 'protocol': '-1',
452
- 'cidr': source_host,
453
- 'port_min': 0,
454
- 'port_max': 65535,
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'{ssh_key}.pub')
487
+ ssh_pubkey = Path(f"{ssh_key}.pub")
463
488
  else:
464
- self._tmpdir = tempfile.TemporaryDirectory()
465
- ssh_key, ssh_pubkey = self._gen_ssh_keypair(self._tmpdir.name)
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['environments'][0]['secrets']
469
- secrets['RESERVE_SSH_PUBKEY'] = pubkey_contents
494
+ secrets = spec["environments"][0]["secrets"]
495
+ secrets["RESERVE_SSH_PUBKEY"] = pubkey_contents
470
496
 
471
- self.request = Request(api=self.api)
472
- self.request.submit(spec)
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
- util.debug(f"pipeline: {line}")
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'\] Guest is ready: ArtemisGuest\([^,]+, (\w+)@([0-9\.]+), ', line)
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'\] starting tests execution', line):
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
- 'ssh', '-q', '-i', ssh_key, f'-oConnectionAttempts={API_QUERY_DELAY}',
495
- '-oStrictHostKeyChecking=no', '-oUserKnownHostsFile=/dev/null',
496
- f'{ssh_user}@{ssh_host}', 'exit 123',
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(ssh_user, ssh_host, ssh_key, self.request)
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.__exit__(*sys.exc_info())
549
+ self.release()
515
550
  raise
516
551
 
517
- def __exit__(self, exc_type, exc_value, traceback):
518
- if self.request:
519
- try:
520
- self.request.cancel()
521
- except APIError:
522
- pass
523
- finally:
524
- self.request = None
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
- if self._tmpdir:
527
- self._tmpdir.cleanup()
528
- self._tmpdir = None
566
+ def __enter__(self):
567
+ return self.reserve()
529
568
 
530
- # cancel request
531
- # clear out stored self.request
532
- pass
569
+ def __exit__(self, exc_type, exc_value, traceback):
570
+ self.release()