Compare commits

..

1 Commits

Author SHA1 Message Date
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
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
17 changed files with 272 additions and 283 deletions
-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__":