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
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:
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
Reference in New Issue
Block a user