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.
- offbox/__init__.py +2 -0
- offbox/__main__.py +4 -0
- offbox/_data/host/cloud-init.yaml.j2 +15 -0
- offbox/_data/host/host-init.sh +230 -0
- offbox/_data/images/universal/Dockerfile +101 -0
- offbox/_data/images/universal/offbox-init +75 -0
- offbox/_data/images/universal/offbox-run +131 -0
- offbox/_data/providers/gcp/.terraform.lock.hcl +52 -0
- offbox/_data/providers/gcp/main.tf +67 -0
- offbox/_data/providers/gcp/outputs.tf +7 -0
- offbox/_data/providers/gcp/provider.toml +9 -0
- offbox/_data/providers/gcp/variables.tf +33 -0
- offbox/_data/providers/gcp/versions.tf +12 -0
- offbox/_data/providers/hetzner/.terraform.lock.hcl +51 -0
- offbox/_data/providers/hetzner/main.tf +32 -0
- offbox/_data/providers/hetzner/outputs.tf +7 -0
- offbox/_data/providers/hetzner/provider.toml +9 -0
- offbox/_data/providers/hetzner/variables.tf +28 -0
- offbox/_data/providers/hetzner/versions.tf +12 -0
- offbox/_data/providers/ssh/provider.toml +4 -0
- offbox/cli/__init__.py +117 -0
- offbox/cli/config.py +39 -0
- offbox/cli/env.py +109 -0
- offbox/cli/exec.py +50 -0
- offbox/cli/host.py +313 -0
- offbox/cli/misc.py +132 -0
- offbox/cli/ports.py +44 -0
- offbox/config.py +189 -0
- offbox/console.py +63 -0
- offbox/doctor.py +174 -0
- offbox/drivers/__init__.py +0 -0
- offbox/drivers/base.py +40 -0
- offbox/drivers/none.py +114 -0
- offbox/drivers/tofu.py +59 -0
- offbox/errors.py +45 -0
- offbox/execute.py +772 -0
- offbox/host/__init__.py +89 -0
- offbox/host/activity.py +58 -0
- offbox/host/auto.py +322 -0
- offbox/host/cloudinit.py +44 -0
- offbox/host/create.py +198 -0
- offbox/host/destroy.py +151 -0
- offbox/host/init.py +196 -0
- offbox/host/ready.py +106 -0
- offbox/host/timer.py +227 -0
- offbox/host/usage.py +123 -0
- offbox/host/warnings.py +61 -0
- offbox/locking.py +27 -0
- offbox/logging.py +154 -0
- offbox/logs.py +135 -0
- offbox/naming.py +67 -0
- offbox/paths.py +55 -0
- offbox/ports.py +610 -0
- offbox/project.py +187 -0
- offbox/providers.py +117 -0
- offbox/py.typed +0 -0
- offbox/runner.py +291 -0
- offbox/runtime.py +887 -0
- offbox/setup.py +181 -0
- offbox/share.py +286 -0
- offbox/ssh.py +179 -0
- offbox/state.py +157 -0
- offbox/status.py +378 -0
- offbox/sync.py +380 -0
- offbox/timings.py +69 -0
- offbox/tools.py +227 -0
- offbox/uninstall.py +114 -0
- offbox-0.1.0.dist-info/METADATA +140 -0
- offbox-0.1.0.dist-info/RECORD +72 -0
- offbox-0.1.0.dist-info/WHEEL +4 -0
- offbox-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|