refactor(gateway): move DockerGateway to the backend layer, drop the dead standalone-gateway path
lint / lint (push) Successful in 54s
lint / lint (push) Successful in 54s
The `DockerGateway` container-lifecycle impl now lives in `backend/docker/gateway.py` (the shape backend gateway classes will share); `orchestrator/gateway.py` keeps only the backend-neutral pieces (the `Gateway` ABC, constants, `rotate_gateway_ca`). Delete the standalone-gateway path it was the only consumer of. `--gateway` on `python -m bot_bottle.orchestrator` was invoked nowhere — the production docker flow runs the gateway data plane inside the combined `bot-bottle-infra` container via `OrchestratorService`, never this class. Removing it takes with it `Orchestrator.ensure_gateway()` and the `gateway` ctor arg; `gateway_status()` becomes a stub reporting `configured: false` so the documented `GET /gateway` control-plane route keeps its contract. Fix a latent bug the move surfaced: `launch.py` read the shared gateway CA via `docker exec bot-bottle-orch-gateway`, a container the consolidated flow never creates — so the agent CA install was reaching a nonexistent name. Read it from `bot-bottle-infra` (INFRA_NAME) instead. Repoint the affected tests to the new module; drop the unit test + fake that covered the deleted standalone wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,8 +7,9 @@ because the attribution invariant (source IP + identity token, see
|
||||
so per-bottle policy lives in one long-lived process keyed on who's calling.
|
||||
|
||||
`Gateway` is the backend-neutral lifecycle contract (mirrors `LaunchBroker`):
|
||||
ensure the single instance is up, report it, tear it down. `DockerGateway`
|
||||
is the docker implementation; a firecracker gateway VM slots in later.
|
||||
ensure the single instance is up, report it, tear it down. The docker
|
||||
implementation (`DockerGateway`) lives in `backend/docker/gateway.py`; a
|
||||
firecracker gateway VM slots in later.
|
||||
|
||||
The defining behaviour is **idempotent singleton**: `ensure_running` starts
|
||||
the instance if absent and is a no-op if it's already up, so N bottle
|
||||
@@ -19,20 +20,13 @@ from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from ..control_auth import ROLE_GATEWAY, mint
|
||||
from ..docker_cmd import run_docker
|
||||
from ..paths import (
|
||||
CONTROL_AUTH_JWT_ENV,
|
||||
host_control_plane_token,
|
||||
host_gateway_ca_dir,
|
||||
)
|
||||
from ..paths import host_gateway_ca_dir
|
||||
|
||||
# The gateway's mitmproxy writes its CA a beat after the container starts, so
|
||||
# reads poll for it rather than assuming it's there on a fresh launch.
|
||||
_CA_POLL_SECONDS = 0.5
|
||||
CA_POLL_SECONDS = 0.5
|
||||
DEFAULT_CA_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
GATEWAY_NAME = "bot-bottle-orch-gateway"
|
||||
@@ -66,7 +60,7 @@ GATEWAY_CA_GLOB = "mitmproxy-ca*"
|
||||
# that lands. Env override matches the backend's BOT_BOTTLE_GATEWAY_IMAGE.
|
||||
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
|
||||
GATEWAY_DOCKERFILE = "Dockerfile.gateway"
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def rotate_gateway_ca(ca_dir: Path | None = None) -> list[Path]:
|
||||
@@ -118,180 +112,8 @@ class Gateway(abc.ABC):
|
||||
"""Remove the gateway. Idempotent — absent is success."""
|
||||
|
||||
|
||||
class DockerGateway(Gateway):
|
||||
"""The consolidated gateway as a single, fixed-name Docker container.
|
||||
|
||||
`image_ref` defaults to the gateway data-plane image; `ensure_built`
|
||||
builds it from `Dockerfile.gateway` when it's missing. (Note: slice 5
|
||||
builds + launches the bundle container; wiring its per-bottle,
|
||||
source-IP-keyed config is a later slice — see PRD 0070.)"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
image_ref: str = GATEWAY_IMAGE,
|
||||
*,
|
||||
name: str = GATEWAY_NAME,
|
||||
network: str = GATEWAY_NETWORK,
|
||||
orchestrator_url: str = "",
|
||||
build_context: Path | None = None,
|
||||
dockerfile: str | None = GATEWAY_DOCKERFILE,
|
||||
host_port_bindings: tuple[int, ...] = (),
|
||||
) -> None:
|
||||
self.image_ref = image_ref
|
||||
self.name = name
|
||||
self.network = network
|
||||
# The control-plane URL the gateway's data plane resolves per bottle
|
||||
# against — reached by container name over docker DNS on the shared
|
||||
# network (container↔container, no host firewall). Mandatory to *run*
|
||||
# the gateway (see `ensure_running`); empty is tolerated only for the
|
||||
# construct-then-read-CA path (`ca_cert_pem` on an already-running
|
||||
# container), which never launches a container.
|
||||
self._orchestrator_url = orchestrator_url
|
||||
self._build_context = build_context or _REPO_ROOT
|
||||
self._dockerfile = dockerfile
|
||||
# Ports published on the host (0.0.0.0). Used by the Firecracker
|
||||
# backend's dev-harness gateway so VMs can reach it via their TAP link;
|
||||
# Docker's DNAT + the nft `ct status dnat accept` rule handle the rest.
|
||||
self._host_port_bindings = host_port_bindings
|
||||
|
||||
def image_exists(self) -> bool:
|
||||
return run_docker(["docker", "image", "inspect", self.image_ref]).returncode == 0
|
||||
|
||||
def ensure_built(self) -> None:
|
||||
"""Build the bundle image from its Dockerfile, **cache-aware** — cheap
|
||||
(a cache check) when nothing changed, a real rebuild when the flat
|
||||
sources (egress addon / git-http / policy_resolver / supervise) moved.
|
||||
|
||||
This deliberately builds every time rather than build-if-missing: the
|
||||
per-bottle model kept the image fresh via compose's `build:` on up, and
|
||||
a stale image silently runs the OLD single-tenant daemons. No-op only
|
||||
when no dockerfile is configured (a pre-pulled image). BOT_BOTTLE_NO_CACHE
|
||||
forces a full rebuild (parity with `start --no-cache`)."""
|
||||
if self._dockerfile is None:
|
||||
return
|
||||
argv = ["docker", "build", "-t", self.image_ref,
|
||||
"-f", str(self._build_context / self._dockerfile),
|
||||
str(self._build_context)]
|
||||
if os.environ.get("BOT_BOTTLE_NO_CACHE"):
|
||||
argv.insert(2, "--no-cache")
|
||||
proc = run_docker(argv)
|
||||
if proc.returncode != 0:
|
||||
raise GatewayError(f"gateway image build failed: {proc.stderr.strip()}")
|
||||
|
||||
def is_running(self) -> bool:
|
||||
proc = run_docker([
|
||||
"docker", "ps",
|
||||
"--filter", f"name=^{self.name}$",
|
||||
"--filter", "status=running",
|
||||
"--format", "{{.Names}}",
|
||||
])
|
||||
return self.name in proc.stdout.split()
|
||||
|
||||
def _running_image_is_current(self) -> bool:
|
||||
"""True iff the running gateway was created from the *current*
|
||||
`image_ref`. When `ensure_built` rebuilds the image (a source change),
|
||||
the running container is still the OLD image running the OLD flat
|
||||
daemons — so this is how a rebuild actually takes effect: a mismatch
|
||||
means recreate."""
|
||||
running = run_docker(["docker", "inspect", "--format", "{{.Image}}", self.name])
|
||||
current = run_docker(["docker", "image", "inspect", "--format", "{{.Id}}", self.image_ref])
|
||||
if running.returncode != 0 or current.returncode != 0:
|
||||
return True # can't compare → don't churn a working container
|
||||
return running.stdout.strip() == current.stdout.strip()
|
||||
|
||||
def _ensure_network(self) -> None:
|
||||
"""Create the shared gateway network if it doesn't exist. Idempotent —
|
||||
a concurrent create loses harmlessly (the loser sees 'already exists').
|
||||
Docker picks the subnet; the launcher reads it back to allocate IPs."""
|
||||
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 ensure_running(self) -> None:
|
||||
# Fail closed on a missing policy source. The data-plane daemons are
|
||||
# resolver-only now (PRD 0070) — without an orchestrator URL egress
|
||||
# raises, git-http exits 1, and supervise exits 2 — so launching a
|
||||
# gateway without one would only crash-loop its daemons. Refuse here so
|
||||
# the misconfiguration surfaces as a clear error, not a broken container.
|
||||
if not self._orchestrator_url:
|
||||
raise GatewayError(
|
||||
"gateway requires an orchestrator URL to run "
|
||||
"(resolver-only data plane; no single-tenant fallback)"
|
||||
)
|
||||
# Recreate when the running container's image is stale (a rebuild),
|
||||
# so source changes to the gateway's flat daemons take effect — not
|
||||
# just when the container is absent.
|
||||
if self.is_running() and self._running_image_is_current():
|
||||
return
|
||||
self._ensure_network()
|
||||
# Clear any stale (stopped OR outdated-image) container holding the
|
||||
# fixed name, then start fresh. `rm --force` on an absent name is a
|
||||
# tolerated no-op.
|
||||
run_docker(["docker", "rm", "--force", self.name])
|
||||
argv = [
|
||||
"docker", "run", "--detach",
|
||||
"--name", self.name,
|
||||
"--label", GATEWAY_LABEL,
|
||||
"--network", self.network,
|
||||
# Persist the self-generated CA on the host so it survives both
|
||||
# container recreation AND docker volume pruning (agents trust it)
|
||||
# — see host_gateway_ca_dir / issue #450.
|
||||
"--volume", f"{host_gateway_ca_dir()}:{MITMPROXY_HOME}",
|
||||
# No DB mount: the data plane (egress / supervise / git-gate) reaches
|
||||
# the supervise queue over the control-plane RPC and never opens
|
||||
# bot-bottle.db, so the gateway container gets no file handle on it
|
||||
# (PRD 0070 / issue #469).
|
||||
]
|
||||
for port in self._host_port_bindings:
|
||||
argv += ["--publish", f"0.0.0.0:{port}:{port}"]
|
||||
run_env = dict(os.environ)
|
||||
# The gateway's egress / git / supervise daemons resolve source-IP ->
|
||||
# policy against the control plane per request (guaranteed non-empty by
|
||||
# the check above).
|
||||
argv += ["--env", f"BOT_BOTTLE_ORCHESTRATOR_URL={self._orchestrator_url}"]
|
||||
# ...presenting a role-scoped `gateway` token (a signed JWT minted from
|
||||
# the host signing key) on those calls. The gateway never receives the
|
||||
# signing key — only this pre-minted token, which it cannot rewrite into
|
||||
# a `cli` token, so a compromised data-plane process can't drive the
|
||||
# operator routes (issue #469 review). Bare `--env NAME` keeps the value
|
||||
# off argv / `docker inspect`; only the gateway (not the agent) is given it.
|
||||
argv += ["--env", CONTROL_AUTH_JWT_ENV]
|
||||
run_env[CONTROL_AUTH_JWT_ENV] = mint(ROLE_GATEWAY, host_control_plane_token())
|
||||
argv.append(self.image_ref)
|
||||
proc = run_docker(argv, env=run_env)
|
||||
if proc.returncode != 0:
|
||||
raise GatewayError(f"gateway failed to start: {proc.stderr.strip()}")
|
||||
|
||||
def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str:
|
||||
"""The gateway's CA certificate (PEM) that agents install to trust its
|
||||
TLS interception. mitmproxy generates it a moment after the container
|
||||
starts, so this **polls** for it (up to `timeout`) rather than assuming
|
||||
it's already there on a fresh gateway — raising only if it never
|
||||
appears."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
proc = run_docker(["docker", "exec", self.name, "cat", GATEWAY_CA_CERT])
|
||||
if proc.returncode == 0 and proc.stdout.strip():
|
||||
return proc.stdout
|
||||
if time.monotonic() >= deadline:
|
||||
raise GatewayError(
|
||||
f"gateway CA cert not available after {timeout:g}s: "
|
||||
f"{proc.stderr.strip() or 'empty'}"
|
||||
)
|
||||
time.sleep(_CA_POLL_SECONDS)
|
||||
|
||||
def stop(self) -> None:
|
||||
proc = run_docker(["docker", "rm", "--force", self.name])
|
||||
if proc.returncode != 0 and "No such container" not in proc.stderr:
|
||||
raise GatewayError(f"gateway failed to stop: {proc.stderr.strip()}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Gateway", "DockerGateway", "GatewayError", "rotate_gateway_ca",
|
||||
"Gateway", "GatewayError", "rotate_gateway_ca",
|
||||
"GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE", "GATEWAY_NETWORK",
|
||||
"GATEWAY_CA_CERT", "GATEWAY_CA_GLOB",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user