fix(firecracker): honor cached image policy
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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, \
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user