refactor(gateway): move the data-plane daemons into a bot_bottle.gateway package
test / integration-docker (pull_request) Successful in 11s
test / unit (pull_request) Successful in 43s
lint / lint (push) Successful in 56s
test / integration-firecracker (pull_request) Successful in 3m19s
test / coverage (pull_request) Successful in 19s
test / publish-infra (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Successful in 7s
test / integration-docker (pull_request) Successful in 11s
test / unit (pull_request) Successful in 43s
lint / lint (push) Successful in 56s
test / integration-firecracker (pull_request) Successful in 3m19s
test / coverage (pull_request) Successful in 19s
test / publish-infra (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Successful in 7s
Separate the gateway (data plane) from the orchestrator (control plane) at the
module level. The gateway runtime files move out of the package root — and the
backend-neutral Gateway lifecycle ABC + GATEWAY_* constants move out of
orchestrator/ — into a new bot_bottle/gateway/ package:
gateway/__init__.py (was orchestrator/gateway.py: Gateway ABC + consts
+ rotate_gateway_ca)
gateway/gateway_init.py (the PID-1 daemon supervisor)
gateway/egress_addon.py, egress_addon_core.py, egress_dlp_config.py,
dlp_detectors.py (the egress mitmproxy daemon)
gateway/git_http_backend.py (the git-http daemon)
gateway/git_gate_render.py (the git-gate pre-receive rendering)
gateway/supervise_server.py (the supervise MCP daemon)
gateway/policy_resolver.py (the data-plane control-plane RPC client)
orchestrator/ now holds only control-plane files. The shared plan/types/auth
layer (egress.py=EgressPlan, git_gate.py=GitGatePlan, supervise.py,
supervise_types.py, control_auth.py) and the launch-time git-gate provisioning
helpers stay at root, so orchestrator/ and backend/ still own them.
Because these daemons are invoked as `python3 -m bot_bottle.<name>`, loaded flat
by mitmproxy, and referenced in Dockerfile.gateway, the move updates more than
Python imports: the `-m` invocations (firecracker/macOS infra scripts), the
Dockerfile.gateway addon shim + ENTRYPOINT, gateway_init's _DAEMONS module
paths, and the git-gate CGI heredocs all now point at bot_bottle.gateway.*.
No behavior change; full unit suite green (2251).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,7 @@ from .broker import (
|
||||
verify_request,
|
||||
)
|
||||
from .docker_broker import DockerBroker, DockerBrokerError
|
||||
from .gateway import Gateway, GatewayError
|
||||
from ..gateway import Gateway, GatewayError
|
||||
from .service import Orchestrator
|
||||
from .control_plane import ControlPlaneServer, dispatch, make_server
|
||||
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -31,7 +31,7 @@ from ..paths import (
|
||||
host_control_plane_token,
|
||||
host_gateway_ca_dir,
|
||||
)
|
||||
from .gateway import (
|
||||
from ..gateway import (
|
||||
GATEWAY_DOCKERFILE,
|
||||
GATEWAY_IMAGE,
|
||||
GATEWAY_NETWORK,
|
||||
|
||||
@@ -25,7 +25,7 @@ from pathlib import Path
|
||||
|
||||
from ..docker_cmd import run_docker
|
||||
from ..paths import host_gateway_ca_dir
|
||||
from .gateway import GATEWAY_NAME, rotate_gateway_ca
|
||||
from ..gateway import GATEWAY_NAME, rotate_gateway_ca
|
||||
from .lifecycle import INFRA_NAME
|
||||
|
||||
# The containers whose mitmproxy would still be serving the old CA from memory:
|
||||
|
||||
Reference in New Issue
Block a user