feat(firecracker): build agent images in a builder VM, not host docker (Stage 3)
Replace the host `docker build` + `docker export` behind the Firecracker agent rootfs with an in-VM buildah build. `image_builder.build_agent_rootfs_dir` boots a throwaway builder VM (the orchestrator image, which carries buildah) on the NAT'd orchestrator link, sends the Dockerfile over SSH, `buildah build`s it, smoke-tests the result with `buildah run` (the image's own PATH, so it catches an npm silent-failure stub), and streams the rootfs tar back into the content-addressed cache dir — the same base dir `util.build_rootfs_ext4` already turns into a bootable ext4 with `mke2fs -d`. No host Docker daemon, no root-equivalent `docker` group; an untrusted Dockerfile runs in a confined microVM, not on the host. - new firecracker/image_builder.py (boot → build → smoke → stream). - launch.py: `_build_agent_image` → `_build_agent_base`, returning the base dir from the builder VM. The committed-snapshot (freeze/migrate) path still exports via host docker until it too is ported. - util: `_inject_guest_boot` → public `inject_guest_boot` (shared with the builder). unit tests for the cache decision + smoke-test paths. Verified on a KVM host end-to-end: builds the real claude Dockerfile (node:22-slim + npm claude-code) in-VM in ~60s, the produced agent VM boots and `claude --version` returns 2.1.172; the content cache skips the rebuild on a repeat launch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
"""Unit tests for the docker-free Firecracker agent-image builder.
|
||||
|
||||
The VM boot / SSH / buildah plumbing (`_build_in_vm`) is integration-tested
|
||||
on a KVM host; here we cover the cache decision, the boot-bit injection, and
|
||||
the smoke-test no-op — the logic that must hold without a VM.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.backend.firecracker import image_builder
|
||||
|
||||
|
||||
class TestBuildAgentRootfsDir(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.cache = Path(self._tmp.name)
|
||||
self.dockerfile = self.cache / "Dockerfile"
|
||||
self.dockerfile.write_text("FROM node:22-slim\n")
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
|
||||
def test_cache_hit_skips_rebuild(self):
|
||||
digest = image_builder._dockerfile_hash(self.dockerfile)
|
||||
base = self.cache / "rootfs" / f"agent-{digest}"
|
||||
base.mkdir(parents=True)
|
||||
(base / ".bb-ready").write_text("ok\n")
|
||||
with patch.object(image_builder.util, "cache_dir", return_value=self.cache), \
|
||||
patch.object(image_builder, "_build_in_vm") as build:
|
||||
out = image_builder.build_agent_rootfs_dir(
|
||||
self.dockerfile, image_tag="t:latest")
|
||||
build.assert_not_called()
|
||||
self.assertEqual(base, out)
|
||||
|
||||
def test_cache_miss_builds_injects_and_marks_ready(self):
|
||||
with patch.object(image_builder.util, "cache_dir", return_value=self.cache), \
|
||||
patch.object(image_builder, "_build_in_vm") as build, \
|
||||
patch.object(image_builder.util, "inject_guest_boot") as inject:
|
||||
out = image_builder.build_agent_rootfs_dir(
|
||||
self.dockerfile, image_tag="t:latest", smoke_test=("claude", "--version"))
|
||||
build.assert_called_once()
|
||||
# smoke_test threads through to the VM build.
|
||||
self.assertEqual(("claude", "--version"), build.call_args.args[2])
|
||||
inject.assert_called_once_with(out)
|
||||
self.assertTrue((out / ".bb-ready").is_file())
|
||||
|
||||
def test_content_addressed_cache_key(self):
|
||||
other = self.cache / "Dockerfile2"
|
||||
other.write_text("FROM python:3.12-slim\n")
|
||||
self.assertNotEqual(
|
||||
image_builder._dockerfile_hash(self.dockerfile),
|
||||
image_builder._dockerfile_hash(other),
|
||||
)
|
||||
|
||||
|
||||
class TestSmokeTest(unittest.TestCase):
|
||||
def test_empty_argv_is_noop(self):
|
||||
with patch.object(image_builder, "_ssh") as ssh:
|
||||
image_builder._smoke_test(Path("/k"), "10.0.0.1", ())
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_failed_smoke_dies(self):
|
||||
import subprocess
|
||||
result = subprocess.CompletedProcess([], 1, stdout="broken", stderr="")
|
||||
with patch.object(image_builder, "_ssh", return_value=result), \
|
||||
self.assertRaises(SystemExit):
|
||||
image_builder._smoke_test(Path("/k"), "10.0.0.1", ("claude", "--version"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user