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
41 changed files with 195 additions and 2973 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_dlp_config.py /app/egress_dlp_config.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/yaml_subset.py /app/yaml_subset.py
COPY bot_bottle/paths.py /app/paths.py
-47
View File
@@ -1,47 +0,0 @@
"""Shared-gateway source-IP allocation for the consolidated docker backend
(PRD 0070).
In the consolidated model one gateway container serves every bottle over a
single shared docker network, and each agent bottle attaches with a pinned,
deterministic address that the gateway uses as its **attribution key**. This
allocates those addresses from the network's subnet, skipping the reserved
ones — the network address and broadcast (excluded by `hosts()`), docker's
router `.1`, and everything already in use (`taken`: the gateway container
plus every live bottle, which the caller reads from the registry).
Pure `ipaddress` logic — the docker-specific bits (the subnet CIDR, the
gateway container's own address) are gathered by the caller and passed in, so
this stays testable without docker.
"""
from __future__ import annotations
import ipaddress
from collections.abc import Iterable
class NoFreeAddressError(RuntimeError):
"""The shared gateway network's subnet is exhausted — every host address
is reserved or already assigned to a bottle."""
def next_free_ip(cidr: str, taken: Iterable[str]) -> str:
"""The lowest host address in `cidr` not in `taken` and not docker's
router (`.1`). `taken` must include the gateway container's own address
and every live bottle's. Raises `NoFreeAddressError` if the subnet is
full."""
net = ipaddress.ip_network(cidr, strict=False)
reserved = {str(a) for a in taken}
# Docker assigns the network's first host (.1) to the bridge router; a
# bottle must never be handed that address.
reserved.add(str(net.network_address + 1))
for host in net.hosts(): # hosts() already excludes network + broadcast
candidate = str(host)
if candidate not in reserved:
return candidate
raise NoFreeAddressError(
f"no free address in {cidr} ({len(reserved)} reserved/assigned)"
)
__all__ = ["next_free_ip", "NoFreeAddressError"]
-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"]
+25 -88
View File
@@ -1,4 +1,4 @@
"""mitmproxy addon entrypoint for the egress gateway (PRD 0017, PRD 0053).
"""mitmproxy addon entrypoint for the egress sidecar (PRD 0017, PRD 0053).
Loaded by `mitmdump -s /app/egress_addon.py` inside the
egress container."""
@@ -32,7 +32,6 @@ from egress_addon_core import ( # type: ignore[import-not-found] # pylint: dis
is_git_push_request,
load_config,
match_route,
resolve_client_context,
outbound_scan_headers,
route_to_yaml_dict,
scan_inbound,
@@ -52,26 +51,11 @@ try:
except ImportError: # pragma: no cover - host-side path
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"
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
# operator's supervisor decision (PRD 0062), overridable via env.
DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS = 300.0
@@ -88,40 +72,20 @@ _TOKEN_ALLOW_JUSTIFICATION = (
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:
self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH)
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), keyed by
# bottle so the shared gateway keeps each bottle's safelist separate —
# a global set would let bottle A's approved secret pass bottle B's DLP
# scan. In-memory only (a restart re-prompts); mutated only from the
# asyncio loop that runs the addon hooks, so no lock is needed.
self._safe_tokens: dict[str, set[str]] = {}
# Tokens the operator has approved this session (PRD 0062). In-memory
# only — a restart re-prompts. Mutated only from the asyncio loop that
# runs the addon hooks, so no lock is needed.
self.safe_tokens: set[str] = set()
self._supervise_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip()
self._token_allow_timeout = _token_allow_timeout_from_env(os.environ)
self._reload(initial=True)
self._install_sighup()
@staticmethod
def _supervise_available(slug: str) -> bool:
"""Supervise is reachable for this request iff we resolved a bottle to
attribute its proposals to (single-tenant env slug, or a source-IP
-attributed bottle id). Empty → fail closed (no queue to write to)."""
return bool(slug)
def _safe_tokens_for(self, slug: str) -> set[str]:
"""This bottle's operator-approved DLP safelist (PRD 0062), created on
first use. Keyed by bottle so the shared gateway never leaks one
bottle's approved token into another's scan."""
return self._safe_tokens.setdefault(slug, set())
def _supervise_available(self) -> bool:
return bool(self._supervise_slug)
def _reload(self, *, initial: bool = False) -> None:
try:
@@ -230,22 +194,6 @@ class EgressAddon:
+ "\n"
)
def _resolve_flow(self, flow: http.HTTPFlow) -> "tuple[Config, str]":
"""The `(Config, supervise slug)` to apply to this request. Single-tenant
→ the static `self.config` and the env slug. Consolidated → the calling
bottle's Config and its bottle id, resolved by source IP in one
round-trip (fail-closed to deny-all + empty slug if unattributed). The
identity token, if the agent injected one, is read then stripped so it
never leaks upstream. The slug keys both the proposal queue and the
per-bottle safelist, so an approval only ever affects its own bottle."""
if self._resolver is None:
return self.config, self._supervise_slug
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_context(self._resolver, client_ip, token)
async def request(self, flow: http.HTTPFlow) -> None:
request_path, _, query = flow.request.path.partition("?")
@@ -253,14 +201,12 @@ class EgressAddon:
self._serve_introspection(flow, request_path)
return
config, slug = self._resolve_flow(flow)
# DLP outbound scan BEFORE stripping auth — catches tokens the
# agent tried to smuggle in any header, path, query param, or body.
# 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 not await self._handle_outbound_dlp(flow, route, slug):
if not await self._handle_outbound_dlp(flow, route):
return
# The redact policy may have rewritten the request line; recompute
# the path/query the git checks below rely on.
@@ -278,7 +224,7 @@ class EgressAddon:
if is_git_fetch_request(request_path, query):
git_decision = decide_git_fetch(
config.routes, flow.request.pretty_host,
self.config.routes, flow.request.pretty_host,
)
if git_decision.action == "block":
self._block(
@@ -289,14 +235,14 @@ class EgressAddon:
return
# Strip agent-set Authorization after DLP scan so smuggled tokens
# are caught above; the route may inject gateway-owned auth below.
# are caught above; the route may inject sidecar-owned auth below.
flow.request.headers.pop("authorization", None)
# Build headers mapping for match evaluation
req_headers = {k.lower(): v for k, v in flow.request.headers.items()}
decision = decide(
config.routes,
self.config.routes,
flow.request.pretty_host,
request_path,
os.environ,
@@ -311,7 +257,7 @@ class EgressAddon:
if decision.inject_authorization is not None:
flow.request.headers["authorization"] = decision.inject_authorization
if config.log >= LOG_FULL:
if self.config.log >= LOG_FULL:
self._log_request(flow)
def _block_dlp(self, flow: http.HTTPFlow, result: ScanResult) -> None:
@@ -324,7 +270,6 @@ class EgressAddon:
self,
flow: http.HTTPFlow,
route: Route,
slug: str,
) -> bool:
"""Scan the outbound request and apply the route's on-match policy
(PRD 0062). Returns True if the request may be forwarded, False if a
@@ -346,7 +291,7 @@ class EgressAddon:
)
result = scan_outbound(
route, scan_text, os.environ,
safe_tokens=self._safe_tokens_for(slug), crlf_text=crlf_text,
safe_tokens=self.safe_tokens, crlf_text=crlf_text,
)
if result is None or result.severity != "block":
return True
@@ -381,10 +326,10 @@ class EgressAddon:
# supervise (default): hold the request for operator approval.
# Fall back to a hard 403 when supervise isn't wired for the bottle.
if not self._supervise_available(slug):
if not self._supervise_available():
self._block_dlp(flow, result)
return False
approved = await self._supervise_token_block(flow, request_path, result, slug)
approved = await self._supervise_token_block(flow, request_path, result)
if not approved:
return False # _supervise_token_block wrote the 403 response
# loop: the approved value is now in safe_tokens; re-scan.
@@ -427,16 +372,12 @@ class EgressAddon:
flow: http.HTTPFlow,
request_path: str,
result: ScanResult,
slug: str,
) -> bool:
"""Route a token DLP block to the operator's supervisor queue and wait.
`slug` attributes the proposal to the calling bottle (its own queue +
safelist) — in the shared gateway this is what keeps one bottle's
approval from unblocking another's request. Returns True if the operator
approved (the matched value is added to that bottle's safelist and the
caller re-scans); False if the request must be blocked (a 403 response
has been written to `flow`)."""
Returns True if the operator approved (the matched value is added to
`self.safe_tokens` and the caller re-scans); False if the request must
be blocked (a 403 response has been written to `flow`)."""
host = flow.request.pretty_host
payload = build_token_allow_payload(
redact_tokens(host, env=os.environ),
@@ -445,7 +386,7 @@ class EgressAddon:
result,
)
proposal = _sv.Proposal.new(
bottle_slug=slug,
bottle_slug=self._supervise_slug,
tool=_sv.TOOL_EGRESS_TOKEN_ALLOW,
proposed_file=payload,
justification=_TOKEN_ALLOW_JUSTIFICATION,
@@ -468,13 +409,13 @@ class EgressAddon:
**self._req_ctx(flow),
}) + "\n")
response = await self._await_token_response(proposal.id, slug)
_sv.archive_proposal(slug, proposal.id)
response = await self._await_token_response(proposal.id)
_sv.archive_proposal(self._supervise_slug, proposal.id)
if response is not None and response.status in (
_sv.STATUS_APPROVED, _sv.STATUS_MODIFIED,
):
self._safe_tokens_for(slug).add(result.matched)
self.safe_tokens.add(result.matched)
if self.config.log >= LOG_BLOCKS:
sys.stderr.write(json.dumps({
"event": "egress_token_allowed",
@@ -497,7 +438,6 @@ class EgressAddon:
async def _await_token_response(
self,
proposal_id: str,
slug: str,
) -> "_sv.Response | None":
"""Poll the DB for the operator's response without blocking the
proxy event loop. Returns the Response, or None on timeout."""
@@ -505,7 +445,7 @@ class EgressAddon:
deadline = loop.time() + self._token_allow_timeout
while True:
try:
return _sv.read_response(slug, proposal_id)
return _sv.read_response(self._supervise_slug, proposal_id)
except (OSError, ValueError, KeyError):
# Not written yet, or a partial/malformed write — retry until
# the deadline, then fail closed.
@@ -559,9 +499,6 @@ class EgressAddon:
"""
if flow.websocket is None: # type: ignore[union-attr]
return
# WebSocket DLP runs against the static config only (single-tenant); in
# the consolidated gateway self.config has no routes, so this is inert
# until websocket routing is made source-IP-aware (a separate slice).
route = match_route(self.config.routes, flow.request.pretty_host)
if route is None:
return
@@ -572,7 +509,7 @@ class EgressAddon:
# not an injection vector here — scan only for credential leakage.
result = scan_outbound(
route, content, os.environ,
safe_tokens=self._safe_tokens_for(self._supervise_slug), crlf_text="",
safe_tokens=self.safe_tokens, crlf_text="",
)
if result is not None and result.severity == "block":
sys.stderr.write(f"egress DLP: {result.reason}\n")
+5 -70
View File
@@ -3,7 +3,7 @@
Split out of `egress_addon.py` so the host's unit tests can
exercise the parse + decision functions without depending on the
`mitmproxy` package. The companion module wraps these with the
`mitmproxy.http.HTTPFlow` API and is loaded inside the gateway
`mitmproxy.http.HTTPFlow` API and is loaded inside the sidecar
container.
Imports: stdlib + `yaml_subset` (which is itself stdlib-only and
@@ -22,7 +22,7 @@ except ImportError: # pragma: no cover - host-side path
from .yaml_subset import YamlSubsetError, parse_yaml_subset
# DLP detector-config parsing lives in a sibling module (also flat-bundled
# into the gateway — see Dockerfile.sidecars). Re-exported below so existing
# into the sidecar — see Dockerfile.sidecars). Re-exported below so existing
# `from egress_addon_core import ON_MATCH_*` callers keep working.
try:
from egress_dlp_config import ( # type: ignore[import-not-found]
@@ -120,7 +120,7 @@ class ScanResult:
reason: str
location: str = "" # where the match was found, e.g. "body", "authorization header"
context: str = "" # surrounding text with the match replaced by REDACT
# Raw substring the detector matched. Used inside the gateway to key the
# Raw substring the detector matched. Used inside the sidecar to key the
# supervisor-approved "safe tokens" set (PRD 0062); never logged or written
# to a proposal file. Empty for structural detectors (CRLF) that carry no
# safelist-able value.
@@ -413,67 +413,6 @@ def load_config(text: str) -> "Config":
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 _config_from_policy(policy: "str | None") -> "Config":
"""Parse a resolved policy blob into a Config, fail-closed: None / empty /
unparseable all become a deny-all Config (no routes → every request
blocked)."""
if not policy:
return Config(routes=()) # unattributed or empty → deny-all
try:
return load_config(policy)
except ValueError:
return Config(routes=()) # unparseable policy → deny
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
return _config_from_policy(policy)
class ContextResolverLike(typing.Protocol):
"""The bit of `policy_resolver.PolicyResolver` `resolve_client_context`
needs — one round-trip returning both policy and bottle id."""
def resolve_policy_and_bottle_id(
self, source_ip: str, identity_token: str = ...,
) -> "tuple[str | None, str | None]":
...
def resolve_client_context(
resolver: ContextResolverLike, client_ip: str, identity_token: str = "",
) -> "tuple[Config, str]":
"""The calling client's `(Config, bottle_id)` in one round-trip —
**fail-closed**. The Config follows `resolve_client_config`'s deny-all
rules; the bottle id is `""` whenever unattributed or the orchestrator
errored, which the caller treats as "supervise unavailable for this
bottle" (never another bottle's queue). One `/resolve` keys both the
per-request egress policy and the per-bottle supervise queue + safelist."""
try:
policy, bottle_id = resolver.resolve_policy_and_bottle_id(client_ip, identity_token)
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
return Config(routes=()), "" # orchestrator unreachable/errored → deny
return _config_from_policy(policy), (bottle_id or "")
# ---------------------------------------------------------------------------
# Match evaluation
# ---------------------------------------------------------------------------
@@ -674,7 +613,7 @@ def outbound_scan_headers(
) -> dict[str, str]:
"""Return request headers that should be included in outbound DLP.
Routes that inject gateway-owned auth always strip the agent's
Routes that inject sidecar-owned auth always strip the agent's
Authorization header before forwarding. Scanning that header first
creates false positives for provider clients that insist on sending
their own bearer-shaped placeholder, while still not changing what
@@ -725,7 +664,7 @@ def scan_outbound(
crlf_text: str | None = None,
) -> ScanResult | None:
# Lazy import to avoid circular deps and keep dlp_detectors optional
# at import time (the gateway copies it flat alongside this file).
# at import time (the sidecar copies it flat alongside this file).
try:
from dlp_detectors import ( # type: ignore[import-not-found]
scan_crlf_injection,
@@ -865,10 +804,6 @@ __all__ = [
"is_git_push_request",
"is_git_fetch_request",
"load_config",
"resolve_client_config",
"resolve_client_context",
"PolicyResolverLike",
"ContextResolverLike",
"match_route",
"outbound_scan_headers",
"parse_config",
-2
View File
@@ -46,7 +46,6 @@ from .git_gate_render import (
git_gate_known_hosts_line,
git_gate_render_access_hook,
git_gate_render_entrypoint,
git_gate_render_provision,
git_gate_render_gitconfig,
git_gate_render_hook,
git_gate_upstreams_for_bottle,
@@ -156,7 +155,6 @@ __all__ = [
"git_gate_render_gitconfig",
"git_gate_known_hosts_line",
"git_gate_render_entrypoint",
"git_gate_render_provision",
"git_gate_render_hook",
"git_gate_render_access_hook",
"provision_git_gate_dynamic_keys",
+21 -57
View File
@@ -9,7 +9,6 @@ own; `git_gate` re-exports these names for API stability."""
from __future__ import annotations
import re
import shlex
from dataclasses import dataclass
from pathlib import Path
@@ -126,18 +125,22 @@ def git_gate_known_hosts_line(host: str, port: str, key: str) -> str:
return f"{target} {key}\n"
def _git_gate_init_repo_fn(repo_root: str, creds_dir: str) -> list[str]:
"""The `init_repo` shell function, parameterized by the bare-repo root
and the per-bottle creds dir. Single source of the credential-wiring
logic, shared by the single-tenant daemon entrypoint (`/git`,
`/git-gate/creds`) and the consolidated per-bottle provisioning
(`/git/<bottle_id>`, `/git-gate/creds/<bottle_id>`)."""
return [
def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
"""Posix-sh entrypoint. One `init_repo` call per upstream, then
`exec git daemon`. The function reads
`/git-gate/creds/<name>-{key,known_hosts}` (bind-mounted into
the bundle by the renderer) and wires them into each bare repo's
config; the access-hook + pre-receive hook pick those paths up
at fetch / push time."""
lines = [
"#!/bin/sh",
"set -eu",
"",
"init_repo() {",
" name=$1",
" upstream_url=$2",
f" keyfile={creds_dir}/${{name}}-key",
f" hostsfile={creds_dir}/${{name}}-known_hosts",
" keyfile=/git-gate/creds/${name}-key",
" hostsfile=/git-gate/creds/${name}-known_hosts",
"",
# `|| true`: PRD 0018 chunk 3+ bind-mounts these RO from the
# host, so chmod-syscalls fail with EROFS. The files already
@@ -150,14 +153,14 @@ def _git_gate_init_repo_fn(repo_root: str, creds_dir: str) -> list[str]:
" chmod 600 \"$hostsfile\" 2>/dev/null || true",
" fi",
"",
f" repo={repo_root}/${{name}}.git",
" repo=/git/${name}.git",
" if [ ! -d \"$repo\" ]; then",
" git init --bare \"$repo\" >/dev/null",
# --mirror=fetch sets remote.origin.fetch = +refs/*:refs/* so a later
# `git fetch origin` mirrors the upstream's full ref graph (heads,
# tags, notes) into the bare repo at canonical paths. It does NOT set
# remote.origin.mirror=true, so an explicit `git push origin
# <ref>:<ref>` still pushes one ref.
# --mirror=fetch sets remote.origin.fetch = +refs/*:refs/* so",
# a later `git fetch origin` mirrors the upstream's full ref",
# graph (heads, tags, notes) into the bare repo at canonical",
# paths. It does NOT set remote.origin.mirror=true, so an",
# explicit `git push origin <ref>:<ref>` still pushes one ref.",
" git -C \"$repo\" remote add --mirror=fetch origin \"$upstream_url\"",
" fi",
" git -C \"$repo\" config git-gate.identityFile \"$keyfile\"",
@@ -167,19 +170,9 @@ def _git_gate_init_repo_fn(repo_root: str, creds_dir: str) -> list[str]:
" git -C \"$repo\" config http.receivepack true",
" install -m 755 /etc/git-gate/pre-receive \"$repo/hooks/pre-receive\"",
"}",
"",
"mkdir -p /git",
]
def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
"""Posix-sh entrypoint. One `init_repo` call per upstream, then
`exec git daemon`. The function reads
`/git-gate/creds/<name>-{key,known_hosts}` (bind-mounted into
the bundle by the renderer) and wires them into each bare repo's
config; the access-hook + pre-receive hook pick those paths up
at fetch / push time."""
lines = ["#!/bin/sh", "set -eu", ""]
lines += _git_gate_init_repo_fn("/git", "/git-gate/creds")
lines += ["", "mkdir -p /git"]
for u in upstreams:
lines.append(f"init_repo {shlex.quote(u.name)} {shlex.quote(u.upstream_url)}")
lines.extend([
@@ -197,35 +190,6 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
return "\n".join(lines) + "\n"
# A bottle id namespaces the consolidated gateway's repo + creds dirs; it is
# embedded unquoted in the provisioning script, so restrict it to a shell- and
# path-safe alphabet (registry ids are token_hex — this is defense in depth).
_SAFE_BOTTLE_ID = re.compile(r"[A-Za-z0-9_-]+")
def git_gate_render_provision(
bottle_id: str, upstreams: tuple[GitGateUpstream, ...],
) -> str:
"""Posix-sh script that provisions ONE bottle's bare repos into the
consolidated gateway (PRD 0070), under `/git/<bottle_id>/` with creds
read from `/git-gate/creds/<bottle_id>/`. Init-only — no `git daemon`,
since the shared gateway already serves every bottle; run inside the
running gateway when the bottle is registered.
Isolating each bottle's repo root and creds dir by id is what keeps one
bottle's push credentials out of another's repos on the shared gateway."""
if not _SAFE_BOTTLE_ID.fullmatch(bottle_id):
raise ValueError(f"git-gate: unsafe bottle id {bottle_id!r}")
repo_root = f"/git/{bottle_id}"
creds_dir = f"/git-gate/creds/{bottle_id}"
lines = ["#!/bin/sh", "set -eu", ""]
lines += _git_gate_init_repo_fn(repo_root, creds_dir)
lines += ["", f"mkdir -p {shlex.quote(repo_root)}"]
for u in upstreams:
lines.append(f"init_repo {shlex.quote(u.name)} {shlex.quote(u.upstream_url)}")
return "\n".join(lines) + "\n"
def git_gate_render_hook() -> str:
"""The shared pre-receive hook: gitleaks-scan all incoming refs,
then forward each accepted ref to the real upstream (`origin`)
+5 -116
View File
@@ -6,15 +6,6 @@ where the guest reaches the sidecar over the point-to-point TAP). The
wrapper serves the same `/git/*.git` bare repos through
`git http-backend`, so pre-receive and upstream forwarding remain the
git-gate enforcement point.
Consolidated (PRD 0070): when `BOT_BOTTLE_ORCHESTRATOR_URL` is set, one
shared gateway serves every bottle, and each request is served from the
calling bottle's repo namespace (`<root>/<bottle_id>`), attributed from
the unspoofable source IP via the orchestrator. Per-repo credentials +
hooks scope by repo directory, so isolating the *root* per bottle isolates
its creds too. Unattributed clients fail closed (404). Unset → the legacy
per-bottle single-tenant flat root, unchanged — a transitional path that
gets stripped out once every backend runs the consolidated gateway.
"""
from __future__ import annotations
@@ -22,86 +13,13 @@ from __future__ import annotations
import os
import subprocess
import sys
import typing
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit
# policy_resolver ships flat alongside this file in the sidecar bundle
# image (see Dockerfile.sidecars); the bot_bottle.* fallback is the
# host-side / test path. Mirrors egress_addon's import shape.
try:
from policy_resolver import ( # type: ignore[import-not-found]
PolicyResolveError,
PolicyResolver,
)
except ImportError: # pragma: no cover - host-side path
from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver
DEFAULT_PORT = 9420
# Consolidated (multi-tenant) mode: when this points at the per-host
# orchestrator's control plane, the backend serves each request from the
# *calling* bottle's repo namespace, selected by source IP, instead of a
# single flat repo root. Unset → legacy per-bottle single-tenant mode
# (unchanged). Same env the egress addon reads, so one orchestrator setting
# flips the whole shared gateway multi-tenant.
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
# App-layer identity token (defense-in-depth over the source-IP invariant);
# the agent injects it, the backend reads it for attribution and never
# forwards it to `git http-backend`. Mirrors egress_addon.IDENTITY_HEADER
# (duplicated, not imported: egress_addon pulls in mitmproxy).
IDENTITY_HEADER = "x-bot-bottle-identity"
# Default flat repo root (single-tenant, and the base under which
# consolidated mode nests each sandbox's namespace).
DEFAULT_REPO_ROOT = "/git"
class ResolverLike(typing.Protocol):
"""Structural type for the resolver `resolve_sandbox_root` needs — just
`resolve_bottle_id`. A Protocol so tests can pass a fake without
importing PolicyResolver (mirrors egress_addon_core.PolicyResolverLike)."""
def resolve_bottle_id(
self, source_ip: str, identity_token: str = ...,
) -> "str | None": ...
def resolve_sandbox_root(
resolver: "ResolverLike | None",
base_root: Path,
source_ip: str,
identity_token: str = "",
) -> Path | None:
"""The per-sandbox repo root to serve this request from, or None to
deny (404).
Single-tenant (`resolver is None`): the flat `base_root`, unchanged.
NOTE: this legacy per-bottle single-tenant path is transitional — it
will be stripped out once every backend runs the consolidated gateway
(PRD 0070), leaving only the source-IP-attributed path below.
Consolidated: `base_root/<bottle_id>`, where the sandbox is attributed
from the source IP via the orchestrator. Fail-closed — an unattributed
client, a resolver error, or a namespace that would escape `base_root`
all deny, so one sandbox can never reach another's repos."""
if resolver is None:
return base_root
try:
bottle_id = resolver.resolve_bottle_id(source_ip, identity_token)
except PolicyResolveError:
return None # orchestrator unreachable/errored → deny
if not bottle_id:
return None # unattributed → deny
base = base_root.resolve()
namespace = (base / bottle_id).resolve()
if base not in namespace.parents:
return None # bottle_id tried to escape the root → deny
return namespace
# Mirrors git_gate_render.GIT_GATE_TIMEOUT_SECS. Duplicated rather than
# imported: this module ships as a flat top-level sibling in the sidecar
# bundle image (see Dockerfile.sidecars), not as part of the bot_bottle
@@ -122,25 +40,10 @@ class GitHttpHandler(BaseHTTPRequestHandler):
def do_POST(self) -> None:
self._run_backend()
def _sandbox_root(self) -> Path | None:
"""This request's per-sandbox repo root, or None to deny. Single-tenant
unless the server was started with a resolver (consolidated mode), in
which case the root is the calling sandbox's source-IP-selected
namespace. `GIT_PROJECT_ROOT` keeps git's own env-var name."""
base = Path(os.environ.get("GIT_PROJECT_ROOT", DEFAULT_REPO_ROOT))
resolver = getattr(self.server, "policy_resolver", None)
token = self.headers.get(IDENTITY_HEADER, "")
return resolve_sandbox_root(resolver, base, self.client_address[0], token)
def _run_backend(self) -> None:
sandbox_root = self._sandbox_root()
if sandbox_root is None:
# Unattributed / resolver error: deny before touching any repo.
self.send_error(404)
return
parsed = urlsplit(self.path)
if self._is_upload_pack(parsed.path, parsed.query):
repo_dir = self._repo_dir(sandbox_root, parsed.path)
repo_dir = self._repo_dir(parsed.path)
if repo_dir is None:
self.send_error(404)
return
@@ -174,7 +77,7 @@ class GitHttpHandler(BaseHTTPRequestHandler):
return
env = os.environ.copy()
env.update({
"GIT_PROJECT_ROOT": str(sandbox_root),
"GIT_PROJECT_ROOT": os.environ.get("GIT_PROJECT_ROOT", "/git"),
"GIT_HTTP_EXPORT_ALL": "1",
"REQUEST_METHOD": self.command,
"PATH_INFO": parsed.path,
@@ -188,14 +91,6 @@ class GitHttpHandler(BaseHTTPRequestHandler):
"SERVER_PORT": str(self.server.server_port), # type: ignore
"SERVER_PROTOCOL": self.request_version,
})
# Consolidated mode: attribute the gitleaks-allow supervise proposal
# (written by receive-pack's pre-receive hook, a child of the CGI we
# spawn below) to the calling bottle. The namespaced root is
# `<base>/<bottle_id>`, so its final component is the bottle id — the
# same per-bottle key egress uses. Single-tenant leaves the hook's
# container-stamped SUPERVISE_BOTTLE_SLUG untouched.
if getattr(self.server, "policy_resolver", None) is not None:
env["SUPERVISE_BOTTLE_SLUG"] = sandbox_root.name
for header, variable in (
("accept", "HTTP_ACCEPT"),
("content-encoding", "HTTP_CONTENT_ENCODING"),
@@ -228,8 +123,8 @@ class GitHttpHandler(BaseHTTPRequestHandler):
)
self._write_cgi_response(proc.stdout)
def _repo_dir(self, sandbox_root: Path, path: str) -> Path | None:
root = sandbox_root.resolve()
def _repo_dir(self, path: str) -> Path | None:
root = Path(os.environ.get("GIT_PROJECT_ROOT", "/git")).resolve()
relative = path.lstrip("/").split(".git", 1)[0] + ".git"
candidate = (root / relative).resolve()
if root not in (candidate, *candidate.parents):
@@ -286,13 +181,7 @@ class GitHttpHandler(BaseHTTPRequestHandler):
def main() -> int:
port = int(os.environ.get("GIT_HTTP_PORT", str(DEFAULT_PORT)))
server = ThreadingHTTPServer(("0.0.0.0", port), GitHttpHandler)
orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip()
# Consolidated mode: resolve each request's sandbox namespace by source
# IP. Absent → single-tenant (flat repo root); behaviour unchanged.
resolver = PolicyResolver(orch_url) if orch_url else None
server.policy_resolver = resolver # type: ignore[attr-defined]
mode = "multi-tenant" if orch_url else "single-tenant"
sys.stdout.write(f"git-http listening on 0.0.0.0:{port} ({mode})\n")
sys.stdout.write(f"git-http listening on 0.0.0.0:{port}\n")
sys.stdout.flush()
server.serve_forever()
return 0
+6 -40
View File
@@ -1,63 +1,29 @@
"""Per-host orchestrator service (PRD 0070).
A single persistent per-host service that will run the gateway functions
A single persistent per-host service that will run the sidecar 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:
* `registry` — the SQLite runtime-state store + fail-closed
attribution (source IP + per-bottle identity token).
* `broker` — the signed, structured launch-request contract + a
`LaunchBroker` (stub for the harness) that verifies
provenance before acting.
* `gateway` — the consolidated per-host gateway: a `Gateway`
lifecycle contract (idempotent singleton) + a
`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 gateway, attributes.
* `control_plane` — the HTTP control-plane RPC (launch / teardown /
list / attribute / gateway / health).
* `control_plane` — the HTTP control-plane RPC (register / deregister /
list / attribute / health), with live reload.
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
proven (see the PRD sequencing: plain-process dev-harness -> docker
orchestrator -> firecracker).
Launch/teardown, the launch broker, and the actual egress/git/supervise
data plane land in later slices once this core is proven (see the PRD's
sequencing: plain-process dev-harness -> docker orchestrator -> firecracker).
"""
from __future__ import annotations
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 .gateway import DockerGateway, Gateway, GatewayError
from .service import Orchestrator
from .control_plane import ControlPlaneServer, dispatch, make_server
__all__ = [
"BottleRecord",
"RegistryStore",
"new_identity_token",
"BrokerAuthError",
"LaunchBroker",
"LaunchRequest",
"StubBroker",
"DockerBroker",
"DockerBrokerError",
"Gateway",
"DockerGateway",
"GatewayError",
"sign_request",
"verify_request",
"Orchestrator",
"ControlPlaneServer",
"dispatch",
"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
import argparse
import secrets
from pathlib import Path
from .. import log
from .broker import LaunchBroker, StubBroker
from .control_plane import make_server
from .docker_broker import DockerBroker
from .registry import RegistryStore, default_db_path
from .service import Orchestrator
from .gateway import DockerGateway, Gateway
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,
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(
"--gateway", action="store_true",
help="run one consolidated per-host sidecar bundle (build-if-missing)",
)
args = parser.parse_args(argv)
registry = RegistryStore(args.db)
registry.migrate()
# An ephemeral signing secret ties the orchestrator (signer) to its
# 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)
gateway: Gateway | None = DockerGateway() if args.gateway else None
orchestrator = Orchestrator(registry, broker, secret, gateway)
# One persistent per-host gateway, shared by every bottle: build the
# bundle image if missing, then bring the singleton up (idempotent).
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)
server = make_server(registry, host=args.host, port=args.port)
bound_host, bound_port = server.server_address[0], server.server_address[1]
log.info(
"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
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
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
**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 /gateway -> 200 {"configured", ["name","running"]}
GET /bottles -> 200 {"bottles": [ <redacted record>, ...]}
POST /bottles -> 201 {"bottle_id","identity_token"} (launch)
body: {"source_ip", ["image_ref"],
["metadata"], ["policy"]}
PUT /bottles/<bottle_id>/policy -> 200 {"updated": true} | 404 (live reload)
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.
GET /health -> 200 {"status": "ok"}
GET /bottles -> 200 {"bottles": [ <redacted record>, ... ]}
POST /bottles -> 201 {"bottle_id", "identity_token"}
body: {"source_ip", ["bottle_id"], ["metadata"]}
DELETE /bottles/<bottle_id> -> 200 {"deregistered": true} | 404
POST /attribute -> 200 {"bottle_id"} | 403
body: {"source_ip", "identity_token"}
Routing/handling is the pure function `dispatch()` so it is unit-testable
without a socket; `Handler` / `ControlPlaneServer` / `make_server` are a
thin stdlib adapter around it. Listing redacts identity tokens — they are
returned only once, to the caller that launches the bottle.
thin stdlib adapter around it.
Note the listing redacts identity tokens — they are never returned except
once, to the caller that registers the bottle.
"""
from __future__ import annotations
@@ -37,7 +30,7 @@ import socketserver
import typing
from urllib.parse import urlsplit
from .service import Orchestrator
from .registry import RegistryStore
# JSON body payload type (parsed request / rendered response).
Json = dict[str, object]
@@ -53,21 +46,18 @@ def _parse_json_object(body: bytes) -> Json:
return obj
def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
orch: Orchestrator, method: str, path: str, body: bytes
def dispatch( # pylint: disable=too-many-return-statements
registry: RegistryStore, method: str, path: str, body: bytes
) -> tuple[int, Json]:
"""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 "/"
if method == "GET" and route == "/health":
return 200, {"status": "ok"}
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()]}
return 200, {"bottles": [r.redacted() for r in registry.all()]}
if method == "POST" and route == "/bottles":
try:
@@ -77,34 +67,19 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
source_ip = data.get("source_ip")
if not isinstance(source_ip, str) or not source_ip:
return 400, {"error": "source_ip (string) is required"}
image_ref = data.get("image_ref")
bottle_id = data.get("bottle_id")
metadata = data.get("metadata")
policy = data.get("policy")
rec = orch.launch_bottle(
rec = registry.register(
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 "",
policy=policy if isinstance(policy, str) else "",
)
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/"):
bottle_id = route[len("/bottles/"):]
if orch.teardown_bottle(bottle_id):
return 200, {"torn_down": True}
if registry.deregister(bottle_id):
return 200, {"deregistered": True}
return 404, {"error": "no such bottle"}
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")
if not isinstance(source_ip, str) or not isinstance(token, str):
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:
return 403, {"error": "unattributed"}
return 200, {"bottle_id": rec.bottle_id}
if method == "POST" and route == "/resolve":
# 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:
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"}
@@ -156,7 +114,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
assert isinstance(server, ControlPlaneServer)
length = int(self.headers.get("Content-Length") or 0)
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()
self.send_response(status)
self.send_header("Content-Type", "application/json")
@@ -170,30 +128,27 @@ class Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self) -> None:
self._serve("POST")
def do_PUT(self) -> None:
self._serve("PUT")
def do_DELETE(self) -> None:
self._serve("DELETE")
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
allow_reuse_address = True
def __init__(self, address: tuple[str, int], orchestrator: Orchestrator) -> None:
self.orchestrator = orchestrator
def __init__(self, address: tuple[str, int], registry: RegistryStore) -> None:
self.registry = registry
super().__init__(address, Handler)
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:
"""Build (but do not start) a control-plane server. `port=0` binds an
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"]
-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",
]
-139
View File
@@ -1,139 +0,0 @@
"""The consolidated per-host gateway (PRD 0070).
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 gateway attribute each request to the right bottle —
so per-bottle policy lives in one long-lived process keyed on who's calling.
`Gateway` is the backend-neutral lifecycle contract (mirrors `LaunchBroker`):
ensure the single instance is up, report it, tear it down. `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 gateways.
"""
from __future__ import annotations
import abc
import os
from pathlib import Path
from ..docker_cmd import run_docker
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.
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 GatewayError(Exception):
"""The shared gateway failed to build/start/stop (non-zero `docker` exit)."""
class Gateway(abc.ABC):
"""Lifecycle of the single per-host gateway. Backend-neutral."""
name: str
def ensure_built(self) -> None:
"""Ensure the gateway's image / rootfs exists, building it if needed.
Default: nothing to build (e.g. a stub or a pre-pulled image)."""
return
@abc.abstractmethod
def ensure_running(self) -> None:
"""Start the gateway if it isn't already up. Idempotent: a no-op
when it's already running (that's the whole point — one per host).
Assumes the image exists — call `ensure_built()` first."""
@abc.abstractmethod
def is_running(self) -> bool:
"""True iff the gateway instance is currently up."""
@abc.abstractmethod
def stop(self) -> None:
"""Remove the gateway. Idempotent — absent is success."""
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
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 = GATEWAY_IMAGE,
*,
name: str = GATEWAY_NAME,
build_context: Path | None = None,
dockerfile: str | None = GATEWAY_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 GatewayError(f"gateway 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", GATEWAY_LABEL,
self.image_ref,
])
if proc.returncode != 0:
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 GatewayError(f"gateway failed to stop: {proc.stderr.strip()}")
__all__ = [
"Gateway", "DockerGateway", "GatewayError",
"GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE",
]
-141
View File
@@ -1,141 +0,0 @@
"""Orchestrator process lifecycle (PRD 0070, docker slice).
Before the CLI can register or launch bottles against the consolidated
model, exactly one orchestrator control plane — and the single per-host
gateway it manages — must be running. This starts the orchestrator
dev-harness (`python -m bot_bottle.orchestrator`) as a background host
process and health-checks it.
It is an **idempotent singleton**: `ensure_running` returns immediately if a
healthy control plane already answers on the port, and otherwise spawns one
and waits for it to come up. The control-plane port is the singleton key —
a second orchestrator can't bind it, so a stray double-start fails fast
rather than forking a rival.
Host-process (not container) on purpose: the PRD sequences the orchestrator
as a plain-process dev-harness first (fast iteration, and it already has the
host user's docker access to broker launches), while the data-plane
*gateway* it manages runs as a container. Wrapping the orchestrator itself
in a backend-native unit is a later step.
"""
from __future__ import annotations
import subprocess
import sys
import time
import urllib.error
import urllib.request
from .. import log
from ..paths import bot_bottle_root
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8080
# Poll cadence + default ceiling while waiting for a freshly-spawned control
# plane to answer /health (the first start also builds/boots the gateway).
_HEALTH_POLL_SECONDS = 0.25
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
class OrchestratorStartError(RuntimeError):
"""The orchestrator process did not become healthy within the timeout."""
class OrchestratorProcess:
"""Manages the local orchestrator control-plane process for the docker
backend. Backend-neutral callers only need `ensure_running()` + `url`."""
def __init__(
self,
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
*,
broker: str = "docker",
gateway: bool = True,
) -> None:
self.host = host
self.port = port
self._broker = broker
self._gateway = gateway
@property
def url(self) -> str:
"""The control-plane base URL — also what the data plane's
BOT_BOTTLE_ORCHESTRATOR_URL points at."""
return f"http://{self.host}:{self.port}"
def is_healthy(self, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS) -> bool:
"""True iff a control plane answers `GET /health` with 200 — the
singleton liveness check."""
try:
with urllib.request.urlopen(f"{self.url}/health", timeout=timeout) as resp:
return resp.status == 200
except (urllib.error.URLError, TimeoutError, OSError):
return False
def ensure_running(
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
) -> str:
"""Return the control-plane URL, starting the orchestrator first if it
isn't already healthy. Idempotent — a healthy control plane is left
untouched. Raises `OrchestratorStartError` if a freshly-spawned one
doesn't answer within `startup_timeout`."""
if self.is_healthy():
return self.url
log.info("starting orchestrator", context={"url": self.url, "broker": self._broker})
self._spawn()
deadline = time.monotonic() + startup_timeout
while time.monotonic() < deadline:
if self.is_healthy():
log.info("orchestrator healthy", context={"url": self.url})
return self.url
time.sleep(_HEALTH_POLL_SECONDS)
raise OrchestratorStartError(
f"orchestrator at {self.url} did not become healthy within "
f"{startup_timeout:g}s"
)
def _argv(self) -> list[str]:
"""`python -m bot_bottle.orchestrator ...` — static flags only."""
argv = [
sys.executable, "-m", "bot_bottle.orchestrator",
"--host", self.host, "--port", str(self.port),
"--broker", self._broker,
]
if self._gateway:
argv.append("--gateway")
return argv
def _log_path(self) -> str:
"""Where the detached orchestrator's stdout/stderr goes so a failed
start is diagnosable after the CLI has moved on."""
return str(bot_bottle_root() / "orchestrator.log")
def _spawn(self) -> None:
"""Launch the orchestrator detached so it outlives this CLI process,
with its output tee'd to a log file under the bot-bottle root."""
root = bot_bottle_root()
root.mkdir(parents=True, exist_ok=True)
logfile = open(self._log_path(), "a", encoding="utf-8") # noqa: SIM115 # pylint: disable=consider-using-with
try:
subprocess.Popen( # noqa: S603 # pylint: disable=consider-using-with
self._argv(),
stdout=logfile,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
start_new_session=True,
)
finally:
# The child inherits its own dup'd fd; this handle is ours to drop.
logfile.close()
__all__ = [
"OrchestratorProcess",
"OrchestratorStartError",
"DEFAULT_HOST",
"DEFAULT_PORT",
"DEFAULT_STARTUP_TIMEOUT_SECONDS",
]
-57
View File
@@ -1,57 +0,0 @@
"""Consolidated registration inputs (PRD 0070, docker slice).
Bridges the existing per-bottle `prepare` output to the consolidated
registry: turns a prepared bottle's egress plan into the backend-neutral
inputs `Orchestrator.launch_bottle` takes — the egress **policy** blob and
launch **metadata**.
The policy blob is the exact routes YAML the per-bottle egress sidecar used
to read from a file; in the consolidated model the multi-tenant gateway's
`PolicyResolver` fetches it from the registry per request (keyed by source
IP) instead. Same render, so consolidated and single-tenant egress apply
byte-identical policy — a bottle's allow-list doesn't change when it moves
onto the shared gateway.
Host-side glue (imports `bot_bottle.egress`), used by the launch path — not
by the lean orchestrator control-plane process itself.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from ..egress import EgressPlan, egress_render_routes
@dataclass(frozen=True)
class RegistrationInputs:
"""What `Orchestrator.launch_bottle` needs to register a bottle, derived
from its prepared plan. `policy` is served verbatim by the gateway's
`/resolve`; `metadata` is opaque forward-compat state — it carries the
human slug so the console / supervise can show a name, not just the
minted bottle id."""
policy: str
metadata: str
def egress_policy(plan: EgressPlan) -> str:
"""The bottle's egress policy blob: the routes YAML the gateway serves
and the addon parses with `load_config`. Identical to the per-bottle
`routes.yaml` render, so the consolidated path applies the same
allow-list."""
return egress_render_routes(plan.routes, log=plan.log)
def registration_inputs(plan: EgressPlan) -> RegistrationInputs:
"""Assemble the orchestrator registration inputs from a prepared egress
plan. `metadata` records the slug so the shared registry can map a minted
bottle id back to its human name."""
return RegistrationInputs(
policy=egress_policy(plan),
metadata=json.dumps({"slug": plan.slug}),
)
__all__ = ["RegistrationInputs", "egress_policy", "registration_inputs"]
+13 -46
View File
@@ -64,13 +64,9 @@ class BottleRecord:
identity_token: str
state: str = "active"
created_at: float = 0.0
# Opaque JSON blob for forward-compat (pool slot, ...) — the registry
# doesn't interpret it, so new fields need no migration.
# Opaque JSON blob for forward-compat (policy refs, pool slot, ...) —
# the registry doesn't interpret it, so new fields need no migration.
metadata: str = ""
# 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 gateway interprets it.
policy: str = ""
def redacted(self) -> dict[str, object]:
"""Public view for listing — never exposes the identity token."""
@@ -80,7 +76,6 @@ class BottleRecord:
"state": self.state,
"created_at": self.created_at,
"metadata": self.metadata,
"policy": self.policy,
}
@@ -102,10 +97,6 @@ _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 gateway interprets): the
# egress allowlist / routes / git config selected by source IP. The
# multi-tenant gateway 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"],
created_at=row["created_at"],
metadata=row["metadata"],
policy=row["policy"],
)
@@ -146,7 +136,6 @@ class RegistryStore(DbStore):
bottle_id: str | None = None,
identity_token: str | None = None,
metadata: str = "",
policy: str = "",
) -> BottleRecord:
"""Register (or replace) a bottle. Mints a bottle_id / identity token
when not supplied. Live — this is the control-plane reload path."""
@@ -157,13 +146,12 @@ class RegistryStore(DbStore):
state="active",
created_at=time.time(),
metadata=metadata,
policy=policy,
)
with self._connect() as conn:
conn.execute(
"INSERT OR REPLACE INTO orchestrator_bottles "
"(bottle_id, source_ip, identity_token, state, created_at, metadata, policy) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
"(bottle_id, source_ip, identity_token, state, created_at, metadata) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
rec.bottle_id,
rec.source_ip,
@@ -171,23 +159,11 @@ class RegistryStore(DbStore):
rec.state,
rec.created_at,
rec.metadata,
rec.policy,
),
)
self._chmod()
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:
"""Remove a bottle. Returns True if a row was deleted."""
with self._connect() as conn:
@@ -212,13 +188,14 @@ class RegistryStore(DbStore):
).fetchall()
return [_row_to_record(r) for r in rows]
def by_source_ip(self, source_ip: str) -> BottleRecord | None:
"""Network-layer attribution: the single active bottle at this source
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 gateway; pair with the
identity token (`attribute`) elsewhere."""
def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None:
"""Fail-closed attribution. Returns the bottle only when exactly one
active record has this source IP AND its identity token matches
(constant-time). Either signal alone is insufficient: an unknown IP,
an ambiguous IP (more than one active bottle — a misconfiguration),
an empty token, or a token mismatch all deny."""
if not identity_token:
return None
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM orchestrator_bottles "
@@ -227,17 +204,7 @@ class RegistryStore(DbStore):
).fetchall()
if len(rows) != 1:
return None
return _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
rec = _row_to_record(rows[0])
if not hmac.compare_digest(rec.identity_token, identity_token):
return None
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 .gateway import Gateway
class Orchestrator:
"""Owns the registry + brokers launches, and manages the single
consolidated per-host gateway. Backend-neutral (broker and gateway
abstract the backend-native pieces)."""
def __init__(
self,
registry: RegistryStore,
broker: LaunchBroker,
sign_secret: bytes,
gateway: Gateway | None = None,
) -> None:
self.registry = registry
self._broker = broker
self._secret = sign_secret
self._gateway = gateway
def launch_bottle(
self,
source_ip: str,
*,
image_ref: str = "",
slot: int | None = None,
metadata: str = "",
policy: str = "",
) -> BottleRecord:
"""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)
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 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
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 gateway policy in place (live reload). False if
the bottle is unknown."""
return self.registry.set_policy(bottle_id, policy)
# --- consolidated gateway ----------------------------------------------
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 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._gateway.name,
"running": self._gateway.is_running(),
}
__all__ = ["Orchestrator"]
-123
View File
@@ -1,123 +0,0 @@
"""Gateway-side per-client policy resolver (PRD 0070).
The consolidated gateway serves every bottle from one process, so for each
request it must apply the *calling* bottle's policy, selected by the source
IP the attribution invariant makes unspoofable. This resolves that policy
from the orchestrator's control plane (`POST /resolve`), keyed on the
`(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 _post_resolve(self, source_ip: str, identity_token: str) -> dict[str, object] | None:
"""The orchestrator's `/resolve` payload for this client, or None if
unattributed (a clean `403`). Raises `PolicyResolveError` on an
unreachable / unexpected-status / malformed response so every caller
can fail closed. Shared by `resolve` and `resolve_bottle_id`."""
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
return payload if isinstance(payload, dict) else {}
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 *transitionally* — omitting it resolves
by source IP alone (the network-layer attribution, sound by
construction on Firecracker's `/31`+nft, weaker elsewhere). The
consolidated end state makes the token mandatory so the app-layer
defense is always enforced, not silently degraded; that flips at the
`/resolve` boundary once identity-token *delivery* lands (a PRD 0070
open question — we can't require what the agent can't yet present).
Raises `PolicyResolveError` if the orchestrator can't be reached."""
payload = self._post_resolve(source_ip, identity_token)
if payload is None:
return None
policy = payload.get("policy")
return policy if isinstance(policy, str) else ""
def resolve_bottle_id(self, source_ip: str, identity_token: str = "") -> str | None:
"""The calling bottle's id, or None if unattributed. This is the
source-IP-keyed identity the git-gate uses to select the bottle's
repo namespace (there is no per-bottle policy blob to parse — the
bottle *is* the namespace). Same fail-closed contract as `resolve`."""
payload = self._post_resolve(source_ip, identity_token)
if payload is None:
return None
bottle_id = payload.get("bottle_id")
return bottle_id if isinstance(bottle_id, str) and bottle_id else None
def resolve_policy_and_bottle_id(
self, source_ip: str, identity_token: str = "",
) -> tuple[str | None, str | None]:
"""Both the policy blob and the bottle id in a single `/resolve` — so
a caller that needs each (the egress addon: policy for routing, bottle
id to key the calling bottle's supervise queue + safelist) makes one
round-trip, not two. Returns `(None, None)` when unattributed (a clean
`403`). Raises `PolicyResolveError` if the orchestrator can't be
reached, so the caller still fails closed."""
payload = self._post_resolve(source_ip, identity_token)
if payload is None:
return None, None
policy = payload.get("policy")
bottle_id = payload.get("bottle_id")
return (
policy if isinstance(policy, str) else "",
bottle_id if isinstance(bottle_id, str) and bottle_id else None,
)
__all__ = ["PolicyResolver", "PolicyResolveError"]
+5 -50
View File
@@ -15,12 +15,6 @@ The bottle slug arrives via SUPERVISE_BOTTLE_SLUG env (stamped at
container creation by the backend's start step). SUPERVISE_DB_PATH
points at the bind-mounted host database.
Consolidated (PRD 0070): when BOT_BOTTLE_ORCHESTRATOR_URL is set, one
shared server fronts every bottle and attributes each proposal to the
calling bottle by source IP (resolved from the orchestrator) instead of a
fixed slug — an unattributed source fails closed. Unset → the legacy
per-bottle single-tenant server, unchanged.
Speaks MCP over HTTP+JSON-RPC. Methods handled:
* `initialize` — handshake; returns server info + caps.
@@ -46,18 +40,16 @@ import time
import typing
import urllib.error
import urllib.request
from dataclasses import dataclass, replace
from dataclasses import dataclass
try:
# Same-directory imports inside the bundle container; these files are
# COPYed flat under /app by Dockerfile.sidecars.
from egress_addon_core import LOG_OFF, load_config
from policy_resolver import PolicyResolveError, PolicyResolver
import supervise as _sv
except ModuleNotFoundError:
# Package imports for host-side tests and tooling.
from .egress_addon_core import LOG_OFF, load_config
from .policy_resolver import PolicyResolveError, PolicyResolver
from . import supervise as _sv
@@ -81,12 +73,6 @@ DEFAULT_RESPONSE_TIMEOUT_SECONDS = 30.0
MIN_RESPONSE_POLL_INTERVAL_SECONDS = 0.05
EGRESS_LIST_TIMEOUT_SECONDS = 5.0
# Consolidated (multi-tenant) mode: when set, one shared supervise server
# fronts every bottle and attributes each proposal to the calling bottle by
# source IP (resolved from the orchestrator), instead of a single
# SUPERVISE_BOTTLE_SLUG env. Unset → legacy per-bottle single-tenant.
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
@dataclass(frozen=True)
class JsonRpcRequest:
@@ -525,30 +511,9 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
if method == "tools/list":
return handle_tools_list(req.params)
if method == "tools/call":
# Attribute the proposal to the calling bottle. Single-tenant → the
# env slug on `config`; consolidated → the source-IP-resolved
# bottle id, so one shared server queues each bottle's proposal
# under its own slug.
return handle_tools_call(req.params, self._attributed_config(config))
return handle_tools_call(req.params, config)
raise _RpcClientError(ERR_METHOD_NOT_FOUND, f"method not found: {method}")
def _attributed_config(self, config: ServerConfig) -> ServerConfig:
"""The ServerConfig with `bottle_slug` bound to *this request's* bottle.
Single-tenant (no resolver): unchanged. Consolidated: the bottle id
attributed from the source IP — **fail-closed**, an unattributed or
unreachable source raises so no proposal is queued under the wrong (or
empty) slug."""
resolver = getattr(self.server, "policy_resolver", None)
if resolver is None:
return config
try:
bottle_id = resolver.resolve_bottle_id(self.client_address[0])
except PolicyResolveError as e:
raise _RpcInternalError(f"orchestrator unreachable, cannot attribute: {e}") from e
if not bottle_id:
raise _RpcInternalError("request source is not attributed to a bottle")
return replace(config, bottle_slug=bottle_id)
def _write_jsonrpc(self, body: bytes) -> None:
self.send_response(200)
self.send_header("Content-Type", "application/json")
@@ -572,9 +537,6 @@ class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
allow_reuse_address = True
daemon_threads = True
config: ServerConfig = ServerConfig(bottle_slug="")
# None → single-tenant (proposals use config.bottle_slug); set → consolidated
# (each proposal attributed to the source-IP-resolved bottle).
policy_resolver: "PolicyResolver | None" = None
# --- Entry point -----------------------------------------------------------
@@ -586,17 +548,15 @@ def serve(
port: int = _sv.SUPERVISE_PORT,
bind: str = "0.0.0.0",
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS,
resolver: "PolicyResolver | None" = None,
) -> typing.NoReturn:
server = MCPServer((bind, port), MCPHandler)
server.config = ServerConfig(
bottle_slug=bottle_slug,
response_timeout_seconds=response_timeout_seconds,
)
server.policy_resolver = resolver
mode = "multi-tenant" if resolver else f"slug={bottle_slug!r}"
sys.stderr.write(
f"supervise listening on {bind}:{port}; {mode}; "
f"supervise listening on {bind}:{port}; "
f"slug={bottle_slug!r}; "
f"tools: {', '.join(t['name'] for t in TOOL_DEFINITIONS)}\n" # type: ignore[arg-type]
)
sys.stderr.flush()
@@ -611,12 +571,8 @@ def serve(
def main(argv: list[str]) -> int:
del argv # config is env-only, no CLI flags
orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip()
resolver = PolicyResolver(orch_url) if orch_url else None
bottle_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
# Consolidated mode resolves the slug per request, so the env slug is
# optional there; single-tenant still requires it.
if not bottle_slug and resolver is None:
if not bottle_slug:
sys.stderr.write("supervise: SUPERVISE_BOTTLE_SLUG env is unset\n")
return 2
port = int(os.environ.get("SUPERVISE_PORT", str(_sv.SUPERVISE_PORT)))
@@ -631,7 +587,6 @@ def main(argv: list[str]) -> int:
port=port,
bind=bind,
response_timeout_seconds=response_timeout_seconds,
resolver=resolver,
)
return 0 # serve() does not return
+59 -27
View File
@@ -10,8 +10,8 @@
## Summary
Replace the **per-bottle gateway bundle** with a single **persistent,
per-host orchestrator**: one long-lived service that runs the gateway
Replace the **per-bottle sidecar bundle** with a single **persistent,
per-host orchestrator**: one long-lived service that runs the sidecar
functions (egress / git-gate / supervise), coordinates with the console,
and brokers agent launches and teardown. It is **virtualized from the
start** using each backend's native isolation primitive — a Firecracker
@@ -22,13 +22,13 @@ container on the legacy backend — and is fronted by a single
## Motivation
Today each bottle spins up its own gateway bundle (egress mitmproxy +
Today each bottle spins up its own sidecar bundle (egress mitmproxy +
git-gate + supervise). That costs:
- **Resources.** N bottles → N heavy bundles booting and idling.
- **Operational churn.** Per-launch container/VM lifecycle for the
gateways, a control path baked at launch and torn down at exit.
- **A blurry contract.** "How a bottle talks to its gateway" is
sidecars, a control path baked at launch and torn down at exit.
- **A blurry contract.** "How a bottle talks to its sidecar" is
re-implemented per backend instead of being one agreed interface.
A per-host orchestrator collapses the first two and forces the third to
@@ -52,11 +52,11 @@ weaken, and the weakened ones must be mitigated by design, not hand-waved.
structured, auditable privileged core instead of a fat socket.
- **Attribution is enforced, not assumed.** Making source-IP identity a
first-class contract invariant (below) means each backend must *prove*
it, rather than the gateway implicitly trusting network position.
it, rather than the sidecar implicitly trusting network position.
### What gets weaker, and the mitigation
1. **Secret concentration.** Per-bottle gateways isolate secrets at the
1. **Secret concentration.** Per-bottle sidecars isolate secrets at the
process boundary — each holds only its bottle's tokens/keys. A host
orchestrator concentrates **every bottle's** egress tokens, git deploy
keys, and the console credential in one long-lived process. A single
@@ -75,7 +75,7 @@ weaken, and the weakened ones must be mitigated by design, not hand-waved.
fleet, plus launch authority, plus the console token.
- *Mitigation:* the orchestrator is itself confined (its own VM/container
with its own fail-closed egress); make it **restartable without killing
running agent VMs** (agents keep running; they briefly lose gateway
running agent VMs** (agents keep running; they briefly lose sidecar
connectivity until it's back); persist state to a host volume so a
restart re-adopts live bottles rather than losing them.
@@ -114,13 +114,13 @@ responsibility** and part of the contract:
- **Apple** — the host-only network.
If a backend can't honor the invariant, source-IP consolidation is not
safe there and that backend keeps per-bottle gateways. The invariant is a
safe there and that backend keeps per-bottle sidecars. The invariant is a
hard precondition, not an aspiration.
**Defense-in-depth — a per-bottle identity token.** On top of the
network-layer invariant, inject a per-bottle secret token into every
request the agent makes to the orchestrator (the agent already egresses
through the gateway proxy, so this is cheap to add). It gives an
through the sidecar proxy, so this is cheap to add). It gives an
**application-layer** proof of identity independent of the network layer:
- On **Firecracker** the `/31` + nft already make source IP unspoofable
@@ -133,7 +133,7 @@ Requirements: the token is **per-bottle, unguessable, and
non-cross-leakable** — a bottle can only ever prove it is *itself* (it
can't learn another bottle's token, given bottle isolation), so a hostile
agent gains nothing by presenting it. The orchestrator provisions the
token at launch and the gateway requires it to attribute + authorize.
token at launch and the sidecar requires it to attribute + authorize.
Note this hardens *attribution*, not *secret exposure*: it's app-layer, so
a compromised orchestrator still sees every token (that's the
concentration problem, addressed separately under Secret handling).
@@ -178,7 +178,7 @@ manifest anywhere a raw token value is accepted, discovered from
`AgentProvider`s are — because maintaining every forge/cloud/OAuth
provider in-tree is untenable. The orchestrator's vault mints via these
providers; but the abstraction is shippable independently and today's
per-bottle gateways can use it too.
per-bottle sidecars can use it too.
## Design
@@ -195,7 +195,7 @@ Three surfaces; only one is per-backend.
every host (no vsock/unix-socket portability caveats); a local unix
socket is a fine optimization, but HTTP is the wire contract.
2. **Data plane (agent → orchestrator)** — the egress / git / supervise
endpoints. Already agnostic today (agents dial `http://gateway:9099`);
endpoints. Already agnostic today (agents dial `http://sidecar:9099`);
only the *address* and *how packets get there* are per-backend.
3. **Launch / wire (orchestrator → backend)** — the irreducibly
backend-specific part; lives on `BottleBackend`.
@@ -249,7 +249,7 @@ forged one from a compromised co-located component. The orchestrator signs;
the broker verifies with the orchestrator's public key (provisioned at
broker install). This is the concrete form of security #3's "structured
requests only." Same JSON-with-ids + JWT shape for all
orchestrator↔broker/gateway communication; cases that don't fit get
orchestrator↔broker/sidecar communication; cases that don't fit get
handled as they arise, with this as the default.
If `BottleBackend` bloats, the pressure valve is composition one level
@@ -299,10 +299,10 @@ and integrate a console against.
the data plane and the console reach state through the control-plane RPC,
never a direct file handle.** No agent-facing component gets the file, so
none can forge attribution. (This supersedes the earlier `ro`-mount idea.)
- *Transitional caveat:* today the per-bottle **supervise gateway
- *Transitional caveat:* today the per-bottle **supervise sidecar
rw-bind-mounts `bot-bottle.db`** to write proposals — exactly the
pattern the orchestrator removes (supervise consolidates into the
orchestrator; gateway writes become RPC calls). Until that lands, don't
orchestrator; sidecar writes become RPC calls). Until that lands, don't
put the attribution registry behind a data-plane-writable mount.
Implementation note for the VM slices: SQLite **WAL** over a guest share
@@ -325,24 +325,56 @@ orchestrator becomes a VM. Decouple the two risks instead:
secret handling) is proven with fast iteration — *then* wrap that exact
service in the VM and solve wiring separately.
Backend order (cheapest proof → hardest → last):
### Slices
1. **Docker orchestrator** — nearly free (the gateway bundle is already
containers; collapse N bundles into one persistent container). Proves
consolidation + the `BottleBackend` seam with the least moving parts.
2. **Firecracker orchestrator**the real work: the shim + VM-to-VM
Built bottom-up as a stack of small PRs off this one (status: **15
implemented** in #352#356#357#358#360):
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
forward rules where today it drops all non-DNAT egress). Built against
the dev-harness so the app logic is already proven.
3. **macOS (Apple container)** — last (container-to-container networking).
forward rules where today it drops all non-DNAT egress).
9. **macOS (Apple container)** — last (container-to-container networking).
Keep the gateway **service one shared thing** throughout.
Backends land cheapest-first (docker → firecracker → macOS); keep the
sidecar **service one shared thing** throughout.
## Non-goals
- Removing OCI/Dockerfile support for agent images (0069's concern).
- A single database for *all* config (see the three-tier table).
- Changing the per-bottle isolation of agent workloads — only the gateway
- Changing the per-bottle isolation of agent workloads — only the sidecar
is consolidated; agents stay one-VM/container-each.
## Relationship to other work
@@ -393,7 +425,7 @@ Keep the gateway **service one shared thing** throughout.
`BottleBackend.wire()` (DNAT+forward for fc, shared-net for
docker/apple), not orchestrator logic. **Not a blocker** — resolvable at
implementation time (may require a bit of host modification, as the pool
setup already does on NixOS); an earlier "gateways in VMs" spike showed
setup already does on NixOS); an earlier "sidecars in VMs" spike showed
it's feasible.
- **Live-reload protocol** for per-bottle policy over the HTTP control
plane (add/remove routes/keys/proposals without a restart).
@@ -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 gateway is an idempotent singleton.
Gated on a reachable Docker daemon. Uses a unique name so it never
collides with a real per-host gateway or a leftover from another run.
"""
from __future__ import annotations
import secrets
import subprocess
import unittest
from bot_bottle.orchestrator.gateway import DockerGateway
from tests._docker import skip_unless_docker
IMAGE = "busybox"
@skip_unless_docker()
class TestDockerGatewayIntegration(unittest.TestCase):
def setUp(self) -> None:
self.name = "bot-bottle-orch-gateway-itest-" + secrets.token_hex(4)
self.sc = DockerGateway(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: DockerGateway.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.gateway import DockerGateway
from tests._docker import skip_unless_docker
IMAGE = "busybox"
@skip_unless_docker()
class TestDockerGatewayImageExists(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(DockerGateway(IMAGE, dockerfile=None).image_exists())
self.assertFalse(
DockerGateway("bot-bottle-nonexistent:doesnotexist", dockerfile=None).image_exists()
)
if __name__ == "__main__":
unittest.main()
@@ -46,7 +46,7 @@ def _addon() -> EgressAddon:
"""Return a bare EgressAddon with LOG_FULL config and no routes file."""
a: EgressAddon = EgressAddon.__new__(EgressAddon)
a.config = Config(routes=(), log=LOG_FULL)
a._safe_tokens = {}
a.safe_tokens = set()
a._supervise_slug = ""
a._token_allow_timeout = 300.0
return a
+2 -83
View File
@@ -211,7 +211,7 @@ def _addon(config: Config) -> EgressAddon:
"""Bare EgressAddon with a supplied config and no supervise wiring."""
a: EgressAddon = EgressAddon.__new__(EgressAddon)
a.config = config
a._safe_tokens = {}
a.safe_tokens = set()
a._supervise_slug = ""
a._token_allow_timeout = 300.0
a.routes_path = "/nonexistent/routes.yaml"
@@ -222,29 +222,6 @@ def _run_request(addon: EgressAddon, flow: _Flow) -> None:
asyncio.run(addon.request(flow)) # type: ignore[arg-type]
def _with_client_ip(flow: _Flow, ip: str) -> _Flow:
"""Attach a mitmproxy-style client_conn so consolidated resolution can read
the source IP (`flow.client_conn.peername[0]`)."""
flow.client_conn = types.SimpleNamespace(peername=(ip, 54321)) # type: ignore[attr-defined]
return flow
class _CtxResolver:
"""Fake orchestrator resolver: maps source IP -> bottle id, and grants the
same allow-list to any attributed bottle (unattributed -> deny)."""
def __init__(self, ip_to_bottle: dict[str, str]) -> None:
self._map = ip_to_bottle
def resolve_policy_and_bottle_id(
self, source_ip: str, identity_token: str = "",
) -> tuple[str | None, str | None]:
del identity_token
bottle_id = self._map.get(source_ip)
policy = "routes:\n - host: api.example.com\n" if bottle_id else None
return policy, bottle_id
# ---------------------------------------------------------------------------
# Introspection endpoint
# ---------------------------------------------------------------------------
@@ -441,8 +418,7 @@ class TestSuperviseBranch(unittest.TestCase):
with patch.object(_ea_mod, "_sv", _fake_sv("approved")):
_run_request(addon, flow)
self.assertIsNone(flow.response) # forwarded after approval
# Approval lands in the calling bottle's safelist (keyed by slug).
self.assertIn(_OPENAI_KEY, addon._safe_tokens_for("test-bottle"))
self.assertIn(_OPENAI_KEY, addon.safe_tokens)
def test_operator_rejection_blocks(self) -> None:
addon = self._supervised_addon()
@@ -759,62 +735,5 @@ class TestLogFullRequest(unittest.TestCase):
self.assertTrue(any(e.get("event") == "egress_request" for e in logged))
class TestSuperviseMultiTenant(unittest.TestCase):
"""Consolidated gateway: supervise proposals + the DLP safelist are keyed
per bottle, resolved by source IP (PRD 0070)."""
def _consolidated_addon(self) -> EgressAddon:
# Static config is empty; the resolver supplies each bottle's config.
addon = _addon(Config(routes=()))
addon._resolver = cast(Any, _CtxResolver({"10.0.0.1": "bottle-a", "10.0.0.2": "bottle-b"}))
addon._token_allow_timeout = 0.05
return addon
def test_approval_is_scoped_to_the_calling_bottle(self) -> None:
addon = self._consolidated_addon()
# bottle-a (10.0.0.1) sends the token; the operator approves.
flow = _with_client_ip(
_Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}")),
"10.0.0.1",
)
with patch.object(_ea_mod, "_sv", _fake_sv("approved")):
_run_request(addon, flow)
self.assertIsNone(flow.response) # forwarded after approval
# The approval lands ONLY in bottle-a's safelist — never bottle-b's.
# A global set here would be the cross-tenant leak this slice closes.
self.assertIn(_OPENAI_KEY, addon._safe_tokens_for("bottle-a"))
self.assertNotIn(_OPENAI_KEY, addon._safe_tokens_for("bottle-b"))
def test_proposal_is_attributed_to_the_source_ip_bottle(self) -> None:
addon = self._consolidated_addon()
seen: list[str] = []
fake = _fake_sv("approved")
def _capture(**kw: Any) -> Any:
seen.append(kw["bottle_slug"])
return types.SimpleNamespace(id="p")
fake.Proposal = types.SimpleNamespace(new=_capture)
flow = _with_client_ip(
_Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}")),
"10.0.0.2",
)
with patch.object(_ea_mod, "_sv", fake):
_run_request(addon, flow)
self.assertEqual(["bottle-b"], seen) # proposal keyed by the resolved bottle
def test_unattributed_source_ip_cannot_supervise(self) -> None:
addon = self._consolidated_addon()
# 10.9.9.9 is not in the resolver map -> deny-all config, empty slug.
flow = _with_client_ip(
_Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}")),
"10.9.9.9",
)
with patch.object(_ea_mod, "_sv", _fake_sv("approved")):
_run_request(addon, flow)
self.assertIsNotNone(flow.response) # blocked (no route, no supervise)
self.assertNotIn(_OPENAI_KEY, addon._safe_tokens_for(""))
if __name__ == "__main__":
unittest.main()
-100
View File
@@ -1,100 +0,0 @@
"""Unit: fail-closed per-client egress resolution — config + context (PRD 0070)."""
from __future__ import annotations
import unittest
from bot_bottle.egress_addon_core import resolve_client_config, resolve_client_context
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])
class _FakeContextResolver:
def __init__(
self, policy: str | None = None, bottle_id: str | None = None, raises: bool = False,
) -> None:
self._policy = policy
self._bottle_id = bottle_id
self._raises = raises
def resolve_policy_and_bottle_id(
self, source_ip: str, identity_token: str = "",
) -> tuple[str | None, str | None]:
if self._raises:
raise PolicyResolveError("orchestrator down")
return self._policy, self._bottle_id
class TestResolveClientContext(unittest.TestCase):
def test_returns_config_and_bottle_id(self) -> None:
cfg, slug = resolve_client_context(
_FakeContextResolver(policy="routes:\n - host: example.com\n", bottle_id="b1"),
"10.243.0.1",
)
self.assertEqual(("example.com",), tuple(r.host for r in cfg.routes))
self.assertEqual("b1", slug)
def test_unattributed_denies_and_empty_slug(self) -> None:
cfg, slug = resolve_client_context(
_FakeContextResolver(policy=None, bottle_id=None), "10.243.0.9",
)
self.assertEqual((), cfg.routes)
self.assertEqual("", slug) # no bottle → supervise unavailable
def test_resolver_error_denies_and_empty_slug(self) -> None:
cfg, slug = resolve_client_context(_FakeContextResolver(raises=True), "10.243.0.1")
self.assertEqual((), cfg.routes)
self.assertEqual("", slug)
def test_unparseable_policy_denies_but_keeps_slug(self) -> None:
# A bad policy denies egress, but the bottle is still attributed (its
# supervise queue is keyed by the id, independent of route parsing).
cfg, slug = resolve_client_context(
_FakeContextResolver(policy="routes: notalist\n", bottle_id="b2"), "10.243.0.1",
)
self.assertEqual((), cfg.routes)
self.assertEqual("b2", slug)
if __name__ == "__main__":
unittest.main()
-42
View File
@@ -1,42 +0,0 @@
"""Unit: shared-gateway source-IP allocation (PRD 0070)."""
from __future__ import annotations
import unittest
from bot_bottle.backend.docker.gateway_net import NoFreeAddressError, next_free_ip
class TestNextFreeIp(unittest.TestCase):
def test_skips_router_and_returns_first_host(self) -> None:
# .1 is docker's router; the first assignable address is .2.
self.assertEqual("172.18.0.2", next_free_ip("172.18.0.0/16", []))
def test_skips_taken_addresses(self) -> None:
# Gateway container holds .2, a live bottle holds .3 -> next is .4.
self.assertEqual(
"172.18.0.4", next_free_ip("172.18.0.0/16", ["172.18.0.2", "172.18.0.3"]),
)
def test_taken_order_does_not_matter(self) -> None:
self.assertEqual(
"172.18.0.2", next_free_ip("172.18.0.0/16", ["172.18.0.3", "172.18.0.5"]),
)
def test_allocation_is_deterministic(self) -> None:
taken = ["172.18.0.2"]
self.assertEqual(next_free_ip("172.18.0.0/16", taken),
next_free_ip("172.18.0.0/16", taken))
def test_raises_when_subnet_exhausted(self) -> None:
# /30: hosts are .1 (router, reserved) and .2; taking .2 leaves none.
with self.assertRaises(NoFreeAddressError):
next_free_ip("10.9.9.0/30", ["10.9.9.2"])
def test_accepts_host_bits_set_cidr(self) -> None:
# A container's inspected address arrives as e.g. 172.18.0.2/16.
self.assertEqual("172.18.0.2", next_free_ip("172.18.0.5/16", []))
if __name__ == "__main__":
unittest.main()
@@ -1,67 +0,0 @@
"""Unit: consolidated per-bottle git-gate provisioning render (PRD 0070)."""
from __future__ import annotations
import unittest
from bot_bottle.git_gate_render import (
GitGateUpstream,
git_gate_render_entrypoint,
git_gate_render_provision,
)
def _ups(*names: str) -> tuple[GitGateUpstream, ...]:
return tuple(
GitGateUpstream(
name=n,
upstream_url=f"ssh://git@github.com/x/{n}.git",
upstream_host="github.com",
upstream_port="22",
identity_file="",
known_host_key="",
)
for n in names
)
class TestProvisionRender(unittest.TestCase):
def test_namespaces_repos_and_creds_by_bottle_id(self) -> None:
script = git_gate_render_provision("bottleab12", _ups("foo"))
self.assertIn("repo=/git/bottleab12/${name}.git", script)
self.assertIn("keyfile=/git-gate/creds/bottleab12/${name}-key", script)
self.assertIn("mkdir -p /git/bottleab12", script)
def test_one_init_repo_call_per_upstream(self) -> None:
script = git_gate_render_provision("b1", _ups("foo", "bar"))
calls = [l for l in script.splitlines() if l.startswith("init_repo ")]
self.assertEqual(2, len(calls))
def test_provision_does_not_start_the_daemon(self) -> None:
# The shared gateway already serves; provisioning is init-only.
self.assertNotIn("git daemon", git_gate_render_provision("b1", _ups("foo")))
def test_installs_pre_receive_hook(self) -> None:
script = git_gate_render_provision("b1", _ups("foo"))
self.assertIn("install -m 755 /etc/git-gate/pre-receive", script)
def test_rejects_unsafe_bottle_id(self) -> None:
for bad in ("../etc", "a/b", "a b", "a;rm", ""):
with self.assertRaises(ValueError):
git_gate_render_provision(bad, _ups("foo"))
class TestEntrypointUnchanged(unittest.TestCase):
"""The shared `_git_gate_init_repo_fn` refactor must not alter the
single-tenant daemon entrypoint's output."""
def test_entrypoint_still_single_tenant_flat(self) -> None:
script = git_gate_render_entrypoint(_ups("foo"))
self.assertIn("repo=/git/${name}.git", script) # flat, not namespaced
self.assertIn("keyfile=/git-gate/creds/${name}-key", script)
self.assertIn("--base-path=/git", script)
self.assertIn("exec git daemon", script)
if __name__ == "__main__":
unittest.main()
-67
View File
@@ -13,17 +13,6 @@ from bot_bottle.git_gate import GIT_GATE_TIMEOUT_SECS
from bot_bottle.git_http_backend import GitHttpHandler, MAX_BODY_BYTES
class _FixedResolver:
"""Maps every source IP to one bottle id (consolidated-mode stub)."""
def __init__(self, bottle_id: str) -> None:
self._bottle_id = bottle_id
def resolve_bottle_id(self, source_ip: str, identity_token: str = "") -> str:
del source_ip, identity_token
return self._bottle_id
class TestGitHttpBackend(unittest.TestCase):
def test_real_git_push_reaches_bare_repo(self):
from http.server import ThreadingHTTPServer
@@ -105,62 +94,6 @@ class TestGitHttpBackend(unittest.TestCase):
).strip()
self.assertEqual(head, cloned)
def test_consolidated_push_stamps_bottle_slug_for_the_hook(self):
# In consolidated mode the backend attributes the push by source IP and
# stamps SUPERVISE_BOTTLE_SLUG=<bottle_id> into the CGI env, so the
# gitleaks-allow pre-receive hook queues its proposal under the right
# bottle. The hook here just records what it received.
from http.server import ThreadingHTTPServer
bottle_id = "bottleab12"
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
bare = root / bottle_id / "repo.git" # namespaced per bottle (slice 10)
bare.parent.mkdir(parents=True)
subprocess.run(["git", "init", "--bare", str(bare)],
check=True, capture_output=True, text=True)
subprocess.run(
["git", "-C", str(bare), "config", "http.receivepack", "true"],
check=True,
)
capture = root / "slug-capture"
hook = bare / "hooks" / "pre-receive"
hook.write_text(
f"#!/bin/sh\nprintf '%s' \"${{SUPERVISE_BOTTLE_SLUG:-UNSET}}\" > "
f"{capture}\ncat >/dev/null\nexit 0\n"
)
hook.chmod(0o755)
old_root = os.environ.get("GIT_PROJECT_ROOT")
os.environ["GIT_PROJECT_ROOT"] = str(root) # base; backend nests per bottle
self.addCleanup(self._restore_env, old_root)
server = ThreadingHTTPServer(("127.0.0.1", 0), GitHttpHandler)
server.policy_resolver = _FixedResolver(bottle_id) # type: ignore[attr-defined]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
self.addCleanup(server.shutdown)
self.addCleanup(server.server_close)
work = root / "work"
work.mkdir()
subprocess.run(["git", "init"], cwd=work, check=True,
capture_output=True, text=True)
subprocess.run(["git", "config", "user.name", "test"], cwd=work, check=True)
subprocess.run(["git", "config", "user.email", "t@example.invalid"],
cwd=work, check=True)
(work / "README.md").write_text("test\n")
subprocess.run(["git", "add", "README.md"], cwd=work, check=True)
subprocess.run(["git", "commit", "-m", "init"], cwd=work,
check=True, capture_output=True, text=True)
url = f"http://127.0.0.1:{server.server_port}/repo.git"
subprocess.run(
["git", "push", url, "HEAD:refs/heads/main"],
cwd=work, check=True, capture_output=True, text=True, timeout=5,
)
self.assertEqual(bottle_id, capture.read_text())
def test_post_forwards_git_cgi_headers(self):
from http.server import ThreadingHTTPServer
-65
View File
@@ -1,65 +0,0 @@
"""Unit: resolve_sandbox_root — source-IP-keyed git-gate repo namespace (PRD 0070).
The consolidated git-http backend serves every bottle from one process and
selects each request's repo root by the calling bottle's source IP. This is
the fail-closed selection logic, tested without a live server.
"""
from __future__ import annotations
import unittest
from pathlib import Path
from bot_bottle.git_http_backend import resolve_sandbox_root
from bot_bottle.policy_resolver import PolicyResolveError
_BASE = Path("/git")
class _FakeResolver:
def __init__(self, bottle_id: str | None = None, raises: bool = False) -> None:
self._bottle_id = bottle_id
self._raises = raises
self.calls: list[tuple[str, str]] = []
def resolve_bottle_id(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._bottle_id
class TestResolveRepoRoot(unittest.TestCase):
def test_single_tenant_passthrough(self) -> None:
# No resolver → the flat base root, unchanged (legacy per-bottle mode).
self.assertEqual(_BASE, resolve_sandbox_root(None, _BASE, "10.243.0.1"))
def test_attributed_bottle_gets_namespaced_root(self) -> None:
root = resolve_sandbox_root(_FakeResolver(bottle_id="ab12cd34"), _BASE, "10.243.0.1")
self.assertEqual(Path("/git/ab12cd34"), root)
def test_unattributed_denies(self) -> None:
self.assertIsNone(resolve_sandbox_root(_FakeResolver(bottle_id=None), _BASE, "10.243.0.9"))
def test_empty_bottle_id_denies(self) -> None:
self.assertIsNone(resolve_sandbox_root(_FakeResolver(bottle_id=""), _BASE, "10.243.0.1"))
def test_resolver_error_denies(self) -> None:
# Orchestrator unreachable/errored must never serve repos.
self.assertIsNone(resolve_sandbox_root(_FakeResolver(raises=True), _BASE, "10.243.0.1"))
def test_namespace_escape_denies(self) -> None:
# A bottle_id that would climb out of the root is rejected (defense in
# depth — registry ids are token_hex, but never trust the namespace).
self.assertIsNone(
resolve_sandbox_root(_FakeResolver(bottle_id="../etc"), _BASE, "10.243.0.1")
)
def test_forwards_source_ip_and_token(self) -> None:
r = _FakeResolver(bottle_id="b1")
resolve_sandbox_root(r, _BASE, "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
import json
import secrets
import tempfile
import threading
import unittest
import urllib.request
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.registry import RegistryStore
from bot_bottle.orchestrator.service import Orchestrator
def _body(obj: object) -> bytes:
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):
def setUp(self) -> None:
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:
self._tmp.cleanup()
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("ok", payload["status"])
def test_register_returns_id_and_token(self) -> None:
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.assertTrue(payload["bottle_id"])
self.assertTrue(payload["identity_token"])
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)
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)
def test_list_redacts_identity_token(self) -> None:
dispatch(self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.1"}))
status, payload = dispatch(self.orch, "GET", "/bottles", b"")
dispatch(self.store, "POST", "/bottles", _body({"source_ip": "10.243.0.1"}))
status, payload = dispatch(self.store, "GET", "/bottles", b"")
self.assertEqual(200, status)
bottles = payload["bottles"]
assert isinstance(bottles, list)
@@ -74,126 +65,58 @@ class TestDispatch(unittest.TestCase):
def test_attribute_ok_and_forbidden(self) -> None:
_, 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"]
ok_status, ok = dispatch(
self.orch, "POST", "/attribute",
self.store, "POST", "/attribute",
_body({"source_ip": "10.243.0.5", "identity_token": token}),
)
self.assertEqual(200, ok_status)
self.assertEqual(reg["bottle_id"], ok["bottle_id"])
bad_status, _ = dispatch(
self.orch, "POST", "/attribute",
self.store, "POST", "/attribute",
_body({"source_ip": "10.243.0.5", "identity_token": "nope"}),
)
self.assertEqual(403, bad_status)
def test_attribute_requires_both_fields(self) -> None:
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)
def test_delete(self) -> None:
_, 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"]
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(True, payload["torn_down"])
self.assertEqual([], self.orch.registry.all())
self.assertEqual(True, payload["deregistered"])
self.assertEqual([], self.store.all())
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)
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)
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)
def test_gateway_status_unconfigured(self) -> None:
status, payload = dispatch(self.orch, "GET", "/gateway", 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):
def test_http_register_health_attribute(self) -> None:
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
orch = _orchestrator(Path(tmp.name) / "r.db")
server = make_server(orch, "127.0.0.1", 0)
store = RegistryStore(Path(tmp.name) / "r.db")
store.migrate()
server = make_server(store, "127.0.0.1", 0)
self.addCleanup(server.server_close)
thread = threading.Thread(target=server.serve_forever, daemon=True)
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()
-129
View File
@@ -1,129 +0,0 @@
"""Unit tests for the consolidated Docker gateway (PRD 0070). Docker mocked."""
from __future__ import annotations
import unittest
from unittest.mock import Mock, patch
from bot_bottle.orchestrator.gateway import (
GATEWAY_NAME,
DockerGateway,
GatewayError,
)
_RUN_DOCKER = "bot_bottle.orchestrator.gateway.run_docker"
def _proc(returncode: int = 0, stdout: str = "", stderr: str = "") -> Mock:
return Mock(returncode=returncode, stdout=stdout, stderr=stderr)
class TestDockerGateway(unittest.TestCase):
def setUp(self) -> None:
self.sc = DockerGateway("bot-bottle-sidecars:latest")
def test_default_name(self) -> None:
self.assertEqual(GATEWAY_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(GatewayError):
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(GatewayError):
self.sc.stop()
class TestDockerGatewayBuild(unittest.TestCase):
def setUp(self) -> None:
self.sc = DockerGateway() # 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 = DockerGateway("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(GatewayError):
self.sc.ensure_built()
if __name__ == "__main__":
unittest.main()
-87
View File
@@ -1,87 +0,0 @@
"""Unit: orchestrator process lifecycle — idempotent singleton (PRD 0070)."""
from __future__ import annotations
import tempfile
import unittest
import urllib.error
from pathlib import Path
from unittest.mock import MagicMock, patch
from bot_bottle.orchestrator.lifecycle import (
OrchestratorProcess,
OrchestratorStartError,
)
from tests.unit import use_bottle_root
_URLOPEN = "bot_bottle.orchestrator.lifecycle.urllib.request.urlopen"
_POPEN = "bot_bottle.orchestrator.lifecycle.subprocess.Popen"
_SLEEP = "bot_bottle.orchestrator.lifecycle.time.sleep"
_MONOTONIC = "bot_bottle.orchestrator.lifecycle.time.monotonic"
def _health(status: int) -> MagicMock:
"""A urlopen() context-manager whose `.status` is `status`."""
m = MagicMock()
m.__enter__.return_value.status = status
return m
class TestOrchestratorProcess(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.addCleanup(use_bottle_root(Path(self._tmp.name)))
self.p = OrchestratorProcess(port=8099)
def test_url(self) -> None:
self.assertEqual("http://127.0.0.1:8099", self.p.url)
def test_is_healthy_true_on_200(self) -> None:
with patch(_URLOPEN, return_value=_health(200)):
self.assertTrue(self.p.is_healthy())
def test_is_healthy_false_on_error(self) -> None:
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
self.assertFalse(self.p.is_healthy())
def test_ensure_running_noop_when_already_healthy(self) -> None:
with patch(_URLOPEN, return_value=_health(200)), patch(_POPEN) as popen:
self.assertEqual(self.p.url, self.p.ensure_running())
popen.assert_not_called() # a live control plane is left untouched
def test_ensure_running_spawns_then_waits_for_health(self) -> None:
# First check (before spawn) fails; after spawn the poll succeeds.
with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
patch(_POPEN) as popen, patch(_SLEEP):
url = self.p.ensure_running()
self.assertEqual(self.p.url, url)
popen.assert_called_once()
def test_ensure_running_raises_on_startup_timeout(self) -> None:
with patch(_URLOPEN, side_effect=urllib.error.URLError("down")), \
patch(_POPEN), patch(_SLEEP), \
patch(_MONOTONIC, side_effect=[0.0, 0.5, 2.0]):
with self.assertRaises(OrchestratorStartError):
self.p.ensure_running(startup_timeout=1.0)
def test_argv_includes_gateway_and_broker(self) -> None:
argv = OrchestratorProcess(port=8099, broker="docker", gateway=True)._argv()
self.assertIn("--gateway", argv)
self.assertIn("bot_bottle.orchestrator", argv)
self.assertEqual("docker", argv[argv.index("--broker") + 1])
def test_argv_omits_gateway_when_disabled(self) -> None:
self.assertNotIn("--gateway", OrchestratorProcess(gateway=False)._argv())
def test_spawn_launches_detached_and_logs(self) -> None:
with patch(_POPEN) as popen:
self.p._spawn()
popen.assert_called_once()
kwargs = popen.call_args.kwargs
self.assertTrue(kwargs["start_new_session"]) # outlives the CLI
self.assertTrue((Path(self._tmp.name) / "orchestrator.log").exists())
if __name__ == "__main__":
unittest.main()
@@ -1,56 +0,0 @@
"""Unit: consolidated registration inputs — egress policy round-trip (PRD 0070)."""
from __future__ import annotations
import json
import unittest
from pathlib import Path
from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.egress_addon_core import LOG_BLOCKS, load_config
from bot_bottle.orchestrator.registration import (
RegistrationInputs,
egress_policy,
registration_inputs,
)
def _plan(routes: tuple[EgressRoute, ...], *, slug: str = "demo", log: int = 0) -> EgressPlan:
return EgressPlan(
slug=slug,
routes_path=Path("/unused/routes.yaml"),
routes=routes,
token_env_map={},
log=log,
)
class TestEgressPolicy(unittest.TestCase):
def test_policy_round_trips_through_load_config(self) -> None:
# The policy the gateway serves must parse back to the same allow-list
# the per-bottle sidecar applied — moving onto the shared gateway must
# not change a bottle's egress.
routes = (EgressRoute(host="api.example.com"), EgressRoute(host="pypi.org"))
cfg = load_config(egress_policy(_plan(routes)))
self.assertEqual(("api.example.com", "pypi.org"), tuple(r.host for r in cfg.routes))
def test_policy_preserves_log_level(self) -> None:
plan = _plan((EgressRoute(host="x.example.com"),), log=LOG_BLOCKS)
self.assertEqual(LOG_BLOCKS, load_config(egress_policy(plan)).log)
def test_empty_routes_yield_deny_all(self) -> None:
cfg = load_config(egress_policy(_plan(())))
self.assertEqual((), cfg.routes) # no routes → default-deny
class TestRegistrationInputs(unittest.TestCase):
def test_bundles_policy_and_slug_metadata(self) -> None:
plan = _plan((EgressRoute(host="api.example.com"),), slug="my-bot")
inputs = registration_inputs(plan)
self.assertIsInstance(inputs, RegistrationInputs)
self.assertEqual("my-bot", json.loads(inputs.metadata)["slug"])
self.assertEqual(egress_policy(plan), inputs.policy) # same blob egress_policy renders
if __name__ == "__main__":
unittest.main()
-36
View File
@@ -97,42 +97,6 @@ class TestRegistryStore(unittest.TestCase):
self.assertNotIn("identity_token", rec.redacted())
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__":
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.gateway import Gateway
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 _FakeGateway(Gateway):
"""In-memory gateway for wiring tests."""
def __init__(self) -> None:
self.name = "fake-gateway"
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_gateway_unconfigured_by_default(self) -> None:
self.assertEqual({"configured": False}, self.orch.gateway_status())
self.orch.ensure_gateway() # no-op, must not raise
def test_gateway_wired_and_ensured(self) -> None:
sc = _FakeGateway()
orch = Orchestrator(self.store, self.broker, self.secret, gateway=sc)
self.assertEqual(
{"configured": True, "name": "fake-gateway", "running": False},
orch.gateway_status(),
)
orch.ensure_gateway()
self.assertEqual(1, sc.built) # ensure_gateway builds first,
self.assertEqual(1, sc.ensured) # then runs
self.assertEqual(
{"configured": True, "name": "fake-gateway", "running": True},
orch.gateway_status(),
)
if __name__ == "__main__":
unittest.main()
-107
View File
@@ -1,107 +0,0 @@
"""Unit tests for the gateway-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"])
def test_resolve_bottle_id_returns_id(self) -> None:
with patch(_URLOPEN, return_value=_resp({"bottle_id": "b1", "policy": "P"})):
self.assertEqual("b1", self.r.resolve_bottle_id("10.243.0.1", "tok"))
def test_resolve_bottle_id_403_is_none_fail_closed(self) -> None:
with patch(_URLOPEN, side_effect=_http_error(403)):
self.assertIsNone(self.r.resolve_bottle_id("10.243.0.9", "tok"))
def test_resolve_bottle_id_missing_field_is_none(self) -> None:
with patch(_URLOPEN, return_value=_resp({"policy": "P"})):
self.assertIsNone(self.r.resolve_bottle_id("10.243.0.1", "tok"))
def test_resolve_bottle_id_error_raises(self) -> None:
with patch(_URLOPEN, side_effect=_http_error(500)):
with self.assertRaises(PolicyResolveError):
self.r.resolve_bottle_id("10.243.0.1", "tok")
def test_resolve_policy_and_bottle_id_one_call(self) -> None:
with patch(_URLOPEN, return_value=_resp({"bottle_id": "b1", "policy": "P"})) as m:
self.assertEqual(("P", "b1"), self.r.resolve_policy_and_bottle_id("10.243.0.1", "t"))
self.assertEqual(1, m.call_count) # both from a single /resolve
def test_resolve_policy_and_bottle_id_403_is_none_none(self) -> None:
with patch(_URLOPEN, side_effect=_http_error(403)):
self.assertEqual((None, None), self.r.resolve_policy_and_bottle_id("10.243.0.9"))
def test_resolve_policy_and_bottle_id_error_raises(self) -> None:
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
with self.assertRaises(PolicyResolveError):
self.r.resolve_policy_and_bottle_id("10.243.0.1")
if __name__ == "__main__":
unittest.main()
-53
View File
@@ -6,7 +6,6 @@ import sys
import tempfile
import threading
import time
import types
import unittest
from pathlib import Path
from unittest.mock import patch
@@ -630,57 +629,5 @@ class TestHttpEndToEnd(unittest.TestCase):
conn.close()
class _FakeResolver:
def __init__(self, bottle_id: str | None = None, raises: bool = False) -> None:
self._bottle_id = bottle_id
self._raises = raises
self.calls: list[str] = []
def resolve_bottle_id(self, source_ip: str, identity_token: str = "") -> str | None:
del identity_token
self.calls.append(source_ip)
if self._raises:
# Raise the exact class supervise_server catches (it imports
# policy_resolver flat inside the bundle, package-side in tests).
raise supervise_server.PolicyResolveError("orchestrator down")
return self._bottle_id
def _handler(resolver: object) -> MCPHandler:
"""A bare MCPHandler wired with a server (carrying the resolver) and a
client address, enough to exercise `_attributed_config` off-socket."""
h: MCPHandler = MCPHandler.__new__(MCPHandler)
h.server = types.SimpleNamespace(policy_resolver=resolver) # type: ignore[assignment]
h.client_address = ("10.0.0.7", 4321)
return h
class TestAttributedConfig(unittest.TestCase):
"""Consolidated supervise: each proposal is attributed to the calling
bottle by source IP; single-tenant keeps the env slug (PRD 0070)."""
def test_single_tenant_keeps_env_slug(self) -> None:
cfg = _handler(None)._attributed_config(ServerConfig(bottle_slug="dev"))
self.assertEqual("dev", cfg.bottle_slug)
def test_consolidated_binds_source_ip_bottle(self) -> None:
r = _FakeResolver(bottle_id="bottle-x")
cfg = _handler(r)._attributed_config(ServerConfig(bottle_slug="ignored"))
self.assertEqual("bottle-x", cfg.bottle_slug) # resolved slug wins
self.assertEqual(["10.0.0.7"], r.calls)
def test_unattributed_source_fails_closed(self) -> None:
with self.assertRaises(_RpcInternalError):
_handler(_FakeResolver(bottle_id=None))._attributed_config(
ServerConfig(bottle_slug="x")
)
def test_resolver_error_fails_closed(self) -> None:
with self.assertRaises(_RpcInternalError):
_handler(_FakeResolver(raises=True))._attributed_config(
ServerConfig(bottle_slug="x")
)
if __name__ == "__main__":
unittest.main()