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/execute.py ADDED
@@ -0,0 +1,772 @@
1
+ import contextvars
2
+ import fcntl
3
+ import hashlib
4
+ import logging
5
+ import os
6
+ import re
7
+ import shlex
8
+ import signal
9
+ import stat
10
+ import sys
11
+ import threading
12
+ import uuid
13
+ from collections.abc import Callable, Generator, Mapping, Sequence
14
+ from contextlib import contextmanager
15
+ from importlib.resources import files
16
+ from pathlib import Path, PurePosixPath
17
+ from types import FrameType
18
+ from typing import TYPE_CHECKING
19
+
20
+ from pydantic import Field, SecretStr, ValidationError
21
+
22
+ from offbox import console
23
+ from offbox.config import InstallConfig, Model, PortsConfig, ProjectConfig
24
+ from offbox.errors import CommandFailed, ConnectionError_, OffboxError, PreconditionError, UsageError
25
+ from offbox.host import host_operation
26
+ from offbox.host.ready import Clock, SystemClock
27
+ from offbox.locking import acquire_lock
28
+ from offbox.logging import register_env_values, register_secrets
29
+ from offbox.naming import Worktree, validate_env_name
30
+ from offbox.paths import Paths
31
+ from offbox.ports import PortContainer, PortManager, PortSnapshot, compose_discovery
32
+ from offbox.project import load_project
33
+ from offbox.providers import load_provider
34
+ from offbox.runner import Runner, StreamProcess
35
+ from offbox.share import Sharing, check_sharing
36
+ from offbox.ssh import ssh_argv
37
+ from offbox.state import EnvState, HostState
38
+ from offbox.timings import measured, stage
39
+
40
+ if TYPE_CHECKING:
41
+ from offbox.runtime import Runtime
42
+
43
+ logger = logging.getLogger(__name__)
44
+ _ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
45
+ _CONNECTION_FIX = "offbox status"
46
+ _CREDENTIAL_ENV = frozenset(
47
+ {"HCLOUD_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_OAUTH_ACCESS_TOKEN", "CLOUDSDK_AUTH_ACCESS_TOKEN", "SSH_AUTH_SOCK"}
48
+ )
49
+ _PUBLIC_PROVIDER_ENV = frozenset({"GOOGLE_PROJECT"})
50
+ INSTALL_WAIT_SECONDS = 600
51
+ PORT_RESTORE_JOIN_SECONDS = 10
52
+ INTERRUPTED_PORT_RESTORE_JOIN_SECONDS = 1
53
+ _SIGNALS = (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
54
+
55
+
56
+ class ExecOptions(Model):
57
+ command: list[str]
58
+ tty: bool | None = None
59
+ no_sync: bool = False
60
+ stdin: bool = False
61
+ overrides: dict[str, SecretStr] = Field(default_factory=dict)
62
+
63
+
64
+ class InstallProbe(Model):
65
+ digest: str = Field(pattern=r"^[a-f0-9]{64}$")
66
+ marker: str = Field(pattern=r"^(?:missing|[a-f0-9]{64})$")
67
+
68
+
69
+ class Preflight(Model):
70
+ containers: list[PortContainer]
71
+ install: str
72
+ compose: str
73
+ compose_failed: bool
74
+ repository: bool = True
75
+
76
+
77
+ def parse_env(entries: Sequence[str]) -> dict[str, SecretStr]:
78
+ result: dict[str, SecretStr] = {}
79
+ for entry in entries:
80
+ name, separator, value = entry.partition("=")
81
+ if not separator or _ENV_NAME.fullmatch(name) is None or "\0" in value:
82
+ raise UsageError("Invalid --env value", "Use --env NAME=value with a valid environment variable name.")
83
+ register_env_values({name: value})
84
+ result[name] = SecretStr(value)
85
+ return result
86
+
87
+
88
+ def credential_env_names(paths: Paths) -> frozenset[str]:
89
+ """Variables that provider manifests read as credentials; exec.env must never forward them from the laptop."""
90
+ directories = [Path(str(entry)) for entry in files("offbox").joinpath("_data", "providers").iterdir() if entry.is_dir()]
91
+ custom = paths.config / "providers"
92
+ if custom.is_dir():
93
+ directories.extend(sorted(path for path in custom.iterdir() if path.is_dir()))
94
+ names = set(_CREDENTIAL_ENV)
95
+ for directory in directories:
96
+ if not (directory / "provider.toml").is_file():
97
+ continue
98
+ try:
99
+ names.update(load_provider(directory).manifest.credentials_env)
100
+ except PreconditionError:
101
+ logger.warning("Ignoring unreadable provider manifest %s", directory, exc_info=True)
102
+ return frozenset(names - _PUBLIC_PROVIDER_ENV)
103
+
104
+
105
+ def check_exec_env(paths: Paths, configured: Sequence[str]) -> None:
106
+ blocked = sorted(set(configured) & credential_env_names(paths))
107
+ if blocked:
108
+ raise PreconditionError(
109
+ "exec.env must not forward provider credentials: " + ", ".join(blocked),
110
+ "Remove them from exec.env (offbox config shows which source sets it); pass one value with offbox exec --env NAME=value.",
111
+ )
112
+
113
+
114
+ def unapproved_exec_env(configured: Sequence[str], approved: Sequence[str] | None) -> list[str]:
115
+ return sorted(set(configured) - set(approved or ()))
116
+
117
+
118
+ def execution_env(
119
+ configured: Sequence[str], overrides: Mapping[str, SecretStr], *, tty: bool, local: Mapping[str, str] | None = None
120
+ ) -> dict[str, str]:
121
+ values = os.environ if local is None else local
122
+ names = [*configured, *(["TERM", "COLORTERM", "LANG"] if tty else [])]
123
+ if any(_ENV_NAME.fullmatch(name) is None for name in names):
124
+ raise PreconditionError("Invalid exec.env name", "Configure exec.env as environment variable names.")
125
+ result = {name: values[name] for name in names if name in values}
126
+ result.update({name: value.get_secret_value() for name, value in overrides.items()})
127
+ register_env_values({name: value for name, value in result.items() if name in configured or name in overrides})
128
+ register_secrets(result)
129
+ return result
130
+
131
+
132
+ def command_argv(
133
+ work: Worktree,
134
+ host: HostState,
135
+ command: Sequence[str],
136
+ *,
137
+ run_id: str,
138
+ directory: str = "/work",
139
+ env: Mapping[str, str] | None = None,
140
+ tty: bool = False,
141
+ force_pty: bool = False,
142
+ stdin: bool = False,
143
+ diagnostics: bool = False,
144
+ ) -> list[str]:
145
+ if not command or not command[0] or any("\0" in part for part in command):
146
+ raise UsageError("A command is required", "Run offbox exec -- COMMAND [ARGS...].")
147
+ if re.fullmatch(r"[a-f0-9]{32}", run_id) is None:
148
+ raise PreconditionError("Invalid execution identifier", "Retry the command.")
149
+ relative = PurePosixPath(directory)
150
+ if not relative.is_relative_to("/work") or ".." in relative.parts or "\0" in directory:
151
+ raise PreconditionError("Command directory is outside /work", "Run the command inside this worktree.")
152
+ remote = ["docker", "exec", *(["-i"] if tty or stdin else []), *(["-t"] if tty else []), "-u", "dev", "-w", directory]
153
+ for name, value in sorted((env or {}).items()):
154
+ if _ENV_NAME.fullmatch(name) is None or "\0" in value:
155
+ raise UsageError("Invalid execution environment", "Use valid environment variable names and values.")
156
+ remote.extend(["-e", f"{name}={value}"])
157
+ remote.extend(["-e", f"OFFBOX_ENV={work.name}"])
158
+ remote.extend([work.container, "offbox-run", *(["--tty"] if tty else []), run_id, directory, "--", *command])
159
+ if tty:
160
+ return ssh_argv(host.name, remote, options=["-tt" if force_pty else "-t"])
161
+ if stdin:
162
+ if diagnostics:
163
+ remote = ["sh", "-c", "exec " + shlex.join(remote) + " >&2"]
164
+ return ssh_argv(host.name, remote)
165
+ return ssh_argv(host.name, ["bash", "-c", WATCH_SCRIPT, "offbox-watch", work.container, run_id, "1" if diagnostics else "0", *remote])
166
+
167
+
168
+ class SignalState:
169
+ def __init__(self, clock: Clock) -> None:
170
+ self.clock = clock
171
+ self.number: int | None = None
172
+ self.received_at: float | None = None
173
+
174
+ def receive(self, number: int, frame: FrameType | None) -> None:
175
+ if self.number is None:
176
+ self.number = number
177
+ self.received_at = self.clock.now()
178
+
179
+
180
+ WATCH_SCRIPT = r"""set -u
181
+ container=$1
182
+ run_id=$2
183
+ diagnostics=$3
184
+ shift 3
185
+ stop() {
186
+ sent=
187
+ attempt=0
188
+ while [ "$attempt" -lt 60 ]; do
189
+ state=$(docker exec "$container" sh -c 'if [ -f "$1.code" ]; then echo done; elif [ -f "$1.pid" ]; then echo running; else echo pending; fi' \
190
+ sh "/run/offbox/exec/$run_id" 2>/dev/null) || return 0
191
+ case "$state" in
192
+ done) return 0 ;;
193
+ pending) kill -0 "$child" 2>/dev/null || return 0 ;;
194
+ *)
195
+ if [ -z "$sent" ]; then
196
+ docker exec "$container" offbox-run --signal TERM "$run_id" >/dev/null 2>&1 && sent=TERM
197
+ elif [ "$sent" = TERM ] && [ "$attempt" -ge 50 ]; then
198
+ docker exec "$container" offbox-run --signal KILL "$run_id" >/dev/null 2>&1 && sent=KILL
199
+ fi
200
+ ;;
201
+ esac
202
+ sleep 0.2
203
+ attempt=$((attempt + 1))
204
+ done
205
+ }
206
+ exec 3<&0
207
+ if [ "$diagnostics" = 1 ]; then
208
+ "$@" </dev/null 3<&- >&2 &
209
+ else
210
+ "$@" </dev/null 3<&- &
211
+ fi
212
+ child=$!
213
+ cat <&3 >/dev/null 2>&1 3<&- &
214
+ reader=$!
215
+ exec 3<&-
216
+ done_pid=
217
+ wait -n -p done_pid "$child" "$reader"
218
+ status=$?
219
+ if [ "$done_pid" = "$child" ]; then
220
+ kill "$reader" 2>/dev/null
221
+ wait "$reader" 2>/dev/null
222
+ [ "$status" = 0 ] || stop
223
+ exit "$status"
224
+ fi
225
+ stop
226
+ wait "$child"
227
+ """
228
+ """Run the docker exec client on the host and stop the remote run once the local client's stdin pipe closes."""
229
+
230
+
231
+ @contextmanager
232
+ def capture_signals(state: SignalState) -> Generator[None]:
233
+ previous = {number: signal.getsignal(number) for number in _SIGNALS}
234
+ try:
235
+ for number in previous:
236
+ signal.signal(number, state.receive)
237
+ yield
238
+ finally:
239
+ for number, handler in previous.items():
240
+ signal.signal(number, handler)
241
+
242
+
243
+ _HASH = r"""set -eu
244
+ export LC_ALL=C
245
+ root=$1
246
+ shift
247
+ [ ! -L /srv/offbox/envs ] && [ ! -L "${root%/work}" ] && [ ! -L "$root" ] || exit 3
248
+ cd -- "$root" || exit 3
249
+ umask 077
250
+ tmp=$(mktemp)
251
+ trap 'rm -f -- "$tmp"' EXIT HUP INT TERM
252
+ for file do
253
+ parent=$file
254
+ while [ "$parent" != . ]; do
255
+ [ ! -L "$parent" ] || { printf '%s\n' 'offbox: install.when contains a symlink' >&2; exit 3; }
256
+ case "$parent" in */*) parent=${parent%/*} ;; *) parent=. ;; esac
257
+ done
258
+ if kind=$(stat --format=%F -- "$file" 2>&1); then
259
+ case "$kind" in 'regular file'|'regular empty file') ;; *) printf '%s\n' 'offbox: install.when must name regular files' >&2; exit 3 ;; esac
260
+ digest=$(sha256sum < "$file")
261
+ else
262
+ case "$kind" in *': No such file or directory'|*': Not a directory') digest=$(printf '' | sha256sum) ;;
263
+ *) printf '%s\n' "$kind" >&2; exit 3 ;; esac
264
+ fi
265
+ digest=${digest%% *}
266
+ printf '%s %s\n' "$digest" "$file" >> "$tmp"
267
+ done
268
+ sha256sum < "$tmp"
269
+ """
270
+ _MARKER_READ = r"""set -eu
271
+ export LC_ALL=C
272
+ path=$1/.offbox
273
+ [ ! -L /srv/offbox/envs ] && [ ! -L "$1" ] || exit 3
274
+ [ ! -L "$path" ] && [ ! -L "$path/install.sha256" ] || exit 3
275
+ if kind=$(stat --format=%F -- "$path/install.sha256" 2>&1); then
276
+ case "$kind" in 'regular file'|'regular empty file') ;; *) exit 3 ;; esac
277
+ cat -- "$path/install.sha256"
278
+ else
279
+ case "$kind" in *': No such file or directory'|*': Not a directory')
280
+ if parent=$(stat --format=%F -- "$path" 2>&1); then
281
+ [ "$parent" = directory ] && [ -r "$path" ] && [ -x "$path" ] || exit 3
282
+ else
283
+ case "$parent" in *': No such file or directory') ;; *) printf '%s\n' "$parent" >&2; exit 3 ;; esac
284
+ fi
285
+ printf 'missing\n' ;;
286
+ *) printf '%s\n' "$kind" >&2; exit 3 ;; esac
287
+ fi
288
+ """
289
+ _MARKER_WRITE = r"""set -eu
290
+ umask 077
291
+ path=$1/.offbox
292
+ [ ! -L /srv/offbox/envs ] && [ ! -L "$1" ] || exit 3
293
+ [ ! -L "$path" ] && [ ! -L "$path/install.sha256" ] || exit 3
294
+ mkdir -p -- "$path"
295
+ tmp=$(mktemp "$path/install.XXXXXXXXXX")
296
+ trap 'rm -f -- "$tmp"' EXIT HUP INT TERM
297
+ printf '%s\n' "$2" > "$tmp"
298
+ mv -f -- "$tmp" "$path/install.sha256"
299
+ """
300
+
301
+ _PROBE_FUNCTIONS = "offbox_hash() (\n" + _HASH + "\n)\noffbox_marker() (\n" + _MARKER_READ + "\n)\n"
302
+ INSTALL_PROBE_SCRIPT = (
303
+ "set -eu\n"
304
+ + _PROBE_FUNCTIONS
305
+ + "offbox_work=$1\noffbox_directory=$2\nshift 2\n"
306
+ + 'offbox_hash "$offbox_work" "$@"\noffbox_marker "$offbox_directory"\n'
307
+ )
308
+ PREFLIGHT_SCRIPT = (
309
+ "set -eu\n"
310
+ + _PROBE_FUNCTIONS
311
+ + r"""name=$1
312
+ worktree=$2
313
+ container=$3
314
+ probe=$4
315
+ discover=$5
316
+ work=$6
317
+ directory=$7
318
+ shift 7
319
+ failure=$(mktemp)
320
+ trap 'rm -f -- "$failure"' EXIT HUP INT TERM
321
+ if ! inspection=$(docker container inspect "$container" 2>"$failure"); then
322
+ if grep -qi -e 'no such container' -e 'no such object' "$failure"; then
323
+ inspection='[]'
324
+ else
325
+ cat -- "$failure" >&2
326
+ exit 1
327
+ fi
328
+ fi
329
+ install=
330
+ compose=
331
+ compose_failed=false
332
+ if printf '%s\n' "$inspection" | jq -e --arg name "$name" --arg worktree "$worktree" --arg container "$container" '
333
+ length == 1 and .[0].Name == ("/" + $container)
334
+ and .[0].Config.Labels["offbox.env"] == $name
335
+ and .[0].Config.Labels["offbox.worktree"] == $worktree
336
+ and .[0].State.Running == true
337
+ ' >/dev/null; then
338
+ if [ "$probe" = 1 ]; then
339
+ hashed=$(offbox_hash "$work" "$@")
340
+ marker=$(offbox_marker "$directory")
341
+ install="$hashed
342
+ $marker"
343
+ fi
344
+ if [ "$discover" = 1 ]; then
345
+ if ! compose=$(docker exec -u dev -w /work "$container" docker compose ps --format json 2>"$failure"); then
346
+ cat -- "$failure" >&2
347
+ compose=
348
+ compose_failed=true
349
+ fi
350
+ fi
351
+ fi
352
+ repository=false
353
+ [ ! -d "$work/.git" ] || repository=true
354
+ printf '%s\n' "$inspection" | jq -c --arg install "$install" --arg compose "$compose" --argjson compose_failed "$compose_failed" \
355
+ --argjson repository "$repository" \
356
+ '{containers: ., install: $install, compose: $compose, compose_failed: $compose_failed, repository: $repository}'
357
+ """
358
+ )
359
+
360
+
361
+ def parse_install_probe(text: str) -> InstallProbe:
362
+ hash_line, separator, marker = text.partition("\n")
363
+ match = re.fullmatch(r"([a-f0-9]{64}) -", hash_line)
364
+ if not separator or match is None:
365
+ raise PreconditionError("Invalid remote install hash", "Check the host sha256sum command and retry.")
366
+ try:
367
+ return InstallProbe(digest=match[1], marker=marker.rstrip())
368
+ except ValidationError as exc:
369
+ raise PreconditionError("Invalid installation marker", "Remove the host .offbox/install.sha256 marker and retry.") from exc
370
+
371
+
372
+ def install_marker(files_digest: str, command: str) -> str:
373
+ """The marker value for an install: the install.when digest bound to the command that produced it."""
374
+ return hashlib.sha256(f"{files_digest}\n{command}\n".encode()).hexdigest()
375
+
376
+
377
+ def install_paths(names: Sequence[str]) -> list[str]:
378
+ for name in names:
379
+ path = PurePosixPath(name)
380
+ if (
381
+ not name
382
+ or path.is_absolute()
383
+ or any(part in ("", ".", "..") for part in name.split("/"))
384
+ or "\\" in name
385
+ or any(ord(char) < 32 or ord(char) == 127 for char in name)
386
+ ):
387
+ raise PreconditionError("Unsafe install.when path", "Use relative file paths inside the worktree without dot components.")
388
+ return list(names)
389
+
390
+
391
+ class Executor:
392
+ def __init__(self, paths: Paths, runner: Runner, *, clock: Clock | None = None, id_factory: Callable[[], str] | None = None) -> None:
393
+ self.paths = paths
394
+ self.runner = runner
395
+ self.clock = clock or SystemClock()
396
+ self.id_factory = id_factory or (lambda: uuid.uuid4().hex)
397
+ self.interrupted = False
398
+
399
+ def _status(self, host: HostState, work: Worktree, run_id: str, *, timeout: float = 2) -> int | None:
400
+ result = self.runner.run(
401
+ ssh_argv(host.name, ["docker", "exec", work.container, "cat", f"/run/offbox/exec/{run_id}.code"]),
402
+ check=False,
403
+ timeout=timeout,
404
+ )
405
+ if result.returncode == 0 and re.fullmatch(r"(?:0|[1-9][0-9]{0,2})\n?", result.stdout):
406
+ status = int(result.stdout)
407
+ if status <= 255:
408
+ return status
409
+ return None
410
+
411
+ def interrupt(self, process: StreamProcess, state: SignalState, host: HostState, work: Worktree, run_id: str) -> int:
412
+ """Relay a recorded signal within the ten-second cleanup budget."""
413
+ number = state.number or signal.SIGINT
414
+ relayed = "TERM" if number == signal.SIGHUP else signal.Signals(number).name[3:]
415
+ deadline = (state.received_at if state.received_at is not None else self.clock.now()) + 9
416
+ accepted = False
417
+ confirmed = False
418
+ while self.clock.now() < deadline:
419
+ local_status = process.poll()
420
+ if local_status is not None and (0 <= local_status < 255 or confirmed):
421
+ return 128 + number
422
+ try:
423
+ if not confirmed and not accepted:
424
+ result = self.runner.run(
425
+ ssh_argv(host.name, ["docker", "exec", work.container, "offbox-run", "--signal", relayed, run_id]),
426
+ check=False,
427
+ timeout=min(1, deadline - self.clock.now()),
428
+ )
429
+ accepted = result.returncode == 0
430
+ remaining = deadline - self.clock.now()
431
+ if not confirmed and remaining > 0:
432
+ confirmed = self._status(host, work, run_id, timeout=min(1, remaining)) is not None
433
+ except OffboxError:
434
+ logger.debug("Signal completion not yet available", exc_info=True)
435
+ self.clock.sleep(min(0.1, max(0, deadline - self.clock.now())))
436
+ if process.poll() is None:
437
+ try:
438
+ process.kill()
439
+ except OffboxError:
440
+ logger.exception("Cannot reap interrupted local SSH process")
441
+ console.warn("Local SSH cleanup failed. Fix: inspect and stop the local SSH process.")
442
+ if not confirmed:
443
+ cleanup = shlex.join(
444
+ ["offbox", "host", "ssh", host.name, "--", "docker", "exec", work.container, "offbox-run", "--signal", "KILL", run_id]
445
+ )
446
+ console.warn(f"Remote command cleanup could not be confirmed. Fix: {cleanup}")
447
+ return 128 + number
448
+
449
+ def run(
450
+ self,
451
+ work: Worktree,
452
+ host: HostState,
453
+ command: Sequence[str],
454
+ *,
455
+ directory: str = "/work",
456
+ env: Mapping[str, str] | None = None,
457
+ tty: bool = False,
458
+ force_pty: bool = False,
459
+ stdin: bool = False,
460
+ diagnostics: bool = False,
461
+ started: Callable[[], None] | None = None,
462
+ ) -> int:
463
+ run_id = self.id_factory()
464
+ register_secrets(env or {})
465
+ argv = command_argv(
466
+ work,
467
+ host,
468
+ command,
469
+ run_id=run_id,
470
+ directory=directory,
471
+ env=env,
472
+ tty=tty,
473
+ force_pty=force_pty,
474
+ stdin=stdin,
475
+ diagnostics=diagnostics,
476
+ )
477
+ if tty:
478
+ if started is not None:
479
+ started()
480
+ status = self.runner.stream(argv, tty=True, stdin=True)
481
+ else:
482
+ state = SignalState(self.clock)
483
+ with capture_signals(state):
484
+ process = self.runner.start_stream(argv, stdin=stdin, keepalive=not stdin)
485
+ try:
486
+ if started is not None:
487
+ started()
488
+ while True:
489
+ if state.number is not None:
490
+ self.interrupted = True
491
+ return self.interrupt(process, state, host, work, run_id)
492
+ observed = process.wait(timeout=0.1)
493
+ if observed is not None:
494
+ status = observed
495
+ break
496
+ finally:
497
+ if state.number is None and process.poll() is None:
498
+ process.kill()
499
+ if status == 255:
500
+ cleanup = shlex.join(
501
+ ["offbox", "host", "ssh", host.name, "--", "docker", "exec", work.container, "offbox-run", "--signal", "TERM", run_id]
502
+ )
503
+ fix = f"Completion is unknown; do not retry blindly. Check offbox status; stop this run if needed: {cleanup}"
504
+ try:
505
+ master = self.runner.run(["ssh", "-O", "check", f"offbox-{host.name}"], check=False, timeout=2)
506
+ if master.returncode == 0:
507
+ confirmed_status = self._status(host, work, run_id)
508
+ if confirmed_status is not None:
509
+ return confirmed_status
510
+ except OffboxError as exc:
511
+ raise ConnectionError_("SSH connection lost before command completion was confirmed", fix) from exc
512
+ raise ConnectionError_("SSH connection lost before command completion was confirmed", fix)
513
+ return status if status >= 0 else 128 - status
514
+
515
+ def _remote(self, host: HostState, argv: Sequence[str], *, input: str | None = None, timeout: float = 30) -> str:
516
+ result = self.runner.run(ssh_argv(host.name, argv), check=False, timeout=timeout, input=input)
517
+ if result.returncode == 255:
518
+ raise ConnectionError_("SSH connection lost during installation setup", _CONNECTION_FIX)
519
+ if result.returncode:
520
+ raise CommandFailed(argv, result.returncode, result.stderr)
521
+ return result.stdout
522
+
523
+ @contextmanager
524
+ def installation_lock(self, work: Worktree, *, wait: float = 0) -> Generator[bool]:
525
+ """Yield whether the lock was contended, so callers re-check work another process may have finished."""
526
+ validate_env_name(work.name)
527
+ directory = self.paths.state / "install-locks"
528
+ failure = PreconditionError("Cannot acquire installation lock", "Check local state directory permissions and retry.")
529
+ try:
530
+ directory.mkdir(parents=True, mode=0o700, exist_ok=True)
531
+ metadata = directory.lstat()
532
+ if not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.getuid():
533
+ raise PreconditionError("Unsafe install lock directory", f"Restore an owned directory at {directory}.")
534
+ descriptor = os.open(directory / work.name, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
535
+ except OSError as exc:
536
+ raise failure from exc
537
+ try:
538
+ try:
539
+ metadata = os.fstat(descriptor)
540
+ if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.getuid():
541
+ raise PreconditionError("Unsafe install lock", "Remove the conflicting install lock and retry.")
542
+ waited = False
543
+ try:
544
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
545
+ except BlockingIOError:
546
+ if wait <= 0:
547
+ raise PreconditionError(
548
+ "Installation is already running for this environment", "Retry after the current installation finishes."
549
+ ) from None
550
+ console.step("Waiting for another installation in this environment to finish")
551
+ acquire_lock(
552
+ descriptor,
553
+ timeout=wait,
554
+ clock=self.clock,
555
+ message="Installation is still running for this environment",
556
+ fix="Retry after the current installation finishes.",
557
+ )
558
+ waited = True
559
+ except OSError as exc:
560
+ raise failure from exc
561
+ yield waited
562
+ finally:
563
+ os.close(descriptor)
564
+
565
+ def install_hash(self, work: Worktree, host: HostState, names: Sequence[str]) -> str:
566
+ result = self._remote(host, ["sh", "-s", "--", work.work, *install_paths(names)], input=_HASH).strip()
567
+ match = re.fullmatch(r"([a-f0-9]{64}) -", result)
568
+ if match is None:
569
+ raise PreconditionError("Invalid remote install hash", "Check the host sha256sum command and retry.")
570
+ return match[1]
571
+
572
+ def install_probe(self, work: Worktree, host: HostState, names: Sequence[str]) -> InstallProbe:
573
+ result = self._remote(
574
+ host, ["sh", "-s", "--", work.work, work.directory, *install_paths(names)], input=INSTALL_PROBE_SCRIPT, timeout=60
575
+ )
576
+ return parse_install_probe(result)
577
+
578
+ def _stage(self, work: Worktree, host: HostState, command: str, *, env: Mapping[str, str] | None = None) -> None:
579
+ status = self.run(work, host, ["sh", "-c", command], env=env, diagnostics=True)
580
+ if status:
581
+ if status in (130, 143):
582
+ raise OffboxError("Setup command interrupted", "Retry offbox up or the requested command when ready.", status)
583
+ raise CommandFailed(["sh", "-c", command], status, "Installation or stack startup failed; the environment is retained.")
584
+
585
+ @measured("Dependencies")
586
+ def install(
587
+ self,
588
+ work: Worktree,
589
+ host: HostState,
590
+ config: InstallConfig,
591
+ *,
592
+ always: bool = False,
593
+ env: Mapping[str, str] | None = None,
594
+ probe: InstallProbe | None = None,
595
+ wait: float = INSTALL_WAIT_SECONDS,
596
+ ) -> None:
597
+ if config.command is None:
598
+ return
599
+ install_paths(config.when)
600
+ command = config.command
601
+ if not always:
602
+ current = probe or self.install_probe(work, host, config.when)
603
+ if current.marker == install_marker(current.digest, command):
604
+ return
605
+ with self.installation_lock(work, wait=wait) as waited:
606
+ if waited and not always:
607
+ current = self.install_probe(work, host, config.when)
608
+ if current.marker == install_marker(current.digest, command):
609
+ return
610
+ console.step("Installing dependencies" if always else "install inputs changed, running install")
611
+ self._stage(work, host, command, env=env)
612
+ digest = install_marker(self.install_hash(work, host, config.when), command)
613
+ self._remote(host, ["sh", "-s", "--", work.directory, digest], input=_MARKER_WRITE)
614
+
615
+ def up_stages(self, work: Worktree, host: HostState, project: ProjectConfig, *, no_stack: bool = False) -> None:
616
+ env = execution_env(project.exec.env, {}, tty=False)
617
+ if any((work.root / name).is_file() for name in (".tool-versions", "mise.toml")):
618
+ console.step("Installing project tools with mise")
619
+ with stage("Project tools"):
620
+ self._stage(work, host, "mise install", env={**env, "MISE_YES": "1"})
621
+ self.install(work, host, project.install, always=True, env=env, wait=0)
622
+ stack, _ = project.stack.commands(work.root)
623
+ if stack and not no_stack:
624
+ console.step("Starting project stack")
625
+ with stage("Stack startup"):
626
+ self._stage(work, host, stack, env=env)
627
+
628
+ def preflight(self, work: Worktree, host: HostState, project: ProjectConfig) -> Preflight:
629
+ """Inspect the container, probe the install marker and list Compose ports in one SSH round trip."""
630
+ probe = project.install.command is not None
631
+ discover = compose_discovery(work.root, project.ports)
632
+ result = self.runner.run(
633
+ ssh_argv(
634
+ host.name,
635
+ [
636
+ "sh",
637
+ "-s",
638
+ "--",
639
+ work.name,
640
+ str(work.root),
641
+ work.container,
642
+ "1" if probe else "0",
643
+ "1" if discover else "0",
644
+ work.work,
645
+ work.directory,
646
+ *install_paths(project.install.when),
647
+ ],
648
+ ),
649
+ check=False,
650
+ timeout=60,
651
+ input=PREFLIGHT_SCRIPT,
652
+ )
653
+ if result.returncode == 255:
654
+ raise ConnectionError_(f"Cannot reach host {host.name}", f"Run offbox host ssh {host.name} and check connectivity.")
655
+ if result.returncode:
656
+ raise CommandFailed(result.argv, result.returncode, result.stderr)
657
+ try:
658
+ return Preflight.model_validate_json(result.stdout)
659
+ except ValidationError as exc:
660
+ raise PreconditionError("Invalid environment preflight response", "Check the host Docker daemon and offbox logs.") from exc
661
+
662
+ def _owned(self, work: Worktree, preflight: Preflight) -> bool:
663
+ if not preflight.containers:
664
+ return False
665
+ if len(preflight.containers) != 1:
666
+ raise PreconditionError("Environment ownership labels do not match", "Run offbox list and resolve conflicting resources.")
667
+ container = preflight.containers[0]
668
+ labels = container.config.labels or {}
669
+ if (
670
+ container.name.lstrip("/") != work.container
671
+ or labels.get("offbox.env") != work.name
672
+ or labels.get("offbox.worktree") != str(work.root)
673
+ ):
674
+ raise PreconditionError("Environment ownership labels do not match", "Run offbox list and resolve conflicting resources.")
675
+ return container.state.running
676
+
677
+ def restore_ports(
678
+ self, manager: PortManager, work: Worktree, host: HostState, config: PortsConfig, snapshot: PortSnapshot | None
679
+ ) -> list[str]:
680
+ """Restore forwards without blocking on a busy host lock; failures become warnings and never affect the command."""
681
+ warnings: list[str] = []
682
+ try:
683
+ with host_operation(self.paths, host.name, clock=self.clock):
684
+ manager.restore(work, host, config, snapshot=snapshot, warn=warnings.append)
685
+ except OffboxError as exc:
686
+ if isinstance(exc.__cause__, BlockingIOError):
687
+ logger.info("port restore skipped because another host operation holds the lock")
688
+ else:
689
+ logger.info("port restore skipped", exc_info=True)
690
+ warnings.append(f"Port forwards were not restored: {exc.message}. Fix: {exc.fix or 'run offbox ports.'}")
691
+ except Exception:
692
+ logger.exception("unexpected port restore failure")
693
+ warnings.append("Port forwards were not restored after an unexpected error. Fix: check offbox logs and run offbox ports.")
694
+ return warnings
695
+
696
+ def execute(self, options: ExecOptions, *, cwd: Path | None = None) -> int:
697
+ from offbox.runtime import Runtime
698
+
699
+ runtime = Runtime(self.paths, self.runner, clock=self.clock)
700
+ with runtime.using("offbox exec", cwd) as (work, recorded, host):
701
+ return self._execute(runtime, work, recorded, host, options, cwd=cwd)
702
+
703
+ def _execute(
704
+ self, runtime: "Runtime", work: Worktree, recorded: EnvState, host: HostState, options: ExecOptions, *, cwd: Path | None
705
+ ) -> int:
706
+ project = load_project(self.paths, work.root)
707
+ install_paths(project.install.when)
708
+ check_exec_env(self.paths, project.exec.env)
709
+ unapproved = unapproved_exec_env(project.exec.env, recorded.approved_exec_env)
710
+ if unapproved:
711
+ raise PreconditionError(
712
+ "exec.env forwards variables that offbox up has not approved: " + ", ".join(unapproved),
713
+ "Review exec.env with offbox config, then run offbox up to approve it.",
714
+ )
715
+ check_sharing(project, recorded)
716
+ local = (cwd or Path.cwd()).resolve()
717
+ try:
718
+ relative = local.relative_to(work.root)
719
+ except ValueError as exc:
720
+ raise PreconditionError("Current directory is outside the worktree", "Run offbox exec inside the worktree.") from exc
721
+ directory = "/work" + ("/" + relative.as_posix() if relative.parts else "")
722
+ tty = options.tty if options.tty is not None else sys.stdin.isatty() and sys.stdout.isatty()
723
+ env = execution_env(project.exec.env, options.overrides, tty=tty)
724
+ command_argv(work, host, options.command, run_id=self.id_factory(), directory=directory, env=env)
725
+ if not options.no_sync:
726
+ runtime.sync.flush(work, host)
727
+ preflight = self.preflight(work, host, project)
728
+ if not self._owned(work, preflight):
729
+ if not options.no_sync:
730
+ try:
731
+ runtime.sync.pause(work, host)
732
+ except OffboxError:
733
+ logger.warning("Cannot pause sync for a stopped environment", exc_info=True)
734
+ raise PreconditionError("Environment is not running", "offbox up")
735
+ Sharing(self.runner).apply(
736
+ runtime.store, work, host, project, force=False, git=not options.no_sync, repository=preflight.repository
737
+ )
738
+ probe = parse_install_probe(preflight.install) if project.install.command is not None else None
739
+ self.install(work, host, project.install, env=env, probe=probe)
740
+ snapshot = None if preflight.compose_failed else PortSnapshot(containers=preflight.containers, compose=preflight.compose)
741
+ warnings: list[str] = []
742
+ deferred = tty
743
+
744
+ def restore() -> None:
745
+ for message in self.restore_ports(runtime.ports, work, host, project.ports, snapshot):
746
+ if deferred:
747
+ warnings.append(message)
748
+ else:
749
+ console.warn(message)
750
+
751
+ context = contextvars.copy_context()
752
+ worker = threading.Thread(target=context.run, args=(restore,), name="offbox-port-restore", daemon=True)
753
+ try:
754
+ with stage("Command execution"):
755
+ return self.run(
756
+ work,
757
+ host,
758
+ options.command,
759
+ directory=directory,
760
+ env=env,
761
+ tty=tty,
762
+ force_pty=options.tty is True and not (sys.stdin.isatty() and sys.stdout.isatty()),
763
+ stdin=options.stdin,
764
+ started=worker.start,
765
+ )
766
+ finally:
767
+ if worker.is_alive():
768
+ worker.join(INTERRUPTED_PORT_RESTORE_JOIN_SECONDS if self.interrupted else PORT_RESTORE_JOIN_SECONDS)
769
+ for message in warnings:
770
+ console.warn(message)
771
+ if worker.is_alive():
772
+ console.warn("Port forwards are still being restored. Fix: run offbox ports to finish restoring them.")