c69642e568
Re-enables the macos-container backend on the shared per-host orchestrator + gateway, replacing the per-bottle companion container removed in #385. This is the last backend in PRD 0070's roadmap. Apple Container 1.0.0 forced three departures from the docker shape, each verified against the live CLI (findings recorded in the networking spike): - No `--ip`. The address is DHCP-assigned and knowable only once the container runs, so the order inverts: gateway up -> run agent -> read its address -> register. The identity token is minted by registration and therefore cannot be in the agent's run-time env; it rides the proxy URL applied at `container exec` time (bare `--env` names keep it off argv). - No container DNS. The gateway can only be handed the control plane's IP, so the orchestrator starts first and the gateway is pointed at its address. - No `network connect`. Networks are fixed at run time, so the shared host-only network is created up front; per-bottle networks would restart the gateway on every launch and defeat the consolidation. The agent runs with `--cap-drop CAP_NET_RAW`: Apple grants NET_RAW by default, which would let an agent forge a neighbour's source address on the shared segment. NET_ADMIN is already absent, so this closes the source-address half of PRD 0070's attribution invariant. Verified end-to-end on real Apple Container 1.0.0: both images build, the control plane comes up healthy, the gateway reaches it by IP, and a registered agent gets 200 for a host in its routes and 403 for one outside them. Bring-up is idempotent — a second launch does not churn the singletons. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
242 lines
9.6 KiB
Python
242 lines
9.6 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,
|
|
)
|
|
|
|
# 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
|
|
|
|
|
|
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."""
|
|
|
|
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
|
|
# 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."""
|
|
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:
|
|
target = url or self._url
|
|
if not target:
|
|
return False
|
|
try:
|
|
with urllib.request.urlopen(f"{target}/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)
|
|
if not self._orchestrator_source_current(current_hash):
|
|
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
|
|
|
|
# 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 _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()
|
|
self._url = ""
|
|
|
|
|
|
__all__ = [
|
|
"MacosOrchestratorService",
|
|
"OrchestratorStartError",
|
|
"ORCHESTRATOR_NAME",
|
|
]
|