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 2cbb178f88
commit 00418a2834
17 changed files with 151 additions and 151 deletions
+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(),
}