83ede8f6ec
tracker-policy-pr / check-pr (pull_request) Successful in 14s
test / integration-docker (pull_request) Successful in 18s
test / unit (pull_request) Successful in 54s
lint / lint (push) Successful in 1m6s
test / integration-firecracker (pull_request) Successful in 3m44s
test / coverage (pull_request) Successful in 16s
test / publish-infra (pull_request) Has been skipped
Addresses the review on PR #481. Self-contained wheel (review point 1): the gateway/infra/orchestrator images build from a context that must hold bot_bottle/, pyproject.toml, and the root-level Dockerfiles. Modules previously located these by walking __file__ to the repo root, so an installed wheel (package in site-packages, no repo root) passed `doctor` but failed `start`. - Add bot_bottle/resources.py: build_root() returns the repo root in a checkout (unchanged) or a staged copy from the wheel's bundled _resources/ otherwise; dockerfile()/nix_netpool_module()/ netpool_script() derive from it. - setup.py bundles the root Dockerfiles, nix module, netpool script, and pyproject.toml into bot_bottle/_resources/ at build; MANIFEST.in ships them in the sdist. - Route every _REPO_ROOT/_REPO_DIR call site (docker/macos launch, macos infra, firecracker infra_vm/infra_artifact/setup, orchestrator lifecycle/gateway) through resources. Checkout behavior is unchanged. install.sh prerequisites (review point 2): check for git when installing a git+ spec, and — before the pip fallback — that pip is usable and the interpreter isn't externally managed (PEP 668), pointing at pipx. Tests: test_resources covers checkout + staged-wheel layouts; test_wheel_install builds the wheel, installs it into an isolated venv, and asserts `doctor` runs and build_root() yields a valid context. Running `start` end-to-end still needs a Docker/KVM host (CI). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
162 lines
7.1 KiB
Python
162 lines
7.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 service contract: bind the single instance to
|
|
its orchestrator and bring it up (`connect_to_orchestrator`), report its
|
|
agent-facing address + CA, vend the provisioning transport, tear it down. The
|
|
docker implementation (`DockerGateway`) lives in `backend/docker/gateway.py`;
|
|
the macOS + firecracker gateways slot in beside it.
|
|
|
|
The defining behaviour is **idempotent singleton**: `connect_to_orchestrator`
|
|
starts the instance if absent and is a no-op if it's already up on the same
|
|
binding, so N bottle launches never spawn N gateways.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import abc
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Protocol
|
|
|
|
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"
|
|
|
|
|
|
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 GatewayProvisionError(RuntimeError):
|
|
"""A git-gate provisioning step against the running gateway failed."""
|
|
|
|
|
|
class GatewayTransport(Protocol):
|
|
"""How the launcher stages files + runs commands in the running gateway.
|
|
Backend-neutral so the same git-gate provisioning logic serves the docker
|
|
gateway container (exec/cp over the docker socket), the Apple gateway
|
|
container, and the firecracker gateway VM (over SSH)."""
|
|
|
|
def exec(self, argv: list[str]) -> None:
|
|
"""Run `argv` in the gateway, raising `GatewayProvisionError` on failure."""
|
|
|
|
def cp_into(self, src: str, dest: str) -> None:
|
|
"""Copy host file `src` to `dest` in the gateway, raising on failure."""
|
|
|
|
|
|
class Gateway(abc.ABC):
|
|
"""Provision + interact with the per-host gateway (data plane).
|
|
|
|
One concrete impl per backend (`backend/*/gateway.py`); the host composes it
|
|
with the Orchestrator service. The gateway **never holds the signing key** —
|
|
it receives a pre-minted `gateway` token from the orchestrator and only
|
|
presents it. 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). Call
|
|
before `connect_to_orchestrator`."""
|
|
return
|
|
|
|
@abc.abstractmethod
|
|
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None:
|
|
"""Bind the gateway to this orchestrator and bring it up: store the URL +
|
|
the pre-minted `gateway` token as instance state, then (re)start the
|
|
gateway unit carrying the mitmproxy CA + that token, resolving policy
|
|
against `orchestrator_url`. Idempotent — a healthy, current gateway on
|
|
the same binding is left alone; a changed binding reconciles it."""
|
|
|
|
@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."""
|
|
|
|
@abc.abstractmethod
|
|
def address(self) -> str:
|
|
"""The agent-facing address agents dial for egress / git-http / supervise
|
|
(today's `gateway_ip`)."""
|
|
|
|
@abc.abstractmethod
|
|
def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str:
|
|
"""The mitmproxy CA (PEM) agents install to trust the gateway's TLS
|
|
interception. Polls — mitmproxy writes it a beat after start."""
|
|
|
|
@abc.abstractmethod
|
|
def provisioning_transport(self) -> "GatewayTransport":
|
|
"""The cp/exec transport git-gate provisioning uses to place per-bottle
|
|
repos + deploy keys into the running gateway."""
|
|
|
|
|
|
__all__ = [
|
|
"Gateway", "GatewayError", "GatewayProvisionError", "GatewayTransport",
|
|
"rotate_gateway_ca",
|
|
"GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE", "GATEWAY_NETWORK",
|
|
"GATEWAY_CA_CERT", "GATEWAY_CA_GLOB",
|
|
]
|