dfce3d9505
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>
120 lines
5.1 KiB
Python
120 lines
5.1 KiB
Python
"""The consolidated per-host gateway (PRD 0070).
|
|
|
|
The core consolidation win: **one** persistent gateway per host, shared by
|
|
every bottle, instead of a gateway per bottle. It's safe to share
|
|
because the attribution invariant (source IP + identity token, see
|
|
`registry`) lets the gateway attribute each request to the right bottle —
|
|
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. 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
|
|
launches never spawn N gateways.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import abc
|
|
import os
|
|
from pathlib import Path
|
|
|
|
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
|
|
DEFAULT_CA_TIMEOUT_SECONDS = 30.0
|
|
|
|
GATEWAY_NAME = "bot-bottle-orch-gateway"
|
|
GATEWAY_LABEL = "bot-bottle-orch-gateway=1"
|
|
# The single user-defined network the gateway and every agent bottle share.
|
|
# Agents attach here with a pinned IP and reach the gateway's egress /
|
|
# git-http / supervise ports by its address — no host port publishing, and
|
|
# the source IP the gateway attributes by is the address on this network.
|
|
GATEWAY_NETWORK = "bot-bottle-gateway"
|
|
|
|
# mitmproxy's CA dir in the bundle. The host's gateway-CA dir (see
|
|
# `host_gateway_ca_dir`) is bind-mounted here so the gateway's self-generated
|
|
# CA stays STABLE across container recreation — every agent installs this one
|
|
# CA to trust the shared gateway's TLS interception, so it must not rotate when
|
|
# the gateway restarts. A host bind-mount rather than a named volume: a named
|
|
# volume is silently wiped by `docker volume prune`, minting a fresh CA that
|
|
# breaks every running bottle (issue #450).
|
|
MITMPROXY_HOME = "/home/mitmproxy/.mitmproxy"
|
|
GATEWAY_CA_CERT = f"{MITMPROXY_HOME}/mitmproxy-ca-cert.pem"
|
|
|
|
# The CA material mitmproxy writes into its confdir. mitmproxy reuses these on
|
|
# startup when present and generates them only on first run, so persisting them
|
|
# is what makes the CA stable; deleting them (see `rotate_gateway_ca`) forces a
|
|
# fresh CA on the next start. `mitmproxy-ca.pem` (cert + private key) is the
|
|
# signing identity; the rest are derived encodings agents/clients consume.
|
|
GATEWAY_CA_GLOB = "mitmproxy-ca*"
|
|
|
|
# The gateway data-plane image + its Dockerfile. Kept as a local constant
|
|
# rather than imported from the backend layer, which would drag
|
|
# the whole backend layer into the lean orchestrator (see #359); unify when
|
|
# 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]
|
|
|
|
|
|
def rotate_gateway_ca(ca_dir: Path | None = None) -> list[Path]:
|
|
"""Delete the persisted mitmproxy CA so the next gateway start mints a
|
|
fresh one — the explicit, deliberate CA-rollover path (issue #450).
|
|
|
|
Persistence keeps the CA stable across restarts precisely because mitmproxy
|
|
reuses the on-disk CA; rotation is therefore just removing that material.
|
|
Returns the files removed (empty when there was no CA yet); idempotent.
|
|
|
|
This only clears the on-disk CA. It does NOT stop the running gateway (whose
|
|
mitmproxy still holds the old CA in memory) or re-provision agents — the
|
|
caller recreates the gateway to mint the new CA and re-attaches bottles.
|
|
`rotate-ca` on the orchestrator CLI wires those steps together."""
|
|
ca_dir = ca_dir if ca_dir is not None else host_gateway_ca_dir()
|
|
removed: list[Path] = []
|
|
for path in sorted(ca_dir.glob(GATEWAY_CA_GLOB)):
|
|
path.unlink()
|
|
removed.append(path)
|
|
return removed
|
|
|
|
|
|
class GatewayError(Exception):
|
|
"""The shared gateway failed to build/start/stop (non-zero `docker` exit)."""
|
|
|
|
|
|
class Gateway(abc.ABC):
|
|
"""Lifecycle of the single per-host gateway. Backend-neutral."""
|
|
|
|
name: str
|
|
|
|
def ensure_built(self) -> None:
|
|
"""Ensure the gateway's image / rootfs exists, building it if needed.
|
|
Default: nothing to build (e.g. a stub or a pre-pulled image)."""
|
|
return
|
|
|
|
@abc.abstractmethod
|
|
def ensure_running(self) -> None:
|
|
"""Start the gateway if it isn't already up. Idempotent: a no-op
|
|
when it's already running (that's the whole point — one per host).
|
|
Assumes the image exists — call `ensure_built()` first."""
|
|
|
|
@abc.abstractmethod
|
|
def is_running(self) -> bool:
|
|
"""True iff the gateway instance is currently up."""
|
|
|
|
@abc.abstractmethod
|
|
def stop(self) -> None:
|
|
"""Remove the gateway. Idempotent — absent is success."""
|
|
|
|
|
|
__all__ = [
|
|
"Gateway", "GatewayError", "rotate_gateway_ca",
|
|
"GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE", "GATEWAY_NETWORK",
|
|
"GATEWAY_CA_CERT", "GATEWAY_CA_GLOB",
|
|
]
|