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/ports.py
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
import fcntl
|
|
2
|
+
import ipaddress
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import socket
|
|
6
|
+
from collections.abc import Callable, Generator, Sequence
|
|
7
|
+
from contextlib import ExitStack, contextmanager
|
|
8
|
+
from ipaddress import IPv4Address
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
|
|
13
|
+
|
|
14
|
+
from offbox import console
|
|
15
|
+
from offbox.config import Model, PortsConfig
|
|
16
|
+
from offbox.errors import CommandFailed, ConnectionError_, OffboxError, PreconditionError
|
|
17
|
+
from offbox.host import host_operation
|
|
18
|
+
from offbox.host.ready import Clock, SystemClock
|
|
19
|
+
from offbox.locking import acquire_lock
|
|
20
|
+
from offbox.naming import Worktree
|
|
21
|
+
from offbox.paths import Paths
|
|
22
|
+
from offbox.runner import Result, Runner
|
|
23
|
+
from offbox.ssh import master_pid, ssh_argv
|
|
24
|
+
from offbox.state import EnvState, HostState, State, StateStore
|
|
25
|
+
from offbox.timings import measured
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
AUTO_FORWARD_LIMIT = 32
|
|
29
|
+
COMPOSE_FILES = ("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DockerModel(BaseModel):
|
|
33
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True, hide_input_in_errors=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Publisher(DockerModel):
|
|
37
|
+
published_port: int = Field(alias="PublishedPort", ge=0, le=65535)
|
|
38
|
+
protocol: str = Field(alias="Protocol")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ComposeService(DockerModel):
|
|
42
|
+
publishers: list[Publisher] | None = Field(default=None, alias="Publishers")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PortForward(Model):
|
|
46
|
+
env: str
|
|
47
|
+
host: str
|
|
48
|
+
remote: int = Field(ge=1, le=65535)
|
|
49
|
+
local: int = Field(ge=1, le=65535)
|
|
50
|
+
ip: IPv4Address | None
|
|
51
|
+
active: bool
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PortsReport(Model):
|
|
55
|
+
forwards: list[PortForward] = Field(default_factory=list[PortForward])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ContainerNetwork(DockerModel):
|
|
59
|
+
ip: str = Field(alias="IPAddress")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ContainerNetworks(DockerModel):
|
|
63
|
+
networks: dict[str, ContainerNetwork] = Field(alias="Networks")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ContainerState(DockerModel):
|
|
67
|
+
running: bool = Field(alias="Running")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ContainerConfig(DockerModel):
|
|
71
|
+
labels: dict[str, str] | None = Field(default=None, alias="Labels")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PortContainer(DockerModel):
|
|
75
|
+
name: str = Field(alias="Name")
|
|
76
|
+
state: ContainerState = Field(alias="State")
|
|
77
|
+
config: ContainerConfig = Field(alias="Config")
|
|
78
|
+
network: ContainerNetworks = Field(alias="NetworkSettings")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class PortSnapshot(Model):
|
|
82
|
+
containers: list[PortContainer]
|
|
83
|
+
compose: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
PORT_DISCOVERY_SCRIPT = r"""set -eu
|
|
87
|
+
name=$1
|
|
88
|
+
worktree=$2
|
|
89
|
+
container=$3
|
|
90
|
+
inspection=$(docker container inspect "$container")
|
|
91
|
+
compose=
|
|
92
|
+
if printf '%s\n' "$inspection" | jq -e --arg name "$name" --arg worktree "$worktree" --arg container "$container" '
|
|
93
|
+
length == 1 and .[0].Name == ("/" + $container)
|
|
94
|
+
and .[0].Config.Labels["offbox.env"] == $name
|
|
95
|
+
and .[0].Config.Labels["offbox.worktree"] == $worktree
|
|
96
|
+
and .[0].State.Running == true
|
|
97
|
+
' >/dev/null; then
|
|
98
|
+
compose=$(docker exec -u dev -w /work "$container" docker compose ps --format json)
|
|
99
|
+
fi
|
|
100
|
+
compose_json=$(printf '%s' "$compose" | jq -Rs .)
|
|
101
|
+
printf '%s\n%s\n' "$inspection" "$compose_json" | jq -cs '{containers: .[0], compose: .[1]}'
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ForwardSpec(Model):
|
|
106
|
+
env: str
|
|
107
|
+
host: str
|
|
108
|
+
remote: int = Field(ge=1, le=65535)
|
|
109
|
+
local: int = Field(ge=1, le=65535)
|
|
110
|
+
ip: IPv4Address
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def value(self) -> str:
|
|
114
|
+
return f"127.0.0.1:{self.local}:{self.ip}:{self.remote}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def compose_discovery(root: Path, config: PortsConfig) -> bool:
|
|
118
|
+
return config.auto and any((root / name).is_file() for name in COMPOSE_FILES)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_compose(text: str) -> list[int]:
|
|
122
|
+
if not text.strip():
|
|
123
|
+
return []
|
|
124
|
+
try:
|
|
125
|
+
if text.lstrip().startswith("["):
|
|
126
|
+
services = TypeAdapter(list[ComposeService]).validate_json(text)
|
|
127
|
+
else:
|
|
128
|
+
services = [ComposeService.model_validate_json(line) for line in text.splitlines() if line.strip()]
|
|
129
|
+
except ValidationError as exc:
|
|
130
|
+
raise PreconditionError("Invalid Compose port response", "Check docker compose ps --format json inside the environment.") from exc
|
|
131
|
+
return sorted(
|
|
132
|
+
{item.published_port for service in services for item in service.publishers or () if item.protocol == "tcp" and item.published_port}
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def loopback_available(port: int) -> bool:
|
|
137
|
+
try:
|
|
138
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
|
|
139
|
+
listener.bind(("127.0.0.1", port))
|
|
140
|
+
return True
|
|
141
|
+
except OSError:
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def allocate_port(preferred: int, reserved: set[int], probe: Callable[[int], bool]) -> int:
|
|
146
|
+
if not 1 <= preferred <= 65535:
|
|
147
|
+
raise PreconditionError("Invalid preferred local port", "Choose a port between 1 and 65535.")
|
|
148
|
+
for candidate in range(preferred, 65536):
|
|
149
|
+
if candidate not in reserved and probe(candidate):
|
|
150
|
+
return candidate
|
|
151
|
+
raise PreconditionError("No free local port is available", "Stop a competing local listener or remove unused offbox port mappings.")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def forwarding_command(action: Literal["forward", "cancel"], spec: ForwardSpec) -> list[str]:
|
|
155
|
+
return ["ssh", "-O", action, "-L", spec.value, f"offbox-{spec.host}"]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class PortManager:
|
|
159
|
+
"""Restore/cancel callers hold host_operation; swap acquires its own host locks."""
|
|
160
|
+
|
|
161
|
+
def __init__(
|
|
162
|
+
self, paths: Paths, runner: Runner, probe: Callable[[int], bool] = loopback_available, *, clock: Clock | None = None
|
|
163
|
+
) -> None:
|
|
164
|
+
self.paths = paths
|
|
165
|
+
self.runner = runner
|
|
166
|
+
self.probe = probe
|
|
167
|
+
self.clock = clock or SystemClock()
|
|
168
|
+
self.store = StateStore(paths.state)
|
|
169
|
+
|
|
170
|
+
@contextmanager
|
|
171
|
+
def _operation(self) -> Generator[None]:
|
|
172
|
+
try:
|
|
173
|
+
self.paths.state.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
174
|
+
handle = (self.paths.state / "ports.lock").open("a", encoding="utf-8")
|
|
175
|
+
except OSError as exc:
|
|
176
|
+
raise PreconditionError("Cannot open port operation lock", "Check the offbox state directory permissions.") from exc
|
|
177
|
+
with handle as lock:
|
|
178
|
+
acquire_lock(
|
|
179
|
+
lock.fileno(),
|
|
180
|
+
timeout=30,
|
|
181
|
+
clock=self.clock,
|
|
182
|
+
message="Another port operation is running",
|
|
183
|
+
fix="Wait for that operation and retry offbox ports.",
|
|
184
|
+
)
|
|
185
|
+
try:
|
|
186
|
+
yield
|
|
187
|
+
finally:
|
|
188
|
+
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
|
189
|
+
|
|
190
|
+
def _host(self, state: State, host: HostState) -> HostState:
|
|
191
|
+
current = state.hosts.get(host.name)
|
|
192
|
+
if current is None or current.model_copy(update={"control_master_pid": None}) != host.model_copy(
|
|
193
|
+
update={"control_master_pid": None}
|
|
194
|
+
):
|
|
195
|
+
raise PreconditionError("Host changed during port setup", "Run offbox ports again after the host operation finishes.")
|
|
196
|
+
if current.phase != "ready":
|
|
197
|
+
raise PreconditionError("Host is not ready for port forwarding", "Finish host initialization before restoring ports.")
|
|
198
|
+
return current
|
|
199
|
+
|
|
200
|
+
def _env(self, state: State, work: Worktree, host: HostState) -> EnvState:
|
|
201
|
+
self._host(state, host)
|
|
202
|
+
env = state.envs.get(work.name)
|
|
203
|
+
if env is None or env.host != host.name or env.worktree_path != work.root:
|
|
204
|
+
raise PreconditionError("Environment changed during port setup", "Run offbox up in the correct worktree.")
|
|
205
|
+
if any(not 1 <= remote <= 65535 or not 1 <= local <= 65535 for remote, local in env.local_ports.items()):
|
|
206
|
+
raise PreconditionError("Stored port mapping is invalid", "Repair the environment's port state before continuing.")
|
|
207
|
+
return env
|
|
208
|
+
|
|
209
|
+
def _mux(self, argv: list[str]) -> Result:
|
|
210
|
+
return self.runner.run(argv, check=False, env={"LC_ALL": "C"}, timeout=10)
|
|
211
|
+
|
|
212
|
+
def _checked(self, result: Result, fix: str) -> None:
|
|
213
|
+
if result.returncode:
|
|
214
|
+
error = CommandFailed(result.argv, result.returncode, result.stderr)
|
|
215
|
+
error.fix = fix
|
|
216
|
+
raise error
|
|
217
|
+
|
|
218
|
+
def _master(self, host: HostState, *, start: bool = True) -> int | None:
|
|
219
|
+
pid = master_pid(self.runner, host.name)
|
|
220
|
+
created = pid is None
|
|
221
|
+
if created and start:
|
|
222
|
+
started = self._mux(["ssh", "-fN", f"offbox-{host.name}"])
|
|
223
|
+
if started.returncode:
|
|
224
|
+
raise ConnectionError_(
|
|
225
|
+
f"Cannot start SSH master for {host.name}", f"Check offbox host ssh {host.name} and retry offbox ports."
|
|
226
|
+
)
|
|
227
|
+
pid = master_pid(self.runner, host.name)
|
|
228
|
+
if pid is None and start:
|
|
229
|
+
raise ConnectionError_(f"SSH master for {host.name} is unavailable", "Check SSH connectivity and retry offbox ports.")
|
|
230
|
+
if not created and self._host(self.store.read(), host).control_master_pid == pid:
|
|
231
|
+
return pid
|
|
232
|
+
with self.store.transaction() as state:
|
|
233
|
+
stored = self._host(state, host)
|
|
234
|
+
if created or stored.control_master_pid != pid:
|
|
235
|
+
for env in state.envs.values():
|
|
236
|
+
if env.host == host.name:
|
|
237
|
+
env.active_forwards = []
|
|
238
|
+
stored.control_master_pid = pid
|
|
239
|
+
return pid
|
|
240
|
+
|
|
241
|
+
def _remote(self, host: HostState, argv: Sequence[str], *, input: str | None = None, timeout: float = 15) -> Result:
|
|
242
|
+
result = self.runner.run(ssh_argv(host.name, argv), check=False, timeout=timeout, input=input)
|
|
243
|
+
if result.returncode == 255:
|
|
244
|
+
raise ConnectionError_(f"Cannot reach host {host.name}", f"Check offbox host ssh {host.name} and retry.")
|
|
245
|
+
self._checked(result, "Run offbox up and check the environment's Docker/Compose configuration.")
|
|
246
|
+
return result
|
|
247
|
+
|
|
248
|
+
def _ip(self, work: Worktree, host: HostState) -> IPv4Address:
|
|
249
|
+
result = self._remote(host, ["docker", "container", "inspect", work.container])
|
|
250
|
+
try:
|
|
251
|
+
containers = TypeAdapter(list[PortContainer]).validate_json(result.stdout)
|
|
252
|
+
except ValidationError as exc:
|
|
253
|
+
raise PreconditionError(
|
|
254
|
+
"Invalid managed container network", "Inspect the offbox network and container ownership before retrying."
|
|
255
|
+
) from exc
|
|
256
|
+
return self._container_ip(work, containers)
|
|
257
|
+
|
|
258
|
+
def _owned_container(self, work: Worktree, containers: Sequence[PortContainer]) -> PortContainer:
|
|
259
|
+
if len(containers) != 1:
|
|
260
|
+
raise PreconditionError(
|
|
261
|
+
"Invalid managed container network", "Inspect the offbox network and container ownership before retrying."
|
|
262
|
+
)
|
|
263
|
+
container = containers[0]
|
|
264
|
+
labels = container.config.labels or {}
|
|
265
|
+
if (
|
|
266
|
+
container.name.lstrip("/") != work.container
|
|
267
|
+
or labels.get("offbox.env") != work.name
|
|
268
|
+
or labels.get("offbox.worktree") != str(work.root)
|
|
269
|
+
):
|
|
270
|
+
raise PreconditionError(
|
|
271
|
+
"Invalid managed container network", "Inspect the offbox network and container ownership before retrying."
|
|
272
|
+
)
|
|
273
|
+
return container
|
|
274
|
+
|
|
275
|
+
def _container_ip(self, work: Worktree, containers: Sequence[PortContainer]) -> IPv4Address:
|
|
276
|
+
container = self._owned_container(work, containers)
|
|
277
|
+
if not container.state.running:
|
|
278
|
+
raise PreconditionError("Environment is not running", "Run offbox up before restoring ports.")
|
|
279
|
+
try:
|
|
280
|
+
return ipaddress.IPv4Address(container.network.networks["offbox"].ip)
|
|
281
|
+
except (ValueError, KeyError) as exc:
|
|
282
|
+
raise PreconditionError(
|
|
283
|
+
"Invalid managed container network", "Inspect the offbox network and container ownership before retrying."
|
|
284
|
+
) from exc
|
|
285
|
+
|
|
286
|
+
def _snapshot(self, work: Worktree, host: HostState) -> PortSnapshot | None:
|
|
287
|
+
result = self.runner.run(ssh_argv(host.name, ["docker", "container", "inspect", work.container]), check=False, timeout=15)
|
|
288
|
+
if result.returncode == 255:
|
|
289
|
+
raise ConnectionError_(f"Cannot reach host {host.name}", f"Check offbox host ssh {host.name} and retry.")
|
|
290
|
+
if result.returncode and "no such" in result.stderr.lower():
|
|
291
|
+
return None
|
|
292
|
+
self._checked(result, "Run offbox up and check the environment's Docker/Compose configuration.")
|
|
293
|
+
try:
|
|
294
|
+
containers = TypeAdapter(list[PortContainer]).validate_json(result.stdout)
|
|
295
|
+
except ValidationError as exc:
|
|
296
|
+
raise PreconditionError(
|
|
297
|
+
"Invalid managed container network", "Inspect the offbox network and container ownership before retrying."
|
|
298
|
+
) from exc
|
|
299
|
+
if not self._owned_container(work, containers).state.running:
|
|
300
|
+
return None
|
|
301
|
+
return PortSnapshot(containers=containers, compose="")
|
|
302
|
+
|
|
303
|
+
def _discovery(self, work: Worktree, host: HostState) -> tuple[IPv4Address, list[int]]:
|
|
304
|
+
result = self._remote(host, ["sh", "-s", "--", work.name, str(work.root), work.container], input=PORT_DISCOVERY_SCRIPT, timeout=30)
|
|
305
|
+
try:
|
|
306
|
+
snapshot = PortSnapshot.model_validate_json(result.stdout)
|
|
307
|
+
except ValidationError as exc:
|
|
308
|
+
raise PreconditionError(
|
|
309
|
+
"Invalid managed container network", "Inspect the offbox network and container ownership before retrying."
|
|
310
|
+
) from exc
|
|
311
|
+
return self._container_ip(work, snapshot.containers), parse_compose(snapshot.compose)
|
|
312
|
+
|
|
313
|
+
def _spec(self, env: EnvState, remote: int) -> ForwardSpec:
|
|
314
|
+
if env.forward_ip is None:
|
|
315
|
+
raise PreconditionError(
|
|
316
|
+
"Forward destination metadata is missing", "Close the offbox SSH master and run offbox ports to restore its forwards."
|
|
317
|
+
)
|
|
318
|
+
return ForwardSpec(env=env.name, host=env.host, remote=remote, local=env.local_ports[remote], ip=env.forward_ip)
|
|
319
|
+
|
|
320
|
+
def _cancel_spec(self, spec: ForwardSpec) -> None:
|
|
321
|
+
result = self._mux(forwarding_command("cancel", spec))
|
|
322
|
+
diagnostics = [line.strip() for line in result.stderr.splitlines() if line.strip()]
|
|
323
|
+
absent = "mux_client_forward: forwarding request failed: port not forwarded"
|
|
324
|
+
summary = "muxclient: master cancel forward request failed"
|
|
325
|
+
if absent in diagnostics and all(line in (absent, summary) for line in diagnostics):
|
|
326
|
+
return
|
|
327
|
+
fix = "Retry offbox ports; the previous listener could not be cancelled safely."
|
|
328
|
+
self._checked(result, fix)
|
|
329
|
+
if diagnostics:
|
|
330
|
+
raise PreconditionError("Cannot confirm SSH forward cancellation: " + result.stderr.strip(), fix)
|
|
331
|
+
|
|
332
|
+
def _clear(self, work: Worktree, host: HostState, remotes: Sequence[int], *, remove: bool = False) -> None:
|
|
333
|
+
for remote in remotes:
|
|
334
|
+
env = self._env(self.store.read(), work, host)
|
|
335
|
+
if env.forward_ip is not None:
|
|
336
|
+
self._cancel_spec(self._spec(env, remote))
|
|
337
|
+
elif remote in env.active_forwards:
|
|
338
|
+
raise PreconditionError(
|
|
339
|
+
"Forward destination metadata is missing", "Close the offbox SSH master and run offbox ports again."
|
|
340
|
+
)
|
|
341
|
+
with self.store.transaction() as state:
|
|
342
|
+
current = self._env(state, work, host)
|
|
343
|
+
current.active_forwards = [value for value in current.active_forwards if value != remote]
|
|
344
|
+
if remove:
|
|
345
|
+
del current.local_ports[remote]
|
|
346
|
+
with self.store.transaction() as state:
|
|
347
|
+
current = self._env(state, work, host)
|
|
348
|
+
if not current.local_ports or all(remote in remotes for remote in current.local_ports):
|
|
349
|
+
current.forward_ip = None
|
|
350
|
+
|
|
351
|
+
def _reserved(self, state: State, env_name: str, remote: int) -> set[int]:
|
|
352
|
+
return {local for env in state.envs.values() for key, local in env.local_ports.items() if env.name != env_name or key != remote}
|
|
353
|
+
|
|
354
|
+
def _reserve(self, work: Worktree, host: HostState, remote: int, ip: IPv4Address, *, replace: bool = False) -> ForwardSpec:
|
|
355
|
+
with self.store.transaction() as state:
|
|
356
|
+
env = self._env(state, work, host)
|
|
357
|
+
reserved = self._reserved(state, env.name, remote)
|
|
358
|
+
current = env.local_ports.get(remote)
|
|
359
|
+
if current is None or replace:
|
|
360
|
+
preferred = max(remote, current + 1) if current is not None else remote
|
|
361
|
+
current = allocate_port(preferred, reserved, self.probe)
|
|
362
|
+
env.local_ports[remote] = current
|
|
363
|
+
elif current in reserved:
|
|
364
|
+
raise PreconditionError("Stored local port has multiple owners", "Repair duplicate port mappings before retrying.")
|
|
365
|
+
env.forward_ip = ip
|
|
366
|
+
return self._spec(env, remote)
|
|
367
|
+
|
|
368
|
+
def _mark(self, work: Worktree, host: HostState, spec: ForwardSpec, pid: int) -> None:
|
|
369
|
+
with self.store.transaction() as state:
|
|
370
|
+
env = self._env(state, work, host)
|
|
371
|
+
if (
|
|
372
|
+
self._host(state, host).control_master_pid != pid
|
|
373
|
+
or env.local_ports.get(spec.remote) != spec.local
|
|
374
|
+
or env.forward_ip != spec.ip
|
|
375
|
+
):
|
|
376
|
+
raise PreconditionError("Port mapping changed while forwarding", "Run offbox ports again to reconcile the listener.")
|
|
377
|
+
env.active_forwards = sorted({*env.active_forwards, spec.remote})
|
|
378
|
+
|
|
379
|
+
def _forward(self, work: Worktree, host: HostState, remote: int, ip: IPv4Address, pid: int) -> None:
|
|
380
|
+
spec = self._reserve(work, host, remote, ip)
|
|
381
|
+
for attempt in range(3):
|
|
382
|
+
result = self._mux(forwarding_command("forward", spec))
|
|
383
|
+
if result.returncode == 0:
|
|
384
|
+
self._mark(work, host, spec, pid)
|
|
385
|
+
return
|
|
386
|
+
bind_failure = any(
|
|
387
|
+
re.fullmatch(r"mux_client_forward: forwarding request failed: Port forwarding failed", line.strip())
|
|
388
|
+
or "Address already in use" in line
|
|
389
|
+
for line in result.stderr.splitlines()
|
|
390
|
+
)
|
|
391
|
+
if not bind_failure or self.probe(spec.local) or attempt == 2:
|
|
392
|
+
self._checked(result, f"Local port {spec.local} could not be forwarded; stop its competing listener or retry offbox ports.")
|
|
393
|
+
with self.store.transaction() as state:
|
|
394
|
+
env = self._env(state, work, host)
|
|
395
|
+
env.active_forwards = [value for value in env.active_forwards if value != remote]
|
|
396
|
+
spec = self._reserve(work, host, remote, ip, replace=True)
|
|
397
|
+
|
|
398
|
+
def report(self, env: EnvState) -> PortsReport:
|
|
399
|
+
return PortsReport(
|
|
400
|
+
forwards=[
|
|
401
|
+
PortForward(
|
|
402
|
+
env=env.name, host=env.host, remote=remote, local=local, ip=env.forward_ip, active=remote in env.active_forwards
|
|
403
|
+
)
|
|
404
|
+
for remote, local in sorted(env.local_ports.items())
|
|
405
|
+
]
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
def _automatic(self, discovered: Sequence[int], config: PortsConfig, env: EnvState, warn: Callable[[str], None]) -> list[int]:
|
|
409
|
+
candidates = sorted(set(discovered) - set(config.forward))
|
|
410
|
+
if len(candidates) <= AUTO_FORWARD_LIMIT:
|
|
411
|
+
return candidates
|
|
412
|
+
kept = sorted(candidates, key=lambda port: (port not in env.local_ports, port))[:AUTO_FORWARD_LIMIT]
|
|
413
|
+
skipped = sorted(set(candidates) - set(kept))
|
|
414
|
+
warn(
|
|
415
|
+
f"Skipped {len(skipped)} Compose ports beyond the automatic limit of {AUTO_FORWARD_LIMIT}: "
|
|
416
|
+
+ ", ".join(str(port) for port in skipped)
|
|
417
|
+
+ ". Fix: list the ports you need in ports.forward in offbox.toml."
|
|
418
|
+
)
|
|
419
|
+
return sorted(kept)
|
|
420
|
+
|
|
421
|
+
def _restore(
|
|
422
|
+
self,
|
|
423
|
+
work: Worktree,
|
|
424
|
+
host: HostState,
|
|
425
|
+
config: PortsConfig,
|
|
426
|
+
snapshot: PortSnapshot | None = None,
|
|
427
|
+
warn: Callable[[str], None] = console.warn,
|
|
428
|
+
) -> PortsReport:
|
|
429
|
+
self._env(self.store.read(), work, host)
|
|
430
|
+
ip = None
|
|
431
|
+
discovered: list[int] = []
|
|
432
|
+
if compose_discovery(work.root, config):
|
|
433
|
+
if snapshot is None:
|
|
434
|
+
ip, discovered = self._discovery(work, host)
|
|
435
|
+
else:
|
|
436
|
+
discovered = parse_compose(snapshot.compose)
|
|
437
|
+
current = self._env(self.store.read(), work, host)
|
|
438
|
+
desired = sorted({*config.forward, *self._automatic(discovered, config, current, warn)})
|
|
439
|
+
if not desired and not current.local_ports:
|
|
440
|
+
return self.report(current)
|
|
441
|
+
if ip is None:
|
|
442
|
+
ip = self._container_ip(work, snapshot.containers) if snapshot is not None else self._ip(work, host)
|
|
443
|
+
pid = self._master(host)
|
|
444
|
+
if pid is None:
|
|
445
|
+
raise ConnectionError_("SSH master is unavailable", "Run offbox ports again after checking SSH connectivity.")
|
|
446
|
+
env = self._env(self.store.read(), work, host)
|
|
447
|
+
obsolete = [remote for remote in env.local_ports if remote not in desired]
|
|
448
|
+
if obsolete:
|
|
449
|
+
self._clear(work, host, obsolete, remove=True)
|
|
450
|
+
env = self._env(self.store.read(), work, host)
|
|
451
|
+
if env.forward_ip is not None and env.forward_ip != ip:
|
|
452
|
+
self._clear(work, host, list(env.local_ports))
|
|
453
|
+
for remote in desired:
|
|
454
|
+
current = self._env(self.store.read(), work, host)
|
|
455
|
+
if remote not in current.active_forwards:
|
|
456
|
+
self._forward(work, host, remote, ip, pid)
|
|
457
|
+
return self.report(self._env(self.store.read(), work, host))
|
|
458
|
+
|
|
459
|
+
@measured("Port forwarding")
|
|
460
|
+
def restore(
|
|
461
|
+
self,
|
|
462
|
+
work: Worktree,
|
|
463
|
+
host: HostState,
|
|
464
|
+
config: PortsConfig,
|
|
465
|
+
*,
|
|
466
|
+
snapshot: PortSnapshot | None = None,
|
|
467
|
+
warn: Callable[[str], None] = console.warn,
|
|
468
|
+
) -> PortsReport:
|
|
469
|
+
"""Reconcile forwards; a snapshot from the caller's preflight replaces the remote inspection."""
|
|
470
|
+
with self._operation():
|
|
471
|
+
return self._restore(work, host, config, snapshot, warn)
|
|
472
|
+
|
|
473
|
+
def cancel(self, work: Worktree, host: HostState) -> None:
|
|
474
|
+
with self._operation():
|
|
475
|
+
env = self._env(self.store.read(), work, host)
|
|
476
|
+
if not env.local_ports:
|
|
477
|
+
return
|
|
478
|
+
if self._master(host, start=False) is None:
|
|
479
|
+
with self.store.transaction() as state:
|
|
480
|
+
self._env(state, work, host).forward_ip = None
|
|
481
|
+
return
|
|
482
|
+
self._clear(work, host, list(env.local_ports))
|
|
483
|
+
|
|
484
|
+
def _set_pair_active(self, specs: Sequence[ForwardSpec], active: Sequence[ForwardSpec]) -> None:
|
|
485
|
+
with self.store.transaction() as state:
|
|
486
|
+
for spec in specs:
|
|
487
|
+
env = state.envs.get(spec.env)
|
|
488
|
+
if env is None or env.host != spec.host or env.local_ports.get(spec.remote) != spec.local or env.forward_ip != spec.ip:
|
|
489
|
+
raise PreconditionError("Port swap state changed", "Run offbox ports in both worktrees before retrying.")
|
|
490
|
+
env.active_forwards = [remote for remote in env.active_forwards if remote != spec.remote]
|
|
491
|
+
if spec in active:
|
|
492
|
+
env.active_forwards = sorted({*env.active_forwards, spec.remote})
|
|
493
|
+
|
|
494
|
+
def _rollback(self, old: list[ForwardSpec], installed: list[ForwardSpec]) -> list[str]:
|
|
495
|
+
failures: list[str] = []
|
|
496
|
+
for spec in installed:
|
|
497
|
+
try:
|
|
498
|
+
self._cancel_spec(spec)
|
|
499
|
+
except OffboxError as exc:
|
|
500
|
+
failures.append(exc.message)
|
|
501
|
+
restored: list[ForwardSpec] = []
|
|
502
|
+
for spec in old:
|
|
503
|
+
try:
|
|
504
|
+
result = self._mux(forwarding_command("forward", spec))
|
|
505
|
+
self._checked(result, "Run offbox ports in the affected worktree.")
|
|
506
|
+
restored.append(spec)
|
|
507
|
+
except OffboxError as exc:
|
|
508
|
+
failures.append(exc.message)
|
|
509
|
+
try:
|
|
510
|
+
self._set_pair_active(old, restored)
|
|
511
|
+
except OffboxError as exc:
|
|
512
|
+
failures.append(exc.message)
|
|
513
|
+
return failures
|
|
514
|
+
|
|
515
|
+
def swap(self, work: Worktree, remote: int) -> PortsReport:
|
|
516
|
+
with self._operation():
|
|
517
|
+
snapshot = self.store.read()
|
|
518
|
+
selected = snapshot.envs.get(work.name)
|
|
519
|
+
if selected is None or selected.worktree_path != work.root or remote not in selected.local_ports:
|
|
520
|
+
raise PreconditionError(
|
|
521
|
+
"Remote port is not mapped for this worktree", "Run offbox ports after configuring or publishing that port."
|
|
522
|
+
)
|
|
523
|
+
owners = [(env, key) for env in snapshot.envs.values() for key, local in env.local_ports.items() if local == remote]
|
|
524
|
+
if len(owners) > 1:
|
|
525
|
+
raise PreconditionError("Preferred local port has multiple owners", "Repair duplicate local port mappings before swapping.")
|
|
526
|
+
owner = owners[0] if owners else None
|
|
527
|
+
hosts = {selected.host, *([owner[0].host] if owner else [])}
|
|
528
|
+
with ExitStack() as locks:
|
|
529
|
+
for name in sorted(hosts):
|
|
530
|
+
locks.enter_context(host_operation(self.paths, name))
|
|
531
|
+
targets = {selected.name: selected}
|
|
532
|
+
if owner:
|
|
533
|
+
targets[owner[0].name] = owner[0]
|
|
534
|
+
stopped: str | None = None
|
|
535
|
+
for env in targets.values():
|
|
536
|
+
host = snapshot.hosts.get(env.host)
|
|
537
|
+
if host is None:
|
|
538
|
+
raise PreconditionError("Port owner has no registered host", "Restore its host registration before swapping ports.")
|
|
539
|
+
target = Worktree(root=env.worktree_path, name=env.name)
|
|
540
|
+
containers = self._snapshot(target, host)
|
|
541
|
+
if containers is None and env.name == selected.name:
|
|
542
|
+
raise PreconditionError("Environment is not running", "Run offbox up before swapping ports.")
|
|
543
|
+
if containers is None:
|
|
544
|
+
stopped = env.name
|
|
545
|
+
continue
|
|
546
|
+
self._restore(target, host, PortsConfig(forward=list(env.local_ports), auto=False), containers)
|
|
547
|
+
state = self.store.read()
|
|
548
|
+
current = state.envs[work.name]
|
|
549
|
+
if current.local_ports[remote] == remote:
|
|
550
|
+
return self.report(current)
|
|
551
|
+
if current.local_ports != selected.local_ports or (owner and state.envs[owner[0].name].local_ports != owner[0].local_ports):
|
|
552
|
+
raise PreconditionError("Port mappings changed before the swap", "Run offbox ports and retry the swap.")
|
|
553
|
+
if owner is None and not self.probe(remote):
|
|
554
|
+
raise PreconditionError(
|
|
555
|
+
f"Local port {remote} is used outside offbox", "Stop that local listener before retrying the swap."
|
|
556
|
+
)
|
|
557
|
+
old = [self._spec(current, remote)]
|
|
558
|
+
new = [old[0].model_copy(update={"local": remote})]
|
|
559
|
+
parked: tuple[str, int] | None = None
|
|
560
|
+
if owner and stopped is None:
|
|
561
|
+
other = self._spec(state.envs[owner[0].name], owner[1])
|
|
562
|
+
old.append(other)
|
|
563
|
+
new.append(other.model_copy(update={"local": old[0].local}))
|
|
564
|
+
elif owner:
|
|
565
|
+
parked = (owner[0].name, owner[1])
|
|
566
|
+
idle = state.envs[owner[0].name]
|
|
567
|
+
if idle.forward_ip is not None and owner[1] in idle.active_forwards:
|
|
568
|
+
old.append(self._spec(idle, owner[1]))
|
|
569
|
+
self._set_pair_active(old, [])
|
|
570
|
+
installed: list[ForwardSpec] = []
|
|
571
|
+
try:
|
|
572
|
+
for spec in old:
|
|
573
|
+
self._cancel_spec(spec)
|
|
574
|
+
for spec in new:
|
|
575
|
+
result = self._mux(forwarding_command("forward", spec))
|
|
576
|
+
self._checked(result, "Run offbox ports in both worktrees and retry the swap.")
|
|
577
|
+
installed.append(spec)
|
|
578
|
+
with self.store.transaction() as state:
|
|
579
|
+
for before, after in zip(old[: len(new)], new, strict=True):
|
|
580
|
+
committed = state.envs.get(before.env)
|
|
581
|
+
if (
|
|
582
|
+
committed is None
|
|
583
|
+
or committed.host != before.host
|
|
584
|
+
or committed.local_ports.get(before.remote) != before.local
|
|
585
|
+
or committed.forward_ip != before.ip
|
|
586
|
+
):
|
|
587
|
+
raise PreconditionError(
|
|
588
|
+
"Port owner changed during swap", "Run offbox ports in both worktrees to recover the listeners."
|
|
589
|
+
)
|
|
590
|
+
committed.local_ports[after.remote] = after.local
|
|
591
|
+
committed.active_forwards = sorted({*committed.active_forwards, after.remote})
|
|
592
|
+
if parked is not None:
|
|
593
|
+
parked_env = state.envs.get(parked[0])
|
|
594
|
+
if parked_env is None or parked_env.local_ports.get(parked[1]) != remote:
|
|
595
|
+
raise PreconditionError(
|
|
596
|
+
"Port owner changed during swap", "Run offbox ports in both worktrees to recover the listeners."
|
|
597
|
+
)
|
|
598
|
+
parked_env.local_ports[parked[1]] = old[0].local
|
|
599
|
+
parked_env.active_forwards = [value for value in parked_env.active_forwards if value != parked[1]]
|
|
600
|
+
except OffboxError as exc:
|
|
601
|
+
failures = self._rollback(old, installed)
|
|
602
|
+
detail = "Original mappings restored." if not failures else "Recovery incomplete: " + "; ".join(failures)
|
|
603
|
+
retry = "run offbox ports in the affected worktrees before retrying --swap."
|
|
604
|
+
if failures:
|
|
605
|
+
commands = "; ".join(f"ssh -O exit offbox-{name}" for name in sorted(hosts))
|
|
606
|
+
fix = f"Run {commands}, then {retry}"
|
|
607
|
+
else:
|
|
608
|
+
fix = "Run offbox ports in the affected worktrees before retrying --swap."
|
|
609
|
+
raise OffboxError("Port swap failed. " + detail, fix) from exc
|
|
610
|
+
return self.report(self.store.read().envs[work.name])
|