fix(smolmachines): docker push fails on Docker Desktop — daemon-side route differs from host loopback #74
@@ -5,13 +5,36 @@ conversion path (PRD 0023 chunk 4c).
|
|||||||
can't read the local docker daemon's image cache, an OCI layout
|
can't read the local docker daemon's image cache, an OCI layout
|
||||||
directory, or a `docker save` tarball. To convert the agent's
|
directory, or a `docker save` tarball. To convert the agent's
|
||||||
Dockerfile-built image into a `.smolmachine` artifact we run a
|
Dockerfile-built image into a `.smolmachine` artifact we run a
|
||||||
short-lived `registry:2.8.3` container on `127.0.0.1:<random>`,
|
short-lived `registry:2.8.3` container, push the locally-tagged
|
||||||
push the locally-tagged image into it, and let smolvm pull from
|
image into it, and let smolvm pull from there. The registry
|
||||||
there. The registry container is torn down as soon as the pack
|
container is torn down as soon as the pack completes.
|
||||||
completes.
|
|
||||||
|
|
||||||
Loopback-only bind + the host's docker layer cache mean the round
|
Two routing hostnames, one registry container. On Docker Desktop
|
||||||
trip is fast (~5s) and there's no exposed surface on the LAN."""
|
(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::<port>` 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:
|
||||||
|
|
||||||
|
- `daemon_endpoint`: how the docker CLI/daemon dials the
|
||||||
|
registry (`host.docker.internal:<port>` on Docker Desktop,
|
||||||
|
`localhost:<port>` 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:<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
|
||||||
|
|
||||||
@@ -21,6 +44,7 @@ import subprocess
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Iterator
|
from typing import Iterator
|
||||||
|
|
||||||
from ...log import die
|
from ...log import die
|
||||||
@@ -40,10 +64,21 @@ REGISTRY_IMAGE = os.environ.get(
|
|||||||
_READY_TIMEOUT_S = 10.0
|
_READY_TIMEOUT_S = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RegistryEndpoints:
|
||||||
|
"""The two `<host>:<port>` strings to embed in image refs. They
|
||||||
|
point at the same registry container; only the routing
|
||||||
|
hostname differs."""
|
||||||
|
|
||||||
|
daemon_endpoint: str
|
||||||
|
host_endpoint: str
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def ephemeral_registry() -> Iterator[int]:
|
def ephemeral_registry() -> Iterator[RegistryEndpoints]:
|
||||||
"""Bring up a `registry:2.8.3` container on a random loopback
|
"""Bring up a `registry:2.8.3` container on a random host port,
|
||||||
port, yield the port, force-remove the container on exit.
|
yield the daemon-side + host-side endpoints, force-remove the
|
||||||
|
container 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
|
||||||
@@ -53,10 +88,14 @@ def ephemeral_registry() -> Iterator[int]:
|
|||||||
[
|
[
|
||||||
"docker", "run", "-d", "--rm",
|
"docker", "run", "-d", "--rm",
|
||||||
"--name", name,
|
"--name", name,
|
||||||
# `127.0.0.1::5000` = bind to loopback, pick a random host
|
# `-p :5000` (no IP prefix) binds the container's port
|
||||||
# port. No LAN exposure; the container hangs around just
|
# 5000 on a random host port across all interfaces. The
|
||||||
# long enough for one push + one pack-create.
|
# registry container itself listens on 0.0.0.0:5000
|
||||||
"-p", "127.0.0.1::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,
|
REGISTRY_IMAGE,
|
||||||
],
|
],
|
||||||
check=True,
|
check=True,
|
||||||
@@ -65,7 +104,11 @@ def ephemeral_registry() -> Iterator[int]:
|
|||||||
try:
|
try:
|
||||||
port = _host_port(name)
|
port = _host_port(name)
|
||||||
_wait_ready(port)
|
_wait_ready(port)
|
||||||
yield port
|
daemon_host = _daemon_side_hostname()
|
||||||
|
yield RegistryEndpoints(
|
||||||
|
daemon_endpoint=f"{daemon_host}:{port}",
|
||||||
|
host_endpoint=f"localhost:{port}",
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", name],
|
["docker", "rm", "-f", name],
|
||||||
@@ -74,11 +117,34 @@ def ephemeral_registry() -> Iterator[int]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
`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."""
|
||||||
|
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:
|
def _host_port(name: str) -> int:
|
||||||
"""Resolve the host-side port docker mapped to the registry's
|
"""Resolve the host-side port docker mapped to the registry's
|
||||||
container port 5000. `docker port <name> 5000/tcp` returns one or
|
container port 5000. `docker port <name> 5000/tcp` returns one
|
||||||
more `host:port` lines; the loopback-only -p binding ensures we
|
or more `host:port` lines (one per address family) — we take
|
||||||
get exactly `127.0.0.1:<port>`."""
|
the first IPv4 line."""
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["docker", "port", name, "5000/tcp"],
|
["docker", "port", name, "5000/tcp"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -90,8 +156,8 @@ def _host_port(name: str) -> int:
|
|||||||
f"docker port {name} 5000/tcp failed: "
|
f"docker port {name} 5000/tcp failed: "
|
||||||
f"{(r.stderr or '').strip() or '<no stderr>'}"
|
f"{(r.stderr or '').strip() or '<no stderr>'}"
|
||||||
)
|
)
|
||||||
# `127.0.0.1:54321\n` — split on the last colon to handle the
|
# `0.0.0.0:54321\n[::]:54321\n` — take the first line, split
|
||||||
# `host:port` shape without parsing IP literals.
|
# on the last colon to 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:
|
||||||
@@ -107,7 +173,10 @@ def _wait_ready(port: int) -> None:
|
|||||||
|
|
||||||
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."""
|
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."""
|
||||||
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:
|
||||||
|
|||||||
@@ -184,14 +184,20 @@ def _ensure_smolmachine(image_ref: str) -> Path:
|
|||||||
a launcher binary at `.smolmachine` plus the sidecar alongside
|
a launcher binary at `.smolmachine` plus the sidecar alongside
|
||||||
it; the sidecar is the actual artifact).
|
it; the sidecar is the actual artifact).
|
||||||
|
|
||||||
Conversion path: `docker build` (the existing layer cache makes
|
Conversion path: `docker build` (the existing layer cache
|
||||||
no-change rebuilds cheap) → `docker tag` with a
|
makes no-change rebuilds cheap) → `docker tag` + `docker push`
|
||||||
`localhost:<port>/...` ref → bring up the ephemeral registry
|
using the daemon-side endpoint (`host.docker.internal:<port>`
|
||||||
container → `docker push` into it → `smolvm pack create --image
|
on Docker Desktop, `localhost:<port>` on native Linux) →
|
||||||
<localhost ref>` → tear down the registry. Each pack-create
|
`smolvm pack create --image <host endpoint>` using the
|
||||||
costs several seconds even on a hot cache, so we skip the whole
|
host-side endpoint (always `localhost:<port>` — smolvm is a
|
||||||
pipeline when the cached sidecar is already on disk for this
|
host process) → tear down the registry. The two endpoints
|
||||||
image ID."""
|
route to the same registry container; only the hostname
|
||||||
|
differs because the docker daemon (on Docker Desktop) doesn't
|
||||||
|
share the host's loopback.
|
||||||
|
|
||||||
|
Each pack-create costs several seconds even on a hot cache,
|
||||||
|
so we skip the whole pipeline when the cached sidecar is
|
||||||
|
already on disk for this image ID."""
|
||||||
_SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
_SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
docker_mod.build_image(image_ref, _REPO_DIR)
|
docker_mod.build_image(image_ref, _REPO_DIR)
|
||||||
# `sha256:abcd...` -> `abcd...` first 16 chars: short enough to
|
# `sha256:abcd...` -> `abcd...` first 16 chars: short enough to
|
||||||
@@ -202,9 +208,10 @@ 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 port:
|
with ephemeral_registry() as endpoints:
|
||||||
local_ref = f"localhost:{port}/claude-bottle:{digest}"
|
push_ref = f"{endpoints.daemon_endpoint}/claude-bottle:{digest}"
|
||||||
docker_mod.tag(image_ref, local_ref)
|
pack_ref = f"{endpoints.host_endpoint}/claude-bottle:{digest}"
|
||||||
docker_mod.push(local_ref)
|
docker_mod.tag(image_ref, push_ref)
|
||||||
_smolvm.pack_create(local_ref, binary)
|
docker_mod.push(push_ref)
|
||||||
|
_smolvm.pack_create(pack_ref, binary)
|
||||||
return sidecar
|
return sidecar
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"""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 random
|
||||||
loopback port, yields the port, and tears the container down on
|
host port, yields a `(daemon_endpoint, host_endpoint)` pair, and
|
||||||
exit. Tests mock `subprocess.run` + `socket.create_connection` so
|
tears the container down on exit. Tests mock `subprocess.run` +
|
||||||
they run without docker."""
|
`socket.create_connection` so they run without docker."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import call, patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from claude_bottle.backend.smolmachines import local_registry
|
from claude_bottle.backend.smolmachines import local_registry
|
||||||
|
|
||||||
@@ -20,38 +20,93 @@ 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",
|
||||||
|
):
|
||||||
|
return [
|
||||||
|
_ok(stdout="<container-id>\n"), # docker run
|
||||||
|
_ok(stdout=port), # docker port
|
||||||
|
_ok(stdout=operating_system), # docker info
|
||||||
|
_ok(), # docker rm -f
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TestEphemeralRegistry(unittest.TestCase):
|
class TestEphemeralRegistry(unittest.TestCase):
|
||||||
def test_yields_host_port_parsed_from_docker_port(self):
|
def test_yields_endpoints_with_docker_desktop_routing(self):
|
||||||
# docker run + docker port + docker rm in that order; the
|
# On Docker Desktop the daemon runs in its own VM, so the
|
||||||
# port command returns `127.0.0.1:54321` for the loopback
|
# registry has to be addressed by host.docker.internal for
|
||||||
# binding.
|
# 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=[
|
side_effect=_stock_run_sequence(operating_system="Docker Desktop\n"),
|
||||||
_ok(stdout="<container-id>\n"),
|
), patch.object(
|
||||||
_ok(stdout="127.0.0.1:54321\n"),
|
local_registry.socket, "create_connection",
|
||||||
_ok(),
|
return_value=_FakeSocket(),
|
||||||
],
|
):
|
||||||
|
with local_registry.ephemeral_registry() as endpoints:
|
||||||
|
self.assertEqual(
|
||||||
|
"host.docker.internal:54321", endpoints.daemon_endpoint,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"localhost:54321", endpoints.host_endpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
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.
|
||||||
|
with patch.object(
|
||||||
|
local_registry.subprocess, "run",
|
||||||
|
side_effect=_stock_run_sequence(),
|
||||||
) as run, 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 port:
|
with local_registry.ephemeral_registry():
|
||||||
self.assertEqual(54321, port)
|
pass
|
||||||
|
|
||||||
# docker run, docker port, docker rm -f
|
|
||||||
self.assertEqual(3, run.call_count)
|
|
||||||
run_argv = run.call_args_list[0].args[0]
|
run_argv = run.call_args_list[0].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)
|
||||||
# Loopback-only port binding so the registry isn't exposed
|
self.assertIn("5000", run_argv)
|
||||||
# on the LAN even briefly.
|
# Explicitly NOT the loopback-only form — that one's broken
|
||||||
self.assertIn("127.0.0.1::5000", run_argv)
|
# under Docker Desktop.
|
||||||
|
self.assertNotIn("127.0.0.1::5000", run_argv)
|
||||||
|
|
||||||
def test_force_removes_container_on_clean_exit(self):
|
def test_force_removes_container_on_clean_exit(self):
|
||||||
with patch.object(
|
with patch.object(
|
||||||
local_registry.subprocess, "run",
|
local_registry.subprocess, "run",
|
||||||
side_effect=[_ok(stdout="cid\n"), _ok(stdout="127.0.0.1:1234\n"), _ok()],
|
side_effect=_stock_run_sequence(),
|
||||||
) as run, patch.object(
|
) as run, patch.object(
|
||||||
local_registry.socket, "create_connection",
|
local_registry.socket, "create_connection",
|
||||||
return_value=_FakeSocket(),
|
return_value=_FakeSocket(),
|
||||||
@@ -66,7 +121,7 @@ class TestEphemeralRegistry(unittest.TestCase):
|
|||||||
def test_force_removes_container_on_exception_inside_with(self):
|
def test_force_removes_container_on_exception_inside_with(self):
|
||||||
with patch.object(
|
with patch.object(
|
||||||
local_registry.subprocess, "run",
|
local_registry.subprocess, "run",
|
||||||
side_effect=[_ok(stdout="cid\n"), _ok(stdout="127.0.0.1:1234\n"), _ok()],
|
side_effect=_stock_run_sequence(),
|
||||||
) as run, patch.object(
|
) as run, patch.object(
|
||||||
local_registry.socket, "create_connection",
|
local_registry.socket, "create_connection",
|
||||||
return_value=_FakeSocket(),
|
return_value=_FakeSocket(),
|
||||||
@@ -83,7 +138,7 @@ class TestEphemeralRegistry(unittest.TestCase):
|
|||||||
# Drop the timeout to a value that fits the test budget.
|
# 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=[_ok(stdout="cid\n"), _ok(stdout="127.0.0.1:1234\n"), _ok()],
|
side_effect=_stock_run_sequence(),
|
||||||
) as run, patch.object(
|
) as run, patch.object(
|
||||||
local_registry.socket, "create_connection",
|
local_registry.socket, "create_connection",
|
||||||
side_effect=OSError("conn refused"),
|
side_effect=OSError("conn refused"),
|
||||||
@@ -105,8 +160,12 @@ class TestEphemeralRegistry(unittest.TestCase):
|
|||||||
def capture(argv, *a, **kw):
|
def capture(argv, *a, **kw):
|
||||||
if argv[:2] == ["docker", "run"]:
|
if argv[:2] == ["docker", "run"]:
|
||||||
names.append(argv[argv.index("--name") + 1])
|
names.append(argv[argv.index("--name") + 1])
|
||||||
return _ok(stdout="cid\n" if argv[:2] == ["docker", "run"]
|
return _ok(stdout="cid\n")
|
||||||
else "127.0.0.1:1\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(
|
with patch.object(
|
||||||
local_registry.subprocess, "run", side_effect=capture,
|
local_registry.subprocess, "run", side_effect=capture,
|
||||||
|
|||||||
@@ -62,10 +62,19 @@ class TestEnsureSmolmachine(unittest.TestCase):
|
|||||||
def test_cache_miss_runs_build_tag_push_pack_in_order(self):
|
def test_cache_miss_runs_build_tag_push_pack_in_order(self):
|
||||||
digest = "0123456789abcdef"
|
digest = "0123456789abcdef"
|
||||||
|
|
||||||
# ephemeral_registry is a context manager yielding the port.
|
# ephemeral_registry yields a RegistryEndpoints with two
|
||||||
|
# routing hostnames — daemon-side for docker push,
|
||||||
|
# host-side for smolvm pack create.
|
||||||
|
from claude_bottle.backend.smolmachines.local_registry import (
|
||||||
|
RegistryEndpoints,
|
||||||
|
)
|
||||||
|
|
||||||
class _Reg:
|
class _Reg:
|
||||||
def __enter__(self_inner):
|
def __enter__(self_inner):
|
||||||
return 54321
|
return RegistryEndpoints(
|
||||||
|
daemon_endpoint="host.docker.internal:54321",
|
||||||
|
host_endpoint="localhost:54321",
|
||||||
|
)
|
||||||
def __exit__(self_inner, *exc):
|
def __exit__(self_inner, *exc):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -98,22 +107,29 @@ class TestEnsureSmolmachine(unittest.TestCase):
|
|||||||
_prepare._ensure_smolmachine("claude-bottle:latest")
|
_prepare._ensure_smolmachine("claude-bottle:latest")
|
||||||
|
|
||||||
# build first (no point pushing if the build fails), then
|
# build first (no point pushing if the build fails), then
|
||||||
# tag → push → pack against the registry port.
|
# tag → push → pack against the registry endpoints.
|
||||||
self.assertEqual(["build", "tag", "push", "pack"], calls)
|
self.assertEqual(["build", "tag", "push", "pack"], calls)
|
||||||
|
|
||||||
# tag goes from the source ref to a localhost:<port> ref
|
# tag + push target the daemon-side endpoint (host.docker
|
||||||
# with the digest as the tag suffix (so different builds
|
# .internal on Docker Desktop, since the daemon's
|
||||||
# land on different tags in the registry).
|
# localhost is its own VM's loopback).
|
||||||
tag_args = tag.call_args.args
|
tag_args = tag.call_args.args
|
||||||
self.assertEqual("claude-bottle:latest", tag_args[0])
|
self.assertEqual("claude-bottle:latest", tag_args[0])
|
||||||
self.assertEqual(f"localhost:54321/claude-bottle:{digest}", tag_args[1])
|
self.assertEqual(
|
||||||
# push targets the same localhost ref tag picks.
|
f"host.docker.internal:54321/claude-bottle:{digest}", tag_args[1],
|
||||||
|
)
|
||||||
push_args = push.call_args.args
|
push_args = push.call_args.args
|
||||||
self.assertEqual(f"localhost:54321/claude-bottle:{digest}", push_args[0])
|
self.assertEqual(
|
||||||
# pack_create reads from the registry ref, writes the
|
f"host.docker.internal:54321/claude-bottle:{digest}", push_args[0],
|
||||||
# binary alongside the cached sidecar.
|
)
|
||||||
|
# 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_args = pack.call_args.args
|
pack_args = pack.call_args.args
|
||||||
self.assertEqual(f"localhost:54321/claude-bottle:{digest}", pack_args[0])
|
self.assertEqual(
|
||||||
|
f"localhost:54321/claude-bottle:{digest}", pack_args[0],
|
||||||
|
)
|
||||||
self.assertTrue(str(pack_args[1]).endswith(f"{digest}.smolmachine"))
|
self.assertTrue(str(pack_args[1]).endswith(f"{digest}.smolmachine"))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user