Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| adc03e0e26 | |||
| 9325745ad4 |
@@ -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
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# bot-bottle
|
# bot-bottle
|
||||||
|
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||||
[](https://coverage.readthedocs.io/)
|
[](https://coverage.readthedocs.io/)
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
|
[](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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,30 +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).
|
|
||||||
* `control_plane` — the HTTP control-plane RPC (register / deregister /
|
|
||||||
list / attribute / health), with live reload.
|
|
||||||
|
|
||||||
Launch/teardown, the launch broker, and the actual egress/git/supervise
|
|
||||||
data plane land in later slices once this core is proven (see the PRD's
|
|
||||||
sequencing: plain-process dev-harness -> docker orchestrator -> firecracker).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .registry import BottleRecord, RegistryStore, new_identity_token
|
|
||||||
from .control_plane import ControlPlaneServer, dispatch, make_server
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"BottleRecord",
|
|
||||||
"RegistryStore",
|
|
||||||
"new_identity_token",
|
|
||||||
"ControlPlaneServer",
|
|
||||||
"dispatch",
|
|
||||||
"make_server",
|
|
||||||
]
|
|
||||||
@@ -1,52 +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
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .. import log
|
|
||||||
from .control_plane import make_server
|
|
||||||
from .registry import RegistryStore, default_db_path
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
"""Parse args, migrate the registry, and serve the control plane."""
|
|
||||||
parser = argparse.ArgumentParser(prog="bot_bottle.orchestrator")
|
|
||||||
parser.add_argument("--host", default="127.0.0.1", help="bind address")
|
|
||||||
parser.add_argument("--port", type=int, default=8080, help="bind port (0 = ephemeral)")
|
|
||||||
parser.add_argument(
|
|
||||||
"--db", type=Path, default=None,
|
|
||||||
help=f"registry DB path (default: {default_db_path()})",
|
|
||||||
)
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
registry = RegistryStore(args.db)
|
|
||||||
registry.migrate()
|
|
||||||
|
|
||||||
server = make_server(registry, host=args.host, port=args.port)
|
|
||||||
bound_host, bound_port = server.server_address[0], server.server_address[1]
|
|
||||||
log.info(
|
|
||||||
"orchestrator control plane listening",
|
|
||||||
context={"host": bound_host, "port": bound_port, "db": str(registry.db_path)},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
server.serve_forever()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
log.info("orchestrator shutting down")
|
|
||||||
finally:
|
|
||||||
server.server_close()
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,154 +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). Endpoints mutate the live
|
|
||||||
registry, so register/deregister are the *live-reload* path — no restart:
|
|
||||||
|
|
||||||
GET /health -> 200 {"status": "ok"}
|
|
||||||
GET /bottles -> 200 {"bottles": [ <redacted record>, ... ]}
|
|
||||||
POST /bottles -> 201 {"bottle_id", "identity_token"}
|
|
||||||
body: {"source_ip", ["bottle_id"], ["metadata"]}
|
|
||||||
DELETE /bottles/<bottle_id> -> 200 {"deregistered": true} | 404
|
|
||||||
POST /attribute -> 200 {"bottle_id"} | 403
|
|
||||||
body: {"source_ip", "identity_token"}
|
|
||||||
|
|
||||||
Routing/handling is the pure function `dispatch()` so it is unit-testable
|
|
||||||
without a socket; `Handler` / `ControlPlaneServer` / `make_server` are a
|
|
||||||
thin stdlib adapter around it.
|
|
||||||
|
|
||||||
Note the listing redacts identity tokens — they are never returned except
|
|
||||||
once, to the caller that registers the bottle.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import http.server
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import socketserver
|
|
||||||
import typing
|
|
||||||
from urllib.parse import urlsplit
|
|
||||||
|
|
||||||
from .registry import RegistryStore
|
|
||||||
|
|
||||||
# JSON body payload type (parsed request / rendered response).
|
|
||||||
Json = dict[str, object]
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_object(body: bytes) -> Json:
|
|
||||||
"""Parse a JSON object body. Raises ValueError for non-objects / bad JSON."""
|
|
||||||
if not body:
|
|
||||||
return {}
|
|
||||||
obj = json.loads(body) # raises json.JSONDecodeError (a ValueError)
|
|
||||||
if not isinstance(obj, dict):
|
|
||||||
raise ValueError("request body must be a JSON object")
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
def dispatch( # pylint: disable=too-many-return-statements
|
|
||||||
registry: RegistryStore, method: str, path: str, body: bytes
|
|
||||||
) -> tuple[int, Json]:
|
|
||||||
"""Route one control-plane request to a (status, payload) pair. Pure —
|
|
||||||
no I/O beyond the registry — so it is fully testable without a socket."""
|
|
||||||
route = urlsplit(path).path.rstrip("/") or "/"
|
|
||||||
|
|
||||||
if method == "GET" and route == "/health":
|
|
||||||
return 200, {"status": "ok"}
|
|
||||||
|
|
||||||
if method == "GET" and route == "/bottles":
|
|
||||||
return 200, {"bottles": [r.redacted() for r in registry.all()]}
|
|
||||||
|
|
||||||
if method == "POST" and route == "/bottles":
|
|
||||||
try:
|
|
||||||
data = _parse_json_object(body)
|
|
||||||
except ValueError as e:
|
|
||||||
return 400, {"error": f"invalid JSON: {e}"}
|
|
||||||
source_ip = data.get("source_ip")
|
|
||||||
if not isinstance(source_ip, str) or not source_ip:
|
|
||||||
return 400, {"error": "source_ip (string) is required"}
|
|
||||||
bottle_id = data.get("bottle_id")
|
|
||||||
metadata = data.get("metadata")
|
|
||||||
rec = registry.register(
|
|
||||||
source_ip,
|
|
||||||
bottle_id=bottle_id if isinstance(bottle_id, str) else None,
|
|
||||||
metadata=metadata if isinstance(metadata, str) else "",
|
|
||||||
)
|
|
||||||
return 201, {"bottle_id": rec.bottle_id, "identity_token": rec.identity_token}
|
|
||||||
|
|
||||||
if method == "DELETE" and route.startswith("/bottles/"):
|
|
||||||
bottle_id = route[len("/bottles/"):]
|
|
||||||
if registry.deregister(bottle_id):
|
|
||||||
return 200, {"deregistered": True}
|
|
||||||
return 404, {"error": "no such bottle"}
|
|
||||||
|
|
||||||
if method == "POST" and route == "/attribute":
|
|
||||||
try:
|
|
||||||
data = _parse_json_object(body)
|
|
||||||
except ValueError as e:
|
|
||||||
return 400, {"error": f"invalid JSON: {e}"}
|
|
||||||
source_ip = data.get("source_ip")
|
|
||||||
token = data.get("identity_token")
|
|
||||||
if not isinstance(source_ip, str) or not isinstance(token, str):
|
|
||||||
return 400, {"error": "source_ip and identity_token (strings) required"}
|
|
||||||
rec = registry.attribute(source_ip, token)
|
|
||||||
if rec is None:
|
|
||||||
return 403, {"error": "unattributed"}
|
|
||||||
return 200, {"bottle_id": rec.bottle_id}
|
|
||||||
|
|
||||||
return 404, {"error": "not found"}
|
|
||||||
|
|
||||||
|
|
||||||
class Handler(http.server.BaseHTTPRequestHandler):
|
|
||||||
"""Thin stdlib adapter: read the body, call `dispatch`, write JSON."""
|
|
||||||
|
|
||||||
# Quiet by default (the orchestrator has its own logging); opt back into
|
|
||||||
# stdlib access logging with BOT_BOTTLE_ORCHESTRATOR_DEBUG.
|
|
||||||
def log_message(self, format: str, *args: typing.Any) -> None: # noqa: A002
|
|
||||||
if os.environ.get("BOT_BOTTLE_ORCHESTRATOR_DEBUG"):
|
|
||||||
super().log_message(format, *args)
|
|
||||||
|
|
||||||
def _serve(self, method: str) -> None:
|
|
||||||
"""Read the request body, dispatch it, and write the JSON reply."""
|
|
||||||
server = self.server
|
|
||||||
assert isinstance(server, ControlPlaneServer)
|
|
||||||
length = int(self.headers.get("Content-Length") or 0)
|
|
||||||
body = self.rfile.read(length) if length > 0 else b""
|
|
||||||
status, payload = dispatch(server.registry, method, self.path, body)
|
|
||||||
data = json.dumps(payload).encode()
|
|
||||||
self.send_response(status)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.send_header("Content-Length", str(len(data)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(data)
|
|
||||||
|
|
||||||
def do_GET(self) -> None:
|
|
||||||
self._serve("GET")
|
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
|
||||||
self._serve("POST")
|
|
||||||
|
|
||||||
def do_DELETE(self) -> None:
|
|
||||||
self._serve("DELETE")
|
|
||||||
|
|
||||||
|
|
||||||
class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|
||||||
"""Threading HTTP server that carries the registry for its handlers."""
|
|
||||||
|
|
||||||
daemon_threads = True
|
|
||||||
allow_reuse_address = True
|
|
||||||
|
|
||||||
def __init__(self, address: tuple[str, int], registry: RegistryStore) -> None:
|
|
||||||
self.registry = registry
|
|
||||||
super().__init__(address, Handler)
|
|
||||||
|
|
||||||
|
|
||||||
def make_server(
|
|
||||||
registry: RegistryStore, host: str = "127.0.0.1", port: int = 0
|
|
||||||
) -> ControlPlaneServer:
|
|
||||||
"""Build (but do not start) a control-plane server. `port=0` binds an
|
|
||||||
ephemeral port — read `server.server_address` for the actual one."""
|
|
||||||
return ControlPlaneServer((host, port), registry)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["dispatch", "Handler", "ControlPlaneServer", "make_server", "Json"]
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -325,50 +258,18 @@ orchestrator becomes a VM. Decouple the two risks instead:
|
|||||||
secret handling) is proven with fast iteration — *then* wrap that exact
|
secret handling) is proven with fast iteration — *then* wrap that exact
|
||||||
service in the VM and solve wiring separately.
|
service in the VM and solve wiring separately.
|
||||||
|
|
||||||
### Slices
|
Backend order (cheapest proof → hardest → last):
|
||||||
|
|
||||||
Built bottom-up as a stack of small PRs off this one (status: **1–5
|
1. **Docker orchestrator** — nearly free (the sidecar bundle is already
|
||||||
implemented** in #352 → #356 → #357 → #358 → #360):
|
containers; collapse N bundles into one persistent container). Proves
|
||||||
|
consolidation + the `BottleBackend` seam with the least moving parts.
|
||||||
1. **Dev-harness core** ✅ — SQLite registry (runtime state) + fail-closed
|
2. **Firecracker orchestrator** — the real work: the shim + VM-to-VM
|
||||||
attribution (source IP + identity token) + the HTTP control plane.
|
|
||||||
2. **Launch lifecycle + broker contract** ✅ — `launch_bottle` /
|
|
||||||
`teardown_bottle` + the signed, structured `LaunchBroker` request
|
|
||||||
(HS256 JWT, static ids/flags only, provenance-verified, fail-closed),
|
|
||||||
with a stub broker for the harness and registry rollback on failure.
|
|
||||||
3. **Docker launch broker** ✅ — the first real broker: `docker run` / `rm`
|
|
||||||
per bottle, built only from the request's static fields; proves the
|
|
||||||
`BottleBackend` seam on the cheapest backend.
|
|
||||||
|
|
||||||
**The consolidated-sidecar arc** (the core consolidation win — one
|
|
||||||
persistent sidecar shared by all bottles instead of one per bottle):
|
|
||||||
|
|
||||||
4. **Sidecar — lifecycle** ✅ — the idempotent-singleton `Sidecar` /
|
|
||||||
`DockerSidecar` (ensure-running is a no-op when already up, stop is
|
|
||||||
idempotent); one container per host.
|
|
||||||
5. **Sidecar — build the real bundle image** ✅ — the orchestrator builds
|
|
||||||
the `bot-bottle-sidecars` bundle from `Dockerfile.sidecars` when missing
|
|
||||||
and launches *that* (not a placeholder) as the singleton.
|
|
||||||
6. **Sidecar — source-IP-keyed multi-tenant config** — the functional
|
|
||||||
core: the per-bottle CA / egress routes / git-gate keys / policy, today
|
|
||||||
baked per bottle, become **multi-tenant and selected by the verified
|
|
||||||
source IP**, with per-bottle add/remove (live reload) on launch/teardown
|
|
||||||
through the control plane. Until this lands, one shared instance can't
|
|
||||||
serve multiple bottles' distinct policies.
|
|
||||||
7. **Sidecar — route agent bottles through it** — wire each agent bottle's
|
|
||||||
egress/git to the shared sidecar (DNAT / network) instead of a per-bottle
|
|
||||||
bundle.
|
|
||||||
|
|
||||||
**Remaining backends** (docker done above; the rest against the proven
|
|
||||||
dev-harness):
|
|
||||||
|
|
||||||
8. **Firecracker broker + sidecar** — the real work: the shim + VM-to-VM
|
|
||||||
routing (host forwards `bbfcN` → orchestrator TAP; the nft table grows
|
routing (host forwards `bbfcN` → orchestrator TAP; the nft table grows
|
||||||
forward rules where today it drops all non-DNAT egress).
|
forward rules where today it drops all non-DNAT egress). Built against
|
||||||
9. **macOS (Apple container)** — last (container-to-container networking).
|
the dev-harness so the app logic is already proven.
|
||||||
|
3. **macOS (Apple container)** — last (container-to-container networking).
|
||||||
|
|
||||||
Backends land cheapest-first (docker → firecracker → macOS); keep the
|
Keep the sidecar **service one shared thing** throughout.
|
||||||
sidecar **service one shared thing** throughout.
|
|
||||||
|
|
||||||
## Non-goals
|
## Non-goals
|
||||||
|
|
||||||
@@ -396,38 +297,17 @@ 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
@@ -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}
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+3
-22
@@ -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
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,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]] = []
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -1,150 +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 tempfile
|
|
||||||
import threading
|
|
||||||
import unittest
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from bot_bottle.orchestrator.control_plane import dispatch, make_server
|
|
||||||
from bot_bottle.orchestrator.registry import RegistryStore
|
|
||||||
|
|
||||||
|
|
||||||
def _body(obj: object) -> bytes:
|
|
||||||
return json.dumps(obj).encode()
|
|
||||||
|
|
||||||
|
|
||||||
class TestDispatch(unittest.TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self._tmp = tempfile.TemporaryDirectory()
|
|
||||||
self.store = RegistryStore(Path(self._tmp.name) / "r.db")
|
|
||||||
self.store.migrate()
|
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
|
||||||
self._tmp.cleanup()
|
|
||||||
|
|
||||||
def test_health(self) -> None:
|
|
||||||
status, payload = dispatch(self.store, "GET", "/health", b"")
|
|
||||||
self.assertEqual(200, status)
|
|
||||||
self.assertEqual("ok", payload["status"])
|
|
||||||
|
|
||||||
def test_register_returns_id_and_token(self) -> None:
|
|
||||||
status, payload = dispatch(
|
|
||||||
self.store, "POST", "/bottles", _body({"source_ip": "10.243.0.1"})
|
|
||||||
)
|
|
||||||
self.assertEqual(201, status)
|
|
||||||
self.assertTrue(payload["bottle_id"])
|
|
||||||
self.assertTrue(payload["identity_token"])
|
|
||||||
|
|
||||||
def test_register_requires_source_ip(self) -> None:
|
|
||||||
status, _ = dispatch(self.store, "POST", "/bottles", _body({}))
|
|
||||||
self.assertEqual(400, status)
|
|
||||||
|
|
||||||
def test_register_rejects_bad_json(self) -> None:
|
|
||||||
status, _ = dispatch(self.store, "POST", "/bottles", b"{not json")
|
|
||||||
self.assertEqual(400, status)
|
|
||||||
|
|
||||||
def test_list_redacts_identity_token(self) -> None:
|
|
||||||
dispatch(self.store, "POST", "/bottles", _body({"source_ip": "10.243.0.1"}))
|
|
||||||
status, payload = dispatch(self.store, "GET", "/bottles", b"")
|
|
||||||
self.assertEqual(200, status)
|
|
||||||
bottles = payload["bottles"]
|
|
||||||
assert isinstance(bottles, list)
|
|
||||||
self.assertEqual(1, len(bottles))
|
|
||||||
first = bottles[0]
|
|
||||||
assert isinstance(first, dict)
|
|
||||||
self.assertNotIn("identity_token", first)
|
|
||||||
self.assertEqual("10.243.0.1", first["source_ip"])
|
|
||||||
|
|
||||||
def test_attribute_ok_and_forbidden(self) -> None:
|
|
||||||
_, reg = dispatch(
|
|
||||||
self.store, "POST", "/bottles", _body({"source_ip": "10.243.0.5"})
|
|
||||||
)
|
|
||||||
token = reg["identity_token"]
|
|
||||||
ok_status, ok = dispatch(
|
|
||||||
self.store, "POST", "/attribute",
|
|
||||||
_body({"source_ip": "10.243.0.5", "identity_token": token}),
|
|
||||||
)
|
|
||||||
self.assertEqual(200, ok_status)
|
|
||||||
self.assertEqual(reg["bottle_id"], ok["bottle_id"])
|
|
||||||
|
|
||||||
bad_status, _ = dispatch(
|
|
||||||
self.store, "POST", "/attribute",
|
|
||||||
_body({"source_ip": "10.243.0.5", "identity_token": "nope"}),
|
|
||||||
)
|
|
||||||
self.assertEqual(403, bad_status)
|
|
||||||
|
|
||||||
def test_attribute_requires_both_fields(self) -> None:
|
|
||||||
status, _ = dispatch(
|
|
||||||
self.store, "POST", "/attribute", _body({"source_ip": "10.243.0.5"})
|
|
||||||
)
|
|
||||||
self.assertEqual(400, status)
|
|
||||||
|
|
||||||
def test_delete(self) -> None:
|
|
||||||
_, reg = dispatch(
|
|
||||||
self.store, "POST", "/bottles", _body({"source_ip": "10.243.0.1"})
|
|
||||||
)
|
|
||||||
bid = reg["bottle_id"]
|
|
||||||
status, payload = dispatch(self.store, "DELETE", f"/bottles/{bid}", b"")
|
|
||||||
self.assertEqual(200, status)
|
|
||||||
self.assertEqual(True, payload["deregistered"])
|
|
||||||
self.assertEqual([], self.store.all())
|
|
||||||
|
|
||||||
def test_delete_missing_404(self) -> None:
|
|
||||||
status, _ = dispatch(self.store, "DELETE", "/bottles/ghost", b"")
|
|
||||||
self.assertEqual(404, status)
|
|
||||||
|
|
||||||
def test_unknown_route_404(self) -> None:
|
|
||||||
status, _ = dispatch(self.store, "GET", "/nope", b"")
|
|
||||||
self.assertEqual(404, status)
|
|
||||||
|
|
||||||
def test_trailing_slash_normalized(self) -> None:
|
|
||||||
status, _ = dispatch(self.store, "GET", "/health/", b"")
|
|
||||||
self.assertEqual(200, status)
|
|
||||||
|
|
||||||
|
|
||||||
class TestServerRoundTrip(unittest.TestCase):
|
|
||||||
def test_http_register_health_attribute(self) -> None:
|
|
||||||
tmp = tempfile.TemporaryDirectory()
|
|
||||||
self.addCleanup(tmp.cleanup)
|
|
||||||
store = RegistryStore(Path(tmp.name) / "r.db")
|
|
||||||
store.migrate()
|
|
||||||
server = make_server(store, "127.0.0.1", 0)
|
|
||||||
self.addCleanup(server.server_close)
|
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
self.addCleanup(server.shutdown)
|
|
||||||
|
|
||||||
host, port = server.server_address[0], server.server_address[1]
|
|
||||||
base = f"http://{host}:{port}"
|
|
||||||
|
|
||||||
reg = json.load(urllib.request.urlopen(
|
|
||||||
urllib.request.Request(
|
|
||||||
f"{base}/bottles", data=_body({"source_ip": "10.243.0.7"}),
|
|
||||||
method="POST", headers={"Content-Type": "application/json"},
|
|
||||||
), timeout=5,
|
|
||||||
))
|
|
||||||
self.assertTrue(reg["bottle_id"])
|
|
||||||
|
|
||||||
health = json.load(urllib.request.urlopen(f"{base}/health", timeout=5))
|
|
||||||
self.assertEqual("ok", health["status"])
|
|
||||||
|
|
||||||
attr = json.load(urllib.request.urlopen(
|
|
||||||
urllib.request.Request(
|
|
||||||
f"{base}/attribute",
|
|
||||||
data=_body({"source_ip": "10.243.0.7", "identity_token": reg["identity_token"]}),
|
|
||||||
method="POST", headers={"Content-Type": "application/json"},
|
|
||||||
), timeout=5,
|
|
||||||
))
|
|
||||||
self.assertEqual(reg["bottle_id"], attr["bottle_id"])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user