Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| add4c53f43 | |||
| 1a8d33a645 | |||
| e862b82322 |
@@ -61,6 +61,13 @@ class AgentProviderRuntime:
|
||||
prompt_mode: PromptMode
|
||||
bypass_args: tuple[str, ...]
|
||||
resume_args: tuple[str, ...]
|
||||
# argv run inside a throwaway container of a freshly built agent
|
||||
# image, right after `build_image()`, to catch a build that
|
||||
# exited 0 but produced a broken CLI (e.g. an npm
|
||||
# optionalDependencies fetch for a platform-native binary that
|
||||
# silently no-ops on a transient failure). Empty tuple skips the
|
||||
# check — not every provider has opted in yet.
|
||||
smoke_test: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -36,6 +36,7 @@ from contextlib import ExitStack, contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Callable, Generator
|
||||
|
||||
from ...agent_provider import runtime_for
|
||||
from ...egress import egress_resolve_token_values
|
||||
from ...git_gate import (
|
||||
provision_git_gate_dynamic_keys,
|
||||
@@ -111,6 +112,9 @@ def launch(
|
||||
plan.image, _REPO_DIR,
|
||||
dockerfile=plan.dockerfile_path,
|
||||
)
|
||||
docker_mod.verify_agent_image(
|
||||
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
|
||||
)
|
||||
|
||||
internal_network = network_mod.network_name_for_slug(plan.slug)
|
||||
egress_network = network_mod.network_egress_name_for_slug(plan.slug)
|
||||
|
||||
@@ -4,6 +4,7 @@ existence, and building images."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -88,6 +89,31 @@ def docker_exec_root(container: str, argv: list[str]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def docker_exec(container: str, argv: list[str], *, user: str = "") -> None:
|
||||
"""Run `docker exec` in the named container, dying with the
|
||||
command's own stderr on failure. Pass `user=\"0\"` to run as root."""
|
||||
cmd = ["docker", "exec"]
|
||||
if user:
|
||||
cmd += ["-u", user]
|
||||
cmd += [container, *argv]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"docker exec in {container} failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
|
||||
|
||||
def docker_cp(src: str, dest: str) -> None:
|
||||
"""Run `docker cp`, dying with the command's own stderr on failure."""
|
||||
result = subprocess.run(
|
||||
["docker", "cp", src, dest], capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(f"docker cp {src} -> {dest} failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}")
|
||||
|
||||
|
||||
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
@@ -108,15 +134,45 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||
|
||||
`dockerfile` is an optional path (relative to `context`, or
|
||||
absolute) for callers that need to build from a non-default
|
||||
Dockerfile in the same context — e.g. `Dockerfile.git-gate`."""
|
||||
Dockerfile in the same context — e.g. `Dockerfile.git-gate`.
|
||||
|
||||
Set `BOT_BOTTLE_NO_CACHE=1` (the `start --no-cache` flag) to force
|
||||
`--no-cache`. The npm/curl installers some provider Dockerfiles
|
||||
shell out to can silently no-op on a transient network failure —
|
||||
e.g. an `optionalDependencies` fetch for a platform-native binary —
|
||||
and Docker will then cache that broken layer indefinitely."""
|
||||
info(f"building image {ref} from {context} (layer cache keeps repeat builds fast)")
|
||||
args = ["docker", "build", "-t", ref]
|
||||
if os.environ.get("BOT_BOTTLE_NO_CACHE") == "1":
|
||||
args.append("--no-cache")
|
||||
if dockerfile:
|
||||
args.extend(["-f", dockerfile])
|
||||
args.append(context)
|
||||
subprocess.run(args, check=True)
|
||||
|
||||
|
||||
def verify_agent_image(image: str, argv: tuple[str, ...]) -> None:
|
||||
"""Run `argv` inside a throwaway container of a freshly built agent
|
||||
image and die loudly if it fails, instead of shipping an image
|
||||
whose CLI only breaks at first real use. No-op when the provider
|
||||
hasn't declared a smoke test (`AgentProviderRuntime.smoke_test`)."""
|
||||
if not argv:
|
||||
return
|
||||
result = subprocess.run(
|
||||
["docker", "run", "--rm", "--entrypoint", argv[0], image, *argv[1:]],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
die(
|
||||
f"agent image {image!r} failed its post-build smoke test "
|
||||
f"({' '.join(argv)}): {detail}\n"
|
||||
f"Try rebuilding from scratch: bot-bottle start --no-cache"
|
||||
)
|
||||
|
||||
|
||||
# def build_image_with_cwd(
|
||||
# derived: str,
|
||||
# base: str,
|
||||
|
||||
@@ -24,6 +24,7 @@ from contextlib import ExitStack, contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Callable, Generator
|
||||
|
||||
from ...agent_provider import runtime_for
|
||||
from ...bottle_state import (
|
||||
egress_state_dir,
|
||||
git_gate_state_dir,
|
||||
@@ -42,6 +43,7 @@ from ...git_gate import (
|
||||
from ...log import die, info, warn
|
||||
from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT
|
||||
from ...util import expand_tilde
|
||||
from ..docker import util as docker_mod
|
||||
from ..docker.egress import (
|
||||
EGRESS_CA_IN_CONTAINER,
|
||||
EGRESS_PORT,
|
||||
@@ -106,9 +108,9 @@ def launch(
|
||||
plan = _provision_git_gate_keys(plan)
|
||||
|
||||
sidecar_name = sidecar_container_name(plan.slug)
|
||||
_force_remove_container(sidecar_name)
|
||||
docker_mod.force_remove_container(sidecar_name)
|
||||
_start_sidecar_bundle(plan, sidecar_name, slot.host_ip)
|
||||
stack.callback(_force_remove_container, sidecar_name)
|
||||
stack.callback(docker_mod.force_remove_container, sidecar_name)
|
||||
_stage_git_gate(plan, sidecar_name)
|
||||
|
||||
plan = _stamp_agent_urls(plan, slot.host_ip)
|
||||
@@ -170,15 +172,18 @@ def _mint_certs(plan: FirecrackerBottlePlan) -> FirecrackerBottlePlan:
|
||||
|
||||
|
||||
def _build_agent_image(plan: FirecrackerBottlePlan) -> FirecrackerBottlePlan:
|
||||
_docker_build(SIDECAR_BUNDLE_IMAGE, _REPO_DIR, dockerfile=SIDECAR_BUNDLE_DOCKERFILE)
|
||||
docker_mod.build_image(SIDECAR_BUNDLE_IMAGE, _REPO_DIR, dockerfile=SIDECAR_BUNDLE_DOCKERFILE)
|
||||
committed = read_committed_image(plan.slug)
|
||||
if committed and _image_exists(committed):
|
||||
if committed and docker_mod.image_exists(committed):
|
||||
info(f"using committed image {committed!r}")
|
||||
return dataclasses.replace(
|
||||
plan,
|
||||
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
|
||||
)
|
||||
_docker_build(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
|
||||
docker_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
|
||||
docker_mod.verify_agent_image(
|
||||
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
|
||||
)
|
||||
return plan
|
||||
|
||||
|
||||
@@ -291,15 +296,15 @@ def _stage_git_gate(plan: FirecrackerBottlePlan, sidecar_name: str) -> None:
|
||||
gp = plan.git_gate_plan
|
||||
if not gp.upstreams:
|
||||
return
|
||||
_docker_exec(sidecar_name, [
|
||||
docker_mod.docker_exec(sidecar_name, [
|
||||
"mkdir", "-p",
|
||||
str(Path(GIT_GATE_HOOK_IN_CONTAINER).parent),
|
||||
GIT_GATE_CREDS_DIR_IN_CONTAINER, "/git",
|
||||
str(Path(_GIT_GATE_READY_FILE).parent),
|
||||
])
|
||||
for host_path, container_path in _git_gate_files(plan):
|
||||
_docker_cp(host_path, f"{sidecar_name}:{container_path}")
|
||||
_docker_exec(sidecar_name, [
|
||||
docker_mod.docker_cp(host_path, f"{sidecar_name}:{container_path}")
|
||||
docker_mod.docker_exec(sidecar_name, [
|
||||
"sh", "-c",
|
||||
f"chmod 755 {GIT_GATE_ENTRYPOINT_IN_CONTAINER} "
|
||||
f"{GIT_GATE_HOOK_IN_CONTAINER} {GIT_GATE_ACCESS_HOOK_IN_CONTAINER} && "
|
||||
@@ -355,50 +360,3 @@ def _agent_guest_env(plan: FirecrackerBottlePlan, host_ip: str) -> dict[str, str
|
||||
if value is not None:
|
||||
env[name] = value
|
||||
return env
|
||||
|
||||
|
||||
# --- docker helpers --------------------------------------------------
|
||||
|
||||
def _docker_build(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||
info(f"docker build {ref}")
|
||||
args = ["docker", "build", "-t", ref]
|
||||
if dockerfile:
|
||||
if not os.path.isabs(dockerfile):
|
||||
dockerfile = os.path.join(context, dockerfile)
|
||||
args += ["-f", dockerfile]
|
||||
args.append(context)
|
||||
result = subprocess.run(args, check=False)
|
||||
if result.returncode != 0:
|
||||
die(f"docker build for {ref!r} failed")
|
||||
|
||||
|
||||
def _image_exists(ref: str) -> bool:
|
||||
return subprocess.run(
|
||||
["docker", "image", "inspect", ref],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||
).returncode == 0
|
||||
|
||||
|
||||
def _force_remove_container(name: str) -> None:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||
)
|
||||
|
||||
|
||||
def _docker_exec(name: str, argv: list[str]) -> None:
|
||||
result = subprocess.run(
|
||||
["docker", "exec", name, *argv], capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(f"docker exec in {name} failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}")
|
||||
|
||||
|
||||
def _docker_cp(host_path: str, dest: str) -> None:
|
||||
result = subprocess.run(
|
||||
["docker", "cp", host_path, dest], capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(f"docker cp {host_path} -> {dest} failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}")
|
||||
|
||||
@@ -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:
|
||||
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
|
||||
isolates every VM. The pool parameters live in exactly one place —
|
||||
`netpool.defaults.env`, a plain KEY=VALUE file next to this module —
|
||||
and every consumer reads *that*: this module (below), the shell script
|
||||
(`scripts/firecracker-netpool.sh`), and the NixOS module
|
||||
(`nix/firecracker-netpool.nix`). A `BOT_BOTTLE_FC_*` env var of the
|
||||
same name always overrides the file, and the backend's fail-closed
|
||||
preflight derives from these accessors, so nothing can drift.
|
||||
isolates every VM. This module is the single source of truth for the
|
||||
pool parameters — the shell script (`scripts/firecracker-netpool.sh`),
|
||||
the NixOS module (`nix/firecracker-netpool.nix`), and the backend's
|
||||
fail-closed preflight all derive from these constants so they can't
|
||||
drift.
|
||||
|
||||
Topology (per slot i):
|
||||
* TAP ``bbfc{i}`` — no shared bridge, so no docker0 / virbr0 / cni0
|
||||
@@ -45,47 +43,18 @@ from typing import IO
|
||||
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
|
||||
# index stays well under that and is distinctive enough to grep for.
|
||||
IFACE_PREFIX = _cfg("BOT_BOTTLE_FC_IFACE_PREFIX")
|
||||
NFT_TABLE = _cfg("BOT_BOTTLE_FC_NFT_TABLE")
|
||||
IFACE_PREFIX = os.environ.get("BOT_BOTTLE_FC_IFACE_PREFIX", "bbfc")
|
||||
NFT_TABLE = "bot_bottle_fc"
|
||||
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
@@ -17,6 +17,7 @@ from contextlib import ExitStack, contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Callable, Generator
|
||||
|
||||
from ...agent_provider import runtime_for
|
||||
from ...bottle_state import (
|
||||
egress_state_dir,
|
||||
git_gate_state_dir,
|
||||
@@ -169,6 +170,9 @@ def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
||||
_REPO_DIR,
|
||||
dockerfile=plan.dockerfile_path,
|
||||
)
|
||||
container_mod.verify_agent_image(
|
||||
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
|
||||
)
|
||||
return plan
|
||||
|
||||
|
||||
|
||||
@@ -60,13 +60,21 @@ def dns_server() -> str:
|
||||
|
||||
|
||||
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||
"""Build an OCI image with Apple's BuildKit-backed `container build`."""
|
||||
"""Build an OCI image with Apple's BuildKit-backed `container build`.
|
||||
|
||||
Set `BOT_BOTTLE_NO_CACHE=1` (the `start --no-cache` flag) to force
|
||||
`--no-cache`. The npm/curl installers some provider Dockerfiles
|
||||
shell out to can silently no-op on a transient network failure —
|
||||
e.g. an `optionalDependencies` fetch for a platform-native binary —
|
||||
and the builder will then cache that broken layer indefinitely."""
|
||||
info(
|
||||
f"building image {ref} from {context} with Apple Container "
|
||||
"(layer cache keeps repeat builds fast)"
|
||||
)
|
||||
_ensure_builder_dns()
|
||||
args = [_CONTAINER, "build", "-t", ref, "--dns", dns_server()]
|
||||
if os.environ.get("BOT_BOTTLE_NO_CACHE") == "1":
|
||||
args.append("--no-cache")
|
||||
if dockerfile:
|
||||
# `container build` resolves -f relative to the current working
|
||||
# directory, not the build context. Anchor a relative Dockerfile to
|
||||
@@ -78,6 +86,28 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||
subprocess.run(args, check=True)
|
||||
|
||||
|
||||
def verify_agent_image(image: str, argv: tuple[str, ...]) -> None:
|
||||
"""Run `argv` inside a throwaway container of a freshly built agent
|
||||
image and die loudly if it fails, instead of shipping an image
|
||||
whose CLI only breaks at first real use. No-op when the provider
|
||||
hasn't declared a smoke test (`AgentProviderRuntime.smoke_test`)."""
|
||||
if not argv:
|
||||
return
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "run", "--rm", "--entrypoint", argv[0], image, *argv[1:]],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
die(
|
||||
f"agent image {image!r} failed its post-build smoke test "
|
||||
f"({' '.join(argv)}): {detail}\n"
|
||||
f"Try rebuilding from scratch: bot-bottle start --no-cache"
|
||||
)
|
||||
|
||||
|
||||
def commit_container(container_name: str, image_tag: str) -> None:
|
||||
"""Snapshot a running Apple Container as a local image.
|
||||
|
||||
|
||||
@@ -46,6 +46,17 @@ def cmd_start(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--cwd", action="store_true", help="copy host cwd into the running bottle")
|
||||
parser.add_argument(
|
||||
"--no-cache",
|
||||
action="store_true",
|
||||
help=(
|
||||
"rebuild agent/sidecar images from scratch, bypassing the "
|
||||
"build layer cache. Use when an image looks broken after a "
|
||||
"dependency bump — e.g. an installer's optionalDependencies "
|
||||
"fetch silently no-op'd on a transient failure and got baked "
|
||||
"into a cached layer."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
choices=known_backend_names(),
|
||||
@@ -97,6 +108,11 @@ def cmd_start(argv: list[str]) -> int:
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
||||
if args.no_cache or os.environ.get("BOT_BOTTLE_NO_CACHE") == "1":
|
||||
# Read by build_image() in each backend's util module — set here
|
||||
# so both the interactive and --headless paths pick it up without
|
||||
# threading a no_cache field through every backend's plan dataclass.
|
||||
os.environ["BOT_BOTTLE_NO_CACHE"] = "1"
|
||||
|
||||
manifest = ManifestIndex.resolve(USER_CWD)
|
||||
backend_name: str | None = args.backend
|
||||
|
||||
@@ -91,6 +91,7 @@ _RUNTIME = AgentProviderRuntime(
|
||||
prompt_mode="append_file",
|
||||
bypass_args=("--dangerously-skip-permissions",),
|
||||
resume_args=("--continue",),
|
||||
smoke_test=("claude", "--version"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ _RUNTIME = AgentProviderRuntime(
|
||||
prompt_mode="read_prompt_file",
|
||||
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
||||
resume_args=("resume", "--last"),
|
||||
smoke_test=(_CODEX_CLI, "--version"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
# PRD 0069: Firecracker-native, Docker-free backend
|
||||
|
||||
- **Status:** Draft (partially superseded)
|
||||
- **Status:** Draft
|
||||
- **Author:** Claude
|
||||
- **Created:** 2026-07-12
|
||||
- **Issue:** #348
|
||||
|
||||
> **Superseded in part by [PRD 0070](0070-per-host-orchestrator.md) (#351):**
|
||||
> the sidecar-consolidation framing here (Stage 1, per-host sidecar; Stage 4,
|
||||
> sidecar-as-VM) is taken over by 0070's per-host orchestrator. This PRD still
|
||||
> owns the docker-free **image-building** work — Stage 2 (nix-built fixed
|
||||
> images, a dependency of 0070) and Stage 3 (in-VM Dockerfile builder).
|
||||
|
||||
## Summary
|
||||
|
||||
Make the Firecracker backend depend on **firecracker + KVM only**, removing
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
# PRD 0070: Per-host orchestrator service
|
||||
|
||||
- **Status:** Draft
|
||||
- **Author:** Claude
|
||||
- **Created:** 2026-07-12
|
||||
- **Issue:** #351
|
||||
- **Supersedes:** the Stage-1 / Stage-4 sidecar-consolidation framing of
|
||||
PRD 0069 (#348). Depends on 0069's nix-built fixed images (Stage 2) for
|
||||
bootstrapping; 0069 still owns the docker-free image-building work.
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the **per-bottle sidecar bundle** with a single **persistent,
|
||||
per-host orchestrator**: one long-lived service that runs the sidecar
|
||||
functions (egress / git-gate / supervise), coordinates with the console,
|
||||
and brokers agent launches and teardown. It is **virtualized from the
|
||||
start** using each backend's native isolation primitive — a Firecracker
|
||||
microVM on the Firecracker backend, an Apple container on macOS, a Docker
|
||||
container on the legacy backend — and is fronted by a single
|
||||
**backend-agnostic contract**. Per-backend variation lives on
|
||||
`BottleBackend`, not in the orchestrator.
|
||||
|
||||
## Motivation
|
||||
|
||||
Today each bottle spins up its own sidecar bundle (egress mitmproxy +
|
||||
git-gate + supervise). That costs:
|
||||
|
||||
- **Resources.** N bottles → N heavy bundles booting and idling.
|
||||
- **Operational churn.** Per-launch container/VM lifecycle for the
|
||||
sidecars, a control path baked at launch and torn down at exit.
|
||||
- **A blurry contract.** "How a bottle talks to its sidecar" is
|
||||
re-implemented per backend instead of being one agreed interface.
|
||||
|
||||
A per-host orchestrator collapses the first two and forces the third to
|
||||
be made explicit. It's also the component that will own per-host runtime
|
||||
**state** (slot leases, the approval queue, the bottle registry) — today
|
||||
that's ad-hoc `fcntl`-locked files.
|
||||
|
||||
## Security review (read this first)
|
||||
|
||||
Consolidation is a real change to the trust model. The goal is to **not
|
||||
significantly weaken** the posture; some properties strengthen, some
|
||||
weaken, and the weakened ones must be mitigated by design, not hand-waved.
|
||||
|
||||
### What gets stronger
|
||||
|
||||
- **Build/host isolation of untrusted inputs** (with 0069 Stage 3): user
|
||||
Dockerfiles build in a disposable VM instead of on the host.
|
||||
- **One audited privileged surface.** Today the launcher runs as the full
|
||||
host user and needs the Docker socket (root-equivalent). The orchestrator
|
||||
model replaces that with a **thin launch broker** (below) — a small,
|
||||
structured, auditable privileged core instead of a fat socket.
|
||||
- **Attribution is enforced, not assumed.** Making source-IP identity a
|
||||
first-class contract invariant (below) means each backend must *prove*
|
||||
it, rather than the sidecar implicitly trusting network position.
|
||||
|
||||
### What gets weaker, and the mitigation
|
||||
|
||||
1. **Secret concentration.** Per-bottle sidecars isolate secrets at the
|
||||
process boundary — each holds only its bottle's tokens/keys. A host
|
||||
orchestrator concentrates **every bottle's** egress tokens, git deploy
|
||||
keys, and the console credential in one long-lived process. A single
|
||||
attribution bug leaks bottle A's token into bottle B's request — a class
|
||||
of bug that *cannot exist* per-bottle.
|
||||
- *Mitigation:* lean on the enforced source-IP invariant for
|
||||
attribution; keep the most secret-dense, least-shareable service
|
||||
(**git-gate**, per-repo deploy keys, no natural source-IP scoping)
|
||||
**per-bottle** unless there's a compelling reason; scope each secret
|
||||
to the bottle in the state DB so a lookup can't return the wrong
|
||||
bottle's secret by construction (key every secret access by the
|
||||
verified source identity, never by ambient state).
|
||||
|
||||
2. **Shared fate.** Orchestrator down = no new launches, and running
|
||||
agents lose egress / git / supervise. Compromise = the whole host's
|
||||
fleet, plus launch authority, plus the console token.
|
||||
- *Mitigation:* the orchestrator is itself confined (its own VM/container
|
||||
with its own fail-closed egress); make it **restartable without killing
|
||||
running agent VMs** (agents keep running; they briefly lose sidecar
|
||||
connectivity until it's back); persist state to a host volume so a
|
||||
restart re-adopts live bottles rather than losing them.
|
||||
|
||||
3. **The launch broker is the new privileged core.** We don't eliminate
|
||||
host privilege — we shrink and relocate it. If the broker accepts
|
||||
arbitrary paths/commands, the orchestrator VM can escape through it.
|
||||
- *Mitigation:* the broker takes **structured requests only** — "launch
|
||||
bottle from *this* content-addressed, nix-built rootfs on TAP slot
|
||||
*k*", never "run this argv". It validates against a fixed image set,
|
||||
not caller-supplied paths. It is small enough to audit line-by-line.
|
||||
|
||||
4. **The egress proxy now parses every bottle's traffic in one process.**
|
||||
Higher blast radius for a mitmproxy/TLS-bump bug.
|
||||
- *Mitigation:* this is the argument for virtualizing the orchestrator
|
||||
from the start (Stage B, not a host daemon) — the code that TLS-bumps
|
||||
and parses agent traffic and holds every token runs **inside its own
|
||||
confined VM**, not as a host process. If egress sharing's blast radius
|
||||
feels too high, egress can stay per-bottle while supervise (near-zero
|
||||
secrets) goes host-level first.
|
||||
|
||||
### The attribution invariant
|
||||
|
||||
Source-IP attribution is what makes a shared orchestrator safe: one
|
||||
process serves every bottle and tells them apart by source address. The
|
||||
*mechanism* is identical everywhere (read source IP → look up bottle); the
|
||||
**guarantee that the address can't be forged is a per-backend
|
||||
responsibility** and part of the contract:
|
||||
|
||||
> **Invariant:** a packet's source address, as seen by the orchestrator,
|
||||
> *provably* identifies the originating bottle.
|
||||
|
||||
- **Firecracker** — enforced by the `/31` point-to-point TAP + the
|
||||
`bot_bottle_fc` nft table (strongest; already built).
|
||||
- **Docker** — the per-bottle `--internal` network + anti-spoof; weaker,
|
||||
must be made explicit.
|
||||
- **Apple** — the host-only network.
|
||||
|
||||
If a backend can't honor the invariant, source-IP consolidation is not
|
||||
safe there and that backend keeps per-bottle sidecars. The invariant is a
|
||||
hard precondition, not an aspiration.
|
||||
|
||||
### Secret handling — a FUTURE pattern (not v1)
|
||||
|
||||
> **Status: future / not required for the initial orchestrator.** The
|
||||
> initial cut can inject secrets as today; this section records the
|
||||
> direction so v1 doesn't paint itself into a corner. Tracked as its own
|
||||
> work in **#355** (generic `SecretProvider`).
|
||||
|
||||
The residual weakness after all of the above is **long-lived credential
|
||||
concentration** — the data-plane proxies must hold every bottle's upstream
|
||||
tokens because the agent must never see them. You can't policy-gate the
|
||||
component whose job is to *use* all the secrets, so a full RCE of a proxy
|
||||
drains its authorized set regardless. Two moves bound this without
|
||||
pretending to prevent it:
|
||||
|
||||
1. **Vault as a separate trust domain.** The long-lived *roots* live in a
|
||||
distinct process (ideally its own VM) that the byte-parsing data plane
|
||||
never shares memory with. The proxies request secrets from it; the
|
||||
crown jewels are not in the process an agent-facing parser can pop.
|
||||
2. **Derive short-lived, scoped creds where the upstream allows it.** The
|
||||
vault holds the root and mints expiring, narrowly-scoped credentials
|
||||
per bottle-start (or per request) — GitHub App installation tokens,
|
||||
OAuth/STS token exchange, forge deploy tokens. A compromise then leaks
|
||||
short-lived material, not permanent keys. For upstreams stuck on static
|
||||
keys the vault passes the value through (only the at-rest / audit /
|
||||
revocation benefits apply, not lifetime reduction) — this residue is
|
||||
accepted and documented, not solved.
|
||||
|
||||
Even a plain per-request fetch (no derivation) still buys **at-rest**
|
||||
reduction (process memory holds only in-flight secrets), a **detection /
|
||||
rate-limit / revocation chokepoint**, and clean **cross-component
|
||||
scoping** (an egress RCE can't request git-gate's creds). It does *not*
|
||||
prevent abuse of currently-authorized access during the compromise window.
|
||||
|
||||
**Mechanism (see #355):** generalize the existing `DeployKeyProvisioner`
|
||||
(PRD 0048) into a user-extensible **`SecretProvider`** droppable into the
|
||||
manifest anywhere a raw token value is accepted, discovered from
|
||||
`~/.bot-bottle/contrib/<name>/secret_provider.py` exactly as user
|
||||
`AgentProvider`s are — because maintaining every forge/cloud/OAuth
|
||||
provider in-tree is untenable. The orchestrator's vault mints via these
|
||||
providers; but the abstraction is shippable independently and today's
|
||||
per-bottle sidecars can use it too.
|
||||
|
||||
## Design
|
||||
|
||||
### The contract (backend-agnostic)
|
||||
|
||||
Three surfaces; only one is per-backend.
|
||||
|
||||
1. **Control plane (CLI / console → orchestrator)** — an RPC:
|
||||
`launch_bottle`, `teardown_bottle`, `register_policy`,
|
||||
`deregister_bottle`, `supervise_queue`. Fully backend-agnostic. Both the
|
||||
local `cli.py` and the remote console funnel through it, so policy is
|
||||
uniform and `cli.py` becomes a thin client rather than a parallel
|
||||
launcher.
|
||||
2. **Data plane (agent → orchestrator)** — the egress / git / supervise
|
||||
endpoints. Already agnostic today (agents dial `http://sidecar:9099`);
|
||||
only the *address* and *how packets get there* are per-backend.
|
||||
3. **Launch / wire (orchestrator → backend)** — the irreducibly
|
||||
backend-specific part; lives on `BottleBackend`.
|
||||
|
||||
### One `Orchestrator`, no subclass tree
|
||||
|
||||
The orchestrator is a **single concrete class** holding all the
|
||||
backend-neutral logic — egress addon, git-gate, supervise, source-IP
|
||||
attribution, live-reload control plane, console client. It never branches
|
||||
on backend; it *composes* a `BottleBackend`. That composition is what makes
|
||||
the contract agnostic: there is nothing backend-specific left in the
|
||||
orchestrator to leak.
|
||||
|
||||
Rejected alternative: an `Orchestrator` ABC with per-backend
|
||||
implementations. The interesting logic (proxies, attribution, control
|
||||
plane) is backend-neutral, so three subclasses would triplicate the hard
|
||||
part; and a second hierarchy paralleling `BottleBackend` reintroduces the
|
||||
same hand-maintained lockstep coupling we just removed from the netpool
|
||||
constants (PR #350). Composition over a parallel tree.
|
||||
|
||||
### `BottleBackend` absorbs the per-backend variation
|
||||
|
||||
A small, cohesive surface — reused for launching agent bottles *and* the
|
||||
orchestrator's own unit (the orchestrator is just another native unit):
|
||||
|
||||
```
|
||||
launch_unit(spec) -> Handle # agent bottle OR the orchestrator itself
|
||||
# (fc microVM / apple ctr / docker ctr)
|
||||
wire(unit, endpoint) -> None # DNAT+forward (fc) | attach shared net (docker/apple)
|
||||
endpoint_of(unit) -> Endpoint # address resolution
|
||||
health(unit) -> Status
|
||||
```
|
||||
|
||||
Plus the **launch broker** — the answer to "a VM/container can't spawn its
|
||||
own host-network siblings." The orchestrator can't directly open host
|
||||
`/dev/kvm` + a host TAP fd (Firecracker), and a container can't spawn
|
||||
siblings without a root-equivalent socket (Docker). So every backend
|
||||
exposes a broker the orchestrator calls to launch an agent:
|
||||
|
||||
- **Firecracker** — a thin, structured host shim (see security #3). This
|
||||
replaces today's implicit "launcher runs as host user."
|
||||
- **Docker** — the socket today (fat, root-equivalent — the thing 0069's
|
||||
Stage 3 removes); a narrower broker later.
|
||||
- **Apple** — the `container` CLI/daemon.
|
||||
|
||||
If `BottleBackend` bloats, the pressure valve is composition one level
|
||||
down: vend a `backend.network()` / `Wiring` collaborator rather than
|
||||
piling methods on — the same discipline, recursed.
|
||||
|
||||
### State: one SQLite DB, owned by the orchestrator
|
||||
|
||||
The orchestrator is the natural owner of per-host **runtime state**:
|
||||
|
||||
- pool **slot leases** (which bottle holds slot *i*) — replaces today's
|
||||
`fcntl`-locked files with WAL-mode transactions;
|
||||
- the **supervise approval queue** + remembered approvals;
|
||||
- the **live bottle registry** (source IP → bottle → policy/secrets refs),
|
||||
the lookup table the attribution invariant reads.
|
||||
|
||||
This is deliberately **not** a "single source of truth for all config."
|
||||
Config splits into three tiers with different homes:
|
||||
|
||||
| Tier | Example | Home |
|
||||
|---|---|---|
|
||||
| Build-time constants | pool size, IP base, nft table | flat `.env` (PR #350) — must be readable by Nix eval + root bash, zero runtime |
|
||||
| User-authored config | bottle manifests, egress routes, secret refs | declarative files under `~/.bot-bottle/` — trust boundary at `$HOME`, git-trackable, "unknown keys die at load" |
|
||||
| Runtime state | slot leases, approvals, registry | **SQLite**, owned by the orchestrator |
|
||||
|
||||
SQLite is right for the runtime tier (mutable, concurrent, queried) and
|
||||
wrong for the other two (Nix can't read it at eval time; it fights the
|
||||
declarative manifest trust model). Keep the tiers separate.
|
||||
|
||||
## Sequencing
|
||||
|
||||
Jump straight to the **virtualized** end state (not a host-daemon stepping
|
||||
stone): a host daemon's agent→`localhost` transport is throwaway once the
|
||||
orchestrator becomes a VM. Decouple the two risks instead:
|
||||
|
||||
- **Consolidation risk** (one process, all secrets, attribution, reload)
|
||||
and **packaging/transport risk** (VM-to-VM wiring, the shim) are
|
||||
independent. Develop the orchestrator **service as a plain process
|
||||
dev-harness** first, so the consolidation logic (attribution, reload,
|
||||
secret handling) is proven with fast iteration — *then* wrap that exact
|
||||
service in the VM and solve wiring separately.
|
||||
|
||||
Backend order (cheapest proof → hardest → last):
|
||||
|
||||
1. **Docker orchestrator** — nearly free (the sidecar bundle is already
|
||||
containers; collapse N bundles into one persistent container). Proves
|
||||
consolidation + the `BottleBackend` seam with the least moving parts.
|
||||
2. **Firecracker orchestrator** — the real work: the shim + VM-to-VM
|
||||
routing (host forwards `bbfcN` → orchestrator TAP; the nft table grows
|
||||
forward rules where today it drops all non-DNAT egress). Built against
|
||||
the dev-harness so the app logic is already proven.
|
||||
3. **macOS (Apple container)** — last (container-to-container networking).
|
||||
|
||||
Keep the sidecar **service one shared thing** throughout.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Removing OCI/Dockerfile support for agent images (0069's concern).
|
||||
- A single database for *all* config (see the three-tier table).
|
||||
- Changing the per-bottle isolation of agent workloads — only the sidecar
|
||||
is consolidated; agents stay one-VM/container-each.
|
||||
|
||||
## Relationship to other work
|
||||
|
||||
- **PRD 0069 (#348):** 0070 subsumes its Stage 1 (per-host sidecar) and
|
||||
Stage 4 (sidecar-as-VM). 0069 retains Stage 2 (nix-built fixed images —
|
||||
a **dependency** here: the orchestrator and agent base must be
|
||||
nix-built so the broker launches from a fixed image set and bootstrapping
|
||||
has no chicken-and-egg) and Stage 3 (in-VM Dockerfile builder).
|
||||
- **Minimal CI runner (paused):** the Firecracker broker + no host Docker
|
||||
is what lets a dedicated `gitea` runner user drop the root-equivalent
|
||||
`docker` group — it only needs broker-socket access + `kvm`/pool group
|
||||
membership. This work unblocks it.
|
||||
- **Generic `SecretProvider` (#355):** the future secret-handling
|
||||
mechanism (see "Secret handling") — generalizes PRD 0048's
|
||||
`DeployKeyProvisioner` into a user-extensible provider that mints
|
||||
short-lived creds. Shippable independently; the orchestrator's vault
|
||||
mints through it.
|
||||
- **PR #350 (netpool single-source):** the same "one source per fact,
|
||||
composition over parallel hierarchies" discipline the contract follows.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Egress sharing tradeoff:** is the secret-concentration blast radius of
|
||||
one shared mitmproxy worth the resource win, or share only supervise
|
||||
(near-zero secrets) and keep egress + git-gate per-bottle initially?
|
||||
- **Control-plane shape:** RPC transport (unix socket / vsock / HTTP over
|
||||
the TAP) and the live-reload protocol for per-bottle policy.
|
||||
- **State re-adoption:** exact scheme for an orchestrator restart to
|
||||
re-adopt running agent VMs from the SQLite registry without racing
|
||||
in-flight launches.
|
||||
- **VM-to-VM routing:** the nft forward rules + addressing for a
|
||||
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.
|
||||
#
|
||||
# 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
|
||||
# nftables table that confines every microVM to its own sidecar.
|
||||
# user-owned point-to-point TAP devices plus a fail-closed nftables table
|
||||
# that confines every microVM to its own sidecar.
|
||||
#
|
||||
# NON-INVASIVE BY DESIGN. It does NOT flip `networking.nftables.enable`
|
||||
# (which would switch your whole host firewall backend) or
|
||||
# `systemd.network.enable` (which would hand your interfaces to
|
||||
# systemd-networkd). Instead a single systemd oneshot brings the pool up
|
||||
# on boot by running the SAME bring-up script as every other install
|
||||
# path (`scripts/firecracker-netpool.sh`) — so the TAP/nft logic lives in
|
||||
# exactly one place. The `inet <tableName>` table is independent (its own
|
||||
# hooks at priority -10), so it coexists with an iptables
|
||||
# `networking.firewall`, Docker, ufw, firewalld, etc.
|
||||
# systemd-networkd). Instead a single systemd oneshot service brings the
|
||||
# pool up on boot — the declarative equivalent of
|
||||
# `sudo ./scripts/firecracker-netpool.sh up`. The `inet <tableName>` table
|
||||
# is independent (its own hooks at priority -10), so it coexists with an
|
||||
# iptables `networking.firewall`, Docker, ufw, firewalld, etc.
|
||||
#
|
||||
# The oneshot is only restarted when this config changes, and the shared
|
||||
# script is non-destructive (it never tears down an existing TAP), so a
|
||||
# `nixos-rebuild switch` won't cut TAPs out from under running VMs.
|
||||
# The oneshot is only restarted when this config changes, so a
|
||||
# `nixos-rebuild switch` doesn't tear down TAPs out from under running
|
||||
# VMs unless you actually changed the pool.
|
||||
#
|
||||
# Single source of the pool defaults: bot_bottle/backend/firecracker/
|
||||
# netpool.defaults.env. This module readFile-parses it for the option
|
||||
# defaults below, then passes the resolved values back to the script as
|
||||
# Environment=, so the host pool and the CLI launcher can't drift.
|
||||
# The option defaults MUST match the backend constants in
|
||||
# bot_bottle/backend/firecracker/netpool.py (pool size, IP base, iface
|
||||
# prefix, table name). The backend reads the same values at launch from
|
||||
# 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, ... }:
|
||||
|
||||
let
|
||||
cfg = config.services.bot-bottle-firecracker;
|
||||
|
||||
# --- shared single-source defaults ---------------------------------
|
||||
# Parse the KEY=VALUE defaults file (the same one netpool.py and the
|
||||
# shell script read). Pure eval — just readFile, no import-from-
|
||||
# derivation — so it works for both flake and channel consumers.
|
||||
readDefaults = file:
|
||||
# --- IPv4 <-> int (full 32-bit carry math) -------------------------
|
||||
toOctets = s: map lib.toInt (lib.splitString "." s);
|
||||
ipToInt = s:
|
||||
let o = toOctets s; in
|
||||
(lib.elemAt o 0) * 16777216
|
||||
+ (lib.elemAt o 1) * 65536
|
||||
+ (lib.elemAt o 2) * 256
|
||||
+ (lib.elemAt o 3);
|
||||
intToIp = n:
|
||||
let
|
||||
lines = lib.splitString "\n" (builtins.readFile file);
|
||||
keep = l: l != "" && !(lib.hasPrefix "#" l) && lib.hasInfix "=" l;
|
||||
toPair = l:
|
||||
let parts = lib.splitString "=" l;
|
||||
in lib.nameValuePair (lib.head parts)
|
||||
(lib.concatStringsSep "=" (lib.tail parts));
|
||||
in lib.listToAttrs (map toPair (lib.filter keep lines));
|
||||
b0 = n / 16777216; r0 = n - b0 * 16777216;
|
||||
b1 = r0 / 65536; r1 = r0 - b1 * 65536;
|
||||
b2 = r1 / 256;
|
||||
b3 = r1 - b2 * 256;
|
||||
in "${toString b0}.${toString b1}.${toString b2}.${toString b3}";
|
||||
|
||||
defaults = readDefaults ../bot_bottle/backend/firecracker/netpool.defaults.env;
|
||||
baseInt = ipToInt cfg.ipBase;
|
||||
|
||||
# The one bring-up implementation, shared with the sudo/systemd paths.
|
||||
netpoolScript = ../scripts/firecracker-netpool.sh;
|
||||
# Slot i: host = base + 2i (the VM's gateway), on its own /31.
|
||||
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).
|
||||
lastOctet = lib.toInt (lib.last (lib.splitString "." cfg.ipBase));
|
||||
ip = "${pkgs.iproute2}/bin/ip";
|
||||
nft = "${pkgs.nftables}/bin/nft";
|
||||
|
||||
# The script needs ip/nft/sysctl + the usual coreutils. It gets every
|
||||
# pool value via the unit's Environment=, so it never reads the shared
|
||||
# defaults file (which isn't beside it once copied to the store).
|
||||
runtimePath = with pkgs; [ iproute2 nftables procps coreutils gnused ];
|
||||
# Idempotent ruleset: (create-if-absent → delete → recreate) is atomic
|
||||
# within one `nft -f`, so re-running `up` always lands a clean table.
|
||||
nftFile = pkgs.writeText "bot-bottle-fc.nft" ''
|
||||
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 =
|
||||
if cfg.group != null
|
||||
then { BOT_BOTTLE_FC_GROUP = cfg.group; }
|
||||
else { BOT_BOTTLE_FC_OWNER = cfg.owner; };
|
||||
# A non-owner user can't open a user-owned TAP, but any member of a
|
||||
# TAP's owning GROUP can (the kernel checks owner-uid OR owning-group).
|
||||
# So `group` lets an interactive user and a CI-runner user share one
|
||||
# pool; `owner` is the single-user default.
|
||||
tapOwn = if cfg.group != null then "group ${cfg.group}" else "user ${cfg.owner}";
|
||||
|
||||
unitEnv = {
|
||||
BOT_BOTTLE_FC_POOL_SIZE = toString cfg.poolSize;
|
||||
BOT_BOTTLE_FC_IP_BASE = cfg.ipBase;
|
||||
BOT_BOTTLE_FC_IFACE_PREFIX = cfg.ifacePrefix;
|
||||
BOT_BOTTLE_FC_NFT_TABLE = cfg.tableName;
|
||||
} // ownEnv;
|
||||
upScript = pkgs.writeShellScript "bot-bottle-fc-netpool-up" ''
|
||||
set -eu
|
||||
${lib.concatMapStringsSep "\n" (s: ''
|
||||
${ip} link show ${s.iface} >/dev/null 2>&1 || ${ip} tuntap add dev ${s.iface} mode tap ${tapOwn}
|
||||
${ip} addr replace ${s.hostIp}/31 dev ${s.iface}
|
||||
${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
|
||||
{
|
||||
options.services.bot-bottle-firecracker = {
|
||||
@@ -72,28 +106,25 @@ in
|
||||
|
||||
poolSize = lib.mkOption {
|
||||
type = lib.types.ints.positive;
|
||||
default = lib.toInt defaults.BOT_BOTTLE_FC_POOL_SIZE;
|
||||
defaultText = lib.literalMD "the shared `netpool.defaults.env` value";
|
||||
default = 8;
|
||||
description = "Number of pool slots (concurrent bottles). Must match BOT_BOTTLE_FC_POOL_SIZE.";
|
||||
};
|
||||
|
||||
ipBase = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = defaults.BOT_BOTTLE_FC_IP_BASE;
|
||||
defaultText = lib.literalMD "the shared `netpool.defaults.env` value";
|
||||
default = "10.243.0.0";
|
||||
description = ''
|
||||
Base IPv4 of the /31 pool; slot i uses host = base + 2i. Must
|
||||
be /31-aligned (even final octet) and must match
|
||||
BOT_BOTTLE_FC_IP_BASE. The shared default is an obscure RFC-1918
|
||||
/16 that dodges docker/libvirt/k8s/LAN and, deliberately,
|
||||
Tailscale's 100.64.0.0/10 CGNAT range.
|
||||
be /31-aligned (even final address) and must match
|
||||
BOT_BOTTLE_FC_IP_BASE. Default is an obscure RFC-1918 /16 that
|
||||
dodges docker/libvirt/k8s/LAN and, deliberately, Tailscale's
|
||||
100.64.0.0/10 CGNAT range.
|
||||
'';
|
||||
};
|
||||
|
||||
ifacePrefix = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = defaults.BOT_BOTTLE_FC_IFACE_PREFIX;
|
||||
defaultText = lib.literalMD "the shared `netpool.defaults.env` value";
|
||||
default = "bbfc";
|
||||
description = "TAP interface name prefix. Must match BOT_BOTTLE_FC_IFACE_PREFIX.";
|
||||
};
|
||||
|
||||
@@ -122,8 +153,7 @@ in
|
||||
|
||||
tableName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = defaults.BOT_BOTTLE_FC_NFT_TABLE;
|
||||
defaultText = lib.literalMD "the shared `netpool.defaults.env` value";
|
||||
default = "bot_bottle_fc";
|
||||
description = "nftables table name for the isolation boundary. Must match netpool.NFT_TABLE.";
|
||||
};
|
||||
|
||||
@@ -141,7 +171,7 @@ in
|
||||
config = lib.mkIf cfg.enable {
|
||||
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}.";
|
||||
}
|
||||
{
|
||||
@@ -153,20 +183,17 @@ in
|
||||
# VM->sidecar traffic is DNAT'd and forwarded, so forwarding must be on.
|
||||
boot.kernel.sysctl."net.ipv4.ip_forward" = 1;
|
||||
|
||||
# One oneshot brings up the whole pool (TAPs + independent nft table)
|
||||
# by running the shared bring-up script. No networking.nftables.enable
|
||||
# / systemd.network.enable — see header.
|
||||
# One oneshot brings up the whole pool (TAPs + independent nft table).
|
||||
# No networking.nftables.enable / systemd.network.enable — see header.
|
||||
systemd.services."bot-bottle-firecracker-netpool" = {
|
||||
description = "bot-bottle Firecracker TAP pool + nft isolation table";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-pre.target" ];
|
||||
path = runtimePath;
|
||||
environment = unitEnv;
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
ExecStart = "${pkgs.bash}/bin/bash ${netpoolScript} up";
|
||||
ExecStop = "${pkgs.bash}/bin/bash ${netpoolScript} down";
|
||||
ExecStart = upScript;
|
||||
ExecStop = downScript;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -175,7 +202,6 @@ in
|
||||
BOT_BOTTLE_FC_POOL_SIZE=${toString cfg.poolSize}
|
||||
BOT_BOTTLE_FC_IP_BASE=${cfg.ipBase}
|
||||
BOT_BOTTLE_FC_IFACE_PREFIX=${cfg.ifacePrefix}
|
||||
BOT_BOTTLE_FC_NFT_TABLE=${cfg.tableName}
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
@@ -33,44 +33,24 @@
|
||||
# sudo ./scripts/firecracker-netpool.sh down
|
||||
# ./scripts/firecracker-netpool.sh status
|
||||
#
|
||||
# Pool params default to the shared single-source file
|
||||
# (bot_bottle/backend/firecracker/netpool.defaults.env); a matching
|
||||
# env var overrides its key:
|
||||
# BOT_BOTTLE_FC_POOL_SIZE number of slots
|
||||
# BOT_BOTTLE_FC_IP_BASE base IPv4 of the /31 pool
|
||||
# BOT_BOTTLE_FC_IFACE_PREFIX TAP name prefix
|
||||
# BOT_BOTTLE_FC_NFT_TABLE isolation table name
|
||||
# BOT_BOTTLE_FC_OWNER owning user (default $SUDO_USER or $USER)
|
||||
# BOT_BOTTLE_FC_GROUP owning group; if set, TAPs are group-owned
|
||||
# instead of user-owned, so any group member
|
||||
# (e.g. an interactive user + a CI runner
|
||||
# user) can open the pool. Overrides OWNER.
|
||||
# Env overrides (must match the backend's util.py constants):
|
||||
# BOT_BOTTLE_FC_POOL_SIZE number of slots (default 8)
|
||||
# BOT_BOTTLE_FC_IP_BASE base IPv4 of the /31 pool (default 10.243.0.0)
|
||||
# BOT_BOTTLE_FC_IFACE_PREFIX TAP name prefix (default bbfc)
|
||||
# BOT_BOTTLE_FC_OWNER owning user (default $SUDO_USER or $USER)
|
||||
# BOT_BOTTLE_FC_GROUP owning group; if set, TAPs are group-owned
|
||||
# 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
|
||||
|
||||
# The single source of the pool defaults, shared with netpool.py and the
|
||||
# Nix module. The Nix path passes every value as env (the script runs
|
||||
# from the store, detached from this file), so this lookup only matters
|
||||
# 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)}"
|
||||
POOL_SIZE="${BOT_BOTTLE_FC_POOL_SIZE:-8}"
|
||||
IP_BASE="${BOT_BOTTLE_FC_IP_BASE:-10.243.0.0}"
|
||||
PREFIX="${BOT_BOTTLE_FC_IFACE_PREFIX:-bbfc}"
|
||||
OWNER="${BOT_BOTTLE_FC_OWNER:-${SUDO_USER:-$USER}}"
|
||||
GROUP="${BOT_BOTTLE_FC_GROUP:-}"
|
||||
|
||||
# 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
|
||||
TABLE="bot_bottle_fc"
|
||||
|
||||
# Sidecar ports (must match the backend). egress=9099, supervise=9100,
|
||||
# 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
|
||||
local dev host
|
||||
dev="$(iface "$i")" ; host="$(host_ip "$i")"
|
||||
# Non-destructive + idempotent: only create a missing TAP (tearing
|
||||
# an existing one down would cut a running VM), and `addr replace`
|
||||
# is safe to re-run. Ownership is fixed at creation, so to change
|
||||
# owner/group run `down` then `up`.
|
||||
ip link show "$dev" >/dev/null 2>&1 \
|
||||
|| ip tuntap add dev "$dev" mode tap "${own_args[@]}"
|
||||
ip addr replace "$host/31" dev "$dev"
|
||||
if ip link show "$dev" >/dev/null 2>&1; then
|
||||
ip link set "$dev" down 2>/dev/null || true
|
||||
ip tuntap del dev "$dev" mode tap 2>/dev/null || true
|
||||
fi
|
||||
ip tuntap add dev "$dev" mode tap "${own_args[@]}"
|
||||
ip addr add "$host/31" dev "$dev"
|
||||
ip link set "$dev" up
|
||||
echo " $dev host=$host guest=$(guest_ip "$i") $own_desc"
|
||||
done
|
||||
|
||||
@@ -103,6 +103,8 @@ class TestLaunchCommittedImage(unittest.TestCase):
|
||||
launch_mod.docker_mod, "image_exists", return_value=image_present,
|
||||
), mock.patch.object(
|
||||
launch_mod.docker_mod, "build_image", side_effect=fake_build,
|
||||
), mock.patch.object(
|
||||
launch_mod.docker_mod, "verify_agent_image",
|
||||
), mock.patch.object(
|
||||
launch_mod, "egress_tls_init",
|
||||
return_value=(Path("/egress_ca"), Path("/egress_cert")),
|
||||
|
||||
@@ -87,6 +87,7 @@ class TestTeardownWarning(unittest.TestCase):
|
||||
buf = io.StringIO()
|
||||
|
||||
with mock.patch.object(launch_mod.docker_mod, "build_image"), \
|
||||
mock.patch.object(launch_mod.docker_mod, "verify_agent_image"), \
|
||||
mock.patch.object(
|
||||
launch_mod, "egress_tls_init",
|
||||
return_value=(Path("/egress_ca"), Path("/egress_cert")),
|
||||
|
||||
@@ -67,24 +67,10 @@ class TestNetpoolRenderers(unittest.TestCase):
|
||||
self.assertNotIn("networking.nftables.enable = true", mod)
|
||||
self.assertNotIn("systemd.network.enable = true", mod)
|
||||
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).
|
||||
self.assertIn("BOT_BOTTLE_FC_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
|
||||
self.assertIn("group ${cfg.group}", mod)
|
||||
|
||||
def test_shell_setup_reflects_overrides(self):
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_FC_POOL_SIZE": "3"}):
|
||||
@@ -276,43 +262,26 @@ class TestBottleAgentArgv(unittest.TestCase):
|
||||
self.assertIn("USER=node", argv)
|
||||
|
||||
|
||||
class TestNetpoolDefaultsSingleSource(unittest.TestCase):
|
||||
"""The pool defaults live in one shared file (netpool.defaults.env);
|
||||
Python parses it and the shell script + Nix module read the same file,
|
||||
so the three setup paths can't drift."""
|
||||
class TestSetupScriptConsistency(unittest.TestCase):
|
||||
"""The shell setup script duplicates the pool defaults; keep them in
|
||||
lockstep with the Python constants so the two setup paths agree."""
|
||||
|
||||
def _root(self) -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
def _script(self) -> str:
|
||||
root = Path(__file__).resolve().parent.parent.parent
|
||||
return (root / "scripts" / "firecracker-netpool.sh").read_text()
|
||||
|
||||
def _shared_defaults(self) -> dict[str, str]:
|
||||
text = netpool.DEFAULTS_FILE.read_text()
|
||||
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()
|
||||
def test_defaults_match_python(self):
|
||||
script = self._script()
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.assertEqual(int(d["BOT_BOTTLE_FC_POOL_SIZE"]), netpool.pool_size())
|
||||
self.assertEqual(d["BOT_BOTTLE_FC_IP_BASE"], netpool.ip_base())
|
||||
# Module constants resolve through the same shared file.
|
||||
self.assertEqual(d["BOT_BOTTLE_FC_IFACE_PREFIX"], netpool.IFACE_PREFIX)
|
||||
self.assertEqual(d["BOT_BOTTLE_FC_NFT_TABLE"], netpool.NFT_TABLE)
|
||||
self.assertIn(f'POOL_SIZE:-{netpool.pool_size()}', script)
|
||||
self.assertIn(f'BOT_BOTTLE_FC_IP_BASE:-{netpool.ip_base()}', script)
|
||||
self.assertIn(f'IFACE_PREFIX:-{netpool.IFACE_PREFIX}', script)
|
||||
|
||||
def test_env_var_overrides_the_shared_default(self):
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_FC_IP_BASE": "10.99.0.0"}):
|
||||
self.assertEqual("10.99.0.0", netpool.ip_base())
|
||||
|
||||
def test_script_defers_to_shared_file_without_literals(self):
|
||||
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)
|
||||
def test_table_name_and_ports_match(self):
|
||||
script = self._script()
|
||||
self.assertIn(f'TABLE="{netpool.NFT_TABLE}"', script)
|
||||
for port in netpool.SIDECAR_PORTS:
|
||||
self.assertIn(str(port), script)
|
||||
|
||||
|
||||
class TestBootArgs(unittest.TestCase):
|
||||
|
||||
@@ -357,12 +357,17 @@ class TestMacosContainerLaunchCommittedImage(unittest.TestCase):
|
||||
launch.container_mod, "image_exists", return_value=False,
|
||||
), patch.object(
|
||||
launch.container_mod, "build_image", side_effect=fake_build,
|
||||
):
|
||||
), patch.object(
|
||||
launch.container_mod, "verify_agent_image",
|
||||
) as verify:
|
||||
updated = launch._build_images(plan)
|
||||
|
||||
self.assertEqual("bot-bottle-agent:latest", updated.image)
|
||||
self.assertEqual(2, len(calls))
|
||||
self.assertEqual("bot-bottle-agent:latest", calls[1][0])
|
||||
verify.assert_called_once_with(
|
||||
"bot-bottle-agent:latest", ("claude", "--version"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user