Compare commits

..

2 Commits

Author SHA1 Message Date
didericis 76b47ee8a9 ci(coverage): run the diff-coverage gate on a self-hosted KVM runner
lint / lint (push) Successful in 1m59s
Re-land the coverage gate deferred from #343. The Firecracker VM/SSH
orchestration (~230 lines) is only exercised by the integration suite,
which needs /dev/kvm + the provisioned TAP/nft pool — a container runner
skips it and those lines read uncovered, so the 90% diff gate can't pass
on ubuntu-latest. Move the `coverage` job to a self-hosted `kvm` runner
with a firecracker-readiness preflight (binary + /dev/kvm + `backend
status`) so the integration test actually runs. Unit/lint stay on
ubuntu-latest. README documents the runner prerequisites.

Depends on a registered self-hosted runner labelled `kvm`; until one is
provisioned this gate will not run. See PRD 0069 / #348.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 12:14:28 -04:00
didericis 2b970d1170 refactor(firecracker): single-source the network-pool defaults
test / unit (pull_request) Successful in 54s
test / integration (pull_request) Successful in 15s
test / coverage (pull_request) Successful in 56s
lint / lint (push) Successful in 1m59s
test / unit (push) Successful in 53s
test / integration (push) Successful in 20s
test / coverage (push) Successful in 58s
Update Quality Badges / update-badges (push) Successful in 57s
The pool params (size, IP base, iface prefix, nft table) were triplicated
— hardcoded in netpool.py, scripts/firecracker-netpool.sh, and
nix/firecracker-netpool.nix — plus the IP math (3x) and the nft ruleset
(2x). Nothing enforced agreement; changing the base (ce3fad9, off CGNAT)
forced a coordinated three-file edit, and a missed one would silently
provision a range the launcher doesn't expect.

Collapse to one source of truth:

  * netpool.defaults.env — a plain KEY=VALUE file (bash-sourceable,
    systemd EnvironmentFile-compatible, Python- and Nix-parseable) holding
    the four defaults. A BOT_BOTTLE_FC_* env var still overrides any key.
  * netpool.py reads it for the Python defaults (missing file = hard
    error, not confusing empty defaults).
  * the shell script falls back to it (no literal `:-8` / `10.243.0.0`),
    and its `up` is now non-destructive/idempotent (only creates a
    missing TAP), so re-running never cuts a live VM.
  * the Nix module readFile-parses it for its option defaults and
    delegates bring-up to the SAME shell script (dropping its duplicate
    IP math, nft ruleset, and TAP loop) — passing every value as
    Environment= so the store-detached script never needs the file.

Net: defaults 3x -> 1x, nft ruleset 2x -> 1x, TAP loop 2x -> 1x. The one
remaining IP-math dup (Python launch-addressing vs bash bring-up) is
justified — different runtimes. Tests now guard the invariant (Python
reads the shared file; the script/module hold no literals) instead of
pinning duplicated strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 04:27:48 -04:00
19 changed files with 306 additions and 300 deletions
+32 -17
View File
@@ -71,30 +71,45 @@ jobs:
- name: Run integration tests - name: Run integration tests
run: python3 -m unittest discover -t . -s tests/integration -v run: python3 -m unittest discover -t . -s tests/integration -v
# Combined unit+integration coverage report (informational). See # Combined unit+integration coverage + the diff-coverage gate (the hard
# docs/decisions/0004-coverage-policy.md. # gate: new/changed lines >= 90%). See docs/decisions/0004-coverage-policy.md.
# #
# The hard diff-coverage gate (changed lines >= 90%) is DEFERRED: the # This runs on a self-hosted KVM runner (label `kvm`), NOT ubuntu-latest,
# Firecracker backend's VM/SSH orchestration is covered by the integration # because the Firecracker backend's subprocess/VM orchestration
# suite, which needs /dev/kvm + the provisioned TAP/nft pool — a # (launch/boot/SSH/isolation-probe) is covered by the integration suite,
# container-based runner skips it and those lines read uncovered, so the # and that suite needs `/dev/kvm` + the provisioned TAP/nft pool — which a
# gate can't pass here. Re-enabling it on a self-hosted KVM runner is # container-based runner doesn't have. On such a runner the firecracker
# tracked separately (see PRD 0069 / #348 and the ci-runner branch). # integration test skips and its ~230 orchestration lines read as
# uncovered, so the gate can't pass there.
#
# Runner prerequisites (provision once on the host; see the README
# "Firecracker on Linux" section): the `firecracker` binary on PATH,
# `/dev/kvm` accessible to the runner user, Docker, the cached guest
# kernel + static dropbear (BOT_BOTTLE_FC_KERNEL / BOT_BOTTLE_FC_DROPBEAR),
# and the network pool installed as the persistent systemd unit
# (`./cli.py backend setup --backend=firecracker`). The preflight step
# below fails fast with instructions if anything is missing.
coverage: coverage:
runs-on: ubuntu-latest runs-on: [self-hosted, kvm]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Python - name: Preflight — Firecracker host is ready
uses: actions/setup-python@v5 run: |
with: command -v firecracker >/dev/null || {
python-version: "3.12" echo "firecracker not on PATH — provision the runner (README: Firecracker on Linux)"; exit 1; }
test -e /dev/kvm || { echo "/dev/kvm missing — KVM not available on this runner"; exit 1; }
# `backend status` exits non-zero unless the TAP pool is up + no
# range overlap; it prints the exact `backend setup` fix.
python3 cli.py backend status --backend=firecracker
- name: Install dev requirements - name: Combined coverage (unit + integration, incl. firecracker)
run: python3 -m pip install -r requirements-dev.txt
- name: Combined coverage report (unit + integration)
run: PYTHON=python3 bash scripts/coverage.sh critical run: PYTHON=python3 bash scripts/coverage.sh critical
- name: Diff-coverage gate (changed lines >= 90%)
run: |
git fetch --no-tags origin main:refs/remotes/origin/main
python3 scripts/diff_coverage.py --base origin/main --min 90
+2
View File
@@ -90,6 +90,8 @@ BOT_BOTTLE_BACKEND=firecracker ./cli.py start <agent>
> **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. For the network pool, consume the flake module — `imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ]; services.bot-bottle-firecracker = { enable = true; owner = "you"; };` — then `nixos-rebuild switch` (imperative nft/TAP rules don't survive a rebuild; channel users can `imports = [ <bot-bottle>/nix/firecracker-netpool.nix ]`). `firecracker` isn't in nixpkgs by default as a user binary — install the release binary (pin the version) and put it on `PATH`. > **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. For the network pool, consume the flake module — `imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ]; services.bot-bottle-firecracker = { enable = true; owner = "you"; };` — then `nixos-rebuild switch` (imperative nft/TAP rules don't survive a rebuild; channel users can `imports = [ <bot-bottle>/nix/firecracker-netpool.nix ]`). `firecracker` isn't in nixpkgs by default as a user binary — install the release binary (pin the version) and put it on `PATH`.
> **CI:** the coverage gate (`.gitea/workflows/test.yml` → `coverage` job) runs on a self-hosted runner labelled `kvm`, because the Firecracker backend's VM/SSH orchestration is exercised only by the integration suite, which needs `/dev/kvm` + the provisioned pool (a container runner would skip it and read as uncovered). Provision that runner exactly like a normal Firecracker host — `firecracker` on `PATH`, `/dev/kvm`, Docker, the cached guest kernel + static dropbear, and the pool installed as the persistent systemd unit — then register it with the `kvm` label. The unit/lint jobs still run on `ubuntu-latest`.
```sh ```sh
./cli.py start <agent> # builds the image on first run, drops you into claude ./cli.py start <agent> # builds the image on first run, drops you into claude
``` ```
-7
View File
@@ -61,13 +61,6 @@ class AgentProviderRuntime:
prompt_mode: PromptMode prompt_mode: PromptMode
bypass_args: tuple[str, ...] bypass_args: tuple[str, ...]
resume_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) @dataclass(frozen=True)
-4
View File
@@ -36,7 +36,6 @@ from contextlib import ExitStack, contextmanager
from pathlib import Path from pathlib import Path
from typing import Callable, Generator from typing import Callable, Generator
from ...agent_provider import runtime_for
from ...egress import egress_resolve_token_values from ...egress import egress_resolve_token_values
from ...git_gate import ( from ...git_gate import (
provision_git_gate_dynamic_keys, provision_git_gate_dynamic_keys,
@@ -112,9 +111,6 @@ def launch(
plan.image, _REPO_DIR, plan.image, _REPO_DIR,
dockerfile=plan.dockerfile_path, 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) internal_network = network_mod.network_name_for_slug(plan.slug)
egress_network = network_mod.network_egress_name_for_slug(plan.slug) egress_network = network_mod.network_egress_name_for_slug(plan.slug)
+1 -57
View File
@@ -4,7 +4,6 @@ existence, and building images."""
from __future__ import annotations from __future__ import annotations
import os
import re import re
import shutil import shutil
import subprocess import subprocess
@@ -89,31 +88,6 @@ 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]+") _SLUG_RE = re.compile(r"[^a-z0-9]+")
@@ -134,45 +108,15 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
`dockerfile` is an optional path (relative to `context`, or `dockerfile` is an optional path (relative to `context`, or
absolute) for callers that need to build from a non-default 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)") info(f"building image {ref} from {context} (layer cache keeps repeat builds fast)")
args = ["docker", "build", "-t", ref] args = ["docker", "build", "-t", ref]
if os.environ.get("BOT_BOTTLE_NO_CACHE") == "1":
args.append("--no-cache")
if dockerfile: if dockerfile:
args.extend(["-f", dockerfile]) args.extend(["-f", dockerfile])
args.append(context) args.append(context)
subprocess.run(args, check=True) 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( # def build_image_with_cwd(
# derived: str, # derived: str,
# base: str, # base: str,
+55 -13
View File
@@ -24,7 +24,6 @@ from contextlib import ExitStack, contextmanager
from pathlib import Path from pathlib import Path
from typing import Callable, Generator from typing import Callable, Generator
from ...agent_provider import runtime_for
from ...bottle_state import ( from ...bottle_state import (
egress_state_dir, egress_state_dir,
git_gate_state_dir, git_gate_state_dir,
@@ -43,7 +42,6 @@ from ...git_gate import (
from ...log import die, info, warn from ...log import die, info, warn
from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT
from ...util import expand_tilde from ...util import expand_tilde
from ..docker import util as docker_mod
from ..docker.egress import ( from ..docker.egress import (
EGRESS_CA_IN_CONTAINER, EGRESS_CA_IN_CONTAINER,
EGRESS_PORT, EGRESS_PORT,
@@ -108,9 +106,9 @@ def launch(
plan = _provision_git_gate_keys(plan) plan = _provision_git_gate_keys(plan)
sidecar_name = sidecar_container_name(plan.slug) sidecar_name = sidecar_container_name(plan.slug)
docker_mod.force_remove_container(sidecar_name) _force_remove_container(sidecar_name)
_start_sidecar_bundle(plan, sidecar_name, slot.host_ip) _start_sidecar_bundle(plan, sidecar_name, slot.host_ip)
stack.callback(docker_mod.force_remove_container, sidecar_name) stack.callback(_force_remove_container, sidecar_name)
_stage_git_gate(plan, sidecar_name) _stage_git_gate(plan, sidecar_name)
plan = _stamp_agent_urls(plan, slot.host_ip) plan = _stamp_agent_urls(plan, slot.host_ip)
@@ -172,18 +170,15 @@ def _mint_certs(plan: FirecrackerBottlePlan) -> FirecrackerBottlePlan:
def _build_agent_image(plan: FirecrackerBottlePlan) -> FirecrackerBottlePlan: def _build_agent_image(plan: FirecrackerBottlePlan) -> FirecrackerBottlePlan:
docker_mod.build_image(SIDECAR_BUNDLE_IMAGE, _REPO_DIR, dockerfile=SIDECAR_BUNDLE_DOCKERFILE) _docker_build(SIDECAR_BUNDLE_IMAGE, _REPO_DIR, dockerfile=SIDECAR_BUNDLE_DOCKERFILE)
committed = read_committed_image(plan.slug) committed = read_committed_image(plan.slug)
if committed and docker_mod.image_exists(committed): if committed and _image_exists(committed):
info(f"using committed image {committed!r}") info(f"using committed image {committed!r}")
return dataclasses.replace( return dataclasses.replace(
plan, plan,
agent_provision=dataclasses.replace(plan.agent_provision, image=committed), agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
) )
docker_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path) _docker_build(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 return plan
@@ -296,15 +291,15 @@ def _stage_git_gate(plan: FirecrackerBottlePlan, sidecar_name: str) -> None:
gp = plan.git_gate_plan gp = plan.git_gate_plan
if not gp.upstreams: if not gp.upstreams:
return return
docker_mod.docker_exec(sidecar_name, [ _docker_exec(sidecar_name, [
"mkdir", "-p", "mkdir", "-p",
str(Path(GIT_GATE_HOOK_IN_CONTAINER).parent), str(Path(GIT_GATE_HOOK_IN_CONTAINER).parent),
GIT_GATE_CREDS_DIR_IN_CONTAINER, "/git", GIT_GATE_CREDS_DIR_IN_CONTAINER, "/git",
str(Path(_GIT_GATE_READY_FILE).parent), str(Path(_GIT_GATE_READY_FILE).parent),
]) ])
for host_path, container_path in _git_gate_files(plan): for host_path, container_path in _git_gate_files(plan):
docker_mod.docker_cp(host_path, f"{sidecar_name}:{container_path}") _docker_cp(host_path, f"{sidecar_name}:{container_path}")
docker_mod.docker_exec(sidecar_name, [ _docker_exec(sidecar_name, [
"sh", "-c", "sh", "-c",
f"chmod 755 {GIT_GATE_ENTRYPOINT_IN_CONTAINER} " f"chmod 755 {GIT_GATE_ENTRYPOINT_IN_CONTAINER} "
f"{GIT_GATE_HOOK_IN_CONTAINER} {GIT_GATE_ACCESS_HOOK_IN_CONTAINER} && " f"{GIT_GATE_HOOK_IN_CONTAINER} {GIT_GATE_ACCESS_HOOK_IN_CONTAINER} && "
@@ -360,3 +355,50 @@ def _agent_guest_env(plan: FirecrackerBottlePlan, host_ip: str) -> dict[str, str
if value is not None: if value is not None:
env[name] = value env[name] = value
return env 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>'}")
@@ -0,0 +1,17 @@
# 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
+40 -9
View File
@@ -4,11 +4,13 @@ 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. This module is the single source of truth for the isolates every VM. The pool parameters live in exactly one place
pool parameters the shell script (`scripts/firecracker-netpool.sh`), `netpool.defaults.env`, a plain KEY=VALUE file next to this module
the NixOS module (`nix/firecracker-netpool.nix`), and the backend's and every consumer reads *that*: this module (below), the shell script
fail-closed preflight all derive from these constants so they can't (`scripts/firecracker-netpool.sh`), and the NixOS module
drift. (`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.
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
@@ -43,18 +45,47 @@ 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 = os.environ.get("BOT_BOTTLE_FC_IFACE_PREFIX", "bbfc") IFACE_PREFIX = _cfg("BOT_BOTTLE_FC_IFACE_PREFIX")
NFT_TABLE = "bot_bottle_fc" NFT_TABLE = _cfg("BOT_BOTTLE_FC_NFT_TABLE")
def pool_size() -> int: def pool_size() -> int:
return int(os.environ.get("BOT_BOTTLE_FC_POOL_SIZE", "8")) return int(_cfg("BOT_BOTTLE_FC_POOL_SIZE"))
def ip_base() -> str: def ip_base() -> str:
return os.environ.get("BOT_BOTTLE_FC_IP_BASE", "10.243.0.0") return _cfg("BOT_BOTTLE_FC_IP_BASE")
# 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
@@ -17,7 +17,6 @@ from contextlib import ExitStack, contextmanager
from pathlib import Path from pathlib import Path
from typing import Callable, Generator from typing import Callable, Generator
from ...agent_provider import runtime_for
from ...bottle_state import ( from ...bottle_state import (
egress_state_dir, egress_state_dir,
git_gate_state_dir, git_gate_state_dir,
@@ -170,9 +169,6 @@ def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
_REPO_DIR, _REPO_DIR,
dockerfile=plan.dockerfile_path, dockerfile=plan.dockerfile_path,
) )
container_mod.verify_agent_image(
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
)
return plan return plan
+1 -31
View File
@@ -60,21 +60,13 @@ def dns_server() -> str:
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None: 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( info(
f"building image {ref} from {context} with Apple Container " f"building image {ref} from {context} with Apple Container "
"(layer cache keeps repeat builds fast)" "(layer cache keeps repeat builds fast)"
) )
_ensure_builder_dns() _ensure_builder_dns()
args = [_CONTAINER, "build", "-t", ref, "--dns", dns_server()] args = [_CONTAINER, "build", "-t", ref, "--dns", dns_server()]
if os.environ.get("BOT_BOTTLE_NO_CACHE") == "1":
args.append("--no-cache")
if dockerfile: if dockerfile:
# `container build` resolves -f relative to the current working # `container build` resolves -f relative to the current working
# directory, not the build context. Anchor a relative Dockerfile to # directory, not the build context. Anchor a relative Dockerfile to
@@ -86,28 +78,6 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
subprocess.run(args, check=True) 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: def commit_container(container_name: str, image_tag: str) -> None:
"""Snapshot a running Apple Container as a local image. """Snapshot a running Apple Container as a local image.
-16
View File
@@ -46,17 +46,6 @@ def cmd_start(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True) parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True)
parser.add_argument("--dry-run", action="store_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("--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( parser.add_argument(
"--backend", "--backend",
choices=known_backend_names(), choices=known_backend_names(),
@@ -108,11 +97,6 @@ def cmd_start(argv: list[str]) -> int:
args = parser.parse_args(argv) args = parser.parse_args(argv)
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1" 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) manifest = ManifestIndex.resolve(USER_CWD)
backend_name: str | None = args.backend backend_name: str | None = args.backend
@@ -91,7 +91,6 @@ _RUNTIME = AgentProviderRuntime(
prompt_mode="append_file", prompt_mode="append_file",
bypass_args=("--dangerously-skip-permissions",), bypass_args=("--dangerously-skip-permissions",),
resume_args=("--continue",), resume_args=("--continue",),
smoke_test=("claude", "--version"),
) )
@@ -61,7 +61,6 @@ _RUNTIME = AgentProviderRuntime(
prompt_mode="read_prompt_file", prompt_mode="read_prompt_file",
bypass_args=("--dangerously-bypass-approvals-and-sandbox",), bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
resume_args=("resume", "--last"), resume_args=("resume", "--last"),
smoke_test=(_CODEX_CLI, "--version"),
) )
+67 -93
View File
@@ -1,104 +1,70 @@
# 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-owned point-to-point TAP devices plus a fail-closed nftables table # user- (or group-) owned point-to-point TAP devices plus a fail-closed
# that confines every microVM to its own sidecar. # nftables table 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 service brings the # systemd-networkd). Instead a single systemd oneshot brings the pool up
# pool up on boot — the declarative equivalent of # on boot by running the SAME bring-up script as every other install
# `sudo ./scripts/firecracker-netpool.sh up`. The `inet <tableName>` table # path (`scripts/firecracker-netpool.sh`) — so the TAP/nft logic lives in
# is independent (its own hooks at priority -10), so it coexists with an # exactly one place. The `inet <tableName>` table is independent (its own
# iptables `networking.firewall`, Docker, ufw, firewalld, etc. # 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, so a # The oneshot is only restarted when this config changes, and the shared
# `nixos-rebuild switch` doesn't tear down TAPs out from under running # script is non-destructive (it never tears down an existing TAP), so a
# VMs unless you actually changed the pool. # `nixos-rebuild switch` won't cut TAPs out from under running VMs.
# #
# The option defaults MUST match the backend constants in # Single source of the pool defaults: bot_bottle/backend/firecracker/
# bot_bottle/backend/firecracker/netpool.py (pool size, IP base, iface # netpool.defaults.env. This module readFile-parses it for the option
# prefix, table name). The backend reads the same values at launch from # defaults below, then passes the resolved values back to the script as
# the BOT_BOTTLE_FC_* env vars; if you override an option here, override # Environment=, so the host pool and the CLI launcher can't drift.
# 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;
# --- IPv4 <-> int (full 32-bit carry math) ------------------------- # --- shared single-source defaults ---------------------------------
toOctets = s: map lib.toInt (lib.splitString "." s); # Parse the KEY=VALUE defaults file (the same one netpool.py and the
ipToInt = s: # shell script read). Pure eval — just readFile, no import-from-
let o = toOctets s; in # derivation — so it works for both flake and channel consumers.
(lib.elemAt o 0) * 16777216 readDefaults = file:
+ (lib.elemAt o 1) * 65536
+ (lib.elemAt o 2) * 256
+ (lib.elemAt o 3);
intToIp = n:
let let
b0 = n / 16777216; r0 = n - b0 * 16777216; lines = lib.splitString "\n" (builtins.readFile file);
b1 = r0 / 65536; r1 = r0 - b1 * 65536; keep = l: l != "" && !(lib.hasPrefix "#" l) && lib.hasInfix "=" l;
b2 = r1 / 256; toPair = l:
b3 = r1 - b2 * 256; let parts = lib.splitString "=" l;
in "${toString b0}.${toString b1}.${toString b2}.${toString b3}"; in lib.nameValuePair (lib.head parts)
(lib.concatStringsSep "=" (lib.tail parts));
in lib.listToAttrs (map toPair (lib.filter keep lines));
baseInt = ipToInt cfg.ipBase; defaults = readDefaults ../bot_bottle/backend/firecracker/netpool.defaults.env;
# Slot i: host = base + 2i (the VM's gateway), on its own /31. # The one bring-up implementation, shared with the sudo/systemd paths.
slots = lib.genList (i: { netpoolScript = ../scripts/firecracker-netpool.sh;
iface = "${cfg.ifacePrefix}${toString i}";
hostIp = intToIp (baseInt + 2 * i);
}) cfg.poolSize;
ip = "${pkgs.iproute2}/bin/ip"; # /31 alignment == an even final octet (only bit 0 matters for base+2i).
nft = "${pkgs.nftables}/bin/nft"; lastOctet = lib.toInt (lib.last (lib.splitString "." cfg.ipBase));
# Idempotent ruleset: (create-if-absent → delete → recreate) is atomic # The script needs ip/nft/sysctl + the usual coreutils. It gets every
# within one `nft -f`, so re-running `up` always lands a clean table. # pool value via the unit's Environment=, so it never reads the shared
nftFile = pkgs.writeText "bot-bottle-fc.nft" '' # defaults file (which isn't beside it once copied to the store).
table inet ${cfg.tableName} runtimePath = with pkgs; [ iproute2 nftables procps coreutils gnused ];
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
}
}
'';
# A non-owner user can't open a user-owned TAP, but any member of a ownEnv =
# TAP's owning GROUP can (the kernel checks owner-uid OR owning-group). if cfg.group != null
# So `group` lets an interactive user and a CI-runner user share one then { BOT_BOTTLE_FC_GROUP = cfg.group; }
# pool; `owner` is the single-user default. else { BOT_BOTTLE_FC_OWNER = cfg.owner; };
tapOwn = if cfg.group != null then "group ${cfg.group}" else "user ${cfg.owner}";
upScript = pkgs.writeShellScript "bot-bottle-fc-netpool-up" '' unitEnv = {
set -eu BOT_BOTTLE_FC_POOL_SIZE = toString cfg.poolSize;
${lib.concatMapStringsSep "\n" (s: '' BOT_BOTTLE_FC_IP_BASE = cfg.ipBase;
${ip} link show ${s.iface} >/dev/null 2>&1 || ${ip} tuntap add dev ${s.iface} mode tap ${tapOwn} BOT_BOTTLE_FC_IFACE_PREFIX = cfg.ifacePrefix;
${ip} addr replace ${s.hostIp}/31 dev ${s.iface} BOT_BOTTLE_FC_NFT_TABLE = cfg.tableName;
${ip} link set ${s.iface} up } // ownEnv;
'') 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 = {
@@ -106,25 +72,28 @@ in
poolSize = lib.mkOption { poolSize = lib.mkOption {
type = lib.types.ints.positive; type = lib.types.ints.positive;
default = 8; default = lib.toInt defaults.BOT_BOTTLE_FC_POOL_SIZE;
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 = "10.243.0.0"; default = defaults.BOT_BOTTLE_FC_IP_BASE;
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 address) and must match be /31-aligned (even final octet) and must match
BOT_BOTTLE_FC_IP_BASE. Default is an obscure RFC-1918 /16 that BOT_BOTTLE_FC_IP_BASE. The shared default is an obscure RFC-1918
dodges docker/libvirt/k8s/LAN and, deliberately, Tailscale's /16 that dodges docker/libvirt/k8s/LAN and, deliberately,
100.64.0.0/10 CGNAT range. Tailscale's 100.64.0.0/10 CGNAT range.
''; '';
}; };
ifacePrefix = lib.mkOption { ifacePrefix = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "bbfc"; default = defaults.BOT_BOTTLE_FC_IFACE_PREFIX;
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.";
}; };
@@ -153,7 +122,8 @@ in
tableName = lib.mkOption { tableName = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "bot_bottle_fc"; default = defaults.BOT_BOTTLE_FC_NFT_TABLE;
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.";
}; };
@@ -171,7 +141,7 @@ in
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
assertions = [ assertions = [
{ {
assertion = lib.mod baseInt 2 == 0; assertion = lib.mod lastOctet 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}.";
} }
{ {
@@ -183,17 +153,20 @@ 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)
# No networking.nftables.enable / systemd.network.enable — see header. # by running the shared bring-up script. No networking.nftables.enable
# / 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 = upScript; ExecStart = "${pkgs.bash}/bin/bash ${netpoolScript} up";
ExecStop = downScript; ExecStop = "${pkgs.bash}/bin/bash ${netpoolScript} down";
}; };
}; };
@@ -202,6 +175,7 @@ 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}
''; '';
}; };
}; };
+35 -14
View File
@@ -33,10 +33,13 @@
# sudo ./scripts/firecracker-netpool.sh down # sudo ./scripts/firecracker-netpool.sh down
# ./scripts/firecracker-netpool.sh status # ./scripts/firecracker-netpool.sh status
# #
# Env overrides (must match the backend's util.py constants): # Pool params default to the shared single-source file
# BOT_BOTTLE_FC_POOL_SIZE number of slots (default 8) # (bot_bottle/backend/firecracker/netpool.defaults.env); a matching
# BOT_BOTTLE_FC_IP_BASE base IPv4 of the /31 pool (default 10.243.0.0) # env var overrides its key:
# BOT_BOTTLE_FC_IFACE_PREFIX TAP name prefix (default bbfc) # 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_OWNER owning user (default $SUDO_USER or $USER)
# BOT_BOTTLE_FC_GROUP owning group; if set, TAPs are group-owned # BOT_BOTTLE_FC_GROUP owning group; if set, TAPs are group-owned
# instead of user-owned, so any group member # instead of user-owned, so any group member
@@ -45,12 +48,29 @@
set -euo pipefail set -euo pipefail
POOL_SIZE="${BOT_BOTTLE_FC_POOL_SIZE:-8}" # The single source of the pool defaults, shared with netpool.py and the
IP_BASE="${BOT_BOTTLE_FC_IP_BASE:-10.243.0.0}" # Nix module. The Nix path passes every value as env (the script runs
PREFIX="${BOT_BOTTLE_FC_IFACE_PREFIX:-bbfc}" # 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)}"
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.
@@ -94,12 +114,13 @@ 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")"
if ip link show "$dev" >/dev/null 2>&1; then # Non-destructive + idempotent: only create a missing TAP (tearing
ip link set "$dev" down 2>/dev/null || true # an existing one down would cut a running VM), and `addr replace`
ip tuntap del dev "$dev" mode tap 2>/dev/null || true # is safe to re-run. Ownership is fixed at creation, so to change
fi # owner/group run `down` then `up`.
ip tuntap add dev "$dev" mode tap "${own_args[@]}" ip link show "$dev" >/dev/null 2>&1 \
ip addr add "$host/31" dev "$dev" || ip tuntap add dev "$dev" mode tap "${own_args[@]}"
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
@@ -103,8 +103,6 @@ class TestLaunchCommittedImage(unittest.TestCase):
launch_mod.docker_mod, "image_exists", return_value=image_present, launch_mod.docker_mod, "image_exists", return_value=image_present,
), mock.patch.object( ), mock.patch.object(
launch_mod.docker_mod, "build_image", side_effect=fake_build, launch_mod.docker_mod, "build_image", side_effect=fake_build,
), mock.patch.object(
launch_mod.docker_mod, "verify_agent_image",
), mock.patch.object( ), mock.patch.object(
launch_mod, "egress_tls_init", launch_mod, "egress_tls_init",
return_value=(Path("/egress_ca"), Path("/egress_cert")), return_value=(Path("/egress_ca"), Path("/egress_cert")),
@@ -87,7 +87,6 @@ class TestTeardownWarning(unittest.TestCase):
buf = io.StringIO() buf = io.StringIO()
with mock.patch.object(launch_mod.docker_mod, "build_image"), \ with mock.patch.object(launch_mod.docker_mod, "build_image"), \
mock.patch.object(launch_mod.docker_mod, "verify_agent_image"), \
mock.patch.object( mock.patch.object(
launch_mod, "egress_tls_init", launch_mod, "egress_tls_init",
return_value=(Path("/egress_ca"), Path("/egress_cert")), return_value=(Path("/egress_ca"), Path("/egress_cert")),
+50 -19
View File
@@ -67,10 +67,24 @@ 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("group ${cfg.group}", mod) 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
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"}):
@@ -262,26 +276,43 @@ class TestBottleAgentArgv(unittest.TestCase):
self.assertIn("USER=node", argv) self.assertIn("USER=node", argv)
class TestSetupScriptConsistency(unittest.TestCase): class TestNetpoolDefaultsSingleSource(unittest.TestCase):
"""The shell setup script duplicates the pool defaults; keep them in """The pool defaults live in one shared file (netpool.defaults.env);
lockstep with the Python constants so the two setup paths agree.""" Python parses it and the shell script + Nix module read the same file,
so the three setup paths can't drift."""
def _script(self) -> str: def _root(self) -> Path:
root = Path(__file__).resolve().parent.parent.parent return Path(__file__).resolve().parents[2]
return (root / "scripts" / "firecracker-netpool.sh").read_text()
def test_defaults_match_python(self): def _shared_defaults(self) -> dict[str, str]:
script = self._script() 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()
with patch.dict(os.environ, {}, clear=True): with patch.dict(os.environ, {}, clear=True):
self.assertIn(f'POOL_SIZE:-{netpool.pool_size()}', script) self.assertEqual(int(d["BOT_BOTTLE_FC_POOL_SIZE"]), netpool.pool_size())
self.assertIn(f'BOT_BOTTLE_FC_IP_BASE:-{netpool.ip_base()}', script) self.assertEqual(d["BOT_BOTTLE_FC_IP_BASE"], netpool.ip_base())
self.assertIn(f'IFACE_PREFIX:-{netpool.IFACE_PREFIX}', script) # 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)
def test_table_name_and_ports_match(self): def test_env_var_overrides_the_shared_default(self):
script = self._script() with patch.dict(os.environ, {"BOT_BOTTLE_FC_IP_BASE": "10.99.0.0"}):
self.assertIn(f'TABLE="{netpool.NFT_TABLE}"', script) self.assertEqual("10.99.0.0", netpool.ip_base())
for port in netpool.SIDECAR_PORTS:
self.assertIn(str(port), script) 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)
class TestBootArgs(unittest.TestCase): class TestBootArgs(unittest.TestCase):
+1 -6
View File
@@ -357,17 +357,12 @@ class TestMacosContainerLaunchCommittedImage(unittest.TestCase):
launch.container_mod, "image_exists", return_value=False, launch.container_mod, "image_exists", return_value=False,
), patch.object( ), patch.object(
launch.container_mod, "build_image", side_effect=fake_build, launch.container_mod, "build_image", side_effect=fake_build,
), patch.object( ):
launch.container_mod, "verify_agent_image",
) as verify:
updated = launch._build_images(plan) updated = launch._build_images(plan)
self.assertEqual("bot-bottle-agent:latest", updated.image) self.assertEqual("bot-bottle-agent:latest", updated.image)
self.assertEqual(2, len(calls)) self.assertEqual(2, len(calls))
self.assertEqual("bot-bottle-agent:latest", calls[1][0]) self.assertEqual("bot-bottle-agent:latest", calls[1][0])
verify.assert_called_once_with(
"bot-bottle-agent:latest", ("claude", "--version"),
)
if __name__ == "__main__": if __name__ == "__main__":