feat(smolmachines): build agent image from repo Dockerfile (PRD 0023 chunk 4c) #71
@@ -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",
|
||||
|
|
||||
)
|
||||
|
||||
|
||||
# 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})"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user
How much attack surface does the env var and this registry download introduce (and the local registry step in general)
Small, mostly equivalent to the docker dependencies the project already has.
The pulled image —
registry:2.8.3pinned by digest (sha256:a3d8aaa6...). Distribution's open-source registry from Docker Inc., the upstream of every docker daemon'sdocker 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 theghcr.io/luckypipewrench/pipelock@sha256:3b1a3941...pin inbackend/docker/pipelock.py.The running registry container — bound to
127.0.0.1::5000(loopback only, no LAN exposure), lives ~5-10s during onepreparecall, force-removed infinally. 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 runscli.pycan already substitutePATH,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 itsregistryuser 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
dockeras 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.