fix(firecracker): honor cached image policy
test / integration (pull_request) Successful in 9s
test / unit (pull_request) Successful in 32s
test / coverage (pull_request) Successful in 36s
lint / lint (push) Successful in 47s
tracker-policy-pr / check-pr (pull_request) Successful in 6s

This commit is contained in:
2026-07-18 21:18:50 +00:00
parent 9004f3eb28
commit fa3e45cc54
6 changed files with 115 additions and 20 deletions
+6 -5
View File
@@ -93,16 +93,17 @@ class FirecrackerBottleBackend(
) )
def _build_or_load_images(self, plan: FirecrackerBottlePlan) -> BottleImages: def _build_or_load_images(self, plan: FirecrackerBottlePlan) -> BottleImages:
# Firecracker builds its rootfs image inside _launch_impl (via buildah in return BottleImages(agent=_launch.build_or_load_agent_base(plan))
# a builder VM), so there is nothing to pre-resolve here.
return BottleImages(agent=plan.image) def prelaunch_checks(self, plan: FirecrackerBottlePlan) -> None:
_launch.stale_checks(plan)
@contextmanager @contextmanager
def _launch_impl( def _launch_impl(
self, plan: FirecrackerBottlePlan, images: BottleImages, self, plan: FirecrackerBottlePlan, images: BottleImages,
) -> Generator[FirecrackerBottle, None, None]: ) -> Generator[FirecrackerBottle, None, None]:
del images # rootfs built inside _launch.launch; images unused here assert isinstance(images.agent, Path)
with _launch.launch(plan, provision=self.provision) as bottle: with _launch.launch(plan, images.agent, provision=self.provision) as bottle:
yield bottle yield bottle
def prepare_cleanup(self) -> FirecrackerBottleCleanupPlan: def prepare_cleanup(self) -> FirecrackerBottleCleanupPlan:
+1 -1
View File
@@ -5,7 +5,7 @@ backend — we stream the guest root filesystem out over the control
channel (SSH here). Unlike the other backends this needs no Docker: the channel (SSH here). Unlike the other backends this needs no Docker: the
tar *is* the resumable artifact. `resume` extracts it and rebuilds a tar *is* the resumable artifact. `resume` extracts it and rebuilds a
fresh per-bottle ext4 with `mke2fs -d` (see `util.build_committed_rootfs_dir` fresh per-bottle ext4 with `mke2fs -d` (see `util.build_committed_rootfs_dir`
and `launch._build_agent_base`). The bottle keeps running after the and `launch.build_or_load_agent_base`). The bottle keeps running after the
snapshot. snapshot.
""" """
@@ -45,6 +45,13 @@ def _dockerfile_hash(dockerfile: Path) -> str:
return hashlib.sha256(dockerfile.read_bytes()).hexdigest()[:16] return hashlib.sha256(dockerfile.read_bytes()).hexdigest()[:16]
def cached_agent_rootfs_dir(dockerfile: Path) -> Path | None:
"""Return the ready cached rootfs for ``dockerfile``, if one exists."""
digest = _dockerfile_hash(dockerfile)
base = util.cache_dir() / "rootfs" / f"agent-{digest}"
return base if (base / ".bb-ready").is_file() else None
def build_agent_rootfs_dir( def build_agent_rootfs_dir(
dockerfile: Path, *, image_tag: str, smoke_test: tuple[str, ...] = (), dockerfile: Path, *, image_tag: str, smoke_test: tuple[str, ...] = (),
) -> Path: ) -> Path:
@@ -58,7 +65,7 @@ def build_agent_rootfs_dir(
silent-failure image at build time rather than at first agent use.""" silent-failure image at build time rather than at first agent use."""
digest = _dockerfile_hash(dockerfile) digest = _dockerfile_hash(dockerfile)
base = util.cache_dir() / "rootfs" / f"agent-{digest}" base = util.cache_dir() / "rootfs" / f"agent-{digest}"
if (base / ".bb-ready").is_file(): if cached_agent_rootfs_dir(dockerfile) is not None:
info(f"using cached agent rootfs {base.name}") info(f"using cached agent rootfs {base.name}")
return base return base
+34 -13
View File
@@ -45,7 +45,8 @@ from ...git_gate import (
provision_git_gate_dynamic_keys, provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys, revoke_git_gate_provisioned_keys,
) )
from ...log import info, warn from ...image_cache import check_stale_path
from ...log import die, info, warn
from ...supervise import SUPERVISE_PORT from ...supervise import SUPERVISE_PORT
from ..docker.egress import EGRESS_PORT from ..docker.egress import EGRESS_PORT
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
@@ -64,6 +65,7 @@ _GIT_HTTP_PORT = 9420
@contextmanager @contextmanager
def launch( def launch(
plan: FirecrackerBottlePlan, plan: FirecrackerBottlePlan,
agent_base: Path,
*, *,
provision: Callable[[FirecrackerBottlePlan, "FirecrackerBottle"], str | None], provision: Callable[[FirecrackerBottlePlan, "FirecrackerBottle"], str | None],
) -> Generator[FirecrackerBottle, None, None]: ) -> Generator[FirecrackerBottle, None, None]:
@@ -85,11 +87,9 @@ def launch(
raise teardown_exc raise teardown_exc
try: try:
# Step 1: agent rootfs. Built from the Dockerfile inside a Firecracker # Step 1 (rootfs resolution/build) runs in BottleBackend.launch before
# builder VM (buildah, no host docker); a committed snapshot is reused # this context starts resources. ``agent_base`` is the selected cache,
# when present. Returns the base dir the per-bottle ext4 is made from. # fresh build, or committed snapshot.
plan, agent_base = _build_agent_base(plan)
# Step 2: mint the git-gate dynamic (gitea) deploy keys, if any. # Step 2: mint the git-gate dynamic (gitea) deploy keys, if any.
git_gate_plan = plan.git_gate_plan git_gate_plan = plan.git_gate_plan
if git_gate_plan.upstreams: if git_gate_plan.upstreams:
@@ -206,9 +206,7 @@ def launch(
teardown() teardown()
def _build_agent_base( def build_or_load_agent_base(plan: FirecrackerBottlePlan) -> Path:
plan: FirecrackerBottlePlan,
) -> tuple[FirecrackerBottlePlan, Path]:
"""Produce the agent's base rootfs dir. Primary path: build the Dockerfile """Produce the agent's base rootfs dir. Primary path: build the Dockerfile
inside a Firecracker builder VM (buildah, no host docker), smoke-testing inside a Firecracker builder VM (buildah, no host docker), smoke-testing
the image before export. A committed snapshot (freeze/migrate) is resumed the image before export. A committed snapshot (freeze/migrate) is resumed
@@ -217,13 +215,36 @@ def _build_agent_base(
committed_tar = committed_rootfs_path(plan.slug) committed_tar = committed_rootfs_path(plan.slug)
if committed and committed_tar.is_file(): if committed and committed_tar.is_file():
info(f"resuming from committed rootfs {committed_tar}") info(f"resuming from committed rootfs {committed_tar}")
return plan, util.build_committed_rootfs_dir(committed_tar) return util.build_committed_rootfs_dir(committed_tar)
base = image_builder.build_agent_rootfs_dir( dockerfile = Path(plan.dockerfile_path)
Path(plan.dockerfile_path), if plan.spec.image_policy == "cached":
cached = image_builder.cached_agent_rootfs_dir(dockerfile)
if cached is None:
die(
f"cached agent rootfs for {plan.image!r} not found; "
"run without --cached-images to build it"
)
info(f"using cached agent rootfs {cached.name}")
return cached
return image_builder.build_agent_rootfs_dir(
dockerfile,
image_tag=plan.image, image_tag=plan.image,
smoke_test=runtime_for(plan.agent_provider_template).smoke_test, smoke_test=runtime_for(plan.agent_provider_template).smoke_test,
) )
return plan, base
def stale_checks(plan: FirecrackerBottlePlan) -> None:
"""Raise when the cached rootfs selected by this plan is stale."""
if plan.spec.image_policy != "cached":
return
committed = read_committed_image(plan.slug)
committed_tar = committed_rootfs_path(plan.slug)
if committed and committed_tar.is_file():
check_stale_path(f"agent rootfs {committed_tar}", committed_tar)
return
cached = image_builder.cached_agent_rootfs_dir(Path(plan.dockerfile_path))
if cached is not None:
check_stale_path(f"agent rootfs {cached}", cached / ".bb-ready")
# --- agent guest env ------------------------------------------------- # --- agent guest env -------------------------------------------------
@@ -35,6 +35,15 @@ class TestBuildAgentRootfsDir(unittest.TestCase):
build.assert_not_called() build.assert_not_called()
self.assertEqual(base, out) self.assertEqual(base, out)
def test_cached_lookup_requires_ready_marker(self):
digest = image_builder._dockerfile_hash(self.dockerfile)
base = self.cache / "rootfs" / f"agent-{digest}"
base.mkdir(parents=True)
with patch.object(image_builder.util, "cache_dir", return_value=self.cache):
self.assertIsNone(image_builder.cached_agent_rootfs_dir(self.dockerfile))
(base / ".bb-ready").write_text("ok\n")
self.assertEqual(base, image_builder.cached_agent_rootfs_dir(self.dockerfile))
def test_cache_miss_builds_injects_and_marks_ready(self): def test_cache_miss_builds_injects_and_marks_ready(self):
with patch.object(image_builder.util, "cache_dir", return_value=self.cache), \ with patch.object(image_builder.util, "cache_dir", return_value=self.cache), \
patch.object(image_builder, "_build_in_infra") as build, \ patch.object(image_builder, "_build_in_infra") as build, \
+57
View File
@@ -7,6 +7,7 @@ calls are mocked at the module boundary."""
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, cast from typing import Any, cast
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -204,5 +205,61 @@ class TestMacosContainerStaleChecks(unittest.TestCase):
sc.assert_called_once_with(plan) sc.assert_called_once_with(plan)
# ---------------------------------------------------------------------------
# Firecracker cached rootfs selection and stale checks
# ---------------------------------------------------------------------------
class TestFirecrackerCachedRootfs(unittest.TestCase):
def _plan(self, policy: str = "cached") -> Any:
return cast(Any, SimpleNamespace(
spec=SimpleNamespace(image_policy=policy),
slug="dev-abc",
image="bot-bottle-agent:latest",
dockerfile_path="/repo/Dockerfile",
agent_provider_template="claude",
))
def test_cached_policy_reuses_ready_rootfs_without_building(self) -> None:
from bot_bottle.backend.firecracker import launch as mod
cached = Path("/cache/rootfs/agent-deadbeef")
with patch.object(mod, "read_committed_image", return_value=""), \
patch.object(
mod.image_builder, "cached_agent_rootfs_dir", return_value=cached,
), \
patch.object(mod.image_builder, "build_agent_rootfs_dir") as build:
self.assertEqual(cached, mod.build_or_load_agent_base(self._plan()))
build.assert_not_called()
def test_cached_policy_fails_when_rootfs_is_missing(self) -> None:
from bot_bottle.backend.firecracker import launch as mod
with patch.object(mod, "read_committed_image", return_value=""), \
patch.object(
mod.image_builder, "cached_agent_rootfs_dir", return_value=None,
), \
patch.object(mod.image_builder, "build_agent_rootfs_dir") as build, \
self.assertRaises(SystemExit):
mod.build_or_load_agent_base(self._plan())
build.assert_not_called()
def test_stale_checks_use_ready_marker_timestamp(self) -> None:
from bot_bottle.backend.firecracker import launch as mod
cached = Path("/cache/rootfs/agent-deadbeef")
with patch.object(mod, "read_committed_image", return_value=""), \
patch.object(
mod.image_builder, "cached_agent_rootfs_dir", return_value=cached,
), \
patch.object(mod, "check_stale_path") as check:
mod.stale_checks(self._plan())
check.assert_called_once_with(f"agent rootfs {cached}", cached / ".bb-ready")
def test_backend_prelaunch_checks_delegates(self) -> None:
from bot_bottle.backend.firecracker.backend import FirecrackerBottleBackend
from bot_bottle.backend.firecracker import launch as mod
plan = self._plan()
with patch.object(mod, "stale_checks") as checks:
FirecrackerBottleBackend().prelaunch_checks(plan)
checks.assert_called_once_with(plan)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()