fix(smolmachines): use containerized crane to push, bypassing docker daemon's HTTPS preference
The previous fix (`host.docker.internal:<port>` for daemon-side push) still failed: Get "https://host.docker.internal:53958/v2/": http: server gave HTTP response to HTTPS client `host.docker.internal` is reachable from Docker Desktop's daemon VM but isn't in the daemon's default insecure-registries CIDRs (only `::1/128` and `127.0.0.0/8` are), so docker push tries HTTPS, hits a plain-HTTP registry, and refuses. The daemon.json fix (`"insecure-registries": ["host.docker.internal"]`) works but is a one-time manual step in Docker Desktop's UI — not something we can do for the user. Sidestep the daemon push entirely: 1. docker build (as before) — local layer cache makes no-change rebuilds cheap. 2. docker save the image to a per-digest tarball alongside the cached `.smolmachine`. 3. Start an ephemeral registry container on a per-session docker network, with `-p :5000` so the host can also reach it for the pack step. 4. docker run a one-shot crane container on the SAME network, mount the tarball, `crane push --insecure /img.tar <registry-container>:5000/...`. Container DNS resolves the registry on the network; `--insecure` forces plain HTTP. 5. `smolvm pack create --image localhost:<host port>/...` from the host. smolvm's bundled crane auto-falls-back to HTTP for localhost addresses, so no insecure-registries config is needed on that side. 6. Tear down everything; reap the tarball (registries hold the same bytes, no need to keep both around). Net effect: the docker daemon never does an HTTP/HTTPS-policy decision on our behalf. `docker push` is gone from the prepare path; `docker save`, `docker network create`, `docker run` (for registry + crane) replace it. Tested end-to-end on Docker Desktop / macOS: `_ensure_smolmachine ("claude-bottle:latest")` produces a 204MB `.smolmachine.smolmachine` artifact. Adds: - backend/docker/util.py:save() — thin docker save wrapper. - local_registry.crane_push_tarball() — one-shot crane run on the registry's network. - CRANE_IMAGE constant pinned by digest (gcr.io/go-containerregistry/crane@sha256:0ae17ecb...). Removes: - backend/docker/util.py:tag() / push() — unused without daemon push. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -166,18 +166,15 @@ def image_id(ref: str) -> str:
|
|||||||
return r.stdout.strip()
|
return r.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
def tag(src: str, dst: str) -> None:
|
def save(ref: str, output: str) -> None:
|
||||||
"""`docker tag SRC DST`. Idempotent. Used by smolmachines prepare
|
"""`docker save REF -o OUTPUT`. Writes a tarball of the image
|
||||||
to retag the locally-built image into a localhost:<port>/... ref
|
layers + manifest to the host path. Used by smolmachines
|
||||||
that the ephemeral registry will accept."""
|
prepare to hand the agent image to a containerized crane that
|
||||||
subprocess.run(["docker", "tag", src, dst], check=True)
|
pushes it to the ephemeral registry — bypassing the docker
|
||||||
|
daemon's `docker push` (which on Docker Desktop can't reach a
|
||||||
|
host-loopback registry and refuses plain-HTTP pushes to
|
||||||
def push(ref: str) -> None:
|
non-loopback hosts)."""
|
||||||
"""`docker push REF`. Used by smolmachines prepare to push the
|
subprocess.run(["docker", "save", ref, "-o", output], check=True)
|
||||||
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:
|
||||||
|
|||||||
@@ -1,40 +1,37 @@
|
|||||||
"""Ephemeral local OCI registry for the smolmachines agent-image
|
"""Ephemeral local OCI registry for the smolmachines agent-image
|
||||||
conversion path (PRD 0023 chunk 4c).
|
conversion path (PRD 0023 chunk 4c).
|
||||||
|
|
||||||
`smolvm pack create --image <ref>` only accepts registry refs — it
|
`smolvm pack create --image <ref>` only accepts OCI registry refs
|
||||||
can't read the local docker daemon's image cache, an OCI layout
|
— it can't read the local docker daemon's image cache, an OCI
|
||||||
directory, or a `docker save` tarball. To convert the agent's
|
layout directory, or a `docker save` tarball. To convert the
|
||||||
Dockerfile-built image into a `.smolmachine` artifact we run a
|
agent's Dockerfile-built image into a `.smolmachine` artifact we
|
||||||
short-lived `registry:2.8.3` container, push the locally-tagged
|
spin up a short-lived `registry:2.8.3` container alongside a
|
||||||
image into it, and let smolvm pull from there. The registry
|
`crane` helper container on a private docker network, push via
|
||||||
container is torn down as soon as the pack completes.
|
`crane push --insecure <tarball> <registry-container>:5000/...`,
|
||||||
|
and let smolvm pull from the registry's published host port. The
|
||||||
|
network + both containers are torn down after the pack completes.
|
||||||
|
|
||||||
Two routing hostnames, one registry container. On Docker Desktop
|
Why this two-container dance instead of plain `docker push`:
|
||||||
(macOS/Windows) the docker daemon runs inside its own Linux VM,
|
- Docker Desktop's daemon runs in its own Linux VM, so its
|
||||||
so its `localhost` is *not* the host's loopback — a registry
|
`localhost` is not the host's loopback. A registry bound to
|
||||||
bound to `127.0.0.1::<port>` on the host is unreachable from the
|
the host's 127.0.0.1 is unreachable from the daemon side.
|
||||||
daemon side, and `docker push` fails with `context deadline
|
- `host.docker.internal` is reachable from the daemon but isn't
|
||||||
exceeded`. The fix: bind to all interfaces so both routes work,
|
in Docker's default insecure-registries CIDRs (only `::1/128`
|
||||||
and yield two refs:
|
and `127.0.0.0/8` are), so `docker push` to it tries HTTPS,
|
||||||
|
hits a plain-HTTP registry, and dies with
|
||||||
|
`http: server gave HTTP response to HTTPS client`. Adding
|
||||||
|
`host.docker.internal` to daemon.json works but is a one-time
|
||||||
|
manual step the user has to do in Docker Desktop's UI.
|
||||||
|
- Going through a docker network sidesteps the host-vs-daemon
|
||||||
|
loopback mismatch (crane and registry containers see each
|
||||||
|
other on the network) AND the HTTPS preference (crane has an
|
||||||
|
`--insecure` flag that forces plain HTTP).
|
||||||
|
|
||||||
- `daemon_endpoint`: how the docker CLI/daemon dials the
|
The registry is also published on a random host port so smolvm
|
||||||
registry (`host.docker.internal:<port>` on Docker Desktop,
|
— a host process — can pull from `localhost:<port>` via Docker's
|
||||||
`localhost:<port>` on a native Linux daemon that shares the
|
port-forward. smolvm's bundled crane auto-falls-back to HTTP for
|
||||||
host's network namespace).
|
localhost addresses, so no insecure-registries config is needed
|
||||||
- `host_endpoint`: how `smolvm pack create` (a host process)
|
on that side either."""
|
||||||
dials the registry. Always `localhost:<port>` — the port
|
|
||||||
binding includes loopback either way.
|
|
||||||
|
|
||||||
The registry stores images by repo+tag; the hostname in the ref
|
|
||||||
is just routing, so a push to `host.docker.internal:<port>/cb:abc`
|
|
||||||
and a pull of `localhost:<port>/cb:abc` hit the same stored
|
|
||||||
blob.
|
|
||||||
|
|
||||||
Trade-off: binding to all interfaces puts the registry on every
|
|
||||||
network interface briefly (~5-10s during prepare). The agent
|
|
||||||
image we push is built from the repo's public Dockerfile — no
|
|
||||||
secrets in it — and the user is on their own machine; the LAN
|
|
||||||
exposure window is short and the contents non-sensitive."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -58,106 +55,150 @@ REGISTRY_IMAGE = os.environ.get(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# gcr.io/go-containerregistry/crane:latest, pinned by digest. ~10MB,
|
||||||
|
# stable upstream from Google; we only invoke `crane push --insecure`
|
||||||
|
# against a localhost-equivalent registry, so the trust surface is
|
||||||
|
# narrow.
|
||||||
|
CRANE_IMAGE = os.environ.get(
|
||||||
|
"CLAUDE_BOTTLE_CRANE_IMAGE",
|
||||||
|
"gcr.io/go-containerregistry/crane@sha256:0ae17ecb34315aa7cbff28f6eddee3b7adae0b2f90101260d990804db1eb0084",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Internal port the registry binds to inside its container — fixed
|
||||||
|
# by the registry:2 image. The host-side mapping is random.
|
||||||
|
_REGISTRY_CONTAINER_PORT = "5000"
|
||||||
|
|
||||||
|
|
||||||
# How long to wait for the registry's HTTP layer to bind before
|
# How long to wait for the registry's HTTP layer to bind before
|
||||||
# giving up. Two seconds is empirically enough; bumping to 10s leaves
|
# giving up. Two seconds is empirically enough; 10s leaves headroom
|
||||||
# headroom for slow CI runners without making the failure mode chatty.
|
# for slow CI runners without making the failure mode chatty.
|
||||||
_READY_TIMEOUT_S = 10.0
|
_READY_TIMEOUT_S = 10.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class RegistryEndpoints:
|
class RegistryHandle:
|
||||||
"""The two `<host>:<port>` strings to embed in image refs. They
|
"""Everything callers need to push to + pull from the ephemeral
|
||||||
point at the same registry container; only the routing
|
registry.
|
||||||
hostname differs."""
|
|
||||||
|
|
||||||
daemon_endpoint: str
|
`network` is the per-session docker network — a `crane push`
|
||||||
host_endpoint: str
|
container has to join it to reach the registry by name.
|
||||||
|
`push_endpoint` is the `<host>:<port>` form to embed in image
|
||||||
|
refs given to the crane push container (resolves via docker
|
||||||
|
network DNS). `pull_endpoint` is the `<host>:<port>` form a
|
||||||
|
host process (smolvm) uses; the registry's host port mapping
|
||||||
|
backs this."""
|
||||||
|
|
||||||
|
network: str
|
||||||
|
push_endpoint: str
|
||||||
|
pull_endpoint: str
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def ephemeral_registry() -> Iterator[RegistryEndpoints]:
|
def ephemeral_registry() -> Iterator[RegistryHandle]:
|
||||||
"""Bring up a `registry:2.8.3` container on a random host port,
|
"""Bring up a per-session docker network + a `registry:2.8.3`
|
||||||
yield the daemon-side + host-side endpoints, force-remove the
|
container on it (published on a random host port), yield a
|
||||||
container on exit.
|
`RegistryHandle`, force-remove both on exit.
|
||||||
|
|
||||||
The container is started with `--rm` so a clean exit cleans up
|
The container is started with `--rm` so a clean exit cleans up
|
||||||
on its own; the `finally` block force-removes on abnormal exit
|
on its own; the `finally` block force-removes on abnormal exit
|
||||||
(the calling process crashes between yield and close)."""
|
(the calling process crashes between yield and close)."""
|
||||||
name = f"claude-bottle-registry-{uuid.uuid4().hex[:12]}"
|
session_id = uuid.uuid4().hex[:12]
|
||||||
|
network = f"claude-bottle-registry-net-{session_id}"
|
||||||
|
registry_name = f"claude-bottle-registry-{session_id}"
|
||||||
|
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
["docker", "network", "create", network],
|
||||||
"docker", "run", "-d", "--rm",
|
|
||||||
"--name", name,
|
|
||||||
# `-p :5000` (no IP prefix) binds the container's port
|
|
||||||
# 5000 on a random host port across all interfaces. The
|
|
||||||
# registry container itself listens on 0.0.0.0:5000
|
|
||||||
# internally; binding to all interfaces is necessary for
|
|
||||||
# Docker Desktop's daemon to reach it via
|
|
||||||
# host.docker.internal — a 127.0.0.1-only host binding
|
|
||||||
# is invisible to a daemon running in its own VM.
|
|
||||||
"-p", "5000",
|
|
||||||
REGISTRY_IMAGE,
|
|
||||||
],
|
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
port = _host_port(name)
|
subprocess.run(
|
||||||
_wait_ready(port)
|
[
|
||||||
daemon_host = _daemon_side_hostname()
|
"docker", "run", "-d", "--rm",
|
||||||
yield RegistryEndpoints(
|
"--name", registry_name,
|
||||||
daemon_endpoint=f"{daemon_host}:{port}",
|
"--network", network,
|
||||||
host_endpoint=f"localhost:{port}",
|
# `-p :5000` (no IP prefix) binds the container's
|
||||||
|
# port 5000 on a random host port across all
|
||||||
|
# interfaces. The host side reaches the registry
|
||||||
|
# via this port — smolvm's `pack create` pulls from
|
||||||
|
# `localhost:<port>` and the docker port-forward
|
||||||
|
# routes there.
|
||||||
|
"-p", _REGISTRY_CONTAINER_PORT,
|
||||||
|
REGISTRY_IMAGE,
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
port = _host_port(registry_name)
|
||||||
|
_wait_ready(port)
|
||||||
|
yield RegistryHandle(
|
||||||
|
network=network,
|
||||||
|
push_endpoint=f"{registry_name}:{_REGISTRY_CONTAINER_PORT}",
|
||||||
|
pull_endpoint=f"localhost:{port}",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", registry_name],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", name],
|
["docker", "network", "rm", network],
|
||||||
check=False,
|
check=False,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _daemon_side_hostname() -> str:
|
def crane_push_tarball(handle: RegistryHandle, tarball_path: str, ref: str) -> None:
|
||||||
"""Pick the hostname the docker daemon should use to dial the
|
"""Run `crane push --insecure <tarball> <ref>` inside a one-shot
|
||||||
registry. On Docker Desktop the daemon runs in its own Linux
|
container on the registry's docker network. `ref` should
|
||||||
VM and only sees the host via `host.docker.internal`; on
|
reference the registry by `handle.push_endpoint` so the crane
|
||||||
native Linux the daemon shares the host's network namespace
|
container resolves it via docker network DNS.
|
||||||
and `localhost` works.
|
|
||||||
|
|
||||||
`docker info --format '{{.OperatingSystem}}'` returns
|
Doesn't go through `docker push` to avoid the Docker-Desktop
|
||||||
`"Docker Desktop"` on macOS / Windows Desktop installs (and on
|
daemon's HTTPS preference for non-loopback hostnames — crane's
|
||||||
Linux Desktop, which also uses a VM). Anything else (e.g.
|
`--insecure` flag forces plain HTTP, which is what the
|
||||||
`"Debian GNU/Linux 12 (bookworm)"`) is a native daemon."""
|
registry container speaks."""
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["docker", "info", "--format", "{{.OperatingSystem}}"],
|
[
|
||||||
capture_output=True,
|
"docker", "run", "--rm",
|
||||||
text=True,
|
"--network", handle.network,
|
||||||
check=False,
|
"-v", f"{tarball_path}:/img.tar:ro",
|
||||||
)
|
CRANE_IMAGE,
|
||||||
operating_system = (r.stdout or "").strip()
|
"push", "--insecure", "/img.tar", ref,
|
||||||
if operating_system == "Docker Desktop":
|
],
|
||||||
return "host.docker.internal"
|
|
||||||
return "localhost"
|
|
||||||
|
|
||||||
|
|
||||||
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 (one per address family) — we take
|
|
||||||
the first IPv4 line."""
|
|
||||||
r = subprocess.run(
|
|
||||||
["docker", "port", name, "5000/tcp"],
|
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
die(
|
die(
|
||||||
f"docker port {name} 5000/tcp failed: "
|
f"crane push of {tarball_path!r} to {ref!r} failed: "
|
||||||
|
f"{(r.stderr or r.stdout or '').strip() or '<no output>'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _host_port(name: str) -> int:
|
||||||
|
"""Resolve the host-side port docker mapped to the registry's
|
||||||
|
container port. `docker port <name> 5000/tcp` returns one or
|
||||||
|
more `host:port` lines (one per address family) — we take the
|
||||||
|
first."""
|
||||||
|
r = subprocess.run(
|
||||||
|
["docker", "port", name, f"{_REGISTRY_CONTAINER_PORT}/tcp"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
die(
|
||||||
|
f"docker port {name} {_REGISTRY_CONTAINER_PORT}/tcp failed: "
|
||||||
f"{(r.stderr or '').strip() or '<no stderr>'}"
|
f"{(r.stderr or '').strip() or '<no stderr>'}"
|
||||||
)
|
)
|
||||||
# `0.0.0.0:54321\n[::]:54321\n` — take the first line, split
|
# `0.0.0.0:54321\n[::]:54321\n` — split on the last colon to
|
||||||
# on the last colon to handle either IPv4 or IPv6 host syntax.
|
# handle either IPv4 or IPv6 host syntax.
|
||||||
line = (r.stdout or "").splitlines()[0].strip()
|
line = (r.stdout or "").splitlines()[0].strip()
|
||||||
_, _, port_str = line.rpartition(":")
|
_, _, port_str = line.rpartition(":")
|
||||||
try:
|
try:
|
||||||
@@ -168,15 +209,15 @@ def _host_port(name: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _wait_ready(port: int) -> None:
|
def _wait_ready(port: int) -> None:
|
||||||
"""Block until the registry's HTTP layer accepts a TCP connection
|
"""Block until the registry's HTTP layer accepts a TCP
|
||||||
on `127.0.0.1:<port>`, or `_READY_TIMEOUT_S` elapses.
|
connection on `127.0.0.1:<port>`, or `_READY_TIMEOUT_S`
|
||||||
|
elapses.
|
||||||
|
|
||||||
A successful TCP connect is sufficient — registry:2.8.3 binds
|
A successful TCP connect is sufficient — registry:2.8.3 binds
|
||||||
after it's ready to serve `/v2/` requests, so the push that
|
after it's ready to serve `/v2/` requests, so the push that
|
||||||
follows will land on a working server. We probe loopback
|
follows will land on a working server. We probe loopback
|
||||||
specifically (not host.docker.internal) because this helper
|
specifically (not via the docker network) because this helper
|
||||||
runs on the host, and 0.0.0.0-bound ports are reachable via
|
runs on the host."""
|
||||||
127.0.0.1 too."""
|
|
||||||
deadline = time.monotonic() + _READY_TIMEOUT_S
|
deadline = time.monotonic() + _READY_TIMEOUT_S
|
||||||
last_err: Exception | None = None
|
last_err: Exception | None = None
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ 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 .local_registry import crane_push_tarball, ephemeral_registry
|
||||||
from .util import smolmachines_bundle_subnet, smolmachines_preflight
|
from .util import smolmachines_bundle_subnet, smolmachines_preflight
|
||||||
|
|
||||||
|
|
||||||
@@ -185,15 +185,14 @@ def _ensure_smolmachine(image_ref: str) -> Path:
|
|||||||
it; the sidecar is the actual artifact).
|
it; the sidecar is the actual artifact).
|
||||||
|
|
||||||
Conversion path: `docker build` (the existing layer cache
|
Conversion path: `docker build` (the existing layer cache
|
||||||
makes no-change rebuilds cheap) → `docker tag` + `docker push`
|
makes no-change rebuilds cheap) → `docker save` to a tarball
|
||||||
using the daemon-side endpoint (`host.docker.internal:<port>`
|
→ spin up an ephemeral registry on a private docker network →
|
||||||
on Docker Desktop, `localhost:<port>` on native Linux) →
|
`crane push --insecure` from a one-shot container on the same
|
||||||
`smolvm pack create --image <host endpoint>` using the
|
network → `smolvm pack create --image localhost:<host port>/...`
|
||||||
host-side endpoint (always `localhost:<port>` — smolvm is a
|
→ tear down the registry + network. The crane push detour
|
||||||
host process) → tear down the registry. The two endpoints
|
sidesteps the Docker-Desktop daemon's HTTPS preference for
|
||||||
route to the same registry container; only the hostname
|
non-loopback registries — see the `local_registry` module
|
||||||
differs because the docker daemon (on Docker Desktop) doesn't
|
docstring for the gory details.
|
||||||
share the host's loopback.
|
|
||||||
|
|
||||||
Each pack-create costs several seconds even on a hot cache,
|
Each pack-create costs several seconds even on a hot cache,
|
||||||
so we skip the whole pipeline when the cached sidecar is
|
so we skip the whole pipeline when the cached sidecar is
|
||||||
@@ -208,10 +207,17 @@ def _ensure_smolmachine(image_ref: str) -> Path:
|
|||||||
sidecar = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine"
|
sidecar = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine"
|
||||||
if sidecar.is_file():
|
if sidecar.is_file():
|
||||||
return sidecar
|
return sidecar
|
||||||
with ephemeral_registry() as endpoints:
|
tarball = _SMOLMACHINE_CACHE_DIR / f"{digest}.image.tar"
|
||||||
push_ref = f"{endpoints.daemon_endpoint}/claude-bottle:{digest}"
|
docker_mod.save(image_ref, str(tarball))
|
||||||
pack_ref = f"{endpoints.host_endpoint}/claude-bottle:{digest}"
|
try:
|
||||||
docker_mod.tag(image_ref, push_ref)
|
with ephemeral_registry() as handle:
|
||||||
docker_mod.push(push_ref)
|
push_ref = f"{handle.push_endpoint}/claude-bottle:{digest}"
|
||||||
_smolvm.pack_create(pack_ref, binary)
|
pack_ref = f"{handle.pull_endpoint}/claude-bottle:{digest}"
|
||||||
|
crane_push_tarball(handle, str(tarball), push_ref)
|
||||||
|
_smolvm.pack_create(pack_ref, binary)
|
||||||
|
finally:
|
||||||
|
# Tarball is ~500MB-1GB for the agent image; reclaim once
|
||||||
|
# the smolmachine artifact exists. The artifact itself is
|
||||||
|
# the long-lived cache entry.
|
||||||
|
tarball.unlink(missing_ok=True)
|
||||||
return sidecar
|
return sidecar
|
||||||
|
|||||||
@@ -54,26 +54,18 @@ class TestImageId(unittest.TestCase):
|
|||||||
self.assertIn("missing:tag", die.call_args.args[0])
|
self.assertIn("missing:tag", die.call_args.args[0])
|
||||||
|
|
||||||
|
|
||||||
class TestTagPush(unittest.TestCase):
|
class TestSave(unittest.TestCase):
|
||||||
def test_tag_runs_docker_tag(self):
|
def test_save_runs_docker_save(self):
|
||||||
with patch.object(
|
with patch.object(
|
||||||
docker_mod.subprocess, "run", return_value=_ok(),
|
docker_mod.subprocess, "run", return_value=_ok(),
|
||||||
) as run:
|
) as run:
|
||||||
docker_mod.tag("claude-bottle:latest", "localhost:5000/cb:abc")
|
docker_mod.save("claude-bottle:latest", "/tmp/img.tar")
|
||||||
argv = run.call_args.args[0]
|
argv = run.call_args.args[0]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["docker", "tag", "claude-bottle:latest", "localhost:5000/cb:abc"],
|
["docker", "save", "claude-bottle:latest", "-o", "/tmp/img.tar"],
|
||||||
argv,
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""Unit: ephemeral local-registry helper (PRD 0023 chunk 4c).
|
"""Unit: ephemeral local-registry helper (PRD 0023 chunk 4c).
|
||||||
|
|
||||||
The helper brings up a `registry:2.8.3` container on a random
|
The helper brings up a `registry:2.8.3` container on a private
|
||||||
host port, yields a `(daemon_endpoint, host_endpoint)` pair, and
|
docker network with a random host-side port, yields a
|
||||||
tears the container down on exit. Tests mock `subprocess.run` +
|
`RegistryHandle`, and tears the container + network down on exit.
|
||||||
`socket.create_connection` so they run without docker."""
|
Tests mock `subprocess.run` + `socket.create_connection` so they
|
||||||
|
run without docker."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -20,71 +21,56 @@ def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# `docker info` always runs once per ephemeral_registry() to pick
|
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess:
|
||||||
# the daemon-side hostname; the run sequence is therefore
|
return subprocess.CompletedProcess(
|
||||||
# (docker run, docker port, docker info, docker rm). Helpers below
|
args=[], returncode=1, stdout="", stderr=stderr,
|
||||||
# build a stock side_effect that covers all four.
|
)
|
||||||
def _stock_run_sequence(
|
|
||||||
*,
|
|
||||||
port: str = "0.0.0.0:54321\n",
|
# Run sequence per ephemeral_registry() call:
|
||||||
operating_system: str = "Docker Desktop\n",
|
# docker network create -> ok
|
||||||
):
|
# docker run -d (registry) -> ok (container id)
|
||||||
|
# docker port (host port) -> ok (mapping line)
|
||||||
|
# docker rm -f (registry) -> ok (in finally)
|
||||||
|
# docker network rm -> ok (in finally)
|
||||||
|
def _stock_run_sequence(port_line: str = "0.0.0.0:54321\n"):
|
||||||
return [
|
return [
|
||||||
_ok(stdout="<container-id>\n"), # docker run
|
_ok(), # docker network create
|
||||||
_ok(stdout=port), # docker port
|
_ok(stdout="<container-id>\n"), # docker run
|
||||||
_ok(stdout=operating_system), # docker info
|
_ok(stdout=port_line), # docker port
|
||||||
_ok(), # docker rm -f
|
_ok(), # docker rm -f
|
||||||
|
_ok(), # docker network rm
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class TestEphemeralRegistry(unittest.TestCase):
|
class TestEphemeralRegistry(unittest.TestCase):
|
||||||
def test_yields_endpoints_with_docker_desktop_routing(self):
|
def test_yields_handle_with_network_and_endpoints(self):
|
||||||
# On Docker Desktop the daemon runs in its own VM, so the
|
|
||||||
# registry has to be addressed by host.docker.internal for
|
|
||||||
# docker push to work; smolvm (host process) still uses
|
|
||||||
# localhost.
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
local_registry.subprocess, "run",
|
local_registry.subprocess, "run",
|
||||||
side_effect=_stock_run_sequence(operating_system="Docker Desktop\n"),
|
side_effect=_stock_run_sequence(),
|
||||||
), patch.object(
|
) as run, patch.object(
|
||||||
local_registry.socket, "create_connection",
|
local_registry.socket, "create_connection",
|
||||||
return_value=_FakeSocket(),
|
return_value=_FakeSocket(),
|
||||||
):
|
):
|
||||||
with local_registry.ephemeral_registry() as endpoints:
|
with local_registry.ephemeral_registry() as handle:
|
||||||
self.assertEqual(
|
# push_endpoint points at the registry container by
|
||||||
"host.docker.internal:54321", endpoints.daemon_endpoint,
|
# its docker-network name on its container port.
|
||||||
|
self.assertTrue(
|
||||||
|
handle.push_endpoint.startswith(
|
||||||
|
"claude-bottle-registry-"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertTrue(handle.push_endpoint.endswith(":5000"))
|
||||||
"localhost:54321", endpoints.host_endpoint,
|
# pull_endpoint is the host-side mapping for smolvm.
|
||||||
|
self.assertEqual("localhost:54321", handle.pull_endpoint)
|
||||||
|
# network name is the per-session bridge crane joins.
|
||||||
|
self.assertTrue(
|
||||||
|
handle.network.startswith("claude-bottle-registry-net-")
|
||||||
)
|
)
|
||||||
|
# docker network create + docker run + docker port + rm -f + network rm
|
||||||
|
self.assertEqual(5, run.call_count)
|
||||||
|
|
||||||
def test_yields_endpoints_with_native_linux_routing(self):
|
def test_registry_run_publishes_random_port_across_interfaces(self):
|
||||||
# On a native Linux daemon the daemon shares the host's
|
|
||||||
# network namespace, so localhost reaches the registry from
|
|
||||||
# both sides.
|
|
||||||
with patch.object(
|
|
||||||
local_registry.subprocess, "run",
|
|
||||||
side_effect=_stock_run_sequence(
|
|
||||||
operating_system="Debian GNU/Linux 12 (bookworm)\n",
|
|
||||||
),
|
|
||||||
), patch.object(
|
|
||||||
local_registry.socket, "create_connection",
|
|
||||||
return_value=_FakeSocket(),
|
|
||||||
):
|
|
||||||
with local_registry.ephemeral_registry() as endpoints:
|
|
||||||
self.assertEqual(
|
|
||||||
"localhost:54321", endpoints.daemon_endpoint,
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
"localhost:54321", endpoints.host_endpoint,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_runs_docker_with_all_interface_bind(self):
|
|
||||||
# `-p 5000` (no IP prefix) binds the container's port 5000
|
|
||||||
# on a random host port across all interfaces — needed so
|
|
||||||
# Docker Desktop's daemon can reach the registry via
|
|
||||||
# host.docker.internal. The 127.0.0.1-only bind we used
|
|
||||||
# previously was invisible to the daemon's VM.
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
local_registry.subprocess, "run",
|
local_registry.subprocess, "run",
|
||||||
side_effect=_stock_run_sequence(),
|
side_effect=_stock_run_sequence(),
|
||||||
@@ -94,16 +80,19 @@ class TestEphemeralRegistry(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
with local_registry.ephemeral_registry():
|
with local_registry.ephemeral_registry():
|
||||||
pass
|
pass
|
||||||
|
# second call is the docker run for the registry
|
||||||
run_argv = run.call_args_list[0].args[0]
|
run_argv = run.call_args_list[1].args[0]
|
||||||
self.assertEqual(["docker", "run"], run_argv[:2])
|
self.assertEqual(["docker", "run"], run_argv[:2])
|
||||||
self.assertIn("--rm", run_argv)
|
self.assertIn("--rm", run_argv)
|
||||||
|
# `-p 5000` (no IP prefix) — needed so the host-published
|
||||||
|
# port is reachable from BOTH the host (for smolvm) and the
|
||||||
|
# docker daemon (for the docker port command to find it).
|
||||||
self.assertIn("5000", run_argv)
|
self.assertIn("5000", run_argv)
|
||||||
# Explicitly NOT the loopback-only form — that one's broken
|
# And the registry is attached to the same per-session
|
||||||
# under Docker Desktop.
|
# network the crane push container joins.
|
||||||
self.assertNotIn("127.0.0.1::5000", run_argv)
|
self.assertIn("--network", run_argv)
|
||||||
|
|
||||||
def test_force_removes_container_on_clean_exit(self):
|
def test_force_removes_container_and_network_on_clean_exit(self):
|
||||||
with patch.object(
|
with patch.object(
|
||||||
local_registry.subprocess, "run",
|
local_registry.subprocess, "run",
|
||||||
side_effect=_stock_run_sequence(),
|
side_effect=_stock_run_sequence(),
|
||||||
@@ -114,11 +103,13 @@ class TestEphemeralRegistry(unittest.TestCase):
|
|||||||
with local_registry.ephemeral_registry():
|
with local_registry.ephemeral_registry():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Last call is `docker rm -f <name>`.
|
# Last two calls are `docker rm -f <container>` then
|
||||||
last_argv = run.call_args_list[-1].args[0]
|
# `docker network rm <network>`.
|
||||||
self.assertEqual(["docker", "rm", "-f"], last_argv[:3])
|
argvs = [c.args[0] for c in run.call_args_list]
|
||||||
|
self.assertEqual(["docker", "rm", "-f"], argvs[-2][:3])
|
||||||
|
self.assertEqual(["docker", "network", "rm"], argvs[-1][:3])
|
||||||
|
|
||||||
def test_force_removes_container_on_exception_inside_with(self):
|
def test_force_removes_on_exception_inside_with(self):
|
||||||
with patch.object(
|
with patch.object(
|
||||||
local_registry.subprocess, "run",
|
local_registry.subprocess, "run",
|
||||||
side_effect=_stock_run_sequence(),
|
side_effect=_stock_run_sequence(),
|
||||||
@@ -130,12 +121,12 @@ class TestEphemeralRegistry(unittest.TestCase):
|
|||||||
with local_registry.ephemeral_registry():
|
with local_registry.ephemeral_registry():
|
||||||
raise RuntimeError("inside with")
|
raise RuntimeError("inside with")
|
||||||
|
|
||||||
# rm -f still ran on exception.
|
# Both teardowns still ran.
|
||||||
last_argv = run.call_args_list[-1].args[0]
|
argvs = [c.args[0] for c in run.call_args_list]
|
||||||
self.assertEqual(["docker", "rm", "-f"], last_argv[:3])
|
self.assertEqual(["docker", "rm", "-f"], argvs[-2][:3])
|
||||||
|
self.assertEqual(["docker", "network", "rm"], argvs[-1][:3])
|
||||||
|
|
||||||
def test_wait_ready_times_out_when_socket_never_connects(self):
|
def test_wait_ready_times_out(self):
|
||||||
# Drop the timeout to a value that fits the test budget.
|
|
||||||
with patch.object(local_registry, "_READY_TIMEOUT_S", 0.1), patch.object(
|
with patch.object(local_registry, "_READY_TIMEOUT_S", 0.1), patch.object(
|
||||||
local_registry.subprocess, "run",
|
local_registry.subprocess, "run",
|
||||||
side_effect=_stock_run_sequence(),
|
side_effect=_stock_run_sequence(),
|
||||||
@@ -150,21 +141,26 @@ class TestEphemeralRegistry(unittest.TestCase):
|
|||||||
with local_registry.ephemeral_registry():
|
with local_registry.ephemeral_registry():
|
||||||
self.fail("yield reached despite unreachable registry")
|
self.fail("yield reached despite unreachable registry")
|
||||||
die.assert_called_once()
|
die.assert_called_once()
|
||||||
# rm -f still ran (cleanup goes through the finally block).
|
# Teardown still ran via the finally blocks.
|
||||||
last_argv = run.call_args_list[-1].args[0]
|
argvs = [c.args[0] for c in run.call_args_list]
|
||||||
self.assertEqual(["docker", "rm", "-f"], last_argv[:3])
|
self.assertEqual(["docker", "rm", "-f"], argvs[-2][:3])
|
||||||
|
self.assertEqual(["docker", "network", "rm"], argvs[-1][:3])
|
||||||
|
|
||||||
def test_unique_container_name_per_call(self):
|
def test_unique_session_ids_per_call(self):
|
||||||
names: list[str] = []
|
sessions: list[tuple[str, str]] = []
|
||||||
|
|
||||||
def capture(argv, *a, **kw):
|
def capture(argv, *a, **kw):
|
||||||
|
if argv[:3] == ["docker", "network", "create"]:
|
||||||
|
return _ok()
|
||||||
if argv[:2] == ["docker", "run"]:
|
if argv[:2] == ["docker", "run"]:
|
||||||
names.append(argv[argv.index("--name") + 1])
|
# `--name <registry-name>` and `--network <net-name>`
|
||||||
|
# both encode the session id.
|
||||||
|
name = argv[argv.index("--name") + 1]
|
||||||
|
network = argv[argv.index("--network") + 1]
|
||||||
|
sessions.append((name, network))
|
||||||
return _ok(stdout="cid\n")
|
return _ok(stdout="cid\n")
|
||||||
if argv[:2] == ["docker", "port"]:
|
if argv[:2] == ["docker", "port"]:
|
||||||
return _ok(stdout="0.0.0.0:1\n")
|
return _ok(stdout="0.0.0.0:1\n")
|
||||||
if argv[:2] == ["docker", "info"]:
|
|
||||||
return _ok(stdout="Docker Desktop\n")
|
|
||||||
return _ok()
|
return _ok()
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
@@ -178,10 +174,64 @@ class TestEphemeralRegistry(unittest.TestCase):
|
|||||||
with local_registry.ephemeral_registry():
|
with local_registry.ephemeral_registry():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self.assertEqual(2, len(names))
|
self.assertEqual(2, len(sessions))
|
||||||
self.assertNotEqual(names[0], names[1])
|
self.assertNotEqual(sessions[0], sessions[1])
|
||||||
for n in names:
|
|
||||||
self.assertTrue(n.startswith("claude-bottle-registry-"))
|
|
||||||
|
class TestCranePushTarball(unittest.TestCase):
|
||||||
|
def test_runs_crane_container_on_registry_network_with_insecure_flag(self):
|
||||||
|
handle = local_registry.RegistryHandle(
|
||||||
|
network="cb-registry-net-x",
|
||||||
|
push_endpoint="cb-registry-x:5000",
|
||||||
|
pull_endpoint="localhost:54321",
|
||||||
|
)
|
||||||
|
with patch.object(
|
||||||
|
local_registry.subprocess, "run", return_value=_ok(),
|
||||||
|
) as run:
|
||||||
|
local_registry.crane_push_tarball(
|
||||||
|
handle, "/tmp/img.tar", "cb-registry-x:5000/cb:abc",
|
||||||
|
)
|
||||||
|
|
||||||
|
argv = run.call_args.args[0]
|
||||||
|
# Joined to the same docker network so it can reach the
|
||||||
|
# registry by container name (no host port-forward needed
|
||||||
|
# for the push leg).
|
||||||
|
self.assertEqual("docker", argv[0])
|
||||||
|
self.assertEqual("run", argv[1])
|
||||||
|
self.assertIn("--rm", argv)
|
||||||
|
self.assertIn("--network", argv)
|
||||||
|
self.assertEqual(
|
||||||
|
"cb-registry-net-x", argv[argv.index("--network") + 1],
|
||||||
|
)
|
||||||
|
# The tarball is mounted read-only at /img.tar.
|
||||||
|
self.assertIn("-v", argv)
|
||||||
|
self.assertIn("/tmp/img.tar:/img.tar:ro", argv)
|
||||||
|
# And the crane command itself uses --insecure so plain
|
||||||
|
# HTTP is allowed against the registry container.
|
||||||
|
self.assertIn("push", argv)
|
||||||
|
self.assertIn("--insecure", argv)
|
||||||
|
self.assertIn("/img.tar", argv)
|
||||||
|
self.assertIn("cb-registry-x:5000/cb:abc", argv)
|
||||||
|
|
||||||
|
def test_dies_when_crane_returns_non_zero(self):
|
||||||
|
handle = local_registry.RegistryHandle(
|
||||||
|
network="cb-net", push_endpoint="cb:5000", pull_endpoint="localhost:1",
|
||||||
|
)
|
||||||
|
with patch.object(
|
||||||
|
local_registry.subprocess, "run",
|
||||||
|
return_value=_fail("push failed"),
|
||||||
|
), patch.object(
|
||||||
|
local_registry, "die", side_effect=SystemExit("die"),
|
||||||
|
) as die:
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
local_registry.crane_push_tarball(
|
||||||
|
handle, "/tmp/img.tar", "cb:5000/cb:abc",
|
||||||
|
)
|
||||||
|
die.assert_called_once()
|
||||||
|
# Error message names what was being pushed where.
|
||||||
|
msg = die.call_args.args[0]
|
||||||
|
self.assertIn("/tmp/img.tar", msg)
|
||||||
|
self.assertIn("cb:5000/cb:abc", msg)
|
||||||
|
|
||||||
|
|
||||||
class _FakeSocket:
|
class _FakeSocket:
|
||||||
|
|||||||
@@ -40,40 +40,42 @@ class TestEnsureSmolmachine(unittest.TestCase):
|
|||||||
_prepare.docker_mod, "image_id",
|
_prepare.docker_mod, "image_id",
|
||||||
return_value=f"sha256:{digest}fffffffffffffffff",
|
return_value=f"sha256:{digest}fffffffffffffffff",
|
||||||
), patch.object(
|
), patch.object(
|
||||||
|
_prepare.docker_mod, "save",
|
||||||
|
) as save, patch.object(
|
||||||
_prepare, "ephemeral_registry",
|
_prepare, "ephemeral_registry",
|
||||||
) as registry, patch.object(
|
) as registry, patch.object(
|
||||||
_prepare.docker_mod, "tag",
|
_prepare, "crane_push_tarball",
|
||||||
) as tag, patch.object(
|
|
||||||
_prepare.docker_mod, "push",
|
|
||||||
) as push, patch.object(
|
) as push, patch.object(
|
||||||
_prepare._smolvm, "pack_create",
|
_prepare._smolvm, "pack_create",
|
||||||
) as pack:
|
) as pack:
|
||||||
result = _prepare._ensure_smolmachine("claude-bottle:latest")
|
result = _prepare._ensure_smolmachine("claude-bottle:latest")
|
||||||
|
|
||||||
self.assertEqual(sidecar, result)
|
self.assertEqual(sidecar, result)
|
||||||
# build still runs (Dockerfile edits land without manual rmi)
|
# build still runs (Dockerfile edits land without manual rmi).
|
||||||
build.assert_called_once()
|
build.assert_called_once()
|
||||||
# No registry, no tag, no push, no pack on cache hit.
|
# No save (500MB tarball), no registry, no push, no pack on
|
||||||
|
# cache hit.
|
||||||
|
save.assert_not_called()
|
||||||
registry.assert_not_called()
|
registry.assert_not_called()
|
||||||
tag.assert_not_called()
|
|
||||||
push.assert_not_called()
|
push.assert_not_called()
|
||||||
pack.assert_not_called()
|
pack.assert_not_called()
|
||||||
|
|
||||||
def test_cache_miss_runs_build_tag_push_pack_in_order(self):
|
def test_cache_miss_runs_build_save_push_pack_in_order(self):
|
||||||
digest = "0123456789abcdef"
|
digest = "0123456789abcdef"
|
||||||
|
|
||||||
# ephemeral_registry yields a RegistryEndpoints with two
|
# ephemeral_registry yields a RegistryHandle with the
|
||||||
# routing hostnames — daemon-side for docker push,
|
# docker network + a push endpoint (container DNS) and
|
||||||
# host-side for smolvm pack create.
|
# pull endpoint (host port-forward).
|
||||||
from claude_bottle.backend.smolmachines.local_registry import (
|
from claude_bottle.backend.smolmachines.local_registry import (
|
||||||
RegistryEndpoints,
|
RegistryHandle,
|
||||||
)
|
)
|
||||||
|
|
||||||
class _Reg:
|
class _Reg:
|
||||||
def __enter__(self_inner):
|
def __enter__(self_inner):
|
||||||
return RegistryEndpoints(
|
return RegistryHandle(
|
||||||
daemon_endpoint="host.docker.internal:54321",
|
network="cb-net-xyz",
|
||||||
host_endpoint="localhost:54321",
|
push_endpoint="cb-registry-xyz:5000",
|
||||||
|
pull_endpoint="localhost:54321",
|
||||||
)
|
)
|
||||||
def __exit__(self_inner, *exc):
|
def __exit__(self_inner, *exc):
|
||||||
return False
|
return False
|
||||||
@@ -92,13 +94,13 @@ class TestEnsureSmolmachine(unittest.TestCase):
|
|||||||
_prepare.docker_mod, "image_id",
|
_prepare.docker_mod, "image_id",
|
||||||
return_value=f"sha256:{digest}fffffffffffffffff",
|
return_value=f"sha256:{digest}fffffffffffffffff",
|
||||||
), patch.object(
|
), patch.object(
|
||||||
|
_prepare.docker_mod, "save",
|
||||||
|
side_effect=record("save"),
|
||||||
|
) as save, patch.object(
|
||||||
_prepare, "ephemeral_registry",
|
_prepare, "ephemeral_registry",
|
||||||
return_value=_Reg(),
|
return_value=_Reg(),
|
||||||
), patch.object(
|
), patch.object(
|
||||||
_prepare.docker_mod, "tag",
|
_prepare, "crane_push_tarball",
|
||||||
side_effect=record("tag"),
|
|
||||||
) as tag, patch.object(
|
|
||||||
_prepare.docker_mod, "push",
|
|
||||||
side_effect=record("push"),
|
side_effect=record("push"),
|
||||||
) as push, patch.object(
|
) as push, patch.object(
|
||||||
_prepare._smolvm, "pack_create",
|
_prepare._smolvm, "pack_create",
|
||||||
@@ -106,26 +108,27 @@ class TestEnsureSmolmachine(unittest.TestCase):
|
|||||||
) as pack:
|
) as pack:
|
||||||
_prepare._ensure_smolmachine("claude-bottle:latest")
|
_prepare._ensure_smolmachine("claude-bottle:latest")
|
||||||
|
|
||||||
# build first (no point pushing if the build fails), then
|
# Build → save → push → pack in that order. No `docker
|
||||||
# tag → push → pack against the registry endpoints.
|
# push` (the daemon's HTTPS-by-default path is what we're
|
||||||
self.assertEqual(["build", "tag", "push", "pack"], calls)
|
# sidestepping).
|
||||||
|
self.assertEqual(["build", "save", "push", "pack"], calls)
|
||||||
|
|
||||||
# tag + push target the daemon-side endpoint (host.docker
|
# docker save targets a per-digest tarball alongside the
|
||||||
# .internal on Docker Desktop, since the daemon's
|
# cached sidecar.
|
||||||
# localhost is its own VM's loopback).
|
save_args = save.call_args.args
|
||||||
tag_args = tag.call_args.args
|
self.assertEqual("claude-bottle:latest", save_args[0])
|
||||||
self.assertEqual("claude-bottle:latest", tag_args[0])
|
self.assertTrue(save_args[1].endswith(f"{digest}.image.tar"))
|
||||||
self.assertEqual(
|
|
||||||
f"host.docker.internal:54321/claude-bottle:{digest}", tag_args[1],
|
# crane push runs against the push_endpoint (container DNS
|
||||||
)
|
# on the registry network) with the digest as the tag.
|
||||||
push_args = push.call_args.args
|
push_args = push.call_args.args
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
f"host.docker.internal:54321/claude-bottle:{digest}", push_args[0],
|
f"cb-registry-xyz:5000/claude-bottle:{digest}", push_args[2],
|
||||||
)
|
)
|
||||||
# pack_create reads from the host-side endpoint (smolvm is
|
|
||||||
# a host process and can only resolve localhost). The
|
# pack_create reads from the pull_endpoint (host port-
|
||||||
# registry stores images by repo+tag, so both endpoints
|
# forward, smolvm is on the host). Same repo+tag, just a
|
||||||
# hit the same blob.
|
# different routing hostname — the registry stores one blob.
|
||||||
pack_args = pack.call_args.args
|
pack_args = pack.call_args.args
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
f"localhost:54321/claude-bottle:{digest}", pack_args[0],
|
f"localhost:54321/claude-bottle:{digest}", pack_args[0],
|
||||||
|
|||||||
Reference in New Issue
Block a user