1fa17d1822
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>
125 lines
4.2 KiB
Python
125 lines
4.2 KiB
Python
"""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})"
|
|
)
|