refactor(macos): one infra container (control plane + gateway), fixes shared-DB races
Adopts the firecracker infra-VM pattern for macOS: the orchestrator control plane and the gateway data plane now run in a SINGLE Apple container instead of two. Apple Containers are lightweight VMs with separate kernels, so the prior two-container design had both guests writing one bot-bottle.db over virtiofs, where fcntl locks are not coherent across kernels — concurrent writes (the orchestrator's registry vs the gateway supervise daemon's queue) could corrupt it. One container = one kernel = coherent locking. The DB moves onto a container-only Apple volume (bot-bottle-mac-db), never bind-mounted from the host, so no host process opens the live file either. The host CLI already reaches registry + supervise state over the control-plane HTTP surface (cli/supervise.py uses OrchestratorClient), exactly as firecracker's VM-only DB requires. Two simplifications fall out of the single container: - No DNS dance: the control plane and gateway daemons reach each other over 127.0.0.1, so the orchestrator-before-gateway ordering (a workaround for Apple having no container DNS) is gone, along with the moved-IP recreate logic it needed. - Net -243 lines. Mechanics: the infra container runs from the gateway image with the control-plane source bind-mounted read-only (like the docker orchestrator, so a code change needs no rebuild) and a small sh -c init that starts both processes (mirrors firecracker's _infra_init). Also implements the macOS backend's ensure_orchestrator() and adds it to discover_orchestrator_url, so operator tools (supervise) can bring up / find the control plane on demand — previously the macOS backend died with "no orchestrator control plane". Verified end-to-end on real Apple Container 1.0.0: the single infra container comes up healthy (one address for control plane + gateway), both processes run, the DB is written on the container-only volume, host-side supervise works over HTTP, and a registered agent gets 200 for an allowed host / 403 for a denied one. 1824 unit tests pass with `container` absent (CI parity), pyright clean, pylint 9.89. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,235 +1,46 @@
|
||||
"""The consolidated per-host gateway as an Apple container (PRD 0070).
|
||||
"""Shared network/image constants for the macOS consolidated infra container.
|
||||
|
||||
The macOS counterpart of `orchestrator.gateway.DockerGateway`: one persistent
|
||||
gateway per host, shared by every bottle, attributing each request to a bottle
|
||||
by its source IP on the shared host-only network.
|
||||
|
||||
Two Apple Container 1.0.0 constraints shape this and make it *not* a
|
||||
transliteration of the docker gateway:
|
||||
|
||||
- **No container DNS.** Containers cannot resolve each other by name (the
|
||||
host-only network's resolver refuses the query), so the gateway reaches the
|
||||
control plane by **IP**, not by name as the docker gateway does. The
|
||||
orchestrator must therefore be started *before* the gateway — see
|
||||
`orchestrator_service`.
|
||||
- **Networks are fixed at `container run`.** There is no `network connect`,
|
||||
so a network cannot be attached to a running container. The gateway must sit
|
||||
on one shared, up-front network for the lifetime of the process; per-bottle
|
||||
networks would mean restarting the gateway on every launch, which defeats the
|
||||
consolidation.
|
||||
|
||||
The gateway is dual-homed, **NAT network first**: Apple Container makes the
|
||||
first `--network` the default route, so the egress network must lead or the
|
||||
gateway has no route to the internet.
|
||||
The gateway data plane no longer runs as its own Apple container — it shares a
|
||||
single per-host **infra container** with the control plane (see `infra`),
|
||||
because two Apple-Container guests writing one `bot-bottle.db` over virtiofs
|
||||
would race incoherent `fcntl` locks. This module holds the pieces both the
|
||||
infra service and the launch/provision glue need: the network names, the
|
||||
gateway image, and the network-creation helper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from ...orchestrator.gateway import (
|
||||
GATEWAY_CA_CERT,
|
||||
GATEWAY_DOCKERFILE,
|
||||
MITMPROXY_HOME,
|
||||
Gateway,
|
||||
GatewayError,
|
||||
)
|
||||
from ...paths import host_db_dir
|
||||
from ...supervise import DB_PATH_IN_CONTAINER
|
||||
from ...orchestrator.gateway import GatewayError
|
||||
from . import util as container_mod
|
||||
from .util import bind_mount_spec as _mount
|
||||
|
||||
# Distinct from the docker gateway's names so both backends' gateways can
|
||||
# coexist on one host (a macOS host can run the docker backend too).
|
||||
GATEWAY_NAME = "bot-bottle-mac-gateway"
|
||||
# The shared host-only network the gateway and every agent bottle sit on. The
|
||||
# agent's address here is the attribution key.
|
||||
# The shared host-only network the infra container and every agent bottle sit
|
||||
# on. The agent's address here is the attribution key. Distinct from the docker
|
||||
# names so both backends can coexist on one host.
|
||||
GATEWAY_NETWORK = "bot-bottle-mac-gateway"
|
||||
# The NAT network that gives the gateway (and only the gateway) a route out.
|
||||
# The NAT network that gives the infra container (and only it) a route out.
|
||||
GATEWAY_EGRESS_NETWORK = "bot-bottle-mac-egress"
|
||||
|
||||
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
_SUPERVISE_DB_DIR_IN_CONTAINER = os.path.dirname(DB_PATH_IN_CONTAINER)
|
||||
|
||||
# mitmproxy writes its CA a beat after start; reads poll rather than assume.
|
||||
_CA_POLL_SECONDS = 0.5
|
||||
DEFAULT_CA_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
|
||||
def gateway_ca_dir() -> Path:
|
||||
"""Host dir bind-mounted as mitmproxy's home, keeping the gateway's
|
||||
self-generated CA **stable across container recreation** — every agent
|
||||
installs this one CA to trust the shared gateway's TLS interception, so it
|
||||
must not rotate when the gateway restarts.
|
||||
|
||||
The docker gateway uses a named volume for this; a plain host dir is the
|
||||
same guarantee with fewer moving parts, and it lets `ca_cert_pem` read the
|
||||
PEM straight off the host instead of shelling into the container."""
|
||||
path = host_db_dir() / "mac-gateway-ca"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def ensure_networks(
|
||||
network: str = GATEWAY_NETWORK, egress_network: str = GATEWAY_EGRESS_NETWORK,
|
||||
) -> None:
|
||||
"""Create the shared host-only network + the gateway's NAT network.
|
||||
Idempotent — `create_network` tolerates 'already exists'.
|
||||
|
||||
Module-level rather than a gateway method because the **orchestrator**
|
||||
needs the shared network too, and it starts first (Apple has no container
|
||||
DNS, so the gateway must be handed the control plane's IP). Both callers
|
||||
ensure the networks; whoever runs first wins."""
|
||||
"""Create the shared host-only network + the NAT egress network. Idempotent
|
||||
— `create_network` tolerates 'already exists'."""
|
||||
container_mod.create_network(egress_network)
|
||||
container_mod.create_network(network, internal=True)
|
||||
|
||||
|
||||
class AppleGateway(Gateway):
|
||||
"""The consolidated gateway as a single, fixed-name Apple container."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
image_ref: str = GATEWAY_IMAGE,
|
||||
*,
|
||||
name: str = GATEWAY_NAME,
|
||||
network: str = GATEWAY_NETWORK,
|
||||
egress_network: str = GATEWAY_EGRESS_NETWORK,
|
||||
orchestrator_url: str = "",
|
||||
build_context: Path | None = None,
|
||||
dockerfile: str | None = GATEWAY_DOCKERFILE,
|
||||
) -> None:
|
||||
self.image_ref = image_ref
|
||||
self.name = name
|
||||
self.network = network
|
||||
self.egress_network = egress_network
|
||||
# Reached by IP (no container DNS on Apple) — the caller resolves the
|
||||
# orchestrator's address before constructing this. Empty → single-tenant.
|
||||
self._orchestrator_url = orchestrator_url
|
||||
self._build_context = build_context or _REPO_ROOT
|
||||
self._dockerfile = dockerfile
|
||||
|
||||
def ensure_built(self) -> None:
|
||||
"""Build the gateway data-plane image from its Dockerfile. Builds every
|
||||
time (cache-aware, so it's cheap when nothing changed): a stale image
|
||||
silently runs the OLD single-tenant daemons. Mirrors `DockerGateway`."""
|
||||
if self._dockerfile is None:
|
||||
return
|
||||
container_mod.build_image(
|
||||
self.image_ref, str(self._build_context), dockerfile=self._dockerfile,
|
||||
)
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return container_mod.container_is_running(self.name)
|
||||
|
||||
def _running_image_is_current(self) -> bool:
|
||||
"""True iff the running gateway was created from the *current*
|
||||
`image_ref`. `ensure_built` rebuilding the image is not enough on its
|
||||
own — the running container still holds the OLD image, so this
|
||||
mismatch check is what makes a rebuild take effect."""
|
||||
running = container_mod.container_image_digest(self.name)
|
||||
current = container_mod.image_digest(self.image_ref)
|
||||
if not running or not current:
|
||||
return True # can't compare → don't churn a working container
|
||||
return running == current
|
||||
|
||||
def _running_control_plane_is_current(self) -> bool:
|
||||
"""True iff the running gateway points at the control plane we would
|
||||
pass today.
|
||||
|
||||
Docker gets this for free — it hands the gateway a container *name*,
|
||||
which survives the orchestrator being recreated. Apple has no container
|
||||
DNS, so the URL is an **IP baked into the gateway's env at run time**,
|
||||
and a recreated orchestrator can come back on a different DHCP address.
|
||||
Without this check the gateway would keep pointing at the old address
|
||||
and every `/resolve` would fail — denying egress for *every* bottle on
|
||||
the host until something else happened to recreate the gateway."""
|
||||
if not self._orchestrator_url:
|
||||
return True
|
||||
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_ORCHESTRATOR_URL") == self._orchestrator_url
|
||||
|
||||
def ensure_running(self) -> None:
|
||||
if (self.is_running()
|
||||
and self._running_image_is_current()
|
||||
and self._running_control_plane_is_current()):
|
||||
return
|
||||
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", "bot-bottle-mac-gateway=1",
|
||||
# NAT network FIRST: Apple Container takes the first --network as
|
||||
# the default route, so this ordering is what gives the gateway a
|
||||
# route out. Reversing it silently blackholes egress.
|
||||
"--network", self.egress_network,
|
||||
"--network", self.network,
|
||||
# The NAT gateway routes but does not resolve, so DNS is explicit.
|
||||
"--dns", container_mod.dns_server(),
|
||||
"--mount", _mount(str(gateway_ca_dir()), MITMPROXY_HOME),
|
||||
"--mount", _mount(str(host_db_dir()), _SUPERVISE_DB_DIR_IN_CONTAINER),
|
||||
"--env", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
||||
]
|
||||
if self._orchestrator_url:
|
||||
# Makes the data plane multi-tenant: each request resolves
|
||||
# source-IP → policy against the control plane.
|
||||
argv += ["--env", f"BOT_BOTTLE_ORCHESTRATOR_URL={self._orchestrator_url}"]
|
||||
argv.append(self.image_ref)
|
||||
result = container_mod.run_container_argv(argv)
|
||||
if result.returncode != 0:
|
||||
raise GatewayError(
|
||||
f"gateway failed to start: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
|
||||
def ip_on_shared_network(self) -> str:
|
||||
"""The gateway's address on the shared host-only network — what agents
|
||||
point their proxy / git-http / supervise URLs at. Polls: a freshly
|
||||
run gateway can be up before vmnet's DHCP has assigned the address."""
|
||||
ip = container_mod.wait_container_ipv4_on_network(self.name, self.network)
|
||||
if not ip:
|
||||
raise GatewayError(
|
||||
f"gateway {self.name} never got an address on {self.network}"
|
||||
)
|
||||
return ip
|
||||
|
||||
def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str:
|
||||
"""The gateway's CA certificate (PEM) that agents install to trust its
|
||||
TLS interception. Polls: mitmproxy generates it a moment after start.
|
||||
Read from the host bind-mount, so no exec into the container."""
|
||||
ca_path = gateway_ca_dir() / os.path.basename(GATEWAY_CA_CERT)
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
try:
|
||||
pem = ca_path.read_text()
|
||||
if pem.strip():
|
||||
return pem
|
||||
except OSError:
|
||||
pass
|
||||
if time.monotonic() >= deadline:
|
||||
raise GatewayError(
|
||||
f"gateway CA cert not available at {ca_path} after {timeout:g}s"
|
||||
)
|
||||
time.sleep(_CA_POLL_SECONDS)
|
||||
|
||||
def stop(self) -> None:
|
||||
container_mod.force_remove_container(self.name)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AppleGateway",
|
||||
"GatewayError",
|
||||
"GATEWAY_NAME",
|
||||
"GATEWAY_NETWORK",
|
||||
"GATEWAY_EGRESS_NETWORK",
|
||||
"GATEWAY_IMAGE",
|
||||
"gateway_ca_dir",
|
||||
"GatewayError",
|
||||
"DEFAULT_CA_TIMEOUT_SECONDS",
|
||||
"ensure_networks",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user