Compare commits

..

2 Commits

Author SHA1 Message Date
didericis 32e9bb95e0 docs(prd): 0070 expand the consolidated-sidecar arc in the roadmap (#352)
test / unit (pull_request) Successful in 1m1s
test / integration (pull_request) Successful in 21s
test / coverage (pull_request) Successful in 1m2s
Split the sidecar work into explicit slices: lifecycle (done), build the
real bundle image (done), source-IP-keyed multi-tenant config +
registration/reload (the functional core, next), and routing agent bottles
through it. Renumber firecracker/macos to 8/9. Slices 1-5 implemented
(#352/#356/#357/#358/#360).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 17:01:27 -04:00
didericis 8e567edde4 docs(prd): 0070 add the slice roadmap incl. consolidated sidecar (#352)
test / unit (pull_request) Successful in 57s
test / integration (pull_request) Successful in 16s
test / coverage (pull_request) Successful in 1m2s
Enumerate the implementation slices in the Sequencing section: dev-harness
core, launch+broker, docker broker, consolidated per-host sidecar (new),
then firecracker and macOS. Slices 1-4 implemented (#352/#356/#357/#358).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 15:30:07 -04:00
25 changed files with 116 additions and 1754 deletions
-1
View File
@@ -64,7 +64,6 @@ COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks
COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
COPY bot_bottle/egress_dlp_config.py /app/egress_dlp_config.py COPY bot_bottle/egress_dlp_config.py /app/egress_dlp_config.py
COPY bot_bottle/egress_addon.py /app/egress_addon.py COPY bot_bottle/egress_addon.py /app/egress_addon.py
COPY bot_bottle/policy_resolver.py /app/policy_resolver.py
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
COPY bot_bottle/paths.py /app/paths.py COPY bot_bottle/paths.py /app/paths.py
-27
View File
@@ -1,27 +0,0 @@
"""Lean, framework-free `docker` subprocess primitive.
Deliberately a top-level module with a single stdlib import so it can be
reused from anywhere without cost. It is intentionally *not* placed in
`backend.docker.util`: importing that module runs `backend/__init__.py`,
which eagerly loads all three bottle backends (docker + firecracker +
macos) plus the manifest/egress/git-gate/supervise framework — ~76 modules
— which would drag the whole backend layer into the deliberately-lean
orchestrator. This primitive stays free of that so both the orchestrator's
docker components and (in time) `backend.docker.util` can share it."""
from __future__ import annotations
import subprocess
def run_docker(argv: list[str]) -> subprocess.CompletedProcess[str]:
"""Run a `docker` command, capturing stdout/stderr as text. Never raises
on a non-zero exit — callers inspect `returncode` / `stderr` so they can
stay fail-closed or tolerate idempotent no-ops (e.g. removing an
already-absent container)."""
return subprocess.run(
argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False,
)
__all__ = ["run_docker"]
+4 -44
View File
@@ -32,7 +32,6 @@ from egress_addon_core import ( # type: ignore[import-not-found] # pylint: dis
is_git_push_request, is_git_push_request,
load_config, load_config,
match_route, match_route,
resolve_client_config,
outbound_scan_headers, outbound_scan_headers,
route_to_yaml_dict, route_to_yaml_dict,
scan_inbound, scan_inbound,
@@ -52,26 +51,11 @@ try:
except ImportError: # pragma: no cover - host-side path except ImportError: # pragma: no cover - host-side path
from bot_bottle import supervise as _sv # type: ignore[import-not-found] from bot_bottle import supervise as _sv # type: ignore[import-not-found]
try:
from policy_resolver import PolicyResolver # type: ignore[import-not-found]
except ImportError: # pragma: no cover - host-side path
from bot_bottle.policy_resolver import PolicyResolver
DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml" DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml"
INTROSPECT_HOST = "_egress.local" INTROSPECT_HOST = "_egress.local"
# Consolidated (multi-tenant) mode: when this points at the per-host
# orchestrator's control plane, the addon resolves each client's Config by
# source IP per request instead of using a single static routes file. Unset
# → legacy per-bottle single-tenant mode (unchanged).
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
# App-layer identity token (defense-in-depth over the source-IP invariant);
# the agent injects it, the addon strips it so it never leaks upstream.
IDENTITY_HEADER = "x-bot-bottle-identity"
# Seconds the egress proxy holds a token-blocked request open waiting for the # Seconds the egress proxy holds a token-blocked request open waiting for the
# operator's supervisor decision (PRD 0062), overridable via env. # operator's supervisor decision (PRD 0062), overridable via env.
DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS = 300.0 DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS = 300.0
@@ -88,17 +72,9 @@ _TOKEN_ALLOW_JUSTIFICATION = (
class EgressAddon: class EgressAddon:
# Class default so addons built via __new__ (e.g. in tests) default to
# single-tenant; __init__ sets the instance attribute for real runs.
_resolver: "PolicyResolver | None" = None
def __init__(self) -> None: def __init__(self) -> None:
self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH) self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH)
self.config: Config = Config(routes=()) self.config: Config = Config(routes=())
# Consolidated mode: resolve per-client Config from the orchestrator.
# Absent → single-tenant (static routes file); behaviour unchanged.
orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip()
self._resolver = PolicyResolver(orch_url) if orch_url else None
# Tokens the operator has approved this session (PRD 0062). In-memory # Tokens the operator has approved this session (PRD 0062). In-memory
# only — a restart re-prompts. Mutated only from the asyncio loop that # only — a restart re-prompts. Mutated only from the asyncio loop that
# runs the addon hooks, so no lock is needed. # runs the addon hooks, so no lock is needed.
@@ -218,20 +194,6 @@ class EgressAddon:
+ "\n" + "\n"
) )
def _active_config(self, flow: http.HTTPFlow) -> Config:
"""The Config to apply to this request. Single-tenant → the static
`self.config`. Consolidated → the calling bottle's Config, resolved
by source IP (fail-closed to deny-all if unattributed). The identity
token, if the agent injected one, is read then stripped so it never
leaks upstream."""
if self._resolver is None:
return self.config
conn = flow.client_conn
client_ip = conn.peername[0] if conn and conn.peername else ""
token = flow.request.headers.get(IDENTITY_HEADER, "")
flow.request.headers.pop(IDENTITY_HEADER, None)
return resolve_client_config(self._resolver, client_ip, token)
async def request(self, flow: http.HTTPFlow) -> None: async def request(self, flow: http.HTTPFlow) -> None:
request_path, _, query = flow.request.path.partition("?") request_path, _, query = flow.request.path.partition("?")
@@ -239,12 +201,10 @@ class EgressAddon:
self._serve_introspection(flow, request_path) self._serve_introspection(flow, request_path)
return return
config = self._active_config(flow)
# DLP outbound scan BEFORE stripping auth — catches tokens the # DLP outbound scan BEFORE stripping auth — catches tokens the
# agent tried to smuggle in any header, path, query param, or body. # agent tried to smuggle in any header, path, query param, or body.
# Hostname is included to catch DNS-tunnelling exfiltration attempts. # Hostname is included to catch DNS-tunnelling exfiltration attempts.
route = match_route(config.routes, flow.request.pretty_host) route = match_route(self.config.routes, flow.request.pretty_host)
if route is not None: if route is not None:
if not await self._handle_outbound_dlp(flow, route): if not await self._handle_outbound_dlp(flow, route):
return return
@@ -264,7 +224,7 @@ class EgressAddon:
if is_git_fetch_request(request_path, query): if is_git_fetch_request(request_path, query):
git_decision = decide_git_fetch( git_decision = decide_git_fetch(
config.routes, flow.request.pretty_host, self.config.routes, flow.request.pretty_host,
) )
if git_decision.action == "block": if git_decision.action == "block":
self._block( self._block(
@@ -282,7 +242,7 @@ class EgressAddon:
req_headers = {k.lower(): v for k, v in flow.request.headers.items()} req_headers = {k.lower(): v for k, v in flow.request.headers.items()}
decision = decide( decision = decide(
config.routes, self.config.routes,
flow.request.pretty_host, flow.request.pretty_host,
request_path, request_path,
os.environ, os.environ,
@@ -297,7 +257,7 @@ class EgressAddon:
if decision.inject_authorization is not None: if decision.inject_authorization is not None:
flow.request.headers["authorization"] = decision.inject_authorization flow.request.headers["authorization"] = decision.inject_authorization
if config.log >= LOG_FULL: if self.config.log >= LOG_FULL:
self._log_request(flow) self._log_request(flow)
def _block_dlp(self, flow: http.HTTPFlow, result: ScanResult) -> None: def _block_dlp(self, flow: http.HTTPFlow, result: ScanResult) -> None:
-30
View File
@@ -413,34 +413,6 @@ def load_config(text: str) -> "Config":
return parse_config(payload) return parse_config(payload)
class PolicyResolverLike(typing.Protocol):
"""The bit of `policy_resolver.PolicyResolver` this module needs — kept a
Protocol so egress_addon_core stays free of that import."""
def resolve(self, source_ip: str, identity_token: str = ...) -> "str | None":
...
def resolve_client_config(
resolver: PolicyResolverLike, client_ip: str, identity_token: str = ""
) -> "Config":
"""The calling client's egress Config, resolved from the orchestrator via
`resolver` and parsed — **fail-closed**. An unattributed client (None), a
resolver error, or an unparseable policy all yield a deny-all Config (no
routes → every request blocked). A compromised, absent, or confused
orchestrator must never *widen* a bottle's egress."""
try:
policy = resolver.resolve(client_ip, identity_token)
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
return Config(routes=()) # orchestrator unreachable/errored → deny
if not policy:
return Config(routes=()) # unattributed or empty → deny-all
try:
return load_config(policy)
except ValueError:
return Config(routes=()) # unparseable policy → deny
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Match evaluation # Match evaluation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -832,8 +804,6 @@ __all__ = [
"is_git_push_request", "is_git_push_request",
"is_git_fetch_request", "is_git_fetch_request",
"load_config", "load_config",
"resolve_client_config",
"PolicyResolverLike",
"match_route", "match_route",
"outbound_scan_headers", "outbound_scan_headers",
"parse_config", "parse_config",
+5 -39
View File
@@ -7,57 +7,23 @@ backend-neutral "consolidation core" that needs no VM packaging:
* `registry` — the SQLite runtime-state store + fail-closed * `registry` — the SQLite runtime-state store + fail-closed
attribution (source IP + per-bottle identity token). attribution (source IP + per-bottle identity token).
* `broker` — the signed, structured launch-request contract + a * `control_plane` — the HTTP control-plane RPC (register / deregister /
`LaunchBroker` (stub for the harness) that verifies list / attribute / health), with live reload.
provenance before acting.
* `sidecar` — the consolidated per-host sidecar: a `Sidecar`
lifecycle contract (idempotent singleton) + a
`DockerSidecar` impl. One sidecar 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.
* `control_plane` — the HTTP control-plane RPC (launch / teardown /
list / attribute / sidecar / health).
The actual backend-native launch (a real docker/firecracker broker) and Launch/teardown, the launch broker, and the actual egress/git/supervise
the egress/git/supervise data plane land in later slices once this core is data plane land in later slices once this core is proven (see the PRD's
proven (see the PRD sequencing: plain-process dev-harness -> docker sequencing: plain-process dev-harness -> docker orchestrator -> firecracker).
orchestrator -> firecracker).
""" """
from __future__ import annotations from __future__ import annotations
from .registry import BottleRecord, RegistryStore, new_identity_token from .registry import BottleRecord, RegistryStore, new_identity_token
from .broker import (
BrokerAuthError,
LaunchBroker,
LaunchRequest,
StubBroker,
sign_request,
verify_request,
)
from .docker_broker import DockerBroker, DockerBrokerError
from .sidecar import DockerSidecar, Sidecar, SidecarError
from .service import Orchestrator
from .control_plane import ControlPlaneServer, dispatch, make_server from .control_plane import ControlPlaneServer, dispatch, make_server
__all__ = [ __all__ = [
"BottleRecord", "BottleRecord",
"RegistryStore", "RegistryStore",
"new_identity_token", "new_identity_token",
"BrokerAuthError",
"LaunchBroker",
"LaunchRequest",
"StubBroker",
"DockerBroker",
"DockerBrokerError",
"Sidecar",
"DockerSidecar",
"SidecarError",
"sign_request",
"verify_request",
"Orchestrator",
"ControlPlaneServer", "ControlPlaneServer",
"dispatch", "dispatch",
"make_server", "make_server",
+1 -28
View File
@@ -12,16 +12,11 @@ container packaging. Wrapping this exact service in a backend-native unit
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import secrets
from pathlib import Path from pathlib import Path
from .. import log from .. import log
from .broker import LaunchBroker, StubBroker
from .control_plane import make_server from .control_plane import make_server
from .docker_broker import DockerBroker
from .registry import RegistryStore, default_db_path from .registry import RegistryStore, default_db_path
from .service import Orchestrator
from .sidecar import DockerSidecar, Sidecar
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
@@ -33,34 +28,12 @@ def main(argv: list[str] | None = None) -> int:
"--db", type=Path, default=None, "--db", type=Path, default=None,
help=f"registry DB path (default: {default_db_path()})", help=f"registry DB path (default: {default_db_path()})",
) )
parser.add_argument(
"--broker", choices=("stub", "docker"), default="stub",
help="launch broker: 'stub' records requests; 'docker' runs containers",
)
parser.add_argument(
"--sidecar", action="store_true",
help="run one consolidated per-host sidecar bundle (build-if-missing)",
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
registry = RegistryStore(args.db) registry = RegistryStore(args.db)
registry.migrate() registry.migrate()
# An ephemeral signing secret ties the orchestrator (signer) to its server = make_server(registry, host=args.host, port=args.port)
# broker (verifier). 'stub' records launches instead of starting
# 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)
# One persistent per-host sidecar, 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})
server = make_server(orchestrator, host=args.host, port=args.port)
bound_host, bound_port = server.server_address[0], server.server_address[1] bound_host, bound_port = server.server_address[0], server.server_address[1]
log.info( log.info(
"orchestrator control plane listening", "orchestrator control plane listening",
-176
View File
@@ -1,176 +0,0 @@
"""Launch broker contract (PRD 0070).
A VM/container can't spawn its own host-network siblings, so the
orchestrator brokers agent launches through a small privileged component.
The request it sends is:
* **structured** — static flags + ids only (bottle id, pool slot, a
content-addressed image ref), never a free-form path or argv, so a
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
"structured requests only" rule in the PRD security review.
Signing is **HS256** over a secret shared by the orchestrator (signer) and
the broker (verifier) — appropriate here because both are trusted host
components provisioned together; the secret is exactly what an
agent-facing component does not have. (Stdlib only — the project takes no
runtime deps; a cross-host deployment could swap in an asymmetric alg.)
"""
from __future__ import annotations
import abc
import base64
import hashlib
import hmac
import json
import secrets
import time
from dataclasses import dataclass
_JWT_HEADER = {"alg": "HS256", "typ": "JWT"}
_ALLOWED_OPS = ("launch", "teardown")
class BrokerAuthError(Exception):
"""A broker request failed provenance or schema verification —
bad/absent signature, malformed token, or a payload that doesn't match
the fixed launch-request shape. Fail-closed: the broker must not act."""
@dataclass(frozen=True)
class LaunchRequest:
"""The structured, un-coercible launch/teardown request. Ids + flags
only — `image_ref` is a content-addressed id, never a path/argv."""
op: str # one of _ALLOWED_OPS
bottle_id: str
source_ip: str = ""
image_ref: str = ""
slot: int | None = None
# --- minimal JWS/JWT (HS256), stdlib only ----------------------------------
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64url_decode(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def _mac(signing_input: str, secret: bytes) -> str:
digest = hmac.new(secret, signing_input.encode("ascii"), hashlib.sha256).digest()
return _b64url(digest)
def sign_request(req: LaunchRequest, secret: bytes) -> str:
"""Serialize `req` to signed-JWT form. Adds a per-signature `jti`
(replay id) and `iat` (issued-at)."""
claims: dict[str, object] = {
"op": req.op,
"bottle_id": req.bottle_id,
"source_ip": req.source_ip,
"image_ref": req.image_ref,
"slot": req.slot,
"jti": secrets.token_hex(8),
"iat": int(time.time()),
}
header = _b64url(json.dumps(_JWT_HEADER, sort_keys=True, separators=(",", ":")).encode())
payload = _b64url(json.dumps(claims, sort_keys=True, separators=(",", ":")).encode())
signing_input = f"{header}.{payload}"
return f"{signing_input}.{_mac(signing_input, secret)}"
def verify_request(token: str, secret: bytes) -> LaunchRequest:
"""Verify the signature and the request shape; return the parsed
request. Raises `BrokerAuthError` on any failure (fail-closed)."""
parts = token.split(".")
if len(parts) != 3:
raise BrokerAuthError("malformed token")
header_b, payload_b, sig = parts
if not hmac.compare_digest(_mac(f"{header_b}.{payload_b}", secret), sig):
raise BrokerAuthError("bad signature")
try:
header = json.loads(_b64url_decode(header_b))
claims = json.loads(_b64url_decode(payload_b))
except (ValueError, json.JSONDecodeError) as e:
raise BrokerAuthError("malformed token payload") from e
if not isinstance(header, dict) or header.get("alg") != "HS256":
raise BrokerAuthError("unexpected header/alg")
if not isinstance(claims, dict):
raise BrokerAuthError("claims are not an object")
op = claims.get("op")
bottle_id = claims.get("bottle_id")
source_ip = claims.get("source_ip", "")
image_ref = claims.get("image_ref", "")
slot = claims.get("slot")
if (
not isinstance(op, str) or op not in _ALLOWED_OPS
or not isinstance(bottle_id, str) or not bottle_id
or not isinstance(source_ip, str) or not isinstance(image_ref, str)
or not (slot is None or isinstance(slot, int))
):
raise BrokerAuthError("request does not match the launch-request schema")
return LaunchRequest(
op=op, bottle_id=bottle_id, source_ip=source_ip, image_ref=image_ref, slot=slot
)
# --- the broker itself ------------------------------------------------------
class LaunchBroker(abc.ABC):
"""Verifies a signed request came from the orchestrator, then performs
the backend-native launch/teardown. Subclasses implement `_launch` /
`_teardown`; verification is shared and fail-closed."""
def __init__(self, secret: bytes) -> None:
self._secret = secret
def submit(self, token: str) -> LaunchRequest:
"""Verify `token` and perform its op. Returns the verified request;
raises `BrokerAuthError` if provenance/shape fails."""
req = verify_request(token, self._secret)
if req.op == "launch":
self._launch(req)
else:
self._teardown(req)
return req
@abc.abstractmethod
def _launch(self, req: LaunchRequest) -> None:
...
@abc.abstractmethod
def _teardown(self, req: LaunchRequest) -> None:
...
class StubBroker(LaunchBroker):
"""Dev-harness broker: records verified requests without launching
anything. Exercises the full sign -> verify -> act contract in-process."""
def __init__(self, secret: bytes) -> None:
super().__init__(secret)
self.launched: list[LaunchRequest] = []
self.torn_down: list[LaunchRequest] = []
def _launch(self, req: LaunchRequest) -> None:
self.launched.append(req)
def _teardown(self, req: LaunchRequest) -> None:
self.torn_down.append(req)
__all__ = [
"BrokerAuthError",
"LaunchRequest",
"LaunchBroker",
"StubBroker",
"sign_request",
"verify_request",
]
+30 -75
View File
@@ -2,30 +2,23 @@
The backend-agnostic control-plane RPC (CLI / console -> orchestrator) over The backend-agnostic control-plane RPC (CLI / console -> orchestrator) over
**HTTP** — the universal transport chosen in 0070 (works on every host; no **HTTP** — the universal transport chosen in 0070 (works on every host; no
vsock / unix-socket portability caveats): vsock / unix-socket portability caveats). Endpoints mutate the live
registry, so register/deregister are the *live-reload* path — no restart:
GET /health -> 200 {"status": "ok"} GET /health -> 200 {"status": "ok"}
GET /sidecar -> 200 {"configured", ["name","running"]} GET /bottles -> 200 {"bottles": [ <redacted record>, ... ]}
GET /bottles -> 200 {"bottles": [ <redacted record>, ...]} POST /bottles -> 201 {"bottle_id", "identity_token"}
POST /bottles -> 201 {"bottle_id","identity_token"} (launch) body: {"source_ip", ["bottle_id"], ["metadata"]}
body: {"source_ip", ["image_ref"], DELETE /bottles/<bottle_id> -> 200 {"deregistered": true} | 404
["metadata"], ["policy"]} POST /attribute -> 200 {"bottle_id"} | 403
PUT /bottles/<bottle_id>/policy -> 200 {"updated": true} | 404 (live reload) body: {"source_ip", "identity_token"}
body: {"policy"}
DELETE /bottles/<bottle_id> -> 200 {"torn_down": true} | 404 (teardown)
POST /attribute -> 200 {"bottle_id"} | 403
POST /resolve -> 200 {"bottle_id","policy"} | 403
body: {"source_ip","identity_token"}
`POST /bottles` / `DELETE` drive the full launch lifecycle: they mint (or
tear down) the bottle in the registry AND broker the backend-native launch
via the orchestrator. Register/deregister without a launch are internal to
`Orchestrator`, not exposed here.
Routing/handling is the pure function `dispatch()` so it is unit-testable Routing/handling is the pure function `dispatch()` so it is unit-testable
without a socket; `Handler` / `ControlPlaneServer` / `make_server` are a without a socket; `Handler` / `ControlPlaneServer` / `make_server` are a
thin stdlib adapter around it. Listing redacts identity tokens — they are thin stdlib adapter around it.
returned only once, to the caller that launches the bottle.
Note the listing redacts identity tokens — they are never returned except
once, to the caller that registers the bottle.
""" """
from __future__ import annotations from __future__ import annotations
@@ -37,7 +30,7 @@ import socketserver
import typing import typing
from urllib.parse import urlsplit from urllib.parse import urlsplit
from .service import Orchestrator from .registry import RegistryStore
# JSON body payload type (parsed request / rendered response). # JSON body payload type (parsed request / rendered response).
Json = dict[str, object] Json = dict[str, object]
@@ -53,21 +46,18 @@ def _parse_json_object(body: bytes) -> Json:
return obj return obj
def dispatch( # pylint: disable=too-many-return-statements,too-many-branches def dispatch( # pylint: disable=too-many-return-statements
orch: Orchestrator, method: str, path: str, body: bytes registry: RegistryStore, method: str, path: str, body: bytes
) -> tuple[int, Json]: ) -> tuple[int, Json]:
"""Route one control-plane request to a (status, payload) pair. Pure — """Route one control-plane request to a (status, payload) pair. Pure —
no I/O beyond the orchestrator — so it is fully testable without a socket.""" no I/O beyond the registry — so it is fully testable without a socket."""
route = urlsplit(path).path.rstrip("/") or "/" route = urlsplit(path).path.rstrip("/") or "/"
if method == "GET" and route == "/health": if method == "GET" and route == "/health":
return 200, {"status": "ok"} return 200, {"status": "ok"}
if method == "GET" and route == "/sidecar":
return 200, orch.sidecar_status()
if method == "GET" and route == "/bottles": if method == "GET" and route == "/bottles":
return 200, {"bottles": [r.redacted() for r in orch.registry.all()]} return 200, {"bottles": [r.redacted() for r in registry.all()]}
if method == "POST" and route == "/bottles": if method == "POST" and route == "/bottles":
try: try:
@@ -77,34 +67,19 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
source_ip = data.get("source_ip") source_ip = data.get("source_ip")
if not isinstance(source_ip, str) or not source_ip: if not isinstance(source_ip, str) or not source_ip:
return 400, {"error": "source_ip (string) is required"} return 400, {"error": "source_ip (string) is required"}
image_ref = data.get("image_ref") bottle_id = data.get("bottle_id")
metadata = data.get("metadata") metadata = data.get("metadata")
policy = data.get("policy") rec = registry.register(
rec = orch.launch_bottle(
source_ip, source_ip,
image_ref=image_ref if isinstance(image_ref, str) else "", bottle_id=bottle_id if isinstance(bottle_id, str) else None,
metadata=metadata if isinstance(metadata, str) else "", metadata=metadata if isinstance(metadata, str) else "",
policy=policy if isinstance(policy, str) else "",
) )
return 201, {"bottle_id": rec.bottle_id, "identity_token": rec.identity_token} return 201, {"bottle_id": rec.bottle_id, "identity_token": rec.identity_token}
if method == "PUT" and route.startswith("/bottles/") and route.endswith("/policy"):
bottle_id = route[len("/bottles/"):-len("/policy")]
try:
data = _parse_json_object(body)
except ValueError as e:
return 400, {"error": f"invalid JSON: {e}"}
policy = data.get("policy")
if not isinstance(policy, str):
return 400, {"error": "policy (string) is required"}
if orch.set_policy(bottle_id, policy):
return 200, {"updated": True}
return 404, {"error": "no such bottle"}
if method == "DELETE" and route.startswith("/bottles/"): if method == "DELETE" and route.startswith("/bottles/"):
bottle_id = route[len("/bottles/"):] bottle_id = route[len("/bottles/"):]
if orch.teardown_bottle(bottle_id): if registry.deregister(bottle_id):
return 200, {"torn_down": True} return 200, {"deregistered": True}
return 404, {"error": "no such bottle"} return 404, {"error": "no such bottle"}
if method == "POST" and route == "/attribute": if method == "POST" and route == "/attribute":
@@ -116,28 +91,11 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
token = data.get("identity_token") token = data.get("identity_token")
if not isinstance(source_ip, str) or not isinstance(token, str): if not isinstance(source_ip, str) or not isinstance(token, str):
return 400, {"error": "source_ip and identity_token (strings) required"} return 400, {"error": "source_ip and identity_token (strings) required"}
rec = orch.attribute(source_ip, token) rec = registry.attribute(source_ip, token)
if rec is None: if rec is None:
return 403, {"error": "unattributed"} return 403, {"error": "unattributed"}
return 200, {"bottle_id": rec.bottle_id} return 200, {"bottle_id": rec.bottle_id}
if method == "POST" and route == "/resolve":
# The per-request lookup the multi-tenant sidecar makes: returns the
# bottle's policy. identity_token is OPTIONAL — absent means resolve
# by source IP alone (network-layer attribution).
try:
data = _parse_json_object(body)
except ValueError as e:
return 400, {"error": f"invalid JSON: {e}"}
source_ip = data.get("source_ip")
token = data.get("identity_token")
if not isinstance(source_ip, str) or not source_ip:
return 400, {"error": "source_ip (string) is required"}
rec = orch.resolve(source_ip, token if isinstance(token, str) else "")
if rec is None:
return 403, {"error": "unattributed"}
return 200, {"bottle_id": rec.bottle_id, "policy": rec.policy}
return 404, {"error": "not found"} return 404, {"error": "not found"}
@@ -156,7 +114,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
assert isinstance(server, ControlPlaneServer) assert isinstance(server, ControlPlaneServer)
length = int(self.headers.get("Content-Length") or 0) length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length > 0 else b"" body = self.rfile.read(length) if length > 0 else b""
status, payload = dispatch(server.orchestrator, method, self.path, body) status, payload = dispatch(server.registry, method, self.path, body)
data = json.dumps(payload).encode() data = json.dumps(payload).encode()
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json") self.send_header("Content-Type", "application/json")
@@ -170,30 +128,27 @@ class Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self) -> None: def do_POST(self) -> None:
self._serve("POST") self._serve("POST")
def do_PUT(self) -> None:
self._serve("PUT")
def do_DELETE(self) -> None: def do_DELETE(self) -> None:
self._serve("DELETE") self._serve("DELETE")
class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer): class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
"""Threading HTTP server that carries the orchestrator for its handlers.""" """Threading HTTP server that carries the registry for its handlers."""
daemon_threads = True daemon_threads = True
allow_reuse_address = True allow_reuse_address = True
def __init__(self, address: tuple[str, int], orchestrator: Orchestrator) -> None: def __init__(self, address: tuple[str, int], registry: RegistryStore) -> None:
self.orchestrator = orchestrator self.registry = registry
super().__init__(address, Handler) super().__init__(address, Handler)
def make_server( def make_server(
orchestrator: Orchestrator, host: str = "127.0.0.1", port: int = 0 registry: RegistryStore, host: str = "127.0.0.1", port: int = 0
) -> ControlPlaneServer: ) -> ControlPlaneServer:
"""Build (but do not start) a control-plane server. `port=0` binds an """Build (but do not start) a control-plane server. `port=0` binds an
ephemeral port — read `server.server_address` for the actual one.""" ephemeral port — read `server.server_address` for the actual one."""
return ControlPlaneServer((host, port), orchestrator) return ControlPlaneServer((host, port), registry)
__all__ = ["dispatch", "Handler", "ControlPlaneServer", "make_server", "Json"] __all__ = ["dispatch", "Handler", "ControlPlaneServer", "make_server", "Json"]
-83
View File
@@ -1,83 +0,0 @@
"""Docker launch broker (PRD 0070) — the first *real* LaunchBroker.
On a verified launch request it starts a Docker container; on teardown it
removes it. This proves the orchestrator -> backend seam on the cheapest
backend (the sidecar bundle is already containers). Only the request's
static ids/flags reach `docker`, so nothing free-form crosses the boundary.
Slice 3 launches a single container from the request's `image_ref`, named
after the bottle id and labelled for cleanup. Wiring the full agent +
sidecar bundle (networks, mounts, the consolidated sidecar) is a later
slice — this is the seam, not the finished launcher.
"""
from __future__ import annotations
import subprocess
from ..docker_cmd import run_docker
from .broker import LaunchBroker, LaunchRequest
CONTAINER_PREFIX = "bot-bottle-orch-"
BOTTLE_ID_LABEL = "bot-bottle-bottle-id"
class DockerBrokerError(Exception):
"""A brokered docker launch/teardown failed (non-zero `docker` exit)."""
def container_name(bottle_id: str) -> str:
"""Deterministic container name for a bottle."""
return f"{CONTAINER_PREFIX}{bottle_id}"
def run_argv(req: LaunchRequest) -> list[str]:
"""`docker run` argv for a launch request — built only from its static
fields, so it can't be coerced into an arbitrary command."""
return [
"docker", "run", "--detach",
"--name", container_name(req.bottle_id),
"--label", f"{BOTTLE_ID_LABEL}={req.bottle_id}",
req.image_ref,
]
def rm_argv(req: LaunchRequest) -> list[str]:
"""`docker rm --force` argv to tear a bottle's container down."""
return ["docker", "rm", "--force", container_name(req.bottle_id)]
class DockerBroker(LaunchBroker):
"""A `LaunchBroker` that runs / removes a Docker container per bottle.
Provenance + schema verification are inherited from `LaunchBroker`."""
def _docker(self, argv: list[str]) -> subprocess.CompletedProcess[str]:
return run_docker(argv)
def _launch(self, req: LaunchRequest) -> None:
if not req.image_ref:
raise DockerBrokerError(f"launch request for {req.bottle_id} has no image_ref")
proc = self._docker(run_argv(req))
if proc.returncode != 0:
raise DockerBrokerError(
f"docker run failed for {req.bottle_id}: {proc.stderr.strip()}"
)
def _teardown(self, req: LaunchRequest) -> None:
proc = self._docker(rm_argv(req))
# Idempotent: an already-absent container is a successful teardown.
if proc.returncode != 0 and "No such container" not in proc.stderr:
raise DockerBrokerError(
f"docker rm failed for {req.bottle_id}: {proc.stderr.strip()}"
)
__all__ = [
"DockerBroker",
"DockerBrokerError",
"container_name",
"run_argv",
"rm_argv",
"CONTAINER_PREFIX",
"BOTTLE_ID_LABEL",
]
+13 -46
View File
@@ -64,13 +64,9 @@ class BottleRecord:
identity_token: str identity_token: str
state: str = "active" state: str = "active"
created_at: float = 0.0 created_at: float = 0.0
# Opaque JSON blob for forward-compat (pool slot, ...) — the registry # Opaque JSON blob for forward-compat (policy refs, pool slot, ...) —
# doesn't interpret it, so new fields need no migration. # the registry doesn't interpret it, so new fields need no migration.
metadata: str = "" metadata: str = ""
# The bottle's sidecar 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.
policy: str = ""
def redacted(self) -> dict[str, object]: def redacted(self) -> dict[str, object]:
"""Public view for listing — never exposes the identity token.""" """Public view for listing — never exposes the identity token."""
@@ -80,7 +76,6 @@ class BottleRecord:
"state": self.state, "state": self.state,
"created_at": self.created_at, "created_at": self.created_at,
"metadata": self.metadata, "metadata": self.metadata,
"policy": self.policy,
} }
@@ -102,10 +97,6 @@ _MIGRATIONS = TableMigrations(
# the "one active bottle per source IP" check cheap. # the "one active bottle per source IP" check cheap.
"CREATE INDEX IF NOT EXISTS idx_orchestrator_bottles_source_ip " "CREATE INDEX IF NOT EXISTS idx_orchestrator_bottles_source_ip "
"ON orchestrator_bottles (source_ip)", "ON orchestrator_bottles (source_ip)",
# v3 — per-bottle policy (opaque JSON the sidecar interprets): the
# egress allowlist / routes / git config selected by source IP. The
# multi-tenant sidecar resolves it per request via `attribute`.
"ALTER TABLE orchestrator_bottles ADD COLUMN policy TEXT NOT NULL DEFAULT ''",
], ],
) )
@@ -119,7 +110,6 @@ def _row_to_record(row: sqlite3.Row) -> BottleRecord:
state=row["state"], state=row["state"],
created_at=row["created_at"], created_at=row["created_at"],
metadata=row["metadata"], metadata=row["metadata"],
policy=row["policy"],
) )
@@ -146,7 +136,6 @@ class RegistryStore(DbStore):
bottle_id: str | None = None, bottle_id: str | None = None,
identity_token: str | None = None, identity_token: str | None = None,
metadata: str = "", metadata: str = "",
policy: str = "",
) -> BottleRecord: ) -> BottleRecord:
"""Register (or replace) a bottle. Mints a bottle_id / identity token """Register (or replace) a bottle. Mints a bottle_id / identity token
when not supplied. Live — this is the control-plane reload path.""" when not supplied. Live — this is the control-plane reload path."""
@@ -157,13 +146,12 @@ class RegistryStore(DbStore):
state="active", state="active",
created_at=time.time(), created_at=time.time(),
metadata=metadata, metadata=metadata,
policy=policy,
) )
with self._connect() as conn: with self._connect() as conn:
conn.execute( conn.execute(
"INSERT OR REPLACE INTO orchestrator_bottles " "INSERT OR REPLACE INTO orchestrator_bottles "
"(bottle_id, source_ip, identity_token, state, created_at, metadata, policy) " "(bottle_id, source_ip, identity_token, state, created_at, metadata) "
"VALUES (?, ?, ?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?, ?)",
( (
rec.bottle_id, rec.bottle_id,
rec.source_ip, rec.source_ip,
@@ -171,23 +159,11 @@ class RegistryStore(DbStore):
rec.state, rec.state,
rec.created_at, rec.created_at,
rec.metadata, rec.metadata,
rec.policy,
), ),
) )
self._chmod() self._chmod()
return rec return rec
def set_policy(self, bottle_id: str, policy: str) -> bool:
"""Update a bottle's policy in place (live reload). Returns True if
the bottle exists."""
with self._connect() as conn:
cur = conn.execute(
"UPDATE orchestrator_bottles SET policy = ? WHERE bottle_id = ?",
(policy, bottle_id),
)
self._chmod()
return cur.rowcount > 0
def deregister(self, bottle_id: str) -> bool: def deregister(self, bottle_id: str) -> bool:
"""Remove a bottle. Returns True if a row was deleted.""" """Remove a bottle. Returns True if a row was deleted."""
with self._connect() as conn: with self._connect() as conn:
@@ -212,13 +188,14 @@ class RegistryStore(DbStore):
).fetchall() ).fetchall()
return [_row_to_record(r) for r in rows] return [_row_to_record(r) for r in rows]
def by_source_ip(self, source_ip: str) -> BottleRecord | None: def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None:
"""Network-layer attribution: the single active bottle at this source """Fail-closed attribution. Returns the bottle only when exactly one
IP, or None if unknown or ambiguous (more than one — a active record has this source IP AND its identity token matches
misconfiguration). Safe as the *sole* attributor only where the (constant-time). Either signal alone is insufficient: an unknown IP,
source IP is unspoofable (Firecracker `/31` + nft) and the control an ambiguous IP (more than one active bottle — a misconfiguration),
plane is reachable only by the trusted sidecar; pair with the an empty token, or a token mismatch all deny."""
identity token (`attribute`) elsewhere.""" if not identity_token:
return None
with self._connect() as conn: with self._connect() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT * FROM orchestrator_bottles " "SELECT * FROM orchestrator_bottles "
@@ -227,17 +204,7 @@ class RegistryStore(DbStore):
).fetchall() ).fetchall()
if len(rows) != 1: if len(rows) != 1:
return None return None
return _row_to_record(rows[0]) rec = _row_to_record(rows[0])
def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None:
"""Fail-closed attribution: `by_source_ip` AND a matching identity
token (constant-time). Either signal alone is insufficient here — an
unknown/ambiguous IP, an empty token, or a token mismatch all deny."""
if not identity_token:
return None
rec = self.by_source_ip(source_ip)
if rec is None:
return None
if not hmac.compare_digest(rec.identity_token, identity_token): if not hmac.compare_digest(rec.identity_token, identity_token):
return None return None
return rec return rec
-121
View File
@@ -1,121 +0,0 @@
"""The per-host orchestrator core (PRD 0070).
`Orchestrator` is the single backend-neutral object the control plane talks
to: it owns the registry (runtime state) and brokers agent launches. It
never branches on backend — the `LaunchBroker` abstracts the backend-native
launch, so this same object drives docker / firecracker / apple once a real
broker is wired in.
Launch lifecycle:
* `launch_bottle` mints the bottle (registry: source IP + identity
token), sends a *signed, structured* launch request through the broker,
and returns the record. If the broker rejects/fails, the registry entry
is rolled back so a failed launch leaves no orphan.
* `teardown_bottle` sends a signed teardown request, then deregisters.
"""
from __future__ import annotations
from .broker import LaunchBroker, LaunchRequest, sign_request
from .registry import BottleRecord, RegistryStore
from .sidecar import Sidecar
class Orchestrator:
"""Owns the registry + brokers launches, and manages the single
consolidated per-host sidecar. Backend-neutral (broker and sidecar
abstract the backend-native pieces)."""
def __init__(
self,
registry: RegistryStore,
broker: LaunchBroker,
sign_secret: bytes,
sidecar: Sidecar | None = None,
) -> None:
self.registry = registry
self._broker = broker
self._secret = sign_secret
self._sidecar = sidecar
def launch_bottle(
self,
source_ip: str,
*,
image_ref: str = "",
slot: int | None = None,
metadata: str = "",
policy: str = "",
) -> BottleRecord:
"""Register a bottle (with its sidecar 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)
req = LaunchRequest(
op="launch",
bottle_id=rec.bottle_id,
source_ip=source_ip,
image_ref=image_ref,
slot=slot,
)
launched = False
try:
self._broker.submit(sign_request(req, self._secret))
launched = True
finally:
if not launched:
self.registry.deregister(rec.bottle_id)
return rec
def teardown_bottle(self, bottle_id: str) -> bool:
"""Broker teardown then deregister. False if the bottle is unknown."""
rec = self.registry.get(bottle_id)
if rec is None:
return False
req = LaunchRequest(op="teardown", bottle_id=bottle_id, source_ip=rec.source_ip)
self._broker.submit(sign_request(req, self._secret))
self.registry.deregister(bottle_id)
return True
def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None:
"""Fail-closed attribution (delegates to the registry)."""
return self.registry.attribute(source_ip, identity_token)
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
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)."""
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
the bottle is unknown."""
return self.registry.set_policy(bottle_id, policy)
# --- consolidated sidecar ----------------------------------------------
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 sidecar_status(self) -> dict[str, object]:
"""Report the shared sidecar for the control plane / console."""
if self._sidecar is None:
return {"configured": False}
return {
"configured": True,
"name": self._sidecar.name,
"running": self._sidecar.is_running(),
}
__all__ = ["Orchestrator"]
-139
View File
@@ -1,139 +0,0 @@
"""The consolidated per-host sidecar (PRD 0070).
The core consolidation win: **one** persistent sidecar 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
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.
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.
"""
from __future__ import annotations
import abc
import os
from pathlib import Path
from ..docker_cmd import run_docker
SIDECAR_NAME = "bot-bottle-orch-sidecar"
SIDECAR_LABEL = "bot-bottle-orch-sidecar=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"
_REPO_ROOT = Path(__file__).resolve().parents[2]
class SidecarError(Exception):
"""The shared sidecar failed to build/start/stop (non-zero `docker` exit)."""
class Sidecar(abc.ABC):
"""Lifecycle of the single per-host sidecar. Backend-neutral."""
name: str
def ensure_built(self) -> None:
"""Ensure the sidecar'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
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."""
@abc.abstractmethod
def stop(self) -> None:
"""Remove the sidecar. Idempotent — absent is success."""
class DockerSidecar(Sidecar):
"""The consolidated sidecar 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
builds + launches the bundle container; wiring its per-bottle,
source-IP-keyed config is a later slice see PRD 0070.)"""
def __init__(
self,
image_ref: str = SIDECAR_BUNDLE_IMAGE,
*,
name: str = SIDECAR_NAME,
build_context: Path | None = None,
dockerfile: str | None = SIDECAR_DOCKERFILE,
) -> None:
self.image_ref = image_ref
self.name = name
self._build_context = build_context or _REPO_ROOT
self._dockerfile = dockerfile
def image_exists(self) -> bool:
return run_docker(["docker", "image", "inspect", self.image_ref]).returncode == 0
def ensure_built(self) -> None:
"""Build the bundle image from its Dockerfile when it's missing.
No-op when the image is already present, or when no dockerfile is
configured (e.g. a pre-pulled image)."""
if self._dockerfile is None or self.image_exists():
return
proc = run_docker([
"docker", "build",
"-t", self.image_ref,
"-f", str(self._build_context / self._dockerfile),
str(self._build_context),
])
if proc.returncode != 0:
raise SidecarError(f"sidecar image build failed: {proc.stderr.strip()}")
def is_running(self) -> bool:
proc = run_docker([
"docker", "ps",
"--filter", f"name=^{self.name}$",
"--filter", "status=running",
"--format", "{{.Names}}",
])
return self.name in proc.stdout.split()
def ensure_running(self) -> None:
if self.is_running():
return
# Clear any stale (stopped) container holding the fixed name, then
# start fresh. `rm --force` on an absent name is a tolerated no-op.
run_docker(["docker", "rm", "--force", self.name])
proc = run_docker([
"docker", "run", "--detach",
"--name", self.name,
"--label", SIDECAR_LABEL,
self.image_ref,
])
if proc.returncode != 0:
raise SidecarError(f"sidecar 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()}")
__all__ = [
"Sidecar", "DockerSidecar", "SidecarError",
"SIDECAR_NAME", "SIDECAR_LABEL", "SIDECAR_BUNDLE_IMAGE",
]
-76
View File
@@ -1,76 +0,0 @@
"""Sidecar-side per-client policy resolver (PRD 0070).
The consolidated sidecar 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
`(source_ip, identity_token)` pair.
**Always fresh no cache.** The resolver is called rarely enough that a
round-trip doesn't matter, and correctness matters more than speed: every
resolve reflects the orchestrator's *current* view, so a revocation, a
policy change, or a bottle teardown the orchestrator knows about is honored
immediately rather than lingering for a cache TTL. (If this ever becomes a
hot path, add caching with orchestrator-driven invalidation not a blind
TTL.)
**Fail-closed:** an unattributed client (the orchestrator answers `403`)
resolves to `None`, and the caller (the egress addon, git-gate) must then
deny exactly as an unknown bottle should be treated. Orchestrator
*errors* (unreachable / unexpected status) raise, so the caller can fail
closed too rather than silently serving stale or empty policy.
The resolved value is the policy blob the orchestrator stores verbatim; the
consumer parses it (e.g. the egress addon's `load_config`). This module is
stdlib-only and free of bot-bottle imports so it can be COPYed flat into
the sidecar bundle.
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request
DEFAULT_TIMEOUT_SECONDS = 2.0
class PolicyResolveError(RuntimeError):
"""The orchestrator was unreachable or returned an unexpected status —
distinct from a clean `403` (unattributed), which returns None."""
class PolicyResolver:
"""Resolves each client's policy from the orchestrator, fresh per call."""
def __init__(self, base_url: str, *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> None:
self._base = base_url.rstrip("/")
self._timeout = timeout
def resolve(self, source_ip: str, identity_token: str = "") -> str | None:
"""The calling bottle's policy blob, or None if unattributed. Always
fetches from the orchestrator so revocations / changes / teardowns
are honored immediately. `identity_token` is optional omit it to
resolve by source IP alone (the network-layer attribution).
Raises `PolicyResolveError` if the orchestrator can't be reached."""
body = json.dumps(
{"source_ip": source_ip, "identity_token": identity_token}
).encode()
req = urllib.request.Request(
f"{self._base}/resolve", data=body, method="POST",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
payload = json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 403:
return None # unattributed → fail closed (caller denies)
raise PolicyResolveError(f"/resolve returned HTTP {e.code}") from e
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
raise PolicyResolveError(f"/resolve unreachable or malformed: {e}") from e
policy = payload.get("policy") if isinstance(payload, dict) else None
return policy if isinstance(policy, str) else ""
__all__ = ["PolicyResolver", "PolicyResolveError"]
+41 -9
View File
@@ -325,18 +325,50 @@ orchestrator becomes a VM. Decouple the two risks instead:
secret handling) is proven with fast iteration — *then* wrap that exact secret handling) is proven with fast iteration — *then* wrap that exact
service in the VM and solve wiring separately. service in the VM and solve wiring separately.
Backend order (cheapest proof → hardest → last): ### Slices
1. **Docker orchestrator** — nearly free (the sidecar bundle is already Built bottom-up as a stack of small PRs off this one (status: **15
containers; collapse N bundles into one persistent container). Proves implemented** in #352#356#357#358#360):
consolidation + the `BottleBackend` seam with the least moving parts.
2. **Firecracker orchestrator**the real work: the shim + VM-to-VM 1. **Dev-harness core**SQLite registry (runtime state) + fail-closed
attribution (source IP + identity token) + the HTTP control plane.
2. **Launch lifecycle + broker contract** ✅ — `launch_bottle` /
`teardown_bottle` + the signed, structured `LaunchBroker` request
(HS256 JWT, static ids/flags only, provenance-verified, fail-closed),
with a stub broker for the harness and registry rollback on failure.
3. **Docker launch broker** ✅ — the first real broker: `docker run` / `rm`
per bottle, built only from the request's static fields; proves the
`BottleBackend` seam on the cheapest backend.
**The consolidated-sidecar arc** (the core consolidation win — one
persistent sidecar shared by all bottles instead of one per bottle):
4. **Sidecar — lifecycle** ✅ — the idempotent-singleton `Sidecar` /
`DockerSidecar` (ensure-running is a no-op when already up, stop is
idempotent); one container per host.
5. **Sidecar — build the real bundle image** ✅ — the orchestrator builds
the `bot-bottle-sidecars` bundle from `Dockerfile.sidecars` when missing
and launches *that* (not a placeholder) as the singleton.
6. **Sidecar — source-IP-keyed multi-tenant config** — the functional
core: the per-bottle CA / egress routes / git-gate keys / policy, today
baked per bottle, become **multi-tenant and selected by the verified
source IP**, with per-bottle add/remove (live reload) on launch/teardown
through the control plane. Until this lands, one shared instance can't
serve multiple bottles' distinct policies.
7. **Sidecar — route agent bottles through it** — wire each agent bottle's
egress/git to the shared sidecar (DNAT / network) instead of a per-bottle
bundle.
**Remaining backends** (docker done above; the rest against the proven
dev-harness):
8. **Firecracker broker + sidecar** — the real work: the shim + VM-to-VM
routing (host forwards `bbfcN` → orchestrator TAP; the nft table grows routing (host forwards `bbfcN` → orchestrator TAP; the nft table grows
forward rules where today it drops all non-DNAT egress). Built against forward rules where today it drops all non-DNAT egress).
the dev-harness so the app logic is already proven. 9. **macOS (Apple container)** — last (container-to-container networking).
3. **macOS (Apple container)** — last (container-to-container networking).
Keep the sidecar **service one shared thing** throughout. Backends land cheapest-first (docker → firecracker → macOS); keep the
sidecar **service one shared thing** throughout.
## Non-goals ## Non-goals
@@ -1,56 +0,0 @@
"""Integration: the Docker launch broker starts and removes a real container.
Gated on a reachable Docker daemon (skips cleanly otherwise). Uses a tiny
image; the container may exit immediately we only assert it exists after
launch and is gone after teardown.
"""
from __future__ import annotations
import secrets
import subprocess
import unittest
from bot_bottle.orchestrator.broker import LaunchRequest, sign_request
from bot_bottle.orchestrator.docker_broker import DockerBroker, container_name
from tests._docker import skip_unless_docker
IMAGE = "busybox"
@skip_unless_docker()
class TestDockerBrokerIntegration(unittest.TestCase):
def setUp(self) -> None:
self.secret = secrets.token_bytes(16)
self.broker = DockerBroker(self.secret)
self.bottle_id = "itest" + secrets.token_hex(4)
self.name = container_name(self.bottle_id)
self.addCleanup(
lambda: subprocess.run(
["docker", "rm", "--force", self.name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
)
def _exists(self) -> bool:
proc = subprocess.run(
["docker", "ps", "-a", "--filter", f"name=^{self.name}$",
"--format", "{{.Names}}"],
stdout=subprocess.PIPE, text=True, check=False,
)
return self.name in proc.stdout.split()
def _submit(self, req: LaunchRequest) -> None:
self.broker.submit(sign_request(req, self.secret))
def test_launch_creates_then_teardown_removes(self) -> None:
self._submit(
LaunchRequest(op="launch", bottle_id=self.bottle_id, image_ref=IMAGE)
)
self.assertTrue(self._exists(), "container should exist after launch")
self._submit(LaunchRequest(op="teardown", bottle_id=self.bottle_id))
self.assertFalse(self._exists(), "container should be gone after teardown")
if __name__ == "__main__":
unittest.main()
@@ -1,45 +0,0 @@
"""Integration: the consolidated Docker sidecar is an idempotent singleton.
Gated on a reachable Docker daemon. Uses a unique name so it never
collides with a real per-host sidecar or a leftover from another run.
"""
from __future__ import annotations
import secrets
import subprocess
import unittest
from bot_bottle.orchestrator.sidecar import DockerSidecar
from tests._docker import skip_unless_docker
IMAGE = "busybox"
@skip_unless_docker()
class TestDockerSidecarIntegration(unittest.TestCase):
def setUp(self) -> None:
self.name = "bot-bottle-orch-sidecar-itest-" + secrets.token_hex(4)
self.sc = DockerSidecar(IMAGE, name=self.name)
self.addCleanup(self.sc.stop)
def _count(self) -> int:
proc = subprocess.run(
["docker", "ps", "-a", "--filter", f"name=^{self.name}$",
"--format", "{{.Names}}"],
stdout=subprocess.PIPE, text=True, check=False,
)
return sum(1 for n in proc.stdout.split() if n == self.name)
def test_ensure_is_idempotent_singleton(self) -> None:
self.sc.ensure_running()
self.assertEqual(1, self._count())
# A second ensure must not spawn a second container — one per host.
self.sc.ensure_running()
self.assertEqual(1, self._count())
self.sc.stop()
self.assertEqual(0, self._count())
if __name__ == "__main__":
unittest.main()
@@ -1,36 +0,0 @@
"""Integration: DockerSidecar.image_exists reflects real docker state.
Gated on a reachable Docker daemon. Deliberately does NOT build the full
sidecar bundle (a heavy, slow image build) it exercises the `image_exists`
primitive that `ensure_built` gates on, against a tiny pulled image and a
name that can't exist.
"""
from __future__ import annotations
import subprocess
import unittest
from bot_bottle.orchestrator.sidecar import DockerSidecar
from tests._docker import skip_unless_docker
IMAGE = "busybox"
@skip_unless_docker()
class TestDockerSidecarImageExists(unittest.TestCase):
def test_image_exists_true_for_present_false_for_absent(self) -> None:
# Ensure the tiny image is present (build_if_missing is disabled here
# so this never triggers a Dockerfile.sidecars build).
subprocess.run(
["docker", "pull", IMAGE],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
self.assertTrue(DockerSidecar(IMAGE, dockerfile=None).image_exists())
self.assertFalse(
DockerSidecar("bot-bottle-nonexistent:doesnotexist", dockerfile=None).image_exists()
)
if __name__ == "__main__":
unittest.main()
-53
View File
@@ -1,53 +0,0 @@
"""Unit: resolve_client_config — fail-closed per-client egress config (PRD 0070)."""
from __future__ import annotations
import unittest
from bot_bottle.egress_addon_core import resolve_client_config
from bot_bottle.policy_resolver import PolicyResolveError
class _FakeResolver:
def __init__(self, result: str | None = None, raises: bool = False) -> None:
self._result = result
self._raises = raises
self.calls: list[tuple[str, str]] = []
def resolve(self, source_ip: str, identity_token: str = "") -> str | None:
self.calls.append((source_ip, identity_token))
if self._raises:
raise PolicyResolveError("orchestrator down")
return self._result
class TestResolveClientConfig(unittest.TestCase):
def test_valid_policy_is_parsed(self) -> None:
cfg = resolve_client_config(
_FakeResolver(result="routes:\n - host: example.com\n"), "10.243.0.1"
)
self.assertEqual(("example.com",), tuple(r.host for r in cfg.routes))
def test_unattributed_none_denies_all(self) -> None:
cfg = resolve_client_config(_FakeResolver(result=None), "10.243.0.9")
self.assertEqual((), cfg.routes) # no routes → default-deny
def test_empty_policy_denies_all(self) -> None:
self.assertEqual((), resolve_client_config(_FakeResolver(result=""), "10.243.0.1").routes)
def test_resolver_error_denies_all(self) -> None:
# Orchestrator unreachable/errored must never widen egress.
self.assertEqual((), resolve_client_config(_FakeResolver(raises=True), "10.243.0.1").routes)
def test_unparseable_policy_denies_all(self) -> None:
cfg = resolve_client_config(_FakeResolver(result="routes: notalist\n"), "10.243.0.1")
self.assertEqual((), cfg.routes)
def test_forwards_source_ip_and_token(self) -> None:
r = _FakeResolver(result=None)
resolve_client_config(r, "10.243.0.1", "tok")
self.assertEqual(("10.243.0.1", "tok"), r.calls[0])
if __name__ == "__main__":
unittest.main()
-86
View File
@@ -1,86 +0,0 @@
"""Unit tests for the orchestrator launch broker (PRD 0070)."""
from __future__ import annotations
import secrets
import unittest
from bot_bottle.orchestrator.broker import (
BrokerAuthError,
LaunchRequest,
StubBroker,
sign_request,
verify_request,
)
class TestSignVerify(unittest.TestCase):
def setUp(self) -> None:
self.secret = secrets.token_bytes(16)
def test_launch_round_trip(self) -> None:
req = LaunchRequest(
op="launch", bottle_id="b1", source_ip="10.243.0.1",
image_ref="sha256:abc", slot=3,
)
self.assertEqual(req, verify_request(sign_request(req, self.secret), self.secret))
def test_teardown_round_trip(self) -> None:
req = LaunchRequest(op="teardown", bottle_id="b1")
got = verify_request(sign_request(req, self.secret), self.secret)
self.assertEqual(("teardown", "b1"), (got.op, got.bottle_id))
def test_wrong_secret_rejected(self) -> None:
token = sign_request(LaunchRequest(op="launch", bottle_id="b1"), self.secret)
with self.assertRaises(BrokerAuthError):
verify_request(token, secrets.token_bytes(16))
def test_tampered_payload_rejected(self) -> None:
token = sign_request(LaunchRequest(op="launch", bottle_id="b1"), self.secret)
header, payload, sig = token.split(".")
flipped = payload[:-1] + ("A" if payload[-1] != "A" else "B")
with self.assertRaises(BrokerAuthError):
verify_request(f"{header}.{flipped}.{sig}", self.secret)
def test_malformed_tokens_rejected(self) -> None:
for bad in ("", "a.b", "a.b.c.d", "not-a-token"):
with self.assertRaises(BrokerAuthError):
verify_request(bad, self.secret)
def test_unknown_op_rejected_despite_valid_signature(self) -> None:
# A correctly-signed token whose op isn't in the allow-list must
# still be refused — the schema guard is independent of provenance.
token = sign_request(LaunchRequest(op="rm-rf", bottle_id="b1"), self.secret)
with self.assertRaises(BrokerAuthError):
verify_request(token, self.secret)
class TestStubBroker(unittest.TestCase):
def setUp(self) -> None:
self.secret = secrets.token_bytes(16)
self.broker = StubBroker(self.secret)
def test_submit_launch_records_verified_request(self) -> None:
req = LaunchRequest(op="launch", bottle_id="b1", source_ip="10.243.0.1")
got = self.broker.submit(sign_request(req, self.secret))
self.assertEqual(req, got)
self.assertEqual(["b1"], [r.bottle_id for r in self.broker.launched])
self.assertEqual([], self.broker.torn_down)
def test_submit_teardown_records(self) -> None:
self.broker.submit(
sign_request(LaunchRequest(op="teardown", bottle_id="b1"), self.secret)
)
self.assertEqual(["b1"], [r.bottle_id for r in self.broker.torn_down])
def test_submit_forged_token_is_fail_closed(self) -> None:
forged = sign_request(
LaunchRequest(op="launch", bottle_id="b1"), secrets.token_bytes(16)
)
with self.assertRaises(BrokerAuthError):
self.broker.submit(forged)
self.assertEqual([], self.broker.launched) # nothing acted on
if __name__ == "__main__":
unittest.main()
+22 -99
View File
@@ -7,62 +7,53 @@ server tests), plus one real-socket round-trip to prove the handler wiring.
from __future__ import annotations from __future__ import annotations
import json import json
import secrets
import tempfile import tempfile
import threading import threading
import unittest import unittest
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
from bot_bottle.orchestrator.broker import StubBroker
from bot_bottle.orchestrator.control_plane import dispatch, make_server from bot_bottle.orchestrator.control_plane import dispatch, make_server
from bot_bottle.orchestrator.registry import RegistryStore from bot_bottle.orchestrator.registry import RegistryStore
from bot_bottle.orchestrator.service import Orchestrator
def _body(obj: object) -> bytes: def _body(obj: object) -> bytes:
return json.dumps(obj).encode() return json.dumps(obj).encode()
def _orchestrator(db_path: Path) -> Orchestrator:
store = RegistryStore(db_path)
store.migrate()
secret = secrets.token_bytes(16)
return Orchestrator(store, StubBroker(secret), secret)
class TestDispatch(unittest.TestCase): class TestDispatch(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory() self._tmp = tempfile.TemporaryDirectory()
self.orch = _orchestrator(Path(self._tmp.name) / "r.db") self.store = RegistryStore(Path(self._tmp.name) / "r.db")
self.store.migrate()
def tearDown(self) -> None: def tearDown(self) -> None:
self._tmp.cleanup() self._tmp.cleanup()
def test_health(self) -> None: def test_health(self) -> None:
status, payload = dispatch(self.orch, "GET", "/health", b"") status, payload = dispatch(self.store, "GET", "/health", b"")
self.assertEqual(200, status) self.assertEqual(200, status)
self.assertEqual("ok", payload["status"]) self.assertEqual("ok", payload["status"])
def test_register_returns_id_and_token(self) -> None: def test_register_returns_id_and_token(self) -> None:
status, payload = dispatch( status, payload = dispatch(
self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.1"}) self.store, "POST", "/bottles", _body({"source_ip": "10.243.0.1"})
) )
self.assertEqual(201, status) self.assertEqual(201, status)
self.assertTrue(payload["bottle_id"]) self.assertTrue(payload["bottle_id"])
self.assertTrue(payload["identity_token"]) self.assertTrue(payload["identity_token"])
def test_register_requires_source_ip(self) -> None: def test_register_requires_source_ip(self) -> None:
status, _ = dispatch(self.orch, "POST", "/bottles", _body({})) status, _ = dispatch(self.store, "POST", "/bottles", _body({}))
self.assertEqual(400, status) self.assertEqual(400, status)
def test_register_rejects_bad_json(self) -> None: def test_register_rejects_bad_json(self) -> None:
status, _ = dispatch(self.orch, "POST", "/bottles", b"{not json") status, _ = dispatch(self.store, "POST", "/bottles", b"{not json")
self.assertEqual(400, status) self.assertEqual(400, status)
def test_list_redacts_identity_token(self) -> None: def test_list_redacts_identity_token(self) -> None:
dispatch(self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.1"})) dispatch(self.store, "POST", "/bottles", _body({"source_ip": "10.243.0.1"}))
status, payload = dispatch(self.orch, "GET", "/bottles", b"") status, payload = dispatch(self.store, "GET", "/bottles", b"")
self.assertEqual(200, status) self.assertEqual(200, status)
bottles = payload["bottles"] bottles = payload["bottles"]
assert isinstance(bottles, list) assert isinstance(bottles, list)
@@ -74,126 +65,58 @@ class TestDispatch(unittest.TestCase):
def test_attribute_ok_and_forbidden(self) -> None: def test_attribute_ok_and_forbidden(self) -> None:
_, reg = dispatch( _, reg = dispatch(
self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.5"}) self.store, "POST", "/bottles", _body({"source_ip": "10.243.0.5"})
) )
token = reg["identity_token"] token = reg["identity_token"]
ok_status, ok = dispatch( ok_status, ok = dispatch(
self.orch, "POST", "/attribute", self.store, "POST", "/attribute",
_body({"source_ip": "10.243.0.5", "identity_token": token}), _body({"source_ip": "10.243.0.5", "identity_token": token}),
) )
self.assertEqual(200, ok_status) self.assertEqual(200, ok_status)
self.assertEqual(reg["bottle_id"], ok["bottle_id"]) self.assertEqual(reg["bottle_id"], ok["bottle_id"])
bad_status, _ = dispatch( bad_status, _ = dispatch(
self.orch, "POST", "/attribute", self.store, "POST", "/attribute",
_body({"source_ip": "10.243.0.5", "identity_token": "nope"}), _body({"source_ip": "10.243.0.5", "identity_token": "nope"}),
) )
self.assertEqual(403, bad_status) self.assertEqual(403, bad_status)
def test_attribute_requires_both_fields(self) -> None: def test_attribute_requires_both_fields(self) -> None:
status, _ = dispatch( status, _ = dispatch(
self.orch, "POST", "/attribute", _body({"source_ip": "10.243.0.5"}) self.store, "POST", "/attribute", _body({"source_ip": "10.243.0.5"})
) )
self.assertEqual(400, status) self.assertEqual(400, status)
def test_delete(self) -> None: def test_delete(self) -> None:
_, reg = dispatch( _, reg = dispatch(
self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.1"}) self.store, "POST", "/bottles", _body({"source_ip": "10.243.0.1"})
) )
bid = reg["bottle_id"] bid = reg["bottle_id"]
status, payload = dispatch(self.orch, "DELETE", f"/bottles/{bid}", b"") status, payload = dispatch(self.store, "DELETE", f"/bottles/{bid}", b"")
self.assertEqual(200, status) self.assertEqual(200, status)
self.assertEqual(True, payload["torn_down"]) self.assertEqual(True, payload["deregistered"])
self.assertEqual([], self.orch.registry.all()) self.assertEqual([], self.store.all())
def test_delete_missing_404(self) -> None: def test_delete_missing_404(self) -> None:
status, _ = dispatch(self.orch, "DELETE", "/bottles/ghost", b"") status, _ = dispatch(self.store, "DELETE", "/bottles/ghost", b"")
self.assertEqual(404, status) self.assertEqual(404, status)
def test_unknown_route_404(self) -> None: def test_unknown_route_404(self) -> None:
status, _ = dispatch(self.orch, "GET", "/nope", b"") status, _ = dispatch(self.store, "GET", "/nope", b"")
self.assertEqual(404, status) self.assertEqual(404, status)
def test_trailing_slash_normalized(self) -> None: def test_trailing_slash_normalized(self) -> None:
status, _ = dispatch(self.orch, "GET", "/health/", b"") status, _ = dispatch(self.store, "GET", "/health/", b"")
self.assertEqual(200, status) self.assertEqual(200, status)
def test_sidecar_status_unconfigured(self) -> None:
status, payload = dispatch(self.orch, "GET", "/sidecar", b"")
self.assertEqual(200, status)
self.assertEqual(False, payload["configured"])
def test_launch_stores_policy_and_resolve_returns_it(self) -> None:
_, reg = dispatch(
self.orch, "POST", "/bottles",
_body({"source_ip": "10.243.0.5", "policy": '{"a":1}'}),
)
status, payload = dispatch(
self.orch, "POST", "/resolve",
_body({"source_ip": "10.243.0.5", "identity_token": reg["identity_token"]}),
)
self.assertEqual(200, status)
self.assertEqual(reg["bottle_id"], payload["bottle_id"])
self.assertEqual('{"a":1}', payload["policy"])
def test_resolve_forbidden_on_bad_token(self) -> None:
dispatch(self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.5"}))
status, _ = dispatch(
self.orch, "POST", "/resolve",
_body({"source_ip": "10.243.0.5", "identity_token": "nope"}),
)
self.assertEqual(403, status)
def test_put_policy_updates_live(self) -> None:
_, reg = dispatch(self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.1"}))
bid = reg["bottle_id"]
status, payload = dispatch(
self.orch, "PUT", f"/bottles/{bid}/policy", _body({"policy": '{"v":9}'})
)
self.assertEqual(200, status)
self.assertEqual(True, payload["updated"])
_, resolved = dispatch(
self.orch, "POST", "/resolve",
_body({"source_ip": "10.243.0.1", "identity_token": reg["identity_token"]}),
)
self.assertEqual('{"v":9}', resolved["policy"])
def test_put_policy_missing_bottle_404(self) -> None:
status, _ = dispatch(
self.orch, "PUT", "/bottles/ghost/policy", _body({"policy": "{}"})
)
self.assertEqual(404, status)
def test_put_policy_requires_string(self) -> None:
_, reg = dispatch(self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.1"}))
status, _ = dispatch(
self.orch, "PUT", f"/bottles/{reg['bottle_id']}/policy", _body({})
)
self.assertEqual(400, status)
def test_resolve_without_token_by_source_ip(self) -> None:
_, reg = dispatch(
self.orch, "POST", "/bottles",
_body({"source_ip": "10.243.0.5", "policy": "P"}),
)
status, payload = dispatch(
self.orch, "POST", "/resolve", _body({"source_ip": "10.243.0.5"})
)
self.assertEqual(200, status)
self.assertEqual(reg["bottle_id"], payload["bottle_id"])
self.assertEqual("P", payload["policy"])
def test_resolve_requires_source_ip(self) -> None:
status, _ = dispatch(self.orch, "POST", "/resolve", _body({}))
self.assertEqual(400, status)
class TestServerRoundTrip(unittest.TestCase): class TestServerRoundTrip(unittest.TestCase):
def test_http_register_health_attribute(self) -> None: def test_http_register_health_attribute(self) -> None:
tmp = tempfile.TemporaryDirectory() tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup) self.addCleanup(tmp.cleanup)
orch = _orchestrator(Path(tmp.name) / "r.db") store = RegistryStore(Path(tmp.name) / "r.db")
server = make_server(orch, "127.0.0.1", 0) store.migrate()
server = make_server(store, "127.0.0.1", 0)
self.addCleanup(server.server_close) self.addCleanup(server.server_close)
thread = threading.Thread(target=server.serve_forever, daemon=True) thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start() thread.start()
@@ -1,100 +0,0 @@
"""Unit tests for the Docker launch broker (PRD 0070). Docker is mocked."""
from __future__ import annotations
import secrets
import unittest
from unittest.mock import Mock, patch
from bot_bottle.orchestrator.broker import (
BrokerAuthError,
LaunchRequest,
sign_request,
)
from bot_bottle.orchestrator.docker_broker import (
BOTTLE_ID_LABEL,
DockerBroker,
DockerBrokerError,
container_name,
rm_argv,
run_argv,
)
class TestArgv(unittest.TestCase):
def test_run_argv_uses_only_static_fields(self) -> None:
req = LaunchRequest(op="launch", bottle_id="b1", image_ref="busybox")
argv = run_argv(req)
self.assertEqual(["docker", "run"], argv[:2])
self.assertIn("--name", argv)
self.assertIn(container_name("b1"), argv)
self.assertIn(f"{BOTTLE_ID_LABEL}=b1", argv)
self.assertEqual("busybox", argv[-1]) # image is the terminal arg
def test_rm_argv(self) -> None:
req = LaunchRequest(op="teardown", bottle_id="b1")
self.assertEqual(["docker", "rm", "--force", container_name("b1")], rm_argv(req))
def test_container_name_is_prefixed(self) -> None:
name = container_name("b1")
self.assertTrue(name.startswith("bot-bottle-orch-"))
self.assertTrue(name.endswith("b1"))
class TestDockerBroker(unittest.TestCase):
def setUp(self) -> None:
self.secret = secrets.token_bytes(16)
self.broker = DockerBroker(self.secret)
def _submit(self, req: LaunchRequest) -> None:
self.broker.submit(sign_request(req, self.secret))
def test_launch_invokes_docker_run(self) -> None:
req = LaunchRequest(op="launch", bottle_id="b1", image_ref="busybox")
with patch.object(self.broker, "_docker", return_value=Mock(returncode=0, stderr="")) as m:
self._submit(req)
m.assert_called_once()
self.assertEqual(run_argv(req), m.call_args.args[0])
def test_launch_without_image_raises_and_skips_docker(self) -> None:
req = LaunchRequest(op="launch", bottle_id="b1", image_ref="")
with patch.object(self.broker, "_docker") as m:
with self.assertRaises(DockerBrokerError):
self._submit(req)
m.assert_not_called()
def test_launch_docker_failure_raises(self) -> None:
req = LaunchRequest(op="launch", bottle_id="b1", image_ref="busybox")
with patch.object(self.broker, "_docker", return_value=Mock(returncode=1, stderr="boom")):
with self.assertRaises(DockerBrokerError):
self._submit(req)
def test_teardown_invokes_docker_rm(self) -> None:
req = LaunchRequest(op="teardown", bottle_id="b1")
with patch.object(self.broker, "_docker", return_value=Mock(returncode=0, stderr="")) as m:
self._submit(req)
self.assertEqual(rm_argv(req), m.call_args.args[0])
def test_teardown_is_idempotent_on_missing_container(self) -> None:
req = LaunchRequest(op="teardown", bottle_id="b1")
absent = Mock(returncode=1, stderr="Error: No such container: bot-bottle-orch-b1")
with patch.object(self.broker, "_docker", return_value=absent):
self._submit(req) # must not raise
def test_teardown_other_failure_raises(self) -> None:
req = LaunchRequest(op="teardown", bottle_id="b1")
with patch.object(self.broker, "_docker", return_value=Mock(returncode=1, stderr="daemon down")):
with self.assertRaises(DockerBrokerError):
self._submit(req)
def test_forged_token_never_touches_docker(self) -> None:
req = LaunchRequest(op="launch", bottle_id="b1", image_ref="busybox")
forged = sign_request(req, secrets.token_bytes(16))
with patch.object(self.broker, "_docker") as m:
with self.assertRaises(BrokerAuthError):
self.broker.submit(forged)
m.assert_not_called()
if __name__ == "__main__":
unittest.main()
-36
View File
@@ -97,42 +97,6 @@ class TestRegistryStore(unittest.TestCase):
self.assertNotIn("identity_token", rec.redacted()) self.assertNotIn("identity_token", rec.redacted())
self.assertEqual("10.243.0.1", rec.redacted()["source_ip"]) self.assertEqual("10.243.0.1", rec.redacted()["source_ip"])
def test_register_with_policy_resolves_via_attribution(self) -> None:
rec = self.store.register("10.243.0.1", policy='{"allow":["x"]}')
self.assertEqual('{"allow":["x"]}', rec.policy)
got = self.store.attribute("10.243.0.1", rec.identity_token)
assert got is not None
self.assertEqual('{"allow":["x"]}', got.policy)
def test_set_policy_updates_live(self) -> None:
rec = self.store.register("10.243.0.1")
self.assertEqual("", rec.policy)
self.assertTrue(self.store.set_policy(rec.bottle_id, '{"v":2}'))
got = self.store.get(rec.bottle_id)
assert got is not None
self.assertEqual('{"v":2}', got.policy)
def test_set_policy_missing_is_false(self) -> None:
self.assertFalse(self.store.set_policy("ghost", "{}"))
def test_policy_defaults_empty_and_persists(self) -> None:
rec = self.store.register("10.243.0.1")
got = RegistryStore(self.db).get(rec.bottle_id)
assert got is not None
self.assertEqual("", got.policy)
def test_by_source_ip_returns_single_active(self) -> None:
rec = self.store.register("10.243.0.1")
got = self.store.by_source_ip("10.243.0.1")
assert got is not None
self.assertEqual(rec.bottle_id, got.bottle_id)
def test_by_source_ip_unknown_or_ambiguous_denied(self) -> None:
self.assertIsNone(self.store.by_source_ip("10.243.9.9")) # unknown
self.store.register("10.243.0.1", bottle_id="a")
self.store.register("10.243.0.1", bottle_id="b")
self.assertIsNone(self.store.by_source_ip("10.243.0.1")) # ambiguous
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
-144
View File
@@ -1,144 +0,0 @@
"""Unit tests for the Orchestrator launch lifecycle (PRD 0070)."""
from __future__ import annotations
import secrets
import tempfile
import unittest
from pathlib import Path
from bot_bottle.orchestrator.broker import LaunchBroker, LaunchRequest, StubBroker
from bot_bottle.orchestrator.registry import RegistryStore
from bot_bottle.orchestrator.service import Orchestrator
from bot_bottle.orchestrator.sidecar import Sidecar
class _FailingBroker(LaunchBroker):
"""Verifies the token like any broker, then fails the launch — to
exercise the orchestrator's registry rollback."""
def _launch(self, req: LaunchRequest) -> None:
raise RuntimeError("launch failed")
def _teardown(self, req: LaunchRequest) -> None:
pass
class _FakeSidecar(Sidecar):
"""In-memory sidecar for wiring tests."""
def __init__(self) -> None:
self.name = "fake-sidecar"
self.ensured = 0
self.built = 0
self._running = False
def ensure_built(self) -> None:
self.built += 1
def ensure_running(self) -> None:
self.ensured += 1
self._running = True
def is_running(self) -> bool:
return self._running
def stop(self) -> None:
self._running = False
class TestOrchestrator(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.secret = secrets.token_bytes(16)
self.store = RegistryStore(Path(self._tmp.name) / "r.db")
self.store.migrate()
self.broker = StubBroker(self.secret)
self.orch = Orchestrator(self.store, self.broker, self.secret)
def tearDown(self) -> None:
self._tmp.cleanup()
def test_launch_registers_and_brokers_signed_request(self) -> None:
rec = self.orch.launch_bottle("10.243.0.1", image_ref="sha256:abc", slot=2)
self.assertIsNotNone(self.store.get(rec.bottle_id))
self.assertEqual(1, len(self.broker.launched))
req = self.broker.launched[0]
self.assertEqual("launch", req.op)
self.assertEqual(rec.bottle_id, req.bottle_id)
self.assertEqual("10.243.0.1", req.source_ip)
self.assertEqual("sha256:abc", req.image_ref)
self.assertEqual(2, req.slot)
def test_launch_then_attribute(self) -> None:
rec = self.orch.launch_bottle("10.243.0.3")
got = self.orch.attribute("10.243.0.3", rec.identity_token)
assert got is not None
self.assertEqual(rec.bottle_id, got.bottle_id)
self.assertIsNone(self.orch.attribute("10.243.0.3", "wrong-token"))
def test_teardown_brokers_and_deregisters(self) -> None:
rec = self.orch.launch_bottle("10.243.0.1")
self.assertTrue(self.orch.teardown_bottle(rec.bottle_id))
self.assertIsNone(self.store.get(rec.bottle_id))
self.assertEqual(["teardown"], [r.op for r in self.broker.torn_down])
def test_teardown_unknown_is_false(self) -> None:
self.assertFalse(self.orch.teardown_bottle("ghost"))
def test_launch_with_policy_is_resolvable(self) -> None:
rec = self.orch.launch_bottle("10.243.0.1", policy='{"routes":[]}')
got = self.orch.attribute("10.243.0.1", rec.identity_token)
assert got is not None
self.assertEqual('{"routes":[]}', got.policy)
def test_set_policy_live_reload(self) -> None:
rec = self.orch.launch_bottle("10.243.0.3")
self.assertTrue(self.orch.set_policy(rec.bottle_id, '{"x":1}'))
got = self.orch.attribute("10.243.0.3", rec.identity_token)
assert got is not None
self.assertEqual('{"x":1}', got.policy)
def test_set_policy_unknown_is_false(self) -> None:
self.assertFalse(self.orch.set_policy("ghost", "{}"))
def test_resolve_by_source_ip_without_token(self) -> None:
rec = self.orch.launch_bottle("10.243.0.1", policy="P")
got = self.orch.resolve("10.243.0.1") # network-layer, no token
assert got is not None
self.assertEqual(rec.bottle_id, got.bottle_id)
self.assertEqual("P", got.policy)
def test_resolve_with_token_stays_strict(self) -> None:
rec = self.orch.launch_bottle("10.243.0.3")
self.assertIsNotNone(self.orch.resolve("10.243.0.3", rec.identity_token))
self.assertIsNone(self.orch.resolve("10.243.0.3", "wrong-token"))
def test_launch_rolls_back_registry_on_broker_failure(self) -> None:
orch = Orchestrator(self.store, _FailingBroker(self.secret), self.secret)
with self.assertRaises(RuntimeError):
orch.launch_bottle("10.243.0.9")
self.assertEqual([], self.store.all()) # no orphan
def test_sidecar_unconfigured_by_default(self) -> None:
self.assertEqual({"configured": False}, self.orch.sidecar_status())
self.orch.ensure_sidecar() # no-op, must not raise
def test_sidecar_wired_and_ensured(self) -> None:
sc = _FakeSidecar()
orch = Orchestrator(self.store, self.broker, self.secret, sidecar=sc)
self.assertEqual(
{"configured": True, "name": "fake-sidecar", "running": False},
orch.sidecar_status(),
)
orch.ensure_sidecar()
self.assertEqual(1, sc.built) # ensure_sidecar builds first,
self.assertEqual(1, sc.ensured) # then runs
self.assertEqual(
{"configured": True, "name": "fake-sidecar", "running": True},
orch.sidecar_status(),
)
if __name__ == "__main__":
unittest.main()
-129
View File
@@ -1,129 +0,0 @@
"""Unit tests for the consolidated Docker sidecar (PRD 0070). Docker mocked."""
from __future__ import annotations
import unittest
from unittest.mock import Mock, patch
from bot_bottle.orchestrator.sidecar import (
SIDECAR_NAME,
DockerSidecar,
SidecarError,
)
_RUN_DOCKER = "bot_bottle.orchestrator.sidecar.run_docker"
def _proc(returncode: int = 0, stdout: str = "", stderr: str = "") -> Mock:
return Mock(returncode=returncode, stdout=stdout, stderr=stderr)
class TestDockerSidecar(unittest.TestCase):
def setUp(self) -> None:
self.sc = DockerSidecar("bot-bottle-sidecars:latest")
def test_default_name(self) -> None:
self.assertEqual(SIDECAR_NAME, self.sc.name)
def test_is_running_reads_docker_ps(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(stdout=self.sc.name + "\n")):
self.assertTrue(self.sc.is_running())
with patch(_RUN_DOCKER, return_value=_proc(stdout="")):
self.assertFalse(self.sc.is_running())
def test_ensure_running_is_noop_when_already_up(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(stdout=self.sc.name)) as m:
self.sc.ensure_running()
# Only the is_running() ps probe — no rm / run.
self.assertEqual(1, m.call_count)
def test_ensure_running_starts_the_singleton_when_absent(self) -> None:
calls: list[list[str]] = []
def fake(argv: list[str]) -> Mock:
calls.append(argv)
return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc()
with patch(_RUN_DOCKER, side_effect=fake):
self.sc.ensure_running()
runs = [c for c in calls if c[:2] == ["docker", "run"]]
self.assertEqual(1, len(runs))
self.assertIn(self.sc.name, runs[0])
self.assertIn("bot-bottle-sidecars:latest", runs[0])
def test_ensure_running_raises_on_docker_failure(self) -> None:
def fake(argv: list[str]) -> Mock:
if argv[:2] == ["docker", "ps"]:
return _proc(stdout="")
if argv[:2] == ["docker", "run"]:
return _proc(returncode=1, stderr="boom")
return _proc()
with patch(_RUN_DOCKER, side_effect=fake):
with self.assertRaises(SidecarError):
self.sc.ensure_running()
def test_stop_is_idempotent_on_missing(self) -> None:
absent = _proc(returncode=1, stderr="Error: No such container: x")
with patch(_RUN_DOCKER, return_value=absent):
self.sc.stop() # must not raise
def test_stop_raises_on_other_failure(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(returncode=1, stderr="daemon down")):
with self.assertRaises(SidecarError):
self.sc.stop()
class TestDockerSidecarBuild(unittest.TestCase):
def setUp(self) -> None:
self.sc = DockerSidecar() # defaults to the real bundle image + dockerfile
def test_image_exists_reads_docker_inspect(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(returncode=0)):
self.assertTrue(self.sc.image_exists())
with patch(_RUN_DOCKER, return_value=_proc(returncode=1)):
self.assertFalse(self.sc.image_exists())
def test_ensure_built_is_noop_when_image_present(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(returncode=0)) as m:
self.sc.ensure_built()
self.assertEqual(1, m.call_count) # only the image-inspect probe
def test_ensure_built_builds_from_dockerfile_when_missing(self) -> None:
calls: list[list[str]] = []
def fake(argv: list[str]) -> Mock:
calls.append(argv)
if argv[:3] == ["docker", "image", "inspect"]:
return _proc(returncode=1) # missing
return _proc()
with patch(_RUN_DOCKER, side_effect=fake):
self.sc.ensure_built()
builds = [c for c in calls if c[:2] == ["docker", "build"]]
self.assertEqual(1, len(builds))
self.assertIn(self.sc.image_ref, builds[0])
self.assertIn("-f", builds[0])
self.assertTrue(any(a.endswith("Dockerfile.sidecars") for a in builds[0]))
def test_ensure_built_noop_when_no_dockerfile(self) -> None:
sc = DockerSidecar("busybox", dockerfile=None)
with patch(_RUN_DOCKER) as m:
sc.ensure_built()
m.assert_not_called()
def test_ensure_built_raises_on_build_failure(self) -> None:
def fake(argv: list[str]) -> Mock:
if argv[:3] == ["docker", "image", "inspect"]:
return _proc(returncode=1)
return _proc(returncode=1, stderr="build boom")
with patch(_RUN_DOCKER, side_effect=fake):
with self.assertRaises(SidecarError):
self.sc.ensure_built()
if __name__ == "__main__":
unittest.main()
-76
View File
@@ -1,76 +0,0 @@
"""Unit tests for the sidecar-side PolicyResolver (PRD 0070). HTTP mocked."""
from __future__ import annotations
import json
import unittest
import urllib.error
from unittest.mock import MagicMock, patch
from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver
_URLOPEN = "bot_bottle.policy_resolver.urllib.request.urlopen"
def _resp(payload: object) -> MagicMock:
"""A urlopen() return value: a context manager whose read() yields JSON."""
m = MagicMock()
m.__enter__.return_value.read.return_value = json.dumps(payload).encode()
return m
def _http_error(code: int) -> urllib.error.HTTPError:
return urllib.error.HTTPError("http://x/resolve", code, "err", {}, None) # type: ignore[arg-type]
class TestPolicyResolver(unittest.TestCase):
def setUp(self) -> None:
self.r = PolicyResolver("http://orch:8080")
def test_resolve_returns_policy(self) -> None:
with patch(_URLOPEN, return_value=_resp({"bottle_id": "b1", "policy": "P"})):
self.assertEqual("P", self.r.resolve("10.243.0.1", "tok"))
def test_resolve_always_fetches_fresh(self) -> None:
# No cache — every resolve hits the orchestrator so revocations /
# policy changes are honored immediately.
with patch(_URLOPEN, return_value=_resp({"policy": "P"})) as m:
self.r.resolve("10.243.0.1", "tok")
self.r.resolve("10.243.0.1", "tok")
self.assertEqual(2, m.call_count)
def test_unattributed_403_is_none_fail_closed(self) -> None:
with patch(_URLOPEN, side_effect=_http_error(403)):
self.assertIsNone(self.r.resolve("10.243.0.9", "tok"))
def test_other_http_error_raises(self) -> None:
with patch(_URLOPEN, side_effect=_http_error(500)):
with self.assertRaises(PolicyResolveError):
self.r.resolve("10.243.0.1", "tok")
def test_unreachable_raises(self) -> None:
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
with self.assertRaises(PolicyResolveError):
self.r.resolve("10.243.0.1", "tok")
def test_missing_policy_field_is_empty(self) -> None:
with patch(_URLOPEN, return_value=_resp({"bottle_id": "b1"})):
self.assertEqual("", self.r.resolve("10.243.0.1", "tok"))
def test_posts_source_ip_and_token(self) -> None:
with patch(_URLOPEN, return_value=_resp({"policy": "P"})) as m:
self.r.resolve("10.243.0.7", "the-token")
req = m.call_args.args[0]
self.assertTrue(req.full_url.endswith("/resolve"))
sent = json.loads(req.data)
self.assertEqual("10.243.0.7", sent["source_ip"])
self.assertEqual("the-token", sent["identity_token"])
def test_resolve_without_token_sends_empty(self) -> None:
with patch(_URLOPEN, return_value=_resp({"policy": "P"})) as m:
self.r.resolve("10.243.0.7") # token optional
self.assertEqual("", json.loads(m.call_args.args[0].data)["identity_token"])
if __name__ == "__main__":
unittest.main()