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
@@ -35,7 +35,6 @@ from tests._backend import skip_unless_backend
# image instead of leaking a new dangling tag on every invocation.
_TEST_ORCHESTRATOR_IMAGE = "bot-bottle-orchestrator:itest"
_TEST_GATEWAY_IMAGE = "bot-bottle-gateway:itest"
_TEST_INFRA_IMAGE = "bot-bottle-infra:itest"
@skip_unless_backend("docker")
@@ -71,17 +70,23 @@ class TestDockerOrchestratorAuthIntegration(unittest.TestCase):
os.environ["BOT_BOTTLE_ROOT"] = cls._tmp.name
cls.addClassCleanup(_restore_root)
infra_name = f"bot-bottle-infra-itest-{suffix}"
orchestrator_name = f"bot-bottle-orchestrator-itest-{suffix}"
gateway_name = f"bot-bottle-gateway-itest-{suffix}"
network = f"bot-bottle-net-itest-{suffix}"
control_network = f"bot-bottle-ctrl-itest-{suffix}"
host_root = Path(cls._tmp.name)
cls.addClassCleanup(
cls._teardown_docker, infra_name, network, host_root
cls._teardown_docker,
orchestrator_name, gateway_name, network, control_network, host_root,
)
cls.svc = DockerInfraService(
infra_name=infra_name,
orchestrator_name=orchestrator_name,
gateway_name=gateway_name,
network=network,
image=_TEST_INFRA_IMAGE,
control_network=control_network,
orchestrator_image=_TEST_ORCHESTRATOR_IMAGE,
gateway_image=_TEST_GATEWAY_IMAGE,
port=20000 + secrets.randbelow(10000),
host_root=host_root,
)
@@ -94,23 +99,26 @@ class TestDockerOrchestratorAuthIntegration(unittest.TestCase):
@staticmethod
def _teardown_docker(
infra_name: str, network: str, host_root: Path
orchestrator_name: str, gateway_name: str,
network: str, control_network: str, host_root: Path,
) -> None:
subprocess.run(
["docker", "rm", "--force", infra_name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
subprocess.run(
["docker", "network", "rm", network],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
# The infra container (no USER directive) wrote the registry
for name in (gateway_name, orchestrator_name):
subprocess.run(
["docker", "rm", "--force", name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
for net in (network, control_network):
subprocess.run(
["docker", "network", "rm", net],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
# The orchestrator container (no USER directive) wrote the registry
# DB as root into the throwaway host_root; chown it back so the
# (non-root) tempdir cleanup can remove it. Same workaround
# test_multitenant_isolation.py uses for the identical bind mount.
subprocess.run(
["docker", "run", "--rm", "-v", f"{host_root}:/r",
"--entrypoint", "chown", _TEST_INFRA_IMAGE, "-R",
"--entrypoint", "chown", _TEST_ORCHESTRATOR_IMAGE, "-R",
f"{os.getuid()}:{os.getgid()}", "/r"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)