refactor(gateway): introduce the Gateway service ABC (docker impl)
Replace the docker backend's ad-hoc gateway wiring with the shared `Gateway` service ABC (in the gateway package), the first of the two per-host service classes that supersede the per-backend infra glue. The contract is backend-neutral: `connect_to_orchestrator(url, gateway_token)` binds the gateway to its control plane and brings it up, `address()` reports the agent-facing proxy target, `ca_cert_pem()` vends the mitmproxy CA, and `provisioning_transport()` hands back the exec/cp seam git-gate provisioning stages per-bottle repos + deploy keys through. `GatewayProvisionError` + `GatewayTransport` move to the ABC so every backend shares them. Key trust-model change: the gateway no longer mints its own token. It never holds the signing key (#469), so the orchestrator mints the role-scoped `gateway` JWT and hands it in via `connect_to_orchestrator`; the gateway only injects it. `DockerGateway.ensure_running` becomes `connect_to_orchestrator` (stashing the URL + token as instance state), and the docker launch flow reads `address()` / `provisioning_transport()` off the service rather than re-deriving the container IP + transport itself. macOS + firecracker gateways move under the ABC in following commits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -61,20 +61,6 @@ def _network_cidr(network: str) -> str:
|
||||
return cidr
|
||||
|
||||
|
||||
def _container_ip(name: str, network: str) -> str:
|
||||
"""A container's IPv4 address on `network`, or raise."""
|
||||
proc = run_docker([
|
||||
"docker", "inspect", "--format",
|
||||
f'{{{{(index .NetworkSettings.Networks "{network}").IPAddress}}}}', name,
|
||||
])
|
||||
ip = proc.stdout.strip()
|
||||
if proc.returncode != 0 or not ip:
|
||||
raise ConsolidatedLaunchError(
|
||||
f"container {name} has no address on {network}: {proc.stderr.strip()}"
|
||||
)
|
||||
return ip
|
||||
|
||||
|
||||
def _network_container_ips(network: str) -> list[str]:
|
||||
"""Every address currently assigned on the gateway network — the ground
|
||||
truth for "in use": the infra container and every live agent. Read from
|
||||
@@ -157,16 +143,17 @@ def launch_consolidated(
|
||||
service = service or DockerInfraService()
|
||||
url = service.ensure_running()
|
||||
# Agents attribute against the *gateway* container (data plane), not the
|
||||
# orchestrator — the two planes are now separate containers.
|
||||
gateway_name = service.gateway_name
|
||||
_reprovision_running_bottles(url, network=network, infra_name=gateway_name)
|
||||
# orchestrator — the two planes are now separate containers. Read its
|
||||
# agent-facing address + provisioning transport off the Gateway service.
|
||||
gateway = service.gateway()
|
||||
_reprovision_running_bottles(url, network=network, infra_name=gateway.name)
|
||||
client = OrchestratorClient(url)
|
||||
|
||||
cidr = _network_cidr(network)
|
||||
gateway_ip = _container_ip(gateway_name, network)
|
||||
gateway_ip = gateway.address()
|
||||
source_ip = next_free_ip(cidr, _network_container_ips(network))
|
||||
|
||||
transport = DockerGatewayTransport(gateway_name)
|
||||
transport = gateway.provisioning_transport()
|
||||
reg = provision_bottle(
|
||||
client, source_ip, egress_plan, git_gate_plan, transport,
|
||||
image_ref=image_ref, tokens=tokens,
|
||||
|
||||
@@ -4,17 +4,16 @@ import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from ...orchestrator_auth import ROLE_GATEWAY, mint
|
||||
from .util import run_docker
|
||||
from .gateway_provision import DockerGatewayTransport
|
||||
from ...paths import (
|
||||
ORCHESTRATOR_AUTH_JWT_ENV,
|
||||
host_orchestrator_token,
|
||||
host_gateway_ca_dir,
|
||||
)
|
||||
from ...gateway import (
|
||||
Gateway, GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK, GATEWAY_DOCKERFILE,
|
||||
REPO_ROOT, GATEWAY_LABEL, MITMPROXY_HOME, DEFAULT_CA_TIMEOUT_SECONDS,
|
||||
CA_POLL_SECONDS, GATEWAY_CA_CERT, GatewayError
|
||||
Gateway, GatewayTransport, GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK,
|
||||
GATEWAY_DOCKERFILE, REPO_ROOT, GATEWAY_LABEL, MITMPROXY_HOME,
|
||||
DEFAULT_CA_TIMEOUT_SECONDS, CA_POLL_SECONDS, GATEWAY_CA_CERT, GatewayError
|
||||
)
|
||||
|
||||
class DockerGateway(Gateway):
|
||||
@@ -32,7 +31,6 @@ class DockerGateway(Gateway):
|
||||
name: str = GATEWAY_NAME,
|
||||
network: str = GATEWAY_NETWORK,
|
||||
control_network: str = "",
|
||||
orchestrator_url: str = "",
|
||||
build_context: Path | None = None,
|
||||
dockerfile: str | None = GATEWAY_DOCKERFILE,
|
||||
host_port_bindings: tuple[int, ...] = (),
|
||||
@@ -45,13 +43,13 @@ class DockerGateway(Gateway):
|
||||
# `orchestrator_url` by container name over docker DNS. Agents are never
|
||||
# on it, so they get no route to the control plane (PRD 0070).
|
||||
self._control_network = control_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
|
||||
# The orchestrator binding — set by `connect_to_orchestrator` (the
|
||||
# control-plane URL the data plane resolves against, and the pre-minted
|
||||
# `gateway` token it presents; the gateway never mints, so it never
|
||||
# holds the signing key — #469). Empty until connected; `ca_cert_pem` /
|
||||
# `address` / `stop` work on an already-running gateway without it.
|
||||
self._orchestrator_url = ""
|
||||
self._gateway_token = ""
|
||||
self._build_context = build_context or REPO_ROOT
|
||||
self._dockerfile = dockerfile
|
||||
# Ports published on the host (0.0.0.0). Used by the Firecracker
|
||||
@@ -116,7 +114,13 @@ class DockerGateway(Gateway):
|
||||
f"gateway network {self.network} failed to create: {proc.stderr.strip()}"
|
||||
)
|
||||
|
||||
def ensure_running(self) -> None:
|
||||
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None:
|
||||
# Bind to this orchestrator (PRD 0070): stash the URL its daemons resolve
|
||||
# policy against + the pre-minted `gateway` token they present, then bring
|
||||
# the container up. The gateway never mints, so it holds no signing key —
|
||||
# only this token (#469).
|
||||
self._orchestrator_url = orchestrator_url
|
||||
self._gateway_token = gateway_token
|
||||
# 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
|
||||
@@ -127,6 +131,11 @@ class DockerGateway(Gateway):
|
||||
"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)"
|
||||
)
|
||||
# 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.
|
||||
@@ -165,7 +174,7 @@ class DockerGateway(Gateway):
|
||||
# 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", ORCHESTRATOR_AUTH_JWT_ENV]
|
||||
run_env[ORCHESTRATOR_AUTH_JWT_ENV] = mint(ROLE_GATEWAY, host_orchestrator_token())
|
||||
run_env[ORCHESTRATOR_AUTH_JWT_ENV] = self._gateway_token
|
||||
argv.append(self.image_ref)
|
||||
proc = run_docker(argv, env=run_env)
|
||||
if proc.returncode != 0:
|
||||
@@ -216,4 +225,28 @@ class DockerGateway(Gateway):
|
||||
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()}")
|
||||
raise GatewayError(f"gateway failed to stop: {proc.stderr.strip()}")
|
||||
|
||||
def address(self) -> str:
|
||||
"""The gateway's IPv4 on the agent-facing network (`self.network`) — the
|
||||
proxy target agents dial for egress / git-http / supervise. This is
|
||||
*not* the control network: agents share only this network with the
|
||||
gateway, so its address here is also the source IP the gateway
|
||||
attributes each request by."""
|
||||
proc = run_docker([
|
||||
"docker", "inspect", "--format",
|
||||
f'{{{{(index .NetworkSettings.Networks "{self.network}").IPAddress}}}}',
|
||||
self.name,
|
||||
])
|
||||
ip = proc.stdout.strip()
|
||||
if proc.returncode != 0 or not ip:
|
||||
raise GatewayError(
|
||||
f"gateway {self.name} has no address on {self.network}: "
|
||||
f"{proc.stderr.strip()}"
|
||||
)
|
||||
return ip
|
||||
|
||||
def provisioning_transport(self) -> GatewayTransport:
|
||||
"""The exec/cp transport git-gate provisioning stages per-bottle repos +
|
||||
deploy keys through (over the docker socket)."""
|
||||
return DockerGatewayTransport(self.name)
|
||||
@@ -15,10 +15,10 @@ bottle's push credentials out of another's repos on the shared gateway.
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Protocol
|
||||
|
||||
from .util import run_docker
|
||||
from ...git_gate import GitGatePlan, git_gate_render_provision
|
||||
from ...gateway import GatewayProvisionError, GatewayTransport
|
||||
|
||||
# bottle ids index the gateway's per-bottle repo + creds dirs; they land in
|
||||
# exec/cp path arguments, so validate before any path is built (a traversal
|
||||
@@ -27,25 +27,6 @@ from ...git_gate import GitGatePlan, git_gate_render_provision
|
||||
_SAFE_BOTTLE_ID = re.compile(r"[A-Za-z0-9_-]+")
|
||||
|
||||
|
||||
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 provisioning logic serves the docker gateway
|
||||
(exec/cp over the docker socket) 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 DockerGatewayTransport:
|
||||
"""`GatewayTransport` for the docker gateway container (exec/cp)."""
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ from ...gateway import (
|
||||
GATEWAY_NETWORK,
|
||||
GatewayError,
|
||||
)
|
||||
from ...orchestrator_auth import ROLE_GATEWAY, mint
|
||||
from ...orchestrator.lifecycle import (
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
||||
@@ -223,15 +224,17 @@ class DockerInfraService:
|
||||
f"orchestrator container failed to start: {proc.stderr.strip()}"
|
||||
)
|
||||
|
||||
def _gateway(self) -> DockerGateway:
|
||||
def gateway(self) -> DockerGateway:
|
||||
"""The data-plane gateway, dual-homed on the agent network + the control
|
||||
network, resolving policy against the orchestrator by name."""
|
||||
network, resolving policy against the orchestrator by name. Cheap to
|
||||
reconstruct — the launch flow reads its `address()` / provisioning
|
||||
transport off it, and `ensure_running` connects it to the control
|
||||
plane."""
|
||||
return DockerGateway(
|
||||
self.gateway_image,
|
||||
name=self._gateway_name,
|
||||
network=self.network,
|
||||
control_network=self.control_network,
|
||||
orchestrator_url=ORCHESTRATOR_URL_IN_NETWORK,
|
||||
build_context=self._repo_root,
|
||||
dockerfile=None, # images are built by _build_images
|
||||
)
|
||||
@@ -265,8 +268,14 @@ class DockerInfraService:
|
||||
)
|
||||
|
||||
# Bring up (or refresh) the gateway once the control plane it resolves
|
||||
# against is healthy. `ensure_running` is itself idempotent.
|
||||
self._gateway().ensure_running()
|
||||
# against is healthy. The orchestrator (which holds the signing key)
|
||||
# mints the role-scoped `gateway` JWT here and hands it to the gateway,
|
||||
# which never sees the key (#469). `connect_to_orchestrator` is
|
||||
# idempotent.
|
||||
gateway_token = mint(ROLE_GATEWAY, host_orchestrator_token())
|
||||
self.gateway().connect_to_orchestrator(
|
||||
ORCHESTRATOR_URL_IN_NETWORK, gateway_token,
|
||||
)
|
||||
return self.url
|
||||
|
||||
def stop(self) -> None:
|
||||
|
||||
@@ -6,14 +6,15 @@ 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.
|
||||
`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**: `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.
|
||||
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
|
||||
@@ -21,6 +22,7 @@ from __future__ import annotations
|
||||
import abc
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from ..paths import host_gateway_ca_dir
|
||||
|
||||
@@ -87,21 +89,46 @@ 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):
|
||||
"""Lifecycle of the single per-host gateway. Backend-neutral."""
|
||||
"""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)."""
|
||||
Default: nothing to build (e.g. a stub or a pre-pulled image). Call
|
||||
before `connect_to_orchestrator`."""
|
||||
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."""
|
||||
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:
|
||||
@@ -111,9 +138,25 @@ class Gateway(abc.ABC):
|
||||
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", "rotate_gateway_ca",
|
||||
"Gateway", "GatewayError", "GatewayProvisionError", "GatewayTransport",
|
||||
"rotate_gateway_ca",
|
||||
"GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE", "GATEWAY_NETWORK",
|
||||
"GATEWAY_CA_CERT", "GATEWAY_CA_GLOB",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user