diff --git a/Dockerfile.sidecars b/Dockerfile.sidecars index e581856..dc7e911 100644 --- a/Dockerfile.sidecars +++ b/Dockerfile.sidecars @@ -66,6 +66,7 @@ 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/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 COPY bot_bottle/migrations.py /app/migrations.py COPY bot_bottle/db_store.py /app/db_store.py COPY bot_bottle/supervise_types.py /app/supervise_types.py diff --git a/bot_bottle/audit_store.py b/bot_bottle/audit_store.py index 7f25e9e..c01d2bb 100644 --- a/bot_bottle/audit_store.py +++ b/bot_bottle/audit_store.py @@ -6,11 +6,13 @@ import sqlite3 from pathlib import Path try: - from .supervise_types import AuditEntry, host_db_path + from .supervise_types import AuditEntry + from .paths import host_db_path from .db_store import DbStore from .migrations import TableMigrations except ImportError: - from supervise_types import AuditEntry, host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + from supervise_types import AuditEntry # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + from paths import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from db_store import DbStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module diff --git a/bot_bottle/backend/docker/cleanup.py b/bot_bottle/backend/docker/cleanup.py index 254c63f..298d5fe 100644 --- a/bot_bottle/backend/docker/cleanup.py +++ b/bot_bottle/backend/docker/cleanup.py @@ -26,7 +26,7 @@ from __future__ import annotations import shutil import subprocess -from ... import supervise as _supervise +from ...paths import bot_bottle_root from ...log import info, warn from . import util as docker_mod from .bottle_cleanup_plan import DockerBottleCleanupPlan @@ -94,7 +94,7 @@ def _list_orphan_state_dirs( ANY backend — used so this docker-side check doesn't reap a running non-docker bottle's state dir (the layout is shared across backends).""" - state_root = _supervise.bot_bottle_root() / "state" + state_root = bot_bottle_root() / "state" if not state_root.is_dir(): return [] orphans: list[str] = [] diff --git a/bot_bottle/bottle_state.py b/bot_bottle/bottle_state.py index b97c89b..1ac5bb0 100644 --- a/bot_bottle/bottle_state.py +++ b/bot_bottle/bottle_state.py @@ -36,7 +36,7 @@ from dataclasses import dataclass from pathlib import Path from typing import cast -from . import supervise as _supervise +from .paths import bot_bottle_root # Directory layout: ~/.bot-bottle/state//... @@ -163,7 +163,7 @@ def read_metadata(identity: str) -> BottleMetadata | None: def bottle_state_dir(identity: str) -> Path: """Per-bottle state directory on the host. Created lazily by the write helpers; readers tolerate its absence.""" - return _supervise.bot_bottle_root() / _STATE_SUBDIR / identity + return bot_bottle_root() / _STATE_SUBDIR / identity def per_bottle_dockerfile_path(identity: str) -> Path: diff --git a/bot_bottle/cli/supervise.py b/bot_bottle/cli/supervise.py index bf0766b..ff66ba6 100644 --- a/bot_bottle/cli/supervise.py +++ b/bot_bottle/cli/supervise.py @@ -19,7 +19,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path -from .. import supervise as _supervise +from ..paths import bot_bottle_root from ..bottle_state import read_metadata from ..backend.docker.egress_apply import ( EgressApplyError, @@ -276,7 +276,7 @@ def _write_crash_log(exc: BaseException) -> Path: ) entry = f"=== supervise crash {stamp} ===\n{body}\n" try: - log_dir = _supervise.bot_bottle_root() / "logs" + log_dir = bot_bottle_root() / "logs" log_dir.mkdir(parents=True, exist_ok=True) path = log_dir / "supervise-crash.log" with path.open("a", encoding="utf-8") as fh: diff --git a/bot_bottle/orchestrator/__init__.py b/bot_bottle/orchestrator/__init__.py new file mode 100644 index 0000000..b04bab4 --- /dev/null +++ b/bot_bottle/orchestrator/__init__.py @@ -0,0 +1,30 @@ +"""Per-host orchestrator service (PRD 0070). + +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). + * `control_plane` — the HTTP control-plane RPC (register / deregister / + list / attribute / health), with live reload. + +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 .control_plane import ControlPlaneServer, dispatch, make_server + +__all__ = [ + "BottleRecord", + "RegistryStore", + "new_identity_token", + "ControlPlaneServer", + "dispatch", + "make_server", +] diff --git a/bot_bottle/orchestrator/__main__.py b/bot_bottle/orchestrator/__main__.py new file mode 100644 index 0000000..7a2fd9a --- /dev/null +++ b/bot_bottle/orchestrator/__main__.py @@ -0,0 +1,52 @@ +"""Run the orchestrator control plane as a plain process (PRD 0070 dev-harness). + + python -m bot_bottle.orchestrator [--host H] [--port P] [--db PATH] + +The PRD sequences the orchestrator as a plain-process dev-harness first, so +the consolidation core (registry + attribution + HTTP control plane + live +reload) can be exercised with fast iteration, decoupled from any VM / +container packaging. Wrapping this exact service in a backend-native unit +(docker container, then Firecracker VM) comes later. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from .. import log +from .control_plane import make_server +from .registry import RegistryStore, default_db_path + + +def main(argv: list[str] | None = None) -> int: + """Parse args, migrate the registry, and serve the control plane.""" + parser = argparse.ArgumentParser(prog="bot_bottle.orchestrator") + parser.add_argument("--host", default="127.0.0.1", help="bind address") + parser.add_argument("--port", type=int, default=8080, help="bind port (0 = ephemeral)") + parser.add_argument( + "--db", type=Path, default=None, + help=f"registry DB path (default: {default_db_path()})", + ) + args = parser.parse_args(argv) + + registry = RegistryStore(args.db) + registry.migrate() + + 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", + context={"host": bound_host, "port": bound_port, "db": str(registry.db_path)}, + ) + try: + server.serve_forever() + except KeyboardInterrupt: + log.info("orchestrator shutting down") + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bot_bottle/orchestrator/control_plane.py b/bot_bottle/orchestrator/control_plane.py new file mode 100644 index 0000000..772eae2 --- /dev/null +++ b/bot_bottle/orchestrator/control_plane.py @@ -0,0 +1,154 @@ +"""Orchestrator HTTP control plane (PRD 0070). + +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). Endpoints mutate the live +registry, so register/deregister are the *live-reload* path — no restart: + + GET /health -> 200 {"status": "ok"} + GET /bottles -> 200 {"bottles": [ , ... ]} + POST /bottles -> 201 {"bottle_id", "identity_token"} + body: {"source_ip", ["bottle_id"], ["metadata"]} + DELETE /bottles/ -> 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. + +Note the listing redacts identity tokens — they are never returned except +once, to the caller that registers the bottle. +""" + +from __future__ import annotations + +import http.server +import json +import os +import socketserver +import typing +from urllib.parse import urlsplit + +from .registry import RegistryStore + +# JSON body payload type (parsed request / rendered response). +Json = dict[str, object] + + +def _parse_json_object(body: bytes) -> Json: + """Parse a JSON object body. Raises ValueError for non-objects / bad JSON.""" + if not body: + return {} + obj = json.loads(body) # raises json.JSONDecodeError (a ValueError) + if not isinstance(obj, dict): + raise ValueError("request body must be a JSON object") + return obj + + +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 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 == "/bottles": + return 200, {"bottles": [r.redacted() for r in registry.all()]} + + if method == "POST" and route == "/bottles": + try: + data = _parse_json_object(body) + except ValueError as e: + return 400, {"error": f"invalid JSON: {e}"} + source_ip = data.get("source_ip") + if not isinstance(source_ip, str) or not source_ip: + return 400, {"error": "source_ip (string) is required"} + bottle_id = data.get("bottle_id") + metadata = data.get("metadata") + rec = registry.register( + source_ip, + bottle_id=bottle_id if isinstance(bottle_id, str) else None, + metadata=metadata if isinstance(metadata, str) else "", + ) + return 201, {"bottle_id": rec.bottle_id, "identity_token": rec.identity_token} + + if method == "DELETE" and route.startswith("/bottles/"): + bottle_id = route[len("/bottles/"):] + if registry.deregister(bottle_id): + return 200, {"deregistered": True} + return 404, {"error": "no such bottle"} + + if method == "POST" and route == "/attribute": + 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 isinstance(token, str): + return 400, {"error": "source_ip and identity_token (strings) required"} + rec = registry.attribute(source_ip, token) + if rec is None: + return 403, {"error": "unattributed"} + return 200, {"bottle_id": rec.bottle_id} + + return 404, {"error": "not found"} + + +class Handler(http.server.BaseHTTPRequestHandler): + """Thin stdlib adapter: read the body, call `dispatch`, write JSON.""" + + # Quiet by default (the orchestrator has its own logging); opt back into + # stdlib access logging with BOT_BOTTLE_ORCHESTRATOR_DEBUG. + def log_message(self, format: str, *args: typing.Any) -> None: # noqa: A002 + if os.environ.get("BOT_BOTTLE_ORCHESTRATOR_DEBUG"): + super().log_message(format, *args) + + def _serve(self, method: str) -> None: + """Read the request body, dispatch it, and write the JSON reply.""" + server = self.server + 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.registry, method, self.path, body) + data = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self) -> None: + self._serve("GET") + + def do_POST(self) -> None: + self._serve("POST") + + def do_DELETE(self) -> None: + self._serve("DELETE") + + +class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer): + """Threading HTTP server that carries the registry for its handlers.""" + + daemon_threads = True + allow_reuse_address = True + + def __init__(self, address: tuple[str, int], registry: RegistryStore) -> None: + self.registry = registry + super().__init__(address, Handler) + + +def make_server( + 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), registry) + + +__all__ = ["dispatch", "Handler", "ControlPlaneServer", "make_server", "Json"] diff --git a/bot_bottle/orchestrator/registry.py b/bot_bottle/orchestrator/registry.py new file mode 100644 index 0000000..dd962a5 --- /dev/null +++ b/bot_bottle/orchestrator/registry.py @@ -0,0 +1,219 @@ +"""Orchestrator bottle registry — the per-host runtime-state store (PRD 0070). + +SQLite-backed registry mapping each live bottle to its source IP and +per-bottle identity token, plus the **fail-closed attribution** the data +plane relies on: a request is attributed to a bottle only when its source +IP *and* its identity token both match a single active record. + +The registry co-tenants the shared host `bot-bottle.db` (the `DbStore` +framework namespaces each store by `schema_key`), so all bot-bottle +runtime state lives in one queryable file — one place to back up, inspect, +and integrate a console against. + +This is the "runtime state" tier of PRD 0070 (leases / approvals / +registry) — deliberately NOT config (which stays declarative under +`~/.bot-bottle/`) and NOT the build-time constants (a flat file). It is the +source of truth the orchestrator sweeps on restart to re-adopt running +bottles, so it lives in a durable store rather than process memory. + +Attribution combines two independent signals, and needs both: + + * source IP — the network-layer invariant (on Firecracker the `/31` TAP + + nft make it unspoofable by construction; weaker on other backends). + * identity token — an application-layer per-bottle secret, defence in + depth that doesn't lean on network anti-spoof. A bottle can only ever + prove it is *itself* (it can't learn another bottle's token), so a + hostile agent gains nothing by presenting it. +""" + +from __future__ import annotations + +import hmac +import secrets +import sqlite3 +import time +from dataclasses import dataclass +from pathlib import Path + +from ..db_store import DbStore +from ..migrations import TableMigrations +from ..paths import host_db_path + +# 256 bits of urandom, URL-safe — unguessable per-bottle identity token. +IDENTITY_TOKEN_BYTES = 32 + + +def new_identity_token() -> str: + """A fresh per-bottle identity token (PRD 0070 attribution defence).""" + return secrets.token_urlsafe(IDENTITY_TOKEN_BYTES) + + +def default_db_path() -> Path: + """The shared host state DB (`bot-bottle.db`) — all bot-bottle runtime + state in one file, co-tenanted via schema_key. Host-resident so it + survives orchestrator restarts (re-adoption sweeps it).""" + return host_db_path() + + +@dataclass(frozen=True) +class BottleRecord: + """One live bottle in the registry.""" + + bottle_id: str + source_ip: str + identity_token: str + state: str = "active" + created_at: float = 0.0 + # Opaque JSON blob for forward-compat (policy refs, pool slot, ...) — + # the registry doesn't interpret it, so new fields need no migration. + metadata: str = "" + + def redacted(self) -> dict[str, object]: + """Public view for listing — never exposes the identity token.""" + return { + "bottle_id": self.bottle_id, + "source_ip": self.source_ip, + "state": self.state, + "created_at": self.created_at, + "metadata": self.metadata, + } + + +_MIGRATIONS = TableMigrations( + "orchestrator_registry", + [ + # v1 — the live bottle registry. + """ + CREATE TABLE IF NOT EXISTS orchestrator_bottles ( + bottle_id TEXT PRIMARY KEY, + source_ip TEXT NOT NULL, + identity_token TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'active', + created_at REAL NOT NULL, + metadata TEXT NOT NULL DEFAULT '' + ) + """, + # source_ip is the data-plane attribution key — index it, and make + # the "one active bottle per source IP" check cheap. + "CREATE INDEX IF NOT EXISTS idx_orchestrator_bottles_source_ip " + "ON orchestrator_bottles (source_ip)", + ], +) + + +def _row_to_record(row: sqlite3.Row) -> BottleRecord: + """Build a BottleRecord from a registry row.""" + return BottleRecord( + bottle_id=row["bottle_id"], + source_ip=row["source_ip"], + identity_token=row["identity_token"], + state=row["state"], + created_at=row["created_at"], + metadata=row["metadata"], + ) + + +class RegistryStore(DbStore): + """SQLite-backed registry of live bottles + fail-closed attribution.""" + + def __init__(self, db_path: Path | None = None) -> None: + super().__init__(db_path or default_db_path(), _MIGRATIONS) + + def _connect(self) -> sqlite3.Connection: + """Open a connection with a busy timeout for the shared DB.""" + conn = super()._connect() + # The registry co-tenants the shared bot-bottle.db, so a busy_timeout + # rides out brief lock contention with the other stores. WAL for the + # shared DB is a deliberate future change (it affects supervise/audit + # and is finicky over guest shares) — not flipped here. + conn.execute("PRAGMA busy_timeout=5000") + return conn + + def register( + self, + source_ip: str, + *, + bottle_id: str | None = None, + identity_token: str | None = None, + metadata: str = "", + ) -> BottleRecord: + """Register (or replace) a bottle. Mints a bottle_id / identity token + when not supplied. Live — this is the control-plane reload path.""" + rec = BottleRecord( + bottle_id=bottle_id or secrets.token_hex(8), + source_ip=source_ip, + identity_token=identity_token or new_identity_token(), + state="active", + created_at=time.time(), + metadata=metadata, + ) + with self._connect() as conn: + conn.execute( + "INSERT OR REPLACE INTO orchestrator_bottles " + "(bottle_id, source_ip, identity_token, state, created_at, metadata) " + "VALUES (?, ?, ?, ?, ?, ?)", + ( + rec.bottle_id, + rec.source_ip, + rec.identity_token, + rec.state, + rec.created_at, + rec.metadata, + ), + ) + self._chmod() + return rec + + def deregister(self, bottle_id: str) -> bool: + """Remove a bottle. Returns True if a row was deleted.""" + with self._connect() as conn: + cur = conn.execute( + "DELETE FROM orchestrator_bottles WHERE bottle_id = ?", (bottle_id,) + ) + return cur.rowcount > 0 + + def get(self, bottle_id: str) -> BottleRecord | None: + """Return the bottle by id, or None if absent.""" + with self._connect() as conn: + row = conn.execute( + "SELECT * FROM orchestrator_bottles WHERE bottle_id = ?", (bottle_id,) + ).fetchone() + return _row_to_record(row) if row else None + + def all(self) -> list[BottleRecord]: + """Every registered bottle, oldest first.""" + with self._connect() as conn: + rows = conn.execute( + "SELECT * FROM orchestrator_bottles ORDER BY created_at" + ).fetchall() + return [_row_to_record(r) for r in rows] + + 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 " + "WHERE source_ip = ? AND state = 'active'", + (source_ip,), + ).fetchall() + if len(rows) != 1: + return None + rec = _row_to_record(rows[0]) + if not hmac.compare_digest(rec.identity_token, identity_token): + return None + return rec + + +__all__ = [ + "BottleRecord", + "RegistryStore", + "new_identity_token", + "default_db_path", + "IDENTITY_TOKEN_BYTES", +] diff --git a/bot_bottle/paths.py b/bot_bottle/paths.py new file mode 100644 index 0000000..8ac64ad --- /dev/null +++ b/bot_bottle/paths.py @@ -0,0 +1,43 @@ +"""Foundational filesystem paths for bot-bottle. + +`bot_bottle_root()` is the app data root — state, queue, audit logs, +git-gate keys, and the shared DB all live under it. It defaults to +`~/.bot-bottle` and is overridable with the **`BOT_BOTTLE_ROOT`** env var. + +The env override is the single knob for redirecting the root: the test +suite points it at a throwaway dir instead of monkey-patching the function +(every module and every flat/package copy reads the same env var, so one +override covers them all), and operators can relocate the root if needed. + +This module has no bot-bottle imports, so it is safe to import from any +layer (and to COPY flat into the sidecar bundle). +""" + +from __future__ import annotations + +import os +from pathlib import Path + +# The single shared host state DB. All bot-bottle SQLite stores (supervise +# queue, audit, the orchestrator registry) co-tenant this one file — the +# TableMigrations schema_key namespaces each store's tables. +HOST_DB_FILENAME = "bot-bottle.db" + + +def bot_bottle_root() -> Path: + """The app data root — `$BOT_BOTTLE_ROOT` if set, else `~/.bot-bottle`.""" + override = os.environ.get("BOT_BOTTLE_ROOT") + return Path(override) if override else Path.home() / ".bot-bottle" + + +def host_db_path() -> Path: + """Path to the shared host state DB, `/db/bot-bottle.db`. + + Kept in its own `db/` subdirectory (not directly under the root) so a + backend that can only bind-mount *directories* can share this one file + with a sidecar without exposing the root's other contents (git-gate + keys, per-bottle state, ...).""" + return bot_bottle_root() / "db" / HOST_DB_FILENAME + + +__all__ = ["HOST_DB_FILENAME", "bot_bottle_root", "host_db_path"] diff --git a/bot_bottle/queue_store.py b/bot_bottle/queue_store.py index 0fe3295..cd6b188 100644 --- a/bot_bottle/queue_store.py +++ b/bot_bottle/queue_store.py @@ -7,11 +7,13 @@ import sqlite3 from pathlib import Path try: - from .supervise_types import Proposal, Response, host_db_path + from .supervise_types import Proposal, Response + from .paths import host_db_path from .db_store import DbStore from .migrations import TableMigrations except ImportError: - from supervise_types import Proposal, Response, host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + from supervise_types import Proposal, Response # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + from paths import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from db_store import DbStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module diff --git a/bot_bottle/store_manager.py b/bot_bottle/store_manager.py index 70b1f46..bb1c494 100644 --- a/bot_bottle/store_manager.py +++ b/bot_bottle/store_manager.py @@ -23,13 +23,10 @@ class StoreManager: def __init__(self, db_path: Path | None = None) -> None: if db_path is None: - # Lazy import to avoid a circular dependency: supervise imports - # StoreManager at module level; StoreManager must not import - # supervise at module level in return. try: - from .supervise import host_db_path + from .paths import host_db_path except ImportError: - from supervise import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + from paths import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module db_path = host_db_path() self.db_path = db_path diff --git a/bot_bottle/supervise.py b/bot_bottle/supervise.py index cfa6a51..b086ad4 100644 --- a/bot_bottle/supervise.py +++ b/bot_bottle/supervise.py @@ -41,7 +41,6 @@ try: from .supervise_types import ( ACTION_OPERATOR_EDIT, AuditEntry, - HOST_DB_FILENAME, Proposal, Response, STATUSES, @@ -54,13 +53,11 @@ try: TOOL_EGRESS_TOKEN_ALLOW, TOOL_GITLEAKS_ALLOW, TOOL_LIST_EGRESS_ROUTES, - bot_bottle_root, ) except ImportError: from supervise_types import ( # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module ACTION_OPERATOR_EDIT, AuditEntry, - HOST_DB_FILENAME, Proposal, Response, STATUSES, @@ -73,10 +70,15 @@ except ImportError: TOOL_EGRESS_TOKEN_ALLOW, TOOL_GITLEAKS_ALLOW, TOOL_LIST_EGRESS_ROUTES, - bot_bottle_root, ) +try: + from .paths import bot_bottle_root +except ImportError: # flat imports inside the sidecar bundle + from paths import bot_bottle_root # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module + + SUPERVISE_HOSTNAME = "supervise" SUPERVISE_PORT = 9100 @@ -106,16 +108,6 @@ def audit_dir() -> Path: return bot_bottle_root() / "audit" -def host_db_path() -> Path: - # Calls bot_bottle_root() through this module's globals so that patches - # on supervise.bot_bottle_root propagate to callers going through - # supervise.host_db_path(). - # - # Kept in its own "db" subdirectory (see supervise_types.host_db_path - # for why) — must stay in sync with that copy. - return bot_bottle_root() / "db" / HOST_DB_FILENAME - - def audit_log_path(component: str, slug: str) -> Path: return audit_dir() / f"{component}-{slug}.log" @@ -297,8 +289,6 @@ __all__ = [ "archive_proposal", "audit_dir", "audit_log_path", - "bot_bottle_root", - "host_db_path", "list_pending_proposals", "list_all_pending_proposals", "read_audit_entries", diff --git a/bot_bottle/supervise_types.py b/bot_bottle/supervise_types.py index 80af0ae..d62343d 100644 --- a/bot_bottle/supervise_types.py +++ b/bot_bottle/supervise_types.py @@ -1,26 +1,19 @@ -"""Shared types and path helpers for supervise stores (PRD 0013). +"""Shared types for supervise stores (PRD 0013). Extracted from supervise.py so queue_store and audit_store can import -Proposal, Response, AuditEntry, and host_db_path without creating a -circular import (supervise imports from queue_store/audit_store and -vice-versa). +Proposal, Response, and AuditEntry without creating a circular import +(supervise imports from queue_store/audit_store and vice-versa). -Patching bot_bottle_root on this module propagates through host_db_path -because host_db_path looks up bot_bottle_root via sys.modules[__name__] -at call time rather than capturing it at import time. +The path helpers (`bot_bottle_root`, `host_db_path`, `HOST_DB_FILENAME`) +live in `paths` — import them from there. """ from __future__ import annotations import dataclasses -import sys import uuid from dataclasses import dataclass from datetime import datetime, timezone -from pathlib import Path - - -HOST_DB_FILENAME = "bot-bottle.db" TOOL_EGRESS_BLOCK = "egress-block" TOOL_EGRESS_ALLOW = "egress-allow" @@ -43,23 +36,6 @@ STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED) ACTION_OPERATOR_EDIT = "operator-edit" -def bot_bottle_root() -> Path: - return Path.home() / ".bot-bottle" - - -def host_db_path() -> Path: - # Look up bot_bottle_root through this module's live namespace so that - # monkey-patches on supervise_types.bot_bottle_root take effect. - # - # Lives in its own "db" subdirectory, not directly under - # bot_bottle_root(), so backends that can only bind-mount - # directories (macos_container's `container run --mount`, which - # rejects file sources with "is not a directory") can share this - # one file with a sidecar without also exposing bot_bottle_root()'s - # other contents (git-gate keys, per-bottle state, etc.). - return sys.modules[__name__].bot_bottle_root() / "db" / HOST_DB_FILENAME - - def _require_str(raw: dict[str, object], key: str) -> str: value = raw.get(key) if not isinstance(value, str): @@ -171,7 +147,6 @@ class AuditEntry: __all__ = [ "ACTION_OPERATOR_EDIT", "AuditEntry", - "HOST_DB_FILENAME", "Proposal", "Response", "STATUSES", @@ -184,6 +159,4 @@ __all__ = [ "TOOL_EGRESS_TOKEN_ALLOW", "TOOL_GITLEAKS_ALLOW", "TOOL_LIST_EGRESS_ROUTES", - "bot_bottle_root", - "host_db_path", ] diff --git a/docs/prds/0069-firecracker-native-docker-free.md b/docs/prds/0069-firecracker-native-docker-free.md index 0a24167..c771c6c 100644 --- a/docs/prds/0069-firecracker-native-docker-free.md +++ b/docs/prds/0069-firecracker-native-docker-free.md @@ -1,10 +1,16 @@ # PRD 0069: Firecracker-native, Docker-free backend -- **Status:** Draft +- **Status:** Draft (partially superseded) - **Author:** Claude - **Created:** 2026-07-12 - **Issue:** #348 +> **Superseded in part by [PRD 0070](0070-per-host-orchestrator.md) (#351):** +> the sidecar-consolidation framing here (Stage 1, per-host sidecar; Stage 4, +> sidecar-as-VM) is taken over by 0070's per-host orchestrator. This PRD still +> owns the docker-free **image-building** work — Stage 2 (nix-built fixed +> images, a dependency of 0070) and Stage 3 (in-VM Dockerfile builder). + ## Summary Make the Firecracker backend depend on **firecracker + KVM only**, removing diff --git a/docs/prds/0070-per-host-orchestrator.md b/docs/prds/0070-per-host-orchestrator.md new file mode 100644 index 0000000..1e8986d --- /dev/null +++ b/docs/prds/0070-per-host-orchestrator.md @@ -0,0 +1,433 @@ +# PRD 0070: Per-host orchestrator service + +- **Status:** Draft +- **Author:** Claude +- **Created:** 2026-07-12 +- **Issue:** #351 +- **Supersedes:** the Stage-1 / Stage-4 sidecar-consolidation framing of + PRD 0069 (#348). Depends on 0069's nix-built fixed images (Stage 2) for + bootstrapping; 0069 still owns the docker-free image-building work. + +## Summary + +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 +microVM on the Firecracker backend, an Apple container on macOS, a Docker +container on the legacy backend — and is fronted by a single +**backend-agnostic contract**. Per-backend variation lives on +`BottleBackend`, not in the orchestrator. + +## Motivation + +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 + 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 +be made explicit. It's also the component that will own per-host runtime +**state** (slot leases, the approval queue, the bottle registry) — today +that's ad-hoc `fcntl`-locked files. + +## Security review (read this first) + +Consolidation is a real change to the trust model. The goal is to **not +significantly weaken** the posture; some properties strengthen, some +weaken, and the weakened ones must be mitigated by design, not hand-waved. + +### What gets stronger + +- **Build/host isolation of untrusted inputs** (with 0069 Stage 3): user + Dockerfiles build in a disposable VM instead of on the host. +- **One audited privileged surface.** Today the launcher runs as the full + host user and needs the Docker socket (root-equivalent). The orchestrator + model replaces that with a **thin launch broker** (below) — a small, + 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 sidecar implicitly trusting network position. + +### What gets weaker, and the mitigation + +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 + attribution bug leaks bottle A's token into bottle B's request — a class + of bug that *cannot exist* per-bottle. + - *Mitigation:* lean on the enforced source-IP invariant for + attribution; keep the most secret-dense, least-shareable service + (**git-gate**, per-repo deploy keys, no natural source-IP scoping) + **per-bottle** unless there's a compelling reason; scope each secret + to the bottle in the state DB so a lookup can't return the wrong + bottle's secret by construction (key every secret access by the + verified source identity, never by ambient state). + +2. **Shared fate.** Orchestrator down = no new launches, and running + agents lose egress / git / supervise. Compromise = the whole host's + 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 sidecar + connectivity until it's back); persist state to a host volume so a + restart re-adopts live bottles rather than losing them. + +3. **The launch broker is the new privileged core.** We don't eliminate + host privilege — we shrink and relocate it. If the broker accepts + arbitrary paths/commands, the orchestrator VM can escape through it. + - *Mitigation:* the broker takes **structured requests only** — "launch + bottle from *this* content-addressed, nix-built rootfs on TAP slot + *k*", never "run this argv". It validates against a fixed image set, + not caller-supplied paths. It is small enough to audit line-by-line. + +4. **The egress proxy now parses every bottle's traffic in one process.** + Higher blast radius for a mitmproxy/TLS-bump bug. + - *Mitigation:* this is the argument for virtualizing the orchestrator + from the start (Stage B, not a host daemon) — the code that TLS-bumps + and parses agent traffic and holds every token runs **inside its own + confined VM**, not as a host process. If egress sharing's blast radius + feels too high, egress can stay per-bottle while supervise (near-zero + secrets) goes host-level first. + +### The attribution invariant + +Source-IP attribution is what makes a shared orchestrator safe: one +process serves every bottle and tells them apart by source address. The +*mechanism* is identical everywhere (read source IP → look up bottle); the +**guarantee that the address can't be forged is a per-backend +responsibility** and part of the contract: + +> **Invariant:** a packet's source address, as seen by the orchestrator, +> *provably* identifies the originating bottle. + +- **Firecracker** — enforced by the `/31` point-to-point TAP + the + `bot_bottle_fc` nft table (strongest; already built). +- **Docker** — the per-bottle `--internal` network + anti-spoof; weaker, + must be made explicit. +- **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 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 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 + *by construction*, so the token is belt-and-suspenders there — but cheap + insurance against a misconfigured invariant. +- On **weaker backends** (Docker) it is load-bearing, providing attribution + that doesn't lean on network anti-spoof. + +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 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). + +### Secret handling — a FUTURE pattern (not v1) + +> **Status: future / not required for the initial orchestrator.** The +> initial cut can inject secrets as today; this section records the +> direction so v1 doesn't paint itself into a corner. Tracked as its own +> work in **#355** (generic `SecretProvider`). + +The residual weakness after all of the above is **long-lived credential +concentration** — the data-plane proxies must hold every bottle's upstream +tokens because the agent must never see them. You can't policy-gate the +component whose job is to *use* all the secrets, so a full RCE of a proxy +drains its authorized set regardless. Two moves bound this without +pretending to prevent it: + +1. **Vault as a separate trust domain.** The long-lived *roots* live in a + distinct process (ideally its own VM) that the byte-parsing data plane + never shares memory with. The proxies request secrets from it; the + crown jewels are not in the process an agent-facing parser can pop. +2. **Derive short-lived, scoped creds where the upstream allows it.** The + vault holds the root and mints expiring, narrowly-scoped credentials + per bottle-start (or per request) — GitHub App installation tokens, + OAuth/STS token exchange, forge deploy tokens. A compromise then leaks + short-lived material, not permanent keys. For upstreams stuck on static + keys the vault passes the value through (only the at-rest / audit / + revocation benefits apply, not lifetime reduction) — this residue is + accepted and documented, not solved. + +Even a plain per-request fetch (no derivation) still buys **at-rest** +reduction (process memory holds only in-flight secrets), a **detection / +rate-limit / revocation chokepoint**, and clean **cross-component +scoping** (an egress RCE can't request git-gate's creds). It does *not* +prevent abuse of currently-authorized access during the compromise window. + +**Mechanism (see #355):** generalize the existing `DeployKeyProvisioner` +(PRD 0048) into a user-extensible **`SecretProvider`** droppable into the +manifest anywhere a raw token value is accepted, discovered from +`~/.bot-bottle/contrib//secret_provider.py` exactly as user +`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 sidecars can use it too. + +## Design + +### The contract (backend-agnostic) + +Three surfaces; only one is per-backend. + +1. **Control plane (CLI / console → orchestrator)** — an RPC: + `launch_bottle`, `teardown_bottle`, `register_policy`, + `deregister_bottle`, `supervise_queue`. Fully backend-agnostic. Both the + local `cli.py` and the remote console funnel through it, so policy is + uniform and `cli.py` becomes a thin client rather than a parallel + launcher. **Transport: HTTP** — the most universal/reliable choice on + 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://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`. + +### One `Orchestrator`, no subclass tree + +The orchestrator is a **single concrete class** holding all the +backend-neutral logic — egress addon, git-gate, supervise, source-IP +attribution, live-reload control plane, console client. It never branches +on backend; it *composes* a `BottleBackend`. That composition is what makes +the contract agnostic: there is nothing backend-specific left in the +orchestrator to leak. + +Rejected alternative: an `Orchestrator` ABC with per-backend +implementations. The interesting logic (proxies, attribution, control +plane) is backend-neutral, so three subclasses would triplicate the hard +part; and a second hierarchy paralleling `BottleBackend` reintroduces the +same hand-maintained lockstep coupling we just removed from the netpool +constants (PR #350). Composition over a parallel tree. + +### `BottleBackend` absorbs the per-backend variation + +A small, cohesive surface — reused for launching agent bottles *and* the +orchestrator's own unit (the orchestrator is just another native unit): + +``` +launch_unit(spec) -> Handle # agent bottle OR the orchestrator itself + # (fc microVM / apple ctr / docker ctr) +wire(unit, endpoint) -> None # DNAT+forward (fc) | attach shared net (docker/apple) +endpoint_of(unit) -> Endpoint # address resolution +health(unit) -> Status +``` + +Plus the **launch broker** — the answer to "a VM/container can't spawn its +own host-network siblings." The orchestrator can't directly open host +`/dev/kvm` + a host TAP fd (Firecracker), and a container can't spawn +siblings without a root-equivalent socket (Docker). So every backend +exposes a broker the orchestrator calls to launch an agent: + +- **Firecracker** — a thin, structured host shim (see security #3). This + replaces today's implicit "launcher runs as host user." +- **Docker** — the socket today (fat, root-equivalent — the thing 0069's + Stage 3 removes); a narrower broker later. +- **Apple** — the `container` CLI/daemon. + +**Broker request schema:** human-readable JSON of **static flags + ids +only** (never free-form paths/argv — that's what keeps it un-coercible +into arbitrary launches), wrapped as a **signed JWT** so the broker +verifies *provenance*: the request came from the real orchestrator, not a +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/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 +down: vend a `backend.network()` / `Wiring` collaborator rather than +piling methods on — the same discipline, recursed. + +### State: one SQLite DB, owned by the orchestrator + +The orchestrator is the natural owner of per-host **runtime state**: + +- pool **slot leases** (which bottle holds slot *i*) — replaces today's + `fcntl`-locked files with SQLite transactions; +- the **supervise approval queue** + remembered approvals; +- the **live bottle registry** (source IP → bottle → policy/secrets refs), + the lookup table the attribution invariant reads. + +This is deliberately **not** a "single source of truth for all config." +Config splits into three tiers with different homes: + +| Tier | Example | Home | +|---|---|---| +| Build-time constants | pool size, IP base, nft table | flat `.env` (PR #350) — must be readable by Nix eval + root bash, zero runtime | +| User-authored config | bottle manifests, egress routes, secret refs | declarative files under `~/.bot-bottle/` — trust boundary at `$HOME`, git-trackable, "unknown keys die at load" | +| Runtime state | slot leases, approvals, registry | one shared **`bot-bottle.db`**, solely owned by the orchestrator | + +SQLite is right for the runtime tier (mutable, concurrent, queried) and +wrong for the other two (Nix can't read it at eval time; it fights the +declarative manifest trust model). Keep the tiers separate. + +**One shared `bot-bottle.db` for all runtime state** (decided in review). +The registry co-tenants the existing host `bot-bottle.db` — the `DbStore` +framework already namespaces each store by `schema_key`, so slot +leases / approvals / registry share one file. One place to query, back up, +and integrate a console against. + +- **Host-resident, for durability.** Re-adoption sweeps the registry after + an orchestrator restart, so state *must* outlive the orchestrator + instance. The file lives on the host (`bot_bottle_root()/db/bot-bottle.db`); + the orchestrator unit reaches it, it doesn't carry it. +- **Integrity by sole ownership, not mount permissions.** Agents can't + touch the DB directly wherever it lives (network-isolated in their + bottles). The risk is a *compromised agent-facing data-plane service* + (egress/git-gate, which parse hostile bytes) writing the registry and + forging attribution. Because it's now one shared file, coarse `ro`/`rw` + mount-splitting no longer isolates the registry — so the rule is stronger + and simpler: **only the orchestrator (control plane) opens `bot-bottle.db`; + 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 sidecar + rw-bind-mounts `bot-bottle.db`** to write proposals — exactly the + pattern the orchestrator removes (supervise consolidates into the + 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 +(virtiofs/9p) is finicky (the `-shm`/`-wal` files need real mmap/locking), +which is a second reason the DB wants a **host-side owner** the orchestrator +reaches over the RPC rather than a shared mount into the VM. WAL on the +shared DB is therefore a deliberate, tested future change — not enabled ad +hoc. `sqlite3` itself is stdlib, so "the host needs SQLite" is a non-cost. + +## Sequencing + +Jump straight to the **virtualized** end state (not a host-daemon stepping +stone): a host daemon's agent→`localhost` transport is throwaway once the +orchestrator becomes a VM. Decouple the two risks instead: + +- **Consolidation risk** (one process, all secrets, attribution, reload) + and **packaging/transport risk** (VM-to-VM wiring, the shim) are + independent. Develop the orchestrator **service as a plain process + dev-harness** first, so the consolidation logic (attribution, reload, + secret handling) is proven with fast iteration — *then* wrap that exact + service in the VM and solve wiring separately. + +### Slices + +Built bottom-up as a stack of small PRs off this one (status: **1–5 +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). +9. **macOS (Apple container)** — last (container-to-container networking). + +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 sidecar + is consolidated; agents stay one-VM/container-each. + +## Relationship to other work + +- **PRD 0069 (#348):** 0070 subsumes its Stage 1 (per-host sidecar) and + Stage 4 (sidecar-as-VM). 0069 retains Stage 2 (nix-built fixed images — + a **dependency** here: the orchestrator and agent base must be + nix-built so the broker launches from a fixed image set and bootstrapping + has no chicken-and-egg) and Stage 3 (in-VM Dockerfile builder). +- **Minimal CI runner (paused):** the Firecracker broker + no host Docker + is what lets a dedicated `gitea` runner user drop the root-equivalent + `docker` group — it only needs broker-socket access + `kvm`/pool group + membership. This work unblocks it. +- **Generic `SecretProvider` (#355):** the future secret-handling + mechanism (see "Secret handling") — generalizes PRD 0048's + `DeployKeyProvisioner` into a user-extensible provider that mints + short-lived creds. Shippable independently; the orchestrator's vault + mints through it. +- **PR #350 (netpool single-source):** the same "one source per fact, + composition over parallel hierarchies" discipline the contract follows. + +## Decisions (review 2026-07-13) + +- **Egress sharing:** **consolidate egress** (worth it). Treat it as a + minimal, hardened attack surface for a malicious agent rather than + keeping it per-bottle; pair it with the identity token above and the + short-lived-vault-token mitigations (Secret handling / #355). +- **Control-plane transport:** **HTTP** — most universal/reliable on every + host; unix socket is an optional local optimization (see the contract). +- **Broker request schema:** **signed-JWT JSON, static flags + ids only** + (see the launch broker) — provenance + un-coercible by construction. +- **State re-adoption:** the restart procedure is: + 1. **Singleton:** a new orchestrator launch requires no pre-existing + orchestrator; any found (healthy or not) is fully shut down first. + 2. Re-adoption **waits for the new orchestrator to be healthy**. + 3. Once healthy, it discovers all agents needing adoption via **both the + SQLite registry and live process/VM inspection** *before serving any + other request*. The two-source sweep is what closes the *in-flight + launch* race — a launch that started (VM booting / slot claimed) but + hadn't committed to SQLite when the old orchestrator died would be + invisible to a SQLite-only sweep; process inspection catches it. + Launches should also write an **intent record ahead of committing + resources** so the sweep can reconcile intent vs. actual. + +## Open questions + +- **VM-to-VM routing:** per-backend, and in the design it *is* + `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 "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). +- **Identity-token delivery:** exactly how the per-bottle token is placed + where the agent can present it but not swap in another bottle's. diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index da5784b..be349dc 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -2,7 +2,7 @@ Isolates ``HOME`` to a throwaway directory for the entire unit suite so no test ever reads or writes the real ``~/.bot-bottle`` (state, queue, -and audit dirs all derive from ``supervise.bot_bottle_root()`` → +and audit dirs all derive from ``paths.bot_bottle_root()`` → ``Path.home()``). Without this, a test that takes a ``flock`` on the real audit log can **block indefinitely** when a live bottle's supervise sidecar holds that lock — observed as a hung ``coverage run`` at 0% CPU — @@ -10,8 +10,12 @@ and unisolated tests otherwise pollute the developer's home dir. Individual tests that need their own ``HOME`` still override ``os.environ['HOME']`` and restore it; they now restore to this isolated -dir rather than the real one, so isolation holds either way. Tests that -patch ``supervise.bot_bottle_root`` directly are unaffected. +dir rather than the real one, so isolation holds either way. + +Tests that need their own app-data root call ``use_bottle_root`` (below), +which sets ``BOT_BOTTLE_ROOT`` — the supported override read by +``paths.bot_bottle_root`` — instead of monkey-patching. One env setting +covers every module and every flat/package copy of the helper. """ from __future__ import annotations @@ -20,6 +24,9 @@ import atexit import os import shutil import tempfile +from collections.abc import Callable +from pathlib import Path +from unittest import mock _real_home = os.environ.get("HOME") _tmp_home = tempfile.mkdtemp(prefix="bot-bottle-unit-home.") @@ -35,3 +42,15 @@ def _restore_home() -> None: atexit.register(_restore_home) + + +def use_bottle_root(root: Path) -> Callable[[], None]: + """Redirect ``paths.bot_bottle_root()`` to ``root`` by setting + ``BOT_BOTTLE_ROOT`` — the supported override. Returns a callable that + undoes it (store it as the test's restore hook, or pass to addCleanup). + + Replaces monkey-patching ``paths.bot_bottle_root``; one env var covers + every module and the flat/package copies alike (they all read it).""" + patcher = mock.patch.dict(os.environ, {"BOT_BOTTLE_ROOT": str(root)}) + patcher.start() + return patcher.stop diff --git a/tests/unit/test_backend_freezer.py b/tests/unit/test_backend_freezer.py index 40241b3..3cb7d54 100644 --- a/tests/unit/test_backend_freezer.py +++ b/tests/unit/test_backend_freezer.py @@ -7,7 +7,8 @@ import unittest from pathlib import Path from unittest.mock import patch -from bot_bottle import supervise, bottle_state +from bot_bottle import bottle_state +from tests.unit import use_bottle_root from bot_bottle.backend import ActiveAgent from bot_bottle.backend.freeze import get_freezer from bot_bottle.backend.docker.freezer import DockerFreezer @@ -18,13 +19,7 @@ from bot_bottle.backend.firecracker.freezer import FirecrackerFreezer class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="freezer-test.") - original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - self._restore = lambda: setattr(supervise, "bot_bottle_root", original) + self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self): self._restore() diff --git a/tests/unit/test_backend_prepare.py b/tests/unit/test_backend_prepare.py index 6423ccd..6013229 100644 --- a/tests/unit/test_backend_prepare.py +++ b/tests/unit/test_backend_prepare.py @@ -13,7 +13,7 @@ from pathlib import Path from unittest.mock import patch from bot_bottle import bottle_state -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle.backend import BottleSpec from bot_bottle.backend.docker import DockerBottleBackend from bot_bottle.backend.resolve_common import mint_slug @@ -55,11 +55,10 @@ class _FakeStateMixin: def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory(prefix="backend-prepare.") self.root = Path(self.tmp.name) / ".bot-bottle" - self.original_root = supervise.bot_bottle_root - supervise.bot_bottle_root = lambda: self.root # type: ignore[assignment] + self._restore = use_bottle_root(self.root) def tearDown(self) -> None: - supervise.bot_bottle_root = self.original_root # type: ignore[assignment] + self._restore() self.tmp.cleanup() diff --git a/tests/unit/test_backend_workspace.py b/tests/unit/test_backend_workspace.py index f57fa02..a7463f3 100644 --- a/tests/unit/test_backend_workspace.py +++ b/tests/unit/test_backend_workspace.py @@ -13,7 +13,7 @@ from pathlib import Path from unittest.mock import MagicMock, call, patch from bot_bottle import bottle_state -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle.backend import Bottle, BottleSpec, ExecResult from bot_bottle.backend.docker import DockerBottleBackend from bot_bottle.backend.firecracker import FirecrackerBottleBackend @@ -55,11 +55,10 @@ class _FakeStateMixin: self.tmp_dir = tempfile.TemporaryDirectory(prefix="backend-workspace.") self.tmp = Path(self.tmp_dir.name) self.root = self.tmp / ".bot-bottle" - self.original_root = supervise.bot_bottle_root - supervise.bot_bottle_root = lambda: self.root # type: ignore[assignment] + self._restore = use_bottle_root(self.root) def tearDown(self) -> None: - supervise.bot_bottle_root = self.original_root # type: ignore[assignment] + self._restore() self.tmp_dir.cleanup() diff --git a/tests/unit/test_bottle_state.py b/tests/unit/test_bottle_state.py index c9abd92..df7a200 100644 --- a/tests/unit/test_bottle_state.py +++ b/tests/unit/test_bottle_state.py @@ -6,7 +6,7 @@ import tempfile import unittest from pathlib import Path -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle import bottle_state from bot_bottle.bottle_state import ( BottleMetadata, @@ -18,13 +18,7 @@ from bot_bottle.bottle_state import ( class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="bottle-state-test.") - original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - self._restore = lambda: setattr(supervise, "bot_bottle_root", original) + self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self): self._restore() diff --git a/tests/unit/test_cli_commit.py b/tests/unit/test_cli_commit.py index 2dd2708..6a7e7ff 100644 --- a/tests/unit/test_cli_commit.py +++ b/tests/unit/test_cli_commit.py @@ -8,7 +8,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch from bot_bottle.cli.commit import cmd_commit -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle import bottle_state from bot_bottle.backend.freeze import CommitCancelled @@ -16,13 +16,7 @@ from bot_bottle.backend.freeze import CommitCancelled class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="cli-commit-test.") - original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - self._restore = lambda: setattr(supervise, "bot_bottle_root", original) + self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self): self._restore() diff --git a/tests/unit/test_cli_start_settle.py b/tests/unit/test_cli_start_settle.py index 9a94ba5..4b1c405 100644 --- a/tests/unit/test_cli_start_settle.py +++ b/tests/unit/test_cli_start_settle.py @@ -8,7 +8,7 @@ import tempfile import unittest from pathlib import Path -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle import bottle_state from bot_bottle.cli import start as start_mod @@ -16,15 +16,10 @@ from bot_bottle.cli import start as start_mod class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="cli-start-settle.") - self._original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] + self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self): - supervise.bot_bottle_root = self._original # type: ignore[assignment] + self._restore() self._tmp.cleanup() diff --git a/tests/unit/test_docker_cleanup.py b/tests/unit/test_docker_cleanup.py index aeab400..730f20f 100644 --- a/tests/unit/test_docker_cleanup.py +++ b/tests/unit/test_docker_cleanup.py @@ -15,7 +15,7 @@ import tempfile import unittest from pathlib import Path -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle import bottle_state from bot_bottle.backend.docker.cleanup import _list_orphan_state_dirs @@ -23,13 +23,7 @@ from bot_bottle.backend.docker.cleanup import _list_orphan_state_dirs class _FakeHomeMixin: def _setup_fake_home(self) -> None: self._tmp = tempfile.TemporaryDirectory(prefix="docker-cleanup-test.") - original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - self._restore = lambda: setattr(supervise, "bot_bottle_root", original) + self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self) -> None: self._restore() diff --git a/tests/unit/test_docker_enumerate_active.py b/tests/unit/test_docker_enumerate_active.py index b6aca74..c774c28 100644 --- a/tests/unit/test_docker_enumerate_active.py +++ b/tests/unit/test_docker_enumerate_active.py @@ -23,7 +23,7 @@ import tempfile import unittest from pathlib import Path -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle import bottle_state from bot_bottle.backend.docker import enumerate as _enumerate @@ -75,13 +75,7 @@ class TestParseServicesByProject(unittest.TestCase): class _FakeHomeMixin: def _setup_fake_home(self) -> None: self._tmp = tempfile.TemporaryDirectory(prefix="enum-active.") - original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - self._restore_home = lambda: setattr(supervise, "bot_bottle_root", original) + self._restore_home = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self) -> None: self._restore_home() diff --git a/tests/unit/test_egress_apply.py b/tests/unit/test_egress_apply.py index de51c57..12d3b1a 100644 --- a/tests/unit/test_egress_apply.py +++ b/tests/unit/test_egress_apply.py @@ -8,7 +8,7 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import patch -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle.backend.egress_apply import EgressApplyError from bot_bottle.backend.docker.egress_apply import applicator @@ -67,14 +67,8 @@ class TestValidateRoutesContent(unittest.TestCase): class TestApplyRoutesChange(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory(prefix="egress-apply-test.") - original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - self.addCleanup(lambda: setattr(supervise, "bot_bottle_root", original)) self.addCleanup(self._tmp.cleanup) + self.addCleanup(use_bottle_root(Path(self._tmp.name) / ".bot-bottle")) def test_writes_live_routes_and_signals_reload(self): calls: list[list[str]] = [] diff --git a/tests/unit/test_orchestrator_control_plane.py b/tests/unit/test_orchestrator_control_plane.py new file mode 100644 index 0000000..8310df1 --- /dev/null +++ b/tests/unit/test_orchestrator_control_plane.py @@ -0,0 +1,150 @@ +"""Unit tests for the orchestrator HTTP control plane (PRD 0070). + +Mostly exercises the pure `dispatch()` (socket-free, like the supervise +server tests), plus one real-socket round-trip to prove the handler wiring. +""" + +from __future__ import annotations + +import json +import tempfile +import threading +import unittest +import urllib.request +from pathlib import Path + +from bot_bottle.orchestrator.control_plane import dispatch, make_server +from bot_bottle.orchestrator.registry import RegistryStore + + +def _body(obj: object) -> bytes: + return json.dumps(obj).encode() + + +class TestDispatch(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + 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.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.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.store, "POST", "/bottles", _body({})) + self.assertEqual(400, status) + + def test_register_rejects_bad_json(self) -> None: + status, _ = dispatch(self.store, "POST", "/bottles", b"{not json") + self.assertEqual(400, status) + + def test_list_redacts_identity_token(self) -> None: + 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) + self.assertEqual(1, len(bottles)) + first = bottles[0] + assert isinstance(first, dict) + self.assertNotIn("identity_token", first) + self.assertEqual("10.243.0.1", first["source_ip"]) + + def test_attribute_ok_and_forbidden(self) -> None: + _, reg = dispatch( + self.store, "POST", "/bottles", _body({"source_ip": "10.243.0.5"}) + ) + token = reg["identity_token"] + ok_status, ok = dispatch( + 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.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.store, "POST", "/attribute", _body({"source_ip": "10.243.0.5"}) + ) + self.assertEqual(400, status) + + def test_delete(self) -> None: + _, reg = dispatch( + self.store, "POST", "/bottles", _body({"source_ip": "10.243.0.1"}) + ) + bid = reg["bottle_id"] + status, payload = dispatch(self.store, "DELETE", f"/bottles/{bid}", b"") + self.assertEqual(200, status) + self.assertEqual(True, payload["deregistered"]) + self.assertEqual([], self.store.all()) + + def test_delete_missing_404(self) -> None: + status, _ = dispatch(self.store, "DELETE", "/bottles/ghost", b"") + self.assertEqual(404, status) + + def test_unknown_route_404(self) -> None: + status, _ = dispatch(self.store, "GET", "/nope", b"") + self.assertEqual(404, status) + + def test_trailing_slash_normalized(self) -> None: + status, _ = dispatch(self.store, "GET", "/health/", b"") + self.assertEqual(200, status) + + +class TestServerRoundTrip(unittest.TestCase): + def test_http_register_health_attribute(self) -> None: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + 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() + self.addCleanup(server.shutdown) + + host, port = server.server_address[0], server.server_address[1] + base = f"http://{host}:{port}" + + reg = json.load(urllib.request.urlopen( + urllib.request.Request( + f"{base}/bottles", data=_body({"source_ip": "10.243.0.7"}), + method="POST", headers={"Content-Type": "application/json"}, + ), timeout=5, + )) + self.assertTrue(reg["bottle_id"]) + + health = json.load(urllib.request.urlopen(f"{base}/health", timeout=5)) + self.assertEqual("ok", health["status"]) + + attr = json.load(urllib.request.urlopen( + urllib.request.Request( + f"{base}/attribute", + data=_body({"source_ip": "10.243.0.7", "identity_token": reg["identity_token"]}), + method="POST", headers={"Content-Type": "application/json"}, + ), timeout=5, + )) + self.assertEqual(reg["bottle_id"], attr["bottle_id"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_orchestrator_registry.py b/tests/unit/test_orchestrator_registry.py new file mode 100644 index 0000000..6962cb1 --- /dev/null +++ b/tests/unit/test_orchestrator_registry.py @@ -0,0 +1,102 @@ +"""Unit tests for the orchestrator bottle registry + attribution (PRD 0070).""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from bot_bottle.orchestrator.registry import ( + BottleRecord, + RegistryStore, + new_identity_token, +) + + +class TestRegistryStore(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.db = Path(self._tmp.name) / "registry.db" + self.store = RegistryStore(self.db) + self.store.migrate() + + def tearDown(self) -> None: + self._tmp.cleanup() + + def test_register_mints_id_and_token(self) -> None: + rec = self.store.register("10.243.0.1") + self.assertTrue(rec.bottle_id) + self.assertTrue(rec.identity_token) + self.assertEqual("active", rec.state) + self.assertEqual(rec, self.store.get(rec.bottle_id)) + + def test_identity_tokens_are_unique(self) -> None: + tokens = {new_identity_token() for _ in range(200)} + self.assertEqual(200, len(tokens)) + a = self.store.register("10.243.0.1") + b = self.store.register("10.243.0.3") + self.assertNotEqual(a.identity_token, b.identity_token) + + def test_all_and_deregister(self) -> None: + a = self.store.register("10.243.0.1") + b = self.store.register("10.243.0.3") + self.assertEqual({a.bottle_id, b.bottle_id}, {r.bottle_id for r in self.store.all()}) + self.assertTrue(self.store.deregister(a.bottle_id)) + self.assertEqual([b.bottle_id], [r.bottle_id for r in self.store.all()]) + + def test_deregister_missing_is_false(self) -> None: + self.assertFalse(self.store.deregister("nope")) + + def test_explicit_id_replaces(self) -> None: + self.store.register("10.243.0.1", bottle_id="fixed", metadata="a") + self.store.register("10.243.0.9", bottle_id="fixed", metadata="b") + rec = self.store.get("fixed") + assert rec is not None + self.assertEqual("10.243.0.9", rec.source_ip) + self.assertEqual("b", rec.metadata) + self.assertEqual(1, len(self.store.all())) + + def test_attribute_success_requires_ip_and_token(self) -> None: + rec = self.store.register("10.243.0.1") + got = self.store.attribute("10.243.0.1", rec.identity_token) + assert got is not None + self.assertEqual(rec.bottle_id, got.bottle_id) + + def test_attribute_wrong_token_denied(self) -> None: + self.store.register("10.243.0.1") + self.assertIsNone(self.store.attribute("10.243.0.1", "wrong-token")) + + def test_attribute_unknown_ip_denied(self) -> None: + rec = self.store.register("10.243.0.1") + self.assertIsNone(self.store.attribute("10.243.9.9", rec.identity_token)) + + def test_attribute_empty_token_denied(self) -> None: + self.store.register("10.243.0.1") + self.assertIsNone(self.store.attribute("10.243.0.1", "")) + + def test_attribute_ambiguous_ip_denied(self) -> None: + # Two active bottles on one source IP is a misconfiguration — deny + # rather than guess (fail-closed), even with a valid token. + a = self.store.register("10.243.0.1", bottle_id="a") + self.store.register("10.243.0.1", bottle_id="b") + self.assertIsNone(self.store.attribute("10.243.0.1", a.identity_token)) + + def test_state_persists_across_reopen(self) -> None: + rec = self.store.register("10.243.0.1") + reopened = RegistryStore(self.db) + got = reopened.get(rec.bottle_id) + assert got is not None + self.assertEqual(rec, got) + # Attribution works against the reopened (durable) store too. + self.assertIsNotNone(reopened.attribute("10.243.0.1", rec.identity_token)) + + def test_redacted_hides_token(self) -> None: + rec = BottleRecord( + bottle_id="x", source_ip="10.243.0.1", identity_token="secret" + ) + self.assertNotIn("identity_token", rec.redacted()) + self.assertEqual("10.243.0.1", rec.redacted()["source_ip"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_supervise.py b/tests/unit/test_supervise.py index bc69b89..6381af0 100644 --- a/tests/unit/test_supervise.py +++ b/tests/unit/test_supervise.py @@ -8,7 +8,8 @@ from datetime import datetime, timezone from pathlib import Path from bot_bottle import supervise -from bot_bottle import supervise_types as sv_types +from bot_bottle.paths import host_db_path +from tests.unit import use_bottle_root from bot_bottle.audit_store import AuditStore from bot_bottle.queue_store import QueueStore from bot_bottle.supervise import ( @@ -21,7 +22,6 @@ from bot_bottle.supervise import ( TOOL_EGRESS_ALLOW, TOOL_GITLEAKS_ALLOW, archive_proposal, - host_db_path, list_pending_proposals, read_audit_entries, read_proposal, @@ -123,20 +123,7 @@ class TestQueueIO(unittest.TestCase): self._tmp.cleanup() def _patch_home(self, fake_home: Path): - original_sv = supervise.bot_bottle_root - original_svt = sv_types.bot_bottle_root - - def fake_root() -> Path: - return fake_home / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - sv_types.bot_bottle_root = fake_root # type: ignore[assignment] - - def restore() -> None: - supervise.bot_bottle_root = original_sv # type: ignore[assignment] - sv_types.bot_bottle_root = original_svt # type: ignore[assignment] - - return restore + return use_bottle_root(fake_home / ".bot-bottle") def test_write_and_read_proposal(self): p = _proposal() @@ -240,20 +227,7 @@ class TestAuditLog(unittest.TestCase): self._tmp.cleanup() def _patch_home(self, fake_home: Path): - original_sv = supervise.bot_bottle_root - original_svt = sv_types.bot_bottle_root - - def fake_root() -> Path: - return fake_home / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - sv_types.bot_bottle_root = fake_root # type: ignore[assignment] - - def restore() -> None: - supervise.bot_bottle_root = original_sv # type: ignore[assignment] - sv_types.bot_bottle_root = original_svt # type: ignore[assignment] - - return restore + return use_bottle_root(fake_home / ".bot-bottle") def test_write_then_read_single_entry(self): e = AuditEntry( @@ -400,20 +374,7 @@ class TestSupervisePrepare(unittest.TestCase): self._tmp.cleanup() def _patch_home(self, fake_home: Path): - original_sv = supervise.bot_bottle_root - original_svt = sv_types.bot_bottle_root - - def fake_root() -> Path: - return fake_home / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - sv_types.bot_bottle_root = fake_root # type: ignore[assignment] - - def restore() -> None: - supervise.bot_bottle_root = original_sv # type: ignore[assignment] - sv_types.bot_bottle_root = original_svt # type: ignore[assignment] - - return restore + return use_bottle_root(fake_home / ".bot-bottle") def test_prepare_creates_queue(self): plan = _StubSupervise().prepare("dev", self.stage_dir) diff --git a/tests/unit/test_supervise_cli.py b/tests/unit/test_supervise_cli.py index 9903da3..9c680c9 100644 --- a/tests/unit/test_supervise_cli.py +++ b/tests/unit/test_supervise_cli.py @@ -12,7 +12,7 @@ from pathlib import Path from unittest.mock import patch from bot_bottle import supervise -from bot_bottle import supervise_types as sv_types +from tests.unit import use_bottle_root from bot_bottle.audit_store import AuditStore from bot_bottle.cli import supervise as supervise_cli from bot_bottle.queue_store import QueueStore @@ -51,24 +51,11 @@ def _proposal(slug: str = "dev", tool: str = TOOL_EGRESS_ALLOW) -> Proposal: class _FakeHomeMixin: - """Patch supervise.bot_bottle_root to a temp dir for the test.""" + """Point bot_bottle_root at a temp dir (via BOT_BOTTLE_ROOT) for the test.""" def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="supervise-test.") - original_sv = supervise.bot_bottle_root - original_svt = sv_types.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - sv_types.bot_bottle_root = fake_root # type: ignore[assignment] - - def restore() -> None: - supervise.bot_bottle_root = original_sv # type: ignore[assignment] - sv_types.bot_bottle_root = original_svt # type: ignore[assignment] - - self._restore_home = restore + self._restore_home = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") QueueStore("").migrate() AuditStore().migrate() diff --git a/tests/unit/test_supervise_cli_crash_logging.py b/tests/unit/test_supervise_cli_crash_logging.py index 56cb6f0..05c5463 100644 --- a/tests/unit/test_supervise_cli_crash_logging.py +++ b/tests/unit/test_supervise_cli_crash_logging.py @@ -11,12 +11,13 @@ from __future__ import annotations import contextlib import io +import os import tempfile import unittest from pathlib import Path from unittest import mock -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle.cli import supervise as supervise_cli from bot_bottle.log import Die, die @@ -39,18 +40,16 @@ class TestDieCarriesMessage(unittest.TestCase): class _FakeHomeMixin: - """Point supervise.bot_bottle_root (what _write_crash_log resolves - through) at a temp dir so the crash log doesn't touch the real - ~/.bot-bottle.""" + """Point bot_bottle_root (what _write_crash_log resolves through) at a + temp dir so the crash log doesn't touch the real ~/.bot-bottle.""" def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="supervise-crash-test.") - self._orig_root = supervise.bot_bottle_root self._root = Path(self._tmp.name) / ".bot-bottle" - supervise.bot_bottle_root = lambda: self._root # type: ignore[assignment] + self._restore_root = use_bottle_root(self._root) def _teardown_fake_home(self): - supervise.bot_bottle_root = self._orig_root # type: ignore[assignment] + self._restore_root() self._tmp.cleanup() @@ -127,11 +126,11 @@ class TestWriteCrashLog(_FakeHomeMixin, unittest.TestCase): # OSError and the helper must fall back to a tempfile. bad = Path(self._tmp.name) / "not-a-dir" bad.write_text("x") - supervise.bot_bottle_root = lambda: bad # type: ignore[assignment] - try: - raise RuntimeError("explode2") - except RuntimeError as e: - path = supervise_cli._write_crash_log(e) + with mock.patch.dict(os.environ, {"BOT_BOTTLE_ROOT": str(bad)}): + try: + raise RuntimeError("explode2") + except RuntimeError as e: + path = supervise_cli._write_crash_log(e) self.assertTrue(path.exists()) self.assertIn("explode2", path.read_text()) diff --git a/tests/unit/test_supervise_edge.py b/tests/unit/test_supervise_edge.py index 3cde845..4cedbfa 100644 --- a/tests/unit/test_supervise_edge.py +++ b/tests/unit/test_supervise_edge.py @@ -12,6 +12,7 @@ from unittest.mock import patch from bot_bottle import supervise from bot_bottle.audit_store import AuditStore +from bot_bottle.paths import bot_bottle_root from bot_bottle.queue_store import QueueStore from bot_bottle.supervise import ( AuditEntry, @@ -39,7 +40,7 @@ def _proposal() -> Proposal: class TestPathHelpers(unittest.TestCase): def test_bot_bottle_root(self) -> None: - self.assertTrue(str(supervise.bot_bottle_root()).endswith(".bot-bottle")) + self.assertTrue(str(bot_bottle_root()).endswith(".bot-bottle")) class TestReadMalformed(unittest.TestCase): diff --git a/tests/unit/test_supervise_server.py b/tests/unit/test_supervise_server.py index 9ea47e5..a29f032 100644 --- a/tests/unit/test_supervise_server.py +++ b/tests/unit/test_supervise_server.py @@ -10,6 +10,8 @@ import unittest from pathlib import Path from unittest.mock import patch +from tests.unit import use_bottle_root + # The server module loads `supervise` via same-directory import inside # the container (Dockerfile.supervise WORKDIRs into /app). For tests @@ -19,10 +21,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "bot_bott import supervise as _sv # noqa: E402 # type: ignore import queue_store as _qs # noqa: E402 # type: ignore import audit_store as _as # noqa: E402 # type: ignore -import supervise_types as svt_flat # noqa: E402 # type: ignore from bot_bottle import supervise_server # noqa: E402 -from bot_bottle import supervise_types as svt_pkg # noqa: E402 from bot_bottle.supervise_server import ( ERR_INTERNAL, ERR_INVALID_PARAMS, @@ -279,23 +279,7 @@ class TestHandleToolsCall(unittest.TestCase): self._tmp.cleanup() def _patch_home(self, fake_home: Path): - original_sv = _sv.bot_bottle_root - original_flat = svt_flat.bot_bottle_root - original_pkg = svt_pkg.bot_bottle_root - - def fake_root() -> Path: - return fake_home / ".bot-bottle" - - _sv.bot_bottle_root = fake_root # type: ignore[assignment] - svt_flat.bot_bottle_root = fake_root # type: ignore[assignment] - svt_pkg.bot_bottle_root = fake_root # type: ignore[assignment] - - def restore() -> None: - _sv.bot_bottle_root = original_sv # type: ignore[assignment] - svt_flat.bot_bottle_root = original_flat # type: ignore[assignment] - svt_pkg.bot_bottle_root = original_pkg # type: ignore[assignment] - - return restore + return use_bottle_root(fake_home / ".bot-bottle") def _respond_when_proposal_appears(self, status: str, notes: str = "") -> threading.Thread: """Background thread: poll the queue for a fresh proposal, write a