d62d19a2e7
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>
286 lines
12 KiB
Python
286 lines
12 KiB
Python
"""The per-host control plane + gateway for the docker backend (PRD 0070).
|
|
|
|
Runs the orchestrator (control plane) and the gateway (data plane) as **two
|
|
separate containers**, split now that #469 got the DB off the data plane:
|
|
|
|
* `bot-bottle-orchestrator` — the lean control-plane container. Joins the
|
|
`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.
|
|
* `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.
|
|
|
|
`DockerInfraService` brings both up as an idempotent per-host pair. Callers use
|
|
`ensure_running()` (returns the host control-plane URL) + `gateway_name` (the
|
|
container the launch flow attributes agents against).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
from ... import log
|
|
from .util import run_docker
|
|
from .gateway import DockerGateway
|
|
from ...paths import (
|
|
ORCHESTRATOR_TOKEN_ENV,
|
|
bot_bottle_root,
|
|
host_orchestrator_token,
|
|
)
|
|
from ...gateway import (
|
|
GATEWAY_DOCKERFILE,
|
|
GATEWAY_IMAGE,
|
|
GATEWAY_NAME,
|
|
GATEWAY_NETWORK,
|
|
GatewayError,
|
|
)
|
|
from ...orchestrator.lifecycle import (
|
|
DEFAULT_PORT,
|
|
DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
|
OrchestratorStartError,
|
|
source_hash,
|
|
)
|
|
|
|
# The control plane's own container + the dedicated control network the gateway
|
|
# reaches it over. The network is created `--internal`: the orchestrator and
|
|
# gateway join it, agents never do, so agents have no route to the control
|
|
# plane (PRD 0070 "Separating the planes"). Same name across docker + macOS.
|
|
ORCHESTRATOR_NAME = "bot-bottle-orchestrator"
|
|
ORCHESTRATOR_LABEL = "bot-bottle-orchestrator=1"
|
|
ORCHESTRATOR_NETWORK = "bot-bottle-orchestrator"
|
|
ORCHESTRATOR_IMAGE = os.environ.get(
|
|
"BOT_BOTTLE_ORCHESTRATOR_IMAGE", "bot-bottle-orchestrator:latest"
|
|
)
|
|
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 bind-mount path for the live control-plane source inside the orchestrator
|
|
# container. PYTHONPATH points here so a code change takes effect on the next
|
|
# launch without an image rebuild.
|
|
_SRC_IN_CONTAINER = "/bot-bottle-src"
|
|
# Bot-bottle host-root bind-mount (DB + state) inside the orchestrator. The
|
|
# control plane opens bot-bottle.db under here (via BOT_BOTTLE_ROOT ->
|
|
# host_db_path()); it is the ONLY container with a handle on it (issue #469).
|
|
_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_REQUEST_TIMEOUT_SECONDS = 1.0
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
|
|
|
|
class DockerInfraService:
|
|
"""Brings up the per-host control plane + gateway as two containers.
|
|
|
|
`orchestrator_name` / `gateway_name` let callers run independent pairs on
|
|
one host without name collisions (e.g. isolated integration tests that
|
|
can't share the production singletons)."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
port: int = DEFAULT_PORT,
|
|
network: str = GATEWAY_NETWORK,
|
|
control_network: str = ORCHESTRATOR_NETWORK,
|
|
orchestrator_image: str = ORCHESTRATOR_IMAGE,
|
|
gateway_image: str = GATEWAY_IMAGE,
|
|
repo_root: Path = _REPO_ROOT,
|
|
host_root: Path | None = None,
|
|
orchestrator_name: str = ORCHESTRATOR_NAME,
|
|
orchestrator_label: str = ORCHESTRATOR_LABEL,
|
|
gateway_name: str = GATEWAY_NAME,
|
|
) -> None:
|
|
self.port = port
|
|
self.network = network
|
|
self.control_network = control_network
|
|
self.orchestrator_image = orchestrator_image
|
|
self.gateway_image = gateway_image
|
|
self._repo_root = repo_root
|
|
self._host_root = host_root or bot_bottle_root()
|
|
self._orchestrator_name = orchestrator_name
|
|
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
|
|
def url(self) -> str:
|
|
"""Host-side control-plane URL (the orchestrator's published loopback)."""
|
|
return f"http://127.0.0.1:{self.port}"
|
|
|
|
def is_healthy(self, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS) -> bool:
|
|
try:
|
|
with urllib.request.urlopen(f"{self.url}/health", timeout=timeout) as resp:
|
|
return resp.status == 200
|
|
except (urllib.error.URLError, TimeoutError, OSError):
|
|
return False
|
|
|
|
def _container_running(self, name: str) -> bool:
|
|
proc = run_docker(["docker", "ps", "--filter", f"name=^/{name}$", "--format", "{{.Names}}"])
|
|
return name in proc.stdout.split()
|
|
|
|
def _orchestrator_source_current(self, current_hash: str) -> bool:
|
|
"""True iff the running orchestrator was started from the current
|
|
bind-mounted source."""
|
|
if not self._container_running(self._orchestrator_name):
|
|
return False
|
|
proc = run_docker([
|
|
"docker", "inspect", "--format",
|
|
"{{ index .Config.Labels \"" + ORCHESTRATOR_SOURCE_HASH_LABEL + "\" }}",
|
|
self._orchestrator_name,
|
|
])
|
|
if proc.returncode != 0:
|
|
return True # can't compare → don't churn a working container
|
|
return proc.stdout.strip() == current_hash
|
|
|
|
def _ensure_network(self, name: str, *, internal: bool) -> None:
|
|
if run_docker(["docker", "network", "inspect", name]).returncode == 0:
|
|
return
|
|
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:
|
|
raise GatewayError(f"network {name} failed to create: {proc.stderr.strip()}")
|
|
|
|
def _build_images(self) -> None:
|
|
"""Build the gateway (data plane) and orchestrator (control plane)
|
|
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 (
|
|
(self.gateway_image, GATEWAY_DOCKERFILE),
|
|
(self.orchestrator_image, ORCHESTRATOR_DOCKERFILE),
|
|
):
|
|
argv = ["docker", "build", "-t", tag,
|
|
"-f", str(self._repo_root / dockerfile),
|
|
str(self._repo_root)]
|
|
if os.environ.get("BOT_BOTTLE_NO_CACHE"):
|
|
argv.insert(2, "--no-cache")
|
|
proc = run_docker(argv)
|
|
if proc.returncode != 0:
|
|
raise GatewayError(f"{dockerfile} build failed: {proc.stderr.strip()}")
|
|
|
|
def _run_orchestrator_container(self, current_hash: str) -> None:
|
|
"""Start the lean control-plane container on the control network only,
|
|
published to host loopback for the CLI. Idempotent (clears a stale
|
|
fixed-name container first)."""
|
|
self._ensure_network(self.control_network, internal=True)
|
|
run_docker(["docker", "rm", "--force", self._orchestrator_name])
|
|
_signing_key = host_orchestrator_token()
|
|
proc = run_docker([
|
|
"docker", "run", "--detach",
|
|
"--name", self._orchestrator_name,
|
|
"--label", self._orchestrator_label,
|
|
"--label", f"{ORCHESTRATOR_SOURCE_HASH_LABEL}={current_hash}",
|
|
# Control network only — agents are never on it, so they have no
|
|
# route to the control plane (the L3 block, not just the JWT).
|
|
"--network", self.control_network,
|
|
# 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}",
|
|
# Live control-plane source (code changes without an image rebuild).
|
|
"--volume", f"{self._repo_root}:{_SRC_IN_CONTAINER}:ro",
|
|
"--env", f"PYTHONPATH={_SRC_IN_CONTAINER}",
|
|
# Orchestrator registry DB on the host (sole writer: control plane).
|
|
"--volume", f"{self._host_root}:{_ROOT_IN_CONTAINER}",
|
|
"--env", f"BOT_BOTTLE_ROOT={_ROOT_IN_CONTAINER}",
|
|
# The signing key — held ONLY by the orchestrator (it verifies
|
|
# tokens); the gateway gets the pre-minted `gateway` JWT, never the
|
|
# key (issue #469). Bare `--env NAME` keeps the value off argv.
|
|
"--env", ORCHESTRATOR_TOKEN_ENV,
|
|
self.orchestrator_image,
|
|
# Dockerfile.orchestrator ENTRYPOINT is `python3 -m bot_bottle.orchestrator`;
|
|
# these are its args.
|
|
"--host", "0.0.0.0", "--port", str(DEFAULT_PORT), "--broker", "stub",
|
|
], env={**os.environ, ORCHESTRATOR_TOKEN_ENV: _signing_key})
|
|
if proc.returncode != 0:
|
|
raise OrchestratorStartError(
|
|
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(
|
|
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
|
) -> str:
|
|
"""Ensure the orchestrator + gateway containers are up; return the host
|
|
control-plane URL. Idempotent — a healthy orchestrator on current source
|
|
(and a current-image gateway) is left untouched. Raises
|
|
`OrchestratorStartError` on control-plane startup timeout."""
|
|
self._build_images()
|
|
self._ensure_network(self.network, internal=False)
|
|
|
|
current_hash = source_hash(self._repo_root)
|
|
if not (self.is_healthy() and self._orchestrator_source_current(current_hash)):
|
|
log.info("starting orchestrator container",
|
|
context={"name": self._orchestrator_name})
|
|
self._run_orchestrator_container(current_hash)
|
|
|
|
deadline = time.monotonic() + startup_timeout
|
|
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"
|
|
)
|
|
|
|
# Bring up (or refresh) the gateway once the control plane it resolves
|
|
# against is healthy. `ensure_running` is itself idempotent.
|
|
self._gateway().ensure_running()
|
|
return self.url
|
|
|
|
def stop(self) -> None:
|
|
"""Remove both containers (idempotent)."""
|
|
run_docker(["docker", "rm", "--force", self._gateway_name])
|
|
run_docker(["docker", "rm", "--force", self._orchestrator_name])
|
|
|
|
|
|
__all__ = [
|
|
"DockerInfraService",
|
|
"ORCHESTRATOR_NAME",
|
|
"ORCHESTRATOR_NETWORK",
|
|
"ORCHESTRATOR_IMAGE",
|
|
"ORCHESTRATOR_SOURCE_HASH_LABEL",
|
|
"INFRA_NAME",
|
|
]
|