feat(backend): reconcile running bottles' CA on gateway bring-up (0081)

Add `attach_bottled_agents_to_gateway()` as an `@abc.abstractmethod` on
`BottleBackend` and implement the first reconcile step across all three
backends: on a gateway cold boot, replace every already-running agent's
trusted CA with the freshly-minted gateway CA. Reconciling running bottles
is a backend responsibility (only the backend can enumerate its agents and
reach them — firecracker over SSH, docker/macOS over exec/cp), so making it
an abstract method keeps the fix cross-backend by construction.

The gateway rootfs is ephemeral, so a rebuild mints a new CA that every
running bottle distrusts (SSL verification fails — #510). This reconcile is
what distributes the fresh CA, so a routine gateway rebuild now doubles as a
free CA rotation. Per-bottle steps are best-effort: one unreachable or
malformed bottle is logged and skipped, never blocking the others.

Trigger — `Gateway.connect_to_orchestrator` now returns a cold-boot bool
(True when it actually (re)brought the gateway up, False when a healthy
current gateway was left untouched); each infra `ensure_running` gates the
reconcile on it, so it fires exactly on cold boot and never on an adopt.

Git-gate re-provision and egress-token restore fold into this same reconcile
on the same trigger in follow-up PRs (#516).

Refs #516. Addresses #510.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 23:54:27 +00:00
committed by didericis
parent 314b30c013
commit 36f594b770
21 changed files with 488 additions and 15 deletions
+21
View File
@@ -512,6 +512,27 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
compose ls`; firecracker cross-references its running gateway
containers against per-bottle metadata."""
@abstractmethod
def attach_bottled_agents_to_gateway(self) -> None:
"""Reconcile every already-running bottle against the freshly-(re)booted
gateway (PRD 0081). The host calls this on the gateway bring-up path —
the cold-boot branch only, never on an adopt of a healthy current
gateway — so a gateway rebuild/restart doesn't silently break running
bottles' gateway-dependent state.
Reconciling running bottles is a backend responsibility: only the
backend can enumerate its agents and reach them (firecracker over SSH,
docker/macOS over `exec`/`cp`), so every backend must implement it —
cross-backend by construction. Each per-bottle step is best-effort: one
unreachable or malformed bottle is logged and skipped so it never blocks
the others or the gateway coming up.
Currently reconciles the **CA**: it replaces each agent's trusted CA
with the current gateway CA (unconditional — there is one gateway, so no
fingerprint match is needed), which rotates the CA for free on every
bring-up. Git-gate re-provision and egress-token restore fold into this
same reconcile on the same trigger (see #516)."""
@classmethod
@abstractmethod
def is_available(cls) -> bool:
+4
View File
@@ -140,3 +140,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
def enumerate_active(self) -> Sequence[ActiveAgent]:
return _enumerate.enumerate_active()
def attach_bottled_agents_to_gateway(self) -> None:
from .consolidated_launch import attach_bottled_agents_to_gateway
attach_bottled_agents_to_gateway()
@@ -13,6 +13,7 @@ orchestrator-facing wiring so that sequence stays testable in isolation.
from __future__ import annotations
import tempfile
from dataclasses import dataclass
from ... import log
@@ -25,6 +26,7 @@ from .infra import INFRA_NAME, DockerInfraService
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
from ...orchestrator.reprovision import reprovision_bottles
from ..provision_bottle import deprovision_bottle, provision_bottle
from ..util import AGENT_CA_PATH
from .gateway_transport import DockerGatewayTransport
from .gateway_net import next_free_ip
@@ -79,6 +81,77 @@ def _network_container_ips(network: str) -> list[str]:
return ips
def _push_ca_to_container(container: str, ca_pem: str) -> None:
"""Replace one running agent container's trusted gateway CA with `ca_pem`
and rebuild its trust store via `docker cp` + `docker exec`. Unconditional
install — there is one gateway, so no fingerprint match is needed. Raises
`ConsolidatedLaunchError` on failure."""
mkdir = run_docker(
["docker", "exec", container, "mkdir", "-p",
"/usr/local/share/ca-certificates"]
)
if mkdir.returncode != 0:
raise ConsolidatedLaunchError(
f"CA push to {container} failed (mkdir): {mkdir.stderr.strip()}"
)
with tempfile.NamedTemporaryFile("w", suffix=".crt") as tmp:
tmp.write(ca_pem)
tmp.flush()
cp = run_docker(["docker", "cp", tmp.name, f"{container}:{AGENT_CA_PATH}"])
if cp.returncode != 0:
raise ConsolidatedLaunchError(
f"CA push to {container} failed (cp): {cp.stderr.strip()}"
)
ex = run_docker(
["docker", "exec", container, "sh", "-c",
f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates"]
)
if ex.returncode != 0:
raise ConsolidatedLaunchError(
f"CA push to {container} failed (update-ca-certificates): "
f"{ex.stderr.strip()}"
)
def attach_bottled_agents_to_gateway(
ca_pem: str | None = None,
*,
network: str = GATEWAY_NETWORK,
gateway_name: str = INFRA_NAME,
) -> None:
"""Reconcile every running agent container against the freshly-(re)created
gateway (PRD 0081): replace each agent's trusted CA with the current gateway
CA, so a gateway rebuild doesn't silently break their TLS egress (#510).
Per-container steps are best-effort: one failure is logged and skipped so it
never blocks the others or the gateway coming up."""
if ca_pem is None:
ca_pem = DockerInfraService().gateway().ca_cert_pem()
try:
proc = run_docker([
"docker", "network", "inspect", "--format",
"{{range .Containers}}{{.Name}}\n{{end}}", network,
])
except OSError as exc:
log.info(f"CA reconcile skipped: {exc}")
return
reconciled = 0
for line in proc.stdout.splitlines():
name = line.strip()
if not name or name == gateway_name:
continue
try:
_push_ca_to_container(name, ca_pem)
reconciled += 1
except (OSError, ConsolidatedLaunchError) as exc:
log.info(f"CA reconcile skipped for {name}: {exc}")
if reconciled:
log.info(
"reconciled gateway CA into running docker bottle(s)",
context={"count": reconciled},
)
def _reprovision_running_bottles(
orchestrator_url: str,
network: str = GATEWAY_NETWORK,
@@ -185,6 +258,7 @@ def deprovision_consolidated(
__all__ = [
"LaunchContext",
"launch_consolidated",
"attach_bottled_agents_to_gateway",
"deprovision_consolidated",
"ConsolidatedLaunchError",
]
+5 -2
View File
@@ -200,7 +200,7 @@ class DockerGateway(Gateway):
f"gateway network {self.network} failed to create: {proc.stderr.strip()}"
)
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None:
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> bool:
# Bind to this orchestrator (PRD 0070): stash the URL its daemons resolve
# policy against + the pre-minted `gateway` token they present, then bring
# the container up. The gateway never mints, so it holds no signing key —
@@ -227,7 +227,7 @@ class DockerGateway(Gateway):
# just when the container is absent.
self._ensure_network()
if self.is_running() and self._running_image_is_current():
return
return False
# Clear any stale (stopped OR outdated-image) container holding the
# fixed name, then start fresh. `rm --force` on an absent name is a
# tolerated no-op.
@@ -271,6 +271,9 @@ class DockerGateway(Gateway):
# pre-connect window (they retry /resolve per request).
if self._control_network:
self._connect_control_network()
# (Re)created a fresh container — signal a cold boot so the caller
# reconciles running bottles against it (PRD 0081).
return True
def _connect_control_network(self) -> None:
"""Ensure the `--internal` control network exists and attach the gateway
+8 -1
View File
@@ -142,9 +142,16 @@ class DockerInfraService(InfraService):
# mints the role-scoped `gateway` JWT here and hands it to the gateway,
# which never sees the key (#469). `connect_to_orchestrator` is
# idempotent.
gateway.connect_to_orchestrator(
cold_booted = gateway.connect_to_orchestrator(
orchestrator.gateway_url(), orchestrator.mint_gateway_token(),
)
# A (re)created gateway container minted/mounted its CA afresh and lost
# every bottle's per-bottle state; reconcile the already-running bottles
# against it (PRD 0081). Skipped when a healthy current gateway was left
# untouched — no cold boot, nothing to reconcile.
if cold_booted:
from .backend import DockerBottleBackend
DockerBottleBackend().attach_bottled_agents_to_gateway()
return orchestrator.url()
def stop(self) -> None:
@@ -119,6 +119,10 @@ class FirecrackerBottleBackend(
def enumerate_active(self) -> Sequence[ActiveAgent]:
return _enumerate.enumerate_active()
def attach_bottled_agents_to_gateway(self) -> None:
from .consolidated_launch import attach_bottled_agents_to_gateway
attach_bottled_agents_to_gateway()
def supervise_mcp_url(self, plan: FirecrackerBottlePlan) -> str:
return plan.agent_supervise_url
@@ -32,6 +32,7 @@ from dataclasses import dataclass
from pathlib import Path
from ...egress import EgressPlan
from ...gateway import GatewayError
from ...git_gate import GitGatePlan
from ...log import info
from ...orchestrator.client import OrchestratorClient, OrchestratorClientError
@@ -41,6 +42,7 @@ from ...orchestrator.lifecycle import (
from ...orchestrator.reprovision import reprovision_bottles
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
from ..provision_bottle import deprovision_bottle, provision_bottle
from ..util import AGENT_CA_PATH
from . import cleanup, util
from .gateway import FirecrackerGateway
from .infra import FirecrackerInfraService
@@ -89,6 +91,56 @@ def persist_env_var_secret(private_key: Path, guest_ip: str, secret: str) -> Non
)
def _push_gateway_ca_to_agent(private_key: Path, guest_ip: str, ca_pem: str) -> None:
"""Replace one running agent VM's trusted gateway CA with `ca_pem` and
rebuild its trust store over SSH. Unconditional install there is one
gateway, so no fingerprint match is needed. Bare-pipe input keeps the cert
off argv. Raises `ConsolidatedLaunchError` on failure."""
install = (
"umask 022; mkdir -p /usr/local/share/ca-certificates && "
f"cat > {AGENT_CA_PATH} && chmod 644 {AGENT_CA_PATH} && "
"update-ca-certificates"
)
proc = subprocess.run(
util.ssh_base_argv(private_key, guest_ip) + [install],
input=ca_pem, capture_output=True, text=True, check=False,
)
if proc.returncode != 0:
raise ConsolidatedLaunchError(
f"CA push to {guest_ip} failed: "
f"{proc.stderr.strip() or '<no stderr>'}"
)
def attach_bottled_agents_to_gateway() -> None:
"""Reconcile every running agent VM against the freshly-booted gateway
(PRD 0081). Replaces each agent's trusted CA with the current gateway CA —
the gateway rootfs is ephemeral, so a cold boot mints a fresh CA and this is
what distributes it (a gateway rebuild doubles as a free CA rotation, #510).
Per-bottle steps are best-effort: one unreachable or malformed VM is logged
and skipped so it never blocks the others or the gateway coming up."""
gateway = FirecrackerGateway()
try:
ca_pem = gateway.ca_cert_pem()
except GatewayError as exc:
info(f"bring-up reconcile skipped: gateway CA unavailable: {exc}")
return
reconciled = 0
for run_dir in cleanup.live_run_dirs():
guest_ip = _guest_ip_from_config(run_dir / "config.json")
private_key = run_dir / "bottle_id_ed25519"
if not guest_ip or not private_key.is_file():
continue
try:
_push_gateway_ca_to_agent(private_key, guest_ip, ca_pem)
reconciled += 1
except (OSError, ConsolidatedLaunchError) as exc:
info(f"CA reconcile skipped for {guest_ip}: {exc}")
if reconciled:
info(f"reconciled gateway CA into {reconciled} running Firecracker bottle(s)")
def _reprovision_running_bottles(client: OrchestratorClient) -> None:
"""Read keys from live agent VMs and restore the restarted gateway."""
try:
@@ -161,6 +213,7 @@ def deprovision_consolidated(
__all__ = [
"LaunchContext",
"launch_consolidated",
"attach_bottled_agents_to_gateway",
"deprovision_consolidated",
"ConsolidatedLaunchError",
"OrchestratorStartError",
+7 -2
View File
@@ -69,13 +69,17 @@ class FirecrackerGateway(Gateway):
self._orchestrator_url = ""
self._gateway_token = ""
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None:
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> bool:
"""Boot the gateway VM on its link resolving policy against
`orchestrator_url`, then seed `gateway_token` over SSH. The orchestrator's
guest IP is parsed from the URL and passed on the cmdline (`bb_orch=`), so
the baked init stays IP-independent. Boot the orchestrator first the
gateway daemons reach it at startup. The gateway never mints, so it holds
no signing key, only this token (#469)."""
no signing key, only this token (#469).
Always returns True: the firecracker gateway VM is booted fresh here
(this runs only on the infra cold-boot path), so it is always a cold
boot that minted a new CA the caller reconciles running bottles."""
self._orchestrator_url = orchestrator_url
self._gateway_token = gateway_token
if not self._orchestrator_url:
@@ -105,6 +109,7 @@ class FirecrackerGateway(Gateway):
"the gateway JWT to the gateway VM (its data plane will not start)",
)
self._vm = vm
return True
def is_running(self) -> bool:
return infra_vm._pidfile_alive(infra_vm._gw_dir())
+9 -1
View File
@@ -67,9 +67,17 @@ class FirecrackerInfraService(InfraService):
# holds the signing key) mints the role-scoped `gateway` JWT for the
# gateway, which never sees the key (#469).
orchestrator.ensure_running(startup_timeout=startup_timeout)
self.gateway().connect_to_orchestrator(
cold_booted = self.gateway().connect_to_orchestrator(
orchestrator.gateway_url(), orchestrator.mint_gateway_token())
infra_vm.record_booted_version(want)
# The fresh gateway rootfs minted a new CA and lost every bottle's
# git-gate/token state; reconcile the already-running bottles against
# it so a gateway rebuild doesn't silently break their egress
# (PRD 0081). Cold-boot only — the adopt fast-paths above never reach
# here, so a healthy current gateway is left untouched.
if cold_booted:
from .backend import FirecrackerBottleBackend
FirecrackerBottleBackend().attach_bottled_agents_to_gateway()
return url
def stop(self) -> None:
@@ -117,5 +117,9 @@ class MacosContainerBottleBackend(
def enumerate_active(self) -> Sequence[ActiveAgent]:
return _enumerate.enumerate_active()
def attach_bottled_agents_to_gateway(self) -> None:
from .consolidated_launch import attach_bottled_agents_to_gateway
attach_bottled_agents_to_gateway()
def supervise_mcp_url(self, plan: MacosContainerBottlePlan) -> str:
return plan.agent_supervise_url
@@ -34,6 +34,7 @@ every real command arrives through `container exec`.
from __future__ import annotations
import tempfile
from dataclasses import dataclass
from ...egress import EgressPlan
@@ -43,6 +44,7 @@ from ...orchestrator.client import OrchestratorClient, OrchestratorClientError
from ...orchestrator.reprovision import reprovision_bottles
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
from ..provision_bottle import deprovision_bottle, provision_bottle
from ..util import AGENT_CA_PATH
from . import util as container_mod
from .enumerate import CONTAINER_NAME_PREFIX, EnumerationError, enumerate_active
from .gateway import GATEWAY_NETWORK
@@ -98,6 +100,66 @@ def ensure_gateway(
return endpoint
def _push_ca_to_container(name: str, ca_pem: str) -> None:
"""Replace one running agent container's trusted gateway CA with `ca_pem`
and rebuild its trust store via `container cp` + `container exec`.
Unconditional install there is one gateway, so no fingerprint match is
needed. Raises `ConsolidatedLaunchError` on failure."""
mkdir = container_mod.run_container_argv(
["container", "exec", name, "mkdir", "-p",
"/usr/local/share/ca-certificates"]
)
if mkdir.returncode != 0:
raise ConsolidatedLaunchError(
f"CA push to {name} failed (mkdir): {(mkdir.stderr or '').strip()}"
)
with tempfile.NamedTemporaryFile("w", suffix=".crt") as tmp:
tmp.write(ca_pem)
tmp.flush()
cp = container_mod.run_container_argv(
["container", "cp", tmp.name, f"{name}:{AGENT_CA_PATH}"]
)
if cp.returncode != 0:
raise ConsolidatedLaunchError(
f"CA push to {name} failed (cp): {(cp.stderr or '').strip()}"
)
ex = container_mod.run_container_argv(
["container", "exec", name, "sh", "-c",
f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates"]
)
if ex.returncode != 0:
raise ConsolidatedLaunchError(
f"CA push to {name} failed (update-ca-certificates): "
f"{(ex.stderr or '').strip()}"
)
def attach_bottled_agents_to_gateway(ca_pem: str | None = None) -> None:
"""Reconcile every running agent container against the freshly-(re)created
gateway (PRD 0081): replace each agent's trusted CA with the current gateway
CA, so a gateway rebuild doesn't silently break their TLS egress (#510).
Per-container steps are best-effort: one failure is logged and skipped so it
never blocks the others or the gateway coming up."""
if ca_pem is None:
ca_pem = MacosInfraService().ca_cert_pem()
try:
agents = list(enumerate_active())
except (EnumerationError, OSError) as exc:
info(f"CA reconcile skipped: {exc}")
return
reconciled = 0
for agent in agents:
name = f"{CONTAINER_NAME_PREFIX}{agent.slug}"
try:
_push_ca_to_container(name, ca_pem)
reconciled += 1
except (OSError, ConsolidatedLaunchError) as exc:
info(f"CA reconcile skipped for {name}: {exc}")
if reconciled:
info(f"reconciled gateway CA into {reconciled} running macOS bottle(s)")
def _reprovision_running_bottles(endpoint: GatewayEndpoint) -> None:
"""Recover keys from live Apple containers and restore gateway tokens."""
try:
@@ -200,6 +262,7 @@ __all__ = [
"GatewayEndpoint",
"LaunchContext",
"ensure_gateway",
"attach_bottled_agents_to_gateway",
"live_source_ips",
"register_agent",
"deprovision_consolidated",
@@ -104,11 +104,15 @@ class MacosGateway(Gateway):
container_mod.build_image(
self.image_ref, str(self._repo_root), dockerfile="Dockerfile.gateway")
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None:
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> bool:
"""Bind the gateway to this orchestrator and (re)start it, dual-homed on
the agent + control networks, resolving policy against `orchestrator_url`
(the orchestrator's control-network address — Apple has no container
DNS) and presenting `gateway_token`."""
DNS) and presenting `gateway_token`.
Always returns True: this recreates the gateway container unconditionally
(a cold boot), so the caller reconciles running bottles against it
(PRD 0081)."""
self._orchestrator_url = orchestrator_url
self._gateway_token = gateway_token
# Fail closed on a missing policy source or token: the resolver-only data
@@ -156,6 +160,7 @@ class MacosGateway(Gateway):
f"gateway container failed to start: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
return True
def is_running(self) -> bool:
return container_mod.container_is_running(self.name)
+9 -1
View File
@@ -138,7 +138,15 @@ class MacosInfraService(InfraService):
# `gateway` JWT and hands it to the gateway, which never sees the key
# (#469).
url = orchestrator.url()
gateway.connect_to_orchestrator(url, orchestrator.mint_gateway_token())
cold_booted = gateway.connect_to_orchestrator(
url, orchestrator.mint_gateway_token())
# A (re)created gateway container minted/mounted its CA afresh and lost
# every bottle's per-bottle state; reconcile the already-running bottles
# against it (PRD 0081). Skipped when a healthy current gateway was left
# untouched — no cold boot, nothing to reconcile.
if cold_booted:
from .backend import MacosContainerBottleBackend
MacosContainerBottleBackend().attach_bottled_agents_to_gateway()
return url
def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str:
+8 -2
View File
@@ -122,12 +122,18 @@ class Gateway(abc.ABC):
return
@abc.abstractmethod
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None:
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> bool:
"""Bind the gateway to this orchestrator and bring it up: store the URL +
the pre-minted `gateway` token as instance state, then (re)start the
gateway unit carrying the mitmproxy CA + that token, resolving policy
against `orchestrator_url`. Idempotent a healthy, current gateway on
the same binding is left alone; a changed binding reconciles it."""
the same binding is left alone; a changed binding reconciles it.
Returns True when the gateway was actually (re)brought up (a cold boot,
so its mitmproxy minted a fresh CA and lost its per-bottle state), False
when a healthy current gateway was left untouched. The bring-up flow
uses this as the cold-boot signal that gates the running-bottle
reconcile (`attach_bottled_agents_to_gateway`, PRD 0081)."""
@abc.abstractmethod
def is_running(self) -> bool: