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:
@@ -54,26 +54,18 @@ class TestImageId(unittest.TestCase):
|
||||
self.assertIn("missing:tag", die.call_args.args[0])
|
||||
|
||||
|
||||
class TestTagPush(unittest.TestCase):
|
||||
def test_tag_runs_docker_tag(self):
|
||||
class TestSave(unittest.TestCase):
|
||||
def test_save_runs_docker_save(self):
|
||||
with patch.object(
|
||||
docker_mod.subprocess, "run", return_value=_ok(),
|
||||
) 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]
|
||||
self.assertEqual(
|
||||
["docker", "tag", "claude-bottle:latest", "localhost:5000/cb:abc"],
|
||||
["docker", "save", "claude-bottle:latest", "-o", "/tmp/img.tar"],
|
||||
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()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Unit: ephemeral local-registry helper (PRD 0023 chunk 4c).
|
||||
|
||||
The helper brings up a `registry:2.8.3` container on a random
|
||||
host port, yields a `(daemon_endpoint, host_endpoint)` pair, and
|
||||
tears the container down on exit. Tests mock `subprocess.run` +
|
||||
`socket.create_connection` so they run without docker."""
|
||||
The helper brings up a `registry:2.8.3` container on a private
|
||||
docker network with a random host-side port, yields a
|
||||
`RegistryHandle`, and tears the container + network down on exit.
|
||||
Tests mock `subprocess.run` + `socket.create_connection` so they
|
||||
run without docker."""
|
||||
|
||||
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
|
||||
# the daemon-side hostname; the run sequence is therefore
|
||||
# (docker run, docker port, docker info, docker rm). Helpers below
|
||||
# build a stock side_effect that covers all four.
|
||||
def _stock_run_sequence(
|
||||
*,
|
||||
port: str = "0.0.0.0:54321\n",
|
||||
operating_system: str = "Docker Desktop\n",
|
||||
):
|
||||
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess:
|
||||
return subprocess.CompletedProcess(
|
||||
args=[], returncode=1, stdout="", stderr=stderr,
|
||||
)
|
||||
|
||||
|
||||
# Run sequence per ephemeral_registry() call:
|
||||
# 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 [
|
||||
_ok(stdout="<container-id>\n"), # docker run
|
||||
_ok(stdout=port), # docker port
|
||||
_ok(stdout=operating_system), # docker info
|
||||
_ok(), # docker rm -f
|
||||
_ok(), # docker network create
|
||||
_ok(stdout="<container-id>\n"), # docker run
|
||||
_ok(stdout=port_line), # docker port
|
||||
_ok(), # docker rm -f
|
||||
_ok(), # docker network rm
|
||||
]
|
||||
|
||||
|
||||
class TestEphemeralRegistry(unittest.TestCase):
|
||||
def test_yields_endpoints_with_docker_desktop_routing(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.
|
||||
def test_yields_handle_with_network_and_endpoints(self):
|
||||
with patch.object(
|
||||
local_registry.subprocess, "run",
|
||||
side_effect=_stock_run_sequence(operating_system="Docker Desktop\n"),
|
||||
), patch.object(
|
||||
side_effect=_stock_run_sequence(),
|
||||
) as run, patch.object(
|
||||
local_registry.socket, "create_connection",
|
||||
return_value=_FakeSocket(),
|
||||
):
|
||||
with local_registry.ephemeral_registry() as endpoints:
|
||||
self.assertEqual(
|
||||
"host.docker.internal:54321", endpoints.daemon_endpoint,
|
||||
with local_registry.ephemeral_registry() as handle:
|
||||
# push_endpoint points at the registry container by
|
||||
# its docker-network name on its container port.
|
||||
self.assertTrue(
|
||||
handle.push_endpoint.startswith(
|
||||
"claude-bottle-registry-"
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
"localhost:54321", endpoints.host_endpoint,
|
||||
self.assertTrue(handle.push_endpoint.endswith(":5000"))
|
||||
# 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):
|
||||
# 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.
|
||||
def test_registry_run_publishes_random_port_across_interfaces(self):
|
||||
with patch.object(
|
||||
local_registry.subprocess, "run",
|
||||
side_effect=_stock_run_sequence(),
|
||||
@@ -94,16 +80,19 @@ class TestEphemeralRegistry(unittest.TestCase):
|
||||
):
|
||||
with local_registry.ephemeral_registry():
|
||||
pass
|
||||
|
||||
run_argv = run.call_args_list[0].args[0]
|
||||
# second call is the docker run for the registry
|
||||
run_argv = run.call_args_list[1].args[0]
|
||||
self.assertEqual(["docker", "run"], run_argv[:2])
|
||||
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)
|
||||
# Explicitly NOT the loopback-only form — that one's broken
|
||||
# under Docker Desktop.
|
||||
self.assertNotIn("127.0.0.1::5000", run_argv)
|
||||
# And the registry is attached to the same per-session
|
||||
# network the crane push container joins.
|
||||
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(
|
||||
local_registry.subprocess, "run",
|
||||
side_effect=_stock_run_sequence(),
|
||||
@@ -114,11 +103,13 @@ class TestEphemeralRegistry(unittest.TestCase):
|
||||
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])
|
||||
# Last two calls are `docker rm -f <container>` then
|
||||
# `docker network rm <network>`.
|
||||
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(
|
||||
local_registry.subprocess, "run",
|
||||
side_effect=_stock_run_sequence(),
|
||||
@@ -130,12 +121,12 @@ class TestEphemeralRegistry(unittest.TestCase):
|
||||
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])
|
||||
# Both teardowns still ran.
|
||||
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_wait_ready_times_out_when_socket_never_connects(self):
|
||||
# Drop the timeout to a value that fits the test budget.
|
||||
def test_wait_ready_times_out(self):
|
||||
with patch.object(local_registry, "_READY_TIMEOUT_S", 0.1), patch.object(
|
||||
local_registry.subprocess, "run",
|
||||
side_effect=_stock_run_sequence(),
|
||||
@@ -150,21 +141,26 @@ class TestEphemeralRegistry(unittest.TestCase):
|
||||
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])
|
||||
# Teardown still ran via the finally blocks.
|
||||
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_unique_container_name_per_call(self):
|
||||
names: list[str] = []
|
||||
def test_unique_session_ids_per_call(self):
|
||||
sessions: list[tuple[str, str]] = []
|
||||
|
||||
def capture(argv, *a, **kw):
|
||||
if argv[:3] == ["docker", "network", "create"]:
|
||||
return _ok()
|
||||
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")
|
||||
if argv[:2] == ["docker", "port"]:
|
||||
return _ok(stdout="0.0.0.0:1\n")
|
||||
if argv[:2] == ["docker", "info"]:
|
||||
return _ok(stdout="Docker Desktop\n")
|
||||
return _ok()
|
||||
|
||||
with patch.object(
|
||||
@@ -178,10 +174,64 @@ class TestEphemeralRegistry(unittest.TestCase):
|
||||
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-"))
|
||||
self.assertEqual(2, len(sessions))
|
||||
self.assertNotEqual(sessions[0], sessions[1])
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@@ -40,40 +40,42 @@ class TestEnsureSmolmachine(unittest.TestCase):
|
||||
_prepare.docker_mod, "image_id",
|
||||
return_value=f"sha256:{digest}fffffffffffffffff",
|
||||
), patch.object(
|
||||
_prepare.docker_mod, "save",
|
||||
) as save, patch.object(
|
||||
_prepare, "ephemeral_registry",
|
||||
) as registry, patch.object(
|
||||
_prepare.docker_mod, "tag",
|
||||
) as tag, patch.object(
|
||||
_prepare.docker_mod, "push",
|
||||
_prepare, "crane_push_tarball",
|
||||
) 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 still runs (Dockerfile edits land without manual rmi).
|
||||
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()
|
||||
tag.assert_not_called()
|
||||
push.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"
|
||||
|
||||
# ephemeral_registry yields a RegistryEndpoints with two
|
||||
# routing hostnames — daemon-side for docker push,
|
||||
# host-side for smolvm pack create.
|
||||
# ephemeral_registry yields a RegistryHandle with the
|
||||
# docker network + a push endpoint (container DNS) and
|
||||
# pull endpoint (host port-forward).
|
||||
from claude_bottle.backend.smolmachines.local_registry import (
|
||||
RegistryEndpoints,
|
||||
RegistryHandle,
|
||||
)
|
||||
|
||||
class _Reg:
|
||||
def __enter__(self_inner):
|
||||
return RegistryEndpoints(
|
||||
daemon_endpoint="host.docker.internal:54321",
|
||||
host_endpoint="localhost:54321",
|
||||
return RegistryHandle(
|
||||
network="cb-net-xyz",
|
||||
push_endpoint="cb-registry-xyz:5000",
|
||||
pull_endpoint="localhost:54321",
|
||||
)
|
||||
def __exit__(self_inner, *exc):
|
||||
return False
|
||||
@@ -92,13 +94,13 @@ class TestEnsureSmolmachine(unittest.TestCase):
|
||||
_prepare.docker_mod, "image_id",
|
||||
return_value=f"sha256:{digest}fffffffffffffffff",
|
||||
), patch.object(
|
||||
_prepare.docker_mod, "save",
|
||||
side_effect=record("save"),
|
||||
) as save, 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",
|
||||
_prepare, "crane_push_tarball",
|
||||
side_effect=record("push"),
|
||||
) as push, patch.object(
|
||||
_prepare._smolvm, "pack_create",
|
||||
@@ -106,26 +108,27 @@ class TestEnsureSmolmachine(unittest.TestCase):
|
||||
) as pack:
|
||||
_prepare._ensure_smolmachine("claude-bottle:latest")
|
||||
|
||||
# build first (no point pushing if the build fails), then
|
||||
# tag → push → pack against the registry endpoints.
|
||||
self.assertEqual(["build", "tag", "push", "pack"], calls)
|
||||
# Build → save → push → pack in that order. No `docker
|
||||
# push` (the daemon's HTTPS-by-default path is what we're
|
||||
# sidestepping).
|
||||
self.assertEqual(["build", "save", "push", "pack"], calls)
|
||||
|
||||
# tag + push target the daemon-side endpoint (host.docker
|
||||
# .internal on Docker Desktop, since the daemon's
|
||||
# localhost is its own VM's loopback).
|
||||
tag_args = tag.call_args.args
|
||||
self.assertEqual("claude-bottle:latest", tag_args[0])
|
||||
self.assertEqual(
|
||||
f"host.docker.internal:54321/claude-bottle:{digest}", tag_args[1],
|
||||
)
|
||||
# docker save targets a per-digest tarball alongside the
|
||||
# cached sidecar.
|
||||
save_args = save.call_args.args
|
||||
self.assertEqual("claude-bottle:latest", save_args[0])
|
||||
self.assertTrue(save_args[1].endswith(f"{digest}.image.tar"))
|
||||
|
||||
# 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
|
||||
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
|
||||
# registry stores images by repo+tag, so both endpoints
|
||||
# hit the same blob.
|
||||
|
||||
# pack_create reads from the pull_endpoint (host port-
|
||||
# forward, smolvm is on the host). Same repo+tag, just a
|
||||
# different routing hostname — the registry stores one blob.
|
||||
pack_args = pack.call_args.args
|
||||
self.assertEqual(
|
||||
f"localhost:54321/claude-bottle:{digest}", pack_args[0],
|
||||
|
||||
Reference in New Issue
Block a user