offbox 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 (72) hide show
  1. offbox/__init__.py +2 -0
  2. offbox/__main__.py +4 -0
  3. offbox/_data/host/cloud-init.yaml.j2 +15 -0
  4. offbox/_data/host/host-init.sh +230 -0
  5. offbox/_data/images/universal/Dockerfile +101 -0
  6. offbox/_data/images/universal/offbox-init +75 -0
  7. offbox/_data/images/universal/offbox-run +131 -0
  8. offbox/_data/providers/gcp/.terraform.lock.hcl +52 -0
  9. offbox/_data/providers/gcp/main.tf +67 -0
  10. offbox/_data/providers/gcp/outputs.tf +7 -0
  11. offbox/_data/providers/gcp/provider.toml +9 -0
  12. offbox/_data/providers/gcp/variables.tf +33 -0
  13. offbox/_data/providers/gcp/versions.tf +12 -0
  14. offbox/_data/providers/hetzner/.terraform.lock.hcl +51 -0
  15. offbox/_data/providers/hetzner/main.tf +32 -0
  16. offbox/_data/providers/hetzner/outputs.tf +7 -0
  17. offbox/_data/providers/hetzner/provider.toml +9 -0
  18. offbox/_data/providers/hetzner/variables.tf +28 -0
  19. offbox/_data/providers/hetzner/versions.tf +12 -0
  20. offbox/_data/providers/ssh/provider.toml +4 -0
  21. offbox/cli/__init__.py +117 -0
  22. offbox/cli/config.py +39 -0
  23. offbox/cli/env.py +109 -0
  24. offbox/cli/exec.py +50 -0
  25. offbox/cli/host.py +313 -0
  26. offbox/cli/misc.py +132 -0
  27. offbox/cli/ports.py +44 -0
  28. offbox/config.py +189 -0
  29. offbox/console.py +63 -0
  30. offbox/doctor.py +174 -0
  31. offbox/drivers/__init__.py +0 -0
  32. offbox/drivers/base.py +40 -0
  33. offbox/drivers/none.py +114 -0
  34. offbox/drivers/tofu.py +59 -0
  35. offbox/errors.py +45 -0
  36. offbox/execute.py +772 -0
  37. offbox/host/__init__.py +89 -0
  38. offbox/host/activity.py +58 -0
  39. offbox/host/auto.py +322 -0
  40. offbox/host/cloudinit.py +44 -0
  41. offbox/host/create.py +198 -0
  42. offbox/host/destroy.py +151 -0
  43. offbox/host/init.py +196 -0
  44. offbox/host/ready.py +106 -0
  45. offbox/host/timer.py +227 -0
  46. offbox/host/usage.py +123 -0
  47. offbox/host/warnings.py +61 -0
  48. offbox/locking.py +27 -0
  49. offbox/logging.py +154 -0
  50. offbox/logs.py +135 -0
  51. offbox/naming.py +67 -0
  52. offbox/paths.py +55 -0
  53. offbox/ports.py +610 -0
  54. offbox/project.py +187 -0
  55. offbox/providers.py +117 -0
  56. offbox/py.typed +0 -0
  57. offbox/runner.py +291 -0
  58. offbox/runtime.py +887 -0
  59. offbox/setup.py +181 -0
  60. offbox/share.py +286 -0
  61. offbox/ssh.py +179 -0
  62. offbox/state.py +157 -0
  63. offbox/status.py +378 -0
  64. offbox/sync.py +380 -0
  65. offbox/timings.py +69 -0
  66. offbox/tools.py +227 -0
  67. offbox/uninstall.py +114 -0
  68. offbox-0.1.0.dist-info/METADATA +140 -0
  69. offbox-0.1.0.dist-info/RECORD +72 -0
  70. offbox-0.1.0.dist-info/WHEEL +4 -0
  71. offbox-0.1.0.dist-info/entry_points.txt +2 -0
  72. offbox-0.1.0.dist-info/licenses/LICENSE +21 -0
offbox/state.py ADDED
@@ -0,0 +1,157 @@
1
+ import fcntl
2
+ import os
3
+ import shlex
4
+ import tempfile
5
+ from collections.abc import Callable, Generator
6
+ from contextlib import contextmanager
7
+ from datetime import datetime
8
+ from ipaddress import IPv4Address
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+ from pydantic import Field, ValidationError
13
+
14
+ from offbox.config import Model
15
+ from offbox.errors import PreconditionError
16
+
17
+
18
+ class HostState(Model):
19
+ control_master_pid: int | None = Field(default=None, gt=0)
20
+ phase: Literal["creating", "initializing", "ready"] = "ready"
21
+ name: str
22
+ provider: str
23
+ address: str
24
+ ssh_user: str
25
+ ssh_port: int = Field(default=22, ge=1, le=65535)
26
+ created_at: datetime
27
+ provider_dir: Path | None = None
28
+ provider_files_hash: str | None = None
29
+ known_hosts_path: Path
30
+ ssh_key: Path | None = None
31
+ auto: bool = False
32
+ idle_seconds: int | None = Field(default=None, gt=0)
33
+ create_interrupted: bool = False
34
+
35
+
36
+ class EnvState(Model):
37
+ forward_ip: IPv4Address | None = None
38
+ name: str
39
+ host: str
40
+ worktree_path: Path
41
+ created_at: datetime
42
+ local_ports: dict[int, int] = Field(default_factory=dict[int, int])
43
+ active_forwards: list[int] = Field(default_factory=list[int])
44
+ image_digest: str | None = None
45
+ approved_exec_env: list[str] | None = None
46
+ approved_files: list[str] | None = None
47
+ approved_git: list[str] | None = None
48
+ copied_files: dict[str, str] = Field(default_factory=dict[str, str])
49
+ mirrored_git: str | None = None
50
+
51
+
52
+ class State(Model):
53
+ version: Literal[1] = 1
54
+ default_host: str | None = None
55
+ hosts: dict[str, HostState] = Field(default_factory=dict)
56
+ envs: dict[str, EnvState] = Field(default_factory=dict)
57
+
58
+
59
+ class StateStore:
60
+ def __init__(self, directory: Path) -> None:
61
+ self.directory = directory
62
+ self.path = directory / "state.json"
63
+ self.backup_path = directory / "state.json.bak"
64
+ self.lock_path = directory / "state.lock"
65
+
66
+ @contextmanager
67
+ def _lock(self) -> Generator[None]:
68
+ try:
69
+ self.directory.mkdir(parents=True, exist_ok=True, mode=0o700)
70
+ lock = self.lock_path.open("a", encoding="utf-8")
71
+ except OSError as exc:
72
+ raise PreconditionError(f"Cannot lock state {self.lock_path}", "Check the state directory permissions.") from exc
73
+ with lock:
74
+ try:
75
+ fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
76
+ except OSError as exc:
77
+ raise PreconditionError(f"Cannot lock state {self.lock_path}", "Check the state directory permissions.") from exc
78
+ try:
79
+ yield
80
+ finally:
81
+ fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
82
+
83
+ def _recovery_fix(self) -> str:
84
+ steps = ["Repair the state file"]
85
+ if self.backup_path.is_file():
86
+ steps = [f"Restore the previous state with cp {shlex.quote(str(self.backup_path))} {shlex.quote(str(self.path))}, or repair it"]
87
+ try:
88
+ workspaces = sorted(path for path in (self.directory / "hosts").glob("*/tofu") if path.is_dir())
89
+ except OSError:
90
+ workspaces = []
91
+ if workspaces:
92
+ commands = "; ".join(shlex.join(["tofu", f"-chdir={workspace}", "destroy"]) for workspace in workspaces)
93
+ steps.append(f"to delete cloud hosts without offbox, run {commands}")
94
+ return "; ".join(steps) + "."
95
+
96
+ def _read(self) -> State:
97
+ try:
98
+ return State.model_validate_json(self.path.read_text(encoding="utf-8"))
99
+ except FileNotFoundError:
100
+ return State()
101
+ except (OSError, UnicodeError, ValidationError) as exc:
102
+ raise PreconditionError(f"Cannot read state {self.path}", self._recovery_fix()) from exc
103
+
104
+ def _backup(self) -> None:
105
+ try:
106
+ previous = self.path.read_bytes()
107
+ except FileNotFoundError:
108
+ return
109
+ temporary: Path | None = None
110
+ try:
111
+ with tempfile.NamedTemporaryFile(mode="wb", dir=self.directory, prefix=".state-backup-", delete=False) as target:
112
+ temporary = Path(target.name)
113
+ target.write(previous)
114
+ target.flush()
115
+ os.fsync(target.fileno())
116
+ os.replace(temporary, self.backup_path)
117
+ finally:
118
+ if temporary is not None:
119
+ temporary.unlink(missing_ok=True)
120
+
121
+ def _write(self, state: State) -> None:
122
+ validated = State.model_validate_json(state.model_dump_json())
123
+ temporary: Path | None = None
124
+ try:
125
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=self.directory, prefix=".state-", delete=False) as target:
126
+ temporary = Path(target.name)
127
+ target.write(validated.model_dump_json(indent=2) + "\n")
128
+ target.flush()
129
+ os.fsync(target.fileno())
130
+ self._backup()
131
+ os.replace(temporary, self.path)
132
+ except OSError as exc:
133
+ raise PreconditionError(f"Cannot write state {self.path}", "Check free space and state directory permissions.") from exc
134
+ finally:
135
+ if temporary is not None:
136
+ temporary.unlink(missing_ok=True)
137
+
138
+ def read(self) -> State:
139
+ with self._lock():
140
+ return self._read()
141
+
142
+ def write(self, state: State) -> None:
143
+ with self._lock():
144
+ self._write(state)
145
+
146
+ @contextmanager
147
+ def transaction(self) -> Generator[State]:
148
+ """Hold the exclusive lock across a read-modify-write operation."""
149
+ with self._lock():
150
+ state = self._read()
151
+ yield state
152
+ self._write(state)
153
+
154
+ def update(self, change: Callable[[State], None]) -> State:
155
+ with self.transaction() as state:
156
+ change(state)
157
+ return state
offbox/status.py ADDED
@@ -0,0 +1,378 @@
1
+ import re
2
+ from collections.abc import Callable
3
+ from pathlib import Path
4
+ from typing import Literal
5
+
6
+ from pydantic import Field, TypeAdapter, ValidationError, model_validator
7
+
8
+ from offbox.config import Model, ProjectConfig
9
+ from offbox.errors import OffboxError, PreconditionError
10
+ from offbox.host import host_operation
11
+ from offbox.naming import Worktree
12
+ from offbox.paths import Paths
13
+ from offbox.ports import PortsReport
14
+ from offbox.project import load_project
15
+ from offbox.runner import Runner
16
+ from offbox.runtime import ContainerInfo, DockerModel, HostResources, Runtime
17
+ from offbox.state import HostState
18
+ from offbox.sync import SyncHealth, health
19
+
20
+
21
+ class StatusCheck(Model):
22
+ name: str
23
+ level: Literal["ok", "warning", "error"]
24
+ message: str
25
+ fix: str | None = None
26
+
27
+ @model_validator(mode="after")
28
+ def require_fix(self) -> "StatusCheck":
29
+ if self.level != "ok" and not self.fix:
30
+ raise ValueError("A problem requires a fix")
31
+ return self
32
+
33
+
34
+ class StackService(Model):
35
+ name: str
36
+ state: str
37
+ health: str | None = None
38
+
39
+
40
+ class StatusReport(Model):
41
+ env: str | None = None
42
+ host: str | None = None
43
+ checks: list[StatusCheck] = Field(default_factory=list[StatusCheck])
44
+ ports: PortsReport = Field(default_factory=PortsReport)
45
+ stack: list[StackService] = Field(default_factory=list[StackService])
46
+ sync: SyncHealth | None = None
47
+
48
+ @property
49
+ def exit_code(self) -> int:
50
+ return 3 if any(check.level == "error" for check in self.checks) else 0
51
+
52
+
53
+ class ComposeStatus(DockerModel):
54
+ name: str = Field(alias="Service")
55
+ state: str = Field(alias="State")
56
+ health: str | None = Field(default=None, alias="Health")
57
+
58
+
59
+ class Platform(DockerModel):
60
+ architecture: str
61
+ os: str
62
+
63
+
64
+ class Descriptor(DockerModel):
65
+ digest: str | None = Field(default=None, pattern=r"^sha256:[a-f0-9]{64}$")
66
+ platform: Platform | None = None
67
+
68
+
69
+ class ConfigDigest(DockerModel):
70
+ digest: str = Field(pattern=r"^sha256:[a-f0-9]{64}$")
71
+
72
+
73
+ class ImageManifest(DockerModel):
74
+ config: ConfigDigest
75
+
76
+
77
+ class ManifestEntry(DockerModel):
78
+ descriptor: Descriptor = Field(alias="Descriptor")
79
+ oci: ImageManifest | None = Field(default=None, alias="OCIManifest")
80
+ docker: ImageManifest | None = Field(default=None, alias="SchemaV2Manifest")
81
+
82
+
83
+ class RegistryImage(Model):
84
+ config: str
85
+ manifests: set[str]
86
+
87
+ @property
88
+ def identities(self) -> set[str]:
89
+ return {self.config, *self.manifests}
90
+
91
+
92
+ def registry_image(text: str) -> RegistryImage:
93
+ try:
94
+ entries = (
95
+ TypeAdapter(list[ManifestEntry]).validate_json(text)
96
+ if text.lstrip().startswith("[")
97
+ else [ManifestEntry.model_validate_json(text)]
98
+ )
99
+ digests: set[str] = set()
100
+ manifests: set[str] = set()
101
+ for entry in entries:
102
+ platform = entry.descriptor.platform
103
+ if platform is not None and (platform.os, platform.architecture) != ("linux", "amd64"):
104
+ continue
105
+ manifest = entry.oci or entry.docker
106
+ if manifest is not None:
107
+ digests.add(manifest.config.digest)
108
+ if entry.descriptor.digest is not None:
109
+ manifests.add(entry.descriptor.digest)
110
+ if len(digests) != 1:
111
+ raise ValueError("Expected one Linux amd64 image config digest")
112
+ return RegistryImage(config=digests.pop(), manifests=manifests)
113
+ except (ValidationError, ValueError) as exc:
114
+ raise PreconditionError(
115
+ "Cannot determine the registry image version", "Check docker manifest inspect --verbose for the configured image."
116
+ ) from exc
117
+
118
+
119
+ def registry_config_digest(text: str) -> str:
120
+ return registry_image(text).config
121
+
122
+
123
+ def image_repository(reference: str) -> str:
124
+ name = reference.split("@", 1)[0]
125
+ tag = name.rfind(":")
126
+ return name[:tag] if tag > name.rfind("/") else name
127
+
128
+
129
+ def compose_status(text: str) -> list[StackService]:
130
+ try:
131
+ entries = (
132
+ TypeAdapter(list[ComposeStatus]).validate_json(text)
133
+ if text.lstrip().startswith("[")
134
+ else [ComposeStatus.model_validate_json(line) for line in text.splitlines() if line.strip()]
135
+ )
136
+ except ValidationError as exc:
137
+ raise PreconditionError("Invalid Compose health response", "Check docker compose ps --format json inside the environment.") from exc
138
+ return [StackService(name=item.name, state=item.state, health=item.health or None) for item in entries]
139
+
140
+
141
+ class HostConfig(DockerModel):
142
+ memory: int = Field(alias="Memory", ge=0)
143
+
144
+
145
+ class ResourceContainer(DockerModel):
146
+ limits: HostConfig = Field(alias="HostConfig")
147
+
148
+
149
+ class Status:
150
+ def __init__(self, paths: Paths, runner: Runner) -> None:
151
+ self.runtime = Runtime(paths, runner)
152
+ self.report = StatusReport()
153
+
154
+ def capture(self, name: str, probe: Callable[[], StatusCheck]) -> bool:
155
+ try:
156
+ check = probe()
157
+ except OffboxError as exc:
158
+ check = StatusCheck(name=name, level="error", message=exc.message, fix=exc.fix or "Inspect offbox logs and retry.")
159
+ self.report.checks.append(check)
160
+ return check.level != "error"
161
+
162
+ def disk(self, host: HostState) -> StatusCheck:
163
+ text = self.runtime.remote(host, ["df", "-P", "/srv/offbox"], timeout=5).stdout
164
+ lines = text.splitlines()
165
+ columns = lines[-1].split() if lines else []
166
+ if len(columns) < 5 or re.fullmatch(r"(?:100|[0-9]{1,2})%", columns[4]) is None:
167
+ raise PreconditionError("Invalid host disk usage response", "Check df -P /srv/offbox on the host.")
168
+ percent = int(columns[4][:-1])
169
+ level: Literal["ok", "warning", "error"] = "error" if percent >= 95 else "warning" if percent >= 80 else "ok"
170
+ return StatusCheck(
171
+ name="disk",
172
+ level=level,
173
+ message=f"Host disk {percent}% used",
174
+ fix="Run offbox gc or offbox rm in unused worktrees." if level != "ok" else None,
175
+ )
176
+
177
+ def resources(self, host: HostState) -> StatusCheck:
178
+ try:
179
+ resources = HostResources.model_validate_json(
180
+ self.runtime.remote(host, ["docker", "info", "--format", '{"cpus":{{.NCPU}},"memory":{{.MemTotal}}}']).stdout
181
+ )
182
+ identifiers = self.runtime.remote(host, ["docker", "ps", "-q", "--filter", "label=offbox.env"]).stdout.split()
183
+ if any(re.fullmatch(r"[a-f0-9]{12,64}", item) is None for item in identifiers):
184
+ raise ValueError("Invalid Docker identifiers")
185
+ containers = (
186
+ TypeAdapter(list[ResourceContainer]).validate_json(
187
+ self.runtime.remote(host, ["docker", "container", "inspect", *identifiers]).stdout
188
+ )
189
+ if identifiers
190
+ else []
191
+ )
192
+ if len(containers) != len(identifiers):
193
+ raise ValueError("Missing running container resource information")
194
+ except (ValidationError, ValueError) as exc:
195
+ raise PreconditionError(
196
+ "Invalid host memory information", "Inspect the host Docker daemon and running environment limits."
197
+ ) from exc
198
+ total = sum(item.limits.memory for item in containers)
199
+ unlimited = any(item.limits.memory == 0 for item in containers)
200
+ warning = unlimited or total > resources.memory
201
+ return StatusCheck(
202
+ name="resources",
203
+ level="warning" if warning else "ok",
204
+ message=(
205
+ "Running environments include unlimited memory"
206
+ if unlimited
207
+ else f"Running memory limits {total} / {resources.memory} bytes"
208
+ ),
209
+ fix="Stop unused environments or reduce env.memory and run offbox up --fresh." if warning else None,
210
+ )
211
+
212
+ def image(self, work: Worktree, host: HostState, project: ProjectConfig, container: ContainerInfo) -> StatusCheck:
213
+ try:
214
+ if project.env.dockerfile is not None:
215
+ dockerfile = self.runtime.dockerfile_path(work, project.env.dockerfile)
216
+ image = self.runtime.custom_image_tag(host, work, dockerfile)
217
+ result = self.runtime.remote(host, ["docker", "image", "inspect", "--format", "{{.Id}}", image], check=False, timeout=10)
218
+ if result.returncode:
219
+ raise PreconditionError("Configured custom image has not been built", "Run offbox up --fresh.")
220
+ digest = result.stdout.strip()
221
+ if re.fullmatch(r"sha256:[a-f0-9]{64}", digest) is None:
222
+ raise PreconditionError("Cannot identify the cached custom image", "Run offbox up to build the configured Dockerfile.")
223
+ different = digest != container.image
224
+ else:
225
+ different = not self.registry_current(host, project.env.image or "", container.image)
226
+ except OffboxError as exc:
227
+ return StatusCheck(
228
+ name="image",
229
+ level="warning",
230
+ message=f"Image update check unavailable: {exc.message}",
231
+ fix=exc.fix or "Check the image registry and retry.",
232
+ )
233
+ return StatusCheck(
234
+ name="image",
235
+ level="warning" if different else "ok",
236
+ message="Newer image available" if different else "Image matches the configured image version",
237
+ fix="Run offbox up --fresh." if different else None,
238
+ )
239
+
240
+ def registry_current(self, host: HostState, image: str, running: str) -> bool:
241
+ """Compare by config or manifest digest; the containerd image store reports the pulled index or manifest digest instead."""
242
+ result = self.runtime.remote(host, ["docker", "manifest", "inspect", "--verbose", image], timeout=15)
243
+ latest = registry_image(result.stdout)
244
+ if running in latest.identities:
245
+ return True
246
+ if re.fullmatch(r"sha256:[a-f0-9]{64}", running) is None:
247
+ return False
248
+ pulled = self.runtime.remote(
249
+ host, ["docker", "manifest", "inspect", "--verbose", f"{image_repository(image)}@{running}"], check=False, timeout=15
250
+ )
251
+ return pulled.returncode == 0 and registry_config_digest(pulled.stdout) == latest.config
252
+
253
+ def diagnose(self, *, cwd: Path | None = None) -> StatusReport:
254
+ try:
255
+ work, _env, host = self.runtime.current(cwd)
256
+ except OffboxError as exc:
257
+ self.report.checks.append(StatusCheck(name="environment", level="error", message=exc.message, fix=exc.fix or "Run offbox up."))
258
+ return self.report
259
+ self.report.env, self.report.host = work.name, host.name
260
+ project: ProjectConfig | None = None
261
+ try:
262
+ project = load_project(self.runtime.paths, work.root)
263
+ except OffboxError as exc:
264
+ self.report.checks.append(StatusCheck(name="config", level="error", message=exc.message, fix=exc.fix or "Correct offbox.toml."))
265
+
266
+ def reachable() -> StatusCheck:
267
+ version = self.runtime.remote(host, ["cat", "/var/lib/offbox/version"], timeout=5).stdout.strip()
268
+ if not version or re.fullmatch(r"[A-Za-z0-9.+_-]+", version) is None:
269
+ raise PreconditionError("Invalid host offbox version", "Reinitialize the host with offbox host init.")
270
+ return StatusCheck(name="host", level="ok", message=f"Host {host.name} reachable, offbox {version}")
271
+
272
+ if not self.capture("host", reachable):
273
+ return self.report
274
+ self.capture("disk", lambda: self.disk(host))
275
+ self.capture("resources", lambda: self.resources(host))
276
+ container: ContainerInfo | None = None
277
+
278
+ def environment() -> StatusCheck:
279
+ nonlocal container
280
+ container = self.runtime.inspect(host, work)
281
+ if container is None or not container.state.running:
282
+ raise PreconditionError("Environment is not running", "Run offbox up.")
283
+ labels = container.config.labels or {}
284
+ if (
285
+ container.name.lstrip("/") != work.container
286
+ or labels.get("offbox.env") != work.name
287
+ or labels.get("offbox.worktree") != str(work.root)
288
+ ):
289
+ raise PreconditionError("Environment ownership does not match", "Inspect offbox list and the container ownership labels.")
290
+ result = self.runtime.remote(
291
+ host,
292
+ [
293
+ "docker",
294
+ "exec",
295
+ work.container,
296
+ "sh",
297
+ "-c",
298
+ "test -f /run/offbox/ready && docker --host=unix:///var/run/docker.sock info >/dev/null 2>&1",
299
+ ],
300
+ check=False,
301
+ timeout=5,
302
+ )
303
+ if result.returncode:
304
+ raise PreconditionError(
305
+ "Environment Docker daemon is not ready", "Run offbox up and inspect offbox logs if readiness fails."
306
+ )
307
+ return StatusCheck(name="environment", level="ok", message="Environment is running and ready")
308
+
309
+ ready = self.capture("environment", environment)
310
+
311
+ def synchronization() -> StatusCheck:
312
+ session = self.runtime.sync.status(work, host)
313
+ if session is None:
314
+ raise PreconditionError("Sync session is missing", "Run offbox up to create source sync.")
315
+ self.report.sync = health(session)
316
+ result = self.report.sync
317
+ return StatusCheck(
318
+ name="sync",
319
+ level="ok" if result.ok else "error",
320
+ message=f"Sync {result.state}; conflicts={result.conflicts}, problems={result.problems}",
321
+ fix=result.fix,
322
+ )
323
+
324
+ self.capture("sync", synchronization)
325
+ if ready and container is not None and project is not None:
326
+ self.report.checks.append(self.image(work, host, project, container))
327
+
328
+ def ports() -> StatusCheck:
329
+ try:
330
+ with host_operation(self.runtime.paths, host.name, wait_timeout=120, clock=self.runtime.clock):
331
+ self.report.ports = self.runtime.ports.restore(work, host, project.ports)
332
+ except OffboxError:
333
+ current = self.runtime.store.read().envs.get(work.name)
334
+ if current is not None and current.host == host.name and current.worktree_path == work.root:
335
+ self.report.ports = self.runtime.ports.report(current)
336
+ raise
337
+ return StatusCheck(name="ports", level="ok", message=f"{len(self.report.ports.forwards)} active port forwards")
338
+
339
+ self.capture("ports", ports)
340
+ if any((work.root / name).is_file() for name in ("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml")):
341
+
342
+ def stack() -> StatusCheck:
343
+ text = self.runtime.remote(
344
+ host,
345
+ [
346
+ "docker",
347
+ "exec",
348
+ "-u",
349
+ "dev",
350
+ "-w",
351
+ "/work",
352
+ work.container,
353
+ "docker",
354
+ "compose",
355
+ "ps",
356
+ "--all",
357
+ "--format",
358
+ "json",
359
+ ],
360
+ ).stdout
361
+ self.report.stack = compose_status(text)
362
+ failures = [
363
+ service.name
364
+ for service in self.report.stack
365
+ if service.state != "running" or service.health not in (None, "healthy")
366
+ ]
367
+ return StatusCheck(
368
+ name="stack",
369
+ level="error" if failures else "ok",
370
+ message="Stack: "
371
+ + (", ".join(failures) + " not healthy" if failures else f"{len(self.report.stack)} services healthy"),
372
+ fix="Run offbox logs --stack and offbox up." if failures else None,
373
+ )
374
+
375
+ self.capture("stack", stack)
376
+ else:
377
+ self.report.checks.append(StatusCheck(name="stack", level="ok", message="No Compose stack configured"))
378
+ return self.report