diff --git a/claude_bottle/backend/docker/util.py b/claude_bottle/backend/docker/util.py index 6599da5..0854f5b 100644 --- a/claude_bottle/backend/docker/util.py +++ b/claude_bottle/backend/docker/util.py @@ -166,18 +166,15 @@ def image_id(ref: str) -> str: 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:/... 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 save(ref: str, output: str) -> None: + """`docker save REF -o OUTPUT`. Writes a tarball of the image + layers + manifest to the host path. Used by smolmachines + prepare to hand the agent image to a containerized crane that + 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 + non-loopback hosts).""" + subprocess.run(["docker", "save", ref, "-o", output], check=True) def _silent_run(cmd: Iterable[str]) -> int: diff --git a/claude_bottle/backend/smolmachines/local_registry.py b/claude_bottle/backend/smolmachines/local_registry.py index 3af46f7..8977f37 100644 --- a/claude_bottle/backend/smolmachines/local_registry.py +++ b/claude_bottle/backend/smolmachines/local_registry.py @@ -1,40 +1,37 @@ """Ephemeral local OCI registry for the smolmachines agent-image conversion path (PRD 0023 chunk 4c). -`smolvm pack create --image ` 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, 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. +`smolvm pack create --image ` only accepts OCI 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 +spin up a short-lived `registry:2.8.3` container alongside a +`crane` helper container on a private docker network, push via +`crane push --insecure :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 -(macOS/Windows) the docker daemon runs inside its own Linux VM, -so its `localhost` is *not* the host's loopback — a registry -bound to `127.0.0.1::` on the host is unreachable from the -daemon side, and `docker push` fails with `context deadline -exceeded`. The fix: bind to all interfaces so both routes work, -and yield two refs: +Why this two-container dance instead of plain `docker push`: +- Docker Desktop's daemon runs in its own Linux VM, so its + `localhost` is not the host's loopback. A registry bound to + the host's 127.0.0.1 is unreachable from the daemon side. +- `host.docker.internal` is reachable from the daemon but isn't + in Docker's default insecure-registries CIDRs (only `::1/128` + 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 - registry (`host.docker.internal:` on Docker Desktop, - `localhost:` on a native Linux daemon that shares the - host's network namespace). - - `host_endpoint`: how `smolvm pack create` (a host process) - dials the registry. Always `localhost:` — 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:/cb:abc` -and a pull of `localhost:/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.""" +The registry is also published on a random host port so smolvm +— a host process — can pull from `localhost:` via Docker's +port-forward. smolvm's bundled crane auto-falls-back to HTTP for +localhost addresses, so no insecure-registries config is needed +on that side either.""" 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 -# giving up. Two seconds is empirically enough; bumping to 10s leaves -# headroom for slow CI runners without making the failure mode chatty. +# giving up. Two seconds is empirically enough; 10s leaves headroom +# for slow CI runners without making the failure mode chatty. _READY_TIMEOUT_S = 10.0 @dataclass(frozen=True) -class RegistryEndpoints: - """The two `:` strings to embed in image refs. They - point at the same registry container; only the routing - hostname differs.""" +class RegistryHandle: + """Everything callers need to push to + pull from the ephemeral + registry. - daemon_endpoint: str - host_endpoint: str + `network` is the per-session docker network — a `crane push` + container has to join it to reach the registry by name. + `push_endpoint` is the `:` form to embed in image + refs given to the crane push container (resolves via docker + network DNS). `pull_endpoint` is the `:` form a + host process (smolvm) uses; the registry's host port mapping + backs this.""" + + network: str + push_endpoint: str + pull_endpoint: str @contextmanager -def ephemeral_registry() -> Iterator[RegistryEndpoints]: - """Bring up a `registry:2.8.3` container on a random host port, - yield the daemon-side + host-side endpoints, force-remove the - container on exit. +def ephemeral_registry() -> Iterator[RegistryHandle]: + """Bring up a per-session docker network + a `registry:2.8.3` + container on it (published on a random host port), yield a + `RegistryHandle`, force-remove both 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]}" + session_id = uuid.uuid4().hex[:12] + network = f"claude-bottle-registry-net-{session_id}" + registry_name = f"claude-bottle-registry-{session_id}" + subprocess.run( - [ - "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, - ], + ["docker", "network", "create", network], check=True, capture_output=True, ) try: - port = _host_port(name) - _wait_ready(port) - daemon_host = _daemon_side_hostname() - yield RegistryEndpoints( - daemon_endpoint=f"{daemon_host}:{port}", - host_endpoint=f"localhost:{port}", + subprocess.run( + [ + "docker", "run", "-d", "--rm", + "--name", registry_name, + "--network", network, + # `-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:` 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: subprocess.run( - ["docker", "rm", "-f", name], + ["docker", "network", "rm", network], check=False, capture_output=True, ) -def _daemon_side_hostname() -> str: - """Pick the hostname the docker daemon should use to dial the - registry. On Docker Desktop the daemon runs in its own Linux - VM and only sees the host via `host.docker.internal`; on - native Linux the daemon shares the host's network namespace - and `localhost` works. +def crane_push_tarball(handle: RegistryHandle, tarball_path: str, ref: str) -> None: + """Run `crane push --insecure ` inside a one-shot + container on the registry's docker network. `ref` should + reference the registry by `handle.push_endpoint` so the crane + container resolves it via docker network DNS. - `docker info --format '{{.OperatingSystem}}'` returns - `"Docker Desktop"` on macOS / Windows Desktop installs (and on - Linux Desktop, which also uses a VM). Anything else (e.g. - `"Debian GNU/Linux 12 (bookworm)"`) is a native daemon.""" + Doesn't go through `docker push` to avoid the Docker-Desktop + daemon's HTTPS preference for non-loopback hostnames — crane's + `--insecure` flag forces plain HTTP, which is what the + registry container speaks.""" r = subprocess.run( - ["docker", "info", "--format", "{{.OperatingSystem}}"], - capture_output=True, - text=True, - check=False, - ) - operating_system = (r.stdout or "").strip() - 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 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"], + [ + "docker", "run", "--rm", + "--network", handle.network, + "-v", f"{tarball_path}:/img.tar:ro", + CRANE_IMAGE, + "push", "--insecure", "/img.tar", ref, + ], capture_output=True, text=True, check=False, ) if r.returncode != 0: 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 ''}" + ) + + +def _host_port(name: str) -> int: + """Resolve the host-side port docker mapped to the registry's + container port. `docker port 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 ''}" ) - # `0.0.0.0:54321\n[::]:54321\n` — take the first line, split - # on the last colon to handle either IPv4 or IPv6 host syntax. + # `0.0.0.0:54321\n[::]:54321\n` — split on the last colon to + # handle either IPv4 or IPv6 host syntax. line = (r.stdout or "").splitlines()[0].strip() _, _, port_str = line.rpartition(":") try: @@ -168,15 +209,15 @@ def _host_port(name: str) -> int: def _wait_ready(port: int) -> None: - """Block until the registry's HTTP layer accepts a TCP connection - on `127.0.0.1:`, or `_READY_TIMEOUT_S` elapses. + """Block until the registry's HTTP layer accepts a TCP + connection on `127.0.0.1:`, 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. We probe loopback - specifically (not host.docker.internal) because this helper - runs on the host, and 0.0.0.0-bound ports are reachable via - 127.0.0.1 too.""" + specifically (not via the docker network) because this helper + runs on the host.""" deadline = time.monotonic() + _READY_TIMEOUT_S last_err: Exception | None = None while time.monotonic() < deadline: diff --git a/claude_bottle/backend/smolmachines/prepare.py b/claude_bottle/backend/smolmachines/prepare.py index dfeeef5..da70685 100644 --- a/claude_bottle/backend/smolmachines/prepare.py +++ b/claude_bottle/backend/smolmachines/prepare.py @@ -34,7 +34,7 @@ from ...pipelock import PipelockProxy from ...supervise import Supervise from . import smolvm as _smolvm from .bottle_plan import SmolmachinesBottlePlan -from .local_registry import ephemeral_registry +from .local_registry import crane_push_tarball, ephemeral_registry 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). Conversion path: `docker build` (the existing layer cache - makes no-change rebuilds cheap) → `docker tag` + `docker push` - using the daemon-side endpoint (`host.docker.internal:` - on Docker Desktop, `localhost:` on native Linux) → - `smolvm pack create --image ` using the - host-side endpoint (always `localhost:` — smolvm is a - host process) → tear down the registry. The two endpoints - route to the same registry container; only the hostname - differs because the docker daemon (on Docker Desktop) doesn't - share the host's loopback. + makes no-change rebuilds cheap) → `docker save` to a tarball + → spin up an ephemeral registry on a private docker network → + `crane push --insecure` from a one-shot container on the same + network → `smolvm pack create --image localhost:/...` + → tear down the registry + network. The crane push detour + sidesteps the Docker-Desktop daemon's HTTPS preference for + non-loopback registries — see the `local_registry` module + docstring for the gory details. Each pack-create costs several seconds even on a hot cache, 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" if sidecar.is_file(): return sidecar - with ephemeral_registry() as endpoints: - push_ref = f"{endpoints.daemon_endpoint}/claude-bottle:{digest}" - pack_ref = f"{endpoints.host_endpoint}/claude-bottle:{digest}" - docker_mod.tag(image_ref, push_ref) - docker_mod.push(push_ref) - _smolvm.pack_create(pack_ref, binary) + tarball = _SMOLMACHINE_CACHE_DIR / f"{digest}.image.tar" + docker_mod.save(image_ref, str(tarball)) + try: + with ephemeral_registry() as handle: + push_ref = f"{handle.push_endpoint}/claude-bottle:{digest}" + 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 diff --git a/tests/unit/test_docker_util_image.py b/tests/unit/test_docker_util_image.py index 6a6bc7a..4afe043 100644 --- a/tests/unit/test_docker_util_image.py +++ b/tests/unit/test_docker_util_image.py @@ -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() diff --git a/tests/unit/test_smolmachines_local_registry.py b/tests/unit/test_smolmachines_local_registry.py index babf5ee..36d534a 100644 --- a/tests/unit/test_smolmachines_local_registry.py +++ b/tests/unit/test_smolmachines_local_registry.py @@ -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="\n"), # docker run - _ok(stdout=port), # docker port - _ok(stdout=operating_system), # docker info - _ok(), # docker rm -f + _ok(), # docker network create + _ok(stdout="\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 `. - last_argv = run.call_args_list[-1].args[0] - self.assertEqual(["docker", "rm", "-f"], last_argv[:3]) + # Last two calls are `docker rm -f ` then + # `docker network rm `. + 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 ` and `--network ` + # 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: diff --git a/tests/unit/test_smolmachines_prepare_image.py b/tests/unit/test_smolmachines_prepare_image.py index afc0804..f1fc64b 100644 --- a/tests/unit/test_smolmachines_prepare_image.py +++ b/tests/unit/test_smolmachines_prepare_image.py @@ -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],