Compare commits

..

2 Commits

Author SHA1 Message Date
didericis adc03e0e26 docs(prd): 0070 secret-handling as a future pattern (SecretProvider, #355)
Add a "Secret handling — FUTURE pattern (not v1)" subsection: vault as a
separate trust domain holding long-lived roots, deriving short-lived
scoped creds where the upstream allows (with the honest limit that a
compromised proxy can still abuse currently-authorized access). The
mechanism is a generic, user-extensible SecretProvider that generalizes
PRD 0048's DeployKeyProvisioner and drops into the manifest wherever a raw
token is accepted — discovered from ~/.bot-bottle/contrib like user
AgentProviders. Marked explicitly as not required for the initial
orchestrator; tracked as #355.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 12:10:45 -04:00
didericis 9325745ad4 docs(prd): 0070 per-host orchestrator service
Fold the per-bottle sidecar bundle into a single persistent per-host
orchestrator: runs the sidecar functions (egress/git-gate/supervise),
coordinates with the console, and brokers agent launches. Virtualized
from the start with backend-native isolation (fc VM / apple ctr / docker
ctr), fronted by a single backend-agnostic contract; per-backend
variation lives on BottleBackend, not a parallel Orchestrator hierarchy.

Leads with the security review (secret concentration, shared fate, the
launch-broker-as-new-privileged-core, and the source-IP attribution
invariant each backend must enforce). Proposes one SQLite DB owned by the
orchestrator for runtime state (slot leases, approvals, registry) —
distinct from build-time constants (flat .env) and user config
(declarative ~/.bot-bottle). Sequences docker -> firecracker -> macos,
developing the service as a plain-process dev-harness before the VM.

Supersedes 0069's Stage-1/4 sidecar framing; depends on 0069's nix-built
fixed images. Tracking issue #351.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-12 17:42:56 -04:00
50 changed files with 378 additions and 2340 deletions
-1
View File
@@ -66,7 +66,6 @@ COPY bot_bottle/egress_dlp_config.py /app/egress_dlp_config.py
COPY bot_bottle/egress_addon.py /app/egress_addon.py COPY bot_bottle/egress_addon.py /app/egress_addon.py
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
COPY bot_bottle/paths.py /app/paths.py
COPY bot_bottle/migrations.py /app/migrations.py COPY bot_bottle/migrations.py /app/migrations.py
COPY bot_bottle/db_store.py /app/db_store.py COPY bot_bottle/db_store.py /app/db_store.py
COPY bot_bottle/supervise_types.py /app/supervise_types.py COPY bot_bottle/supervise_types.py /app/supervise_types.py
+1 -1
View File
@@ -5,7 +5,7 @@
# bot-bottle # bot-bottle
[![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml) [![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
[![coverage](https://img.shields.io/badge/coverage-82%25-brightgreen)](https://coverage.readthedocs.io/) [![coverage](https://img.shields.io/badge/coverage-84%25-brightgreen)](https://coverage.readthedocs.io/)
[![core coverage](https://img.shields.io/badge/core%20coverage-95%25-brightgreen)](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md) [![core coverage](https://img.shields.io/badge/core%20coverage-95%25-brightgreen)](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data. **Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
+2 -4
View File
@@ -6,13 +6,11 @@ import sqlite3
from pathlib import Path from pathlib import Path
try: try:
from .supervise_types import AuditEntry from .supervise_types import AuditEntry, host_db_path
from .paths import host_db_path
from .db_store import DbStore from .db_store import DbStore
from .migrations import TableMigrations from .migrations import TableMigrations
except ImportError: except ImportError:
from supervise_types import AuditEntry # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from supervise_types import AuditEntry, 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
from db_store import DbStore # 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 from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
+2 -2
View File
@@ -26,7 +26,7 @@ from __future__ import annotations
import shutil import shutil
import subprocess import subprocess
from ...paths import bot_bottle_root from ... import supervise as _supervise
from ...log import info, warn from ...log import info, warn
from . import util as docker_mod from . import util as docker_mod
from .bottle_cleanup_plan import DockerBottleCleanupPlan 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 ANY backend — used so this docker-side check doesn't reap a
running non-docker bottle's state dir (the layout is shared running non-docker bottle's state dir (the layout is shared
across backends).""" across backends)."""
state_root = bot_bottle_root() / "state" state_root = _supervise.bot_bottle_root() / "state"
if not state_root.is_dir(): if not state_root.is_dir():
return [] return []
orphans: list[str] = [] orphans: list[str] = []
@@ -1,17 +0,0 @@
# Firecracker network-pool defaults — the SINGLE source of these values.
#
# Read by every consumer so they can't drift:
# * netpool.py — parses this for the Python defaults (below).
# * scripts/firecracker-netpool.sh — falls back to these when the
# matching BOT_BOTTLE_FC_* env var is unset.
# * nix/firecracker-netpool.nix — readFile-parses this for its option
# defaults, then passes the resolved values back as Environment=.
#
# Plain KEY=VALUE (no quoting, no inline comments, no spaces around `=`)
# so it is bash-sourceable, systemd EnvironmentFile-compatible, and
# trivially parseable from Python and Nix. A real BOT_BOTTLE_FC_* env
# var of the same name always overrides the value here.
BOT_BOTTLE_FC_POOL_SIZE=8
BOT_BOTTLE_FC_IP_BASE=10.243.0.0
BOT_BOTTLE_FC_IFACE_PREFIX=bbfc
BOT_BOTTLE_FC_NFT_TABLE=bot_bottle_fc
+9 -40
View File
@@ -4,13 +4,11 @@ config renderers (shell command + NixOS module) shown to operators.
The Firecracker backend needs a privileged one-time network setup: The Firecracker backend needs a privileged one-time network setup:
a pool of point-to-point TAP devices (owned by the invoking user, so a pool of point-to-point TAP devices (owned by the invoking user, so
`./cli.py start` never needs root) and a dedicated nftables table that `./cli.py start` never needs root) and a dedicated nftables table that
isolates every VM. The pool parameters live in exactly one place — isolates every VM. This module is the single source of truth for the
`netpool.defaults.env`, a plain KEY=VALUE file next to this module — pool parameters — the shell script (`scripts/firecracker-netpool.sh`),
and every consumer reads *that*: this module (below), the shell script the NixOS module (`nix/firecracker-netpool.nix`), and the backend's
(`scripts/firecracker-netpool.sh`), and the NixOS module fail-closed preflight all derive from these constants so they can't
(`nix/firecracker-netpool.nix`). A `BOT_BOTTLE_FC_*` env var of the drift.
same name always overrides the file, and the backend's fail-closed
preflight derives from these accessors, so nothing can drift.
Topology (per slot i): Topology (per slot i):
* TAP ``bbfc{i}`` — no shared bridge, so no docker0 / virbr0 / cni0 * TAP ``bbfc{i}`` — no shared bridge, so no docker0 / virbr0 / cni0
@@ -45,47 +43,18 @@ from typing import IO
from ...log import die from ...log import die
# The pool defaults live in one shared file (see module docstring); the
# shell script and NixOS module read the same file, so the values can't
# drift. This is a packaged data file — a missing/broken install is a
# hard error, surfaced here rather than as confusing empty defaults.
DEFAULTS_FILE = Path(__file__).with_name("netpool.defaults.env")
def _load_defaults() -> dict[str, str]:
out: dict[str, str] = {}
for raw in DEFAULTS_FILE.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
out[key.strip()] = value.strip()
return out
_DEFAULTS = _load_defaults()
def _cfg(key: str) -> str:
"""A `BOT_BOTTLE_FC_*` env var overrides the shared-file default."""
try:
return os.environ.get(key) or _DEFAULTS[key]
except KeyError:
die(f"{key} is missing from {DEFAULTS_FILE.name} (broken install)")
# Interface names are capped at 15 chars (IFNAMSIZ-1); "bbfc" + a small # Interface names are capped at 15 chars (IFNAMSIZ-1); "bbfc" + a small
# index stays well under that and is distinctive enough to grep for. # index stays well under that and is distinctive enough to grep for.
IFACE_PREFIX = _cfg("BOT_BOTTLE_FC_IFACE_PREFIX") IFACE_PREFIX = os.environ.get("BOT_BOTTLE_FC_IFACE_PREFIX", "bbfc")
NFT_TABLE = _cfg("BOT_BOTTLE_FC_NFT_TABLE") NFT_TABLE = "bot_bottle_fc"
def pool_size() -> int: def pool_size() -> int:
return int(_cfg("BOT_BOTTLE_FC_POOL_SIZE")) return int(os.environ.get("BOT_BOTTLE_FC_POOL_SIZE", "8"))
def ip_base() -> str: def ip_base() -> str:
return _cfg("BOT_BOTTLE_FC_IP_BASE") return os.environ.get("BOT_BOTTLE_FC_IP_BASE", "10.243.0.0")
# Sidecar ports the VM reaches at its host-side TAP IP. Kept in sync # Sidecar ports the VM reaches at its host-side TAP IP. Kept in sync
+2 -2
View File
@@ -36,7 +36,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import cast from typing import cast
from .paths import bot_bottle_root from . import supervise as _supervise
# Directory layout: ~/.bot-bottle/state/<identity>/... # Directory layout: ~/.bot-bottle/state/<identity>/...
@@ -163,7 +163,7 @@ def read_metadata(identity: str) -> BottleMetadata | None:
def bottle_state_dir(identity: str) -> Path: def bottle_state_dir(identity: str) -> Path:
"""Per-bottle state directory on the host. Created lazily by the """Per-bottle state directory on the host. Created lazily by the
write helpers; readers tolerate its absence.""" write helpers; readers tolerate its absence."""
return bot_bottle_root() / _STATE_SUBDIR / identity return _supervise.bot_bottle_root() / _STATE_SUBDIR / identity
def per_bottle_dockerfile_path(identity: str) -> Path: def per_bottle_dockerfile_path(identity: str) -> Path:
+2 -2
View File
@@ -19,7 +19,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from ..paths import bot_bottle_root from .. import supervise as _supervise
from ..bottle_state import read_metadata from ..bottle_state import read_metadata
from ..backend.docker.egress_apply import ( from ..backend.docker.egress_apply import (
EgressApplyError, EgressApplyError,
@@ -276,7 +276,7 @@ def _write_crash_log(exc: BaseException) -> Path:
) )
entry = f"=== supervise crash {stamp} ===\n{body}\n" entry = f"=== supervise crash {stamp} ===\n{body}\n"
try: try:
log_dir = bot_bottle_root() / "logs" log_dir = _supervise.bot_bottle_root() / "logs"
log_dir.mkdir(parents=True, exist_ok=True) log_dir.mkdir(parents=True, exist_ok=True)
path = log_dir / "supervise-crash.log" path = log_dir / "supervise-crash.log"
with path.open("a", encoding="utf-8") as fh: with path.open("a", encoding="utf-8") as fh:
-27
View File
@@ -1,27 +0,0 @@
"""Lean, framework-free `docker` subprocess primitive.
Deliberately a top-level module with a single stdlib import so it can be
reused from anywhere without cost. It is intentionally *not* placed in
`backend.docker.util`: importing that module runs `backend/__init__.py`,
which eagerly loads all three bottle backends (docker + firecracker +
macos) plus the manifest/egress/git-gate/supervise framework ~76 modules
which would drag the whole backend layer into the deliberately-lean
orchestrator. This primitive stays free of that so both the orchestrator's
docker components and (in time) `backend.docker.util` can share it."""
from __future__ import annotations
import subprocess
def run_docker(argv: list[str]) -> subprocess.CompletedProcess[str]:
"""Run a `docker` command, capturing stdout/stderr as text. Never raises
on a non-zero exit callers inspect `returncode` / `stderr` so they can
stay fail-closed or tolerate idempotent no-ops (e.g. removing an
already-absent container)."""
return subprocess.run(
argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False,
)
__all__ = ["run_docker"]
-64
View File
@@ -1,64 +0,0 @@
"""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).
* `broker` the signed, structured launch-request contract + a
`LaunchBroker` (stub for the harness) that verifies
provenance before acting.
* `sidecar` the consolidated per-host sidecar: a `Sidecar`
lifecycle contract (idempotent singleton) + a
`DockerSidecar` impl. One sidecar shared by all
bottles instead of one per bottle.
* `service` the `Orchestrator`: owns the registry, brokers the
launch lifecycle (launch/teardown), manages the
shared sidecar, attributes.
* `control_plane` the HTTP control-plane RPC (launch / teardown /
list / attribute / sidecar / health).
The actual backend-native launch (a real docker/firecracker broker) and
the egress/git/supervise data plane land in later slices once this core is
proven (see the PRD sequencing: plain-process dev-harness -> docker
orchestrator -> firecracker).
"""
from __future__ import annotations
from .registry import BottleRecord, RegistryStore, new_identity_token
from .broker import (
BrokerAuthError,
LaunchBroker,
LaunchRequest,
StubBroker,
sign_request,
verify_request,
)
from .docker_broker import DockerBroker, DockerBrokerError
from .sidecar import DockerSidecar, Sidecar, SidecarError
from .service import Orchestrator
from .control_plane import ControlPlaneServer, dispatch, make_server
__all__ = [
"BottleRecord",
"RegistryStore",
"new_identity_token",
"BrokerAuthError",
"LaunchBroker",
"LaunchRequest",
"StubBroker",
"DockerBroker",
"DockerBrokerError",
"Sidecar",
"DockerSidecar",
"SidecarError",
"sign_request",
"verify_request",
"Orchestrator",
"ControlPlaneServer",
"dispatch",
"make_server",
]
-79
View File
@@ -1,79 +0,0 @@
"""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
import secrets
from pathlib import Path
from .. import log
from .broker import LaunchBroker, StubBroker
from .control_plane import make_server
from .docker_broker import DockerBroker
from .registry import RegistryStore, default_db_path
from .service import Orchestrator
from .sidecar import DockerSidecar, Sidecar
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()})",
)
parser.add_argument(
"--broker", choices=("stub", "docker"), default="stub",
help="launch broker: 'stub' records requests; 'docker' runs containers",
)
parser.add_argument(
"--sidecar", action="store_true",
help="run one consolidated per-host sidecar bundle (build-if-missing)",
)
args = parser.parse_args(argv)
registry = RegistryStore(args.db)
registry.migrate()
# An ephemeral signing secret ties the orchestrator (signer) to its
# broker (verifier). 'stub' records launches instead of starting
# anything; 'docker' runs real containers (firecracker drops in later).
secret = secrets.token_bytes(32)
broker: LaunchBroker = DockerBroker(secret) if args.broker == "docker" else StubBroker(secret)
sidecar: Sidecar | None = DockerSidecar() if args.sidecar else None
orchestrator = Orchestrator(registry, broker, secret, sidecar)
# One persistent per-host sidecar, shared by every bottle: build the
# bundle image if missing, then bring the singleton up (idempotent).
if sidecar is not None:
orchestrator.ensure_sidecar()
log.info("consolidated sidecar ensured", context={"name": sidecar.name})
server = make_server(orchestrator, host=args.host, port=args.port)
bound_host, bound_port = server.server_address[0], server.server_address[1]
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())
-176
View File
@@ -1,176 +0,0 @@
"""Launch broker contract (PRD 0070).
A VM/container can't spawn its own host-network siblings, so the
orchestrator brokers agent launches through a small privileged component.
The request it sends is:
* **structured** static flags + ids only (bottle id, pool slot, a
content-addressed image ref), never a free-form path or argv, so a
request can't be coerced into launching an arbitrary payload; and
* **signed** wrapped as a compact JWS/JWT the broker verifies before
acting, so a *compromised co-located component* (an agent-facing
sidecar, say) can't forge a launch. This is the concrete form of the
"structured requests only" rule in the PRD security review.
Signing is **HS256** over a secret shared by the orchestrator (signer) and
the broker (verifier) appropriate here because both are trusted host
components provisioned together; the secret is exactly what an
agent-facing component does not have. (Stdlib only the project takes no
runtime deps; a cross-host deployment could swap in an asymmetric alg.)
"""
from __future__ import annotations
import abc
import base64
import hashlib
import hmac
import json
import secrets
import time
from dataclasses import dataclass
_JWT_HEADER = {"alg": "HS256", "typ": "JWT"}
_ALLOWED_OPS = ("launch", "teardown")
class BrokerAuthError(Exception):
"""A broker request failed provenance or schema verification —
bad/absent signature, malformed token, or a payload that doesn't match
the fixed launch-request shape. Fail-closed: the broker must not act."""
@dataclass(frozen=True)
class LaunchRequest:
"""The structured, un-coercible launch/teardown request. Ids + flags
only `image_ref` is a content-addressed id, never a path/argv."""
op: str # one of _ALLOWED_OPS
bottle_id: str
source_ip: str = ""
image_ref: str = ""
slot: int | None = None
# --- minimal JWS/JWT (HS256), stdlib only ----------------------------------
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64url_decode(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def _mac(signing_input: str, secret: bytes) -> str:
digest = hmac.new(secret, signing_input.encode("ascii"), hashlib.sha256).digest()
return _b64url(digest)
def sign_request(req: LaunchRequest, secret: bytes) -> str:
"""Serialize `req` to signed-JWT form. Adds a per-signature `jti`
(replay id) and `iat` (issued-at)."""
claims: dict[str, object] = {
"op": req.op,
"bottle_id": req.bottle_id,
"source_ip": req.source_ip,
"image_ref": req.image_ref,
"slot": req.slot,
"jti": secrets.token_hex(8),
"iat": int(time.time()),
}
header = _b64url(json.dumps(_JWT_HEADER, sort_keys=True, separators=(",", ":")).encode())
payload = _b64url(json.dumps(claims, sort_keys=True, separators=(",", ":")).encode())
signing_input = f"{header}.{payload}"
return f"{signing_input}.{_mac(signing_input, secret)}"
def verify_request(token: str, secret: bytes) -> LaunchRequest:
"""Verify the signature and the request shape; return the parsed
request. Raises `BrokerAuthError` on any failure (fail-closed)."""
parts = token.split(".")
if len(parts) != 3:
raise BrokerAuthError("malformed token")
header_b, payload_b, sig = parts
if not hmac.compare_digest(_mac(f"{header_b}.{payload_b}", secret), sig):
raise BrokerAuthError("bad signature")
try:
header = json.loads(_b64url_decode(header_b))
claims = json.loads(_b64url_decode(payload_b))
except (ValueError, json.JSONDecodeError) as e:
raise BrokerAuthError("malformed token payload") from e
if not isinstance(header, dict) or header.get("alg") != "HS256":
raise BrokerAuthError("unexpected header/alg")
if not isinstance(claims, dict):
raise BrokerAuthError("claims are not an object")
op = claims.get("op")
bottle_id = claims.get("bottle_id")
source_ip = claims.get("source_ip", "")
image_ref = claims.get("image_ref", "")
slot = claims.get("slot")
if (
not isinstance(op, str) or op not in _ALLOWED_OPS
or not isinstance(bottle_id, str) or not bottle_id
or not isinstance(source_ip, str) or not isinstance(image_ref, str)
or not (slot is None or isinstance(slot, int))
):
raise BrokerAuthError("request does not match the launch-request schema")
return LaunchRequest(
op=op, bottle_id=bottle_id, source_ip=source_ip, image_ref=image_ref, slot=slot
)
# --- the broker itself ------------------------------------------------------
class LaunchBroker(abc.ABC):
"""Verifies a signed request came from the orchestrator, then performs
the backend-native launch/teardown. Subclasses implement `_launch` /
`_teardown`; verification is shared and fail-closed."""
def __init__(self, secret: bytes) -> None:
self._secret = secret
def submit(self, token: str) -> LaunchRequest:
"""Verify `token` and perform its op. Returns the verified request;
raises `BrokerAuthError` if provenance/shape fails."""
req = verify_request(token, self._secret)
if req.op == "launch":
self._launch(req)
else:
self._teardown(req)
return req
@abc.abstractmethod
def _launch(self, req: LaunchRequest) -> None:
...
@abc.abstractmethod
def _teardown(self, req: LaunchRequest) -> None:
...
class StubBroker(LaunchBroker):
"""Dev-harness broker: records verified requests without launching
anything. Exercises the full sign -> verify -> act contract in-process."""
def __init__(self, secret: bytes) -> None:
super().__init__(secret)
self.launched: list[LaunchRequest] = []
self.torn_down: list[LaunchRequest] = []
def _launch(self, req: LaunchRequest) -> None:
self.launched.append(req)
def _teardown(self, req: LaunchRequest) -> None:
self.torn_down.append(req)
__all__ = [
"BrokerAuthError",
"LaunchRequest",
"LaunchBroker",
"StubBroker",
"sign_request",
"verify_request",
]
-160
View File
@@ -1,160 +0,0 @@
"""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):
GET /health -> 200 {"status": "ok"}
GET /sidecar -> 200 {"configured", ["name", "running"]}
GET /bottles -> 200 {"bottles": [ <redacted record>, ... ]}
POST /bottles -> 201 {"bottle_id", "identity_token"} (launch)
body: {"source_ip", ["image_ref"], ["metadata"]}
DELETE /bottles/<bottle_id> -> 200 {"torn_down": true} | 404 (teardown)
POST /attribute -> 200 {"bottle_id"} | 403
body: {"source_ip", "identity_token"}
`POST /bottles` / `DELETE` drive the full launch lifecycle: they mint (or
tear down) the bottle in the registry AND broker the backend-native launch
via the orchestrator. Register/deregister without a launch are internal to
`Orchestrator`, not exposed here.
Routing/handling is the pure function `dispatch()` so it is unit-testable
without a socket; `Handler` / `ControlPlaneServer` / `make_server` are a
thin stdlib adapter around it. Listing redacts identity tokens they are
returned only once, to the caller that launches the bottle.
"""
from __future__ import annotations
import http.server
import json
import os
import socketserver
import typing
from urllib.parse import urlsplit
from .service import Orchestrator
# 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
orch: Orchestrator, method: str, path: str, body: bytes
) -> tuple[int, Json]:
"""Route one control-plane request to a (status, payload) pair. Pure —
no I/O beyond the orchestrator so it is fully testable without a socket."""
route = urlsplit(path).path.rstrip("/") or "/"
if method == "GET" and route == "/health":
return 200, {"status": "ok"}
if method == "GET" and route == "/sidecar":
return 200, orch.sidecar_status()
if method == "GET" and route == "/bottles":
return 200, {"bottles": [r.redacted() for r in orch.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"}
image_ref = data.get("image_ref")
metadata = data.get("metadata")
rec = orch.launch_bottle(
source_ip,
image_ref=image_ref if isinstance(image_ref, str) else "",
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 orch.teardown_bottle(bottle_id):
return 200, {"torn_down": 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 = orch.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.orchestrator, 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 orchestrator for its handlers."""
daemon_threads = True
allow_reuse_address = True
def __init__(self, address: tuple[str, int], orchestrator: Orchestrator) -> None:
self.orchestrator = orchestrator
super().__init__(address, Handler)
def make_server(
orchestrator: Orchestrator, host: str = "127.0.0.1", port: int = 0
) -> ControlPlaneServer:
"""Build (but do not start) a control-plane server. `port=0` binds an
ephemeral port read `server.server_address` for the actual one."""
return ControlPlaneServer((host, port), orchestrator)
__all__ = ["dispatch", "Handler", "ControlPlaneServer", "make_server", "Json"]
-83
View File
@@ -1,83 +0,0 @@
"""Docker launch broker (PRD 0070) — the first *real* LaunchBroker.
On a verified launch request it starts a Docker container; on teardown it
removes it. This proves the orchestrator -> backend seam on the cheapest
backend (the sidecar bundle is already containers). Only the request's
static ids/flags reach `docker`, so nothing free-form crosses the boundary.
Slice 3 launches a single container from the request's `image_ref`, named
after the bottle id and labelled for cleanup. Wiring the full agent +
sidecar bundle (networks, mounts, the consolidated sidecar) is a later
slice this is the seam, not the finished launcher.
"""
from __future__ import annotations
import subprocess
from ..docker_cmd import run_docker
from .broker import LaunchBroker, LaunchRequest
CONTAINER_PREFIX = "bot-bottle-orch-"
BOTTLE_ID_LABEL = "bot-bottle-bottle-id"
class DockerBrokerError(Exception):
"""A brokered docker launch/teardown failed (non-zero `docker` exit)."""
def container_name(bottle_id: str) -> str:
"""Deterministic container name for a bottle."""
return f"{CONTAINER_PREFIX}{bottle_id}"
def run_argv(req: LaunchRequest) -> list[str]:
"""`docker run` argv for a launch request — built only from its static
fields, so it can't be coerced into an arbitrary command."""
return [
"docker", "run", "--detach",
"--name", container_name(req.bottle_id),
"--label", f"{BOTTLE_ID_LABEL}={req.bottle_id}",
req.image_ref,
]
def rm_argv(req: LaunchRequest) -> list[str]:
"""`docker rm --force` argv to tear a bottle's container down."""
return ["docker", "rm", "--force", container_name(req.bottle_id)]
class DockerBroker(LaunchBroker):
"""A `LaunchBroker` that runs / removes a Docker container per bottle.
Provenance + schema verification are inherited from `LaunchBroker`."""
def _docker(self, argv: list[str]) -> subprocess.CompletedProcess[str]:
return run_docker(argv)
def _launch(self, req: LaunchRequest) -> None:
if not req.image_ref:
raise DockerBrokerError(f"launch request for {req.bottle_id} has no image_ref")
proc = self._docker(run_argv(req))
if proc.returncode != 0:
raise DockerBrokerError(
f"docker run failed for {req.bottle_id}: {proc.stderr.strip()}"
)
def _teardown(self, req: LaunchRequest) -> None:
proc = self._docker(rm_argv(req))
# Idempotent: an already-absent container is a successful teardown.
if proc.returncode != 0 and "No such container" not in proc.stderr:
raise DockerBrokerError(
f"docker rm failed for {req.bottle_id}: {proc.stderr.strip()}"
)
__all__ = [
"DockerBroker",
"DockerBrokerError",
"container_name",
"run_argv",
"rm_argv",
"CONTAINER_PREFIX",
"BOTTLE_ID_LABEL",
]
-219
View File
@@ -1,219 +0,0 @@
"""Orchestrator bottle registry — the per-host runtime-state store (PRD 0070).
SQLite-backed registry mapping each live bottle to its source IP and
per-bottle identity token, plus the **fail-closed attribution** the data
plane relies on: a request is attributed to a bottle only when its source
IP *and* its identity token both match a single active record.
The registry co-tenants the shared host `bot-bottle.db` (the `DbStore`
framework namespaces each store by `schema_key`), so all bot-bottle
runtime state lives in one queryable file one place to back up, inspect,
and integrate a console against.
This is the "runtime state" tier of PRD 0070 (leases / approvals /
registry) deliberately NOT config (which stays declarative under
`~/.bot-bottle/`) and NOT the build-time constants (a flat file). It is the
source of truth the orchestrator sweeps on restart to re-adopt running
bottles, so it lives in a durable store rather than process memory.
Attribution combines two independent signals, and needs both:
* source IP the network-layer invariant (on Firecracker the `/31` TAP
+ nft make it unspoofable by construction; weaker on other backends).
* identity token an application-layer per-bottle secret, defence in
depth that doesn't lean on network anti-spoof. A bottle can only ever
prove it is *itself* (it can't learn another bottle's token), so a
hostile agent gains nothing by presenting it.
"""
from __future__ import annotations
import hmac
import secrets
import sqlite3
import time
from dataclasses import dataclass
from pathlib import Path
from ..db_store import DbStore
from ..migrations import TableMigrations
from ..paths import host_db_path
# 256 bits of urandom, URL-safe — unguessable per-bottle identity token.
IDENTITY_TOKEN_BYTES = 32
def new_identity_token() -> str:
"""A fresh per-bottle identity token (PRD 0070 attribution defence)."""
return secrets.token_urlsafe(IDENTITY_TOKEN_BYTES)
def default_db_path() -> Path:
"""The shared host state DB (`bot-bottle.db`) — all bot-bottle runtime
state in one file, co-tenanted via schema_key. Host-resident so it
survives orchestrator restarts (re-adoption sweeps it)."""
return host_db_path()
@dataclass(frozen=True)
class BottleRecord:
"""One live bottle in the registry."""
bottle_id: str
source_ip: str
identity_token: str
state: str = "active"
created_at: float = 0.0
# Opaque JSON blob for forward-compat (policy refs, pool slot, ...) —
# the registry doesn't interpret it, so new fields need no migration.
metadata: str = ""
def redacted(self) -> dict[str, object]:
"""Public view for listing — never exposes the identity token."""
return {
"bottle_id": self.bottle_id,
"source_ip": self.source_ip,
"state": self.state,
"created_at": self.created_at,
"metadata": self.metadata,
}
_MIGRATIONS = TableMigrations(
"orchestrator_registry",
[
# v1 — the live bottle registry.
"""
CREATE TABLE IF NOT EXISTS orchestrator_bottles (
bottle_id TEXT PRIMARY KEY,
source_ip TEXT NOT NULL,
identity_token TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'active',
created_at REAL NOT NULL,
metadata TEXT NOT NULL DEFAULT ''
)
""",
# source_ip is the data-plane attribution key — index it, and make
# the "one active bottle per source IP" check cheap.
"CREATE INDEX IF NOT EXISTS idx_orchestrator_bottles_source_ip "
"ON orchestrator_bottles (source_ip)",
],
)
def _row_to_record(row: sqlite3.Row) -> BottleRecord:
"""Build a BottleRecord from a registry row."""
return BottleRecord(
bottle_id=row["bottle_id"],
source_ip=row["source_ip"],
identity_token=row["identity_token"],
state=row["state"],
created_at=row["created_at"],
metadata=row["metadata"],
)
class RegistryStore(DbStore):
"""SQLite-backed registry of live bottles + fail-closed attribution."""
def __init__(self, db_path: Path | None = None) -> None:
super().__init__(db_path or default_db_path(), _MIGRATIONS)
def _connect(self) -> sqlite3.Connection:
"""Open a connection with a busy timeout for the shared DB."""
conn = super()._connect()
# The registry co-tenants the shared bot-bottle.db, so a busy_timeout
# rides out brief lock contention with the other stores. WAL for the
# shared DB is a deliberate future change (it affects supervise/audit
# and is finicky over guest shares) — not flipped here.
conn.execute("PRAGMA busy_timeout=5000")
return conn
def register(
self,
source_ip: str,
*,
bottle_id: str | None = None,
identity_token: str | None = None,
metadata: str = "",
) -> BottleRecord:
"""Register (or replace) a bottle. Mints a bottle_id / identity token
when not supplied. Live this is the control-plane reload path."""
rec = BottleRecord(
bottle_id=bottle_id or secrets.token_hex(8),
source_ip=source_ip,
identity_token=identity_token or new_identity_token(),
state="active",
created_at=time.time(),
metadata=metadata,
)
with self._connect() as conn:
conn.execute(
"INSERT OR REPLACE INTO orchestrator_bottles "
"(bottle_id, source_ip, identity_token, state, created_at, metadata) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
rec.bottle_id,
rec.source_ip,
rec.identity_token,
rec.state,
rec.created_at,
rec.metadata,
),
)
self._chmod()
return rec
def deregister(self, bottle_id: str) -> bool:
"""Remove a bottle. Returns True if a row was deleted."""
with self._connect() as conn:
cur = conn.execute(
"DELETE FROM orchestrator_bottles WHERE bottle_id = ?", (bottle_id,)
)
return cur.rowcount > 0
def get(self, bottle_id: str) -> BottleRecord | None:
"""Return the bottle by id, or None if absent."""
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM orchestrator_bottles WHERE bottle_id = ?", (bottle_id,)
).fetchone()
return _row_to_record(row) if row else None
def all(self) -> list[BottleRecord]:
"""Every registered bottle, oldest first."""
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM orchestrator_bottles ORDER BY created_at"
).fetchall()
return [_row_to_record(r) for r in rows]
def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None:
"""Fail-closed attribution. Returns the bottle only when exactly one
active record has this source IP AND its identity token matches
(constant-time). Either signal alone is insufficient: an unknown IP,
an ambiguous IP (more than one active bottle a misconfiguration),
an empty token, or a token mismatch all deny."""
if not identity_token:
return None
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM orchestrator_bottles "
"WHERE source_ip = ? AND state = 'active'",
(source_ip,),
).fetchall()
if len(rows) != 1:
return None
rec = _row_to_record(rows[0])
if not hmac.compare_digest(rec.identity_token, identity_token):
return None
return rec
__all__ = [
"BottleRecord",
"RegistryStore",
"new_identity_token",
"default_db_path",
"IDENTITY_TOKEN_BYTES",
]
-103
View File
@@ -1,103 +0,0 @@
"""The per-host orchestrator core (PRD 0070).
`Orchestrator` is the single backend-neutral object the control plane talks
to: it owns the registry (runtime state) and brokers agent launches. It
never branches on backend the `LaunchBroker` abstracts the backend-native
launch, so this same object drives docker / firecracker / apple once a real
broker is wired in.
Launch lifecycle:
* `launch_bottle` mints the bottle (registry: source IP + identity
token), sends a *signed, structured* launch request through the broker,
and returns the record. If the broker rejects/fails, the registry entry
is rolled back so a failed launch leaves no orphan.
* `teardown_bottle` sends a signed teardown request, then deregisters.
"""
from __future__ import annotations
from .broker import LaunchBroker, LaunchRequest, sign_request
from .registry import BottleRecord, RegistryStore
from .sidecar import Sidecar
class Orchestrator:
"""Owns the registry + brokers launches, and manages the single
consolidated per-host sidecar. Backend-neutral (broker and sidecar
abstract the backend-native pieces)."""
def __init__(
self,
registry: RegistryStore,
broker: LaunchBroker,
sign_secret: bytes,
sidecar: Sidecar | None = None,
) -> None:
self.registry = registry
self._broker = broker
self._secret = sign_secret
self._sidecar = sidecar
def launch_bottle(
self,
source_ip: str,
*,
image_ref: str = "",
slot: int | None = None,
metadata: str = "",
) -> BottleRecord:
"""Register a bottle and broker its launch. Rolls the registry entry
back if the launch doesn't take, so a failure leaves no orphan."""
rec = self.registry.register(source_ip, metadata=metadata)
req = LaunchRequest(
op="launch",
bottle_id=rec.bottle_id,
source_ip=source_ip,
image_ref=image_ref,
slot=slot,
)
launched = False
try:
self._broker.submit(sign_request(req, self._secret))
launched = True
finally:
if not launched:
self.registry.deregister(rec.bottle_id)
return rec
def teardown_bottle(self, bottle_id: str) -> bool:
"""Broker teardown then deregister. False if the bottle is unknown."""
rec = self.registry.get(bottle_id)
if rec is None:
return False
req = LaunchRequest(op="teardown", bottle_id=bottle_id, source_ip=rec.source_ip)
self._broker.submit(sign_request(req, self._secret))
self.registry.deregister(bottle_id)
return True
def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None:
"""Fail-closed attribution (delegates to the registry)."""
return self.registry.attribute(source_ip, identity_token)
# --- consolidated sidecar ----------------------------------------------
def ensure_sidecar(self) -> None:
"""Ensure the single per-host sidecar is built and up (idempotent).
No-op when no sidecar is configured."""
if self._sidecar is not None:
self._sidecar.ensure_built()
self._sidecar.ensure_running()
def sidecar_status(self) -> dict[str, object]:
"""Report the shared sidecar for the control plane / console."""
if self._sidecar is None:
return {"configured": False}
return {
"configured": True,
"name": self._sidecar.name,
"running": self._sidecar.is_running(),
}
__all__ = ["Orchestrator"]
-139
View File
@@ -1,139 +0,0 @@
"""The consolidated per-host sidecar (PRD 0070).
The core consolidation win: **one** persistent sidecar per host, shared by
every bottle, instead of a sidecar bundle per bottle. It's safe to share
because the attribution invariant (source IP + identity token, see
`registry`) lets the sidecar attribute each request to the right bottle
so per-bottle policy lives in one long-lived process keyed on who's calling.
`Sidecar` is the backend-neutral lifecycle contract (mirrors `LaunchBroker`):
ensure the single instance is up, report it, tear it down. `DockerSidecar`
is the docker implementation; a firecracker sidecar VM slots in later.
The defining behaviour is **idempotent singleton**: `ensure_running` starts
the instance if absent and is a no-op if it's already up, so N bottle
launches never spawn N sidecars.
"""
from __future__ import annotations
import abc
import os
from pathlib import Path
from ..docker_cmd import run_docker
SIDECAR_NAME = "bot-bottle-orch-sidecar"
SIDECAR_LABEL = "bot-bottle-orch-sidecar=1"
# The real sidecar-bundle image + its Dockerfile. Kept as a local constant
# rather than imported from backend.docker.sidecar_bundle, which would drag
# the whole backend layer into the lean orchestrator (see #359); unify when
# that lands. Env override matches the backend's BOT_BOTTLE_SIDECAR_IMAGE.
SIDECAR_BUNDLE_IMAGE = os.environ.get("BOT_BOTTLE_SIDECAR_IMAGE", "bot-bottle-sidecars:latest")
SIDECAR_DOCKERFILE = "Dockerfile.sidecars"
_REPO_ROOT = Path(__file__).resolve().parents[2]
class SidecarError(Exception):
"""The shared sidecar failed to build/start/stop (non-zero `docker` exit)."""
class Sidecar(abc.ABC):
"""Lifecycle of the single per-host sidecar. Backend-neutral."""
name: str
def ensure_built(self) -> None:
"""Ensure the sidecar's image / rootfs exists, building it if needed.
Default: nothing to build (e.g. a stub or a pre-pulled image)."""
return
@abc.abstractmethod
def ensure_running(self) -> None:
"""Start the sidecar if it isn't already up. Idempotent: a no-op
when it's already running (that's the whole point one per host).
Assumes the image exists call `ensure_built()` first."""
@abc.abstractmethod
def is_running(self) -> bool:
"""True iff the sidecar instance is currently up."""
@abc.abstractmethod
def stop(self) -> None:
"""Remove the sidecar. Idempotent — absent is success."""
class DockerSidecar(Sidecar):
"""The consolidated sidecar as a single, fixed-name Docker container.
`image_ref` defaults to the real sidecar-bundle image; `ensure_built`
builds it from `Dockerfile.sidecars` when it's missing. (Note: slice 5
builds + launches the bundle container; wiring its per-bottle,
source-IP-keyed config is a later slice see PRD 0070.)"""
def __init__(
self,
image_ref: str = SIDECAR_BUNDLE_IMAGE,
*,
name: str = SIDECAR_NAME,
build_context: Path | None = None,
dockerfile: str | None = SIDECAR_DOCKERFILE,
) -> None:
self.image_ref = image_ref
self.name = name
self._build_context = build_context or _REPO_ROOT
self._dockerfile = dockerfile
def image_exists(self) -> bool:
return run_docker(["docker", "image", "inspect", self.image_ref]).returncode == 0
def ensure_built(self) -> None:
"""Build the bundle image from its Dockerfile when it's missing.
No-op when the image is already present, or when no dockerfile is
configured (e.g. a pre-pulled image)."""
if self._dockerfile is None or self.image_exists():
return
proc = run_docker([
"docker", "build",
"-t", self.image_ref,
"-f", str(self._build_context / self._dockerfile),
str(self._build_context),
])
if proc.returncode != 0:
raise SidecarError(f"sidecar image build failed: {proc.stderr.strip()}")
def is_running(self) -> bool:
proc = run_docker([
"docker", "ps",
"--filter", f"name=^{self.name}$",
"--filter", "status=running",
"--format", "{{.Names}}",
])
return self.name in proc.stdout.split()
def ensure_running(self) -> None:
if self.is_running():
return
# Clear any stale (stopped) container holding the fixed name, then
# start fresh. `rm --force` on an absent name is a tolerated no-op.
run_docker(["docker", "rm", "--force", self.name])
proc = run_docker([
"docker", "run", "--detach",
"--name", self.name,
"--label", SIDECAR_LABEL,
self.image_ref,
])
if proc.returncode != 0:
raise SidecarError(f"sidecar failed to start: {proc.stderr.strip()}")
def stop(self) -> None:
proc = run_docker(["docker", "rm", "--force", self.name])
if proc.returncode != 0 and "No such container" not in proc.stderr:
raise SidecarError(f"sidecar failed to stop: {proc.stderr.strip()}")
__all__ = [
"Sidecar", "DockerSidecar", "SidecarError",
"SIDECAR_NAME", "SIDECAR_LABEL", "SIDECAR_BUNDLE_IMAGE",
]
-43
View File
@@ -1,43 +0,0 @@
"""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"]
+2 -4
View File
@@ -7,13 +7,11 @@ import sqlite3
from pathlib import Path from pathlib import Path
try: try:
from .supervise_types import Proposal, Response from .supervise_types import Proposal, Response, host_db_path
from .paths import host_db_path
from .db_store import DbStore from .db_store import DbStore
from .migrations import TableMigrations from .migrations import TableMigrations
except ImportError: except ImportError:
from supervise_types import Proposal, Response # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from supervise_types import Proposal, Response, 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
from db_store import DbStore # 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 from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
+5 -2
View File
@@ -23,10 +23,13 @@ class StoreManager:
def __init__(self, db_path: Path | None = None) -> None: def __init__(self, db_path: Path | None = None) -> None:
if db_path is 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: try:
from .paths import host_db_path from .supervise import host_db_path
except ImportError: except ImportError:
from paths import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from supervise import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
db_path = host_db_path() db_path = host_db_path()
self.db_path = db_path self.db_path = db_path
+16 -6
View File
@@ -41,6 +41,7 @@ try:
from .supervise_types import ( from .supervise_types import (
ACTION_OPERATOR_EDIT, ACTION_OPERATOR_EDIT,
AuditEntry, AuditEntry,
HOST_DB_FILENAME,
Proposal, Proposal,
Response, Response,
STATUSES, STATUSES,
@@ -53,11 +54,13 @@ try:
TOOL_EGRESS_TOKEN_ALLOW, TOOL_EGRESS_TOKEN_ALLOW,
TOOL_GITLEAKS_ALLOW, TOOL_GITLEAKS_ALLOW,
TOOL_LIST_EGRESS_ROUTES, TOOL_LIST_EGRESS_ROUTES,
bot_bottle_root,
) )
except ImportError: except ImportError:
from supervise_types import ( # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module from supervise_types import ( # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module
ACTION_OPERATOR_EDIT, ACTION_OPERATOR_EDIT,
AuditEntry, AuditEntry,
HOST_DB_FILENAME,
Proposal, Proposal,
Response, Response,
STATUSES, STATUSES,
@@ -70,15 +73,10 @@ except ImportError:
TOOL_EGRESS_TOKEN_ALLOW, TOOL_EGRESS_TOKEN_ALLOW,
TOOL_GITLEAKS_ALLOW, TOOL_GITLEAKS_ALLOW,
TOOL_LIST_EGRESS_ROUTES, 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_HOSTNAME = "supervise"
SUPERVISE_PORT = 9100 SUPERVISE_PORT = 9100
@@ -108,6 +106,16 @@ def audit_dir() -> Path:
return bot_bottle_root() / "audit" 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: def audit_log_path(component: str, slug: str) -> Path:
return audit_dir() / f"{component}-{slug}.log" return audit_dir() / f"{component}-{slug}.log"
@@ -289,6 +297,8 @@ __all__ = [
"archive_proposal", "archive_proposal",
"audit_dir", "audit_dir",
"audit_log_path", "audit_log_path",
"bot_bottle_root",
"host_db_path",
"list_pending_proposals", "list_pending_proposals",
"list_all_pending_proposals", "list_all_pending_proposals",
"read_audit_entries", "read_audit_entries",
+32 -5
View File
@@ -1,19 +1,26 @@
"""Shared types for supervise stores (PRD 0013). """Shared types and path helpers for supervise stores (PRD 0013).
Extracted from supervise.py so queue_store and audit_store can import Extracted from supervise.py so queue_store and audit_store can import
Proposal, Response, and AuditEntry without creating a circular import Proposal, Response, AuditEntry, and host_db_path without creating a
(supervise imports from queue_store/audit_store and vice-versa). circular import (supervise imports from queue_store/audit_store and
vice-versa).
The path helpers (`bot_bottle_root`, `host_db_path`, `HOST_DB_FILENAME`) Patching bot_bottle_root on this module propagates through host_db_path
live in `paths` import them from there. because host_db_path looks up bot_bottle_root via sys.modules[__name__]
at call time rather than capturing it at import time.
""" """
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
import sys
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
HOST_DB_FILENAME = "bot-bottle.db"
TOOL_EGRESS_BLOCK = "egress-block" TOOL_EGRESS_BLOCK = "egress-block"
TOOL_EGRESS_ALLOW = "egress-allow" TOOL_EGRESS_ALLOW = "egress-allow"
@@ -36,6 +43,23 @@ STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED)
ACTION_OPERATOR_EDIT = "operator-edit" 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: def _require_str(raw: dict[str, object], key: str) -> str:
value = raw.get(key) value = raw.get(key)
if not isinstance(value, str): if not isinstance(value, str):
@@ -147,6 +171,7 @@ class AuditEntry:
__all__ = [ __all__ = [
"ACTION_OPERATOR_EDIT", "ACTION_OPERATOR_EDIT",
"AuditEntry", "AuditEntry",
"HOST_DB_FILENAME",
"Proposal", "Proposal",
"Response", "Response",
"STATUSES", "STATUSES",
@@ -159,4 +184,6 @@ __all__ = [
"TOOL_EGRESS_TOKEN_ALLOW", "TOOL_EGRESS_TOKEN_ALLOW",
"TOOL_GITLEAKS_ALLOW", "TOOL_GITLEAKS_ALLOW",
"TOOL_LIST_EGRESS_ROUTES", "TOOL_LIST_EGRESS_ROUTES",
"bot_bottle_root",
"host_db_path",
] ]
+15 -103
View File
@@ -117,27 +117,6 @@ 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 safe there and that backend keeps per-bottle sidecars. The invariant is a
hard precondition, not an aspiration. 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) ### Secret handling — a FUTURE pattern (not v1)
> **Status: future / not required for the initial orchestrator.** The > **Status: future / not required for the initial orchestrator.** The
@@ -191,9 +170,7 @@ Three surfaces; only one is per-backend.
`deregister_bottle`, `supervise_queue`. Fully backend-agnostic. Both the `deregister_bottle`, `supervise_queue`. Fully backend-agnostic. Both the
local `cli.py` and the remote console funnel through it, so policy is 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 uniform and `cli.py` becomes a thin client rather than a parallel
launcher. **Transport: HTTP** — the most universal/reliable choice on launcher.
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 2. **Data plane (agent → orchestrator)** — the egress / git / supervise
endpoints. Already agnostic today (agents dial `http://sidecar:9099`); endpoints. Already agnostic today (agents dial `http://sidecar:9099`);
only the *address* and *how packets get there* are per-backend. only the *address* and *how packets get there* are per-backend.
@@ -241,17 +218,6 @@ exposes a broker the orchestrator calls to launch an agent:
Stage 3 removes); a narrower broker later. Stage 3 removes); a narrower broker later.
- **Apple** — the `container` CLI/daemon. - **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 If `BottleBackend` bloats, the pressure valve is composition one level
down: vend a `backend.network()` / `Wiring` collaborator rather than down: vend a `backend.network()` / `Wiring` collaborator rather than
piling methods on — the same discipline, recursed. piling methods on — the same discipline, recursed.
@@ -261,7 +227,7 @@ piling methods on — the same discipline, recursed.
The orchestrator is the natural owner of per-host **runtime state**: The orchestrator is the natural owner of per-host **runtime state**:
- pool **slot leases** (which bottle holds slot *i*) — replaces today's - pool **slot leases** (which bottle holds slot *i*) — replaces today's
`fcntl`-locked files with SQLite transactions; `fcntl`-locked files with WAL-mode transactions;
- the **supervise approval queue** + remembered approvals; - the **supervise approval queue** + remembered approvals;
- the **live bottle registry** (source IP → bottle → policy/secrets refs), - the **live bottle registry** (source IP → bottle → policy/secrets refs),
the lookup table the attribution invariant reads. the lookup table the attribution invariant reads.
@@ -273,45 +239,12 @@ Config splits into three tiers with different homes:
|---|---|---| |---|---|---|
| Build-time constants | pool size, IP base, nft table | flat `.env` (PR #350) — must be readable by Nix eval + root bash, zero runtime | | 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" | | 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 | | Runtime state | slot leases, approvals, registry | **SQLite**, owned by the orchestrator |
SQLite is right for the runtime tier (mutable, concurrent, queried) and 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 wrong for the other two (Nix can't read it at eval time; it fights the
declarative manifest trust model). Keep the tiers separate. declarative manifest trust model). Keep the tiers separate.
**One shared `bot-bottle.db` for all runtime state** (decided in review).
The registry co-tenants the existing host `bot-bottle.db` — the `DbStore`
framework already namespaces each store by `schema_key`, so slot
leases / approvals / registry share one file. One place to query, back up,
and integrate a console against.
- **Host-resident, for durability.** Re-adoption sweeps the registry after
an orchestrator restart, so state *must* outlive the orchestrator
instance. The file lives on the host (`bot_bottle_root()/db/bot-bottle.db`);
the orchestrator unit reaches it, it doesn't carry it.
- **Integrity by sole ownership, not mount permissions.** Agents can't
touch the DB directly wherever it lives (network-isolated in their
bottles). The risk is a *compromised agent-facing data-plane service*
(egress/git-gate, which parse hostile bytes) writing the registry and
forging attribution. Because it's now one shared file, coarse `ro`/`rw`
mount-splitting no longer isolates the registry — so the rule is stronger
and simpler: **only the orchestrator (control plane) opens `bot-bottle.db`;
the data plane and the console reach state through the control-plane RPC,
never a direct file handle.** No agent-facing component gets the file, so
none can forge attribution. (This supersedes the earlier `ro`-mount idea.)
- *Transitional caveat:* today the per-bottle **supervise sidecar
rw-bind-mounts `bot-bottle.db`** to write proposals — exactly the
pattern the orchestrator removes (supervise consolidates into the
orchestrator; sidecar writes become RPC calls). Until that lands, don't
put the attribution registry behind a data-plane-writable mount.
Implementation note for the VM slices: SQLite **WAL** over a guest share
(virtiofs/9p) is finicky (the `-shm`/`-wal` files need real mmap/locking),
which is a second reason the DB wants a **host-side owner** the orchestrator
reaches over the RPC rather than a shared mount into the VM. WAL on the
shared DB is therefore a deliberate, tested future change — not enabled ad
hoc. `sqlite3` itself is stdlib, so "the host needs SQLite" is a non-cost.
## Sequencing ## Sequencing
Jump straight to the **virtualized** end state (not a host-daemon stepping Jump straight to the **virtualized** end state (not a host-daemon stepping
@@ -364,38 +297,17 @@ Keep the sidecar **service one shared thing** throughout.
- **PR #350 (netpool single-source):** the same "one source per fact, - **PR #350 (netpool single-source):** the same "one source per fact,
composition over parallel hierarchies" discipline the contract follows. 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 ## Open questions
- **VM-to-VM routing:** per-backend, and in the design it *is* - **Egress sharing tradeoff:** is the secret-concentration blast radius of
`BottleBackend.wire()` (DNAT+forward for fc, shared-net for one shared mitmproxy worth the resource win, or share only supervise
docker/apple), not orchestrator logic. **Not a blocker** — resolvable at (near-zero secrets) and keep egress + git-gate per-bottle initially?
implementation time (may require a bit of host modification, as the pool - **Control-plane shape:** RPC transport (unix socket / vsock / HTTP over
setup already does on NixOS); an earlier "sidecars in VMs" spike showed the TAP) and the live-reload protocol for per-bottle policy.
it's feasible. - **State re-adoption:** exact scheme for an orchestrator restart to
- **Live-reload protocol** for per-bottle policy over the HTTP control re-adopt running agent VMs from the SQLite registry without racing
plane (add/remove routes/keys/proposals without a restart). in-flight launches.
- **Identity-token delivery:** exactly how the per-bottle token is placed - **VM-to-VM routing:** the nft forward rules + addressing for a
where the agent can present it but not swap in another bottle's. per-host orchestrator VM on its own TAP.
- **Broker request schema:** the exact structured contract that stays
auditable and can't be coerced into launching arbitrary payloads.
+93 -67
View File
@@ -1,70 +1,104 @@
# bot-bottle Firecracker network pool — declarative NixOS module. # bot-bottle Firecracker network pool — declarative NixOS module.
# #
# The one-time privileged setup the Firecracker backend needs: a pool of # The one-time privileged setup the Firecracker backend needs: a pool of
# user- (or group-) owned point-to-point TAP devices plus a fail-closed # user-owned point-to-point TAP devices plus a fail-closed nftables table
# nftables table that confines every microVM to its own sidecar. # that confines every microVM to its own sidecar.
# #
# NON-INVASIVE BY DESIGN. It does NOT flip `networking.nftables.enable` # NON-INVASIVE BY DESIGN. It does NOT flip `networking.nftables.enable`
# (which would switch your whole host firewall backend) or # (which would switch your whole host firewall backend) or
# `systemd.network.enable` (which would hand your interfaces to # `systemd.network.enable` (which would hand your interfaces to
# systemd-networkd). Instead a single systemd oneshot brings the pool up # systemd-networkd). Instead a single systemd oneshot service brings the
# on boot by running the SAME bring-up script as every other install # pool up on boot — the declarative equivalent of
# path (`scripts/firecracker-netpool.sh`) — so the TAP/nft logic lives in # `sudo ./scripts/firecracker-netpool.sh up`. The `inet <tableName>` table
# exactly one place. The `inet <tableName>` table is independent (its own # is independent (its own hooks at priority -10), so it coexists with an
# hooks at priority -10), so it coexists with an iptables # iptables `networking.firewall`, Docker, ufw, firewalld, etc.
# `networking.firewall`, Docker, ufw, firewalld, etc.
# #
# The oneshot is only restarted when this config changes, and the shared # The oneshot is only restarted when this config changes, so a
# script is non-destructive (it never tears down an existing TAP), so a # `nixos-rebuild switch` doesn't tear down TAPs out from under running
# `nixos-rebuild switch` won't cut TAPs out from under running VMs. # VMs unless you actually changed the pool.
# #
# Single source of the pool defaults: bot_bottle/backend/firecracker/ # The option defaults MUST match the backend constants in
# netpool.defaults.env. This module readFile-parses it for the option # bot_bottle/backend/firecracker/netpool.py (pool size, IP base, iface
# defaults below, then passes the resolved values back to the script as # prefix, table name). The backend reads the same values at launch from
# Environment=, so the host pool and the CLI launcher can't drift. # the BOT_BOTTLE_FC_* env vars; if you override an option here, override
# the matching env var for the CLI too (or set writeEnvFile = true below
# to have this module emit them). Keep the two sides in lockstep.
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
let let
cfg = config.services.bot-bottle-firecracker; cfg = config.services.bot-bottle-firecracker;
# --- shared single-source defaults --------------------------------- # --- IPv4 <-> int (full 32-bit carry math) -------------------------
# Parse the KEY=VALUE defaults file (the same one netpool.py and the toOctets = s: map lib.toInt (lib.splitString "." s);
# shell script read). Pure eval — just readFile, no import-from- ipToInt = s:
# derivation — so it works for both flake and channel consumers. let o = toOctets s; in
readDefaults = file: (lib.elemAt o 0) * 16777216
+ (lib.elemAt o 1) * 65536
+ (lib.elemAt o 2) * 256
+ (lib.elemAt o 3);
intToIp = n:
let let
lines = lib.splitString "\n" (builtins.readFile file); b0 = n / 16777216; r0 = n - b0 * 16777216;
keep = l: l != "" && !(lib.hasPrefix "#" l) && lib.hasInfix "=" l; b1 = r0 / 65536; r1 = r0 - b1 * 65536;
toPair = l: b2 = r1 / 256;
let parts = lib.splitString "=" l; b3 = r1 - b2 * 256;
in lib.nameValuePair (lib.head parts) in "${toString b0}.${toString b1}.${toString b2}.${toString b3}";
(lib.concatStringsSep "=" (lib.tail parts));
in lib.listToAttrs (map toPair (lib.filter keep lines));
defaults = readDefaults ../bot_bottle/backend/firecracker/netpool.defaults.env; baseInt = ipToInt cfg.ipBase;
# The one bring-up implementation, shared with the sudo/systemd paths. # Slot i: host = base + 2i (the VM's gateway), on its own /31.
netpoolScript = ../scripts/firecracker-netpool.sh; slots = lib.genList (i: {
iface = "${cfg.ifacePrefix}${toString i}";
hostIp = intToIp (baseInt + 2 * i);
}) cfg.poolSize;
# /31 alignment == an even final octet (only bit 0 matters for base+2i). ip = "${pkgs.iproute2}/bin/ip";
lastOctet = lib.toInt (lib.last (lib.splitString "." cfg.ipBase)); nft = "${pkgs.nftables}/bin/nft";
# The script needs ip/nft/sysctl + the usual coreutils. It gets every # Idempotent ruleset: (create-if-absent → delete → recreate) is atomic
# pool value via the unit's Environment=, so it never reads the shared # within one `nft -f`, so re-running `up` always lands a clean table.
# defaults file (which isn't beside it once copied to the store). nftFile = pkgs.writeText "bot-bottle-fc.nft" ''
runtimePath = with pkgs; [ iproute2 nftables procps coreutils gnused ]; table inet ${cfg.tableName}
delete table inet ${cfg.tableName}
table inet ${cfg.tableName} {
chain forward {
type filter hook forward priority -10; policy accept;
iifname != "${cfg.ifacePrefix}*" return
ct state established,related accept
ct status dnat accept
drop
}
chain input {
type filter hook input priority -10; policy accept;
iifname != "${cfg.ifacePrefix}*" return
ct state established,related accept
drop
}
}
'';
ownEnv = # A non-owner user can't open a user-owned TAP, but any member of a
if cfg.group != null # TAP's owning GROUP can (the kernel checks owner-uid OR owning-group).
then { BOT_BOTTLE_FC_GROUP = cfg.group; } # So `group` lets an interactive user and a CI-runner user share one
else { BOT_BOTTLE_FC_OWNER = cfg.owner; }; # pool; `owner` is the single-user default.
tapOwn = if cfg.group != null then "group ${cfg.group}" else "user ${cfg.owner}";
unitEnv = { upScript = pkgs.writeShellScript "bot-bottle-fc-netpool-up" ''
BOT_BOTTLE_FC_POOL_SIZE = toString cfg.poolSize; set -eu
BOT_BOTTLE_FC_IP_BASE = cfg.ipBase; ${lib.concatMapStringsSep "\n" (s: ''
BOT_BOTTLE_FC_IFACE_PREFIX = cfg.ifacePrefix; ${ip} link show ${s.iface} >/dev/null 2>&1 || ${ip} tuntap add dev ${s.iface} mode tap ${tapOwn}
BOT_BOTTLE_FC_NFT_TABLE = cfg.tableName; ${ip} addr replace ${s.hostIp}/31 dev ${s.iface}
} // ownEnv; ${ip} link set ${s.iface} up
'') slots}
${nft} -f ${nftFile}
'';
downScript = pkgs.writeShellScript "bot-bottle-fc-netpool-down" ''
${nft} delete table inet ${cfg.tableName} 2>/dev/null || true
${lib.concatMapStringsSep "\n" (s: ''
${ip} link show ${s.iface} >/dev/null 2>&1 && ${ip} tuntap del dev ${s.iface} mode tap || true
'') slots}
'';
in in
{ {
options.services.bot-bottle-firecracker = { options.services.bot-bottle-firecracker = {
@@ -72,28 +106,25 @@ in
poolSize = lib.mkOption { poolSize = lib.mkOption {
type = lib.types.ints.positive; type = lib.types.ints.positive;
default = lib.toInt defaults.BOT_BOTTLE_FC_POOL_SIZE; default = 8;
defaultText = lib.literalMD "the shared `netpool.defaults.env` value";
description = "Number of pool slots (concurrent bottles). Must match BOT_BOTTLE_FC_POOL_SIZE."; description = "Number of pool slots (concurrent bottles). Must match BOT_BOTTLE_FC_POOL_SIZE.";
}; };
ipBase = lib.mkOption { ipBase = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = defaults.BOT_BOTTLE_FC_IP_BASE; default = "10.243.0.0";
defaultText = lib.literalMD "the shared `netpool.defaults.env` value";
description = '' description = ''
Base IPv4 of the /31 pool; slot i uses host = base + 2i. Must Base IPv4 of the /31 pool; slot i uses host = base + 2i. Must
be /31-aligned (even final octet) and must match be /31-aligned (even final address) and must match
BOT_BOTTLE_FC_IP_BASE. The shared default is an obscure RFC-1918 BOT_BOTTLE_FC_IP_BASE. Default is an obscure RFC-1918 /16 that
/16 that dodges docker/libvirt/k8s/LAN and, deliberately, dodges docker/libvirt/k8s/LAN and, deliberately, Tailscale's
Tailscale's 100.64.0.0/10 CGNAT range. 100.64.0.0/10 CGNAT range.
''; '';
}; };
ifacePrefix = lib.mkOption { ifacePrefix = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = defaults.BOT_BOTTLE_FC_IFACE_PREFIX; default = "bbfc";
defaultText = lib.literalMD "the shared `netpool.defaults.env` value";
description = "TAP interface name prefix. Must match BOT_BOTTLE_FC_IFACE_PREFIX."; description = "TAP interface name prefix. Must match BOT_BOTTLE_FC_IFACE_PREFIX.";
}; };
@@ -122,8 +153,7 @@ in
tableName = lib.mkOption { tableName = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = defaults.BOT_BOTTLE_FC_NFT_TABLE; default = "bot_bottle_fc";
defaultText = lib.literalMD "the shared `netpool.defaults.env` value";
description = "nftables table name for the isolation boundary. Must match netpool.NFT_TABLE."; description = "nftables table name for the isolation boundary. Must match netpool.NFT_TABLE.";
}; };
@@ -141,7 +171,7 @@ in
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
assertions = [ assertions = [
{ {
assertion = lib.mod lastOctet 2 == 0; assertion = lib.mod baseInt 2 == 0;
message = "services.bot-bottle-firecracker.ipBase must be /31-aligned (even final octet); got ${cfg.ipBase}."; message = "services.bot-bottle-firecracker.ipBase must be /31-aligned (even final octet); got ${cfg.ipBase}.";
} }
{ {
@@ -153,20 +183,17 @@ in
# VM->sidecar traffic is DNAT'd and forwarded, so forwarding must be on. # VM->sidecar traffic is DNAT'd and forwarded, so forwarding must be on.
boot.kernel.sysctl."net.ipv4.ip_forward" = 1; boot.kernel.sysctl."net.ipv4.ip_forward" = 1;
# One oneshot brings up the whole pool (TAPs + independent nft table) # One oneshot brings up the whole pool (TAPs + independent nft table).
# by running the shared bring-up script. No networking.nftables.enable # No networking.nftables.enable / systemd.network.enable — see header.
# / systemd.network.enable — see header.
systemd.services."bot-bottle-firecracker-netpool" = { systemd.services."bot-bottle-firecracker-netpool" = {
description = "bot-bottle Firecracker TAP pool + nft isolation table"; description = "bot-bottle Firecracker TAP pool + nft isolation table";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
after = [ "network-pre.target" ]; after = [ "network-pre.target" ];
path = runtimePath;
environment = unitEnv;
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
RemainAfterExit = true; RemainAfterExit = true;
ExecStart = "${pkgs.bash}/bin/bash ${netpoolScript} up"; ExecStart = upScript;
ExecStop = "${pkgs.bash}/bin/bash ${netpoolScript} down"; ExecStop = downScript;
}; };
}; };
@@ -175,7 +202,6 @@ in
BOT_BOTTLE_FC_POOL_SIZE=${toString cfg.poolSize} BOT_BOTTLE_FC_POOL_SIZE=${toString cfg.poolSize}
BOT_BOTTLE_FC_IP_BASE=${cfg.ipBase} BOT_BOTTLE_FC_IP_BASE=${cfg.ipBase}
BOT_BOTTLE_FC_IFACE_PREFIX=${cfg.ifacePrefix} BOT_BOTTLE_FC_IFACE_PREFIX=${cfg.ifacePrefix}
BOT_BOTTLE_FC_NFT_TABLE=${cfg.tableName}
''; '';
}; };
}; };
+19 -40
View File
@@ -33,44 +33,24 @@
# sudo ./scripts/firecracker-netpool.sh down # sudo ./scripts/firecracker-netpool.sh down
# ./scripts/firecracker-netpool.sh status # ./scripts/firecracker-netpool.sh status
# #
# Pool params default to the shared single-source file # Env overrides (must match the backend's util.py constants):
# (bot_bottle/backend/firecracker/netpool.defaults.env); a matching # BOT_BOTTLE_FC_POOL_SIZE number of slots (default 8)
# env var overrides its key: # BOT_BOTTLE_FC_IP_BASE base IPv4 of the /31 pool (default 10.243.0.0)
# BOT_BOTTLE_FC_POOL_SIZE number of slots # BOT_BOTTLE_FC_IFACE_PREFIX TAP name prefix (default bbfc)
# BOT_BOTTLE_FC_IP_BASE base IPv4 of the /31 pool # BOT_BOTTLE_FC_OWNER owning user (default $SUDO_USER or $USER)
# BOT_BOTTLE_FC_IFACE_PREFIX TAP name prefix # BOT_BOTTLE_FC_GROUP owning group; if set, TAPs are group-owned
# BOT_BOTTLE_FC_NFT_TABLE isolation table name # instead of user-owned, so any group member
# BOT_BOTTLE_FC_OWNER owning user (default $SUDO_USER or $USER) # (e.g. an interactive user + a CI runner
# BOT_BOTTLE_FC_GROUP owning group; if set, TAPs are group-owned # user) can open the pool. Overrides OWNER.
# instead of user-owned, so any group member
# (e.g. an interactive user + a CI runner
# user) can open the pool. Overrides OWNER.
set -euo pipefail set -euo pipefail
# The single source of the pool defaults, shared with netpool.py and the POOL_SIZE="${BOT_BOTTLE_FC_POOL_SIZE:-8}"
# Nix module. The Nix path passes every value as env (the script runs IP_BASE="${BOT_BOTTLE_FC_IP_BASE:-10.243.0.0}"
# from the store, detached from this file), so this lookup only matters PREFIX="${BOT_BOTTLE_FC_IFACE_PREFIX:-bbfc}"
# on the direct/sudo path where the script sits in the repo tree.
_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_DEFAULTS="$_SCRIPT_DIR/../bot_bottle/backend/firecracker/netpool.defaults.env"
_default() { # value of KEY=... from the shared file; empty if unavailable
[ -f "$_DEFAULTS" ] || return 0
sed -n "s/^$1=//p" "$_DEFAULTS" | tail -1
}
POOL_SIZE="${BOT_BOTTLE_FC_POOL_SIZE:-$(_default BOT_BOTTLE_FC_POOL_SIZE)}"
IP_BASE="${BOT_BOTTLE_FC_IP_BASE:-$(_default BOT_BOTTLE_FC_IP_BASE)}"
PREFIX="${BOT_BOTTLE_FC_IFACE_PREFIX:-$(_default BOT_BOTTLE_FC_IFACE_PREFIX)}"
TABLE="${BOT_BOTTLE_FC_NFT_TABLE:-$(_default BOT_BOTTLE_FC_NFT_TABLE)}"
OWNER="${BOT_BOTTLE_FC_OWNER:-${SUDO_USER:-$USER}}" OWNER="${BOT_BOTTLE_FC_OWNER:-${SUDO_USER:-$USER}}"
GROUP="${BOT_BOTTLE_FC_GROUP:-}" GROUP="${BOT_BOTTLE_FC_GROUP:-}"
TABLE="bot_bottle_fc"
# Fail loudly rather than provisioning a half/empty range if a value
# resolved to nothing (env unset AND the shared file unreadable).
for _v in POOL_SIZE IP_BASE PREFIX TABLE; do
[ -n "${!_v}" ] || { echo "error: $_v unresolved (set BOT_BOTTLE_FC_* or fix $_DEFAULTS)" >&2; exit 1; }
done
# Sidecar ports (must match the backend). egress=9099, supervise=9100, # Sidecar ports (must match the backend). egress=9099, supervise=9100,
# git-http=9420. Reached by the VM at its host-side TAP IP. # git-http=9420. Reached by the VM at its host-side TAP IP.
@@ -114,13 +94,12 @@ cmd_up() {
for i in $(seq 0 $((POOL_SIZE-1))); do for i in $(seq 0 $((POOL_SIZE-1))); do
local dev host local dev host
dev="$(iface "$i")" ; host="$(host_ip "$i")" dev="$(iface "$i")" ; host="$(host_ip "$i")"
# Non-destructive + idempotent: only create a missing TAP (tearing if ip link show "$dev" >/dev/null 2>&1; then
# an existing one down would cut a running VM), and `addr replace` ip link set "$dev" down 2>/dev/null || true
# is safe to re-run. Ownership is fixed at creation, so to change ip tuntap del dev "$dev" mode tap 2>/dev/null || true
# owner/group run `down` then `up`. fi
ip link show "$dev" >/dev/null 2>&1 \ ip tuntap add dev "$dev" mode tap "${own_args[@]}"
|| ip tuntap add dev "$dev" mode tap "${own_args[@]}" ip addr add "$host/31" dev "$dev"
ip addr replace "$host/31" dev "$dev"
ip link set "$dev" up ip link set "$dev" up
echo " $dev host=$host guest=$(guest_ip "$i") $own_desc" echo " $dev host=$host guest=$(guest_ip "$i") $own_desc"
done done
@@ -1,56 +0,0 @@
"""Integration: the Docker launch broker starts and removes a real container.
Gated on a reachable Docker daemon (skips cleanly otherwise). Uses a tiny
image; the container may exit immediately we only assert it exists after
launch and is gone after teardown.
"""
from __future__ import annotations
import secrets
import subprocess
import unittest
from bot_bottle.orchestrator.broker import LaunchRequest, sign_request
from bot_bottle.orchestrator.docker_broker import DockerBroker, container_name
from tests._docker import skip_unless_docker
IMAGE = "busybox"
@skip_unless_docker()
class TestDockerBrokerIntegration(unittest.TestCase):
def setUp(self) -> None:
self.secret = secrets.token_bytes(16)
self.broker = DockerBroker(self.secret)
self.bottle_id = "itest" + secrets.token_hex(4)
self.name = container_name(self.bottle_id)
self.addCleanup(
lambda: subprocess.run(
["docker", "rm", "--force", self.name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
)
def _exists(self) -> bool:
proc = subprocess.run(
["docker", "ps", "-a", "--filter", f"name=^{self.name}$",
"--format", "{{.Names}}"],
stdout=subprocess.PIPE, text=True, check=False,
)
return self.name in proc.stdout.split()
def _submit(self, req: LaunchRequest) -> None:
self.broker.submit(sign_request(req, self.secret))
def test_launch_creates_then_teardown_removes(self) -> None:
self._submit(
LaunchRequest(op="launch", bottle_id=self.bottle_id, image_ref=IMAGE)
)
self.assertTrue(self._exists(), "container should exist after launch")
self._submit(LaunchRequest(op="teardown", bottle_id=self.bottle_id))
self.assertFalse(self._exists(), "container should be gone after teardown")
if __name__ == "__main__":
unittest.main()
@@ -1,45 +0,0 @@
"""Integration: the consolidated Docker sidecar is an idempotent singleton.
Gated on a reachable Docker daemon. Uses a unique name so it never
collides with a real per-host sidecar or a leftover from another run.
"""
from __future__ import annotations
import secrets
import subprocess
import unittest
from bot_bottle.orchestrator.sidecar import DockerSidecar
from tests._docker import skip_unless_docker
IMAGE = "busybox"
@skip_unless_docker()
class TestDockerSidecarIntegration(unittest.TestCase):
def setUp(self) -> None:
self.name = "bot-bottle-orch-sidecar-itest-" + secrets.token_hex(4)
self.sc = DockerSidecar(IMAGE, name=self.name)
self.addCleanup(self.sc.stop)
def _count(self) -> int:
proc = subprocess.run(
["docker", "ps", "-a", "--filter", f"name=^{self.name}$",
"--format", "{{.Names}}"],
stdout=subprocess.PIPE, text=True, check=False,
)
return sum(1 for n in proc.stdout.split() if n == self.name)
def test_ensure_is_idempotent_singleton(self) -> None:
self.sc.ensure_running()
self.assertEqual(1, self._count())
# A second ensure must not spawn a second container — one per host.
self.sc.ensure_running()
self.assertEqual(1, self._count())
self.sc.stop()
self.assertEqual(0, self._count())
if __name__ == "__main__":
unittest.main()
@@ -1,36 +0,0 @@
"""Integration: DockerSidecar.image_exists reflects real docker state.
Gated on a reachable Docker daemon. Deliberately does NOT build the full
sidecar bundle (a heavy, slow image build) it exercises the `image_exists`
primitive that `ensure_built` gates on, against a tiny pulled image and a
name that can't exist.
"""
from __future__ import annotations
import subprocess
import unittest
from bot_bottle.orchestrator.sidecar import DockerSidecar
from tests._docker import skip_unless_docker
IMAGE = "busybox"
@skip_unless_docker()
class TestDockerSidecarImageExists(unittest.TestCase):
def test_image_exists_true_for_present_false_for_absent(self) -> None:
# Ensure the tiny image is present (build_if_missing is disabled here
# so this never triggers a Dockerfile.sidecars build).
subprocess.run(
["docker", "pull", IMAGE],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
self.assertTrue(DockerSidecar(IMAGE, dockerfile=None).image_exists())
self.assertFalse(
DockerSidecar("bot-bottle-nonexistent:doesnotexist", dockerfile=None).image_exists()
)
if __name__ == "__main__":
unittest.main()
+3 -22
View File
@@ -2,7 +2,7 @@
Isolates ``HOME`` to a throwaway directory for the entire unit suite so Isolates ``HOME`` to a throwaway directory for the entire unit suite so
no test ever reads or writes the real ``~/.bot-bottle`` (state, queue, no test ever reads or writes the real ``~/.bot-bottle`` (state, queue,
and audit dirs all derive from ``paths.bot_bottle_root()`` and audit dirs all derive from ``supervise.bot_bottle_root()``
``Path.home()``). Without this, a test that takes a ``flock`` on the ``Path.home()``). Without this, a test that takes a ``flock`` on the
real audit log can **block indefinitely** when a live bottle's supervise 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 sidecar holds that lock observed as a hung ``coverage run`` at 0% CPU
@@ -10,12 +10,8 @@ and unisolated tests otherwise pollute the developer's home dir.
Individual tests that need their own ``HOME`` still override Individual tests that need their own ``HOME`` still override
``os.environ['HOME']`` and restore it; they now restore to this isolated ``os.environ['HOME']`` and restore it; they now restore to this isolated
dir rather than the real one, so isolation holds either way. dir rather than the real one, so isolation holds either way. Tests that
patch ``supervise.bot_bottle_root`` directly are unaffected.
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 from __future__ import annotations
@@ -24,9 +20,6 @@ import atexit
import os import os
import shutil import shutil
import tempfile import tempfile
from collections.abc import Callable
from pathlib import Path
from unittest import mock
_real_home = os.environ.get("HOME") _real_home = os.environ.get("HOME")
_tmp_home = tempfile.mkdtemp(prefix="bot-bottle-unit-home.") _tmp_home = tempfile.mkdtemp(prefix="bot-bottle-unit-home.")
@@ -42,15 +35,3 @@ def _restore_home() -> None:
atexit.register(_restore_home) 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
+8 -3
View File
@@ -7,8 +7,7 @@ import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from bot_bottle import bottle_state from bot_bottle import supervise, bottle_state
from tests.unit import use_bottle_root
from bot_bottle.backend import ActiveAgent from bot_bottle.backend import ActiveAgent
from bot_bottle.backend.freeze import get_freezer from bot_bottle.backend.freeze import get_freezer
from bot_bottle.backend.docker.freezer import DockerFreezer from bot_bottle.backend.docker.freezer import DockerFreezer
@@ -19,7 +18,13 @@ from bot_bottle.backend.firecracker.freezer import FirecrackerFreezer
class _FakeHomeMixin: class _FakeHomeMixin:
def _setup_fake_home(self): def _setup_fake_home(self):
self._tmp = tempfile.TemporaryDirectory(prefix="freezer-test.") self._tmp = tempfile.TemporaryDirectory(prefix="freezer-test.")
self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") 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)
def _teardown_fake_home(self): def _teardown_fake_home(self):
self._restore() self._restore()
+4 -3
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from bot_bottle import bottle_state from bot_bottle import bottle_state
from tests.unit import use_bottle_root from bot_bottle import supervise
from bot_bottle.backend import BottleSpec from bot_bottle.backend import BottleSpec
from bot_bottle.backend.docker import DockerBottleBackend from bot_bottle.backend.docker import DockerBottleBackend
from bot_bottle.backend.resolve_common import mint_slug from bot_bottle.backend.resolve_common import mint_slug
@@ -55,10 +55,11 @@ class _FakeStateMixin:
def setUp(self) -> None: def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory(prefix="backend-prepare.") self.tmp = tempfile.TemporaryDirectory(prefix="backend-prepare.")
self.root = Path(self.tmp.name) / ".bot-bottle" self.root = Path(self.tmp.name) / ".bot-bottle"
self._restore = use_bottle_root(self.root) self.original_root = supervise.bot_bottle_root
supervise.bot_bottle_root = lambda: self.root # type: ignore[assignment]
def tearDown(self) -> None: def tearDown(self) -> None:
self._restore() supervise.bot_bottle_root = self.original_root # type: ignore[assignment]
self.tmp.cleanup() self.tmp.cleanup()
+4 -3
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from unittest.mock import MagicMock, call, patch from unittest.mock import MagicMock, call, patch
from bot_bottle import bottle_state from bot_bottle import bottle_state
from tests.unit import use_bottle_root from bot_bottle import supervise
from bot_bottle.backend import Bottle, BottleSpec, ExecResult from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker import DockerBottleBackend from bot_bottle.backend.docker import DockerBottleBackend
from bot_bottle.backend.firecracker import FirecrackerBottleBackend from bot_bottle.backend.firecracker import FirecrackerBottleBackend
@@ -55,10 +55,11 @@ class _FakeStateMixin:
self.tmp_dir = tempfile.TemporaryDirectory(prefix="backend-workspace.") self.tmp_dir = tempfile.TemporaryDirectory(prefix="backend-workspace.")
self.tmp = Path(self.tmp_dir.name) self.tmp = Path(self.tmp_dir.name)
self.root = self.tmp / ".bot-bottle" self.root = self.tmp / ".bot-bottle"
self._restore = use_bottle_root(self.root) self.original_root = supervise.bot_bottle_root
supervise.bot_bottle_root = lambda: self.root # type: ignore[assignment]
def tearDown(self) -> None: def tearDown(self) -> None:
self._restore() supervise.bot_bottle_root = self.original_root # type: ignore[assignment]
self.tmp_dir.cleanup() self.tmp_dir.cleanup()
+8 -2
View File
@@ -6,7 +6,7 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from tests.unit import use_bottle_root from bot_bottle import supervise
from bot_bottle import bottle_state from bot_bottle import bottle_state
from bot_bottle.bottle_state import ( from bot_bottle.bottle_state import (
BottleMetadata, BottleMetadata,
@@ -18,7 +18,13 @@ from bot_bottle.bottle_state import (
class _FakeHomeMixin: class _FakeHomeMixin:
def _setup_fake_home(self): def _setup_fake_home(self):
self._tmp = tempfile.TemporaryDirectory(prefix="bottle-state-test.") self._tmp = tempfile.TemporaryDirectory(prefix="bottle-state-test.")
self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") 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)
def _teardown_fake_home(self): def _teardown_fake_home(self):
self._restore() self._restore()
+8 -2
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from bot_bottle.cli.commit import cmd_commit from bot_bottle.cli.commit import cmd_commit
from tests.unit import use_bottle_root from bot_bottle import supervise
from bot_bottle import bottle_state from bot_bottle import bottle_state
from bot_bottle.backend.freeze import CommitCancelled from bot_bottle.backend.freeze import CommitCancelled
@@ -16,7 +16,13 @@ from bot_bottle.backend.freeze import CommitCancelled
class _FakeHomeMixin: class _FakeHomeMixin:
def _setup_fake_home(self): def _setup_fake_home(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cli-commit-test.") self._tmp = tempfile.TemporaryDirectory(prefix="cli-commit-test.")
self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") 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)
def _teardown_fake_home(self): def _teardown_fake_home(self):
self._restore() self._restore()
+8 -3
View File
@@ -8,7 +8,7 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from tests.unit import use_bottle_root from bot_bottle import supervise
from bot_bottle import bottle_state from bot_bottle import bottle_state
from bot_bottle.cli import start as start_mod from bot_bottle.cli import start as start_mod
@@ -16,10 +16,15 @@ from bot_bottle.cli import start as start_mod
class _FakeHomeMixin: class _FakeHomeMixin:
def _setup_fake_home(self): def _setup_fake_home(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cli-start-settle.") self._tmp = tempfile.TemporaryDirectory(prefix="cli-start-settle.")
self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") 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]
def _teardown_fake_home(self): def _teardown_fake_home(self):
self._restore() supervise.bot_bottle_root = self._original # type: ignore[assignment]
self._tmp.cleanup() self._tmp.cleanup()
+8 -2
View File
@@ -15,7 +15,7 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from tests.unit import use_bottle_root from bot_bottle import supervise
from bot_bottle import bottle_state from bot_bottle import bottle_state
from bot_bottle.backend.docker.cleanup import _list_orphan_state_dirs from bot_bottle.backend.docker.cleanup import _list_orphan_state_dirs
@@ -23,7 +23,13 @@ from bot_bottle.backend.docker.cleanup import _list_orphan_state_dirs
class _FakeHomeMixin: class _FakeHomeMixin:
def _setup_fake_home(self) -> None: def _setup_fake_home(self) -> None:
self._tmp = tempfile.TemporaryDirectory(prefix="docker-cleanup-test.") self._tmp = tempfile.TemporaryDirectory(prefix="docker-cleanup-test.")
self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") 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)
def _teardown_fake_home(self) -> None: def _teardown_fake_home(self) -> None:
self._restore() self._restore()
+8 -2
View File
@@ -23,7 +23,7 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from tests.unit import use_bottle_root from bot_bottle import supervise
from bot_bottle import bottle_state from bot_bottle import bottle_state
from bot_bottle.backend.docker import enumerate as _enumerate from bot_bottle.backend.docker import enumerate as _enumerate
@@ -75,7 +75,13 @@ class TestParseServicesByProject(unittest.TestCase):
class _FakeHomeMixin: class _FakeHomeMixin:
def _setup_fake_home(self) -> None: def _setup_fake_home(self) -> None:
self._tmp = tempfile.TemporaryDirectory(prefix="enum-active.") self._tmp = tempfile.TemporaryDirectory(prefix="enum-active.")
self._restore_home = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") 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)
def _teardown_fake_home(self) -> None: def _teardown_fake_home(self) -> None:
self._restore_home() self._restore_home()
+8 -2
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from tests.unit import use_bottle_root from bot_bottle import supervise
from bot_bottle.backend.egress_apply import EgressApplyError from bot_bottle.backend.egress_apply import EgressApplyError
from bot_bottle.backend.docker.egress_apply import applicator from bot_bottle.backend.docker.egress_apply import applicator
@@ -67,8 +67,14 @@ class TestValidateRoutesContent(unittest.TestCase):
class TestApplyRoutesChange(unittest.TestCase): class TestApplyRoutesChange(unittest.TestCase):
def setUp(self): def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="egress-apply-test.") 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(self._tmp.cleanup)
self.addCleanup(use_bottle_root(Path(self._tmp.name) / ".bot-bottle"))
def test_writes_live_routes_and_signals_reload(self): def test_writes_live_routes_and_signals_reload(self):
calls: list[list[str]] = [] calls: list[list[str]] = []
+19 -50
View File
@@ -67,24 +67,10 @@ class TestNetpoolRenderers(unittest.TestCase):
self.assertNotIn("networking.nftables.enable = true", mod) self.assertNotIn("networking.nftables.enable = true", mod)
self.assertNotIn("systemd.network.enable = true", mod) self.assertNotIn("systemd.network.enable = true", mod)
self.assertIn("bot-bottle-firecracker-netpool", mod) # the oneshot self.assertIn("bot-bottle-firecracker-netpool", mod) # the oneshot
self.assertIn(netpool.NFT_TABLE, mod)
self.assertIn('iifname != "${cfg.ifacePrefix}*" return', mod)
# Supports group-owned TAPs (shared pool for a multi-user host). # Supports group-owned TAPs (shared pool for a multi-user host).
self.assertIn("BOT_BOTTLE_FC_GROUP", mod) self.assertIn("group ${cfg.group}", mod)
def test_nixos_module_delegates_and_holds_no_literals(self):
# Single-source discipline: the module must NOT re-implement the
# bring-up (it delegates to the shared script) and must NOT hard-
# code the pool defaults (it readFile-parses the shared .env), so
# nothing can drift from netpool.py / the shell script.
root = Path(__file__).resolve().parents[2]
mod = (root / "nix" / "firecracker-netpool.nix").read_text()
# Delegation to the one bring-up implementation + shared defaults.
self.assertIn("scripts/firecracker-netpool.sh", mod)
self.assertIn("netpool.defaults.env", mod)
self.assertIn("BOT_BOTTLE_FC_NFT_TABLE", mod)
# No duplicated literals or nft ruleset.
self.assertNotIn(netpool.ip_base(), mod) # 10.243.0.0
self.assertNotIn(netpool.NFT_TABLE, mod) # bot_bottle_fc
self.assertNotIn("ct status dnat", mod) # the nft ruleset
def test_shell_setup_reflects_overrides(self): def test_shell_setup_reflects_overrides(self):
with patch.dict(os.environ, {"BOT_BOTTLE_FC_POOL_SIZE": "3"}): with patch.dict(os.environ, {"BOT_BOTTLE_FC_POOL_SIZE": "3"}):
@@ -276,43 +262,26 @@ class TestBottleAgentArgv(unittest.TestCase):
self.assertIn("USER=node", argv) self.assertIn("USER=node", argv)
class TestNetpoolDefaultsSingleSource(unittest.TestCase): class TestSetupScriptConsistency(unittest.TestCase):
"""The pool defaults live in one shared file (netpool.defaults.env); """The shell setup script duplicates the pool defaults; keep them in
Python parses it and the shell script + Nix module read the same file, lockstep with the Python constants so the two setup paths agree."""
so the three setup paths can't drift."""
def _root(self) -> Path: def _script(self) -> str:
return Path(__file__).resolve().parents[2] root = Path(__file__).resolve().parent.parent.parent
return (root / "scripts" / "firecracker-netpool.sh").read_text()
def _shared_defaults(self) -> dict[str, str]: def test_defaults_match_python(self):
text = netpool.DEFAULTS_FILE.read_text() script = self._script()
out: dict[str, str] = {}
for raw in text.splitlines():
line = raw.strip()
if line and not line.startswith("#") and "=" in line:
k, _, v = line.partition("=")
out[k.strip()] = v.strip()
return out
def test_python_reads_the_shared_file(self):
d = self._shared_defaults()
with patch.dict(os.environ, {}, clear=True): with patch.dict(os.environ, {}, clear=True):
self.assertEqual(int(d["BOT_BOTTLE_FC_POOL_SIZE"]), netpool.pool_size()) self.assertIn(f'POOL_SIZE:-{netpool.pool_size()}', script)
self.assertEqual(d["BOT_BOTTLE_FC_IP_BASE"], netpool.ip_base()) self.assertIn(f'BOT_BOTTLE_FC_IP_BASE:-{netpool.ip_base()}', script)
# Module constants resolve through the same shared file. self.assertIn(f'IFACE_PREFIX:-{netpool.IFACE_PREFIX}', script)
self.assertEqual(d["BOT_BOTTLE_FC_IFACE_PREFIX"], netpool.IFACE_PREFIX)
self.assertEqual(d["BOT_BOTTLE_FC_NFT_TABLE"], netpool.NFT_TABLE)
def test_env_var_overrides_the_shared_default(self): def test_table_name_and_ports_match(self):
with patch.dict(os.environ, {"BOT_BOTTLE_FC_IP_BASE": "10.99.0.0"}): script = self._script()
self.assertEqual("10.99.0.0", netpool.ip_base()) self.assertIn(f'TABLE="{netpool.NFT_TABLE}"', script)
for port in netpool.SIDECAR_PORTS:
def test_script_defers_to_shared_file_without_literals(self): self.assertIn(str(port), script)
script = (self._root() / "scripts" / "firecracker-netpool.sh").read_text()
# Delegates its defaults to the shared file, not hardcoded values.
self.assertIn("netpool.defaults.env", script)
self.assertIn("BOT_BOTTLE_FC_POOL_SIZE:-$(_default BOT_BOTTLE_FC_POOL_SIZE)", script)
self.assertIn('TABLE="${BOT_BOTTLE_FC_NFT_TABLE:-$(_default BOT_BOTTLE_FC_NFT_TABLE)}"', script)
class TestBootArgs(unittest.TestCase): class TestBootArgs(unittest.TestCase):
-86
View File
@@ -1,86 +0,0 @@
"""Unit tests for the orchestrator launch broker (PRD 0070)."""
from __future__ import annotations
import secrets
import unittest
from bot_bottle.orchestrator.broker import (
BrokerAuthError,
LaunchRequest,
StubBroker,
sign_request,
verify_request,
)
class TestSignVerify(unittest.TestCase):
def setUp(self) -> None:
self.secret = secrets.token_bytes(16)
def test_launch_round_trip(self) -> None:
req = LaunchRequest(
op="launch", bottle_id="b1", source_ip="10.243.0.1",
image_ref="sha256:abc", slot=3,
)
self.assertEqual(req, verify_request(sign_request(req, self.secret), self.secret))
def test_teardown_round_trip(self) -> None:
req = LaunchRequest(op="teardown", bottle_id="b1")
got = verify_request(sign_request(req, self.secret), self.secret)
self.assertEqual(("teardown", "b1"), (got.op, got.bottle_id))
def test_wrong_secret_rejected(self) -> None:
token = sign_request(LaunchRequest(op="launch", bottle_id="b1"), self.secret)
with self.assertRaises(BrokerAuthError):
verify_request(token, secrets.token_bytes(16))
def test_tampered_payload_rejected(self) -> None:
token = sign_request(LaunchRequest(op="launch", bottle_id="b1"), self.secret)
header, payload, sig = token.split(".")
flipped = payload[:-1] + ("A" if payload[-1] != "A" else "B")
with self.assertRaises(BrokerAuthError):
verify_request(f"{header}.{flipped}.{sig}", self.secret)
def test_malformed_tokens_rejected(self) -> None:
for bad in ("", "a.b", "a.b.c.d", "not-a-token"):
with self.assertRaises(BrokerAuthError):
verify_request(bad, self.secret)
def test_unknown_op_rejected_despite_valid_signature(self) -> None:
# A correctly-signed token whose op isn't in the allow-list must
# still be refused — the schema guard is independent of provenance.
token = sign_request(LaunchRequest(op="rm-rf", bottle_id="b1"), self.secret)
with self.assertRaises(BrokerAuthError):
verify_request(token, self.secret)
class TestStubBroker(unittest.TestCase):
def setUp(self) -> None:
self.secret = secrets.token_bytes(16)
self.broker = StubBroker(self.secret)
def test_submit_launch_records_verified_request(self) -> None:
req = LaunchRequest(op="launch", bottle_id="b1", source_ip="10.243.0.1")
got = self.broker.submit(sign_request(req, self.secret))
self.assertEqual(req, got)
self.assertEqual(["b1"], [r.bottle_id for r in self.broker.launched])
self.assertEqual([], self.broker.torn_down)
def test_submit_teardown_records(self) -> None:
self.broker.submit(
sign_request(LaunchRequest(op="teardown", bottle_id="b1"), self.secret)
)
self.assertEqual(["b1"], [r.bottle_id for r in self.broker.torn_down])
def test_submit_forged_token_is_fail_closed(self) -> None:
forged = sign_request(
LaunchRequest(op="launch", bottle_id="b1"), secrets.token_bytes(16)
)
with self.assertRaises(BrokerAuthError):
self.broker.submit(forged)
self.assertEqual([], self.broker.launched) # nothing acted on
if __name__ == "__main__":
unittest.main()
@@ -1,163 +0,0 @@
"""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 secrets
import tempfile
import threading
import unittest
import urllib.request
from pathlib import Path
from bot_bottle.orchestrator.broker import StubBroker
from bot_bottle.orchestrator.control_plane import dispatch, make_server
from bot_bottle.orchestrator.registry import RegistryStore
from bot_bottle.orchestrator.service import Orchestrator
def _body(obj: object) -> bytes:
return json.dumps(obj).encode()
def _orchestrator(db_path: Path) -> Orchestrator:
store = RegistryStore(db_path)
store.migrate()
secret = secrets.token_bytes(16)
return Orchestrator(store, StubBroker(secret), secret)
class TestDispatch(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.orch = _orchestrator(Path(self._tmp.name) / "r.db")
def tearDown(self) -> None:
self._tmp.cleanup()
def test_health(self) -> None:
status, payload = dispatch(self.orch, "GET", "/health", b"")
self.assertEqual(200, status)
self.assertEqual("ok", payload["status"])
def test_register_returns_id_and_token(self) -> None:
status, payload = dispatch(
self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.1"})
)
self.assertEqual(201, status)
self.assertTrue(payload["bottle_id"])
self.assertTrue(payload["identity_token"])
def test_register_requires_source_ip(self) -> None:
status, _ = dispatch(self.orch, "POST", "/bottles", _body({}))
self.assertEqual(400, status)
def test_register_rejects_bad_json(self) -> None:
status, _ = dispatch(self.orch, "POST", "/bottles", b"{not json")
self.assertEqual(400, status)
def test_list_redacts_identity_token(self) -> None:
dispatch(self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.1"}))
status, payload = dispatch(self.orch, "GET", "/bottles", b"")
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.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.5"})
)
token = reg["identity_token"]
ok_status, ok = dispatch(
self.orch, "POST", "/attribute",
_body({"source_ip": "10.243.0.5", "identity_token": token}),
)
self.assertEqual(200, ok_status)
self.assertEqual(reg["bottle_id"], ok["bottle_id"])
bad_status, _ = dispatch(
self.orch, "POST", "/attribute",
_body({"source_ip": "10.243.0.5", "identity_token": "nope"}),
)
self.assertEqual(403, bad_status)
def test_attribute_requires_both_fields(self) -> None:
status, _ = dispatch(
self.orch, "POST", "/attribute", _body({"source_ip": "10.243.0.5"})
)
self.assertEqual(400, status)
def test_delete(self) -> None:
_, reg = dispatch(
self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.1"})
)
bid = reg["bottle_id"]
status, payload = dispatch(self.orch, "DELETE", f"/bottles/{bid}", b"")
self.assertEqual(200, status)
self.assertEqual(True, payload["torn_down"])
self.assertEqual([], self.orch.registry.all())
def test_delete_missing_404(self) -> None:
status, _ = dispatch(self.orch, "DELETE", "/bottles/ghost", b"")
self.assertEqual(404, status)
def test_unknown_route_404(self) -> None:
status, _ = dispatch(self.orch, "GET", "/nope", b"")
self.assertEqual(404, status)
def test_trailing_slash_normalized(self) -> None:
status, _ = dispatch(self.orch, "GET", "/health/", b"")
self.assertEqual(200, status)
def test_sidecar_status_unconfigured(self) -> None:
status, payload = dispatch(self.orch, "GET", "/sidecar", b"")
self.assertEqual(200, status)
self.assertEqual(False, payload["configured"])
class TestServerRoundTrip(unittest.TestCase):
def test_http_register_health_attribute(self) -> None:
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
orch = _orchestrator(Path(tmp.name) / "r.db")
server = make_server(orch, "127.0.0.1", 0)
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()
@@ -1,100 +0,0 @@
"""Unit tests for the Docker launch broker (PRD 0070). Docker is mocked."""
from __future__ import annotations
import secrets
import unittest
from unittest.mock import Mock, patch
from bot_bottle.orchestrator.broker import (
BrokerAuthError,
LaunchRequest,
sign_request,
)
from bot_bottle.orchestrator.docker_broker import (
BOTTLE_ID_LABEL,
DockerBroker,
DockerBrokerError,
container_name,
rm_argv,
run_argv,
)
class TestArgv(unittest.TestCase):
def test_run_argv_uses_only_static_fields(self) -> None:
req = LaunchRequest(op="launch", bottle_id="b1", image_ref="busybox")
argv = run_argv(req)
self.assertEqual(["docker", "run"], argv[:2])
self.assertIn("--name", argv)
self.assertIn(container_name("b1"), argv)
self.assertIn(f"{BOTTLE_ID_LABEL}=b1", argv)
self.assertEqual("busybox", argv[-1]) # image is the terminal arg
def test_rm_argv(self) -> None:
req = LaunchRequest(op="teardown", bottle_id="b1")
self.assertEqual(["docker", "rm", "--force", container_name("b1")], rm_argv(req))
def test_container_name_is_prefixed(self) -> None:
name = container_name("b1")
self.assertTrue(name.startswith("bot-bottle-orch-"))
self.assertTrue(name.endswith("b1"))
class TestDockerBroker(unittest.TestCase):
def setUp(self) -> None:
self.secret = secrets.token_bytes(16)
self.broker = DockerBroker(self.secret)
def _submit(self, req: LaunchRequest) -> None:
self.broker.submit(sign_request(req, self.secret))
def test_launch_invokes_docker_run(self) -> None:
req = LaunchRequest(op="launch", bottle_id="b1", image_ref="busybox")
with patch.object(self.broker, "_docker", return_value=Mock(returncode=0, stderr="")) as m:
self._submit(req)
m.assert_called_once()
self.assertEqual(run_argv(req), m.call_args.args[0])
def test_launch_without_image_raises_and_skips_docker(self) -> None:
req = LaunchRequest(op="launch", bottle_id="b1", image_ref="")
with patch.object(self.broker, "_docker") as m:
with self.assertRaises(DockerBrokerError):
self._submit(req)
m.assert_not_called()
def test_launch_docker_failure_raises(self) -> None:
req = LaunchRequest(op="launch", bottle_id="b1", image_ref="busybox")
with patch.object(self.broker, "_docker", return_value=Mock(returncode=1, stderr="boom")):
with self.assertRaises(DockerBrokerError):
self._submit(req)
def test_teardown_invokes_docker_rm(self) -> None:
req = LaunchRequest(op="teardown", bottle_id="b1")
with patch.object(self.broker, "_docker", return_value=Mock(returncode=0, stderr="")) as m:
self._submit(req)
self.assertEqual(rm_argv(req), m.call_args.args[0])
def test_teardown_is_idempotent_on_missing_container(self) -> None:
req = LaunchRequest(op="teardown", bottle_id="b1")
absent = Mock(returncode=1, stderr="Error: No such container: bot-bottle-orch-b1")
with patch.object(self.broker, "_docker", return_value=absent):
self._submit(req) # must not raise
def test_teardown_other_failure_raises(self) -> None:
req = LaunchRequest(op="teardown", bottle_id="b1")
with patch.object(self.broker, "_docker", return_value=Mock(returncode=1, stderr="daemon down")):
with self.assertRaises(DockerBrokerError):
self._submit(req)
def test_forged_token_never_touches_docker(self) -> None:
req = LaunchRequest(op="launch", bottle_id="b1", image_ref="busybox")
forged = sign_request(req, secrets.token_bytes(16))
with patch.object(self.broker, "_docker") as m:
with self.assertRaises(BrokerAuthError):
self.broker.submit(forged)
m.assert_not_called()
if __name__ == "__main__":
unittest.main()
-102
View File
@@ -1,102 +0,0 @@
"""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()
-116
View File
@@ -1,116 +0,0 @@
"""Unit tests for the Orchestrator launch lifecycle (PRD 0070)."""
from __future__ import annotations
import secrets
import tempfile
import unittest
from pathlib import Path
from bot_bottle.orchestrator.broker import LaunchBroker, LaunchRequest, StubBroker
from bot_bottle.orchestrator.registry import RegistryStore
from bot_bottle.orchestrator.service import Orchestrator
from bot_bottle.orchestrator.sidecar import Sidecar
class _FailingBroker(LaunchBroker):
"""Verifies the token like any broker, then fails the launch — to
exercise the orchestrator's registry rollback."""
def _launch(self, req: LaunchRequest) -> None:
raise RuntimeError("launch failed")
def _teardown(self, req: LaunchRequest) -> None:
pass
class _FakeSidecar(Sidecar):
"""In-memory sidecar for wiring tests."""
def __init__(self) -> None:
self.name = "fake-sidecar"
self.ensured = 0
self.built = 0
self._running = False
def ensure_built(self) -> None:
self.built += 1
def ensure_running(self) -> None:
self.ensured += 1
self._running = True
def is_running(self) -> bool:
return self._running
def stop(self) -> None:
self._running = False
class TestOrchestrator(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.secret = secrets.token_bytes(16)
self.store = RegistryStore(Path(self._tmp.name) / "r.db")
self.store.migrate()
self.broker = StubBroker(self.secret)
self.orch = Orchestrator(self.store, self.broker, self.secret)
def tearDown(self) -> None:
self._tmp.cleanup()
def test_launch_registers_and_brokers_signed_request(self) -> None:
rec = self.orch.launch_bottle("10.243.0.1", image_ref="sha256:abc", slot=2)
self.assertIsNotNone(self.store.get(rec.bottle_id))
self.assertEqual(1, len(self.broker.launched))
req = self.broker.launched[0]
self.assertEqual("launch", req.op)
self.assertEqual(rec.bottle_id, req.bottle_id)
self.assertEqual("10.243.0.1", req.source_ip)
self.assertEqual("sha256:abc", req.image_ref)
self.assertEqual(2, req.slot)
def test_launch_then_attribute(self) -> None:
rec = self.orch.launch_bottle("10.243.0.3")
got = self.orch.attribute("10.243.0.3", rec.identity_token)
assert got is not None
self.assertEqual(rec.bottle_id, got.bottle_id)
self.assertIsNone(self.orch.attribute("10.243.0.3", "wrong-token"))
def test_teardown_brokers_and_deregisters(self) -> None:
rec = self.orch.launch_bottle("10.243.0.1")
self.assertTrue(self.orch.teardown_bottle(rec.bottle_id))
self.assertIsNone(self.store.get(rec.bottle_id))
self.assertEqual(["teardown"], [r.op for r in self.broker.torn_down])
def test_teardown_unknown_is_false(self) -> None:
self.assertFalse(self.orch.teardown_bottle("ghost"))
def test_launch_rolls_back_registry_on_broker_failure(self) -> None:
orch = Orchestrator(self.store, _FailingBroker(self.secret), self.secret)
with self.assertRaises(RuntimeError):
orch.launch_bottle("10.243.0.9")
self.assertEqual([], self.store.all()) # no orphan
def test_sidecar_unconfigured_by_default(self) -> None:
self.assertEqual({"configured": False}, self.orch.sidecar_status())
self.orch.ensure_sidecar() # no-op, must not raise
def test_sidecar_wired_and_ensured(self) -> None:
sc = _FakeSidecar()
orch = Orchestrator(self.store, self.broker, self.secret, sidecar=sc)
self.assertEqual(
{"configured": True, "name": "fake-sidecar", "running": False},
orch.sidecar_status(),
)
orch.ensure_sidecar()
self.assertEqual(1, sc.built) # ensure_sidecar builds first,
self.assertEqual(1, sc.ensured) # then runs
self.assertEqual(
{"configured": True, "name": "fake-sidecar", "running": True},
orch.sidecar_status(),
)
if __name__ == "__main__":
unittest.main()
-129
View File
@@ -1,129 +0,0 @@
"""Unit tests for the consolidated Docker sidecar (PRD 0070). Docker mocked."""
from __future__ import annotations
import unittest
from unittest.mock import Mock, patch
from bot_bottle.orchestrator.sidecar import (
SIDECAR_NAME,
DockerSidecar,
SidecarError,
)
_RUN_DOCKER = "bot_bottle.orchestrator.sidecar.run_docker"
def _proc(returncode: int = 0, stdout: str = "", stderr: str = "") -> Mock:
return Mock(returncode=returncode, stdout=stdout, stderr=stderr)
class TestDockerSidecar(unittest.TestCase):
def setUp(self) -> None:
self.sc = DockerSidecar("bot-bottle-sidecars:latest")
def test_default_name(self) -> None:
self.assertEqual(SIDECAR_NAME, self.sc.name)
def test_is_running_reads_docker_ps(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(stdout=self.sc.name + "\n")):
self.assertTrue(self.sc.is_running())
with patch(_RUN_DOCKER, return_value=_proc(stdout="")):
self.assertFalse(self.sc.is_running())
def test_ensure_running_is_noop_when_already_up(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(stdout=self.sc.name)) as m:
self.sc.ensure_running()
# Only the is_running() ps probe — no rm / run.
self.assertEqual(1, m.call_count)
def test_ensure_running_starts_the_singleton_when_absent(self) -> None:
calls: list[list[str]] = []
def fake(argv: list[str]) -> Mock:
calls.append(argv)
return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc()
with patch(_RUN_DOCKER, side_effect=fake):
self.sc.ensure_running()
runs = [c for c in calls if c[:2] == ["docker", "run"]]
self.assertEqual(1, len(runs))
self.assertIn(self.sc.name, runs[0])
self.assertIn("bot-bottle-sidecars:latest", runs[0])
def test_ensure_running_raises_on_docker_failure(self) -> None:
def fake(argv: list[str]) -> Mock:
if argv[:2] == ["docker", "ps"]:
return _proc(stdout="")
if argv[:2] == ["docker", "run"]:
return _proc(returncode=1, stderr="boom")
return _proc()
with patch(_RUN_DOCKER, side_effect=fake):
with self.assertRaises(SidecarError):
self.sc.ensure_running()
def test_stop_is_idempotent_on_missing(self) -> None:
absent = _proc(returncode=1, stderr="Error: No such container: x")
with patch(_RUN_DOCKER, return_value=absent):
self.sc.stop() # must not raise
def test_stop_raises_on_other_failure(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(returncode=1, stderr="daemon down")):
with self.assertRaises(SidecarError):
self.sc.stop()
class TestDockerSidecarBuild(unittest.TestCase):
def setUp(self) -> None:
self.sc = DockerSidecar() # defaults to the real bundle image + dockerfile
def test_image_exists_reads_docker_inspect(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(returncode=0)):
self.assertTrue(self.sc.image_exists())
with patch(_RUN_DOCKER, return_value=_proc(returncode=1)):
self.assertFalse(self.sc.image_exists())
def test_ensure_built_is_noop_when_image_present(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(returncode=0)) as m:
self.sc.ensure_built()
self.assertEqual(1, m.call_count) # only the image-inspect probe
def test_ensure_built_builds_from_dockerfile_when_missing(self) -> None:
calls: list[list[str]] = []
def fake(argv: list[str]) -> Mock:
calls.append(argv)
if argv[:3] == ["docker", "image", "inspect"]:
return _proc(returncode=1) # missing
return _proc()
with patch(_RUN_DOCKER, side_effect=fake):
self.sc.ensure_built()
builds = [c for c in calls if c[:2] == ["docker", "build"]]
self.assertEqual(1, len(builds))
self.assertIn(self.sc.image_ref, builds[0])
self.assertIn("-f", builds[0])
self.assertTrue(any(a.endswith("Dockerfile.sidecars") for a in builds[0]))
def test_ensure_built_noop_when_no_dockerfile(self) -> None:
sc = DockerSidecar("busybox", dockerfile=None)
with patch(_RUN_DOCKER) as m:
sc.ensure_built()
m.assert_not_called()
def test_ensure_built_raises_on_build_failure(self) -> None:
def fake(argv: list[str]) -> Mock:
if argv[:3] == ["docker", "image", "inspect"]:
return _proc(returncode=1)
return _proc(returncode=1, stderr="build boom")
with patch(_RUN_DOCKER, side_effect=fake):
with self.assertRaises(SidecarError):
self.sc.ensure_built()
if __name__ == "__main__":
unittest.main()
+44 -5
View File
@@ -8,8 +8,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from bot_bottle import supervise from bot_bottle import supervise
from bot_bottle.paths import host_db_path 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.audit_store import AuditStore
from bot_bottle.queue_store import QueueStore from bot_bottle.queue_store import QueueStore
from bot_bottle.supervise import ( from bot_bottle.supervise import (
@@ -22,6 +21,7 @@ from bot_bottle.supervise import (
TOOL_EGRESS_ALLOW, TOOL_EGRESS_ALLOW,
TOOL_GITLEAKS_ALLOW, TOOL_GITLEAKS_ALLOW,
archive_proposal, archive_proposal,
host_db_path,
list_pending_proposals, list_pending_proposals,
read_audit_entries, read_audit_entries,
read_proposal, read_proposal,
@@ -123,7 +123,20 @@ class TestQueueIO(unittest.TestCase):
self._tmp.cleanup() self._tmp.cleanup()
def _patch_home(self, fake_home: Path): def _patch_home(self, fake_home: Path):
return use_bottle_root(fake_home / ".bot-bottle") 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
def test_write_and_read_proposal(self): def test_write_and_read_proposal(self):
p = _proposal() p = _proposal()
@@ -227,7 +240,20 @@ class TestAuditLog(unittest.TestCase):
self._tmp.cleanup() self._tmp.cleanup()
def _patch_home(self, fake_home: Path): def _patch_home(self, fake_home: Path):
return use_bottle_root(fake_home / ".bot-bottle") 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
def test_write_then_read_single_entry(self): def test_write_then_read_single_entry(self):
e = AuditEntry( e = AuditEntry(
@@ -374,7 +400,20 @@ class TestSupervisePrepare(unittest.TestCase):
self._tmp.cleanup() self._tmp.cleanup()
def _patch_home(self, fake_home: Path): def _patch_home(self, fake_home: Path):
return use_bottle_root(fake_home / ".bot-bottle") 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
def test_prepare_creates_queue(self): def test_prepare_creates_queue(self):
plan = _StubSupervise().prepare("dev", self.stage_dir) plan = _StubSupervise().prepare("dev", self.stage_dir)
+16 -3
View File
@@ -12,7 +12,7 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from bot_bottle import supervise from bot_bottle import supervise
from tests.unit import use_bottle_root from bot_bottle import supervise_types as sv_types
from bot_bottle.audit_store import AuditStore from bot_bottle.audit_store import AuditStore
from bot_bottle.cli import supervise as supervise_cli from bot_bottle.cli import supervise as supervise_cli
from bot_bottle.queue_store import QueueStore from bot_bottle.queue_store import QueueStore
@@ -51,11 +51,24 @@ def _proposal(slug: str = "dev", tool: str = TOOL_EGRESS_ALLOW) -> Proposal:
class _FakeHomeMixin: class _FakeHomeMixin:
"""Point bot_bottle_root at a temp dir (via BOT_BOTTLE_ROOT) for the test.""" """Patch supervise.bot_bottle_root to a temp dir for the test."""
def _setup_fake_home(self): def _setup_fake_home(self):
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-test.") self._tmp = tempfile.TemporaryDirectory(prefix="supervise-test.")
self._restore_home = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") 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
QueueStore("").migrate() QueueStore("").migrate()
AuditStore().migrate() AuditStore().migrate()
+12 -11
View File
@@ -11,13 +11,12 @@ from __future__ import annotations
import contextlib import contextlib
import io import io
import os
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
from tests.unit import use_bottle_root from bot_bottle import supervise
from bot_bottle.cli import supervise as supervise_cli from bot_bottle.cli import supervise as supervise_cli
from bot_bottle.log import Die, die from bot_bottle.log import Die, die
@@ -40,16 +39,18 @@ class TestDieCarriesMessage(unittest.TestCase):
class _FakeHomeMixin: class _FakeHomeMixin:
"""Point bot_bottle_root (what _write_crash_log resolves through) at a """Point supervise.bot_bottle_root (what _write_crash_log resolves
temp dir so the crash log doesn't touch the real ~/.bot-bottle.""" through) at a temp dir so the crash log doesn't touch the real
~/.bot-bottle."""
def _setup_fake_home(self): def _setup_fake_home(self):
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-crash-test.") self._tmp = tempfile.TemporaryDirectory(prefix="supervise-crash-test.")
self._orig_root = supervise.bot_bottle_root
self._root = Path(self._tmp.name) / ".bot-bottle" self._root = Path(self._tmp.name) / ".bot-bottle"
self._restore_root = use_bottle_root(self._root) supervise.bot_bottle_root = lambda: self._root # type: ignore[assignment]
def _teardown_fake_home(self): def _teardown_fake_home(self):
self._restore_root() supervise.bot_bottle_root = self._orig_root # type: ignore[assignment]
self._tmp.cleanup() self._tmp.cleanup()
@@ -126,11 +127,11 @@ class TestWriteCrashLog(_FakeHomeMixin, unittest.TestCase):
# OSError and the helper must fall back to a tempfile. # OSError and the helper must fall back to a tempfile.
bad = Path(self._tmp.name) / "not-a-dir" bad = Path(self._tmp.name) / "not-a-dir"
bad.write_text("x") bad.write_text("x")
with mock.patch.dict(os.environ, {"BOT_BOTTLE_ROOT": str(bad)}): supervise.bot_bottle_root = lambda: bad # type: ignore[assignment]
try: try:
raise RuntimeError("explode2") raise RuntimeError("explode2")
except RuntimeError as e: except RuntimeError as e:
path = supervise_cli._write_crash_log(e) path = supervise_cli._write_crash_log(e)
self.assertTrue(path.exists()) self.assertTrue(path.exists())
self.assertIn("explode2", path.read_text()) self.assertIn("explode2", path.read_text())
+1 -2
View File
@@ -12,7 +12,6 @@ from unittest.mock import patch
from bot_bottle import supervise from bot_bottle import supervise
from bot_bottle.audit_store import AuditStore 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.queue_store import QueueStore
from bot_bottle.supervise import ( from bot_bottle.supervise import (
AuditEntry, AuditEntry,
@@ -40,7 +39,7 @@ def _proposal() -> Proposal:
class TestPathHelpers(unittest.TestCase): class TestPathHelpers(unittest.TestCase):
def test_bot_bottle_root(self) -> None: def test_bot_bottle_root(self) -> None:
self.assertTrue(str(bot_bottle_root()).endswith(".bot-bottle")) self.assertTrue(str(supervise.bot_bottle_root()).endswith(".bot-bottle"))
class TestReadMalformed(unittest.TestCase): class TestReadMalformed(unittest.TestCase):
+19 -3
View File
@@ -10,8 +10,6 @@ import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from tests.unit import use_bottle_root
# The server module loads `supervise` via same-directory import inside # The server module loads `supervise` via same-directory import inside
# the container (Dockerfile.supervise WORKDIRs into /app). For tests # the container (Dockerfile.supervise WORKDIRs into /app). For tests
@@ -21,8 +19,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "bot_bott
import supervise as _sv # noqa: E402 # type: ignore import supervise as _sv # noqa: E402 # type: ignore
import queue_store as _qs # noqa: E402 # type: ignore import queue_store as _qs # noqa: E402 # type: ignore
import audit_store as _as # 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_server # noqa: E402
from bot_bottle import supervise_types as svt_pkg # noqa: E402
from bot_bottle.supervise_server import ( from bot_bottle.supervise_server import (
ERR_INTERNAL, ERR_INTERNAL,
ERR_INVALID_PARAMS, ERR_INVALID_PARAMS,
@@ -279,7 +279,23 @@ class TestHandleToolsCall(unittest.TestCase):
self._tmp.cleanup() self._tmp.cleanup()
def _patch_home(self, fake_home: Path): def _patch_home(self, fake_home: Path):
return use_bottle_root(fake_home / ".bot-bottle") 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
def _respond_when_proposal_appears(self, status: str, notes: str = "") -> threading.Thread: def _respond_when_proposal_appears(self, status: str, notes: str = "") -> threading.Thread:
"""Background thread: poll the queue for a fresh proposal, write a """Background thread: poll the queue for a fresh proposal, write a