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.
Files changed (48) hide show
  1. atex/cli/fmf.py +143 -0
  2. atex/cli/libvirt.py +127 -0
  3. atex/cli/testingfarm.py +35 -13
  4. atex/connection/__init__.py +13 -19
  5. atex/connection/podman.py +63 -0
  6. atex/connection/ssh.py +34 -52
  7. atex/executor/__init__.py +2 -0
  8. atex/executor/duration.py +60 -0
  9. atex/executor/executor.py +402 -0
  10. atex/executor/reporter.py +101 -0
  11. atex/{minitmt → executor}/scripts.py +37 -25
  12. atex/{minitmt → executor}/testcontrol.py +54 -42
  13. atex/fmf.py +237 -0
  14. atex/orchestrator/__init__.py +3 -59
  15. atex/orchestrator/aggregator.py +82 -134
  16. atex/orchestrator/orchestrator.py +385 -0
  17. atex/provision/__init__.py +74 -105
  18. atex/provision/libvirt/__init__.py +2 -24
  19. atex/provision/libvirt/libvirt.py +465 -0
  20. atex/provision/libvirt/locking.py +168 -0
  21. atex/provision/libvirt/setup-libvirt.sh +21 -1
  22. atex/provision/podman/__init__.py +1 -0
  23. atex/provision/podman/podman.py +274 -0
  24. atex/provision/testingfarm/__init__.py +2 -29
  25. atex/provision/testingfarm/api.py +123 -65
  26. atex/provision/testingfarm/testingfarm.py +234 -0
  27. atex/util/__init__.py +1 -6
  28. atex/util/libvirt.py +18 -0
  29. atex/util/log.py +31 -8
  30. atex/util/named_mapping.py +158 -0
  31. atex/util/path.py +16 -0
  32. atex/util/ssh_keygen.py +14 -0
  33. atex/util/threads.py +99 -0
  34. atex-0.9.dist-info/METADATA +178 -0
  35. atex-0.9.dist-info/RECORD +43 -0
  36. atex/cli/minitmt.py +0 -175
  37. atex/minitmt/__init__.py +0 -23
  38. atex/minitmt/executor.py +0 -348
  39. atex/minitmt/fmf.py +0 -202
  40. atex/provision/nspawn/README +0 -74
  41. atex/provision/podman/README +0 -59
  42. atex/provision/podman/host_container.sh +0 -74
  43. atex/provision/testingfarm/foo.py +0 -1
  44. atex-0.7.dist-info/METADATA +0 -102
  45. atex-0.7.dist-info/RECORD +0 -32
  46. {atex-0.7.dist-info → atex-0.9.dist-info}/WHEEL +0 -0
  47. {atex-0.7.dist-info → atex-0.9.dist-info}/entry_points.txt +0 -0
  48. {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
- #from ... import connection
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 = 10
21
-
22
- RESERVE_TASK = {
23
- "fmf": {
24
- "url": "https://github.com/RHSecurityCompliance/atex",
25
- "ref": "main",
26
- "path": "tmt_tests",
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 3 outstanding HTTP requests to every given API host,
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(maxsize=3, block=True)
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, mine=True, ranch=None, created_before=None, created_after=None,
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
- 'ranch' is 'public' or 'redhat', or (probably?) all if left empty.
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 mine:
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 it being created
293
- if reply.status == 404:
294
- util.debug(f"got 404 for {log}, retrying")
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, api=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"Will reserve compose:{compose} on arch:{arch} for {timeout}min")
433
+ util.info(f"will reserve compose:{compose} on arch:{arch} for {timeout}min")
397
434
  spec = {
398
- "test": RESERVE_TASK,
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
- spec["environments"][0]["hardware"] = hardware
464
+ spec_env["hardware"] = hardware
426
465
  if kickstart:
427
- spec["environments"][0]["kickstart"] = kickstart
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
- @staticmethod
449
- def _gen_ssh_keypair(tmpdir):
450
- tmpdir = Path(tmpdir)
451
- subprocess.run(
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
- try:
462
- # add source_host firewall filter
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
- ingress = \
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._tmpdir = tempfile.TemporaryDirectory()
482
- ssh_key, ssh_pubkey = self._gen_ssh_keypair(self._tmpdir.name)
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
- secrets = spec["environments"][0]["secrets"]
486
- secrets["RESERVE_SSH_PUBKEY"] = pubkey_contents
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
- self.request = Request(api=self.api)
489
- self.request.submit(spec)
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
- util.debug(f"pipeline: {line}")
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(r"\] Guest is ready: ArtemisGuest\([^,]+, (\w+)@([0-9\.]+), ", line)
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.__exit__(*sys.exc_info())
588
+ self.release()
538
589
  raise
539
590
 
540
- def __exit__(self, exc_type, exc_value, traceback):
541
- if self.request:
542
- try:
543
- self.request.cancel()
544
- except APIError:
545
- pass
546
- finally:
547
- self.request = None
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
- if self._tmpdir:
550
- self._tmpdir.cleanup()
551
- self._tmpdir = None
601
+ if self._tmpdir:
602
+ self._tmpdir.cleanup()
603
+ self._tmpdir = None
552
604
 
553
- # cancel request
554
- # clear out stored self.request
555
- pass
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()