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