docs(prd): 0070 per-host orchestrator service #352
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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/<identity>/...
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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())
|
||||
@@ -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": [ <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.
|
||||
|
||||
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"]
|
||||
@@ -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
|
||||
|
didericis
commented
this should be moved to db_store this should be moved to db_store
|
||||
|
||||
# 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)
|
||||
|
didericis
commented
@didericis-claude Think we should use the same “bot-bottle.db” for all bot-bottle state, including the registry. Makes querying and future console related integration easier. Thoughts? @didericis-claude Think we should use the same “bot-bottle.db” for all bot-bottle state, including the registry. Makes querying and future console related integration easier. Thoughts?
|
||||
|
||||
|
||||
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",
|
||||
]
|
||||
@@ -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, `<root>/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"]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
didericis marked this conversation as resolved
didericis
commented
Given all the different ways to do ip spoofing, would it also be worth adding an outbound proxy to the agent that injects a token to every outbound request to the sidecar so an agent can prove who it is? I think we’ve already added that proxy/should be trivial to add a token, but let me know if that didn’t land Given all the different ways to do ip spoofing, would it also be worth adding an outbound proxy to the agent that injects a token to every outbound request to the sidecar so an agent can prove who it is? I think we’ve already added that proxy/should be trivial to add a token, but let me know if that didn’t land
|
||||
|
||||
- **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/<name>/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
|
||||
|
didericis marked this conversation as resolved
Outdated
didericis
commented
It’s worth it, should be a minimal surface for a malicious agent to exploit/we should be able to harden it and keep it secure. Also spec-ed out more mitigations via short lived vault tokens (link the relevant issue in reply to this comment) It’s worth it, should be a minimal surface for a malicious agent to exploit/we should be able to harden it and keep it secure.
Also spec-ed out more mitigations via short lived vault tokens (link the relevant issue in reply to this comment)
|
||||
orchestrator; sidecar writes become RPC calls). Until that lands, don't
|
||||
put the attribution registry behind a data-plane-writable mount.
|
||||
|
didericis marked this conversation as resolved
Outdated
didericis
commented
Whatever is most universal/reliable on every host machine (probably http?) Whatever is most universal/reliable on every host machine (probably http?)
|
||||
|
||||
Implementation note for the VM slices: SQLite **WAL** over a guest share
|
||||
(virtiofs/9p) is finicky (the `-shm`/`-wal` files need real mmap/locking),
|
||||
|
didericis marked this conversation as resolved
Outdated
didericis
commented
Can you elaborate racing in flight launches? You mean possible parallel orchestrator relaunches on the same host, or an or orchestrator possibly restarting an agent before readopting it? Regardless (unless I’m missing something), I think the procedure should be fairly straightforward:
Can you elaborate racing in flight launches? You mean possible parallel orchestrator relaunches on the same host, or an or orchestrator possibly restarting an agent before readopting it?
Regardless (unless I’m missing something), I think the procedure should be fairly straightforward:
1) every orchestrator launch requires there to be no preexisting, healthy orchestrator launch. If a healthy or non healthy orchestrator is found, it must be fully removed/shut down first
2) readoption requires waiting for the new orchestrator to be healthy
3) once the new orchestrator is healthy, the orchestrator must search to find all agents needing adoption (via both sqlite state and process inspection) before handling any other requests
|
||||
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
|
||||
|
didericis marked this conversation as resolved
Outdated
didericis
commented
Guessing this will have to vary per backend, but think we’ve solved this already? Had spikes related to putting sidecars in vms where I think we figured out how to do this Guessing this will have to vary per backend, but think we’ve solved this already? Had spikes related to putting sidecars in vms where I think we figured out how to do this
|
||||
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.
|
||||
|
didericis marked this conversation as resolved
Outdated
didericis
commented
human readable json with static flags and ids makes sense for all communication with the side, I think. Might be missing cases where that doesn’t work, but we’ll tackle them as we go/make that the ideal. Would also be worth making it a signed jwt for provenance human readable json with static flags and ids makes sense for all communication with the side, I think. Might be missing cases where that doesn’t work, but we’ll tackle them as we go/make that the ideal. Would also be worth making it a signed jwt for provenance
|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]] = []
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Debating whether orchestrator should own db entirely so the host doesn’t need sqlite… am thinking this is probably better, as that potentially opens up agents to mutate the db, and sqlite is universal enough that keeping it on the host is fine. Keeping it on the host also means it survives orchestrator reboots.