e24b62b6b9
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>
243 lines
10 KiB
Python
243 lines
10 KiB
Python
"""Orchestrator + gateway lifecycle for the macOS backend (PRD 0070).
|
|
|
|
The macOS counterpart of `orchestrator.lifecycle.OrchestratorService`. Same
|
|
shape — an idempotent singleton that brings up the control plane and the shared
|
|
gateway and hands back the control-plane URL — but the startup **order is
|
|
reversed**, and the reason is a real Apple Container constraint rather than a
|
|
stylistic choice:
|
|
|
|
- Docker starts the gateway first and lets it find the control plane by
|
|
*container name* over docker DNS.
|
|
- Apple Container 1.0.0 has **no container DNS** (see `gateway`), so the
|
|
gateway can only be handed an **IP**. That IP does not exist until the
|
|
orchestrator container is running — hence: orchestrator first, read its
|
|
address, then start the gateway pointed at it.
|
|
|
|
The second difference: no published port. Apple Container puts every container
|
|
on a host-reachable address, and the host can reach the host-only network
|
|
directly, so the host CLI and the gateway use the **same** URL — the
|
|
orchestrator's address on the shared network. Docker needs a
|
|
`--publish 127.0.0.1:…` hop plus a separate `internal_url` for the same job.
|
|
|
|
Like the docker service this runs the **register-only broker**: the backend
|
|
launches agent containers, so the control plane needs no privileged socket.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
from ... import log
|
|
from ...orchestrator.lifecycle import (
|
|
DEFAULT_PORT,
|
|
DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
|
ORCHESTRATOR_DOCKERFILE,
|
|
ORCHESTRATOR_IMAGE,
|
|
ORCHESTRATOR_SOURCE_HASH_LABEL,
|
|
OrchestratorStartError,
|
|
source_hash,
|
|
)
|
|
from ...paths import bot_bottle_root
|
|
from . import util as container_mod
|
|
from .gateway import (
|
|
GATEWAY_EGRESS_NETWORK,
|
|
GATEWAY_IMAGE,
|
|
GATEWAY_NETWORK,
|
|
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"
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
_APP_DIR = "/app"
|
|
_ROOT_IN_CONTAINER = "/bot-bottle-root"
|
|
|
|
_HEALTH_POLL_SECONDS = 0.25
|
|
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
|
|
|
|
|
|
class MacosOrchestratorService:
|
|
"""Manages the orchestrator control-plane container + the shared Apple
|
|
gateway. Callers only need `ensure_running()`, which returns the URL."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
port: int = DEFAULT_PORT,
|
|
network: str = GATEWAY_NETWORK,
|
|
egress_network: str = GATEWAY_EGRESS_NETWORK,
|
|
image: str = ORCHESTRATOR_IMAGE,
|
|
gateway_image: str = GATEWAY_IMAGE,
|
|
repo_root: Path = _REPO_ROOT,
|
|
host_root: Path | None = None,
|
|
orchestrator_name: str = ORCHESTRATOR_NAME,
|
|
) -> None:
|
|
self.port = port
|
|
self.network = network
|
|
self.egress_network = egress_network
|
|
self.image = image
|
|
self._gateway_image = gateway_image
|
|
self._repo_root = repo_root
|
|
self._host_root = host_root or bot_bottle_root()
|
|
self._orchestrator_name = orchestrator_name
|
|
|
|
def _resolve_url(self) -> str:
|
|
"""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,
|
|
) -> bool:
|
|
if not url:
|
|
return False
|
|
try:
|
|
with urllib.request.urlopen(f"{url}/health", timeout=timeout) as resp:
|
|
return resp.status == 200
|
|
except (urllib.error.URLError, TimeoutError, OSError):
|
|
return False
|
|
|
|
def _ensure_orchestrator_image(self) -> None:
|
|
"""Build the lean control-plane image when missing. Build-if-missing,
|
|
not build-every-time: the control plane bind-mounts its source, so a
|
|
code change is caught by the source-hash recreate, not a rebuild."""
|
|
if container_mod.image_exists(self.image):
|
|
return
|
|
container_mod.build_image(
|
|
self.image, str(self._repo_root), dockerfile=ORCHESTRATOR_DOCKERFILE,
|
|
)
|
|
|
|
def _orchestrator_source_current(self, current_hash: str) -> bool:
|
|
"""True iff the running orchestrator was created from the *current*
|
|
bind-mounted source. The process loaded that code at startup and won't
|
|
reload it, so a stale container would keep serving OLD control-plane
|
|
code."""
|
|
if not container_mod.container_is_running(self._orchestrator_name):
|
|
return False
|
|
data = container_mod.inspect_container(self._orchestrator_name)
|
|
config = data.get("configuration")
|
|
labels = config.get("labels") if isinstance(config, dict) else None
|
|
if not isinstance(labels, dict):
|
|
return True # can't compare → don't churn a working container
|
|
return labels.get(ORCHESTRATOR_SOURCE_HASH_LABEL) == current_hash
|
|
|
|
def _run_orchestrator_container(self, current_hash: str) -> None:
|
|
# The orchestrator is the first thing on the shared network, so it —
|
|
# not the gateway — is what has to bring the network into existence.
|
|
ensure_networks(self.network, self.egress_network)
|
|
container_mod.force_remove_container(self._orchestrator_name)
|
|
argv = [
|
|
"container", "run", "--detach",
|
|
"--name", self._orchestrator_name,
|
|
"--label", "bot-bottle.backend=macos-container",
|
|
"--label", f"{ORCHESTRATOR_SOURCE_HASH_LABEL}={current_hash}",
|
|
# Host-only network only: the control plane needs no route out, and
|
|
# the host reaches it here directly (no --publish needed).
|
|
"--network", self.network,
|
|
"--mount", _mount(str(self._repo_root), _APP_DIR, readonly=True),
|
|
"--workdir", _APP_DIR,
|
|
# Persist the registry DB on the host (sole-owner: only the
|
|
# orchestrator opens bot-bottle.db).
|
|
"--mount", _mount(str(self._host_root), _ROOT_IN_CONTAINER),
|
|
"--env", f"BOT_BOTTLE_ROOT={_ROOT_IN_CONTAINER}",
|
|
"--entrypoint", "python3",
|
|
self.image,
|
|
"-m", "bot_bottle.orchestrator",
|
|
"--host", "0.0.0.0", "--port", str(self.port), "--broker", "stub",
|
|
]
|
|
result = container_mod.run_container_argv(argv)
|
|
if result.returncode != 0:
|
|
raise OrchestratorStartError(
|
|
f"orchestrator container failed to start: "
|
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
)
|
|
|
|
def gateway(self, orchestrator_url: str) -> AppleGateway:
|
|
"""The shared gateway, pointed at the control plane at
|
|
`orchestrator_url` (by IP — Apple has no container DNS)."""
|
|
return AppleGateway(
|
|
self._gateway_image,
|
|
network=self.network,
|
|
egress_network=self.egress_network,
|
|
orchestrator_url=orchestrator_url,
|
|
)
|
|
|
|
def ensure_running(
|
|
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
|
) -> str:
|
|
"""Ensure the control plane + shared gateway are up; return the
|
|
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)
|
|
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)
|
|
|
|
# Gateway second: it can only reach the control plane by IP, which does
|
|
# not exist until the orchestrator container is up (see the docstring).
|
|
gateway = self.gateway(url)
|
|
gateway.ensure_built()
|
|
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
|
|
before the server binds."""
|
|
deadline = time.monotonic() + startup_timeout
|
|
while True:
|
|
url = self._resolve_url()
|
|
if url and self.is_healthy(url):
|
|
log.info("orchestrator healthy", context={"url": url})
|
|
return url
|
|
if time.monotonic() >= deadline:
|
|
raise OrchestratorStartError(
|
|
f"orchestrator did not become healthy within "
|
|
f"{startup_timeout:g}s"
|
|
)
|
|
time.sleep(_HEALTH_POLL_SECONDS)
|
|
|
|
def stop(self) -> None:
|
|
"""Remove the orchestrator + gateway containers (idempotent)."""
|
|
container_mod.force_remove_container(self._orchestrator_name)
|
|
self.gateway("").stop()
|
|
|
|
|
|
__all__ = [
|
|
"MacosOrchestratorService",
|
|
"OrchestratorStartError",
|
|
"ORCHESTRATOR_NAME",
|
|
]
|