feat(firecracker): add Linux microVM backend to replace smolmachines
Adds a Firecracker-based backend for Linux, providing mature KVM-based microVM isolation to replace smolmachines/libkrun (issue #342, closes the dead-end tracked in #332). Architecture: - Guest control over SSH (dropbear injected into the rootfs) on a point-to-point TAP link. `ssh -t` forwards SIGWINCH natively, so no resize bridge is needed. - Networking: a one-time, root-provisioned pool of user-owned TAP devices (no shared bridge → no docker0/virbr0/cni0 collisions) plus a dedicated `table inet bot_bottle_fc` nftables table (independent of Docker/ufw/firewalld rules). `./cli.py firecracker setup` prints the host-appropriate config (NixOS module or sudo script). - Rootfs: `docker export` → ext4 via `mke2fs -d` (rootless, no mount), cached by image digest; per-bottle SSH pubkey + IP passed via the kernel cmdline. - Sidecar: reuses the Docker bundle, published on the slot's host TAP IP. - Fail-closed isolation: TAP pool verified at preflight; the egress boundary is proven empirically post-boot (before the agent runs) by a canary probe — the VM must fail to reach the host directly, or launch is refused. Linux hosts with Firecracker + KVM now default to this backend; macOS stays on macos-container. Not yet validated end-to-end on live hardware (requires the one-time network pool). Unit tests + pyright pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8p32HJgPoS1hLPWubbftM
This commit is contained in:
@@ -64,6 +64,24 @@ class TestGetBottleBackend(unittest.TestCase):
|
||||
b = get_bottle_backend()
|
||||
self.assertEqual("smolmachines", b.name)
|
||||
|
||||
def test_default_firecracker_when_macos_unavailable_but_fc_available(self):
|
||||
class _FakeBackend:
|
||||
def __init__(self, name: str, available: bool) -> None:
|
||||
self.name = name
|
||||
self._available = available
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True), \
|
||||
patch.object(backend_mod, "_BACKENDS", {
|
||||
"macos-container": _FakeBackend("macos-container", False),
|
||||
"firecracker": _FakeBackend("firecracker", True),
|
||||
"smolmachines": _FakeBackend("smolmachines", True),
|
||||
}):
|
||||
b = get_bottle_backend()
|
||||
self.assertEqual("firecracker", b.name)
|
||||
|
||||
def test_unknown_dies(self):
|
||||
with patch.object(backend_mod, "die", side_effect=SystemExit("die")):
|
||||
with self.assertRaises(SystemExit):
|
||||
@@ -73,7 +91,7 @@ class TestGetBottleBackend(unittest.TestCase):
|
||||
class TestKnownBackendNames(unittest.TestCase):
|
||||
def test_returns_backends_sorted(self):
|
||||
self.assertEqual(
|
||||
("docker", "macos-container", "smolmachines"),
|
||||
("docker", "firecracker", "macos-container", "smolmachines"),
|
||||
known_backend_names(),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Unit: Firecracker backend helpers.
|
||||
|
||||
Covers the pieces that run without KVM/root: pool IP math + config
|
||||
renderers + allocation, the SSH-backed bottle's argv construction, and
|
||||
the VM boot-arg assembly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.backend.firecracker import firecracker_vm, netpool
|
||||
from bot_bottle.backend.firecracker.bottle import FirecrackerBottle
|
||||
|
||||
|
||||
def _bottle(**kw: Any) -> FirecrackerBottle:
|
||||
defaults = dict(
|
||||
name="bot-bottle-dev-abc",
|
||||
private_key=Path("/tmp/key"),
|
||||
guest_ip="100.64.0.1",
|
||||
)
|
||||
defaults.update(kw)
|
||||
return FirecrackerBottle(**defaults) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestNetpoolSlots(unittest.TestCase):
|
||||
def test_slot_ip_math_31_pairs(self):
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_FC_IP_BASE": "100.64.0.0"}):
|
||||
s0, s1 = netpool.slot(0), netpool.slot(1)
|
||||
self.assertEqual(("bbfc0", "100.64.0.0", "100.64.0.1"),
|
||||
(s0.iface, s0.host_ip, s0.guest_ip))
|
||||
self.assertEqual(("bbfc1", "100.64.0.2", "100.64.0.3"),
|
||||
(s1.iface, s1.host_ip, s1.guest_ip))
|
||||
|
||||
def test_guest_cidr_is_31(self):
|
||||
self.assertEqual("100.64.0.1/31", netpool.slot(0).guest_cidr)
|
||||
|
||||
def test_pool_size_env_override(self):
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_FC_POOL_SIZE": "4"}):
|
||||
self.assertEqual(4, netpool.pool_size())
|
||||
self.assertEqual(4, len(netpool.all_slots()))
|
||||
|
||||
|
||||
class TestNetpoolRenderers(unittest.TestCase):
|
||||
def test_nixos_module_has_table_and_taps(self):
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_FC_POOL_SIZE": "2"}):
|
||||
out = netpool.render_nixos_module()
|
||||
self.assertIn('networking.nftables.tables."bot_bottle_fc"', out)
|
||||
self.assertIn('Name = "bbfc0"', out)
|
||||
self.assertIn('Name = "bbfc1"', out)
|
||||
self.assertIn("net.ipv4.ip_forward", out)
|
||||
# fail-closed isolation rules present
|
||||
self.assertIn('iifname != "bbfc*" return', out)
|
||||
self.assertIn("ct status dnat accept", out)
|
||||
|
||||
def test_shell_setup_reflects_overrides(self):
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_FC_POOL_SIZE": "3"}):
|
||||
out = netpool.render_shell_setup()
|
||||
self.assertIn("BOT_BOTTLE_FC_POOL_SIZE=3", out)
|
||||
self.assertIn("firecracker-netpool.sh up", out)
|
||||
|
||||
|
||||
class TestNetpoolAllocation(unittest.TestCase):
|
||||
def test_allocate_is_mutually_exclusive(self):
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_FC_POOL_SIZE": "1"}):
|
||||
# First allocation grabs the only slot; the lock is held
|
||||
# until the handle is closed, so a second call must fail
|
||||
# over (and here, exhaust the pool).
|
||||
slot, lock = netpool.allocate("first")
|
||||
self.addCleanup(lock.close)
|
||||
self.assertEqual("bbfc0", slot.iface)
|
||||
with patch.object(netpool, "die",
|
||||
side_effect=SystemExit("exhausted")):
|
||||
with self.assertRaises(SystemExit):
|
||||
netpool.allocate("second")
|
||||
|
||||
def test_allocate_releases_on_close(self):
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_FC_POOL_SIZE": "1"}):
|
||||
slot, lock = netpool.allocate("first")
|
||||
lock.close()
|
||||
# Slot is free again after close.
|
||||
slot2, lock2 = netpool.allocate("second")
|
||||
self.addCleanup(lock2.close)
|
||||
self.assertEqual(slot.iface, slot2.iface)
|
||||
|
||||
|
||||
class TestBottleAgentArgv(unittest.TestCase):
|
||||
def test_interactive_uses_ssh_tty_and_runuser(self):
|
||||
argv = _bottle().agent_argv([], tty=True)
|
||||
self.assertEqual("ssh", argv[0])
|
||||
self.assertIn("-t", argv)
|
||||
self.assertIn("100.64.0.1", " ".join(argv))
|
||||
idx = argv.index("--")
|
||||
self.assertEqual(["runuser", "-u", "node", "--"], argv[idx + 1:idx + 5])
|
||||
self.assertIn("claude", argv)
|
||||
|
||||
def test_non_interactive_has_no_tty(self):
|
||||
argv = _bottle().agent_argv([], tty=False)
|
||||
self.assertEqual("ssh", argv[0])
|
||||
self.assertNotIn("-t", argv)
|
||||
|
||||
def test_appends_extra_args_after_command(self):
|
||||
argv = _bottle().agent_argv(
|
||||
["--dangerously-skip-permissions", "--continue"], tty=False,
|
||||
)
|
||||
idx = argv.index("claude")
|
||||
self.assertEqual(
|
||||
["claude", "--dangerously-skip-permissions", "--continue"],
|
||||
argv[idx:],
|
||||
)
|
||||
|
||||
def test_prompt_file_flag_injected(self):
|
||||
argv = _bottle(
|
||||
prompt_path_in_guest="/home/node/.bot-bottle-prompt.txt",
|
||||
).agent_argv(["--continue"], tty=False)
|
||||
idx = argv.index("claude")
|
||||
self.assertEqual(
|
||||
["claude", "--continue",
|
||||
"--append-system-prompt-file", "/home/node/.bot-bottle-prompt.txt"],
|
||||
argv[idx:],
|
||||
)
|
||||
|
||||
def test_workdir_wraps_command(self):
|
||||
argv = _bottle(agent_workdir="/home/node/workspace").agent_argv([], tty=False)
|
||||
self.assertIn("sh", argv)
|
||||
joined = " ".join(argv)
|
||||
self.assertIn("cd /home/node/workspace", joined)
|
||||
|
||||
def test_guest_env_injected(self):
|
||||
argv = _bottle(guest_env={"HTTPS_PROXY": "http://100.64.0.0:9099"}).agent_argv(
|
||||
[], tty=False,
|
||||
)
|
||||
self.assertIn("HTTPS_PROXY=http://100.64.0.0:9099", argv)
|
||||
self.assertIn("HOME=/home/node", argv)
|
||||
self.assertIn("USER=node", argv)
|
||||
|
||||
|
||||
class TestSetupScriptConsistency(unittest.TestCase):
|
||||
"""The shell setup script duplicates the pool defaults; keep them in
|
||||
lockstep with the Python constants so the two setup paths agree."""
|
||||
|
||||
def _script(self) -> str:
|
||||
root = Path(__file__).resolve().parent.parent.parent
|
||||
return (root / "scripts" / "firecracker-netpool.sh").read_text()
|
||||
|
||||
def test_defaults_match_python(self):
|
||||
script = self._script()
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.assertIn(f'POOL_SIZE:-{netpool.pool_size()}', script)
|
||||
self.assertIn(f'BOT_BOTTLE_FC_IP_BASE:-{netpool.ip_base()}', script)
|
||||
self.assertIn(f'IFACE_PREFIX:-{netpool.IFACE_PREFIX}', script)
|
||||
|
||||
def test_table_name_and_ports_match(self):
|
||||
script = self._script()
|
||||
self.assertIn(f'TABLE="{netpool.NFT_TABLE}"', script)
|
||||
for port in netpool.SIDECAR_PORTS:
|
||||
self.assertIn(str(port), script)
|
||||
|
||||
|
||||
class TestBootArgs(unittest.TestCase):
|
||||
def test_boot_args_carry_ip_and_pubkey(self):
|
||||
args = firecracker_vm._boot_args(
|
||||
guest_ip="100.64.0.1", host_ip="100.64.0.0", pubkey="ssh-ed25519 AAAA x",
|
||||
)
|
||||
self.assertIn("ip=100.64.0.1::100.64.0.0:255.255.255.254::eth0:off", args)
|
||||
self.assertIn("init=/bb-init", args)
|
||||
b64 = base64.b64encode(b"ssh-ed25519 AAAA x").decode()
|
||||
self.assertIn(f"bb_pubkey={b64}", args)
|
||||
|
||||
def test_config_has_rootfs_and_tap(self):
|
||||
cfg = cast(Any, firecracker_vm._config(
|
||||
rootfs=Path("/run/rootfs.ext4"), tap="bbfc0",
|
||||
guest_ip="100.64.0.1", host_ip="100.64.0.0", pubkey="k",
|
||||
vcpus=2, mem_mib=2048, guest_mac="06:00:AC:10:00:02",
|
||||
))
|
||||
self.assertEqual("/run/rootfs.ext4", cfg["drives"][0]["path_on_host"])
|
||||
self.assertFalse(cfg["drives"][0]["is_read_only"])
|
||||
self.assertEqual("bbfc0", cfg["network-interfaces"][0]["host_dev_name"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user