From 1fa17d1822f14984470cab67fa2e1b1733b0183a Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 13:51:02 -0400 Subject: [PATCH] feat(smolmachines): build agent image from repo Dockerfile (PRD 0023 chunk 4c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:, docker tag + docker push into it, smolvm pack create --image localhost:/claude-bottle:, 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 --- claude_bottle/backend/docker/util.py | 33 ++++ .../backend/smolmachines/local_registry.py | 124 +++++++++++++++ claude_bottle/backend/smolmachines/prepare.py | 80 +++++++--- docs/prds/0023-smolmachines-backend.md | 17 ++- tests/unit/test_docker_util_image.py | 79 ++++++++++ .../unit/test_smolmachines_local_registry.py | 141 ++++++++++++++++++ tests/unit/test_smolmachines_prepare_image.py | 121 +++++++++++++++ 7 files changed, 567 insertions(+), 28 deletions(-) create mode 100644 claude_bottle/backend/smolmachines/local_registry.py create mode 100644 tests/unit/test_docker_util_image.py create mode 100644 tests/unit/test_smolmachines_local_registry.py create mode 100644 tests/unit/test_smolmachines_prepare_image.py diff --git a/claude_bottle/backend/docker/util.py b/claude_bottle/backend/docker/util.py index 5cb671b..6599da5 100644 --- a/claude_bottle/backend/docker/util.py +++ b/claude_bottle/backend/docker/util.py @@ -147,6 +147,39 @@ def build_image_with_cwd(derived: str, base: str, cwd: str) -> None: ) +def image_id(ref: str) -> str: + """Return the content-addressed image ID (e.g. + `sha256:abcd...`) for `ref`. The smolmachines backend keys its + `.smolmachine` artifact cache on this, so a Dockerfile change + that produces a new image automatically invalidates the cache.""" + r = subprocess.run( + ["docker", "image", "inspect", "--format", "{{.Id}}", ref], + capture_output=True, + text=True, + check=False, + ) + if r.returncode != 0: + die( + f"docker image inspect for {ref!r} failed: " + f"{(r.stderr or '').strip() or ''}" + ) + return r.stdout.strip() + + +def tag(src: str, dst: str) -> None: + """`docker tag SRC DST`. Idempotent. Used by smolmachines prepare + to retag the locally-built image into a localhost:/... ref + that the ephemeral registry will accept.""" + subprocess.run(["docker", "tag", src, dst], check=True) + + +def push(ref: str) -> None: + """`docker push REF`. Used by smolmachines prepare to push the + agent image into the ephemeral local registry so smolvm's crane + backend can pull it.""" + subprocess.run(["docker", "push", ref], check=True) + + def _silent_run(cmd: Iterable[str]) -> int: return subprocess.run( list(cmd), diff --git a/claude_bottle/backend/smolmachines/local_registry.py b/claude_bottle/backend/smolmachines/local_registry.py new file mode 100644 index 0000000..7b27aa3 --- /dev/null +++ b/claude_bottle/backend/smolmachines/local_registry.py @@ -0,0 +1,124 @@ +"""Ephemeral local OCI registry for the smolmachines agent-image +conversion path (PRD 0023 chunk 4c). + +`smolvm pack create --image ` only accepts registry refs — it +can't read the local docker daemon's image cache, an OCI layout +directory, or a `docker save` tarball. To convert the agent's +Dockerfile-built image into a `.smolmachine` artifact we run a +short-lived `registry:2.8.3` container on `127.0.0.1:`, +push the locally-tagged image into it, and let smolvm pull from +there. The registry container is torn down as soon as the pack +completes. + +Loopback-only bind + the host's docker layer cache mean the round +trip is fast (~5s) and there's no exposed surface on the LAN.""" + +from __future__ import annotations + +import os +import socket +import subprocess +import time +import uuid +from contextlib import contextmanager +from typing import Iterator + +from ...log import die + + +# registry:2.8.3, pinned by digest. Same env-override pattern as the +# pipelock image pin in claude_bottle/backend/docker/pipelock.py. +REGISTRY_IMAGE = os.environ.get( + "CLAUDE_BOTTLE_REGISTRY_IMAGE", + "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373", +) + + +# How long to wait for the registry's HTTP layer to bind before +# giving up. Two seconds is empirically enough; bumping to 10s leaves +# headroom for slow CI runners without making the failure mode chatty. +_READY_TIMEOUT_S = 10.0 + + +@contextmanager +def ephemeral_registry() -> Iterator[int]: + """Bring up a `registry:2.8.3` container on a random loopback + port, yield the port, force-remove the container on exit. + + The container is started with `--rm` so a clean exit cleans up + on its own; the `finally` block force-removes on abnormal exit + (the calling process crashes between yield and close).""" + name = f"claude-bottle-registry-{uuid.uuid4().hex[:12]}" + subprocess.run( + [ + "docker", "run", "-d", "--rm", + "--name", name, + # `127.0.0.1::5000` = bind to loopback, pick a random host + # port. No LAN exposure; the container hangs around just + # long enough for one push + one pack-create. + "-p", "127.0.0.1::5000", + REGISTRY_IMAGE, + ], + check=True, + capture_output=True, + ) + try: + port = _host_port(name) + _wait_ready(port) + yield port + finally: + subprocess.run( + ["docker", "rm", "-f", name], + check=False, + capture_output=True, + ) + + +def _host_port(name: str) -> int: + """Resolve the host-side port docker mapped to the registry's + container port 5000. `docker port 5000/tcp` returns one or + more `host:port` lines; the loopback-only -p binding ensures we + get exactly `127.0.0.1:`.""" + r = subprocess.run( + ["docker", "port", name, "5000/tcp"], + capture_output=True, + text=True, + check=False, + ) + if r.returncode != 0: + die( + f"docker port {name} 5000/tcp failed: " + f"{(r.stderr or '').strip() or ''}" + ) + # `127.0.0.1:54321\n` — split on the last colon to handle the + # `host:port` shape without parsing IP literals. + line = (r.stdout or "").splitlines()[0].strip() + _, _, port_str = line.rpartition(":") + try: + return int(port_str) + except ValueError: + die(f"unexpected `docker port` output: {line!r}") + return -1 # unreachable; die() never returns + + +def _wait_ready(port: int) -> None: + """Block until the registry's HTTP layer accepts a TCP connection + on `127.0.0.1:`, or `_READY_TIMEOUT_S` elapses. + + A successful TCP connect is sufficient — registry:2.8.3 binds + after it's ready to serve `/v2/` requests, so the push that + follows will land on a working server.""" + deadline = time.monotonic() + _READY_TIMEOUT_S + last_err: Exception | None = None + while time.monotonic() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.5): + return + except OSError as e: + last_err = e + time.sleep(0.1) + die( + f"local registry on 127.0.0.1:{port} did not accept " + f"connections within {_READY_TIMEOUT_S:.0f}s " + f"(last error: {last_err})" + ) diff --git a/claude_bottle/backend/smolmachines/prepare.py b/claude_bottle/backend/smolmachines/prepare.py index f023cee..bd9d258 100644 --- a/claude_bottle/backend/smolmachines/prepare.py +++ b/claude_bottle/backend/smolmachines/prepare.py @@ -1,16 +1,23 @@ -"""smolmachines `_resolve_plan` (PRD 0023 chunk 2d). +"""smolmachines `_resolve_plan` (PRD 0023 chunks 2d + 4c). -Resolves the per-bottle docker subnet + bundle IP, pre-packs the -agent's `.smolmachine` artifact (cached under -`~/.cache/claude-bottle/smolmachines/`), and assembles the guest -env. No VM bringup — that's `launch.launch`'s job.""" +Resolves the per-bottle docker subnet + bundle IP, builds the +agent's docker image from the repo Dockerfile, converts it into a +`.smolmachine` artifact via an ephemeral local registry (smolvm's +crane backend only reads registry refs), and assembles the guest +env. The `.smolmachine` is cached under +`~/.cache/claude-bottle/smolmachines/` keyed by the docker image +ID so Dockerfile changes invalidate the cache automatically. + +No VM bringup — that's `launch.launch`'s job.""" from __future__ import annotations +import os from datetime import datetime, timezone from pathlib import Path from ...backend import BottleSpec +from ...backend.docker import util as docker_mod from ...backend.docker.bottle_state import ( BottleMetadata, agent_state_dir, @@ -27,9 +34,14 @@ from ...pipelock import PipelockProxy from ...supervise import Supervise from . import smolvm as _smolvm from .bottle_plan import SmolmachinesBottlePlan +from .local_registry import ephemeral_registry from .util import smolmachines_bundle_subnet, smolmachines_preflight +# Repo root, used as the `docker build` context for the agent image. +_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) + + # Per-host cache for `smolvm pack create` outputs. Keyed by the # image ref so re-prepares for the same image hit the cache # (pack create is idempotent on the smolvm side but takes several @@ -132,11 +144,15 @@ def resolve_plan( prompt_file.chmod(0o600) machine_name = f"claude-bottle-{slug}" - # Chunk 2d placeholder until the agent-image work lands. - # alpine pulls cleanly from docker.io via smolvm's crane - # backend; the real claude-bottle image lives in the local - # docker daemon and isn't reachable that way. - agent_image_ref = "alpine:latest" + # Build the agent image from the repo Dockerfile (shared with + # the docker backend, layer-cached) and convert it into a + # `.smolmachine` artifact via an ephemeral local registry. The + # CLAUDE_BOTTLE_IMAGE env var match the docker backend's + # resolve_plan default so both backends use the same image when + # one is built. + agent_image_ref = os.environ.get( + "CLAUDE_BOTTLE_IMAGE", "claude-bottle:latest" + ) agent_from_path = _ensure_smolmachine(agent_image_ref) return SmolmachinesBottlePlan( @@ -158,21 +174,37 @@ def resolve_plan( def _ensure_smolmachine(image_ref: str) -> Path: - """Cache `smolvm pack create --image ` output under - `~/.cache/claude-bottle/smolmachines/`. Returns the - `.smolmachine.smolmachine` sidecar path — that's the file - `machine create --from` consumes (pack create produces a - launcher binary at `.smolmachine` plus the sidecar alongside + """Build the agent docker image and convert it into a + `.smolmachine` artifact, caching the result under + `~/.cache/claude-bottle/smolmachines/` keyed by the docker image + ID (so a Dockerfile change automatically invalidates the cache). + + Returns the `.smolmachine.smolmachine` sidecar path — that's + the file `machine create --from` consumes (pack create produces + a launcher binary at `.smolmachine` plus the sidecar alongside it; the sidecar is the actual artifact). - Re-runs of pack create against the same image hit smolvm's - layer cache; we still skip the call entirely when the - sidecar is already on disk, since each invocation costs - several seconds even on a hot cache.""" + Conversion path: `docker build` (the existing layer cache makes + no-change rebuilds cheap) → `docker tag` with a + `localhost:/...` ref → bring up the ephemeral registry + container → `docker push` into it → `smolvm pack create --image + ` → tear down the registry. Each pack-create + costs several seconds even on a hot cache, so we skip the whole + pipeline when the cached sidecar is already on disk for this + image ID.""" _SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True) - slug = image_ref.replace(":", "_").replace("/", "_") - binary = _SMOLMACHINE_CACHE_DIR / f"{slug}.smolmachine" - sidecar = _SMOLMACHINE_CACHE_DIR / f"{slug}.smolmachine.smolmachine" - if not sidecar.is_file(): - _smolvm.pack_create(image_ref, binary) + docker_mod.build_image(image_ref, _REPO_DIR) + # `sha256:abcd...` -> `abcd...` first 16 chars: short enough to + # keep filenames manageable, long enough to make collisions + # astronomically unlikely. + digest = docker_mod.image_id(image_ref).split(":", 1)[-1][:16] + binary = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine" + sidecar = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine" + if sidecar.is_file(): + return sidecar + with ephemeral_registry() as port: + local_ref = f"localhost:{port}/claude-bottle:{digest}" + docker_mod.tag(image_ref, local_ref) + docker_mod.push(local_ref) + _smolvm.pack_create(local_ref, binary) return sidecar diff --git a/docs/prds/0023-smolmachines-backend.md b/docs/prds/0023-smolmachines-backend.md index 3fe1e3f..5e511a0 100644 --- a/docs/prds/0023-smolmachines-backend.md +++ b/docs/prds/0023-smolmachines-backend.md @@ -393,10 +393,19 @@ Three changes vs. the Docker backend: 2. Derive a per-bottle docker subnet from `sha256(slug) % 254` (skipping the docker-default 17): `192.168.X.0/24`. The bundle IP is always `192.168.X.2` (gateway is `.1`). -3. Resolve the agent guest image: convert the existing - `Dockerfile` into a `.smolmachine` artifact via - `smolvm pack create --image -o /agent.smolmachine` - (idempotent, layer-cached). +3. Resolve the agent guest image: `docker build` the existing + `Dockerfile`, then convert the resulting image into a + `.smolmachine` artifact. Empirically `smolvm pack create` only + reads OCI registry refs — it rejects `docker-daemon://`, + `oci-layout://`, `docker-archive:` tarballs, and every other + transport tested. The conversion path is a registry hop: bring + up an ephemeral `registry:2.8.3` container bound to + `127.0.0.1:`, `docker tag` + `docker push` into it, + `smolvm pack create --image localhost:/claude-bottle:`, + tear down the registry. The `.smolmachine` is cached under + `~/.cache/claude-bottle/smolmachines/` keyed by the docker + image ID, so Dockerfile changes invalidate the cache and + unchanged rebuilds skip the whole pipeline. 4. Render the per-bottle Smolfile to `stage_dir/smolfile.toml` using smolvm 0.8.0's schema: - `image` / `entrypoint` / `cmd` — bundled into the diff --git a/tests/unit/test_docker_util_image.py b/tests/unit/test_docker_util_image.py new file mode 100644 index 0000000..6a6bc7a --- /dev/null +++ b/tests/unit/test_docker_util_image.py @@ -0,0 +1,79 @@ +"""Unit: image_id / tag / push helpers in +claude_bottle.backend.docker.util (PRD 0023 chunk 4c additions). + +Tests mock `subprocess.run` and assert on argv shape + parsing. +The actual docker round-trip is covered by the chunk 4c +integration smoke.""" + +from __future__ import annotations + +import subprocess +import unittest +from unittest.mock import patch + +from claude_bottle.backend.docker import util as docker_mod + + +def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess: + return subprocess.CompletedProcess( + args=[], returncode=0, stdout=stdout, stderr=stderr, + ) + + +def _fail(stderr: str = "boom") -> subprocess.CompletedProcess: + return subprocess.CompletedProcess( + args=[], returncode=1, stdout="", stderr=stderr, + ) + + +class TestImageId(unittest.TestCase): + def test_strips_trailing_newline(self): + # docker image inspect --format ... emits a trailing newline. + with patch.object( + docker_mod.subprocess, "run", + return_value=_ok(stdout="sha256:abcdef\n"), + ) as run: + self.assertEqual( + "sha256:abcdef", docker_mod.image_id("claude-bottle:latest") + ) + argv = run.call_args.args[0] + self.assertEqual( + ["docker", "image", "inspect", "--format", "{{.Id}}", "claude-bottle:latest"], + argv, + ) + + def test_dies_on_inspect_failure(self): + with patch.object( + docker_mod.subprocess, "run", return_value=_fail("No such image"), + ), patch.object( + docker_mod, "die", side_effect=SystemExit("die"), + ) as die: + with self.assertRaises(SystemExit): + docker_mod.image_id("missing:tag") + die.assert_called_once() + self.assertIn("missing:tag", die.call_args.args[0]) + + +class TestTagPush(unittest.TestCase): + def test_tag_runs_docker_tag(self): + with patch.object( + docker_mod.subprocess, "run", return_value=_ok(), + ) as run: + docker_mod.tag("claude-bottle:latest", "localhost:5000/cb:abc") + argv = run.call_args.args[0] + self.assertEqual( + ["docker", "tag", "claude-bottle:latest", "localhost:5000/cb:abc"], + argv, + ) + + def test_push_runs_docker_push(self): + with patch.object( + docker_mod.subprocess, "run", return_value=_ok(), + ) as run: + docker_mod.push("localhost:5000/cb:abc") + argv = run.call_args.args[0] + self.assertEqual(["docker", "push", "localhost:5000/cb:abc"], argv) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_smolmachines_local_registry.py b/tests/unit/test_smolmachines_local_registry.py new file mode 100644 index 0000000..4e08269 --- /dev/null +++ b/tests/unit/test_smolmachines_local_registry.py @@ -0,0 +1,141 @@ +"""Unit: ephemeral local-registry helper (PRD 0023 chunk 4c). + +The helper brings up a `registry:2.8.3` container on a random +loopback port, yields the port, and tears the container down on +exit. Tests mock `subprocess.run` + `socket.create_connection` so +they run without docker.""" + +from __future__ import annotations + +import subprocess +import unittest +from unittest.mock import call, patch + +from claude_bottle.backend.smolmachines import local_registry + + +def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess: + return subprocess.CompletedProcess( + args=[], returncode=0, stdout=stdout, stderr=stderr, + ) + + +class TestEphemeralRegistry(unittest.TestCase): + def test_yields_host_port_parsed_from_docker_port(self): + # docker run + docker port + docker rm in that order; the + # port command returns `127.0.0.1:54321` for the loopback + # binding. + with patch.object( + local_registry.subprocess, "run", + side_effect=[ + _ok(stdout="\n"), + _ok(stdout="127.0.0.1:54321\n"), + _ok(), + ], + ) as run, patch.object( + local_registry.socket, "create_connection", + return_value=_FakeSocket(), + ): + with local_registry.ephemeral_registry() as port: + self.assertEqual(54321, port) + + # docker run, docker port, docker rm -f + self.assertEqual(3, run.call_count) + run_argv = run.call_args_list[0].args[0] + self.assertEqual(["docker", "run"], run_argv[:2]) + self.assertIn("--rm", run_argv) + # Loopback-only port binding so the registry isn't exposed + # on the LAN even briefly. + self.assertIn("127.0.0.1::5000", run_argv) + + def test_force_removes_container_on_clean_exit(self): + with patch.object( + local_registry.subprocess, "run", + side_effect=[_ok(stdout="cid\n"), _ok(stdout="127.0.0.1:1234\n"), _ok()], + ) as run, patch.object( + local_registry.socket, "create_connection", + return_value=_FakeSocket(), + ): + with local_registry.ephemeral_registry(): + pass + + # Last call is `docker rm -f `. + last_argv = run.call_args_list[-1].args[0] + self.assertEqual(["docker", "rm", "-f"], last_argv[:3]) + + def test_force_removes_container_on_exception_inside_with(self): + with patch.object( + local_registry.subprocess, "run", + side_effect=[_ok(stdout="cid\n"), _ok(stdout="127.0.0.1:1234\n"), _ok()], + ) as run, patch.object( + local_registry.socket, "create_connection", + return_value=_FakeSocket(), + ): + with self.assertRaises(RuntimeError): + with local_registry.ephemeral_registry(): + raise RuntimeError("inside with") + + # rm -f still ran on exception. + last_argv = run.call_args_list[-1].args[0] + self.assertEqual(["docker", "rm", "-f"], last_argv[:3]) + + def test_wait_ready_times_out_when_socket_never_connects(self): + # Drop the timeout to a value that fits the test budget. + with patch.object(local_registry, "_READY_TIMEOUT_S", 0.1), patch.object( + local_registry.subprocess, "run", + side_effect=[_ok(stdout="cid\n"), _ok(stdout="127.0.0.1:1234\n"), _ok()], + ) as run, patch.object( + local_registry.socket, "create_connection", + side_effect=OSError("conn refused"), + ), patch.object( + local_registry, "die", + side_effect=SystemExit("die called"), + ) as die: + with self.assertRaises(SystemExit): + with local_registry.ephemeral_registry(): + self.fail("yield reached despite unreachable registry") + die.assert_called_once() + # rm -f still ran (cleanup goes through the finally block). + last_argv = run.call_args_list[-1].args[0] + self.assertEqual(["docker", "rm", "-f"], last_argv[:3]) + + def test_unique_container_name_per_call(self): + names: list[str] = [] + + def capture(argv, *a, **kw): + if argv[:2] == ["docker", "run"]: + names.append(argv[argv.index("--name") + 1]) + return _ok(stdout="cid\n" if argv[:2] == ["docker", "run"] + else "127.0.0.1:1\n") + + with patch.object( + local_registry.subprocess, "run", side_effect=capture, + ), patch.object( + local_registry.socket, "create_connection", + return_value=_FakeSocket(), + ): + with local_registry.ephemeral_registry(): + pass + with local_registry.ephemeral_registry(): + pass + + self.assertEqual(2, len(names)) + self.assertNotEqual(names[0], names[1]) + for n in names: + self.assertTrue(n.startswith("claude-bottle-registry-")) + + +class _FakeSocket: + """Minimal context-manager stand-in for the socket + `create_connection` returns. The helper only uses `with` on it + and discards the value, so we don't need any real network.""" + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_smolmachines_prepare_image.py b/tests/unit/test_smolmachines_prepare_image.py new file mode 100644 index 0000000..7d046f6 --- /dev/null +++ b/tests/unit/test_smolmachines_prepare_image.py @@ -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: 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()