8a1b833aaa
The shared gateway self-generates a mitmproxy CA that every bottle installs to trust its TLS interception. It was persisted on a Docker named volume, which survives `docker rm` but is silently wiped by `docker volume prune` / `docker system prune --volumes` during routine host maintenance. When that happens the gateway mints a fresh CA on restart, and every already-running bottle fails the TLS handshake even after it re-resolves and reconnects to the moved gateway — a re-attachment blocker distinct from #443/#445. Move CA persistence to a host bind-mount under the app-data root (`bot_bottle_root()/gateway-ca`, via `host_gateway_ca_dir()`), mirroring how the shared DB and control-plane token already live on the host. Docker never prunes a path under the root, and it stays inspectable + rotatable from the host. mitmproxy already adopts an existing CA and generates one only on first run, so the bind-mount gives adopt-existing/generate-on-first-run for free. Add an explicit rollover path: `rotate_gateway_ca()` clears the persisted CA so the next start remints it, and `python -m bot_bottle.orchestrator.rotate_ca` wires that together with dropping the running gateway container (whose mitmproxy still holds the old CA in memory). Rotation stays an operator action — it doesn't auto-re-provision running bottles, which re-attach to pick up the new anchor. Scope: the Docker infra/gateway path (the "infra container" in the report). The macOS (`container`-only volume) and Firecracker (VM-attached ext4) backends persist the CA differently and are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
273 lines
11 KiB
Python
273 lines
11 KiB
Python
"""Orchestrator + gateway lifecycle (PRD 0070, docker slice).
|
|
|
|
Runs both the orchestrator control plane and the gateway data plane inside
|
|
a single `bot-bottle-infra` container on the shared gateway network —
|
|
matching the structure already used by the macOS and Firecracker backends.
|
|
`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 + bot-bottle-orch-gateway). The host CLI reaches
|
|
the control plane via a published loopback port; gateway daemons reach it
|
|
over 127.0.0.1 (same container).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import os
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
from .. import log
|
|
from ..docker_cmd import run_docker
|
|
from ..paths import (
|
|
CONTROL_PLANE_TOKEN_ENV,
|
|
bot_bottle_root,
|
|
host_control_plane_token,
|
|
host_gateway_ca_dir,
|
|
)
|
|
from ..supervise import DB_PATH_IN_CONTAINER
|
|
from .gateway import (
|
|
GATEWAY_DOCKERFILE,
|
|
GATEWAY_IMAGE,
|
|
GATEWAY_NETWORK,
|
|
GatewayError,
|
|
MITMPROXY_HOME,
|
|
_host_db_dir,
|
|
)
|
|
|
|
DEFAULT_PORT = 8099
|
|
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
|
|
|
|
INFRA_NAME = "bot-bottle-infra"
|
|
INFRA_LABEL = "bot-bottle-infra=1"
|
|
# The combined infra image: gateway data plane + orchestrator content.
|
|
# Built from Dockerfile.infra (FROM gateway + COPY --from orchestrator).
|
|
INFRA_IMAGE = os.environ.get("BOT_BOTTLE_INFRA_IMAGE", "bot-bottle-infra:latest")
|
|
INFRA_DOCKERFILE = "Dockerfile.infra"
|
|
# Baked as a container label so `ensure_running` can detect whether the
|
|
# 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(
|
|
"BOT_BOTTLE_ORCHESTRATOR_IMAGE", "bot-bottle-orchestrator:latest"
|
|
)
|
|
ORCHESTRATOR_DOCKERFILE = "Dockerfile.orchestrator"
|
|
|
|
# The gateway daemons + orchestrator the infra container runs.
|
|
# BOT_BOTTLE_GATEWAY_DAEMONS listing `orchestrator` opts it in to
|
|
# gateway_init's supervise tree (see gateway_init._OPT_IN_DAEMONS).
|
|
_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"
|
|
# Bot-bottle host-root bind-mount inside the container (DB + state).
|
|
_ROOT_IN_CONTAINER = "/bot-bottle-root"
|
|
|
|
# The supervise daemon writes proposals into the host DB directory.
|
|
_SUPERVISE_DB_DIR_IN_CONTAINER = os.path.dirname(DB_PATH_IN_CONTAINER)
|
|
|
|
_HEALTH_POLL_SECONDS = 0.25
|
|
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
class OrchestratorStartError(RuntimeError):
|
|
"""The infra container did not become healthy within the timeout."""
|
|
|
|
|
|
def source_hash(repo_root: Path) -> str:
|
|
"""Content hash of the orchestrator's bind-mounted Python source (the
|
|
`bot_bottle` package the control-plane process imports). Changes only
|
|
when the code that would actually run changes — `ensure_running`
|
|
recreates the container on a mismatch so a code change takes effect,
|
|
but leaves a healthy up-to-date container alone to preserve in-memory
|
|
egress tokens."""
|
|
h = hashlib.sha256()
|
|
for path in sorted((repo_root / "bot_bottle").rglob("*.py")):
|
|
h.update(str(path.relative_to(repo_root)).encode())
|
|
h.update(path.read_bytes())
|
|
return h.hexdigest()
|
|
|
|
|
|
class OrchestratorService:
|
|
"""Manages the single per-host infra container (control plane + gateway).
|
|
Callers only need `ensure_running()` + `url`.
|
|
|
|
`infra_name` / `infra_label` let backends run independent infra containers
|
|
on the same host without name collisions (e.g. isolated integration tests
|
|
that can't share the production INFRA_NAME singleton)."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
port: int = DEFAULT_PORT,
|
|
network: str = GATEWAY_NETWORK,
|
|
image: str = INFRA_IMAGE,
|
|
repo_root: Path = _REPO_ROOT,
|
|
host_root: Path | None = None,
|
|
infra_name: str = INFRA_NAME,
|
|
infra_label: str = INFRA_LABEL,
|
|
) -> None:
|
|
self.port = port
|
|
self.network = network
|
|
self.image = image
|
|
self._repo_root = repo_root
|
|
self._host_root = host_root or bot_bottle_root()
|
|
self._infra_name = infra_name
|
|
self._infra_label = infra_label
|
|
|
|
@property
|
|
def url(self) -> str:
|
|
"""Host-side control-plane URL (published loopback port)."""
|
|
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 _infra_source_current(self, current_hash: str) -> bool:
|
|
"""True iff the running infra container was started from the current
|
|
bind-mounted source. Mirrors the macOS backend's `_source_current`."""
|
|
if not self._container_running(self._infra_name):
|
|
return False
|
|
proc = run_docker([
|
|
"docker", "inspect", "--format",
|
|
"{{ index .Config.Labels \"" + INFRA_SOURCE_HASH_LABEL + "\" }}",
|
|
self._infra_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) -> None:
|
|
if run_docker(["docker", "network", "inspect", self.network]).returncode == 0:
|
|
return
|
|
proc = run_docker(["docker", "network", "create", self.network])
|
|
if proc.returncode != 0 and "already exists" not in proc.stderr:
|
|
raise GatewayError(
|
|
f"gateway network {self.network} failed to create: {proc.stderr.strip()}"
|
|
)
|
|
|
|
def _build_images(self) -> None:
|
|
"""Build the gateway base, the orchestrator intermediate, then the
|
|
infra image. All are cache-aware: a no-op when nothing changed."""
|
|
for tag, dockerfile in (
|
|
(GATEWAY_IMAGE, GATEWAY_DOCKERFILE),
|
|
(ORCHESTRATOR_IMAGE, ORCHESTRATOR_DOCKERFILE),
|
|
(self.image, INFRA_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_infra_container(self, current_hash: str) -> None:
|
|
"""Start the combined infra container (idempotent: clears a stale
|
|
fixed-name container first). Labels the container with `current_hash`
|
|
so a later `ensure_running` can detect a real code change."""
|
|
self._ensure_network()
|
|
run_docker(["docker", "rm", "--force", self._infra_name])
|
|
proc = run_docker([
|
|
"docker", "run", "--detach",
|
|
"--name", self._infra_name,
|
|
"--label", self._infra_label,
|
|
"--label", f"{INFRA_SOURCE_HASH_LABEL}={current_hash}",
|
|
"--network", self.network,
|
|
# Host CLI reaches the control plane here (loopback only).
|
|
# gateway_init always starts the orchestrator on DEFAULT_PORT (8099)
|
|
# inside the container; self.port is the host-side published port.
|
|
"--publish", f"127.0.0.1:{self.port}:{DEFAULT_PORT}",
|
|
# Persist the mitmproxy CA on the host so it survives container
|
|
# 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}",
|
|
# Shared supervise DB (same file the operator reads over HTTP).
|
|
"--volume", f"{_host_db_dir()}:{_SUPERVISE_DB_DIR_IN_CONTAINER}",
|
|
"--env", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
|
# 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",
|
|
# PYTHONPATH lets the orchestrator (and other Python daemons)
|
|
# import the live source ahead of the installed package.
|
|
"--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}",
|
|
# Control-plane secret: required by the orchestrator (to enforce)
|
|
# and by the gateway daemons (to present on /resolve calls).
|
|
"--env", CONTROL_PLANE_TOKEN_ENV,
|
|
# Gateway daemons reach the orchestrator over loopback at its
|
|
# fixed internal port (DEFAULT_PORT), independent of self.port.
|
|
"--env", f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{DEFAULT_PORT}",
|
|
# Opt the orchestrator into gateway_init's supervise tree.
|
|
"--env", f"BOT_BOTTLE_GATEWAY_DAEMONS={_INFRA_DAEMONS}",
|
|
self.image,
|
|
], env={**os.environ, CONTROL_PLANE_TOKEN_ENV: host_control_plane_token()})
|
|
if proc.returncode != 0:
|
|
raise OrchestratorStartError(
|
|
f"infra container failed to start: {proc.stderr.strip()}"
|
|
)
|
|
|
|
def ensure_running(
|
|
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
|
) -> str:
|
|
"""Ensure the infra container (control plane + gateway) is up; return
|
|
the host control-plane URL. Idempotent — a healthy container on current
|
|
source is left untouched. Raises `OrchestratorStartError` on timeout."""
|
|
self._build_images()
|
|
|
|
current_hash = source_hash(self._repo_root)
|
|
if self.is_healthy() and self._infra_source_current(current_hash):
|
|
return self.url
|
|
|
|
log.info("starting infra container", context={"name": self._infra_name})
|
|
self._run_infra_container(current_hash)
|
|
|
|
deadline = time.monotonic() + startup_timeout
|
|
while time.monotonic() < deadline:
|
|
if self.is_healthy():
|
|
log.info("infra container healthy", context={"url": 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:
|
|
"""Remove the infra container (idempotent)."""
|
|
run_docker(["docker", "rm", "--force", self._infra_name])
|
|
|
|
|
|
__all__ = [
|
|
"OrchestratorService",
|
|
"OrchestratorStartError",
|
|
"INFRA_NAME",
|
|
"INFRA_IMAGE",
|
|
"INFRA_SOURCE_HASH_LABEL",
|
|
"ORCHESTRATOR_IMAGE",
|
|
"DEFAULT_PORT",
|
|
"DEFAULT_STARTUP_TIMEOUT_SECONDS",
|
|
"source_hash",
|
|
]
|