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/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")