Files
bot-bottle/tests/unit/test_smolmachines_local_registry.py
T
didericis-claude 47eb56bd10
test / unit (pull_request) Successful in 27s
test / integration (pull_request) Successful in 42s
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>
2026-05-27 14:52:40 -04:00

251 lines
9.8 KiB
Python

"""Unit: ephemeral local-registry helper (PRD 0023 chunk 4c).
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
import subprocess
import unittest
from unittest.mock import patch
from claude_bottle.backend.smolmachines import local_registry
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(
args=[], returncode=0, stdout=stdout, stderr=stderr,
)
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(), # 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_handle_with_network_and_endpoints(self):
with patch.object(
local_registry.subprocess, "run",
side_effect=_stock_run_sequence(),
) as run, patch.object(
local_registry.socket, "create_connection",
return_value=_FakeSocket(),
):
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.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_registry_run_publishes_random_port_across_interfaces(self):
with patch.object(
local_registry.subprocess, "run",
side_effect=_stock_run_sequence(),
) as run, patch.object(
local_registry.socket, "create_connection",
return_value=_FakeSocket(),
):
with local_registry.ephemeral_registry():
pass
# 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)
# 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_and_network_on_clean_exit(self):
with patch.object(
local_registry.subprocess, "run",
side_effect=_stock_run_sequence(),
) as run, patch.object(
local_registry.socket, "create_connection",
return_value=_FakeSocket(),
):
with local_registry.ephemeral_registry():
pass
# 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_on_exception_inside_with(self):
with patch.object(
local_registry.subprocess, "run",
side_effect=_stock_run_sequence(),
) as run, patch.object(
local_registry.socket, "create_connection",
return_value=_FakeSocket(),
):
with self.assertRaises(RuntimeError):
with local_registry.ephemeral_registry():
raise RuntimeError("inside with")
# 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(self):
with patch.object(local_registry, "_READY_TIMEOUT_S", 0.1), patch.object(
local_registry.subprocess, "run",
side_effect=_stock_run_sequence(),
) as run, patch.object(
local_registry.socket, "create_connection",
side_effect=OSError("conn refused"),
), patch.object(
local_registry, "die",
side_effect=SystemExit("die called"),
) as die:
with self.assertRaises(SystemExit):
with local_registry.ephemeral_registry():
self.fail("yield reached despite unreachable registry")
die.assert_called_once()
# 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_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"]:
# `--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")
return _ok()
with patch.object(
local_registry.subprocess, "run", side_effect=capture,
), patch.object(
local_registry.socket, "create_connection",
return_value=_FakeSocket(),
):
with local_registry.ephemeral_registry():
pass
with local_registry.ephemeral_registry():
pass
self.assertEqual(2, len(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:
"""Minimal context-manager stand-in for the socket
`create_connection` returns. The helper only uses `with` on it
and discards the value, so we don't need any real network."""
def __enter__(self):
return self
def __exit__(self, *exc):
return False
if __name__ == "__main__":
unittest.main()