diff --git a/bot_bottle/backend/firecracker/image_builder.py b/bot_bottle/backend/firecracker/image_builder.py index 176ce31..bf5a473 100644 --- a/bot_bottle/backend/firecracker/image_builder.py +++ b/bot_bottle/backend/firecracker/image_builder.py @@ -38,25 +38,39 @@ _BUILD_TIMEOUT_SECONDS = 900.0 def _dockerfile_hash(dockerfile: Path) -> str: - """Cache key: the Dockerfile's content. The shipped agent Dockerfiles - COPY nothing from the build context (see .dockerignore), so their content - fully determines the image; a Dockerfile that adds COPY will want the + """The Dockerfile's content hash. The shipped agent Dockerfiles COPY + nothing from the build context (see .dockerignore), so their content fully + determines the built image; a Dockerfile that adds COPY will want the context folded in here too.""" return hashlib.sha256(dockerfile.read_bytes()).hexdigest()[:16] +def _rootfs_digest(dockerfile: Path) -> str: + """Cache key for the built AND boot-injected agent rootfs. Two inputs + determine the on-disk rootfs: the Dockerfile (the image) and the guest init + injected into it (`util._GUEST_INIT`). Folding the init in means a fix to + it — e.g. making /tmp world-writable — busts the cache instead of silently + reusing a stale rootfs built with the old init.""" + h = hashlib.sha256() + h.update(_dockerfile_hash(dockerfile).encode()) + h.update(b"\0") + h.update(util._GUEST_INIT.encode()) + return h.hexdigest()[:16] + + def build_agent_rootfs_dir( dockerfile: Path, *, image_tag: str, smoke_test: tuple[str, ...] = (), ) -> Path: """Build `dockerfile` in the infra VM (buildah, no host docker), export its rootfs, inject the guest boot bits, and return the cached base dir — the - same shape `util.build_rootfs_ext4` consumes. Cached by Dockerfile content, - so a repeat launch skips the rebuild. + same shape `util.build_rootfs_ext4` consumes. Cached by Dockerfile content + + injected guest init, so a repeat launch skips the rebuild but an init or + Dockerfile change rebuilds. `smoke_test` (the provider's declared argv, e.g. `("claude","--version")`) is run in the freshly built image before export, catching an npm silent-failure image at build time rather than at first agent use.""" - digest = _dockerfile_hash(dockerfile) + digest = _rootfs_digest(dockerfile) base = util.cache_dir() / "rootfs" / f"agent-{digest}" if (base / ".bb-ready").is_file(): info(f"using cached agent rootfs {base.name}") diff --git a/tests/unit/test_firecracker_image_builder.py b/tests/unit/test_firecracker_image_builder.py index 40e132c..7191e6a 100644 --- a/tests/unit/test_firecracker_image_builder.py +++ b/tests/unit/test_firecracker_image_builder.py @@ -24,7 +24,7 @@ class TestBuildAgentRootfsDir(unittest.TestCase): self.addCleanup(self._tmp.cleanup) def test_cache_hit_skips_rebuild(self): - digest = image_builder._dockerfile_hash(self.dockerfile) + digest = image_builder._rootfs_digest(self.dockerfile) base = self.cache / "rootfs" / f"agent-{digest}" base.mkdir(parents=True) (base / ".bb-ready").write_text("ok\n") @@ -55,6 +55,17 @@ class TestBuildAgentRootfsDir(unittest.TestCase): image_builder._dockerfile_hash(other), ) + def test_rootfs_digest_tracks_dockerfile_and_init(self): + # Same Dockerfile, different injected init -> different rootfs key, so + # an init fix (e.g. /tmp perms) rebuilds instead of reusing a stale + # rootfs; different Dockerfiles also differ. + base = image_builder._rootfs_digest(self.dockerfile) + with patch.object(image_builder.util, "_GUEST_INIT", "#!/bin/sh\n# changed\n"): + self.assertNotEqual(base, image_builder._rootfs_digest(self.dockerfile)) + other = self.cache / "Dockerfile2" + other.write_text("FROM python:3.12-slim\n") + self.assertNotEqual(base, image_builder._rootfs_digest(other)) + class TestSmokeTest(unittest.TestCase): def test_empty_argv_is_noop(self):