feat(smolmachines): build agent image from repo Dockerfile (PRD 0023 chunk 4c) #71

Merged
didericis merged 1 commits from prd-0023-chunk-4c-agent-image into main 2026-05-27 14:05:39 -04:00
7 changed files with 567 additions and 28 deletions
+33
View File
@@ -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 '<no stderr>'}"
)
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:<port>/... 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),
@@ -0,0 +1,124 @@
"""Ephemeral local OCI registry for the smolmachines agent-image
conversion path (PRD 0023 chunk 4c).
`smolvm pack create --image <ref>` 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:<random>`,
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",
Review

How much attack surface does the env var and this registry download introduce (and the local registry step in general)

How much attack surface does the env var and this registry download introduce (and the local registry step in general)
Review

Small, mostly equivalent to the docker dependencies the project already has.

The pulled imageregistry:2.8.3 pinned by digest (sha256:a3d8aaa6...). Distribution's open-source registry from Docker Inc., the upstream of every docker daemon's docker push; CVE history is mostly DoS, no RCE. Pinning means the bytes are immutable — once the digest is in the source tree, every machine that runs this verifies against it. Same trust model as the ghcr.io/luckypipewrench/pipelock@sha256:3b1a3941... pin in backend/docker/pipelock.py.

The running registry container — bound to 127.0.0.1::5000 (loopback only, no LAN exposure), lives ~5-10s during one prepare call, force-removed in finally. While alive it's reachable by other processes on the host running as the same uid — but those processes are already past whatever sandbox the project provides (this is host-side bringup, before the bottle exists). They could read the pushed image bytes, but those bytes are the public Dockerfile-built image — no secrets baked in.

The env var override — same pattern as CLAUDE_BOTTLE_PIPELOCK_IMAGE, CLAUDE_BOTTLE_EGRESS_PORT, etc. An attacker who can set arbitrary env vars in the shell that runs cli.py can already substitute PATH, DOCKER_HOST, LD_PRELOAD, etc. — way nastier vectors than swapping the registry image. It exists for developer override (testing against a different registry version); shouldn't appear in production wrapping scripts.

What it doesn't add — no new network surface (loopback only), no new persistent state (container is --rm), no secrets in flight (the pushed image is just the agent Dockerfile output), no new privileged ops (registry runs as its registry user inside its container).

The one thing worth flagging: this puts a docker dependency in the smolmachines prepare path that wasn't there before (the docker backend ships with docker as a hard requirement; smolmachines previously only needed it for the sidecar bundle, now also for the agent-image build). Worth a README note for the v1 macOS-only scope.

Small, mostly equivalent to the docker dependencies the project already has. **The pulled image** — `registry:2.8.3` pinned by digest (`sha256:a3d8aaa6...`). Distribution's open-source registry from Docker Inc., the upstream of every docker daemon's `docker push`; CVE history is mostly DoS, no RCE. Pinning means the bytes are immutable — once the digest is in the source tree, every machine that runs this verifies against it. Same trust model as the `ghcr.io/luckypipewrench/pipelock@sha256:3b1a3941...` pin in `backend/docker/pipelock.py`. **The running registry container** — bound to `127.0.0.1::5000` (loopback only, no LAN exposure), lives ~5-10s during one `prepare` call, force-removed in `finally`. While alive it's reachable by other processes on the host running as the same uid — but those processes are already past whatever sandbox the project provides (this is host-side bringup, before the bottle exists). They could read the pushed image bytes, but those bytes are the public Dockerfile-built image — no secrets baked in. **The env var override** — same pattern as `CLAUDE_BOTTLE_PIPELOCK_IMAGE`, `CLAUDE_BOTTLE_EGRESS_PORT`, etc. An attacker who can set arbitrary env vars in the shell that runs `cli.py` can already substitute `PATH`, `DOCKER_HOST`, `LD_PRELOAD`, etc. — way nastier vectors than swapping the registry image. It exists for developer override (testing against a different registry version); shouldn't appear in production wrapping scripts. **What it doesn't add** — no new network surface (loopback only), no new persistent state (container is `--rm`), no secrets in flight (the pushed image is just the agent Dockerfile output), no new privileged ops (registry runs as its `registry` user inside its container). The one thing worth flagging: this puts a docker dependency in the smolmachines prepare path that wasn't there before (the docker backend ships with `docker` as a hard requirement; smolmachines previously only needed it for the sidecar bundle, now also for the agent-image build). Worth a README note for the v1 macOS-only scope.
)
# 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 <name> 5000/tcp` returns one or
more `host:port` lines; the loopback-only -p binding ensures we
get exactly `127.0.0.1:<port>`."""
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 '<no stderr>'}"
)
# `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:<port>`, 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})"
)
+56 -24
View File
@@ -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 <image_ref>` output under
`~/.cache/claude-bottle/smolmachines/<slug>`. 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:<port>/...` ref → bring up the ephemeral registry
container → `docker push` into it → `smolvm pack create --image
<localhost ref>` → 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
+13 -4
View File
@@ -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 <name> -o <stage>/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:<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, 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
+79
View File
@@ -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()
@@ -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="<container-id>\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 <name>`.
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()
@@ -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()