atex 0.9__py3-none-any.whl → 0.11__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 (38) hide show
  1. atex/aggregator/__init__.py +62 -0
  2. atex/aggregator/json.py +279 -0
  3. atex/cli/__init__.py +14 -1
  4. atex/cli/fmf.py +7 -7
  5. atex/cli/libvirt.py +3 -2
  6. atex/cli/testingfarm.py +74 -3
  7. atex/connection/podman.py +2 -4
  8. atex/connection/ssh.py +7 -14
  9. atex/executor/executor.py +21 -20
  10. atex/executor/scripts.py +5 -3
  11. atex/executor/testcontrol.py +1 -1
  12. atex/orchestrator/__init__.py +76 -3
  13. atex/orchestrator/{orchestrator.py → adhoc.py} +246 -108
  14. atex/orchestrator/contest.py +94 -0
  15. atex/{provision → provisioner}/__init__.py +48 -52
  16. atex/{provision → provisioner}/libvirt/libvirt.py +34 -15
  17. atex/{provision → provisioner}/libvirt/locking.py +3 -1
  18. atex/provisioner/podman/__init__.py +2 -0
  19. atex/provisioner/podman/podman.py +169 -0
  20. atex/{provision → provisioner}/testingfarm/api.py +56 -48
  21. atex/{provision → provisioner}/testingfarm/testingfarm.py +43 -45
  22. atex/util/log.py +62 -67
  23. atex/util/subprocess.py +46 -12
  24. atex/util/threads.py +7 -0
  25. atex-0.11.dist-info/METADATA +86 -0
  26. atex-0.11.dist-info/RECORD +45 -0
  27. {atex-0.9.dist-info → atex-0.11.dist-info}/WHEEL +1 -1
  28. atex/orchestrator/aggregator.py +0 -111
  29. atex/provision/podman/__init__.py +0 -1
  30. atex/provision/podman/podman.py +0 -274
  31. atex-0.9.dist-info/METADATA +0 -178
  32. atex-0.9.dist-info/RECORD +0 -43
  33. /atex/{provision → provisioner}/libvirt/VM_PROVISION +0 -0
  34. /atex/{provision → provisioner}/libvirt/__init__.py +0 -0
  35. /atex/{provision → provisioner}/libvirt/setup-libvirt.sh +0 -0
  36. /atex/{provision → provisioner}/testingfarm/__init__.py +0 -0
  37. {atex-0.9.dist-info → atex-0.11.dist-info}/entry_points.txt +0 -0
  38. {atex-0.9.dist-info → atex-0.11.dist-info}/licenses/COPYING.txt +0 -0
@@ -4,41 +4,74 @@ import pkgutil as _pkgutil
4
4
  from .. import connection as _connection
5
5
 
6
6
 
7
+ class Remote(_connection.Connection):
8
+ """
9
+ Representation of a provisioned (reserved) remote system, providing
10
+ a Connection-like API in addition to system management helpers.
11
+
12
+ An instance of Remote is typically prepared by a Provisioner and returned
13
+ to the caller for use and an eventual .release().
14
+
15
+ Also note that Remote can be used via Context Manager, but does not
16
+ do automatic .release(), the manager only handles the built-in Connection.
17
+ The intention is for a Provisioner to run via its own Contest Manager and
18
+ release all Remotes upon exit.
19
+ If you need automatic release of one Remote, use a try/finally block.
20
+ """
21
+
22
+ def release(self):
23
+ """
24
+ Release (de-provision) the remote resource.
25
+ """
26
+ raise NotImplementedError(f"'release' not implemented for {self.__class__.__name__}")
27
+
28
+
7
29
  class Provisioner:
8
30
  """
9
31
  A remote resource (machine/system) provider.
10
32
 
11
- The main interface is .get_remote() that returns a connected class Remote
12
- instance for use by the user, to be .release()d when not needed anymore,
13
- with Provisioner automatically getting a replacement for it, to be returned
14
- via .get_remote() later.
33
+ The idea is to request machines (a.k.a. Remotes, or class Remote instances)
34
+ to be reserved via a non-blocking .provision() and for them to be retrieved
35
+ through blocking / non-blocking .get_remote() when they become available.
36
+
37
+ Each Remote has its own .release() for freeing (de-provisioning) it once
38
+ the user doesn't need it anymore. The Provisioner does this automatically
39
+ to all Remotes during .stop() or context manager exit.
15
40
 
16
41
  p = Provisioner()
17
42
  p.start()
43
+ p.provision(count=1)
18
44
  remote = p.get_remote()
19
45
  remote.cmd(["ls", "/"])
20
46
  remote.release()
21
47
  p.stop()
22
48
 
23
49
  with Provisioner() as p:
24
- remote = p.get_remote()
50
+ p.provision(count=2)
51
+ remote1 = p.get_remote()
52
+ remote2 = p.get_remote()
25
53
  ...
26
- remote.release()
27
-
28
- TODO: mention how a Provisioner always needs to take care of release all Remotes
29
- when .stop()ped or when context terminates; even the ones handed over to
30
- the user
31
54
 
32
- Note that .stop() or .defer_stop() may be called from a different
33
- thread, asynchronously to any other functions.
55
+ Note that .provision() is a hint expressed by the caller, not a guarantee
56
+ that .get_remote() will ever return a Remote. Ie. the caller can call
57
+ .provision(count=math.inf) to receive as many remotes as the Provisioner
58
+ can possibly supply.
34
59
  """
35
60
 
61
+ def provision(self, count=1):
62
+ """
63
+ Request that 'count' machines be provisioned (reserved) for use,
64
+ to be returned at a later point by .get_remote().
65
+ """
66
+ raise NotImplementedError(f"'provision' not implemented for {self.__class__.__name__}")
67
+
36
68
  def get_remote(self, block=True):
37
69
  """
38
- Get a connected class Remote instance.
70
+ Return a connected class Remote instance of a previously .provision()ed
71
+ remote system.
39
72
 
40
- If 'block' is True, wait for the remote to be available and connected,
41
- otherwise return None if there is no Remote available yet.
73
+ If 'block' is True, wait for the Remote to be available and connected,
74
+ otherwise return None if there is none available yet.
42
75
  """
43
76
  raise NotImplementedError(f"'get_remote' not implemented for {self.__class__.__name__}")
44
77
 
@@ -56,18 +89,6 @@ class Provisioner:
56
89
  """
57
90
  raise NotImplementedError(f"'stop' not implemented for {self.__class__.__name__}")
58
91
 
59
- def stop_defer(self):
60
- """
61
- Enable an external caller to stop the Provisioner instance,
62
- deferring resource deallocation to the caller.
63
-
64
- Return an iterable of argument-free thread-safe callables that can be
65
- called, possibly in parallel, to free up resources.
66
- Ie. a list of 200 .release() functions, to be called in a thread pool
67
- by the user, speeding up cleanup.
68
- """
69
- return (self.stop,)
70
-
71
92
  def __enter__(self):
72
93
  try:
73
94
  self.start()
@@ -80,31 +101,6 @@ class Provisioner:
80
101
  self.stop()
81
102
 
82
103
 
83
- class Remote(_connection.Connection):
84
- """
85
- Representation of a provisioned (reserved) remote system, providing
86
- a Connection-like API in addition to system management helpers.
87
-
88
- An instance of Remote is typically prepared by a Provisioner and lent out
89
- for further use, to be .release()d by the user (if destroyed).
90
- It is not meant for repeated reserve/release cycles, hence the lack
91
- of .reserve().
92
-
93
- Also note that Remote can be used via Context Manager, but does not
94
- do automatic .release(), the manager only handles the built-in Connection.
95
- The intention is for a Provisioner to run via its own Contest Manager and
96
- release all Remotes upon exit.
97
- If you need automatic release of one Remote, use a contextlib.ExitStack
98
- with a callback, or a try/finally block.
99
- """
100
-
101
- def release(self):
102
- """
103
- Release (de-provision) the remote resource.
104
- """
105
- raise NotImplementedError(f"'release' not implemented for {self.__class__.__name__}")
106
-
107
-
108
104
  _submodules = [
109
105
  info.name for info in _pkgutil.iter_modules(__spec__.submodule_search_locations)
110
106
  ]
@@ -4,6 +4,7 @@ import uuid
4
4
  import shlex
5
5
  import socket
6
6
  import random
7
+ import textwrap
7
8
  import tempfile
8
9
  import threading
9
10
  import subprocess
@@ -38,14 +39,14 @@ def setup_event_loop():
38
39
  thread.start()
39
40
 
40
41
 
41
- class LibvirtCloningRemote(Remote, connection.ssh.ManagedSSHConn):
42
+ class LibvirtCloningRemote(Remote, connection.ssh.ManagedSSHConnection):
42
43
  """
43
44
  TODO
44
45
  """
45
46
 
46
47
  def __init__(self, ssh_options, host, domain, source_image, *, release_hook):
47
48
  """
48
- 'ssh_options' are a dict, passed to ManagedSSHConn __init__().
49
+ 'ssh_options' are a dict, passed to ManagedSSHConnection __init__().
49
50
 
50
51
  'host' is a str of libvirt host name (used for repr()).
51
52
 
@@ -57,7 +58,7 @@ class LibvirtCloningRemote(Remote, connection.ssh.ManagedSSHConn):
57
58
  'release_hook' is a callable called on .release() in addition
58
59
  to disconnecting the connection.
59
60
  """
60
- # NOTE: self.lock inherited from ManagedSSHConn
61
+ # NOTE: self.lock inherited from ManagedSSHConnection
61
62
  super().__init__(options=ssh_options)
62
63
  self.host = host
63
64
  self.domain = domain
@@ -80,7 +81,7 @@ class LibvirtCloningRemote(Remote, connection.ssh.ManagedSSHConn):
80
81
  return f"{class_name}({self.host}, {self.domain}, {self.source_image})"
81
82
 
82
83
 
83
- # needs ManagedSSHConn due to .forward()
84
+ # needs ManagedSSHConnection due to .forward()
84
85
  def reliable_ssh_local_fwd(conn, dest, retries=10):
85
86
  for _ in range(retries):
86
87
  # let the kernel give us a free port
@@ -128,7 +129,7 @@ class LibvirtCloningProvisioner(Provisioner):
128
129
  reserve_delay=3, reserve_time=3600, start_event_loop=True,
129
130
  ):
130
131
  """
131
- 'host' is a ManagedSSHConn class instance, connected to a libvirt host.
132
+ 'host' is a ManagedSSHConnection class instance, connected to a libvirt host.
132
133
 
133
134
  'image' is a string with a libvirt storage volume name inside the
134
135
  given storage 'pool' that should be used as the source for cloning.
@@ -174,6 +175,7 @@ class LibvirtCloningProvisioner(Provisioner):
174
175
  self.signature = uuid.uuid4()
175
176
  self.reserve_end = None
176
177
  self.queue = util.ThreadQueue(daemon=True)
178
+ self.to_reserve = 0
177
179
 
178
180
  # use two libvirt connections - one to handle reservations and cloning,
179
181
  # and another for management and cleanup;
@@ -242,8 +244,8 @@ class LibvirtCloningProvisioner(Provisioner):
242
244
  raise
243
245
 
244
246
  # parse XML definition of the domain
245
- xmldesc = acquired.XMLDesc()
246
- util.debug(f"domain {acquired.name()} XML:\n{xmldesc}") # TODO: EXTRADEBUG log level
247
+ xmldesc = acquired.XMLDesc().rstrip("\n")
248
+ util.extradebug(f"domain {acquired.name()} XML:\n{textwrap.indent(xmldesc, ' ')}")
247
249
  xml_root = ET.fromstring(xmldesc)
248
250
  nvram_vol = nvram_path = None
249
251
 
@@ -258,7 +260,19 @@ class LibvirtCloningProvisioner(Provisioner):
258
260
  # by libvirt natively (because treating nvram as a storage pool
259
261
  # is a user hack)
260
262
  for p in conn.listAllStoragePools():
261
- p.refresh()
263
+ # retry a few times to work around a libvirt race condition
264
+ for _ in range(10):
265
+ try:
266
+ p.refresh()
267
+ except libvirt.libvirtError as e:
268
+ if "domain is not running" in str(e):
269
+ break
270
+ elif "has asynchronous jobs running" in str(e):
271
+ continue
272
+ else:
273
+ raise
274
+ else:
275
+ break
262
276
  try:
263
277
  nvram_vol = conn.storageVolLookupByPath(nvram_path)
264
278
  except libvirt.libvirtError as e:
@@ -325,7 +339,7 @@ class LibvirtCloningProvisioner(Provisioner):
325
339
  # set up ssh LocalForward to it
326
340
  port = reliable_ssh_local_fwd(self.host, f"{first_addr}:22")
327
341
 
328
- # create a remote and connect it
342
+ # prepare release using variables from this scope
329
343
  def release_hook(remote):
330
344
  # un-forward the libvirt host ssh-forwarded port
331
345
  self.host.forward("LocalForward", f"127.0.0.1:{port} {first_addr}:22", cancel=True)
@@ -339,6 +353,7 @@ class LibvirtCloningProvisioner(Provisioner):
339
353
  try:
340
354
  domain = self.manage_conn.lookupByName(remote.domain)
341
355
  locking.unlock(domain, self.signature)
356
+ domain.destroy()
342
357
  except libvirt.libvirtError as e:
343
358
  if "Domain not found" not in str(e):
344
359
  raise
@@ -348,11 +363,12 @@ class LibvirtCloningProvisioner(Provisioner):
348
363
  except ValueError:
349
364
  pass
350
365
 
366
+ # create a remote and connect it
351
367
  ssh_options = {
352
368
  "Hostname": "127.0.0.1",
353
369
  "User": self.domain_user,
354
370
  "Port": str(port),
355
- "IdentityFile": self.domain_sshkey,
371
+ "IdentityFile": str(Path(self.domain_sshkey).absolute()),
356
372
  "ConnectionAttempts": "1000",
357
373
  "Compression": "yes",
358
374
  }
@@ -385,7 +401,7 @@ class LibvirtCloningProvisioner(Provisioner):
385
401
  ("virt-ssh-helper", "qemu:///system"),
386
402
  func=lambda *args, **_: args[0],
387
403
  )
388
- # to make libvirt connect via our ManagedSSHConn, we need to give it
404
+ # to make libvirt connect via our ManagedSSHConnection, we need to give it
389
405
  # a specific ssh CLI, but libvirt URI command= takes only one argv[0]
390
406
  # and cannot pass arguments - we work around this by creating a temp
391
407
  # arg-less executable
@@ -410,8 +426,6 @@ class LibvirtCloningProvisioner(Provisioner):
410
426
  self.reserve_conn = self._open_libvirt_conn()
411
427
  self.manage_conn = self.reserve_conn # for now
412
428
  self.reserve_end = int(time.time()) + self.reserve_time
413
- # get an initial first remote
414
- self.queue.start_thread(target=self._reserve_one)
415
429
 
416
430
  def stop(self):
417
431
  with self.lock:
@@ -443,11 +457,16 @@ class LibvirtCloningProvisioner(Provisioner):
443
457
  self.reserve_end = None
444
458
  # TODO: wait for threadqueue threads to join?
445
459
 
460
+ def provision(self, count=1):
461
+ with self.lock:
462
+ self.to_reserve += count
463
+
446
464
  def get_remote(self, block=True):
447
- # if the reservation thread is not running, start one
448
465
  with self.lock:
449
- if not self.queue.threads:
466
+ # if the reservation thread is not running, start one
467
+ if not self.queue.threads and self.to_reserve > 0:
450
468
  self.queue.start_thread(target=self._reserve_one)
469
+ self.to_reserve -= 1
451
470
  try:
452
471
  return self.queue.get(block=block)
453
472
  except util.ThreadQueue.Empty:
@@ -19,7 +19,9 @@ import time
19
19
  import random
20
20
  import xml.etree.ElementTree as ET
21
21
 
22
- import libvirt
22
+ from ... import util
23
+
24
+ libvirt = util.import_libvirt()
23
25
 
24
26
 
25
27
  def get_locks(domain, expired=False):
@@ -0,0 +1,2 @@
1
+ from .podman import PodmanProvisioner, PodmanRemote # noqa: F401
2
+ from .podman import pull_image, build_container_with_deps # noqa: F401
@@ -0,0 +1,169 @@
1
+ import tempfile
2
+ import threading
3
+ import subprocess
4
+
5
+ from ... import connection, util
6
+ from .. import Provisioner, Remote
7
+
8
+
9
+ class PodmanRemote(Remote, connection.podman.PodmanConnection):
10
+ """
11
+ Built on the official Remote API, pulling in the Connection API
12
+ as implemented by ManagedSSHConnection.
13
+ """
14
+
15
+ def __init__(self, image, container, *, release_hook):
16
+ """
17
+ 'image' is an image tag (used for repr()).
18
+
19
+ 'container' is a podman container id / name.
20
+
21
+ 'release_hook' is a callable called on .release() in addition
22
+ to disconnecting the connection.
23
+ """
24
+ super().__init__(container=container)
25
+ self.lock = threading.RLock()
26
+ self.image = image
27
+ self.container = container
28
+ self.release_called = False
29
+ self.release_hook = release_hook
30
+
31
+ def release(self):
32
+ with self.lock:
33
+ if self.release_called:
34
+ return
35
+ else:
36
+ self.release_called = True
37
+ self.release_hook(self)
38
+ self.disconnect()
39
+ util.subprocess_run(
40
+ ("podman", "container", "rm", "-f", "-t", "0", self.container),
41
+ check=False, # ignore if it fails
42
+ stdout=subprocess.DEVNULL,
43
+ )
44
+
45
+ # not /technically/ a valid repr(), but meh
46
+ def __repr__(self):
47
+ class_name = self.__class__.__name__
48
+
49
+ if "/" in self.image:
50
+ image = self.image.rsplit("/",1)[1]
51
+ elif len(self.image) > 20:
52
+ image = f"{self.image[:17]}..."
53
+ else:
54
+ image = self.image
55
+
56
+ name = f"{self.container[:17]}..." if len(self.container) > 20 else self.container
57
+
58
+ return f"{class_name}({image}, {name})"
59
+
60
+
61
+ class PodmanProvisioner(Provisioner):
62
+ def __init__(self, image, run_options=None):
63
+ """
64
+ 'image' is a string of image tag/id to create containers from.
65
+ It can be a local identifier or an URL.
66
+
67
+ 'run_options' is an iterable with additional CLI options passed
68
+ to 'podman container run'.
69
+ """
70
+ self.lock = threading.RLock()
71
+ self.image = image
72
+ self.run_options = run_options or ()
73
+
74
+ # created PodmanRemote instances, ready to be handed over to the user,
75
+ # or already in use by the user
76
+ self.remotes = []
77
+ self.to_create = 0
78
+
79
+ def start(self):
80
+ if not self.image:
81
+ raise ValueError("image cannot be empty")
82
+
83
+ def stop(self):
84
+ with self.lock:
85
+ while self.remotes:
86
+ self.remotes.pop().release()
87
+
88
+ def provision(self, count=1):
89
+ with self.lock:
90
+ self.to_create += count
91
+
92
+ def get_remote(self, block=True):
93
+ if self.to_create <= 0:
94
+ if block:
95
+ raise RuntimeError("no .provision() requested, would block forever")
96
+ else:
97
+ return None
98
+
99
+ proc = util.subprocess_run(
100
+ (
101
+ "podman", "container", "run", "--quiet", "--detach", "--pull", "never",
102
+ *self.run_options, self.image, "sleep", "inf",
103
+ ),
104
+ check=True,
105
+ text=True,
106
+ stdout=subprocess.PIPE,
107
+ )
108
+ container_id = proc.stdout.rstrip("\n")
109
+
110
+ def release_hook(remote):
111
+ # remove from the list of remotes inside this Provisioner
112
+ with self.lock:
113
+ try:
114
+ self.remotes.remove(remote)
115
+ except ValueError:
116
+ pass
117
+
118
+ remote = PodmanRemote(
119
+ self.image,
120
+ container_id,
121
+ release_hook=release_hook,
122
+ )
123
+
124
+ with self.lock:
125
+ self.remotes.append(remote)
126
+ self.to_create -= 1
127
+
128
+ return remote
129
+
130
+ # not /technically/ a valid repr(), but meh
131
+ def __repr__(self):
132
+ class_name = self.__class__.__name__
133
+ return (
134
+ f"{class_name}({self.image}, {len(self.remotes)} remotes, {hex(id(self))})"
135
+ )
136
+
137
+
138
+ def pull_image(origin):
139
+ proc = util.subprocess_run(
140
+ ("podman", "image", "pull", "-q", origin),
141
+ check=True,
142
+ text=True,
143
+ stdout=subprocess.PIPE,
144
+ )
145
+ return proc.stdout.rstrip("\n")
146
+
147
+
148
+ def build_container_with_deps(origin, tag=None, *, extra_pkgs=None):
149
+ tag_args = ("-t", tag) if tag else ()
150
+
151
+ pkgs = ["rsync"]
152
+ if extra_pkgs:
153
+ pkgs += extra_pkgs
154
+ pkgs_str = " ".join(pkgs)
155
+
156
+ with tempfile.NamedTemporaryFile("w+t", delete_on_close=False) as tmpf:
157
+ tmpf.write(util.dedent(fr"""
158
+ FROM {origin}
159
+ RUN dnf -y -q --setopt=install_weak_deps=False install {pkgs_str} >/dev/null
160
+ RUN dnf -y -q clean packages >/dev/null
161
+ """))
162
+ tmpf.close()
163
+ proc = util.subprocess_run(
164
+ ("podman", "image", "build", "-q", "-f", tmpf.name, *tag_args, "."),
165
+ check=True,
166
+ text=True,
167
+ stdout=subprocess.PIPE,
168
+ )
169
+ return proc.stdout.rstrip("\n")