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