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
77 lines
3.0 KiB
Python
77 lines
3.0 KiB
Python
"""FirecrackerFreezer — snapshot a running microVM to a Docker image.
|
|
|
|
The VM is live and can't be block-copied safely, so — like the macOS
|
|
backend — we stream the guest root filesystem out over the control
|
|
channel (SSH here) and rebuild an image from it. The bottle keeps
|
|
running after the snapshot.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from ...log import die, info
|
|
from .. import ActiveAgent
|
|
from ..freeze import Freezer
|
|
from . import util
|
|
|
|
|
|
class FirecrackerFreezer(Freezer):
|
|
backend_name = "firecracker"
|
|
|
|
def _freeze(self, agent: ActiveAgent) -> str:
|
|
run_dir = util.cache_dir() / "run" / agent.slug
|
|
private_key = run_dir / "bottle_id_ed25519"
|
|
guest_ip = _guest_ip_from_config(run_dir / "config.json")
|
|
if not private_key.is_file() or not guest_ip:
|
|
die(f"cannot freeze {agent.slug}: run dir {run_dir} is missing the "
|
|
f"SSH key or VM config (is the bottle still running?)")
|
|
image_tag = f"bot-bottle-committed-{agent.slug}:latest"
|
|
_commit_via_ssh(private_key, guest_ip, image_tag)
|
|
info(f"committed {agent.slug} -> {image_tag!r}")
|
|
return image_tag
|
|
|
|
def _export_hint(self, slug: str, image_ref: str) -> None:
|
|
info(f"to export for migration: docker image save {image_ref} "
|
|
f"-o {slug}.tar")
|
|
|
|
|
|
def _guest_ip_from_config(config_path: Path) -> str:
|
|
try:
|
|
cfg = json.loads(config_path.read_text())
|
|
except (OSError, json.JSONDecodeError):
|
|
return ""
|
|
boot_args = cfg.get("boot-source", {}).get("boot_args", "")
|
|
for token in boot_args.split():
|
|
if token.startswith("ip="):
|
|
# ip=<client>::<gw>:<mask>::<dev>:off
|
|
return token[len("ip="):].split(":", 1)[0]
|
|
return ""
|
|
|
|
|
|
def _commit_via_ssh(private_key: Path, guest_ip: str, image_tag: str) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="bot-bottle-fc-commit.") as tmp:
|
|
rootfs_tar = os.path.join(tmp, "rootfs.tar")
|
|
ssh = util.ssh_base_argv(private_key, guest_ip)
|
|
with open(rootfs_tar, "wb") as tar_out:
|
|
result = subprocess.run(
|
|
[*ssh, "--", "tar", "--create", "--one-file-system",
|
|
"--exclude=./proc", "--exclude=./sys", "--exclude=./dev",
|
|
"--exclude=./run", "--file=-", "--directory=/", "."],
|
|
stdout=tar_out, stderr=subprocess.PIPE, check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
die(f"ssh tar for {guest_ip} failed: "
|
|
f"{(result.stderr or b'').decode().strip() or '<no stderr>'}")
|
|
with open(os.path.join(tmp, "Dockerfile"), "w", encoding="utf-8") as f:
|
|
f.write("FROM scratch\nADD rootfs.tar /\nUSER node\nWORKDIR /home/node\n")
|
|
build = subprocess.run(
|
|
["docker", "build", "-t", image_tag, tmp], check=False,
|
|
)
|
|
if build.returncode != 0:
|
|
die(f"docker build for {image_tag!r} failed")
|