44479f328e
test / stage-firecracker-inputs (pull_request) Successful in 5s
test / integration-docker (pull_request) Successful in 8s
tracker-policy-pr / check-pr (pull_request) Successful in 7s
test / unit (pull_request) Successful in 30s
test / build-infra (pull_request) Successful in 3m56s
test / integration-firecracker (pull_request) Successful in 1m36s
test / coverage (pull_request) Successful in 2m33s
test / publish-infra (pull_request) Has been skipped
test / stage-firecracker-inputs (push) Successful in 2s
test / integration-docker (push) Successful in 33s
test / unit (push) Successful in 34s
lint / lint (push) Successful in 48s
Update Quality Badges / update-badges (push) Failing after 44s
test / build-infra (push) Successful in 3m48s
test / integration-firecracker (push) Successful in 1m44s
test / coverage (push) Successful in 1m36s
test / publish-infra (push) Successful in 2m33s
The macOS infra container's init script still spawned the supervisor as `python3 /app/gateway_init.py`.5ad3449moved bot_bottle from flat files under /app into a pip-installed package, so that file no longer exists: the supervisor never started, mitmdump never generated its CA, and the launch failed downstream with a misleading "gateway CA not available" error pointing at TLS rather than at the supervisor. This is the same defect127ba49fixed for the firecracker backend. It survived on macOS because CI has no Apple Container runner — the KVM runner added inc193b04covers firecracker, and ubuntu-latest covers docker, but nothing exercises macos_container. test_macos_infra asserted the substring "gateway_init.py", which the broken path satisfied; it now asserts the module form, matching the assertion127ba49already made in test_firecracker_infra_vm. Verified end to end on the macos-container backend: image builds, CA is generated, agent registers, and a headless bottle runs a prompt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
306 lines
13 KiB
Python
306 lines
13 KiB
Python
"""The per-host infra container for the macOS backend (PRD 0070).
|
|
|
|
A single persistent Apple container that runs BOTH the orchestrator control
|
|
plane and the gateway data plane — the macOS analogue of the Firecracker infra
|
|
VM (`backend/firecracker/infra_vm.py`), not the docker backend's two separate
|
|
containers.
|
|
|
|
Why one container, not two: Apple Containers are lightweight VMs, each with its
|
|
own kernel. The docker backend runs the orchestrator and gateway as two
|
|
containers safely because they share the host kernel, so their concurrent
|
|
writes to the one `bot-bottle.db` (the orchestrator's registry + the gateway
|
|
supervise daemon's queue) are serialized by coherent `fcntl` locks. Across two
|
|
*guest* kernels sharing a virtiofs-mounted DB those locks are not coherent, and
|
|
concurrent writers can corrupt the file. Firecracker solved this by putting
|
|
both services in one guest with the DB on a device only that guest mounts; this
|
|
does the same with Apple primitives.
|
|
|
|
Two consequences fall out of the single container, both simplifications:
|
|
|
|
- **No DNS dance.** The control plane and the gateway daemons reach each other
|
|
over `127.0.0.1`, so nothing depends on Apple's (absent) container DNS and
|
|
there is no orchestrator-before-gateway ordering to get right.
|
|
- **The DB is never host-shared.** It lives on a container-only volume, so no
|
|
host process opens the live file. The host CLI reaches registry + supervise
|
|
state through the control-plane HTTP surface (`cli/supervise.py` already uses
|
|
`OrchestratorClient`), exactly as it does for firecracker.
|
|
|
|
The control-plane source is bind-mounted (like the docker orchestrator), so a
|
|
code change takes effect on the next launch without an image rebuild; the
|
|
gateway daemons are baked in the gateway image and rebuild through its own
|
|
digest check.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from ... import log
|
|
from ...orchestrator.gateway import GATEWAY_CA_CERT
|
|
from ...orchestrator.lifecycle import (
|
|
DEFAULT_PORT,
|
|
DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
|
OrchestratorStartError,
|
|
source_hash,
|
|
)
|
|
from ...paths import (
|
|
CONTROL_PLANE_TOKEN_ENV,
|
|
HOST_DB_FILENAME,
|
|
host_control_plane_token,
|
|
)
|
|
from . import util as container_mod
|
|
from .gateway import (
|
|
DEFAULT_CA_TIMEOUT_SECONDS,
|
|
GATEWAY_EGRESS_NETWORK,
|
|
GATEWAY_IMAGE,
|
|
GATEWAY_NETWORK,
|
|
GatewayError,
|
|
ensure_networks,
|
|
)
|
|
|
|
# The one per-host infra container: control plane + gateway data plane.
|
|
INFRA_NAME = "bot-bottle-mac-infra"
|
|
INFRA_LABEL = "bot-bottle-mac-infra=1"
|
|
# Container-only volume holding bot-bottle.db. No host bind-mount, so the DB is
|
|
# written by exactly one kernel (this container's). Survives recreation.
|
|
INFRA_DB_VOLUME = "bot-bottle-mac-db"
|
|
|
|
# BOT_BOTTLE_ROOT inside the container; host_db_path() resolves the DB to
|
|
# <root>/db/<filename> and the supervise daemon writes the same file.
|
|
_DB_ROOT_IN_CONTAINER = "/var/lib/bot-bottle"
|
|
_DB_PATH_IN_CONTAINER = f"{_DB_ROOT_IN_CONTAINER}/db/{HOST_DB_FILENAME}"
|
|
_SRC_IN_CONTAINER = "/bot-bottle-src"
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
|
|
_HEALTH_POLL_SECONDS = 0.25
|
|
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
|
|
_CA_POLL_SECONDS = 0.5
|
|
|
|
# The gateway subset the consolidated model runs (no per-bottle git:// daemon).
|
|
_GATEWAY_DAEMONS = "egress,git-http,supervise"
|
|
|
|
|
|
def _init_script(port: int) -> str:
|
|
"""PID-1 init: start the control plane and the gateway daemons, both in
|
|
this container, reaching each other over loopback. Backgrounded so `wait`
|
|
reaps as PID 1. No `set -e` — a transient daemon failure must not kill the
|
|
whole container (gateway_init applies the same 'stay up' policy)."""
|
|
return (
|
|
"export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n"
|
|
f"mkdir -p $(dirname {_DB_PATH_IN_CONTAINER})\n"
|
|
# Control plane, from the bind-mounted source (stdlib-only package).
|
|
f"( cd {_SRC_IN_CONTAINER} && BOT_BOTTLE_ROOT={_DB_ROOT_IN_CONTAINER} "
|
|
f"python3 -m bot_bottle.orchestrator --host 0.0.0.0 --port {port} "
|
|
"--broker stub ) &\n"
|
|
# Gateway data plane, multi-tenant against the local control plane.
|
|
f"( cd /app && BOT_BOTTLE_GATEWAY_DAEMONS={_GATEWAY_DAEMONS} "
|
|
f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{port} "
|
|
f"SUPERVISE_DB_PATH={_DB_PATH_IN_CONTAINER} python3 -m bot_bottle.gateway_init ) &\n"
|
|
"while : ; do wait ; done\n"
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class InfraEndpoint:
|
|
"""How to reach the running infra container. The control plane and the
|
|
gateway are the same container, so one address serves both."""
|
|
|
|
control_plane_url: str # http://<infra ip>:8099 — host CLI + registration
|
|
gateway_ip: str # same container; agents' proxy / git-http / MCP target
|
|
|
|
|
|
class MacosInfraService:
|
|
"""Manages the single per-host infra container. Callers use
|
|
`ensure_running()` (returns the endpoint) and `ca_cert_pem()`."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
port: int = DEFAULT_PORT,
|
|
network: str = GATEWAY_NETWORK,
|
|
egress_network: str = GATEWAY_EGRESS_NETWORK,
|
|
image: str = GATEWAY_IMAGE,
|
|
repo_root: Path = _REPO_ROOT,
|
|
name: str = INFRA_NAME,
|
|
db_volume: str = INFRA_DB_VOLUME,
|
|
) -> None:
|
|
self.port = port
|
|
self.network = network
|
|
self.egress_network = egress_network
|
|
self.image = image
|
|
self._repo_root = repo_root
|
|
self._name = name
|
|
self._db_volume = db_volume
|
|
|
|
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._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 _source_current(self, current_hash: str) -> bool:
|
|
"""True iff the running infra container was created from the current
|
|
bind-mounted control-plane source. The control-plane process loads that
|
|
code at startup and won't reload it, so a stale container keeps serving
|
|
OLD code."""
|
|
if not container_mod.container_is_running(self._name):
|
|
return False
|
|
env = container_mod.container_env(self._name)
|
|
if not env:
|
|
return True # can't compare → don't churn a working container
|
|
return env.get("BOT_BOTTLE_SOURCE_HASH") == current_hash
|
|
|
|
def _running_healthy_endpoint(self, current_hash: str) -> InfraEndpoint | None:
|
|
"""The endpoint if the running container is BOTH source-current and
|
|
answering /health, else None (→ recreate). Health, not just the source
|
|
label, is what lets a wedged-but-current container self-heal instead of
|
|
being polled to death forever."""
|
|
if not self._source_current(current_hash):
|
|
return None
|
|
url = self._resolve_url()
|
|
if url and self.is_healthy(url):
|
|
return InfraEndpoint(control_plane_url=url, gateway_ip=_ip_of(url))
|
|
return None
|
|
|
|
def ensure_built(self) -> None:
|
|
"""Ensure the gateway data-plane image exists. The control-plane source
|
|
is bind-mounted, not baked, so only the gateway image needs building."""
|
|
container_mod.build_image(
|
|
self.image, str(self._repo_root), dockerfile="Dockerfile.gateway",
|
|
)
|
|
|
|
def ensure_running(
|
|
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
|
) -> InfraEndpoint:
|
|
"""Ensure the single infra container is up; return how to reach it.
|
|
Idempotent per-host singleton — a healthy container on current source
|
|
is left untouched, so N launches share the one control plane + gateway.
|
|
Raises `OrchestratorStartError` on startup timeout."""
|
|
current_hash = source_hash(self._repo_root)
|
|
endpoint = self._running_healthy_endpoint(current_hash)
|
|
if endpoint is not None:
|
|
return endpoint
|
|
self.ensure_built()
|
|
log.info("starting infra container", context={"name": self._name})
|
|
self._run_container(current_hash)
|
|
return self._wait_healthy(startup_timeout)
|
|
|
|
def _run_container(self, current_hash: str) -> None:
|
|
ensure_networks(self.network, self.egress_network)
|
|
container_mod.force_remove_container(self._name)
|
|
argv = [
|
|
"container", "run", "--detach",
|
|
"--name", self._name,
|
|
"--label", "bot-bottle.backend=macos-container",
|
|
"--label", INFRA_LABEL,
|
|
# NAT network FIRST so the gateway's egress has a default route;
|
|
# the host-only network is where agents (and the host CLI) reach it.
|
|
"--network", self.egress_network,
|
|
"--network", self.network,
|
|
"--dns", container_mod.dns_server(),
|
|
# Container-only DB volume: one kernel writes bot-bottle.db, never
|
|
# shared with the host or another guest.
|
|
"--volume", f"{self._db_volume}:{_DB_ROOT_IN_CONTAINER}",
|
|
# Bind-mount the control-plane source (read-only); a code change
|
|
# takes effect on relaunch with no image rebuild.
|
|
"--mount",
|
|
container_mod.bind_mount_spec(
|
|
str(self._repo_root), _SRC_IN_CONTAINER, readonly=True),
|
|
# Baked onto the container so `_source_current` can detect a real
|
|
# control-plane code change and recreate.
|
|
"--env", f"BOT_BOTTLE_SOURCE_HASH={current_hash}",
|
|
# The control-plane secret, for BOTH the control plane (to require
|
|
# it) and the gateway's PolicyResolver (to present it) — they share
|
|
# this one container. Bare `--env NAME` inherits the value from the
|
|
# run process below, so the secret never lands on argv or in
|
|
# `container inspect`'s command line. The agent runs in a SEPARATE
|
|
# container that is never given this var, which is the whole point.
|
|
"--env", CONTROL_PLANE_TOKEN_ENV,
|
|
"--entrypoint", "sh",
|
|
self.image,
|
|
"-c", _init_script(self.port),
|
|
]
|
|
run_env = {**os.environ, CONTROL_PLANE_TOKEN_ENV: host_control_plane_token()}
|
|
result = container_mod.run_container_argv(argv, env=run_env)
|
|
if result.returncode != 0:
|
|
raise OrchestratorStartError(
|
|
f"infra container failed to start: "
|
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
)
|
|
|
|
def _wait_healthy(self, startup_timeout: float) -> InfraEndpoint:
|
|
deadline = time.monotonic() + startup_timeout
|
|
while True:
|
|
url = self._resolve_url()
|
|
if url and self.is_healthy(url):
|
|
log.info("infra container healthy", context={"url": url})
|
|
return InfraEndpoint(control_plane_url=url, gateway_ip=_ip_of(url))
|
|
if time.monotonic() >= deadline:
|
|
raise OrchestratorStartError(
|
|
f"infra container did not become healthy within "
|
|
f"{startup_timeout:g}s"
|
|
)
|
|
time.sleep(_HEALTH_POLL_SECONDS)
|
|
|
|
def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str:
|
|
"""The gateway's mitmproxy CA (PEM) agents install to trust its TLS
|
|
interception. Read out of the container (the CA lives on a
|
|
container-internal path, not a host mount); polls because mitmproxy
|
|
writes it a beat after start."""
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
result = container_mod.run_container_argv(
|
|
["container", "exec", self._name, "cat", GATEWAY_CA_CERT])
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
return result.stdout
|
|
if time.monotonic() >= deadline:
|
|
raise GatewayError(
|
|
f"gateway CA not available in {self._name} after {timeout:g}s: "
|
|
f"{(result.stderr or '').strip() or 'empty'}"
|
|
)
|
|
time.sleep(_CA_POLL_SECONDS)
|
|
|
|
def stop(self) -> None:
|
|
"""Remove the infra container (idempotent). The DB volume persists."""
|
|
container_mod.force_remove_container(self._name)
|
|
|
|
|
|
def _ip_of(url: str) -> str:
|
|
"""The host from an http://host:port URL."""
|
|
return url.split("://", 1)[-1].rsplit(":", 1)[0]
|
|
|
|
|
|
def probe_control_plane_url(port: int = DEFAULT_PORT) -> str:
|
|
"""The running infra container's control-plane URL, or "" if it isn't up.
|
|
Used by host-side control-plane discovery (`discover_orchestrator_url`);
|
|
safe to call on any host — returns "" when the container or the `container`
|
|
CLI isn't present."""
|
|
ip = container_mod.try_container_ipv4_on_network(INFRA_NAME, GATEWAY_NETWORK)
|
|
return f"http://{ip}:{port}" if ip else ""
|
|
|
|
|
|
__all__ = [
|
|
"MacosInfraService",
|
|
"InfraEndpoint",
|
|
"OrchestratorStartError",
|
|
"GatewayError",
|
|
"INFRA_NAME",
|
|
"INFRA_DB_VOLUME",
|
|
]
|