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/runtime.py
ADDED
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import Generator, Sequence
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from decimal import Decimal, DecimalException
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
|
|
16
|
+
|
|
17
|
+
from offbox import DEFAULT_IMAGE, __version__, console
|
|
18
|
+
from offbox.config import EnvConfig, Model, UserConfig, default_host, load_config
|
|
19
|
+
from offbox.errors import CommandFailed, ConnectionError_, OffboxError, PreconditionError
|
|
20
|
+
from offbox.host import host_operation, select_host
|
|
21
|
+
from offbox.host.activity import forget, last_activity, touch
|
|
22
|
+
from offbox.host.auto import clear_lost, ensure_host, lost_environment_hint, release_if_unused, timer_status
|
|
23
|
+
from offbox.host.ready import Clock, SystemClock
|
|
24
|
+
from offbox.host.timer import health as timer_health
|
|
25
|
+
from offbox.host.timer import installed as timer_installed
|
|
26
|
+
from offbox.host.usage import shared_usage, utc_now
|
|
27
|
+
from offbox.naming import Worktree, find_worktree, validate_env_name
|
|
28
|
+
from offbox.paths import Paths
|
|
29
|
+
from offbox.ports import PortManager
|
|
30
|
+
from offbox.project import load_project
|
|
31
|
+
from offbox.runner import Result, Runner
|
|
32
|
+
from offbox.ssh import ssh_argv
|
|
33
|
+
from offbox.state import EnvState, HostState, StateStore
|
|
34
|
+
from offbox.sync import SyncManager
|
|
35
|
+
from offbox.timings import measured
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DockerModel(BaseModel):
|
|
41
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True, hide_input_in_errors=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ContainerStatus(DockerModel):
|
|
45
|
+
running: bool = Field(alias="Running")
|
|
46
|
+
status: str = Field(default="unknown", alias="Status")
|
|
47
|
+
exit_code: int = Field(default=0, alias="ExitCode")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ContainerConfig(DockerModel):
|
|
51
|
+
labels: dict[str, str] | None = Field(default=None, alias="Labels")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ContainerInfo(DockerModel):
|
|
55
|
+
name: str = Field(alias="Name")
|
|
56
|
+
image: str = Field(alias="Image")
|
|
57
|
+
state: ContainerStatus = Field(alias="State")
|
|
58
|
+
config: ContainerConfig = Field(alias="Config")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class VolumeInfo(DockerModel):
|
|
62
|
+
name: str = Field(alias="Name")
|
|
63
|
+
labels: dict[str, str] | None = Field(default=None, alias="Labels")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class BuiltImage(Model):
|
|
67
|
+
tag: str
|
|
68
|
+
digest: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class HostResources(Model):
|
|
72
|
+
cpus: int = Field(gt=0)
|
|
73
|
+
memory: int = Field(gt=0)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class EnvSummary(Model):
|
|
77
|
+
name: str
|
|
78
|
+
host: str
|
|
79
|
+
worktree: Path
|
|
80
|
+
status: str
|
|
81
|
+
running: bool = False
|
|
82
|
+
image_digest: str | None = None
|
|
83
|
+
last_used: datetime | None = None
|
|
84
|
+
fix: str | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class EnvReport(Model):
|
|
88
|
+
envs: list[EnvSummary] = Field(default_factory=list[EnvSummary])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class GcTarget(Model):
|
|
92
|
+
host: str
|
|
93
|
+
worktree: Worktree
|
|
94
|
+
reason: str
|
|
95
|
+
registered: bool
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class GcPlan(Model):
|
|
99
|
+
targets: list[GcTarget] = Field(default_factory=list[GcTarget])
|
|
100
|
+
running: list[GcTarget] = Field(default_factory=list[GcTarget])
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def worktree_exists(root: Path) -> bool:
|
|
104
|
+
try:
|
|
105
|
+
root.stat()
|
|
106
|
+
return True
|
|
107
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
108
|
+
return False
|
|
109
|
+
except OSError as exc:
|
|
110
|
+
raise PreconditionError(f"Cannot inspect worktree {root}", "Restore access to the worktree before running GC.") from exc
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def env_host_mismatch(previous: EnvState | None, configured: str | None) -> str | None:
|
|
114
|
+
if previous is None or configured is None or configured == previous.host:
|
|
115
|
+
return None
|
|
116
|
+
return f"This environment runs on {previous.host}; env.host says {configured}. Run offbox rm, then offbox up to move it."
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
STOP_LOCK_WAIT_SECONDS = 10
|
|
120
|
+
READY_POLL_SECONDS = 0.2
|
|
121
|
+
SETTINGS_LABEL = "offbox.settings"
|
|
122
|
+
_HOST_RESOURCES = r"""test -f /var/lib/offbox/ready || { printf '%s\n' 'offbox: host initialisation is incomplete' >&2; exit 3; }
|
|
123
|
+
exec docker info --format '{"cpus":{{.NCPU}},"memory":{{.MemTotal}}}'
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def settings_digest(command: Sequence[str]) -> str:
|
|
128
|
+
"""Hash the container-shaping docker run settings; offbox upgrades and labels do not count as drift."""
|
|
129
|
+
values = {flag: command[command.index(flag) + 1] for flag in ("--cpus", "--memory", "--shm-size")}
|
|
130
|
+
values["image"] = command[-1]
|
|
131
|
+
return hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest()[:32]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def with_settings_label(command: Sequence[str]) -> list[str]:
|
|
135
|
+
return [*command[:-1], "--label", f"{SETTINGS_LABEL}={settings_digest(command)}", command[-1]]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _labels(work: Worktree) -> list[str]:
|
|
139
|
+
return ["--label", f"offbox.env={work.name}", "--label", f"offbox.worktree={work.root}", "--label", f"offbox.version={__version__}"]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def volume_command(work: Worktree) -> list[str]:
|
|
143
|
+
return ["docker", "volume", "create", *_labels(work), work.volume]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _byte_limit(value: str, field: str, *, minimum: int = 1) -> None:
|
|
147
|
+
match = re.fullmatch(r"(\+?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:e[+-]?[0-9]+)?)(?: ?(b|[kmgtp](?:b|ib)?))?", value, flags=re.IGNORECASE)
|
|
148
|
+
if match is None:
|
|
149
|
+
raise PreconditionError(f"Invalid {field} size", f"Set env.{field} to a positive Docker byte size, for example 8g.")
|
|
150
|
+
suffix = (match[2] or "b").lower()
|
|
151
|
+
exponent = {"b": 0, "k": 1, "m": 2, "g": 3, "t": 4, "p": 5}[suffix[0]]
|
|
152
|
+
try:
|
|
153
|
+
amount = Decimal(match[1]) * (1024**exponent)
|
|
154
|
+
if not amount.is_finite() or amount < minimum or amount > 2**63 - 1:
|
|
155
|
+
raise PreconditionError(f"Invalid {field} size", f"Set env.{field} between {minimum} and {2**63 - 1} bytes.")
|
|
156
|
+
except DecimalException as exc:
|
|
157
|
+
raise PreconditionError(f"Invalid {field} size", f"Set env.{field} to a finite Docker byte size.") from exc
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def container_command(work: Worktree, config: EnvConfig, host_cpus: int, host_memory: int) -> list[str]:
|
|
161
|
+
if config.dockerfile is not None or config.image is None:
|
|
162
|
+
raise PreconditionError(
|
|
163
|
+
"Custom Dockerfile builds require source sync", "Set env.image to a prebuilt image until source sync is available."
|
|
164
|
+
)
|
|
165
|
+
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:/@-]*", config.image) is None:
|
|
166
|
+
raise PreconditionError("Invalid container image reference", "Set env.image to a valid prebuilt Docker image reference.")
|
|
167
|
+
cpus, memory = config.resource_limits(host_cpus, host_memory)
|
|
168
|
+
if not math.isfinite(cpus) or cpus < 0.01 or cpus > host_cpus:
|
|
169
|
+
raise PreconditionError("Invalid CPU limit", "Set env.cpus between 0.01 and the host CPU count.")
|
|
170
|
+
_byte_limit(memory, "memory", minimum=6 * 1024**2)
|
|
171
|
+
_byte_limit(config.shm_size, "shm_size")
|
|
172
|
+
return [
|
|
173
|
+
"docker",
|
|
174
|
+
"run",
|
|
175
|
+
"-d",
|
|
176
|
+
"--name",
|
|
177
|
+
work.container,
|
|
178
|
+
"--hostname",
|
|
179
|
+
work.name,
|
|
180
|
+
"--privileged",
|
|
181
|
+
"--cgroupns=private",
|
|
182
|
+
"--network",
|
|
183
|
+
"offbox",
|
|
184
|
+
"--cpus",
|
|
185
|
+
f"{cpus:g}",
|
|
186
|
+
"--memory",
|
|
187
|
+
memory,
|
|
188
|
+
"--shm-size",
|
|
189
|
+
config.shm_size,
|
|
190
|
+
"--restart",
|
|
191
|
+
"no",
|
|
192
|
+
"-v",
|
|
193
|
+
f"{work.work}:/work",
|
|
194
|
+
"-v",
|
|
195
|
+
f"{work.volume}:/var/lib/docker",
|
|
196
|
+
*_labels(work),
|
|
197
|
+
config.image,
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
_GUARD = """set -eu
|
|
202
|
+
name=$1
|
|
203
|
+
worktree=$2
|
|
204
|
+
container=$3
|
|
205
|
+
volume=$4
|
|
206
|
+
directory=/srv/offbox/envs/$name
|
|
207
|
+
for path in /srv/offbox/envs "$directory" "$directory/work"; do
|
|
208
|
+
if [ -L "$path" ]; then
|
|
209
|
+
printf '%s\\n' 'offbox: refusing a symlinked environment directory' >&2
|
|
210
|
+
exit 3
|
|
211
|
+
fi
|
|
212
|
+
done
|
|
213
|
+
container_id=$(docker ps -aq --filter "name=^/${container}$")
|
|
214
|
+
volume_name=$(docker volume ls -q --filter "name=^${volume}$")
|
|
215
|
+
if [ -n "$container_id" ]; then
|
|
216
|
+
env_label=$(docker container inspect --format '{{index .Config.Labels "offbox.env"}}' "$container")
|
|
217
|
+
path_label=$(docker container inspect --format '{{index .Config.Labels "offbox.worktree"}}' "$container")
|
|
218
|
+
if [ "$env_label" != "$name" ] || [ "$path_label" != "$worktree" ]; then
|
|
219
|
+
printf '%s\\n' 'offbox: refusing a container with conflicting ownership labels' >&2
|
|
220
|
+
exit 3
|
|
221
|
+
fi
|
|
222
|
+
fi
|
|
223
|
+
if [ -n "$volume_name" ]; then
|
|
224
|
+
env_label=$(docker volume inspect --format '{{index .Labels "offbox.env"}}' "$volume")
|
|
225
|
+
path_label=$(docker volume inspect --format '{{index .Labels "offbox.worktree"}}' "$volume")
|
|
226
|
+
if [ "$env_label" != "$name" ] || [ "$path_label" != "$worktree" ]; then
|
|
227
|
+
printf '%s\\n' 'offbox: refusing a volume with conflicting ownership labels' >&2
|
|
228
|
+
exit 3
|
|
229
|
+
fi
|
|
230
|
+
fi
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def mutation_script(
|
|
235
|
+
work: Worktree, action: Literal["up", "fresh", "stop", "remove", "prune", "prepare"], command: Sequence[str] = (), *, pull: bool = True
|
|
236
|
+
) -> str:
|
|
237
|
+
if action == "prepare":
|
|
238
|
+
return _GUARD + '\nmkdir -p -- "$directory/work"\n'
|
|
239
|
+
if action in ("up", "fresh"):
|
|
240
|
+
if not command:
|
|
241
|
+
raise PreconditionError("Container command is missing", "Provide a valid environment configuration.")
|
|
242
|
+
fresh = "true" if action == "fresh" else "false"
|
|
243
|
+
prepare_image = shlex.join(["docker", "pull", command[-1]]) + " >/dev/null" if pull else ":"
|
|
244
|
+
return (
|
|
245
|
+
_GUARD
|
|
246
|
+
+ f"""
|
|
247
|
+
if {fresh} || [ -z "$container_id" ]; then
|
|
248
|
+
{prepare_image}
|
|
249
|
+
if {fresh}; then
|
|
250
|
+
if [ -n "$container_id" ]; then docker rm -f "$container" >/dev/null; fi
|
|
251
|
+
if [ -n "$volume_name" ]; then docker volume rm "$volume" >/dev/null; fi
|
|
252
|
+
volume_name=
|
|
253
|
+
fi
|
|
254
|
+
mkdir -p -- "$directory/work"
|
|
255
|
+
if [ -z "$volume_name" ]; then {shlex.join(volume_command(work))} >/dev/null; fi
|
|
256
|
+
{shlex.join(command)} >/dev/null
|
|
257
|
+
elif [ "$(docker container inspect --format '{{{{.State.Running}}}}' "$container")" != true ]; then
|
|
258
|
+
docker start "$container" >/dev/null
|
|
259
|
+
fi
|
|
260
|
+
docker container inspect "$container"
|
|
261
|
+
"""
|
|
262
|
+
)
|
|
263
|
+
if action == "prune":
|
|
264
|
+
return (
|
|
265
|
+
_GUARD
|
|
266
|
+
+ """
|
|
267
|
+
if [ -n "$container_id" ] && [ "$(docker container inspect --format '{{.State.Running}}' "$container")" = true ]; then
|
|
268
|
+
docker exec "$container" docker system prune -f >/dev/null
|
|
269
|
+
fi
|
|
270
|
+
"""
|
|
271
|
+
)
|
|
272
|
+
if action == "stop":
|
|
273
|
+
return (
|
|
274
|
+
_GUARD
|
|
275
|
+
+ """
|
|
276
|
+
if [ -n "$container_id" ] && [ "$(docker container inspect --format '{{.State.Running}}' "$container")" = true ]; then
|
|
277
|
+
docker stop --timeout 30 "$container" >/dev/null
|
|
278
|
+
fi
|
|
279
|
+
"""
|
|
280
|
+
)
|
|
281
|
+
return (
|
|
282
|
+
_GUARD
|
|
283
|
+
+ """
|
|
284
|
+
if [ -n "$container_id" ]; then docker rm -f "$container" >/dev/null; fi
|
|
285
|
+
if [ -n "$volume_name" ]; then docker volume rm "$volume" >/dev/null; fi
|
|
286
|
+
sudo -n rm -rf -- "$directory"
|
|
287
|
+
"""
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class Runtime:
|
|
292
|
+
def __init__(self, paths: Paths, runner: Runner, clock: Clock | None = None) -> None:
|
|
293
|
+
self.paths = paths
|
|
294
|
+
self.runner = runner
|
|
295
|
+
self.clock = clock or SystemClock()
|
|
296
|
+
self.store = StateStore(paths.state)
|
|
297
|
+
self.sync = SyncManager(runner, cache=paths.cache)
|
|
298
|
+
self.ports = PortManager(paths, runner, clock=self.clock)
|
|
299
|
+
|
|
300
|
+
def remote(self, host: HostState, argv: Sequence[str], *, check: bool = True, timeout: float = 30, input: str | None = None) -> Result:
|
|
301
|
+
result = self.runner.run(ssh_argv(host.name, argv), check=False, timeout=timeout, input=input)
|
|
302
|
+
if result.returncode == 255:
|
|
303
|
+
raise ConnectionError_(f"Cannot reach host {host.name}", f"Run offbox host ssh {host.name} and check connectivity.")
|
|
304
|
+
if check and result.returncode:
|
|
305
|
+
logger.error("remote failure stdout=%s stderr=%s", result.stdout, result.stderr)
|
|
306
|
+
if "offbox: refusing" in result.stderr:
|
|
307
|
+
raise PreconditionError(result.stderr.strip(), "Inspect the environment ownership and paths before retrying.")
|
|
308
|
+
raise CommandFailed(result.argv, result.returncode, result.stderr)
|
|
309
|
+
return result
|
|
310
|
+
|
|
311
|
+
def inspect(self, host: HostState, work: Worktree, *, timeout: float = 30) -> ContainerInfo | None:
|
|
312
|
+
result = self.remote(host, ["docker", "container", "inspect", work.container], check=False, timeout=timeout)
|
|
313
|
+
if result.returncode:
|
|
314
|
+
if "no such container" in result.stderr.lower() or "no such object" in result.stderr.lower():
|
|
315
|
+
return None
|
|
316
|
+
raise CommandFailed(result.argv, result.returncode, result.stderr)
|
|
317
|
+
return self._container(result.stdout)
|
|
318
|
+
|
|
319
|
+
def _container(self, text: str) -> ContainerInfo:
|
|
320
|
+
try:
|
|
321
|
+
values = TypeAdapter(list[ContainerInfo]).validate_json(text)
|
|
322
|
+
if len(values) != 1:
|
|
323
|
+
raise ValueError("Expected one container")
|
|
324
|
+
return values[0]
|
|
325
|
+
except (ValueError, ValidationError) as exc:
|
|
326
|
+
raise PreconditionError(
|
|
327
|
+
"Invalid Docker container inspection response", "Check the host Docker daemon and offbox logs."
|
|
328
|
+
) from exc
|
|
329
|
+
|
|
330
|
+
def _owned(self, work: Worktree, container: ContainerInfo) -> None:
|
|
331
|
+
labels = container.config.labels or {}
|
|
332
|
+
if (
|
|
333
|
+
container.name.lstrip("/") != work.container
|
|
334
|
+
or labels.get("offbox.env") != work.name
|
|
335
|
+
or labels.get("offbox.worktree") != str(work.root)
|
|
336
|
+
):
|
|
337
|
+
raise PreconditionError(
|
|
338
|
+
f"Container {work.container} has conflicting ownership", "Inspect its labels or choose a different worktree path."
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
def _host(self, name: str | None) -> HostState:
|
|
342
|
+
host = select_host(self.paths, name)
|
|
343
|
+
if host.phase != "ready":
|
|
344
|
+
raise PreconditionError(f"Host {host.name} is not ready", "Finish host initialisation before running an environment.")
|
|
345
|
+
return host
|
|
346
|
+
|
|
347
|
+
def _recorded(self, work: Worktree) -> EnvState | None:
|
|
348
|
+
env = self.store.read().envs.get(work.name)
|
|
349
|
+
if env is not None and (env.name != work.name or env.worktree_path != work.root):
|
|
350
|
+
raise PreconditionError("Environment name collides with another worktree", "Use a different worktree directory name.")
|
|
351
|
+
return env
|
|
352
|
+
|
|
353
|
+
def registered(self, work: Worktree) -> EnvState:
|
|
354
|
+
env = self._recorded(work)
|
|
355
|
+
if env is None:
|
|
356
|
+
hint = lost_environment_hint(self.paths, work.name)
|
|
357
|
+
raise PreconditionError("Environment is not registered", f"offbox up ({hint})" if hint else "offbox up")
|
|
358
|
+
return env
|
|
359
|
+
|
|
360
|
+
def current(self, cwd: Path | None = None) -> tuple[Worktree, EnvState, HostState]:
|
|
361
|
+
work = find_worktree(self.runner, cwd)
|
|
362
|
+
env = self.registered(work)
|
|
363
|
+
return work, env, self._host(env.host)
|
|
364
|
+
|
|
365
|
+
@contextmanager
|
|
366
|
+
def using(self, command: str, cwd: Path | None = None) -> Generator[tuple[Worktree, EnvState, HostState]]:
|
|
367
|
+
"""Hold the host's shared usage lock and record activity around a command, so no reap or rm destroys the host under it."""
|
|
368
|
+
work = find_worktree(self.runner, cwd)
|
|
369
|
+
for _attempt in range(2):
|
|
370
|
+
peeked = self.registered(work).host
|
|
371
|
+
with shared_usage(self.paths, peeked, command, clock=self.clock):
|
|
372
|
+
env = self.registered(work)
|
|
373
|
+
if env.host != peeked:
|
|
374
|
+
continue
|
|
375
|
+
host = self._host(env.host)
|
|
376
|
+
touch(self.paths, env.name, utc_now())
|
|
377
|
+
try:
|
|
378
|
+
yield work, env, host
|
|
379
|
+
finally:
|
|
380
|
+
touch(self.paths, env.name, utc_now())
|
|
381
|
+
return
|
|
382
|
+
raise PreconditionError("Host selection changed while starting", "Retry the command.")
|
|
383
|
+
|
|
384
|
+
@measured("Container operation")
|
|
385
|
+
def mutate(
|
|
386
|
+
self,
|
|
387
|
+
host: HostState,
|
|
388
|
+
work: Worktree,
|
|
389
|
+
action: Literal["up", "fresh", "stop", "remove", "prune", "prepare"],
|
|
390
|
+
command: Sequence[str] = (),
|
|
391
|
+
*,
|
|
392
|
+
pull: bool = True,
|
|
393
|
+
) -> Result:
|
|
394
|
+
return self.remote(
|
|
395
|
+
host,
|
|
396
|
+
["flock", "/var/lib/offbox/lock", "sh", "-s", "--", work.name, str(work.root), work.container, work.volume],
|
|
397
|
+
input=mutation_script(work, action, command, pull=pull),
|
|
398
|
+
timeout=600,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def up_target(self, work: Worktree, host_name: str | None) -> str:
|
|
402
|
+
"""The host an up uses: the recorded host of an existing environment, else --host, env.host or the default host."""
|
|
403
|
+
previous = self._recorded(work)
|
|
404
|
+
if previous is not None:
|
|
405
|
+
if host_name is not None and previous.host != host_name:
|
|
406
|
+
raise PreconditionError("Environment is registered on another host", "Remove it before selecting a different host.")
|
|
407
|
+
return previous.host
|
|
408
|
+
selected = (
|
|
409
|
+
host_name
|
|
410
|
+
or load_project(self.paths, work.root).env.host
|
|
411
|
+
or default_host(load_config(self.paths.user_config, UserConfig), self.store.read().default_host)
|
|
412
|
+
)
|
|
413
|
+
if selected is None:
|
|
414
|
+
raise PreconditionError("No default host", "Run offbox setup, set env.host, or offbox host use NAME.")
|
|
415
|
+
return selected
|
|
416
|
+
|
|
417
|
+
def ready_host(self, name: str) -> HostState:
|
|
418
|
+
host = self.store.read().hosts.get(name)
|
|
419
|
+
if host is None or (host.auto and host.phase != "ready"):
|
|
420
|
+
return ensure_host(self.paths, self.runner, name, clock=self.clock, timer_installed=timer_installed)
|
|
421
|
+
return self._host(name)
|
|
422
|
+
|
|
423
|
+
def _check_project(self, work: Worktree) -> None:
|
|
424
|
+
from offbox.execute import check_exec_env, execution_env, install_paths
|
|
425
|
+
|
|
426
|
+
project = load_project(self.paths, work.root)
|
|
427
|
+
install_paths(project.install.when)
|
|
428
|
+
execution_env(project.exec.env, {}, tty=False)
|
|
429
|
+
check_exec_env(self.paths, project.exec.env)
|
|
430
|
+
if project.env.dockerfile is not None:
|
|
431
|
+
self.dockerfile_path(work, project.env.dockerfile)
|
|
432
|
+
|
|
433
|
+
def up(self, *, cwd: Path | None = None, host_name: str | None = None, fresh: bool = False, no_stack: bool = False) -> EnvState:
|
|
434
|
+
work = find_worktree(self.runner, cwd)
|
|
435
|
+
self._check_project(work)
|
|
436
|
+
last_run, failure = timer_status(self.paths)
|
|
437
|
+
warning = timer_health(self.paths, utc_now(), last_run, failure)
|
|
438
|
+
if warning is not None:
|
|
439
|
+
console.warn(warning)
|
|
440
|
+
for _attempt in range(2):
|
|
441
|
+
target = self.up_target(work, host_name)
|
|
442
|
+
with shared_usage(self.paths, target, "offbox up", clock=self.clock):
|
|
443
|
+
if self.up_target(work, host_name) != target:
|
|
444
|
+
continue
|
|
445
|
+
env = self._up(work, self.ready_host(target), fresh=fresh, no_stack=no_stack)
|
|
446
|
+
touch(self.paths, env.name, utc_now())
|
|
447
|
+
clear_lost(self.paths, env.name)
|
|
448
|
+
return env
|
|
449
|
+
raise PreconditionError("Host selection changed while starting", "Retry the command.")
|
|
450
|
+
|
|
451
|
+
def _up(self, work: Worktree, selected: HostState, *, fresh: bool, no_stack: bool) -> EnvState:
|
|
452
|
+
from offbox.execute import Executor, check_exec_env, execution_env, install_paths, unapproved_exec_env
|
|
453
|
+
from offbox.share import Sharing, git_sharing, sharing_additions
|
|
454
|
+
|
|
455
|
+
project = load_project(self.paths, work.root)
|
|
456
|
+
install_paths(project.install.when)
|
|
457
|
+
execution_env(project.exec.env, {}, tty=False)
|
|
458
|
+
check_exec_env(self.paths, project.exec.env)
|
|
459
|
+
config = project.env
|
|
460
|
+
dockerfile = self.dockerfile_path(work, config.dockerfile) if config.dockerfile is not None else None
|
|
461
|
+
previous = self._recorded(work)
|
|
462
|
+
mismatch = env_host_mismatch(previous, project.env.host)
|
|
463
|
+
if mismatch is not None:
|
|
464
|
+
console.warn(mismatch)
|
|
465
|
+
host = selected
|
|
466
|
+
with host_operation(self.paths, host.name, wait_timeout=120, clock=self.clock):
|
|
467
|
+
resource_result = self.remote(host, ["sh", "-c", _HOST_RESOURCES])
|
|
468
|
+
try:
|
|
469
|
+
resources = HostResources.model_validate_json(resource_result.stdout)
|
|
470
|
+
except ValidationError as exc:
|
|
471
|
+
raise PreconditionError("Invalid host resource information", "Check the host Docker daemon.") from exc
|
|
472
|
+
resolved = config.model_copy(update={"dockerfile": None, "image": config.image or DEFAULT_IMAGE})
|
|
473
|
+
command = container_command(work, resolved, resources.cpus, resources.memory)
|
|
474
|
+
existing = self.inspect(host, work)
|
|
475
|
+
if existing is not None:
|
|
476
|
+
self._owned(work, existing)
|
|
477
|
+
with self.store.transaction() as state:
|
|
478
|
+
stored_host = state.hosts.get(host.name)
|
|
479
|
+
if (
|
|
480
|
+
stored_host is None
|
|
481
|
+
or stored_host.model_dump(exclude={"control_master_pid"}) != host.model_dump(exclude={"control_master_pid"})
|
|
482
|
+
or state.envs.get(work.name) != previous
|
|
483
|
+
):
|
|
484
|
+
raise PreconditionError("Environment or host changed while preparing up", "Retry offbox up.")
|
|
485
|
+
env = previous or EnvState(name=work.name, host=host.name, worktree_path=work.root, created_at=datetime.now(UTC))
|
|
486
|
+
added = [] if env.approved_exec_env is None else unapproved_exec_env(project.exec.env, env.approved_exec_env)
|
|
487
|
+
shared = sharing_additions(project, env)
|
|
488
|
+
state.envs[env.name] = env
|
|
489
|
+
if added:
|
|
490
|
+
console.warn("Forwarding new local variables to the environment: " + ", ".join(added))
|
|
491
|
+
for message in shared:
|
|
492
|
+
console.warn(message)
|
|
493
|
+
self.mutate(host, work, "prepare")
|
|
494
|
+
self.sync.ensure(work, host, project.sync)
|
|
495
|
+
built: BuiltImage | None = None
|
|
496
|
+
if dockerfile is not None:
|
|
497
|
+
self.sync.flush(work, host)
|
|
498
|
+
built = self.build_image(host, work, dockerfile)
|
|
499
|
+
command[-1] = built.tag
|
|
500
|
+
if fresh:
|
|
501
|
+
self.sync.pause(work, host)
|
|
502
|
+
self.ports.cancel(work, host)
|
|
503
|
+
command = with_settings_label(command)
|
|
504
|
+
result = self.mutate(host, work, "fresh" if fresh else "up", command, pull=dockerfile is None)
|
|
505
|
+
container = self._container(result.stdout)
|
|
506
|
+
self._owned(work, container)
|
|
507
|
+
recorded_settings = (container.config.labels or {}).get(SETTINGS_LABEL)
|
|
508
|
+
drifted = recorded_settings is not None and recorded_settings != settings_digest(command)
|
|
509
|
+
self.wait_ready(host, work, container)
|
|
510
|
+
if fresh:
|
|
511
|
+
self.sync.ensure(work, host, project.sync)
|
|
512
|
+
self.sync.flush(work, host)
|
|
513
|
+
Sharing(self.runner).apply(self.store, work, host, project, force=True)
|
|
514
|
+
Executor(self.paths, self.runner, clock=self.clock).up_stages(work, host, project, no_stack=no_stack)
|
|
515
|
+
ports = self.ports.restore(work, host, project.ports)
|
|
516
|
+
for forward in ports.forwards:
|
|
517
|
+
console.info(f"localhost:{forward.local} → {forward.env}:{forward.remote}")
|
|
518
|
+
if built is not None and built.digest != container.image:
|
|
519
|
+
console.warn("Custom image changed; run offbox up --fresh to use it. Existing Docker data is retained.")
|
|
520
|
+
elif drifted:
|
|
521
|
+
console.warn("Environment settings changed (image, cpus, memory or shm_size); run offbox up --fresh to apply them.")
|
|
522
|
+
env.image_digest = container.image
|
|
523
|
+
with self.store.transaction() as state:
|
|
524
|
+
if state.envs.get(env.name) is None:
|
|
525
|
+
raise PreconditionError("Environment registration disappeared", "Run offbox list and retry up.")
|
|
526
|
+
current = state.envs[env.name]
|
|
527
|
+
if current.host != host.name or current.worktree_path != work.root:
|
|
528
|
+
raise PreconditionError("Environment registration changed", "Run offbox list before retrying.")
|
|
529
|
+
current.image_digest = env.image_digest
|
|
530
|
+
current.approved_exec_env = sorted(set(project.exec.env))
|
|
531
|
+
current.approved_files = sorted(set(project.files.copy_))
|
|
532
|
+
current.approved_git = git_sharing(project.git)
|
|
533
|
+
env = current
|
|
534
|
+
console.success(f"Environment {env.name} ready on {host.name}")
|
|
535
|
+
return env
|
|
536
|
+
|
|
537
|
+
def dockerfile_path(self, work: Worktree, configured: Path) -> str:
|
|
538
|
+
relative = configured
|
|
539
|
+
if configured.is_absolute():
|
|
540
|
+
try:
|
|
541
|
+
relative = configured.relative_to(work.root)
|
|
542
|
+
except ValueError as exc:
|
|
543
|
+
raise PreconditionError("Dockerfile is outside the worktree", "Set env.dockerfile to a file inside the worktree.") from exc
|
|
544
|
+
if ".." in relative.parts or relative == Path("."):
|
|
545
|
+
raise PreconditionError("Dockerfile is outside the worktree", "Set env.dockerfile to a file inside the worktree.")
|
|
546
|
+
candidate = work.root
|
|
547
|
+
try:
|
|
548
|
+
for component in relative.parts:
|
|
549
|
+
candidate /= component
|
|
550
|
+
if candidate.is_symlink():
|
|
551
|
+
raise PreconditionError("Dockerfile path contains a symlink", "Use a regular Dockerfile path inside the worktree.")
|
|
552
|
+
if not candidate.is_file():
|
|
553
|
+
raise PreconditionError("Dockerfile does not exist", "Create the configured Dockerfile before running offbox up.")
|
|
554
|
+
except OSError as exc:
|
|
555
|
+
raise PreconditionError("Cannot inspect Dockerfile", "Check Dockerfile permissions and retry.") from exc
|
|
556
|
+
return relative.as_posix()
|
|
557
|
+
|
|
558
|
+
def custom_image_tag(self, host: HostState, work: Worktree, dockerfile: str) -> str:
|
|
559
|
+
from offbox.execute import Executor
|
|
560
|
+
|
|
561
|
+
digest = Executor(self.paths, self.runner).install_hash(work, host, [dockerfile])
|
|
562
|
+
try:
|
|
563
|
+
local = hashlib.sha256((work.root / dockerfile).read_bytes()).hexdigest()
|
|
564
|
+
except OSError as exc:
|
|
565
|
+
raise PreconditionError("Cannot read the configured Dockerfile", "Check its permissions and run offbox up.") from exc
|
|
566
|
+
expected = hashlib.sha256(f"{local} {dockerfile}\n".encode()).hexdigest()
|
|
567
|
+
if digest != expected:
|
|
568
|
+
raise PreconditionError(
|
|
569
|
+
"Configured Dockerfile changes are not synchronized", "Run offbox up --fresh after source sync completes."
|
|
570
|
+
)
|
|
571
|
+
project_hash = hashlib.sha256(str(work.root).encode()).hexdigest()[:16]
|
|
572
|
+
return f"offbox-local/{project_hash}:{local}"
|
|
573
|
+
|
|
574
|
+
@measured("Image build")
|
|
575
|
+
def build_image(self, host: HostState, work: Worktree, dockerfile: str) -> BuiltImage:
|
|
576
|
+
project_hash = hashlib.sha256(str(work.root).encode()).hexdigest()[:16]
|
|
577
|
+
script = (
|
|
578
|
+
_GUARD
|
|
579
|
+
+ """
|
|
580
|
+
target=$directory/work/$5
|
|
581
|
+
parent=$target
|
|
582
|
+
while [ "$parent" != "$directory/work" ]; do
|
|
583
|
+
if [ -L "$parent" ]; then
|
|
584
|
+
printf '%s\\n' 'offbox: refusing a symlinked Dockerfile path' >&2
|
|
585
|
+
exit 3
|
|
586
|
+
fi
|
|
587
|
+
parent=${parent%/*}
|
|
588
|
+
done
|
|
589
|
+
if [ ! -f "$target" ] || [ ! -r "$target" ]; then
|
|
590
|
+
printf '%s\\n' 'offbox: refusing a missing synced Dockerfile; check sync ignores' >&2
|
|
591
|
+
exit 3
|
|
592
|
+
fi
|
|
593
|
+
file_hash=$(sha256sum < "$target" | cut -d' ' -f1)
|
|
594
|
+
case "$file_hash" in *[!a-f0-9]*|'') exit 3 ;; esac
|
|
595
|
+
[ "${#file_hash}" -eq 64 ] || exit 3
|
|
596
|
+
tag=offbox-local/$6:$file_hash
|
|
597
|
+
cached=$(docker image ls -q --no-trunc "$tag")
|
|
598
|
+
if [ -z "$cached" ]; then
|
|
599
|
+
docker build -t "$tag" -f "$target" "$directory/work" >&2
|
|
600
|
+
fi
|
|
601
|
+
image_id=$(docker image inspect --format '{{json .Id}}' "$tag")
|
|
602
|
+
printf '{"tag":"%s","digest":%s}\\n' "$tag" "$image_id"
|
|
603
|
+
"""
|
|
604
|
+
)
|
|
605
|
+
result = self.remote(
|
|
606
|
+
host,
|
|
607
|
+
[
|
|
608
|
+
"flock",
|
|
609
|
+
"/var/lib/offbox/lock",
|
|
610
|
+
"sh",
|
|
611
|
+
"-s",
|
|
612
|
+
"--",
|
|
613
|
+
work.name,
|
|
614
|
+
str(work.root),
|
|
615
|
+
work.container,
|
|
616
|
+
work.volume,
|
|
617
|
+
dockerfile,
|
|
618
|
+
project_hash,
|
|
619
|
+
],
|
|
620
|
+
input=script,
|
|
621
|
+
timeout=1800,
|
|
622
|
+
)
|
|
623
|
+
try:
|
|
624
|
+
image = BuiltImage.model_validate_json(result.stdout)
|
|
625
|
+
except ValidationError as exc:
|
|
626
|
+
raise PreconditionError("Invalid custom image build result", "Inspect the Docker build log and retry offbox up.") from exc
|
|
627
|
+
if (
|
|
628
|
+
re.fullmatch(r"offbox-local/" + project_hash + r":[a-f0-9]{64}", image.tag) is None
|
|
629
|
+
or re.fullmatch(r"sha256:[a-f0-9]{64}", image.digest) is None
|
|
630
|
+
):
|
|
631
|
+
raise PreconditionError("Invalid custom image build result", "Inspect the Docker build log and retry offbox up.")
|
|
632
|
+
return image
|
|
633
|
+
|
|
634
|
+
def diagnostics(self, host: HostState, work: Worktree) -> None:
|
|
635
|
+
for command in (
|
|
636
|
+
["docker", "logs", "--tail", "40", work.container],
|
|
637
|
+
["docker", "exec", work.container, "tail", "-n", "40", "/var/log/dockerd.log"],
|
|
638
|
+
):
|
|
639
|
+
try:
|
|
640
|
+
result = self.remote(host, command, check=False, timeout=5)
|
|
641
|
+
logger.error("environment diagnostic: %s\n%s", result.stdout, result.stderr)
|
|
642
|
+
except OffboxError:
|
|
643
|
+
logger.exception("Cannot retrieve environment diagnostics")
|
|
644
|
+
|
|
645
|
+
@measured("Container readiness")
|
|
646
|
+
def wait_ready(self, host: HostState, work: Worktree, container: ContainerInfo, *, timeout: float = 60) -> None:
|
|
647
|
+
deadline = self.clock.now() + timeout
|
|
648
|
+
while self.clock.now() < deadline:
|
|
649
|
+
if not container.state.running:
|
|
650
|
+
self.diagnostics(host, work)
|
|
651
|
+
raise PreconditionError(
|
|
652
|
+
f"Environment {work.name} exited with code {container.state.exit_code}",
|
|
653
|
+
"Inspect offbox logs and run offbox up --fresh.",
|
|
654
|
+
)
|
|
655
|
+
try:
|
|
656
|
+
result = self.remote(
|
|
657
|
+
host,
|
|
658
|
+
[
|
|
659
|
+
"docker",
|
|
660
|
+
"exec",
|
|
661
|
+
work.container,
|
|
662
|
+
"sh",
|
|
663
|
+
"-c",
|
|
664
|
+
"test -f /run/offbox/ready && docker --host=unix:///var/run/docker.sock info >/dev/null 2>&1",
|
|
665
|
+
],
|
|
666
|
+
check=False,
|
|
667
|
+
timeout=min(5, deadline - self.clock.now()),
|
|
668
|
+
)
|
|
669
|
+
if result.returncode == 0:
|
|
670
|
+
return
|
|
671
|
+
self.clock.sleep(min(READY_POLL_SECONDS, max(0, deadline - self.clock.now())))
|
|
672
|
+
if self.clock.now() < deadline:
|
|
673
|
+
current = self.inspect(host, work, timeout=min(5, deadline - self.clock.now()))
|
|
674
|
+
if current is None:
|
|
675
|
+
raise PreconditionError("Environment container disappeared", "Run offbox up again.")
|
|
676
|
+
self._owned(work, current)
|
|
677
|
+
container = current
|
|
678
|
+
except OffboxError as exc:
|
|
679
|
+
if isinstance(exc.__cause__, subprocess.TimeoutExpired):
|
|
680
|
+
self.clock.sleep(min(READY_POLL_SECONDS, max(0, deadline - self.clock.now())))
|
|
681
|
+
continue
|
|
682
|
+
self.diagnostics(host, work)
|
|
683
|
+
raise
|
|
684
|
+
self.diagnostics(host, work)
|
|
685
|
+
raise PreconditionError(
|
|
686
|
+
f"Environment {work.name} did not become ready in {timeout:g}s", "Inspect offbox logs and run offbox up --fresh."
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
def _check_record(self, work: Worktree, env: EnvState, host: HostState) -> None:
|
|
690
|
+
state = self.store.read()
|
|
691
|
+
current = state.envs.get(work.name)
|
|
692
|
+
stored_host = state.hosts.get(host.name)
|
|
693
|
+
if (
|
|
694
|
+
stored_host is None
|
|
695
|
+
or stored_host.model_dump(exclude={"control_master_pid"}) != host.model_dump(exclude={"control_master_pid"})
|
|
696
|
+
or current is None
|
|
697
|
+
or current.host != env.host
|
|
698
|
+
or current.worktree_path != work.root
|
|
699
|
+
):
|
|
700
|
+
raise PreconditionError("Environment changed while preparing the operation", "Run offbox list and retry.")
|
|
701
|
+
|
|
702
|
+
def stop(self, *, cwd: Path | None = None) -> None:
|
|
703
|
+
work, env, host = self.current(cwd)
|
|
704
|
+
with host_operation(self.paths, host.name, wait_timeout=STOP_LOCK_WAIT_SECONDS, clock=self.clock):
|
|
705
|
+
self._check_record(work, env, host)
|
|
706
|
+
self.ports.cancel(work, host)
|
|
707
|
+
self.sync.pause(work, host)
|
|
708
|
+
self.mutate(host, work, "stop")
|
|
709
|
+
console.success(f"Stopped {work.name}; data retained")
|
|
710
|
+
|
|
711
|
+
def _remove(self, host: HostState, work: Worktree, *, registered: bool) -> None:
|
|
712
|
+
current = self.store.read().envs.get(work.name)
|
|
713
|
+
if current is not None and (
|
|
714
|
+
(current.host == host.name and (not registered or current.worktree_path != work.root))
|
|
715
|
+
or (registered and current.host != host.name)
|
|
716
|
+
):
|
|
717
|
+
raise PreconditionError("Environment registration changed before cleanup", "Review offbox list and run cleanup again.")
|
|
718
|
+
if current is not None and current.host == host.name:
|
|
719
|
+
self.ports.cancel(work, host)
|
|
720
|
+
if current is None or current.host == host.name:
|
|
721
|
+
self.sync.terminate(work, host)
|
|
722
|
+
self.mutate(host, work, "remove")
|
|
723
|
+
with self.store.transaction() as state:
|
|
724
|
+
current = state.envs.get(work.name)
|
|
725
|
+
if current is not None and current.host == host.name and current.worktree_path == work.root:
|
|
726
|
+
del state.envs[work.name]
|
|
727
|
+
forget(self.paths, work.name)
|
|
728
|
+
clear_lost(self.paths, work.name)
|
|
729
|
+
|
|
730
|
+
def remove(self, *, cwd: Path | None = None) -> None:
|
|
731
|
+
work, env, host = self.current(cwd)
|
|
732
|
+
with (
|
|
733
|
+
shared_usage(self.paths, host.name, "offbox rm", clock=self.clock),
|
|
734
|
+
host_operation(self.paths, host.name, wait_timeout=STOP_LOCK_WAIT_SECONDS, clock=self.clock),
|
|
735
|
+
):
|
|
736
|
+
self._check_record(work, env, host)
|
|
737
|
+
self._remove(host, work, registered=True)
|
|
738
|
+
console.success(f"Removed {work.name}")
|
|
739
|
+
release_if_unused(self.paths, self.runner, host.name, clock=self.clock, timer_installed=timer_installed)
|
|
740
|
+
|
|
741
|
+
def report(self) -> EnvReport:
|
|
742
|
+
state = self.store.read()
|
|
743
|
+
report = EnvReport()
|
|
744
|
+
for env in sorted(state.envs.values(), key=lambda item: item.name):
|
|
745
|
+
work = Worktree(root=env.worktree_path, name=env.name)
|
|
746
|
+
host = state.hosts.get(env.host)
|
|
747
|
+
summary = EnvSummary(
|
|
748
|
+
name=env.name,
|
|
749
|
+
host=env.host,
|
|
750
|
+
worktree=env.worktree_path,
|
|
751
|
+
status="missing",
|
|
752
|
+
image_digest=env.image_digest,
|
|
753
|
+
last_used=last_activity(self.paths, env),
|
|
754
|
+
)
|
|
755
|
+
if host is None:
|
|
756
|
+
summary.status = "host missing"
|
|
757
|
+
summary.fix = "Restore the registered host before managing this environment."
|
|
758
|
+
else:
|
|
759
|
+
try:
|
|
760
|
+
container = self.inspect(host, work)
|
|
761
|
+
if container is not None:
|
|
762
|
+
self._owned(work, container)
|
|
763
|
+
summary.status = container.state.status
|
|
764
|
+
summary.running = container.state.running
|
|
765
|
+
else:
|
|
766
|
+
summary.fix = "Run offbox up in this worktree."
|
|
767
|
+
except ConnectionError_ as exc:
|
|
768
|
+
summary.status = "unreachable"
|
|
769
|
+
summary.fix = exc.fix
|
|
770
|
+
report.envs.append(summary)
|
|
771
|
+
return report
|
|
772
|
+
|
|
773
|
+
def shell(self, *, cwd: Path | None = None) -> int:
|
|
774
|
+
with self.using("offbox shell", cwd) as (work, _env, host):
|
|
775
|
+
return self._shell(work, host)
|
|
776
|
+
|
|
777
|
+
def _shell(self, work: Worktree, host: HostState) -> int:
|
|
778
|
+
container = self.inspect(host, work)
|
|
779
|
+
if container is None or not container.state.running:
|
|
780
|
+
raise PreconditionError("Environment is not running", "Run offbox up before opening a shell.")
|
|
781
|
+
self._owned(work, container)
|
|
782
|
+
return self.runner.stream(
|
|
783
|
+
ssh_argv(
|
|
784
|
+
host.name,
|
|
785
|
+
["docker", "exec", "-it", "-u", "dev", "-w", "/work", "-e", f"OFFBOX_ENV={work.name}", work.container, "bash", "-l"],
|
|
786
|
+
options=["-t"],
|
|
787
|
+
),
|
|
788
|
+
stdin=True,
|
|
789
|
+
tty=True,
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
def _containers(self, host: HostState) -> list[ContainerInfo]:
|
|
793
|
+
result = self.remote(host, ["docker", "ps", "-aq", "--filter", "label=offbox.env"])
|
|
794
|
+
identifiers = result.stdout.split()
|
|
795
|
+
if not identifiers:
|
|
796
|
+
return []
|
|
797
|
+
if any(re.fullmatch(r"[a-f0-9]{12,64}", value) is None for value in identifiers):
|
|
798
|
+
raise PreconditionError("Invalid managed container identifiers", "Inspect the Docker daemon before running GC.")
|
|
799
|
+
result = self.remote(host, ["docker", "container", "inspect", *identifiers])
|
|
800
|
+
try:
|
|
801
|
+
return TypeAdapter(list[ContainerInfo]).validate_json(result.stdout)
|
|
802
|
+
except ValidationError as exc:
|
|
803
|
+
raise PreconditionError("Invalid Docker inventory", "Check the host Docker daemon and offbox logs.") from exc
|
|
804
|
+
|
|
805
|
+
def _volumes(self, host: HostState) -> list[VolumeInfo]:
|
|
806
|
+
result = self.remote(host, ["docker", "volume", "ls", "-q", "--filter", "label=offbox.env"])
|
|
807
|
+
names = result.stdout.split()
|
|
808
|
+
if not names:
|
|
809
|
+
return []
|
|
810
|
+
if any(re.fullmatch(r"offbox-[a-z0-9][a-z0-9-]{0,36}-docker", value) is None for value in names):
|
|
811
|
+
raise PreconditionError("Invalid managed volume names", "Inspect the Docker volumes before running GC.")
|
|
812
|
+
result = self.remote(host, ["docker", "volume", "inspect", *names])
|
|
813
|
+
try:
|
|
814
|
+
return TypeAdapter(list[VolumeInfo]).validate_json(result.stdout)
|
|
815
|
+
except ValidationError as exc:
|
|
816
|
+
raise PreconditionError("Invalid Docker volume inventory", "Check the host Docker daemon and offbox logs.") from exc
|
|
817
|
+
|
|
818
|
+
def _resource_worktree(self, labels: dict[str, str] | None, resource_name: str, *, volume: bool = False) -> Worktree:
|
|
819
|
+
values = labels or {}
|
|
820
|
+
name = validate_env_name(values.get("offbox.env", ""))
|
|
821
|
+
root = Path(values.get("offbox.worktree", ""))
|
|
822
|
+
if not root.is_absolute():
|
|
823
|
+
raise PreconditionError("Managed resource has no absolute worktree label", "Inspect its ownership labels before running GC.")
|
|
824
|
+
work = Worktree(root=root, name=name)
|
|
825
|
+
if resource_name.lstrip("/") != (work.volume if volume else work.container):
|
|
826
|
+
raise PreconditionError("Managed resource name does not match its labels", "Inspect its ownership labels before running GC.")
|
|
827
|
+
return work
|
|
828
|
+
|
|
829
|
+
def gc_plan(self) -> GcPlan:
|
|
830
|
+
state = self.store.read()
|
|
831
|
+
targets: dict[tuple[str, str], GcTarget] = {}
|
|
832
|
+
running: list[GcTarget] = []
|
|
833
|
+
for stored_env in state.envs.values():
|
|
834
|
+
if stored_env.host not in state.hosts:
|
|
835
|
+
raise PreconditionError(
|
|
836
|
+
f"Environment {stored_env.name} has an unregistered host", "Restore its host registration before running GC."
|
|
837
|
+
)
|
|
838
|
+
if not worktree_exists(stored_env.worktree_path):
|
|
839
|
+
work = Worktree(root=stored_env.worktree_path, name=stored_env.name)
|
|
840
|
+
targets[(stored_env.host, stored_env.name)] = GcTarget(
|
|
841
|
+
host=stored_env.host, worktree=work, reason="worktree no longer exists", registered=True
|
|
842
|
+
)
|
|
843
|
+
for host in sorted(state.hosts.values(), key=lambda item: item.name):
|
|
844
|
+
if not host.address or host.phase != "ready":
|
|
845
|
+
continue
|
|
846
|
+
for container in self._containers(host):
|
|
847
|
+
work = self._resource_worktree(container.config.labels, container.name)
|
|
848
|
+
env = state.envs.get(work.name)
|
|
849
|
+
registered = env is not None and env.host == host.name
|
|
850
|
+
if registered and env is not None and env.worktree_path != work.root:
|
|
851
|
+
raise PreconditionError("Container labels conflict with registered worktree", "Inspect state and ownership before GC.")
|
|
852
|
+
if not registered:
|
|
853
|
+
targets[(host.name, work.name)] = GcTarget(host=host.name, worktree=work, reason="orphan container", registered=False)
|
|
854
|
+
elif container.state.running and (host.name, work.name) not in targets:
|
|
855
|
+
running.append(GcTarget(host=host.name, worktree=work, reason="prune unused Docker data", registered=True))
|
|
856
|
+
for volume in self._volumes(host):
|
|
857
|
+
work = self._resource_worktree(volume.labels, volume.name, volume=True)
|
|
858
|
+
env = state.envs.get(work.name)
|
|
859
|
+
registered = env is not None and env.host == host.name
|
|
860
|
+
if registered and env is not None and env.worktree_path != work.root:
|
|
861
|
+
raise PreconditionError("Volume labels conflict with registered worktree", "Inspect state and ownership before GC.")
|
|
862
|
+
key = (host.name, work.name)
|
|
863
|
+
if key in targets and targets[key].worktree.root != work.root:
|
|
864
|
+
raise PreconditionError("Container and volume labels disagree", "Inspect resource ownership before GC.")
|
|
865
|
+
if not registered and key not in targets:
|
|
866
|
+
targets[key] = GcTarget(host=host.name, worktree=work, reason="orphan volume", registered=False)
|
|
867
|
+
return GcPlan(targets=list(targets.values()), running=running)
|
|
868
|
+
|
|
869
|
+
def collect(self, plan: GcPlan) -> None:
|
|
870
|
+
hosts = sorted({target.host for target in [*plan.targets, *plan.running]})
|
|
871
|
+
for name in hosts:
|
|
872
|
+
host = self._host(name)
|
|
873
|
+
with host_operation(self.paths, name):
|
|
874
|
+
for target in plan.targets:
|
|
875
|
+
if target.host == name:
|
|
876
|
+
if target.reason == "worktree no longer exists" and worktree_exists(target.worktree.root):
|
|
877
|
+
raise PreconditionError(
|
|
878
|
+
"A worktree selected for cleanup exists again", "Review a new GC plan before removing it."
|
|
879
|
+
)
|
|
880
|
+
self._remove(host, target.worktree, registered=target.registered)
|
|
881
|
+
for target in plan.running:
|
|
882
|
+
if target.host == name:
|
|
883
|
+
env = self.store.read().envs.get(target.worktree.name)
|
|
884
|
+
if env is None or env.host != name or env.worktree_path != target.worktree.root:
|
|
885
|
+
raise PreconditionError("Environment changed before pruning", "Review the GC plan and retry.")
|
|
886
|
+
self.mutate(host, target.worktree, "prune")
|
|
887
|
+
console.success(f"Garbage collection removed {len(plan.targets)} environments")
|