fix(macos): review fixes — token on plan, self-heal, symmetric digest, DHCP poll
Addresses findings from a high-effort review of the PRD 0070 macOS backend. Correctness: - Stamp identity_token onto MacosContainerBottlePlan after registration. git's gitconfig extraHeader and the supervise MCP --header read getattr(plan,"identity_token","") at provision time, and both reach the gateway on NO_PROXY (bypassing the egress proxy that carries the token). The plan never carried it, so /resolve fail-closed and every git fetch/push and supervise call from a macOS bottle would have been denied. Registration precedes provision(), so — unlike the run-time env — the plan can carry it. - Self-heal the orchestrator: recreate when it is not (source-current AND answering /health), not on the source-hash label alone. A container running current code but with a wedged HTTP server was left alone and polled to death, failing every launch until manual deletion. - image_digest and container_image_digest now read the same descriptor.digest field; dropped image_digest's id/tag fallback that could yield a value the container side can't produce — a permanent mismatch would have recreated the shared gateway on every launch (severing every live bottle's egress, since the replacement gets a new DHCP address). - Poll for the agent's and gateway's DHCP address instead of a fatal read right after `container run` (there is no --ip; the address can lag start). Cleanup: - One _inspect_first + _descriptor_digest behind the four inspect readers. - Shared bind_mount_spec (util) and host_db_dir (paths) replace per-module copies; _GIT_HTTP_PORT now imports git_http_backend.DEFAULT_PORT. - Drop the dead _url cache / url property and the write-only agent_proxy_url. Deferred (noted on the PR, not fixed here): the gateway image rebuilding on every launch (needs source-hash-labeled build), SQLite shared across VM guests, and the sh -lc profile-override edge — each is design-level or behavior-risk beyond a review fix. Verified: real Apple Container bring-up is green and idempotent; 1826 unit tests pass with `container` absent (CI parity), pyright clean, pylint 9.86. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,7 @@ from .gateway import (
|
||||
AppleGateway,
|
||||
ensure_networks,
|
||||
)
|
||||
from .util import bind_mount_spec as _mount
|
||||
|
||||
# Distinct from the docker backend's container name so both can run on one host.
|
||||
ORCHESTRATOR_NAME = "bot-bottle-mac-orchestrator"
|
||||
@@ -61,13 +62,6 @@ _HEALTH_POLL_SECONDS = 0.25
|
||||
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
|
||||
|
||||
|
||||
def _mount(source: str, target: str, *, readonly: bool = False) -> str:
|
||||
spec = f"type=bind,source={source},target={target}"
|
||||
if readonly:
|
||||
spec += ",readonly"
|
||||
return spec
|
||||
|
||||
|
||||
class MacosOrchestratorService:
|
||||
"""Manages the orchestrator control-plane container + the shared Apple
|
||||
gateway. Callers only need `ensure_running()`, which returns the URL."""
|
||||
@@ -92,30 +86,24 @@ class MacosOrchestratorService:
|
||||
self._repo_root = repo_root
|
||||
self._host_root = host_root or bot_bottle_root()
|
||||
self._orchestrator_name = orchestrator_name
|
||||
# Resolved once the container is up — there is no name to fall back on.
|
||||
self._url = ""
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""The control-plane URL, or "" before the container is up. One URL for
|
||||
both the host CLI and the gateway (see the module docstring)."""
|
||||
return self._url
|
||||
|
||||
def _resolve_url(self) -> str:
|
||||
"""The control-plane URL, or "" while the container has no address."""
|
||||
"""The control-plane URL, or "" while the container has no address.
|
||||
Resolved fresh each time (there is no name to fall back on, and a
|
||||
recreated orchestrator can come back on a different DHCP address), so
|
||||
nothing caches a URL that could go stale."""
|
||||
ip = container_mod.try_container_ipv4_on_network(
|
||||
self._orchestrator_name, self.network,
|
||||
)
|
||||
return f"http://{ip}:{self.port}" if ip else ""
|
||||
|
||||
def is_healthy(
|
||||
self, url: str = "", *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS,
|
||||
self, url: str, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS,
|
||||
) -> bool:
|
||||
target = url or self._url
|
||||
if not target:
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
with urllib.request.urlopen(f"{target}/health", timeout=timeout) as resp:
|
||||
with urllib.request.urlopen(f"{url}/health", timeout=timeout) as resp:
|
||||
return resp.status == 200
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
return False
|
||||
@@ -192,16 +180,15 @@ class MacosOrchestratorService:
|
||||
control-plane URL. Idempotent — a healthy control plane running current
|
||||
code and a running gateway are left untouched."""
|
||||
current_hash = source_hash(self._repo_root)
|
||||
if not self._orchestrator_source_current(current_hash):
|
||||
url = self._running_healthy_url(current_hash)
|
||||
if not url:
|
||||
self._ensure_orchestrator_image()
|
||||
log.info(
|
||||
"starting orchestrator container",
|
||||
context={"name": self._orchestrator_name},
|
||||
)
|
||||
self._run_orchestrator_container(current_hash)
|
||||
|
||||
url = self._wait_healthy(startup_timeout)
|
||||
self._url = url
|
||||
url = self._wait_healthy(startup_timeout)
|
||||
|
||||
# Gateway second: it can only reach the control plane by IP, which does
|
||||
# not exist until the orchestrator container is up (see the docstring).
|
||||
@@ -210,6 +197,21 @@ class MacosOrchestratorService:
|
||||
gateway.ensure_running()
|
||||
return url
|
||||
|
||||
def _running_healthy_url(self, current_hash: str) -> str:
|
||||
"""The control-plane URL if the running orchestrator is BOTH current
|
||||
and answering /health, else "" (→ recreate).
|
||||
|
||||
Checking health, not just the source-hash label, is what lets the
|
||||
service self-heal: a container that is running the current code but
|
||||
whose HTTP server is wedged (bind failure, deadlock, OOM'd thread)
|
||||
would otherwise be left alone and polled to death on every launch
|
||||
forever. Mirrors the docker service's `is_healthy() and source_current`
|
||||
gate."""
|
||||
if not self._orchestrator_source_current(current_hash):
|
||||
return ""
|
||||
url = self._resolve_url()
|
||||
return url if url and self.is_healthy(url) else ""
|
||||
|
||||
def _wait_healthy(self, startup_timeout: float) -> str:
|
||||
"""Poll until the control plane answers /health, resolving its address
|
||||
each time: the container is up before it has an IP, and it has an IP
|
||||
@@ -231,7 +233,6 @@ class MacosOrchestratorService:
|
||||
"""Remove the orchestrator + gateway containers (idempotent)."""
|
||||
container_mod.force_remove_container(self._orchestrator_name)
|
||||
self.gateway("").stop()
|
||||
self._url = ""
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
Reference in New Issue
Block a user