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:
@@ -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:
|
def _silent_run(cmd: Iterable[str]) -> int:
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
list(cmd),
|
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
|
Resolves the per-bottle docker subnet + bundle IP, builds the
|
||||||
agent's `.smolmachine` artifact (cached under
|
agent's docker image from the repo Dockerfile, converts it into a
|
||||||
`~/.cache/claude-bottle/smolmachines/`), and assembles the guest
|
`.smolmachine` artifact via an ephemeral local registry (smolvm's
|
||||||
env. No VM bringup — that's `launch.launch`'s job."""
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ...backend import BottleSpec
|
from ...backend import BottleSpec
|
||||||
|
from ...backend.docker import util as docker_mod
|
||||||
from ...backend.docker.bottle_state import (
|
from ...backend.docker.bottle_state import (
|
||||||
BottleMetadata,
|
BottleMetadata,
|
||||||
agent_state_dir,
|
agent_state_dir,
|
||||||
@@ -27,9 +34,14 @@ from ...pipelock import PipelockProxy
|
|||||||
from ...supervise import Supervise
|
from ...supervise import Supervise
|
||||||
from . import smolvm as _smolvm
|
from . import smolvm as _smolvm
|
||||||
from .bottle_plan import SmolmachinesBottlePlan
|
from .bottle_plan import SmolmachinesBottlePlan
|
||||||
|
from .local_registry import ephemeral_registry
|
||||||
from .util import smolmachines_bundle_subnet, smolmachines_preflight
|
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
|
# Per-host cache for `smolvm pack create` outputs. Keyed by the
|
||||||
# image ref so re-prepares for the same image hit the cache
|
# image ref so re-prepares for the same image hit the cache
|
||||||
# (pack create is idempotent on the smolvm side but takes several
|
# (pack create is idempotent on the smolvm side but takes several
|
||||||
@@ -132,11 +144,15 @@ def resolve_plan(
|
|||||||
prompt_file.chmod(0o600)
|
prompt_file.chmod(0o600)
|
||||||
|
|
||||||
machine_name = f"claude-bottle-{slug}"
|
machine_name = f"claude-bottle-{slug}"
|
||||||
# Chunk 2d placeholder until the agent-image work lands.
|
# Build the agent image from the repo Dockerfile (shared with
|
||||||
# alpine pulls cleanly from docker.io via smolvm's crane
|
# the docker backend, layer-cached) and convert it into a
|
||||||
# backend; the real claude-bottle image lives in the local
|
# `.smolmachine` artifact via an ephemeral local registry. The
|
||||||
# docker daemon and isn't reachable that way.
|
# CLAUDE_BOTTLE_IMAGE env var match the docker backend's
|
||||||
agent_image_ref = "alpine:latest"
|
# 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)
|
agent_from_path = _ensure_smolmachine(agent_image_ref)
|
||||||
|
|
||||||
return SmolmachinesBottlePlan(
|
return SmolmachinesBottlePlan(
|
||||||
@@ -158,21 +174,37 @@ def resolve_plan(
|
|||||||
|
|
||||||
|
|
||||||
def _ensure_smolmachine(image_ref: str) -> Path:
|
def _ensure_smolmachine(image_ref: str) -> Path:
|
||||||
"""Cache `smolvm pack create --image <image_ref>` output under
|
"""Build the agent docker image and convert it into a
|
||||||
`~/.cache/claude-bottle/smolmachines/<slug>`. Returns the
|
`.smolmachine` artifact, caching the result under
|
||||||
`.smolmachine.smolmachine` sidecar path — that's the file
|
`~/.cache/claude-bottle/smolmachines/` keyed by the docker image
|
||||||
`machine create --from` consumes (pack create produces a
|
ID (so a Dockerfile change automatically invalidates the cache).
|
||||||
launcher binary at `.smolmachine` plus the sidecar alongside
|
|
||||||
|
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).
|
it; the sidecar is the actual artifact).
|
||||||
|
|
||||||
Re-runs of pack create against the same image hit smolvm's
|
Conversion path: `docker build` (the existing layer cache makes
|
||||||
layer cache; we still skip the call entirely when the
|
no-change rebuilds cheap) → `docker tag` with a
|
||||||
sidecar is already on disk, since each invocation costs
|
`localhost:<port>/...` ref → bring up the ephemeral registry
|
||||||
several seconds even on a hot cache."""
|
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)
|
_SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
slug = image_ref.replace(":", "_").replace("/", "_")
|
docker_mod.build_image(image_ref, _REPO_DIR)
|
||||||
binary = _SMOLMACHINE_CACHE_DIR / f"{slug}.smolmachine"
|
# `sha256:abcd...` -> `abcd...` first 16 chars: short enough to
|
||||||
sidecar = _SMOLMACHINE_CACHE_DIR / f"{slug}.smolmachine.smolmachine"
|
# keep filenames manageable, long enough to make collisions
|
||||||
if not sidecar.is_file():
|
# astronomically unlikely.
|
||||||
_smolvm.pack_create(image_ref, binary)
|
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
|
return sidecar
|
||||||
|
|||||||
@@ -393,10 +393,19 @@ Three changes vs. the Docker backend:
|
|||||||
2. Derive a per-bottle docker subnet from `sha256(slug) % 254`
|
2. Derive a per-bottle docker subnet from `sha256(slug) % 254`
|
||||||
(skipping the docker-default 17): `192.168.X.0/24`. The bundle
|
(skipping the docker-default 17): `192.168.X.0/24`. The bundle
|
||||||
IP is always `192.168.X.2` (gateway is `.1`).
|
IP is always `192.168.X.2` (gateway is `.1`).
|
||||||
3. Resolve the agent guest image: convert the existing
|
3. Resolve the agent guest image: `docker build` the existing
|
||||||
`Dockerfile` into a `.smolmachine` artifact via
|
`Dockerfile`, then convert the resulting image into a
|
||||||
`smolvm pack create --image <name> -o <stage>/agent.smolmachine`
|
`.smolmachine` artifact. Empirically `smolvm pack create` only
|
||||||
(idempotent, layer-cached).
|
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`
|
4. Render the per-bottle Smolfile to `stage_dir/smolfile.toml`
|
||||||
using smolvm 0.8.0's schema:
|
using smolvm 0.8.0's schema:
|
||||||
- `image` / `entrypoint` / `cmd` — bundled into the
|
- `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