feat(docker): split orchestrator and gateway into separate containers (PRD 0070)
tracker-policy-pr / check-pr (pull_request) Successful in 6s
test / integration-docker (pull_request) Successful in 13s
test / unit (pull_request) Failing after 37s
lint / lint (push) Successful in 57s
test / integration-firecracker (pull_request) Successful in 3m19s
test / coverage (pull_request) Has been skipped
test / publish-infra (pull_request) Has been skipped

Now that #469 got the DB off the data plane, the docker backend runs the
control plane and data plane as two containers instead of the combined
`bot-bottle-infra` container:

  * bot-bottle-orchestrator — the lean control-plane container (image
    Dockerfile.orchestrator, `-m bot_bottle.orchestrator`). Joins the new
    `bot-bottle-orchestrator` control network (`--internal`) only, plus a
    host-loopback publish for the CLI. Sole opener of bot-bottle.db; holds the
    signing key; no CA, no gateway daemons.
  * bot-bottle-orch-gateway — the data-plane container (DockerGateway),
    dual-homed on the agent `bot-bottle-gateway` network AND the control
    network, so it resolves the orchestrator by name
    (http://bot-bottle-orchestrator:8099) while agents — never on the control
    network — have no route to the control plane (the L3 block, not just the
    JWT). Holds the gateway JWT + the mitmproxy CA.

DockerInfraService now orchestrates the pair (builds gateway + orchestrator
images, brings up the orchestrator then the gateway) and exposes gateway_name;
the launch flow attributes agents against the gateway container. DockerGateway
gains a control_network it joins after run. rotate_ca / the CA read target the
gateway container. The combined Dockerfile.infra is no longer built by docker
(firecracker/macOS still use it until their splits).

pyright 0 errors; unit suite green (2273). Integration tests updated for the
two-container shape but need a real-docker run to validate the networking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 02:32:05 -04:00
parent 7e9ad8a78d
commit d62d19a2e7
8 changed files with 298 additions and 243 deletions
+30
View File
@@ -31,6 +31,7 @@ class DockerGateway(Gateway):
*,
name: str = GATEWAY_NAME,
network: str = GATEWAY_NETWORK,
control_network: str = "",
orchestrator_url: str = "",
build_context: Path | None = None,
dockerfile: str | None = GATEWAY_DOCKERFILE,
@@ -39,6 +40,11 @@ class DockerGateway(Gateway):
self.image_ref = image_ref
self.name = name
self.network = network
# When set, the gateway is dual-homed: it also joins this `--internal`
# control network (shared only with the orchestrator) so it can resolve
# `orchestrator_url` by container name over docker DNS. Agents are never
# on it, so they get no route to the control plane (PRD 0070).
self._control_network = control_network
# The control-plane URL the gateway's data plane resolves per bottle
# against — reached by container name over docker DNS on the shared
# network (container↔container, no host firewall). Mandatory to *run*
@@ -164,6 +170,30 @@ class DockerGateway(Gateway):
proc = run_docker(argv, env=run_env)
if proc.returncode != 0:
raise GatewayError(f"gateway failed to start: {proc.stderr.strip()}")
# Dual-home onto the control network so the daemons resolve the
# orchestrator by name. Docker attaches only one network at `run`, so
# the second is a `network connect` — the daemons tolerate the brief
# pre-connect window (they retry /resolve per request).
if self._control_network:
self._connect_control_network()
def _connect_control_network(self) -> None:
"""Ensure the `--internal` control network exists and attach the gateway
to it (idempotent — an already-connected container is a tolerated
no-op)."""
if run_docker(["docker", "network", "inspect", self._control_network]).returncode != 0:
proc = run_docker(["docker", "network", "create", "--internal", self._control_network])
if proc.returncode != 0 and "already exists" not in proc.stderr:
raise GatewayError(
f"control network {self._control_network} failed to create: "
f"{proc.stderr.strip()}"
)
proc = run_docker(["docker", "network", "connect", self._control_network, self.name])
if proc.returncode != 0 and "already exists" not in proc.stderr:
raise GatewayError(
f"gateway failed to join control network {self._control_network}: "
f"{proc.stderr.strip()}"
)
def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str:
"""The gateway's CA certificate (PEM) that agents install to trust its