feat(smolmachines): build agent image from repo Dockerfile (PRD 0023 chunk 4c)
Replaces the alpine:latest placeholder with a real claude-bottle agent image, converted into a .smolmachine artifact via an ephemeral local OCI registry. Why the registry hop: smolvm pack create only accepts OCI registry refs. Empirically it rejects docker-daemon://, oci-layout://, docker-archive: tarballs, and every other transport tested — the crane backend treats anything with a scheme prefix as a registry hostname. To convert a locally-built docker image into a .smolmachine we have to push it somewhere smolvm can pull from. Smallest path: bring up registry:2.8.3 bound to 127.0.0.1:<random>, docker tag + docker push into it, smolvm pack create --image localhost:<port>/claude-bottle:<id>, tear down the registry. The .smolmachine is cached under ~/.cache/claude-bottle/smolmachines/ keyed by the docker image ID (first 16 hex chars of the sha256), so a Dockerfile change picks up a new image ID and invalidates the cache. Unchanged rebuilds skip the whole build → registry → pack pipeline. This puts `docker build` in smolmachines prepare (the docker backend defers it to launch). Necessary because pack_create needs the image ID to derive the cache key, and prepare is the only hook ahead of launch that runs once per slug. Adds: - claude_bottle/backend/docker/util.py: image_id / tag / push helpers (thin docker CLI wrappers). - claude_bottle/backend/smolmachines/local_registry.py: ephemeral_registry() context manager; pins registry:2.8.3 by digest, binds 127.0.0.1::5000 (loopback-only), force-removes on exit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit was merged in pull request #71.
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
"""Unit: smolmachines `_ensure_smolmachine` agent-image pipeline
|
||||
(PRD 0023 chunk 4c).
|
||||
|
||||
Asserts that the cache-hit path returns without touching the
|
||||
registry / pack pipeline, and that the cache-miss path runs
|
||||
build → tag → push → pack in order against a registry port the
|
||||
helper yields."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from claude_bottle.backend.smolmachines import prepare as _prepare
|
||||
|
||||
|
||||
class TestEnsureSmolmachine(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="cb-cache.")
|
||||
self._cache_patch = patch.object(
|
||||
_prepare, "_SMOLMACHINE_CACHE_DIR", Path(self._tmp.name),
|
||||
)
|
||||
self._cache_patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._cache_patch.stop()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_cache_hit_skips_registry_and_pack(self):
|
||||
# Pre-populate the cache for image id `sha256:abcdef0123456789...`.
|
||||
digest = "abcdef0123456789"
|
||||
sidecar = Path(self._tmp.name) / f"{digest}.smolmachine.smolmachine"
|
||||
sidecar.write_text("")
|
||||
|
||||
with patch.object(
|
||||
_prepare.docker_mod, "build_image",
|
||||
) as build, patch.object(
|
||||
_prepare.docker_mod, "image_id",
|
||||
return_value=f"sha256:{digest}fffffffffffffffff",
|
||||
), patch.object(
|
||||
_prepare, "ephemeral_registry",
|
||||
) as registry, patch.object(
|
||||
_prepare.docker_mod, "tag",
|
||||
) as tag, patch.object(
|
||||
_prepare.docker_mod, "push",
|
||||
) as push, patch.object(
|
||||
_prepare._smolvm, "pack_create",
|
||||
) as pack:
|
||||
result = _prepare._ensure_smolmachine("claude-bottle:latest")
|
||||
|
||||
self.assertEqual(sidecar, result)
|
||||
# build still runs (Dockerfile edits land without manual rmi)
|
||||
build.assert_called_once()
|
||||
# No registry, no tag, no push, no pack on cache hit.
|
||||
registry.assert_not_called()
|
||||
tag.assert_not_called()
|
||||
push.assert_not_called()
|
||||
pack.assert_not_called()
|
||||
|
||||
def test_cache_miss_runs_build_tag_push_pack_in_order(self):
|
||||
digest = "0123456789abcdef"
|
||||
|
||||
# ephemeral_registry is a context manager yielding the port.
|
||||
class _Reg:
|
||||
def __enter__(self_inner):
|
||||
return 54321
|
||||
def __exit__(self_inner, *exc):
|
||||
return False
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
def record(name):
|
||||
def _f(*a, **kw):
|
||||
calls.append(name)
|
||||
return _f
|
||||
|
||||
with patch.object(
|
||||
_prepare.docker_mod, "build_image",
|
||||
side_effect=record("build"),
|
||||
), patch.object(
|
||||
_prepare.docker_mod, "image_id",
|
||||
return_value=f"sha256:{digest}fffffffffffffffff",
|
||||
), patch.object(
|
||||
_prepare, "ephemeral_registry",
|
||||
return_value=_Reg(),
|
||||
), patch.object(
|
||||
_prepare.docker_mod, "tag",
|
||||
side_effect=record("tag"),
|
||||
) as tag, patch.object(
|
||||
_prepare.docker_mod, "push",
|
||||
side_effect=record("push"),
|
||||
) as push, patch.object(
|
||||
_prepare._smolvm, "pack_create",
|
||||
side_effect=record("pack"),
|
||||
) as pack:
|
||||
_prepare._ensure_smolmachine("claude-bottle:latest")
|
||||
|
||||
# build first (no point pushing if the build fails), then
|
||||
# tag → push → pack against the registry port.
|
||||
self.assertEqual(["build", "tag", "push", "pack"], calls)
|
||||
|
||||
# tag goes from the source ref to a localhost:<port> ref
|
||||
# with the digest as the tag suffix (so different builds
|
||||
# land on different tags in the registry).
|
||||
tag_args = tag.call_args.args
|
||||
self.assertEqual("claude-bottle:latest", tag_args[0])
|
||||
self.assertEqual(f"localhost:54321/claude-bottle:{digest}", tag_args[1])
|
||||
# push targets the same localhost ref tag picks.
|
||||
push_args = push.call_args.args
|
||||
self.assertEqual(f"localhost:54321/claude-bottle:{digest}", push_args[0])
|
||||
# pack_create reads from the registry ref, writes the
|
||||
# binary alongside the cached sidecar.
|
||||
pack_args = pack.call_args.args
|
||||
self.assertEqual(f"localhost:54321/claude-bottle:{digest}", pack_args[0])
|
||||
self.assertTrue(str(pack_args[1]).endswith(f"{digest}.smolmachine"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user