refactor(orchestrator): rename Sidecar -> Gateway for the consolidated data plane

Retire "sidecar" for the consolidated per-host path (PRD 0070 naming
decision): the orchestrator is the umbrella/control plane, and the
egress/git/supervise data-plane unit it runs is the "gateway".

- git mv sidecar.py -> gateway.py and the two integration + one unit test
  files; DockerSidecar->DockerGateway, Sidecar->Gateway,
  SidecarError->GatewayError, SIDECAR_*->GATEWAY_*, ensure_sidecar->
  ensure_gateway, sidecar_status->gateway_status, container name
  bot-bottle-orch-sidecar->bot-bottle-orch-gateway.
- Prose rename across broker/registry/egress/policy_resolver + PRD 0070.
- Preserved: the image name bot-bottle-sidecars, the
  BOT_BOTTLE_SIDECAR_IMAGE env var, Dockerfile.sidecars, and PRD 0069's
  own stage-name cross-references (that doc still uses "sidecar").

No behavior change. Full unit suite green (1679 tests; the 13
test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
2026-07-13 18:12:17 -04:00
parent 47268f6fd6
commit 3178453f83
17 changed files with 151 additions and 151 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
"""mitmproxy addon entrypoint for the egress sidecar (PRD 0017, PRD 0053).
"""mitmproxy addon entrypoint for the egress gateway (PRD 0017, PRD 0053).
Loaded by `mitmdump -s /app/egress_addon.py` inside the
egress container."""
@@ -275,7 +275,7 @@ class EgressAddon:
return
# Strip agent-set Authorization after DLP scan so smuggled tokens
# are caught above; the route may inject sidecar-owned auth below.
# are caught above; the route may inject gateway-owned auth below.
flow.request.headers.pop("authorization", None)
# Build headers mapping for match evaluation
+5 -5
View File
@@ -3,7 +3,7 @@
Split out of `egress_addon.py` so the host's unit tests can
exercise the parse + decision functions without depending on the
`mitmproxy` package. The companion module wraps these with the
`mitmproxy.http.HTTPFlow` API and is loaded inside the sidecar
`mitmproxy.http.HTTPFlow` API and is loaded inside the gateway
container.
Imports: stdlib + `yaml_subset` (which is itself stdlib-only and
@@ -22,7 +22,7 @@ except ImportError: # pragma: no cover - host-side path
from .yaml_subset import YamlSubsetError, parse_yaml_subset
# DLP detector-config parsing lives in a sibling module (also flat-bundled
# into the sidecar — see Dockerfile.sidecars). Re-exported below so existing
# into the gateway — see Dockerfile.sidecars). Re-exported below so existing
# `from egress_addon_core import ON_MATCH_*` callers keep working.
try:
from egress_dlp_config import ( # type: ignore[import-not-found]
@@ -120,7 +120,7 @@ class ScanResult:
reason: str
location: str = "" # where the match was found, e.g. "body", "authorization header"
context: str = "" # surrounding text with the match replaced by REDACT
# Raw substring the detector matched. Used inside the sidecar to key the
# Raw substring the detector matched. Used inside the gateway to key the
# supervisor-approved "safe tokens" set (PRD 0062); never logged or written
# to a proposal file. Empty for structural detectors (CRLF) that carry no
# safelist-able value.
@@ -641,7 +641,7 @@ def outbound_scan_headers(
) -> dict[str, str]:
"""Return request headers that should be included in outbound DLP.
Routes that inject sidecar-owned auth always strip the agent's
Routes that inject gateway-owned auth always strip the agent's
Authorization header before forwarding. Scanning that header first
creates false positives for provider clients that insist on sending
their own bearer-shaped placeholder, while still not changing what
@@ -692,7 +692,7 @@ def scan_outbound(
crlf_text: str | None = None,
) -> ScanResult | None:
# Lazy import to avoid circular deps and keep dlp_detectors optional
# at import time (the sidecar copies it flat alongside this file).
# at import time (the gateway copies it flat alongside this file).
try:
from dlp_detectors import ( # type: ignore[import-not-found]
scan_crlf_injection,
+9 -9
View File
@@ -1,6 +1,6 @@
"""Per-host orchestrator service (PRD 0070).
A single persistent per-host service that will run the sidecar functions
A single persistent per-host service that will run the gateway functions
(egress / git-gate / supervise), coordinate with the console, and broker
agent launches. This package is being built bottom-up, starting with the
backend-neutral "consolidation core" that needs no VM packaging:
@@ -10,15 +10,15 @@ backend-neutral "consolidation core" that needs no VM packaging:
* `broker` the signed, structured launch-request contract + a
`LaunchBroker` (stub for the harness) that verifies
provenance before acting.
* `sidecar` the consolidated per-host sidecar: a `Sidecar`
* `gateway` the consolidated per-host gateway: a `Gateway`
lifecycle contract (idempotent singleton) + a
`DockerSidecar` impl. One sidecar shared by all
`DockerGateway` impl. One gateway shared by all
bottles instead of one per bottle.
* `service` the `Orchestrator`: owns the registry, brokers the
launch lifecycle (launch/teardown), manages the
shared sidecar, attributes.
shared gateway, attributes.
* `control_plane` the HTTP control-plane RPC (launch / teardown /
list / attribute / sidecar / health).
list / attribute / gateway / health).
The actual backend-native launch (a real docker/firecracker broker) and
the egress/git/supervise data plane land in later slices once this core is
@@ -38,7 +38,7 @@ from .broker import (
verify_request,
)
from .docker_broker import DockerBroker, DockerBrokerError
from .sidecar import DockerSidecar, Sidecar, SidecarError
from .gateway import DockerGateway, Gateway, GatewayError
from .service import Orchestrator
from .control_plane import ControlPlaneServer, dispatch, make_server
@@ -52,9 +52,9 @@ __all__ = [
"StubBroker",
"DockerBroker",
"DockerBrokerError",
"Sidecar",
"DockerSidecar",
"SidecarError",
"Gateway",
"DockerGateway",
"GatewayError",
"sign_request",
"verify_request",
"Orchestrator",
+8 -8
View File
@@ -21,7 +21,7 @@ from .control_plane import make_server
from .docker_broker import DockerBroker
from .registry import RegistryStore, default_db_path
from .service import Orchestrator
from .sidecar import DockerSidecar, Sidecar
from .gateway import DockerGateway, Gateway
def main(argv: list[str] | None = None) -> int:
@@ -38,7 +38,7 @@ def main(argv: list[str] | None = None) -> int:
help="launch broker: 'stub' records requests; 'docker' runs containers",
)
parser.add_argument(
"--sidecar", action="store_true",
"--gateway", action="store_true",
help="run one consolidated per-host sidecar bundle (build-if-missing)",
)
args = parser.parse_args(argv)
@@ -51,14 +51,14 @@ def main(argv: list[str] | None = None) -> int:
# anything; 'docker' runs real containers (firecracker drops in later).
secret = secrets.token_bytes(32)
broker: LaunchBroker = DockerBroker(secret) if args.broker == "docker" else StubBroker(secret)
sidecar: Sidecar | None = DockerSidecar() if args.sidecar else None
orchestrator = Orchestrator(registry, broker, secret, sidecar)
gateway: Gateway | None = DockerGateway() if args.gateway else None
orchestrator = Orchestrator(registry, broker, secret, gateway)
# One persistent per-host sidecar, shared by every bottle: build the
# One persistent per-host gateway, shared by every bottle: build the
# bundle image if missing, then bring the singleton up (idempotent).
if sidecar is not None:
orchestrator.ensure_sidecar()
log.info("consolidated sidecar ensured", context={"name": sidecar.name})
if gateway is not None:
orchestrator.ensure_gateway()
log.info("consolidated gateway ensured", context={"name": gateway.name})
server = make_server(orchestrator, host=args.host, port=args.port)
bound_host, bound_port = server.server_address[0], server.server_address[1]
+1 -1
View File
@@ -9,7 +9,7 @@ The request it sends is:
request can't be coerced into launching an arbitrary payload; and
* **signed** wrapped as a compact JWS/JWT the broker verifies before
acting, so a *compromised co-located component* (an agent-facing
sidecar, say) can't forge a launch. This is the concrete form of the
gateway, say) can't forge a launch. This is the concrete form of the
"structured requests only" rule in the PRD security review.
Signing is **HS256** over a secret shared by the orchestrator (signer) and
+4 -4
View File
@@ -5,7 +5,7 @@ The backend-agnostic control-plane RPC (CLI / console -> orchestrator) over
vsock / unix-socket portability caveats):
GET /health -> 200 {"status": "ok"}
GET /sidecar -> 200 {"configured", ["name","running"]}
GET /gateway -> 200 {"configured", ["name","running"]}
GET /bottles -> 200 {"bottles": [ <redacted record>, ...]}
POST /bottles -> 201 {"bottle_id","identity_token"} (launch)
body: {"source_ip", ["image_ref"],
@@ -63,8 +63,8 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
if method == "GET" and route == "/health":
return 200, {"status": "ok"}
if method == "GET" and route == "/sidecar":
return 200, orch.sidecar_status()
if method == "GET" and route == "/gateway":
return 200, orch.gateway_status()
if method == "GET" and route == "/bottles":
return 200, {"bottles": [r.redacted() for r in orch.registry.all()]}
@@ -122,7 +122,7 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
return 200, {"bottle_id": rec.bottle_id}
if method == "POST" and route == "/resolve":
# The per-request lookup the multi-tenant sidecar makes: returns the
# The per-request lookup the multi-tenant gateway makes: returns the
# bottle's policy. identity_token is OPTIONAL — absent means resolve
# by source IP alone (network-layer attribution).
try:
@@ -1,18 +1,18 @@
"""The consolidated per-host sidecar (PRD 0070).
"""The consolidated per-host gateway (PRD 0070).
The core consolidation win: **one** persistent sidecar per host, shared by
The core consolidation win: **one** persistent gateway per host, shared by
every bottle, instead of a sidecar bundle per bottle. It's safe to share
because the attribution invariant (source IP + identity token, see
`registry`) lets the sidecar attribute each request to the right bottle
`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.
`Sidecar` is the backend-neutral lifecycle contract (mirrors `LaunchBroker`):
ensure the single instance is up, report it, tear it down. `DockerSidecar`
is the docker implementation; a firecracker sidecar VM slots in later.
`Gateway` is the backend-neutral lifecycle contract (mirrors `LaunchBroker`):
ensure the single instance is up, report it, tear it down. `DockerGateway`
is the docker implementation; 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 sidecars.
launches never spawn N gateways.
"""
from __future__ import annotations
@@ -23,49 +23,49 @@ from pathlib import Path
from ..docker_cmd import run_docker
SIDECAR_NAME = "bot-bottle-orch-sidecar"
SIDECAR_LABEL = "bot-bottle-orch-sidecar=1"
GATEWAY_NAME = "bot-bottle-orch-gateway"
GATEWAY_LABEL = "bot-bottle-orch-gateway=1"
# The real sidecar-bundle image + its Dockerfile. Kept as a local constant
# rather than imported from backend.docker.sidecar_bundle, 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_SIDECAR_IMAGE.
SIDECAR_BUNDLE_IMAGE = os.environ.get("BOT_BOTTLE_SIDECAR_IMAGE", "bot-bottle-sidecars:latest")
SIDECAR_DOCKERFILE = "Dockerfile.sidecars"
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_SIDECAR_IMAGE", "bot-bottle-sidecars:latest")
GATEWAY_DOCKERFILE = "Dockerfile.sidecars"
_REPO_ROOT = Path(__file__).resolve().parents[2]
class SidecarError(Exception):
"""The shared sidecar failed to build/start/stop (non-zero `docker` exit)."""
class GatewayError(Exception):
"""The shared gateway failed to build/start/stop (non-zero `docker` exit)."""
class Sidecar(abc.ABC):
"""Lifecycle of the single per-host sidecar. Backend-neutral."""
class Gateway(abc.ABC):
"""Lifecycle of the single per-host gateway. Backend-neutral."""
name: str
def ensure_built(self) -> None:
"""Ensure the sidecar's image / rootfs exists, building it if needed.
"""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 sidecar if it isn't already up. Idempotent: a no-op
"""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 sidecar instance is currently up."""
"""True iff the gateway instance is currently up."""
@abc.abstractmethod
def stop(self) -> None:
"""Remove the sidecar. Idempotent — absent is success."""
"""Remove the gateway. Idempotent — absent is success."""
class DockerSidecar(Sidecar):
"""The consolidated sidecar as a single, fixed-name Docker container.
class DockerGateway(Gateway):
"""The consolidated gateway as a single, fixed-name Docker container.
`image_ref` defaults to the real sidecar-bundle image; `ensure_built`
builds it from `Dockerfile.sidecars` when it's missing. (Note: slice 5
@@ -74,11 +74,11 @@ class DockerSidecar(Sidecar):
def __init__(
self,
image_ref: str = SIDECAR_BUNDLE_IMAGE,
image_ref: str = GATEWAY_IMAGE,
*,
name: str = SIDECAR_NAME,
name: str = GATEWAY_NAME,
build_context: Path | None = None,
dockerfile: str | None = SIDECAR_DOCKERFILE,
dockerfile: str | None = GATEWAY_DOCKERFILE,
) -> None:
self.image_ref = image_ref
self.name = name
@@ -101,7 +101,7 @@ class DockerSidecar(Sidecar):
str(self._build_context),
])
if proc.returncode != 0:
raise SidecarError(f"sidecar image build failed: {proc.stderr.strip()}")
raise GatewayError(f"gateway image build failed: {proc.stderr.strip()}")
def is_running(self) -> bool:
proc = run_docker([
@@ -121,19 +121,19 @@ class DockerSidecar(Sidecar):
proc = run_docker([
"docker", "run", "--detach",
"--name", self.name,
"--label", SIDECAR_LABEL,
"--label", GATEWAY_LABEL,
self.image_ref,
])
if proc.returncode != 0:
raise SidecarError(f"sidecar failed to start: {proc.stderr.strip()}")
raise GatewayError(f"gateway failed to start: {proc.stderr.strip()}")
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 SidecarError(f"sidecar failed to stop: {proc.stderr.strip()}")
raise GatewayError(f"gateway failed to stop: {proc.stderr.strip()}")
__all__ = [
"Sidecar", "DockerSidecar", "SidecarError",
"SIDECAR_NAME", "SIDECAR_LABEL", "SIDECAR_BUNDLE_IMAGE",
"Gateway", "DockerGateway", "GatewayError",
"GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE",
]
+5 -5
View File
@@ -67,9 +67,9 @@ class BottleRecord:
# Opaque JSON blob for forward-compat (pool slot, ...) — the registry
# doesn't interpret it, so new fields need no migration.
metadata: str = ""
# The bottle's sidecar policy (opaque JSON — egress allowlist / routes /
# The bottle's gateway policy (opaque JSON — egress allowlist / routes /
# git config). The registry stores and serves it verbatim, keyed by
# source IP; the multi-tenant sidecar interprets it.
# source IP; the multi-tenant gateway interprets it.
policy: str = ""
def redacted(self) -> dict[str, object]:
@@ -102,9 +102,9 @@ _MIGRATIONS = TableMigrations(
# the "one active bottle per source IP" check cheap.
"CREATE INDEX IF NOT EXISTS idx_orchestrator_bottles_source_ip "
"ON orchestrator_bottles (source_ip)",
# v3 — per-bottle policy (opaque JSON the sidecar interprets): the
# v3 — per-bottle policy (opaque JSON the gateway interprets): the
# egress allowlist / routes / git config selected by source IP. The
# multi-tenant sidecar resolves it per request via `attribute`.
# multi-tenant gateway resolves it per request via `attribute`.
"ALTER TABLE orchestrator_bottles ADD COLUMN policy TEXT NOT NULL DEFAULT ''",
],
)
@@ -217,7 +217,7 @@ class RegistryStore(DbStore):
IP, or None if unknown or ambiguous (more than one a
misconfiguration). Safe as the *sole* attributor only where the
source IP is unspoofable (Firecracker `/31` + nft) and the control
plane is reachable only by the trusted sidecar; pair with the
plane is reachable only by the trusted gateway; pair with the
identity token (`attribute`) elsewhere."""
with self._connect() as conn:
rows = conn.execute(
+20 -20
View File
@@ -19,12 +19,12 @@ from __future__ import annotations
from .broker import LaunchBroker, LaunchRequest, sign_request
from .registry import BottleRecord, RegistryStore
from .sidecar import Sidecar
from .gateway import Gateway
class Orchestrator:
"""Owns the registry + brokers launches, and manages the single
consolidated per-host sidecar. Backend-neutral (broker and sidecar
consolidated per-host gateway. Backend-neutral (broker and gateway
abstract the backend-native pieces)."""
def __init__(
@@ -32,12 +32,12 @@ class Orchestrator:
registry: RegistryStore,
broker: LaunchBroker,
sign_secret: bytes,
sidecar: Sidecar | None = None,
gateway: Gateway | None = None,
) -> None:
self.registry = registry
self._broker = broker
self._secret = sign_secret
self._sidecar = sidecar
self._gateway = gateway
def launch_bottle(
self,
@@ -48,7 +48,7 @@ class Orchestrator:
metadata: str = "",
policy: str = "",
) -> BottleRecord:
"""Register a bottle (with its sidecar policy) and broker its launch.
"""Register a bottle (with its gateway policy) and broker its launch.
Rolls the registry entry back if the launch doesn't take, so a
failure leaves no orphan."""
rec = self.registry.register(source_ip, metadata=metadata, policy=policy)
@@ -84,37 +84,37 @@ class Orchestrator:
def resolve(self, source_ip: str, identity_token: str = "") -> BottleRecord | None:
"""Resolve the bottle behind a request — the source-IP-keyed lookup
the multi-tenant sidecar makes per request; the returned record
the multi-tenant gateway makes per request; the returned record
carries its `policy`. With a token, full attribution (source IP +
token); without, network-layer attribution by source IP alone
(valid where the IP is unspoofable and the control plane is
sidecar-only)."""
gateway-only)."""
if identity_token:
return self.registry.attribute(source_ip, identity_token)
return self.registry.by_source_ip(source_ip)
def set_policy(self, bottle_id: str, policy: str) -> bool:
"""Update a bottle's sidecar policy in place (live reload). False if
"""Update a bottle's gateway policy in place (live reload). False if
the bottle is unknown."""
return self.registry.set_policy(bottle_id, policy)
# --- consolidated sidecar ----------------------------------------------
# --- consolidated gateway ----------------------------------------------
def ensure_sidecar(self) -> None:
"""Ensure the single per-host sidecar is built and up (idempotent).
No-op when no sidecar is configured."""
if self._sidecar is not None:
self._sidecar.ensure_built()
self._sidecar.ensure_running()
def ensure_gateway(self) -> None:
"""Ensure the single per-host gateway is built and up (idempotent).
No-op when no gateway is configured."""
if self._gateway is not None:
self._gateway.ensure_built()
self._gateway.ensure_running()
def sidecar_status(self) -> dict[str, object]:
"""Report the shared sidecar for the control plane / console."""
if self._sidecar is None:
def gateway_status(self) -> dict[str, object]:
"""Report the shared gateway for the control plane / console."""
if self._gateway is None:
return {"configured": False}
return {
"configured": True,
"name": self._sidecar.name,
"running": self._sidecar.is_running(),
"name": self._gateway.name,
"running": self._gateway.is_running(),
}
+2 -2
View File
@@ -1,6 +1,6 @@
"""Sidecar-side per-client policy resolver (PRD 0070).
"""Gateway-side per-client policy resolver (PRD 0070).
The consolidated sidecar serves every bottle from one process, so for each
The consolidated gateway serves every bottle from one process, so for each
request it must apply the *calling* bottle's policy, selected by the source
IP the attribution invariant makes unspoofable. This resolves that policy
from the orchestrator's control plane (`POST /resolve`), keyed on the