c276f7b0b1
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
91 lines
2.5 KiB
Python
91 lines
2.5 KiB
Python
"""Cleanup for the Firecracker backend.
|
|
|
|
Orphans are: firecracker VMM processes whose config lives under our run
|
|
dir, the `bot-bottle-sidecars-*` containers, and the per-bottle run
|
|
dirs. TAP slots free themselves (the flock drops when the launcher
|
|
exits), so there is nothing to reclaim there.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from ...log import info
|
|
from . import util
|
|
from .bottle_cleanup_plan import FirecrackerBottleCleanupPlan
|
|
|
|
_SIDECAR_PREFIX = "bot-bottle-sidecars-"
|
|
|
|
|
|
def _run_root() -> Path:
|
|
return util.cache_dir() / "run"
|
|
|
|
|
|
def _orphan_vm_pids() -> list[int]:
|
|
"""firecracker processes whose --config-file is under our run dir."""
|
|
run_root = str(_run_root())
|
|
result = subprocess.run(
|
|
["pgrep", "-a", "firecracker"],
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
return []
|
|
pids: list[int] = []
|
|
for line in result.stdout.splitlines():
|
|
parts = line.split(None, 1)
|
|
if len(parts) != 2 or run_root not in parts[1]:
|
|
continue
|
|
try:
|
|
pids.append(int(parts[0]))
|
|
except ValueError:
|
|
continue
|
|
return pids
|
|
|
|
|
|
def _sidecar_containers() -> list[str]:
|
|
result = subprocess.run(
|
|
["docker", "ps", "-a", "--format", "{{.Names}}",
|
|
"--filter", f"name={_SIDECAR_PREFIX}"],
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
return []
|
|
return sorted(n.strip() for n in result.stdout.splitlines() if n.strip())
|
|
|
|
|
|
def _run_dirs() -> list[str]:
|
|
run_root = _run_root()
|
|
if not run_root.is_dir():
|
|
return []
|
|
return sorted(str(p) for p in run_root.iterdir() if p.is_dir())
|
|
|
|
|
|
def prepare_cleanup() -> FirecrackerBottleCleanupPlan:
|
|
return FirecrackerBottleCleanupPlan(
|
|
vm_pids=tuple(_orphan_vm_pids()),
|
|
containers=tuple(_sidecar_containers()),
|
|
run_dirs=tuple(_run_dirs()),
|
|
)
|
|
|
|
|
|
def cleanup(plan: FirecrackerBottleCleanupPlan) -> None:
|
|
for pid in plan.vm_pids:
|
|
info(f"kill firecracker VM pid {pid}")
|
|
try:
|
|
os.kill(pid, signal.SIGTERM)
|
|
except ProcessLookupError:
|
|
pass
|
|
for name in plan.containers:
|
|
info(f"docker rm -f {name}")
|
|
subprocess.run(
|
|
["docker", "rm", "-f", name],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
|
)
|
|
for path in plan.run_dirs:
|
|
info(f"rm -rf {path}")
|
|
shutil.rmtree(path, ignore_errors=True)
|