Files
bot-bottle/tests/unit/test_smolmachines_local_registry.py
T
didericis-claude 1fa17d1822
test / unit (pull_request) Successful in 21s
test / unit (push) Successful in 21s
test / integration (push) Successful in 42s
test / integration (pull_request) Successful in 41s
feat(smolmachines): build agent image from repo Dockerfile (PRD 0023 chunk 4c)
Replaces the alpine:latest placeholder with a real claude-bottle
agent image, converted into a .smolmachine artifact via an
ephemeral local OCI registry.

Why the registry hop: smolvm pack create only accepts OCI registry
refs. Empirically it rejects docker-daemon://, oci-layout://,
docker-archive: tarballs, and every other transport tested — the
crane backend treats anything with a scheme prefix as a registry
hostname. To convert a locally-built docker image into a
.smolmachine we have to push it somewhere smolvm can pull from.
Smallest path: bring up registry:2.8.3 bound to 127.0.0.1:<random>,
docker tag + docker push into it, smolvm pack create --image
localhost:<port>/claude-bottle:<id>, tear down the registry.

The .smolmachine is cached under
~/.cache/claude-bottle/smolmachines/ keyed by the docker image ID
(first 16 hex chars of the sha256), so a Dockerfile change picks
up a new image ID and invalidates the cache. Unchanged rebuilds
skip the whole build → registry → pack pipeline.

This puts `docker build` in smolmachines prepare (the docker
backend defers it to launch). Necessary because pack_create needs
the image ID to derive the cache key, and prepare is the only
hook ahead of launch that runs once per slug.

Adds:
- claude_bottle/backend/docker/util.py: image_id / tag / push
  helpers (thin docker CLI wrappers).
- claude_bottle/backend/smolmachines/local_registry.py:
  ephemeral_registry() context manager; pins registry:2.8.3 by
  digest, binds 127.0.0.1::5000 (loopback-only), force-removes on
  exit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:51:02 -04:00

142 lines
5.2 KiB
Python

"""Unit: ephemeral local-registry helper (PRD 0023 chunk 4c).
The helper brings up a `registry:2.8.3` container on a random
loopback port, yields the port, and tears the container 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 call, 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,
)
class TestEphemeralRegistry(unittest.TestCase):
def test_yields_host_port_parsed_from_docker_port(self):
# docker run + docker port + docker rm in that order; the
# port command returns `127.0.0.1:54321` for the loopback
# binding.
with patch.object(
local_registry.subprocess, "run",
side_effect=[
_ok(stdout="<container-id>\n"),
_ok(stdout="127.0.0.1:54321\n"),
_ok(),
],
) as run, patch.object(
local_registry.socket, "create_connection",
return_value=_FakeSocket(),
):
with local_registry.ephemeral_registry() as port:
self.assertEqual(54321, port)
# docker run, docker port, docker rm -f
self.assertEqual(3, run.call_count)
run_argv = run.call_args_list[0].args[0]
self.assertEqual(["docker", "run"], run_argv[:2])
self.assertIn("--rm", run_argv)
# Loopback-only port binding so the registry isn't exposed
# on the LAN even briefly.
self.assertIn("127.0.0.1::5000", run_argv)
def test_force_removes_container_on_clean_exit(self):
with patch.object(
local_registry.subprocess, "run",
side_effect=[_ok(stdout="cid\n"), _ok(stdout="127.0.0.1:1234\n"), _ok()],
) as run, patch.object(
local_registry.socket, "create_connection",
return_value=_FakeSocket(),
):
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])
def test_force_removes_container_on_exception_inside_with(self):
with patch.object(
local_registry.subprocess, "run",
side_effect=[_ok(stdout="cid\n"), _ok(stdout="127.0.0.1:1234\n"), _ok()],
) 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")
# rm -f still ran on exception.
last_argv = run.call_args_list[-1].args[0]
self.assertEqual(["docker", "rm", "-f"], last_argv[:3])
def test_wait_ready_times_out_when_socket_never_connects(self):
# Drop the timeout to a value that fits the test budget.
with patch.object(local_registry, "_READY_TIMEOUT_S", 0.1), patch.object(
local_registry.subprocess, "run",
side_effect=[_ok(stdout="cid\n"), _ok(stdout="127.0.0.1:1234\n"), _ok()],
) 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()
# 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])
def test_unique_container_name_per_call(self):
names: list[str] = []
def capture(argv, *a, **kw):
if argv[:2] == ["docker", "run"]:
names.append(argv[argv.index("--name") + 1])
return _ok(stdout="cid\n" if argv[:2] == ["docker", "run"]
else "127.0.0.1:1\n")
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(names))
self.assertNotEqual(names[0], names[1])
for n in names:
self.assertTrue(n.startswith("claude-bottle-registry-"))
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()