1a4b390e8a
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>
214 lines
9.1 KiB
Python
214 lines
9.1 KiB
Python
"""The macOS gateway data plane as an Apple container (PRD 0070).
|
|
|
|
`MacosGateway` is the Apple-Container implementation of the backend-neutral
|
|
`Gateway` service. It runs the gateway daemons (egress / git-http / supervise)
|
|
in a single triple-homed container and never opens `bot-bottle.db` — it reaches
|
|
the supervise queue over the control-plane RPC (#469), so it holds no signing
|
|
key, only the pre-minted `gateway` token the orchestrator hands it via
|
|
`connect_to_orchestrator`.
|
|
|
|
This module also holds the pieces the infra service + launch/provision glue
|
|
share: the network names, the gateway image, and the network-creation helper.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from ...gateway import (
|
|
DEFAULT_CA_TIMEOUT_SECONDS,
|
|
GATEWAY_CA_CERT,
|
|
MITMPROXY_HOME,
|
|
Gateway,
|
|
GatewayError,
|
|
GatewayTransport,
|
|
)
|
|
from ...paths import (
|
|
ORCHESTRATOR_AUTH_JWT_ENV,
|
|
host_gateway_ca_dir,
|
|
)
|
|
from ... import resources
|
|
from .. import util as backend_util
|
|
from . import util as container_mod
|
|
|
|
# The shared host-only network the gateway container and every agent bottle sit
|
|
# on. The agent's address here is the attribution key. Distinct from the docker
|
|
# names so both backends can coexist on one host.
|
|
GATEWAY_NETWORK = "bot-bottle-mac-gateway"
|
|
# The NAT network that gives the gateway (and only it) a route out.
|
|
GATEWAY_EGRESS_NETWORK = "bot-bottle-mac-egress"
|
|
# The control network the gateway reaches the orchestrator over (host-only).
|
|
# Only the orchestrator + gateway join it; agents never do, so agents have no
|
|
# route to the control plane (PRD 0070 "Separating the planes").
|
|
CONTROL_NETWORK = "bot-bottle-mac-control"
|
|
|
|
# The gateway (data plane) container. The name predates the split — it was the
|
|
# combined "infra" container — and is kept so callers importing it still resolve
|
|
# the gateway (probe / reprovision attribute against it).
|
|
GATEWAY_NAME = "bot-bottle-mac-infra"
|
|
GATEWAY_LABEL = "bot-bottle-mac-infra=1"
|
|
# The gateway subset the consolidated model runs (no per-bottle git:// daemon).
|
|
GATEWAY_DAEMONS = "egress,git-http,supervise"
|
|
|
|
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
|
|
|
|
|
|
def ensure_networks(
|
|
network: str = GATEWAY_NETWORK,
|
|
egress_network: str = GATEWAY_EGRESS_NETWORK,
|
|
control_network: str = CONTROL_NETWORK,
|
|
) -> None:
|
|
"""Create the shared host-only agent network, the NAT egress network, and
|
|
the host-only control network. Idempotent — `create_network` tolerates
|
|
'already exists'."""
|
|
container_mod.create_network(egress_network)
|
|
container_mod.create_network(network, internal=True)
|
|
container_mod.create_network(control_network, internal=True)
|
|
|
|
|
|
class MacosGateway(Gateway):
|
|
"""The consolidated gateway as a single Apple container, triple-homed on the
|
|
NAT egress, host-only agent, and control networks.
|
|
|
|
`ensure_built` builds `Dockerfile.gateway`; the networks are ensured by the
|
|
composer. `connect_to_orchestrator` runs the container carrying the mitmproxy
|
|
CA + the pre-minted `gateway` token."""
|
|
|
|
def __init__(
|
|
self,
|
|
image_ref: str = GATEWAY_IMAGE,
|
|
*,
|
|
name: str = GATEWAY_NAME,
|
|
network: str = GATEWAY_NETWORK,
|
|
egress_network: str = GATEWAY_EGRESS_NETWORK,
|
|
control_network: str = CONTROL_NETWORK,
|
|
repo_root: Path | None = None,
|
|
) -> None:
|
|
self.image_ref = image_ref
|
|
self.name = name
|
|
self.network = network
|
|
self.egress_network = egress_network
|
|
self.control_network = control_network
|
|
# Build context: the repo root in a checkout, a staged copy from the
|
|
# installed wheel otherwise (bot_bottle.resources).
|
|
self._repo_root = repo_root if repo_root is not None else resources.build_root()
|
|
# Set by `connect_to_orchestrator`: the URL the daemons resolve policy
|
|
# against + the pre-minted `gateway` token they present. The gateway
|
|
# never mints, so it never holds the signing key (#469).
|
|
self._orchestrator_url = ""
|
|
self._gateway_token = ""
|
|
|
|
def ensure_built(self) -> None:
|
|
"""Build the data-plane image from `Dockerfile.gateway`."""
|
|
container_mod.build_image(
|
|
self.image_ref, str(self._repo_root), dockerfile="Dockerfile.gateway")
|
|
|
|
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None:
|
|
"""Bind the gateway to this orchestrator and (re)start it, dual-homed on
|
|
the agent + control networks, resolving policy against `orchestrator_url`
|
|
(the orchestrator's control-network address — Apple has no container
|
|
DNS) and presenting `gateway_token`."""
|
|
self._orchestrator_url = orchestrator_url
|
|
self._gateway_token = gateway_token
|
|
# Fail closed on a missing policy source or token: the resolver-only data
|
|
# plane (PRD 0070) would only crash-loop its daemons without an
|
|
# orchestrator URL, and it cannot mint the token it presents (#469).
|
|
if not self._orchestrator_url:
|
|
raise GatewayError(
|
|
"gateway requires an orchestrator URL to run "
|
|
"(resolver-only data plane; no single-tenant fallback)"
|
|
)
|
|
if not self._gateway_token:
|
|
raise GatewayError(
|
|
"gateway requires a pre-minted `gateway` token to run "
|
|
"(the orchestrator mints it; the gateway never holds the key)"
|
|
)
|
|
container_mod.force_remove_container(self.name)
|
|
argv = [
|
|
"container", "run", "--detach",
|
|
"--name", self.name,
|
|
"--label", "bot-bottle.backend=macos-container",
|
|
"--label", GATEWAY_LABEL,
|
|
# NAT egress FIRST (default route out); the host-only agent network
|
|
# is where agents reach the gateway; the control network reaches the
|
|
# orchestrator.
|
|
"--network", self.egress_network,
|
|
"--network", self.network,
|
|
"--network", self.control_network,
|
|
"--dns", container_mod.dns_server(),
|
|
# The mitmproxy CA on a host bind-mount (survives recreation +
|
|
# volume pruning — issue #450). No DB mount: the data plane never
|
|
# opens bot-bottle.db (#469).
|
|
"--mount",
|
|
container_mod.bind_mount_spec(str(host_gateway_ca_dir()), MITMPROXY_HOME),
|
|
"--env", f"BOT_BOTTLE_GATEWAY_DAEMONS={GATEWAY_DAEMONS}",
|
|
"--env", f"BOT_BOTTLE_ORCHESTRATOR_URL={self._orchestrator_url}",
|
|
# The pre-minted `gateway` JWT (never the signing key). Bare
|
|
# `--env NAME` inherits the value from run_env below.
|
|
"--env", ORCHESTRATOR_AUTH_JWT_ENV,
|
|
self.image_ref,
|
|
]
|
|
run_env = {**os.environ, ORCHESTRATOR_AUTH_JWT_ENV: self._gateway_token}
|
|
result = container_mod.run_container_argv(argv, env=run_env)
|
|
if result.returncode != 0:
|
|
raise GatewayError(
|
|
f"gateway container failed to start: "
|
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
)
|
|
|
|
def is_running(self) -> bool:
|
|
return container_mod.container_is_running(self.name)
|
|
|
|
def stop(self) -> None:
|
|
"""Remove the gateway container (idempotent)."""
|
|
container_mod.force_remove_container(self.name)
|
|
|
|
def address(self) -> str:
|
|
"""The gateway's agent-network address — the proxy / git-http / supervise
|
|
target agents dial (also the source IP the gateway attributes by)."""
|
|
ip = container_mod.try_container_ipv4_on_network(self.name, self.network)
|
|
if not ip:
|
|
raise GatewayError(
|
|
f"gateway {self.name} has no address on {self.network}"
|
|
)
|
|
return ip
|
|
|
|
def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str:
|
|
"""The gateway's mitmproxy CA (PEM) agents install to trust its TLS
|
|
interception. Read from the gateway container; polls because mitmproxy
|
|
writes it a beat after start."""
|
|
def _fetch() -> str | None:
|
|
result = container_mod.run_container_argv(
|
|
["container", "exec", self.name, "cat", GATEWAY_CA_CERT])
|
|
return result.stdout if result.returncode == 0 and result.stdout.strip() else None
|
|
try:
|
|
return backend_util.poll_ca_cert(_fetch, timeout=timeout)
|
|
except TimeoutError as exc:
|
|
raise GatewayError(
|
|
f"gateway CA not available in {self.name} after {timeout:g}s"
|
|
) from exc
|
|
|
|
def provisioning_transport(self) -> GatewayTransport:
|
|
"""The exec/cp transport git-gate provisioning stages per-bottle repos +
|
|
deploy keys through (over the `container` CLI)."""
|
|
# Local import: gateway_transport imports GATEWAY_NAME from this module,
|
|
# so importing MacosGatewayTransport at module scope would cycle.
|
|
from .gateway_transport import MacosGatewayTransport
|
|
return MacosGatewayTransport(self.name)
|
|
|
|
|
|
__all__ = [
|
|
"GATEWAY_NETWORK",
|
|
"GATEWAY_EGRESS_NETWORK",
|
|
"CONTROL_NETWORK",
|
|
"GATEWAY_NAME",
|
|
"GATEWAY_LABEL",
|
|
"GATEWAY_DAEMONS",
|
|
"GATEWAY_IMAGE",
|
|
"GatewayError",
|
|
"DEFAULT_CA_TIMEOUT_SECONDS",
|
|
"ensure_networks",
|
|
"MacosGateway",
|
|
]
|