ceph-devstack 0.1.0__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 (44) hide show
  1. ceph_devstack/Dockerfile.selinux +20 -0
  2. ceph_devstack/__init__.py +187 -0
  3. ceph_devstack/ceph_devstack.pp +0 -0
  4. ceph_devstack/ceph_devstack.te +127 -0
  5. ceph_devstack/cli.py +64 -0
  6. ceph_devstack/config.toml +24 -0
  7. ceph_devstack/exec.py +93 -0
  8. ceph_devstack/host.py +154 -0
  9. ceph_devstack/logging.conf +30 -0
  10. ceph_devstack/py.typed +0 -0
  11. ceph_devstack/requirements.py +277 -0
  12. ceph_devstack/resources/__init__.py +115 -0
  13. ceph_devstack/resources/ceph/__init__.py +266 -0
  14. ceph_devstack/resources/ceph/containers.py +419 -0
  15. ceph_devstack/resources/ceph/exceptions.py +3 -0
  16. ceph_devstack/resources/ceph/requirements.py +90 -0
  17. ceph_devstack/resources/ceph/utils.py +45 -0
  18. ceph_devstack/resources/container.py +171 -0
  19. ceph_devstack/resources/misc.py +15 -0
  20. ceph_devstack-0.1.0.dist-info/METADATA +222 -0
  21. ceph_devstack-0.1.0.dist-info/RECORD +44 -0
  22. ceph_devstack-0.1.0.dist-info/WHEEL +5 -0
  23. ceph_devstack-0.1.0.dist-info/entry_points.txt +2 -0
  24. ceph_devstack-0.1.0.dist-info/licenses/LICENSE +21 -0
  25. ceph_devstack-0.1.0.dist-info/top_level.txt +2 -0
  26. tests/__init__.py +0 -0
  27. tests/conftest.py +9 -0
  28. tests/resources/__init__.py +0 -0
  29. tests/resources/ceph/__init__.py +0 -0
  30. tests/resources/ceph/fixtures/__init__.py +0 -0
  31. tests/resources/ceph/fixtures/testnode-config.toml +2 -0
  32. tests/resources/ceph/test_cephdevstack_core.py +459 -0
  33. tests/resources/ceph/test_devstack.py +182 -0
  34. tests/resources/ceph/test_env_vars.py +110 -0
  35. tests/resources/ceph/test_requirements_ceph.py +262 -0
  36. tests/resources/ceph/test_ssh_keypair.py +109 -0
  37. tests/resources/ceph/test_testnode.py +36 -0
  38. tests/resources/test_container.py +247 -0
  39. tests/resources/test_misc.py +46 -0
  40. tests/resources/test_podmanresource.py +59 -0
  41. tests/test_config.py +120 -0
  42. tests/test_deep_merge.py +71 -0
  43. tests/test_parse_args.py +228 -0
  44. tests/test_requirements_core.py +495 -0
@@ -0,0 +1,277 @@
1
+ import shlex
2
+ import sys
3
+
4
+ from pathlib import Path
5
+ from packaging.version import parse as parse_version, Version
6
+ from typing import List
7
+
8
+ from ceph_devstack import config, logger
9
+ from ceph_devstack.host import Host, host, local_host
10
+
11
+
12
+ class Requirement:
13
+ host: Host = host
14
+ check_cmd: List[str]
15
+
16
+ async def evaluate(self) -> bool:
17
+ return await self.check()
18
+
19
+ async def check(self) -> bool:
20
+ proc = await self.host.arun(self.check_cmd)
21
+ return await proc.wait() == 0
22
+
23
+
24
+ class FixableRequirement(Requirement):
25
+ fix_cmd: List[str]
26
+ suggest_msg: str
27
+
28
+ async def evaluate(self) -> bool:
29
+ if await self.check() is True:
30
+ return True
31
+ if config["args"].get("fix", False):
32
+ return await self.fix()
33
+ else:
34
+ await self.suggest()
35
+ return False
36
+
37
+ async def suggest(self):
38
+ if hasattr(self, "suggest_msg"):
39
+ logger.error(f"{self.suggest_msg}. Try: {shlex.join(self.fix_cmd)}")
40
+
41
+ async def fix(self) -> bool:
42
+ assert self.fix_cmd, "Attempted to fix without a fix command"
43
+ proc = await self.host.arun(self.fix_cmd)
44
+ return await proc.wait() == 0
45
+
46
+
47
+ class LocalRequirement(Requirement):
48
+ host = local_host
49
+
50
+
51
+ class PodmanPlatform(Requirement):
52
+ async def check(self):
53
+ result = False
54
+ try:
55
+ podman_info = await self.host.podman_info()
56
+ except FileNotFoundError:
57
+ logger.error("podman not found. Try: dnf install podman")
58
+ return False
59
+ try:
60
+ host_os = (
61
+ podman_info["host"].get("Os") or podman_info["host"]["os"]
62
+ ).lower()
63
+ if host_os == "linux":
64
+ result = True
65
+ except KeyError:
66
+ host_os = sys.platform.lower()
67
+ result = False
68
+ if sys.platform == "darwin":
69
+ logger.error(
70
+ "The podman machine (VM) is not running. "
71
+ "Try: podman machine init --now"
72
+ )
73
+ else:
74
+ logger.error(
75
+ "Unknown error trying to query podman. Is podman installed?"
76
+ )
77
+ return result
78
+ if host_os != "linux":
79
+ logger.error("The platform '{host_os}' is not currently supported.")
80
+ return result
81
+
82
+
83
+ class PodmanGraphDriver(Requirement):
84
+ async def check(self):
85
+ podman_info = await self.host.podman_info()
86
+ storage_conf_path = podman_info["store"]["configFile"]
87
+ graph_driver = podman_info["store"]["graphDriverName"]
88
+ if graph_driver == "overlay":
89
+ return True
90
+ else:
91
+ self.suggest_msg = (
92
+ f"The configured graph driver is '{graph_driver}'. "
93
+ f"It must be set to 'overlay' in {storage_conf_path}."
94
+ )
95
+ return False
96
+
97
+
98
+ class KernelVersionForOverlay(Requirement):
99
+ async def check(self):
100
+ kernel_version = self.host.kernel_version()
101
+ version_for_overlay = Version("5.12")
102
+ if kernel_version < version_for_overlay:
103
+ self.suggest_msg = (
104
+ f"Kernel version ({kernel_version}) is too old to support native rootless "
105
+ f"overlayfs (needs {version_for_overlay})"
106
+ )
107
+ return False
108
+ return True
109
+
110
+
111
+ class KernelVersionForCgroupV2(Requirement):
112
+ async def check(self):
113
+ version_for_cgroup = Version("4.15")
114
+ kernel_version = self.host.kernel_version()
115
+ if not kernel_version >= version_for_cgroup:
116
+ self.suggest_msg = (
117
+ f"Kernel version ({kernel_version}) is too old to support cgroup v2 "
118
+ f"(needs {version_for_cgroup})"
119
+ )
120
+ return False
121
+
122
+
123
+ class CgroupV2(FixableRequirement):
124
+ suggest_msg = "cgroup v2 is not enabled"
125
+ fix_cmd = [
126
+ "sudo",
127
+ "grubby",
128
+ "--update-kernel=ALL",
129
+ "--args='systemd.unified_cgroup_hierarchy=1'",
130
+ ]
131
+
132
+ async def check(self):
133
+ podman_info = await self.host.podman_info()
134
+ return podman_info["host"]["cgroupVersion"] == "v2"
135
+
136
+
137
+ class PodmanVersion(Requirement):
138
+ def __init__(self, version: str, msg: str = ""):
139
+ self.required_version = parse_version(version)
140
+ self.msg = msg
141
+
142
+ async def check(self):
143
+ podman_info = await self.host.podman_info()
144
+ podman_version = parse_version(podman_info["version"]["Version"])
145
+ if podman_version < self.required_version:
146
+ if self.msg:
147
+ logger.warning(self.msg)
148
+ return False
149
+ return True
150
+
151
+
152
+ class PodmanRuntime(Requirement):
153
+ async def check(self):
154
+ podman_info = await self.host.podman_info()
155
+ storage_conf_path = podman_info["store"]["configFile"]
156
+ runtime = podman_info["host"]["ociRuntime"]["name"]
157
+ if runtime == "crun":
158
+ return True
159
+ else:
160
+ containers_conf_path = Path(storage_conf_path).parent / "containers.conf"
161
+ cmd = host.cmd(["podman", "system", "reset"])
162
+ logger.error(
163
+ f"The configured runtime is '{runtime}'. "
164
+ f"It must be set to 'crun' in {containers_conf_path}. "
165
+ f"Afterward, run '{cmd}'."
166
+ )
167
+ return False
168
+
169
+
170
+ class SELinuxBoolean(FixableRequirement):
171
+ def __init__(self, boolean_name: str):
172
+ super().__init__()
173
+ self.boolean_name = boolean_name
174
+ self.fix_cmd = ["sudo", "setsebool", "-P", f"{self.boolean_name}=true"]
175
+ self.suggest_msg = f"SELinux boolean '{self.boolean_name}' must be enabled"
176
+
177
+ async def check(self):
178
+ return await self.host.check_selinux_bool(self.boolean_name)
179
+
180
+
181
+ class SysctlValue(FixableRequirement):
182
+ def __init__(self, name: str, min_value: int):
183
+ super().__init__()
184
+ self.key = name
185
+ self.min_value = min_value
186
+ self.fix_cmd = ["sudo", "sysctl", f"{name}={min_value}"]
187
+
188
+ async def check(self):
189
+ current_value = await self.host.get_sysctl_value(self.key)
190
+ self.suggest_msg = f"sysctl setting {self.key} ({current_value}) is too low"
191
+ return current_value >= self.min_value
192
+
193
+
194
+ class PodmanDNSPlugin(FixableRequirement):
195
+ suggest_msg = "Could not find the podman DNS plugin"
196
+
197
+ def __init__(self):
198
+ os_type = self.host.os_type()
199
+ if os_type == "centos":
200
+ dns_plugin_path = "/usr/libexec/cni/dnsname"
201
+ self.check_cmd = ["test", "-x", dns_plugin_path]
202
+ self.fix_cmd = ["sudo", "dnf", "install", "-y", dns_plugin_path]
203
+ elif os_type in ["ubuntu", "debian"]:
204
+ dns_plugin_path = "/usr/lib/cni/dnsname"
205
+ self.check_cmd = ["test", "-x", dns_plugin_path]
206
+ self.fix_cmd = [
207
+ "sudo",
208
+ "apt",
209
+ "install",
210
+ "-y",
211
+ "golang-github-containernetworking-plugin-dnsname",
212
+ ]
213
+
214
+
215
+ class FuseOverlayfsPresence(FixableRequirement):
216
+ check_cmd = ["command", "-v", "fuse-overlayfs"]
217
+ suggest_msg = "Could not find fuse-overlayfs"
218
+ fix_cmd = ["sudo", "dnf", "install", "-y", "fuse-overlayfs"]
219
+
220
+
221
+ class AppArmorProfile(FixableRequirement):
222
+ _profile_path = "/etc/apparmor.d/local/unix-chkpwd"
223
+ _profile_content = '"capability dac_override,"'
224
+ check_cmd = ["test", "-f", _profile_path]
225
+ suggest_msg = "Did not find required apparmor profile"
226
+ fix_cmd = [
227
+ "sudo",
228
+ "bash",
229
+ "-c",
230
+ f"echo -e {_profile_content} > {_profile_path} && systemctl reload apparmor",
231
+ ]
232
+
233
+
234
+ async def check_requirements():
235
+ if not await PodmanPlatform().evaluate():
236
+ return False
237
+
238
+ result = True
239
+ # kernel and podman versions for native overlay filesystem
240
+ result = result and await PodmanGraphDriver().evaluate()
241
+ podman_overlay_version = "3.10"
242
+ podman_version_overlay = await PodmanVersion(
243
+ podman_overlay_version,
244
+ "Podman version is too old for rootless native overlayfs (needs {podman_overlay_version})",
245
+ ).evaluate()
246
+ needs_fuse = not (
247
+ await KernelVersionForOverlay().evaluate() and podman_version_overlay
248
+ )
249
+ # if not using native overlay, we need fuse-overlayfs
250
+ if needs_fuse:
251
+ result = result and await FuseOverlayfsPresence().evaluate()
252
+
253
+ # cgroup v2
254
+ if not await CgroupV2().evaluate():
255
+ result = result and await KernelVersionForCgroupV2().evaluate()
256
+
257
+ # runtime
258
+ result = result and await PodmanRuntime().evaluate()
259
+
260
+ # SELinux
261
+ if await host.selinux_enforcing():
262
+ result = result and await SELinuxBoolean("container_manage_cgroup").evaluate()
263
+ result = result and await SELinuxBoolean("container_use_devices").evaluate()
264
+
265
+ # AppArmor
266
+ if await host.apparmor_enabled():
267
+ result = result and await AppArmorProfile().evaluate()
268
+
269
+ # podman DNS plugin
270
+ if not await PodmanVersion("5.0").evaluate():
271
+ result = result and await PodmanDNSPlugin().evaluate()
272
+
273
+ # sysctl settings for OSD
274
+ result = result and await SysctlValue("fs.aio-max-nr", 2097152).evaluate()
275
+ result = result and await SysctlValue("kernel.pid_max", 4194304).evaluate()
276
+
277
+ return result
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import subprocess
7
+
8
+ from pathlib import Path
9
+ from subprocess import CalledProcessError
10
+ from typing import List, Dict, Set
11
+
12
+ from ceph_devstack.host import host, local_host
13
+
14
+
15
+ class DevStack:
16
+ def __init__(self, args: argparse.Namespace):
17
+ self.args = args
18
+ self.env: Dict[str, str] = {}
19
+
20
+ def choose_teuthology_branch(self):
21
+ branch = os.environ.get("TEUTHOLOGY_BRANCH", self.get_current_branch())
22
+ self.env["TEUTH_BRANCH"] = branch
23
+ return branch
24
+
25
+ def get_current_branch(self, repo_path: str = "."):
26
+ return subprocess.check_output(
27
+ ["git", "branch", "--show-current"],
28
+ cwd=repo_path,
29
+ ).decode()
30
+
31
+
32
+ class PodmanResource:
33
+ cwd = "."
34
+ exists_cmd: List[str] = []
35
+ create_cmd: List[str] = []
36
+ remove_cmd: List[str] = []
37
+ start_cmd: List[str] = []
38
+ stop_cmd: List[str] = []
39
+ cmd_vars: List[str] = ["name"]
40
+ log: Dict[str, Set[str]] = {}
41
+ _name: str | None = None
42
+
43
+ def __init__(self, name: str = ""):
44
+ if name:
45
+ self._name = name
46
+
47
+ @property
48
+ def name(self) -> str:
49
+ if self._name is not None:
50
+ return self._name
51
+ return self.__class__.__name__.lower()
52
+
53
+ async def cmd(
54
+ self,
55
+ args: List[str],
56
+ check: bool = False,
57
+ force_local: bool = False,
58
+ stream_output: bool = False,
59
+ ) -> asyncio.subprocess.Process:
60
+ exec_host = local_host if force_local else host
61
+ proc = await exec_host.arun(
62
+ args,
63
+ cwd=Path(self.cwd),
64
+ stream_output=stream_output,
65
+ )
66
+ assert proc.stderr is not None
67
+ assert proc.stdout is not None
68
+ returncode = await proc.wait()
69
+ if check and returncode != 0:
70
+ # out = await proc.stderr.read()
71
+ # logger.error(out.decode())
72
+ raise CalledProcessError(cmd=args, returncode=returncode)
73
+ return proc
74
+
75
+ def format_cmd(self, args: List):
76
+ vars = {}
77
+ for k in self.cmd_vars:
78
+ v = getattr(self, k, None)
79
+ if v is not None:
80
+ if isinstance(v, Path):
81
+ v = v.expanduser()
82
+ vars[k] = v
83
+ return [s.format(**vars) for s in args]
84
+
85
+ async def apply(self, action: str):
86
+ method = getattr(self, action, None)
87
+ if method is None:
88
+ return
89
+ await method()
90
+
91
+ async def inspect(self):
92
+ proc = await self.cmd(self.format_cmd(self.exists_cmd))
93
+ out, err = await proc.communicate()
94
+ return json.loads(out)
95
+ return json.loads(proc.stdout.read())
96
+ if proc.stdout is None:
97
+ return {}
98
+ return json.loads((await proc.stdout.read()).decode())
99
+
100
+ async def exists(self):
101
+ if not self.exists_cmd:
102
+ return False
103
+ proc = await self.cmd(self.format_cmd(self.exists_cmd), check=False)
104
+ return await proc.wait() == 0
105
+
106
+ async def create(self):
107
+ if not await self.exists():
108
+ await self.cmd(self.format_cmd(self.create_cmd), check=True)
109
+
110
+ async def remove(self):
111
+ await self.cmd(self.format_cmd(self.remove_cmd))
112
+
113
+ def __repr__(self):
114
+ param_str = "" if self._name is None else f'name="{self._name}"'
115
+ return f"{self.__class__.__name__}({param_str})"
@@ -0,0 +1,266 @@
1
+ import asyncio
2
+ import contextlib
3
+ import os
4
+ import pathlib
5
+ import tempfile
6
+
7
+ from subprocess import CalledProcessError
8
+
9
+ from ceph_devstack import config, logger
10
+ from ceph_devstack.host import host
11
+ from ceph_devstack.resources.misc import Secret, Network
12
+ from ceph_devstack.resources.ceph.containers import (
13
+ Postgres,
14
+ Beanstalk,
15
+ Paddles,
16
+ Pulpito,
17
+ TestNode,
18
+ Teuthology,
19
+ Archive,
20
+ )
21
+ from ceph_devstack.resources.ceph.requirements import (
22
+ HasSudo,
23
+ LoopControlDeviceExists,
24
+ LoopControlDeviceWriteable,
25
+ SELinuxModule,
26
+ )
27
+ from ceph_devstack.resources.ceph.utils import get_most_recent_run, get_job_id
28
+ from ceph_devstack.resources.ceph.exceptions import TooManyJobsFound
29
+
30
+
31
+ class SSHKeyPair(Secret):
32
+ _name = "id_rsa"
33
+ cmd_vars = ["name", "privkey_path", "pubkey_path"]
34
+ privkey_path = "id_rsa"
35
+ pubkey_path = "id_rsa.pub"
36
+ exists_cmds = [
37
+ ["podman", "secret", "inspect", "{name}"],
38
+ ["podman", "secret", "inspect", "{name}.pub"],
39
+ ]
40
+ create_cmds = [
41
+ ["podman", "secret", "create", "{name}", "{privkey_path}"],
42
+ ["podman", "secret", "create", "{name}.pub", "{pubkey_path}"],
43
+ ]
44
+ remove_cmds = [
45
+ ["podman", "secret", "rm", "{name}"],
46
+ ["podman", "secret", "rm", "{name}.pub"],
47
+ ]
48
+
49
+ async def exists(self):
50
+ for exists_cmd in self.exists_cmds:
51
+ proc = await self.cmd(self.format_cmd(exists_cmd), check=False)
52
+ if await proc.wait():
53
+ return False
54
+ return True
55
+
56
+ async def create(self):
57
+ if await self.exists():
58
+ return
59
+ await self._get_ssh_keys()
60
+ for create_cmd in self.create_cmds:
61
+ await self.cmd(self.format_cmd(create_cmd), check=True)
62
+
63
+ async def remove(self):
64
+ for remove_cmd in self.remove_cmds:
65
+ await self.cmd(self.format_cmd(remove_cmd))
66
+
67
+ async def _get_ssh_keys(self):
68
+ privkey_path = os.environ.get("SSH_PRIVKEY_PATH")
69
+ self.pubkey_path = "/dev/null"
70
+ if not privkey_path:
71
+ temp_dir = tempfile.mkdtemp(
72
+ prefix="teuthology-ssh-key-",
73
+ dir="/tmp",
74
+ )
75
+ privkey_path = pathlib.Path(temp_dir) / self.__class__.privkey_path
76
+ await self.cmd(
77
+ ["ssh-keygen", "-t", "rsa", "-N", "", "-f", str(privkey_path)],
78
+ check=True,
79
+ force_local=True,
80
+ )
81
+ self.pubkey_path = f"{privkey_path}.pub"
82
+ self.privkey_path = privkey_path
83
+
84
+
85
+ class CephDevStackNetwork(Network):
86
+ _name = "ceph-devstack"
87
+
88
+
89
+ class CephDevStack:
90
+ networks = [CephDevStackNetwork]
91
+ secrets = [SSHKeyPair]
92
+
93
+ def __init__(self):
94
+ services = [
95
+ Postgres,
96
+ Paddles,
97
+ Beanstalk,
98
+ Pulpito,
99
+ Teuthology,
100
+ TestNode,
101
+ Archive,
102
+ ]
103
+ self.service_specs = {}
104
+ for service in services:
105
+ name = service.__name__.lower()
106
+ count = config["containers"][name].get("count", 1)
107
+ if count == 0:
108
+ continue
109
+ self.service_specs[name] = {
110
+ "obj": service,
111
+ "count": count,
112
+ }
113
+ if count == 1:
114
+ self.service_specs[name]["objects"] = [service()]
115
+ elif count > 1:
116
+ self.service_specs[name]["objects"] = [
117
+ service(name=f"{name}_{i}") for i in range(count)
118
+ ]
119
+ if postgres_spec := self.service_specs.get("postgres"):
120
+ postgres_obj = postgres_spec["objects"][0]
121
+ paddles_obj = self.service_specs["paddles"]["objects"][0]
122
+ paddles_obj.env_vars["PADDLES_SQLALCHEMY_URL"] = (
123
+ postgres_obj.paddles_sqla_url
124
+ )
125
+
126
+ async def check_requirements(self):
127
+ result = True
128
+
129
+ result = has_sudo = await HasSudo().evaluate()
130
+ result = result and await LoopControlDeviceExists().evaluate()
131
+ result = result and await LoopControlDeviceWriteable().evaluate()
132
+
133
+ # Check for SELinux being enabled and Enforcing; then check for the
134
+ # presence of our module. If necessary, inform the user and instruct
135
+ # them how to build and install.
136
+ if has_sudo and await host.selinux_enforcing():
137
+ result = result and await SELinuxModule().evaluate()
138
+
139
+ for name, obj in config["containers"].items():
140
+ if (repo := obj.get("repo")) and not host.path_exists(repo):
141
+ result = False
142
+ logger.error(f"Repo for {name} not found at {repo}")
143
+ return result
144
+
145
+ async def apply(self, action):
146
+ return await getattr(self, action)()
147
+
148
+ async def pull(self):
149
+ logger.info("Pulling images...")
150
+ for spec in self.service_specs.values():
151
+ await spec["objects"][0].pull()
152
+
153
+ async def build(self):
154
+ logger.info("Building images...")
155
+ for spec in self.service_specs.values():
156
+ await spec["objects"][0].build()
157
+
158
+ async def create(self):
159
+ logger.info("Creating containers...")
160
+ await CephDevStackNetwork().create()
161
+ await SSHKeyPair().create()
162
+ containers = []
163
+ for spec in self.service_specs.values():
164
+ for object in spec["objects"]:
165
+ containers.append(object.create())
166
+ await asyncio.gather(*containers)
167
+
168
+ async def start(self):
169
+ await self.create()
170
+ logger.info("Starting containers...")
171
+ for spec in self.service_specs.values():
172
+ for object in spec["objects"]:
173
+ await object.start()
174
+ logger.info(
175
+ "All containers are running. To monitor teuthology, try running: podman "
176
+ "logs -f teuthology"
177
+ )
178
+ hostname = host.hostname()
179
+ logger.info(f"View test results at http://{hostname}:8081/")
180
+
181
+ async def stop(self):
182
+ logger.info("Stopping containers...")
183
+ containers = []
184
+ for spec in self.service_specs.values():
185
+ for object in spec["objects"]:
186
+ containers.append(object.stop())
187
+ await asyncio.gather(*containers)
188
+
189
+ async def remove(self):
190
+ logger.info("Removing containers...")
191
+ containers = []
192
+ for spec in self.service_specs.values():
193
+ for object in spec["objects"]:
194
+ containers.append(object.remove())
195
+ await asyncio.gather(*containers)
196
+ await CephDevStackNetwork().remove()
197
+ await SSHKeyPair().remove()
198
+
199
+ async def watch(self):
200
+ logger.info("Watching containers; will replace any that are stopped")
201
+ containers = []
202
+ for spec in self.service_specs.values():
203
+ if not spec["count"] > 0:
204
+ continue
205
+ for object in spec["objects"]:
206
+ containers.append(object)
207
+ logger.info(f"Watching {containers}")
208
+ while True:
209
+ try:
210
+ for container in containers:
211
+ with contextlib.suppress(CalledProcessError):
212
+ if not await container.exists():
213
+ logger.info(
214
+ f"Container {container.name} was removed; replacing"
215
+ )
216
+ await container.create()
217
+ await container.start()
218
+ elif not await container.is_running():
219
+ logger.info(
220
+ f"Container {container.name} stopped; restarting"
221
+ )
222
+ await container.start()
223
+ except KeyboardInterrupt:
224
+ break
225
+
226
+ async def wait(self, container_name: str):
227
+ for spec in self.service_specs.values():
228
+ for object in spec["objects"]:
229
+ if object.name == container_name:
230
+ return await object.wait()
231
+ logger.error(f"Could not find container {container_name}")
232
+ return 1
233
+
234
+ async def logs(self, run_name: str = "", job_id: str = "", locate: bool = False):
235
+ try:
236
+ log_file = self.get_log_file(run_name, job_id)
237
+ except FileNotFoundError:
238
+ logger.error("No log file found")
239
+ except TooManyJobsFound as e:
240
+ msg = "Found too many jobs ({jobs}) for target run. Please pick a job id with -j option.".format(
241
+ jobs=", ".join(e.jobs)
242
+ )
243
+ logger.error(msg)
244
+ else:
245
+ if locate:
246
+ print(log_file)
247
+ else:
248
+ buffer_size = 8 * 1024
249
+ with open(log_file) as f:
250
+ while chunk := f.read(buffer_size):
251
+ print(chunk, end="")
252
+
253
+ def get_log_file(self, run_name: str = "", job_id: str = ""):
254
+ archive_dir = Teuthology().archive_dir.expanduser()
255
+
256
+ if not run_name:
257
+ run_name = get_most_recent_run(os.listdir(archive_dir))
258
+ run_dir = archive_dir.joinpath(run_name)
259
+
260
+ if not job_id:
261
+ job_id = get_job_id(os.listdir(run_dir))
262
+
263
+ log_file = run_dir.joinpath(job_id, "teuthology.log")
264
+ if not log_file.exists():
265
+ raise FileNotFoundError
266
+ return log_file