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
@@ -156,14 +156,17 @@ def launch_consolidated(
they regain egress access before the new bottle is registered.""" they regain egress access before the new bottle is registered."""
service = service or DockerInfraService() service = service or DockerInfraService()
url = service.ensure_running() url = service.ensure_running()
_reprovision_running_bottles(url, network=network, infra_name=infra_name) # Agents attribute against the *gateway* container (data plane), not the
# orchestrator — the two planes are now separate containers.
gateway_name = service.gateway_name
_reprovision_running_bottles(url, network=network, infra_name=gateway_name)
client = OrchestratorClient(url) client = OrchestratorClient(url)
cidr = _network_cidr(network) cidr = _network_cidr(network)
gateway_ip = _container_ip(infra_name, network) gateway_ip = _container_ip(gateway_name, network)
source_ip = next_free_ip(cidr, _network_container_ips(network)) source_ip = next_free_ip(cidr, _network_container_ips(network))
transport = DockerGatewayTransport(infra_name) transport = DockerGatewayTransport(gateway_name)
reg = provision_bottle( reg = provision_bottle(
client, source_ip, egress_plan, git_gate_plan, transport, client, source_ip, egress_plan, git_gate_plan, transport,
image_ref=image_ref, tokens=tokens, image_ref=image_ref, tokens=tokens,
+30
View File
@@ -31,6 +31,7 @@ class DockerGateway(Gateway):
*, *,
name: str = GATEWAY_NAME, name: str = GATEWAY_NAME,
network: str = GATEWAY_NETWORK, network: str = GATEWAY_NETWORK,
control_network: str = "",
orchestrator_url: str = "", orchestrator_url: str = "",
build_context: Path | None = None, build_context: Path | None = None,
dockerfile: str | None = GATEWAY_DOCKERFILE, dockerfile: str | None = GATEWAY_DOCKERFILE,
@@ -39,6 +40,11 @@ class DockerGateway(Gateway):
self.image_ref = image_ref self.image_ref = image_ref
self.name = name self.name = name
self.network = network 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 # The control-plane URL the gateway's data plane resolves per bottle
# against — reached by container name over docker DNS on the shared # against — reached by container name over docker DNS on the shared
# network (container↔container, no host firewall). Mandatory to *run* # network (container↔container, no host firewall). Mandatory to *run*
@@ -164,6 +170,30 @@ class DockerGateway(Gateway):
proc = run_docker(argv, env=run_env) proc = run_docker(argv, env=run_env)
if proc.returncode != 0: if proc.returncode != 0:
raise GatewayError(f"gateway failed to start: {proc.stderr.strip()}") 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: def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str:
"""The gateway's CA certificate (PEM) that agents install to trust its """The gateway's CA certificate (PEM) that agents install to trust its
+154 -130
View File
@@ -1,19 +1,22 @@
"""The per-host infra container for the docker backend (PRD 0070). """The per-host control plane + gateway for the docker backend (PRD 0070).
Runs both the orchestrator control plane and the gateway data plane inside a Runs the orchestrator (control plane) and the gateway (data plane) as **two
single `bot-bottle-infra` container on the shared gateway network — the docker separate containers**, split now that #469 got the DB off the data plane:
analogue of the macOS infra container (`backend/macos_container/infra.py`) and
the Firecracker infra VM (`backend/firecracker/infra_vm.py`). `gateway_init` is
PID 1 and supervises both; the infra container is an idempotent per-host
singleton.
The combined container replaces the prior two-container split * `bot-bottle-orchestrator` — the lean control-plane container. Joins the
(bot-bottle-orchestrator + bot-bottle-orch-gateway). The host CLI reaches the `bot-bottle-orchestrator` **control network** (`--internal`) only, plus a
control plane via a published loopback port; gateway daemons reach it over host-loopback publish for the CLI. Sole opener of `bot-bottle.db`; holds the
127.0.0.1 (same container). signing key.
* `bot-bottle-gateway` — the data-plane container (`DockerGateway`).
**Dual-homed** on the agent-facing `bot-bottle-gateway` network *and* the
control network, so it reaches the orchestrator by name over docker DNS
(`http://bot-bottle-orchestrator:8099`) while agents — which are never on
the control network — cannot reach the control plane at all (the L3 block
Firecracker's nft already has). Holds the `gateway` JWT + the mitmproxy CA.
The shared, backend-neutral pieces (control-plane port, startup timeout, `DockerInfraService` brings both up as an idempotent per-host pair. Callers use
`OrchestratorStartError`, `source_hash`) live in `orchestrator.lifecycle`. `ensure_running()` (returns the host control-plane URL) + `gateway_name` (the
container the launch flow attributes agents against).
""" """
from __future__ import annotations from __future__ import annotations
@@ -25,21 +28,19 @@ import urllib.request
from pathlib import Path from pathlib import Path
from ... import log from ... import log
from ...orchestrator_auth import ROLE_GATEWAY, mint
from .util import run_docker from .util import run_docker
from .gateway import DockerGateway
from ...paths import ( from ...paths import (
ORCHESTRATOR_AUTH_JWT_ENV,
ORCHESTRATOR_TOKEN_ENV, ORCHESTRATOR_TOKEN_ENV,
bot_bottle_root, bot_bottle_root,
host_orchestrator_token, host_orchestrator_token,
host_gateway_ca_dir,
) )
from ...gateway import ( from ...gateway import (
GATEWAY_DOCKERFILE, GATEWAY_DOCKERFILE,
GATEWAY_IMAGE, GATEWAY_IMAGE,
GATEWAY_NAME,
GATEWAY_NETWORK, GATEWAY_NETWORK,
GatewayError, GatewayError,
MITMPROXY_HOME,
) )
from ...orchestrator.lifecycle import ( from ...orchestrator.lifecycle import (
DEFAULT_PORT, DEFAULT_PORT,
@@ -48,39 +49,39 @@ from ...orchestrator.lifecycle import (
source_hash, source_hash,
) )
INFRA_NAME = "bot-bottle-infra" # The control plane's own container + the dedicated control network the gateway
INFRA_LABEL = "bot-bottle-infra=1" # reaches it over. The network is created `--internal`: the orchestrator and
# The combined infra image: gateway data plane + orchestrator content. # gateway join it, agents never do, so agents have no route to the control
# Built from Dockerfile.infra (FROM gateway + COPY --from orchestrator). # plane (PRD 0070 "Separating the planes"). Same name across docker + macOS.
INFRA_IMAGE = os.environ.get("BOT_BOTTLE_INFRA_IMAGE", "bot-bottle-infra:latest") ORCHESTRATOR_NAME = "bot-bottle-orchestrator"
INFRA_DOCKERFILE = "Dockerfile.infra" ORCHESTRATOR_LABEL = "bot-bottle-orchestrator=1"
# Baked as a container label so `ensure_running` can detect whether the ORCHESTRATOR_NETWORK = "bot-bottle-orchestrator"
# running container is executing the current bind-mounted source.
INFRA_SOURCE_HASH_LABEL = "bot-bottle-infra-source-hash"
# Orchestrator image: the single canonical definition of the control-plane
# content (lean: python:3.12-slim + bot_bottle package, no mitmproxy/git).
# Used as a build intermediate: `Dockerfile.infra` COPY --from this image.
ORCHESTRATOR_IMAGE = os.environ.get( ORCHESTRATOR_IMAGE = os.environ.get(
"BOT_BOTTLE_ORCHESTRATOR_IMAGE", "bot-bottle-orchestrator:latest" "BOT_BOTTLE_ORCHESTRATOR_IMAGE", "bot-bottle-orchestrator:latest"
) )
ORCHESTRATOR_DOCKERFILE = "Dockerfile.orchestrator" ORCHESTRATOR_DOCKERFILE = "Dockerfile.orchestrator"
# Baked as a container label so `ensure_running` can detect whether the running
# orchestrator is executing the current bind-mounted source.
ORCHESTRATOR_SOURCE_HASH_LABEL = "bot-bottle-orchestrator-source-hash"
# The gateway daemons + orchestrator the infra container runs. # The bind-mount path for the live control-plane source inside the orchestrator
# BOT_BOTTLE_GATEWAY_DAEMONS listing `orchestrator` opts it in to # container. PYTHONPATH points here so a code change takes effect on the next
# gateway_init's supervise tree (see gateway_init._OPT_IN_DAEMONS). # launch without an image rebuild.
_INFRA_DAEMONS = "egress,git-http,supervise,orchestrator"
# The bind-mount path for the live control-plane source inside the
# container. Separate from /app so the gateway's baked scripts
# (egress_addon.py, egress-entrypoint.sh) are not overlaid.
_SRC_IN_CONTAINER = "/bot-bottle-src" _SRC_IN_CONTAINER = "/bot-bottle-src"
# Bot-bottle host-root bind-mount inside the container (DB + state). The # Bot-bottle host-root bind-mount (DB + state) inside the orchestrator. The
# orchestrator control plane opens bot-bottle.db under here (via BOT_BOTTLE_ROOT # control plane opens bot-bottle.db under here (via BOT_BOTTLE_ROOT ->
# -> host_db_path()); it is the ONLY process in the container with a file # host_db_path()); it is the ONLY container with a handle on it (issue #469).
# handle on it (PRD 0070 / issue #469).
_ROOT_IN_CONTAINER = "/bot-bottle-root" _ROOT_IN_CONTAINER = "/bot-bottle-root"
# The URL the gateway's data plane resolves policy against — the orchestrator
# container by name on the control network (docker DNS, container↔container).
ORCHESTRATOR_URL_IN_NETWORK = f"http://{ORCHESTRATOR_NAME}:{DEFAULT_PORT}"
# Back-compat aliases: the split retired the combined `bot-bottle-infra`
# container, but the fixed-name constants some callers/tests import still map to
# the pair's public identity.
INFRA_NAME = GATEWAY_NAME # the container agents attribute against is the gateway
_HEALTH_POLL_SECONDS = 0.25 _HEALTH_POLL_SECONDS = 0.25
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0 _HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
@@ -88,35 +89,46 @@ _REPO_ROOT = Path(__file__).resolve().parents[3]
class DockerInfraService: class DockerInfraService:
"""Manages the single per-host docker infra container (control plane + """Brings up the per-host control plane + gateway as two containers.
gateway). Callers only need `ensure_running()` + `url`.
`infra_name` / `infra_label` let callers run independent infra containers `orchestrator_name` / `gateway_name` let callers run independent pairs on
on the same host without name collisions (e.g. isolated integration tests one host without name collisions (e.g. isolated integration tests that
that can't share the production INFRA_NAME singleton).""" can't share the production singletons)."""
def __init__( def __init__(
self, self,
*, *,
port: int = DEFAULT_PORT, port: int = DEFAULT_PORT,
network: str = GATEWAY_NETWORK, network: str = GATEWAY_NETWORK,
image: str = INFRA_IMAGE, control_network: str = ORCHESTRATOR_NETWORK,
orchestrator_image: str = ORCHESTRATOR_IMAGE,
gateway_image: str = GATEWAY_IMAGE,
repo_root: Path = _REPO_ROOT, repo_root: Path = _REPO_ROOT,
host_root: Path | None = None, host_root: Path | None = None,
infra_name: str = INFRA_NAME, orchestrator_name: str = ORCHESTRATOR_NAME,
infra_label: str = INFRA_LABEL, orchestrator_label: str = ORCHESTRATOR_LABEL,
gateway_name: str = GATEWAY_NAME,
) -> None: ) -> None:
self.port = port self.port = port
self.network = network self.network = network
self.image = image self.control_network = control_network
self.orchestrator_image = orchestrator_image
self.gateway_image = gateway_image
self._repo_root = repo_root self._repo_root = repo_root
self._host_root = host_root or bot_bottle_root() self._host_root = host_root or bot_bottle_root()
self._infra_name = infra_name self._orchestrator_name = orchestrator_name
self._infra_label = infra_label self._orchestrator_label = orchestrator_label
self._gateway_name = gateway_name
@property
def gateway_name(self) -> str:
"""The gateway container — the address the launch flow attributes agents
against (gateway IP, git-gate provisioning transport, CA fetch)."""
return self._gateway_name
@property @property
def url(self) -> str: def url(self) -> str:
"""Host-side control-plane URL (published loopback port).""" """Host-side control-plane URL (the orchestrator's published loopback)."""
return f"http://127.0.0.1:{self.port}" return f"http://127.0.0.1:{self.port}"
def is_healthy(self, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS) -> bool: def is_healthy(self, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS) -> bool:
@@ -130,36 +142,38 @@ class DockerInfraService:
proc = run_docker(["docker", "ps", "--filter", f"name=^/{name}$", "--format", "{{.Names}}"]) proc = run_docker(["docker", "ps", "--filter", f"name=^/{name}$", "--format", "{{.Names}}"])
return name in proc.stdout.split() return name in proc.stdout.split()
def _infra_source_current(self, current_hash: str) -> bool: def _orchestrator_source_current(self, current_hash: str) -> bool:
"""True iff the running infra container was started from the current """True iff the running orchestrator was started from the current
bind-mounted source. Mirrors the macOS backend's `_source_current`.""" bind-mounted source."""
if not self._container_running(self._infra_name): if not self._container_running(self._orchestrator_name):
return False return False
proc = run_docker([ proc = run_docker([
"docker", "inspect", "--format", "docker", "inspect", "--format",
"{{ index .Config.Labels \"" + INFRA_SOURCE_HASH_LABEL + "\" }}", "{{ index .Config.Labels \"" + ORCHESTRATOR_SOURCE_HASH_LABEL + "\" }}",
self._infra_name, self._orchestrator_name,
]) ])
if proc.returncode != 0: if proc.returncode != 0:
return True # can't compare → don't churn a working container return True # can't compare → don't churn a working container
return proc.stdout.strip() == current_hash return proc.stdout.strip() == current_hash
def _ensure_network(self) -> None: def _ensure_network(self, name: str, *, internal: bool) -> None:
if run_docker(["docker", "network", "inspect", self.network]).returncode == 0: if run_docker(["docker", "network", "inspect", name]).returncode == 0:
return return
proc = run_docker(["docker", "network", "create", self.network]) argv = ["docker", "network", "create"]
if internal:
argv.append("--internal")
argv.append(name)
proc = run_docker(argv)
if proc.returncode != 0 and "already exists" not in proc.stderr: if proc.returncode != 0 and "already exists" not in proc.stderr:
raise GatewayError( raise GatewayError(f"network {name} failed to create: {proc.stderr.strip()}")
f"gateway network {self.network} failed to create: {proc.stderr.strip()}"
)
def _build_images(self) -> None: def _build_images(self) -> None:
"""Build the gateway base, the orchestrator intermediate, then the """Build the gateway (data plane) and orchestrator (control plane)
infra image. All are cache-aware: a no-op when nothing changed.""" images. Cache-aware: a no-op when nothing changed. The combined
`Dockerfile.infra` is no longer built — the planes ship as two images."""
for tag, dockerfile in ( for tag, dockerfile in (
(GATEWAY_IMAGE, GATEWAY_DOCKERFILE), (self.gateway_image, GATEWAY_DOCKERFILE),
(ORCHESTRATOR_IMAGE, ORCHESTRATOR_DOCKERFILE), (self.orchestrator_image, ORCHESTRATOR_DOCKERFILE),
(self.image, INFRA_DOCKERFILE),
): ):
argv = ["docker", "build", "-t", tag, argv = ["docker", "build", "-t", tag,
"-f", str(self._repo_root / dockerfile), "-f", str(self._repo_root / dockerfile),
@@ -170,92 +184,102 @@ class DockerInfraService:
if proc.returncode != 0: if proc.returncode != 0:
raise GatewayError(f"{dockerfile} build failed: {proc.stderr.strip()}") raise GatewayError(f"{dockerfile} build failed: {proc.stderr.strip()}")
def _run_infra_container(self, current_hash: str) -> None: def _run_orchestrator_container(self, current_hash: str) -> None:
"""Start the combined infra container (idempotent: clears a stale """Start the lean control-plane container on the control network only,
fixed-name container first). Labels the container with `current_hash` published to host loopback for the CLI. Idempotent (clears a stale
so a later `ensure_running` can detect a real code change.""" fixed-name container first)."""
self._ensure_network() self._ensure_network(self.control_network, internal=True)
run_docker(["docker", "rm", "--force", self._infra_name]) run_docker(["docker", "rm", "--force", self._orchestrator_name])
_signing_key = host_orchestrator_token() _signing_key = host_orchestrator_token()
proc = run_docker([ proc = run_docker([
"docker", "run", "--detach", "docker", "run", "--detach",
"--name", self._infra_name, "--name", self._orchestrator_name,
"--label", self._infra_label, "--label", self._orchestrator_label,
"--label", f"{INFRA_SOURCE_HASH_LABEL}={current_hash}", "--label", f"{ORCHESTRATOR_SOURCE_HASH_LABEL}={current_hash}",
"--network", self.network, # Control network only — agents are never on it, so they have no
# Host CLI reaches the control plane here (loopback only). # route to the control plane (the L3 block, not just the JWT).
# gateway_init always starts the orchestrator on DEFAULT_PORT (8099) "--network", self.control_network,
# inside the container; self.port is the host-side published port. # Host CLI reaches the control plane here (loopback only). The
# orchestrator listens on the fixed DEFAULT_PORT inside the
# container; self.port is the host-side published port.
"--publish", f"127.0.0.1:{self.port}:{DEFAULT_PORT}", "--publish", f"127.0.0.1:{self.port}:{DEFAULT_PORT}",
# Persist the mitmproxy CA on the host so it survives container # Live control-plane source (code changes without an image rebuild).
# recreation AND docker volume pruning (issue #450): every agent
# trusts this one CA, so a fresh one would break all running bottles.
"--volume", f"{host_gateway_ca_dir()}:{MITMPROXY_HOME}",
# Live control-plane source, mounted to a path that does not
# overlay the gateway's baked /app scripts.
"--volume", f"{self._repo_root}:{_SRC_IN_CONTAINER}:ro", "--volume", f"{self._repo_root}:{_SRC_IN_CONTAINER}:ro",
# PYTHONPATH lets the orchestrator (and other Python daemons)
# import the live source ahead of the installed package.
"--env", f"PYTHONPATH={_SRC_IN_CONTAINER}", "--env", f"PYTHONPATH={_SRC_IN_CONTAINER}",
# Orchestrator registry DB on the host (sole writer: control plane). # Orchestrator registry DB on the host (sole writer: control plane).
"--volume", f"{self._host_root}:{_ROOT_IN_CONTAINER}", "--volume", f"{self._host_root}:{_ROOT_IN_CONTAINER}",
"--env", f"BOT_BOTTLE_ROOT={_ROOT_IN_CONTAINER}", "--env", f"BOT_BOTTLE_ROOT={_ROOT_IN_CONTAINER}",
# Control-plane signing key (orchestrator: verifies tokens) + the # The signing key — held ONLY by the orchestrator (it verifies
# pre-minted `gateway` JWT (data-plane daemons: present it). gateway_init # tokens); the gateway gets the pre-minted `gateway` JWT, never the
# scopes each to its process, so a compromised data-plane daemon never # key (issue #469). Bare `--env NAME` keeps the value off argv.
# sees the key and can't mint a `cli` token (issue #469 review).
"--env", ORCHESTRATOR_TOKEN_ENV, "--env", ORCHESTRATOR_TOKEN_ENV,
"--env", ORCHESTRATOR_AUTH_JWT_ENV, self.orchestrator_image,
# Gateway daemons reach the orchestrator over loopback at its # Dockerfile.orchestrator ENTRYPOINT is `python3 -m bot_bottle.orchestrator`;
# fixed internal port (DEFAULT_PORT), independent of self.port. # these are its args.
"--env", f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{DEFAULT_PORT}", "--host", "0.0.0.0", "--port", str(DEFAULT_PORT), "--broker", "stub",
# Opt the orchestrator into gateway_init's supervise tree. ], env={**os.environ, ORCHESTRATOR_TOKEN_ENV: _signing_key})
"--env", f"BOT_BOTTLE_GATEWAY_DAEMONS={_INFRA_DAEMONS}",
self.image,
], env={
**os.environ,
ORCHESTRATOR_TOKEN_ENV: _signing_key,
ORCHESTRATOR_AUTH_JWT_ENV: mint(ROLE_GATEWAY, _signing_key),
})
if proc.returncode != 0: if proc.returncode != 0:
raise OrchestratorStartError( raise OrchestratorStartError(
f"infra container failed to start: {proc.stderr.strip()}" f"orchestrator container failed to start: {proc.stderr.strip()}"
) )
def _gateway(self) -> DockerGateway:
"""The data-plane gateway, dual-homed on the agent network + the control
network, resolving policy against the orchestrator by name."""
return DockerGateway(
self.gateway_image,
name=self._gateway_name,
network=self.network,
control_network=self.control_network,
orchestrator_url=ORCHESTRATOR_URL_IN_NETWORK,
build_context=self._repo_root,
dockerfile=None, # images are built by _build_images
)
def ensure_running( def ensure_running(
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS, self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
) -> str: ) -> str:
"""Ensure the infra container (control plane + gateway) is up; return """Ensure the orchestrator + gateway containers are up; return the host
the host control-plane URL. Idempotent — a healthy container on current control-plane URL. Idempotent — a healthy orchestrator on current source
source is left untouched. Raises `OrchestratorStartError` on timeout.""" (and a current-image gateway) is left untouched. Raises
`OrchestratorStartError` on control-plane startup timeout."""
self._build_images() self._build_images()
self._ensure_network(self.network, internal=False)
current_hash = source_hash(self._repo_root) current_hash = source_hash(self._repo_root)
if self.is_healthy() and self._infra_source_current(current_hash): if not (self.is_healthy() and self._orchestrator_source_current(current_hash)):
return self.url log.info("starting orchestrator container",
context={"name": self._orchestrator_name})
self._run_orchestrator_container(current_hash)
log.info("starting infra container", context={"name": self._infra_name}) deadline = time.monotonic() + startup_timeout
self._run_infra_container(current_hash) while time.monotonic() < deadline:
if self.is_healthy():
log.info("orchestrator healthy", context={"url": self.url})
break
time.sleep(_HEALTH_POLL_SECONDS)
else:
raise OrchestratorStartError(
f"orchestrator at {self.url} did not become healthy "
f"within {startup_timeout:g}s"
)
deadline = time.monotonic() + startup_timeout # Bring up (or refresh) the gateway once the control plane it resolves
while time.monotonic() < deadline: # against is healthy. `ensure_running` is itself idempotent.
if self.is_healthy(): self._gateway().ensure_running()
log.info("infra container healthy", context={"url": self.url}) return self.url
return self.url
time.sleep(_HEALTH_POLL_SECONDS)
raise OrchestratorStartError(
f"infra container at {self.url} did not become healthy within {startup_timeout:g}s"
)
def stop(self) -> None: def stop(self) -> None:
"""Remove the infra container (idempotent).""" """Remove both containers (idempotent)."""
run_docker(["docker", "rm", "--force", self._infra_name]) run_docker(["docker", "rm", "--force", self._gateway_name])
run_docker(["docker", "rm", "--force", self._orchestrator_name])
__all__ = [ __all__ = [
"DockerInfraService", "DockerInfraService",
"INFRA_NAME", "ORCHESTRATOR_NAME",
"INFRA_IMAGE", "ORCHESTRATOR_NETWORK",
"INFRA_SOURCE_HASH_LABEL",
"ORCHESTRATOR_IMAGE", "ORCHESTRATOR_IMAGE",
"ORCHESTRATOR_SOURCE_HASH_LABEL",
"INFRA_NAME",
] ]
+3 -4
View File
@@ -157,10 +157,9 @@ def launch(
) )
# Step 4: install the SHARED gateway CA into the agent (replaces the # Step 4: install the SHARED gateway CA into the agent (replaces the
# per-bottle CA) — read it out of the running gateway. The consolidated # per-bottle CA) — read it out of the running gateway container
# flow runs the gateway data plane inside the per-host infra container # (INFRA_NAME now aliases the gateway container name; the orchestrator,
# (INFRA_NAME), so read the CA from there, not the legacy standalone # split into its own container, holds no CA).
# gateway container name.
ca_dir = egress_state_dir(plan.slug) / "gateway-ca" ca_dir = egress_state_dir(plan.slug) / "gateway-ca"
ca_dir.mkdir(parents=True, exist_ok=True) ca_dir.mkdir(parents=True, exist_ok=True)
ca_file = ca_dir / "gateway-ca.pem" ca_file = ca_dir / "gateway-ca.pem"
+4 -4
View File
@@ -26,11 +26,11 @@ from pathlib import Path
from ..backend.docker.util import run_docker from ..backend.docker.util import run_docker
from ..paths import host_gateway_ca_dir from ..paths import host_gateway_ca_dir
from ..gateway import GATEWAY_NAME, rotate_gateway_ca from ..gateway import GATEWAY_NAME, rotate_gateway_ca
from ..backend.docker.infra import INFRA_NAME
# The containers whose mitmproxy would still be serving the old CA from memory: # The container whose mitmproxy would still be serving the old CA from memory:
# the consolidated infra container and the standalone per-host gateway. # the per-host gateway (the data plane holds the CA; the split-out orchestrator
_GATEWAY_CONTAINERS = (INFRA_NAME, GATEWAY_NAME) # never does).
_GATEWAY_CONTAINERS = (GATEWAY_NAME,)
def _out(msg: str) -> None: def _out(msg: str) -> None:
@@ -35,7 +35,6 @@ from tests._backend import skip_unless_backend
# image instead of leaking a new dangling tag on every invocation. # image instead of leaking a new dangling tag on every invocation.
_TEST_ORCHESTRATOR_IMAGE = "bot-bottle-orchestrator:itest" _TEST_ORCHESTRATOR_IMAGE = "bot-bottle-orchestrator:itest"
_TEST_GATEWAY_IMAGE = "bot-bottle-gateway:itest" _TEST_GATEWAY_IMAGE = "bot-bottle-gateway:itest"
_TEST_INFRA_IMAGE = "bot-bottle-infra:itest"
@skip_unless_backend("docker") @skip_unless_backend("docker")
@@ -71,17 +70,23 @@ class TestDockerOrchestratorAuthIntegration(unittest.TestCase):
os.environ["BOT_BOTTLE_ROOT"] = cls._tmp.name os.environ["BOT_BOTTLE_ROOT"] = cls._tmp.name
cls.addClassCleanup(_restore_root) 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}" network = f"bot-bottle-net-itest-{suffix}"
control_network = f"bot-bottle-ctrl-itest-{suffix}"
host_root = Path(cls._tmp.name) host_root = Path(cls._tmp.name)
cls.addClassCleanup( 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( cls.svc = DockerInfraService(
infra_name=infra_name, orchestrator_name=orchestrator_name,
gateway_name=gateway_name,
network=network, 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), port=20000 + secrets.randbelow(10000),
host_root=host_root, host_root=host_root,
) )
@@ -94,23 +99,26 @@ class TestDockerOrchestratorAuthIntegration(unittest.TestCase):
@staticmethod @staticmethod
def _teardown_docker( 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: ) -> None:
subprocess.run( for name in (gateway_name, orchestrator_name):
["docker", "rm", "--force", infra_name], subprocess.run(
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ["docker", "rm", "--force", name],
) stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
subprocess.run( )
["docker", "network", "rm", network], for net in (network, control_network):
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, subprocess.run(
) ["docker", "network", "rm", net],
# The infra container (no USER directive) wrote the registry 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 # DB as root into the throwaway host_root; chown it back so the
# (non-root) tempdir cleanup can remove it. Same workaround # (non-root) tempdir cleanup can remove it. Same workaround
# test_multitenant_isolation.py uses for the identical bind mount. # test_multitenant_isolation.py uses for the identical bind mount.
subprocess.run( subprocess.run(
["docker", "run", "--rm", "-v", f"{host_root}:/r", ["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"], f"{os.getuid()}:{os.getgid()}", "/r"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
) )
@@ -82,8 +82,10 @@ class TestMacosReprovision(unittest.TestCase):
class TestDockerReprovision(unittest.TestCase): class TestDockerReprovision(unittest.TestCase):
def test_maps_network_containers_to_keys(self) -> None: def test_maps_network_containers_to_keys(self) -> None:
# The gateway container (excluded — it's on the data network but isn't
# an agent) + one agent + a malformed line.
inspect = _proc(stdout=( inspect = _proc(stdout=(
"bot-bottle-infra 172.18.0.2/16\n" "bot-bottle-orch-gateway 172.18.0.2/16\n"
"bot-bottle-a 172.18.0.3/16\n" "bot-bottle-a 172.18.0.3/16\n"
"malformed\n" "malformed\n"
)) ))
+74 -85
View File
@@ -1,4 +1,9 @@
"""Unit: infra container lifecycle — idempotent singleton (PRD 0070).""" """Unit: orchestrator + gateway container lifecycle (PRD 0070 plane split).
`DockerInfraService` brings up two containers the lean orchestrator on the
`--internal` control network + host loopback, and the dual-homed gateway. These
tests isolate the orchestrator-container logic by mocking `_gateway` (the
gateway runs via `DockerGateway`, exercised in its own test)."""
from __future__ import annotations from __future__ import annotations
@@ -10,15 +15,16 @@ from unittest.mock import MagicMock, Mock, patch
from bot_bottle.gateway import GatewayError from bot_bottle.gateway import GatewayError
from bot_bottle.backend.docker.infra import ( from bot_bottle.backend.docker.infra import (
INFRA_NAME, ORCHESTRATOR_NAME,
INFRA_SOURCE_HASH_LABEL, ORCHESTRATOR_NETWORK,
ORCHESTRATOR_SOURCE_HASH_LABEL,
DockerInfraService, DockerInfraService,
) )
from bot_bottle.gateway import GATEWAY_NAME
from bot_bottle.orchestrator.lifecycle import ( from bot_bottle.orchestrator.lifecycle import (
OrchestratorStartError, OrchestratorStartError,
source_hash, source_hash,
) )
from bot_bottle.paths import GATEWAY_CA_DIRNAME
from tests.unit import use_bottle_root from tests.unit import use_bottle_root
_URLOPEN = "bot_bottle.backend.docker.infra.urllib.request.urlopen" _URLOPEN = "bot_bottle.backend.docker.infra.urllib.request.urlopen"
@@ -43,168 +49,147 @@ class TestDockerInfraService(unittest.TestCase):
self.addCleanup(self._tmp.cleanup) self.addCleanup(self._tmp.cleanup)
self.addCleanup(use_bottle_root(Path(self._tmp.name))) self.addCleanup(use_bottle_root(Path(self._tmp.name)))
self.svc = DockerInfraService(port=8099) self.svc = DockerInfraService(port=8099)
# Isolate the orchestrator-container logic: the gateway is brought up by
# DockerGateway (its own module/run_docker), tested separately.
self.gw = MagicMock()
patcher = patch.object(self.svc, "_gateway", return_value=self.gw)
patcher.start()
self.addCleanup(patcher.stop)
def test_url(self) -> None: def test_url(self) -> None:
self.assertEqual("http://127.0.0.1:8099", self.svc.url) self.assertEqual("http://127.0.0.1:8099", self.svc.url)
def test_gateway_name(self) -> None:
self.assertEqual(GATEWAY_NAME, self.svc.gateway_name)
def test_is_healthy(self) -> None: def test_is_healthy(self) -> None:
with patch(_URLOPEN, return_value=_health(200)): with patch(_URLOPEN, return_value=_health(200)):
self.assertTrue(self.svc.is_healthy()) self.assertTrue(self.svc.is_healthy())
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")): with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
self.assertFalse(self.svc.is_healthy()) self.assertFalse(self.svc.is_healthy())
def test_ensure_running_noop_when_healthy_and_source_unchanged(self) -> None: def test_noop_when_healthy_and_source_unchanged(self) -> None:
# A healthy container on current source is left alone — recreating it # A healthy orchestrator on current source is left alone — recreating it
# on every launch drops in-memory egress tokens (#381). # on every launch drops in-memory egress tokens (#381). The gateway is
# still refreshed (idempotent).
current = source_hash(self.svc._repo_root) current = source_hash(self.svc._repo_root)
calls: list[list[str]] = []
def fake(argv: list[str], **_kw: object) -> Mock: def fake(argv: list[str], **_kw: object) -> Mock:
calls.append(argv)
if argv[:2] == ["docker", "ps"]: if argv[:2] == ["docker", "ps"]:
return _proc(stdout=INFRA_NAME) return _proc(stdout=ORCHESTRATOR_NAME)
if argv[:2] == ["docker", "inspect"]: if argv[:2] == ["docker", "inspect"]:
return _proc(stdout=current) return _proc(stdout=current)
return _proc() return _proc()
with patch(_URLOPEN, return_value=_health(200)), \ with patch(_URLOPEN, return_value=_health(200)), \
patch(_RUN, side_effect=fake), patch(_SLEEP): patch(_RUN, side_effect=fake) as run, patch(_SLEEP):
self.assertEqual(self.svc.url, self.svc.ensure_running()) self.assertEqual(self.svc.url, self.svc.ensure_running())
runs = [c for c in calls if c[:2] == ["docker", "run"]] runs = [c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "run"]]
rms = [c for c in calls if c[:3] == ["docker", "rm", "--force"] and INFRA_NAME in c]
self.assertEqual([], runs) self.assertEqual([], runs)
self.assertEqual([], rms) self.gw.ensure_running.assert_called_once_with()
def test_ensure_running_recreates_when_source_changed(self) -> None:
calls: list[list[str]] = []
def test_recreates_orchestrator_when_source_changed(self) -> None:
def fake(argv: list[str], **_kw: object) -> Mock: def fake(argv: list[str], **_kw: object) -> Mock:
calls.append(argv)
if argv[:2] == ["docker", "ps"]: if argv[:2] == ["docker", "ps"]:
return _proc(stdout=INFRA_NAME) return _proc(stdout=ORCHESTRATOR_NAME)
if argv[:2] == ["docker", "inspect"]: if argv[:2] == ["docker", "inspect"]:
return _proc(stdout="stale-hash") return _proc(stdout="stale-hash")
return _proc() return _proc()
with patch(_URLOPEN, return_value=_health(200)), \ with patch(_URLOPEN, return_value=_health(200)), \
patch(_RUN, side_effect=fake), patch(_SLEEP): patch(_RUN, side_effect=fake) as run, patch(_SLEEP):
self.assertEqual(self.svc.url, self.svc.ensure_running()) self.assertEqual(self.svc.url, self.svc.ensure_running())
runs = [c for c in calls if c[:2] == ["docker", "run"]] runs = [c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "run"]]
self.assertEqual(1, len(runs)) self.assertEqual(1, len(runs))
self.assertIn(INFRA_NAME, runs[0]) self.assertIn(ORCHESTRATOR_NAME, runs[0])
current = source_hash(self.svc._repo_root) current = source_hash(self.svc._repo_root)
self.assertIn(f"{INFRA_SOURCE_HASH_LABEL}={current}", runs[0]) self.assertIn(f"{ORCHESTRATOR_SOURCE_HASH_LABEL}={current}", runs[0])
def test_ensure_running_starts_infra_container_when_absent(self) -> None:
calls: list[list[str]] = []
def test_starts_orchestrator_when_absent(self) -> None:
def fake(argv: list[str], **_kw: object) -> Mock: def fake(argv: list[str], **_kw: object) -> Mock:
calls.append(argv)
if argv[:2] == ["docker", "ps"]: if argv[:2] == ["docker", "ps"]:
return _proc(stdout="") return _proc(stdout="")
return _proc() return _proc()
with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \ with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
patch(_RUN, side_effect=fake), patch(_SLEEP): patch(_RUN, side_effect=fake) as run, patch(_SLEEP):
self.assertEqual(self.svc.url, self.svc.ensure_running()) self.assertEqual(self.svc.url, self.svc.ensure_running())
runs = [c for c in calls if c[:2] == ["docker", "run"]] runs = [c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "run"]]
self.assertEqual(1, len(runs)) self.assertEqual(1, len(runs))
argv = runs[0] argv = runs[0]
self.assertIn(INFRA_NAME, argv) self.assertIn(ORCHESTRATOR_NAME, argv)
# Published on loopback — not exposed on external interfaces. # Control network only — agents are never on it.
self.assertEqual(ORCHESTRATOR_NETWORK, argv[argv.index("--network") + 1])
# Published on loopback for the CLI, mapped to the fixed internal 8099.
self.assertEqual("127.0.0.1:8099:8099", argv[argv.index("--publish") + 1]) self.assertEqual("127.0.0.1:8099:8099", argv[argv.index("--publish") + 1])
# Both processes in one container — no separate entrypoint override. # The lean control plane: no mitmproxy CA mount, no gateway daemons.
self.assertNotIn("--entrypoint", argv) self.assertFalse([a for a in argv if a.endswith(":/home/mitmproxy/.mitmproxy")])
# Gateway daemons + orchestrator explicitly opted in. self.assertNotIn("BOT_BOTTLE_GATEWAY_DAEMONS", " ".join(argv))
daemons_flag = "BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise,orchestrator" # Orchestrator entrypoint args (image ENTRYPOINT is `-m bot_bottle.orchestrator`).
self.assertIn("orchestrator", argv[argv.index(daemons_flag)]) self.assertIn("--broker", argv)
# The mitmproxy CA persists on a HOST bind-mount under the app-data root self.assertIn("stub", argv)
# (not a docker named volume `docker volume prune` would wipe — #450), so # Gateway is brought up after the control plane is healthy.
# a restarted infra container keeps the CA every running bottle trusts. self.gw.ensure_running.assert_called_once_with()
ca_mounts = [a for a in argv if a.endswith(":/home/mitmproxy/.mitmproxy")]
self.assertEqual(1, len(ca_mounts))
src = ca_mounts[0].rsplit(":", 1)[0]
self.assertTrue(src.startswith(self._tmp.name), src)
self.assertTrue(src.endswith("/" + GATEWAY_CA_DIRNAME), src)
def test_ensure_running_builds_all_images(self) -> None:
calls: list[list[str]] = []
def test_builds_gateway_and_orchestrator_images(self) -> None:
def fake(argv: list[str], **_kw: object) -> Mock: def fake(argv: list[str], **_kw: object) -> Mock:
calls.append(argv)
if argv[:2] == ["docker", "ps"]: if argv[:2] == ["docker", "ps"]:
return _proc(stdout="") return _proc(stdout="")
return _proc() return _proc()
with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \ with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
patch(_RUN, side_effect=fake), patch(_SLEEP): patch(_RUN, side_effect=fake) as run, patch(_SLEEP):
self.svc.ensure_running() self.svc.ensure_running()
builds = [c for c in calls if c[:2] == ["docker", "build"]] builds = [c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "build"]]
# Gateway base + orchestrator intermediate + infra image — all three built. # Two images now (the combined Dockerfile.infra is retired).
self.assertEqual(3, len(builds)) self.assertEqual(2, len(builds))
dockerfiles = [next(a for a in b if "Dockerfile" in a) for b in builds] dockerfiles = [next(a for a in b if "Dockerfile" in a) for b in builds]
self.assertIn("Dockerfile.gateway", dockerfiles[0]) self.assertIn("Dockerfile.gateway", dockerfiles[0])
self.assertIn("Dockerfile.orchestrator", dockerfiles[1]) self.assertIn("Dockerfile.orchestrator", dockerfiles[1])
self.assertIn("Dockerfile.infra", dockerfiles[2])
# All three images are distinct.
tags = [b[b.index("-t") + 1] for b in builds]
self.assertEqual(3, len(set(tags)))
def test_publish_maps_host_port_to_fixed_internal_port(self) -> None: def test_publish_maps_host_port_to_fixed_internal_port(self) -> None:
"""A non-default self.port is published to the fixed internal port 8099,
not to self.port:self.port the orchestrator always listens on 8099."""
calls: list[list[str]] = []
def fake(argv: list[str], **_kw: object) -> Mock: def fake(argv: list[str], **_kw: object) -> Mock:
calls.append(argv)
if argv[:2] == ["docker", "ps"]: if argv[:2] == ["docker", "ps"]:
return _proc(stdout="") return _proc(stdout="")
return _proc() return _proc()
svc = DockerInfraService(port=20001) svc = DockerInfraService(port=20001)
with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \ with patch.object(svc, "_gateway", return_value=MagicMock()), \
patch(_RUN, side_effect=fake), patch(_SLEEP): patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
patch(_RUN, side_effect=fake) as run, patch(_SLEEP):
svc.ensure_running() svc.ensure_running()
runs = [c for c in calls if c[:2] == ["docker", "run"]] argv = next(c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "run"])
argv = runs[0]
self.assertEqual("127.0.0.1:20001:8099", argv[argv.index("--publish") + 1]) self.assertEqual("127.0.0.1:20001:8099", argv[argv.index("--publish") + 1])
orch_url = next(a for a in argv if "BOT_BOTTLE_ORCHESTRATOR_URL" in a)
self.assertIn(":8099", orch_url)
def test_ensure_running_raises_on_timeout(self) -> None: def test_raises_on_control_plane_timeout(self) -> None:
with patch(_URLOPEN, side_effect=urllib.error.URLError("down")), \ with patch(_URLOPEN, side_effect=urllib.error.URLError("down")), \
patch(_RUN, return_value=Mock(returncode=0, stdout="", stderr="")), \ patch(_RUN, return_value=_proc()), \
patch(_SLEEP), patch(_MONOTONIC, side_effect=[0.0, 0.5, 2.0]): patch(_SLEEP), patch(_MONOTONIC, side_effect=[0.0, 0.5, 2.0]):
with self.assertRaises(OrchestratorStartError): with self.assertRaises(OrchestratorStartError):
self.svc.ensure_running(startup_timeout=1.0) self.svc.ensure_running(startup_timeout=1.0)
def test_noop_when_healthy_and_inspect_fails(self) -> None: def test_noop_when_healthy_and_inspect_fails(self) -> None:
"""If docker inspect fails (e.g. docker daemon hiccup), leave the
working container alone rather than churning it."""
def fake(argv: list[str], **_kw: object) -> Mock: def fake(argv: list[str], **_kw: object) -> Mock:
if argv[:2] == ["docker", "ps"]: if argv[:2] == ["docker", "ps"]:
return _proc(stdout=INFRA_NAME) return _proc(stdout=ORCHESTRATOR_NAME)
if argv[:2] == ["docker", "inspect"]: if argv[:2] == ["docker", "inspect"]:
return _proc(returncode=1, stderr="daemon error") return _proc(returncode=1, stderr="daemon error")
return _proc() return _proc()
with patch(_URLOPEN, return_value=_health(200)), \ with patch(_URLOPEN, return_value=_health(200)), \
patch(_RUN, side_effect=fake), patch(_SLEEP): patch(_RUN, side_effect=fake) as run, patch(_SLEEP):
self.svc.ensure_running() self.svc.ensure_running()
# no docker run — the working container was left alone runs = [c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "run"]]
self.assertEqual([], runs)
def test_build_failure_raises(self) -> None: def test_build_failure_raises(self) -> None:
with patch(_URLOPEN, side_effect=urllib.error.URLError("down")), \ with patch(_URLOPEN, side_effect=urllib.error.URLError("down")), \
patch(_RUN, return_value=_proc(returncode=1, stderr="no space left on device")): patch(_RUN, return_value=_proc(returncode=1, stderr="no space left")):
with self.assertRaises(GatewayError): with self.assertRaises(GatewayError):
self.svc.ensure_running() self.svc.ensure_running()
def test_ensure_network_creates_if_missing(self) -> None: def test_creates_control_network_internal(self) -> None:
"""If the gateway network doesn't exist yet, create it."""
calls: list[list[str]] = []
def fake(argv: list[str], **_kw: object) -> Mock: def fake(argv: list[str], **_kw: object) -> Mock:
calls.append(argv)
if argv[:3] == ["docker", "network", "inspect"]: if argv[:3] == ["docker", "network", "inspect"]:
return _proc(returncode=1, stderr="not found") return _proc(returncode=1, stderr="not found")
if argv[:2] == ["docker", "ps"]: if argv[:2] == ["docker", "ps"]:
@@ -212,19 +197,23 @@ class TestDockerInfraService(unittest.TestCase):
return _proc() return _proc()
with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \ with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
patch(_RUN, side_effect=fake), patch(_SLEEP): patch(_RUN, side_effect=fake) as run, patch(_SLEEP):
self.svc.ensure_running() self.svc.ensure_running()
creates = [c for c in calls if c[:3] == ["docker", "network", "create"]] creates = [c.args[0] for c in run.call_args_list if c.args[0][:3] == ["docker", "network", "create"]]
self.assertEqual(1, len(creates)) # The control network is created --internal; the data network isn't.
control = [c for c in creates if ORCHESTRATOR_NETWORK in c]
self.assertEqual(1, len(control))
self.assertIn("--internal", control[0])
def test_stop_removes_infra_container(self) -> None: def test_stop_removes_both_containers(self) -> None:
with patch(_RUN) as run: with patch(_RUN) as run:
self.svc.stop() self.svc.stop()
rms = [ rms = [
c.args[0] for c in run.call_args_list c.args[0] for c in run.call_args_list
if c.args[0][:3] == ["docker", "rm", "--force"] if c.args[0][:3] == ["docker", "rm", "--force"]
] ]
self.assertTrue(any(INFRA_NAME in a for a in rms)) self.assertTrue(any(GATEWAY_NAME in a for a in rms))
self.assertTrue(any(ORCHESTRATOR_NAME in a for a in rms))
if __name__ == "__main__": if __name__ == "__main__":