20f411b22e
Ships the smolmachines backend's prepare side: subpackage layout,
`_BACKENDS` registration under "smolmachines", preflight check
for `smolvm` + `gvproxy` on PATH, and the two config-file
renderers (Smolfile TOML + gvproxy YAML). Launch raises
NotImplementedError until chunk 2.
New module layout (mirrors backend/docker/):
claude_bottle/backend/smolmachines/
__init__.py re-exports SmolmachinesBottleBackend
backend.py SmolmachinesBottleBackend façade
bottle.py SmolmachinesBottle stub (NotImpl until ch2)
bottle_plan.py SmolmachinesBottlePlan + .print()
bottle_cleanup_plan.py SmolmachinesBottleCleanupPlan stub
prepare.py resolve_plan: writes both config files
smolfile.py TOML renderer (stdlib, no tomli_w dep)
gvproxy_config.py YAML renderer (same shape as pipelock_yaml)
util.py preflight + per-slug subnet + loopback port
The renderers are pure functions. `resolve_plan` runs the
preflight, allocates one host-side loopback port per active
sidecar (pipelock always; git-gate / supervise conditional),
derives a per-slug gvproxy subnet (hash-mod-254, skipping the
docker-default 17), and writes:
- <stage>/gvproxy.yaml: subnet + DNS rule resolving only
`proxy.internal` + port_forwards (one per active sidecar).
- <stage>/smolfile.toml: guest command/env + virtio-net device
backed by gvproxy's unixgram socket. No TSI flags — see
PRD 0023 "Why gvproxy, not TSI".
The agent's HTTPS_PROXY etc. point at `proxy.internal:<gateway-
port>` so the guest dials through gvproxy. gvproxy resolves only
`proxy.internal` → the gateway IP, and forwards exactly the
listed ports to the host-side sidecar bundle (PRD 0024); every
other destination — host LAN, host loopback, public internet
directly — is unreachable by construction.
29 new unit tests covering renderer correctness, subnet
derivation stability + collision-avoidance, loopback port
allocation, and preflight error paths. Full unit suite: 532
passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
130 lines
5.0 KiB
Python
130 lines
5.0 KiB
Python
"""Unit: smolmachines backend util helpers (PRD 0023 chunk 1)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import socket
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from claude_bottle.backend.smolmachines.util import (
|
|
allocate_loopback_port,
|
|
smolmachines_gvproxy_subnet,
|
|
smolmachines_preflight,
|
|
)
|
|
|
|
|
|
class TestGvproxySubnet(unittest.TestCase):
|
|
def test_returns_192_168_X_format(self):
|
|
subnet, gateway = smolmachines_gvproxy_subnet("demo-abc12")
|
|
self.assertTrue(subnet.startswith("192.168."))
|
|
self.assertTrue(subnet.endswith(".0/24"))
|
|
self.assertTrue(gateway.startswith("192.168."))
|
|
self.assertTrue(gateway.endswith(".1"))
|
|
# The subnet and gateway share the same third octet.
|
|
sub_octet = subnet.split(".")[2]
|
|
gw_octet = gateway.split(".")[2]
|
|
self.assertEqual(sub_octet, gw_octet)
|
|
|
|
def test_stable_for_same_slug(self):
|
|
# Recoverability: `resume` reuses the slug + expects the
|
|
# same subnet so a re-attach doesn't try to grab a fresh
|
|
# network range from gvproxy.
|
|
a = smolmachines_gvproxy_subnet("demo-abc12")
|
|
b = smolmachines_gvproxy_subnet("demo-abc12")
|
|
self.assertEqual(a, b)
|
|
|
|
def test_different_slugs_likely_differ(self):
|
|
# Not a guarantee (it's hash-mod-254 so collisions exist),
|
|
# but two arbitrary slugs shouldn't share a subnet in the
|
|
# typical case.
|
|
seen = {
|
|
smolmachines_gvproxy_subnet(s)
|
|
for s in ("a", "b", "c", "d", "e", "alpha", "beta", "gamma")
|
|
}
|
|
self.assertGreater(len(seen), 1)
|
|
|
|
def test_never_collides_with_docker_default_bridge(self):
|
|
# docker's default bridge sits at 172.17.x.x but operators
|
|
# commonly also see 192.168.17.x from VPN clients on macOS.
|
|
# The util explicitly skips octet 17 → 18 so the smolmachines
|
|
# subnet doesn't collide with that historical pain point.
|
|
for slug in (f"slug-{i}" for i in range(500)):
|
|
subnet, gateway = smolmachines_gvproxy_subnet(slug)
|
|
self.assertNotEqual("192.168.17.0/24", subnet,
|
|
f"slug {slug!r} landed on the skipped octet")
|
|
|
|
|
|
class TestAllocateLoopbackPort(unittest.TestCase):
|
|
def test_returns_in_ephemeral_range(self):
|
|
port = allocate_loopback_port()
|
|
# Linux ephemeral starts at 32768; macOS at 49152. Either
|
|
# way it's >1024, which is what matters.
|
|
self.assertGreater(port, 1024)
|
|
self.assertLess(port, 65536)
|
|
|
|
def test_port_is_free_at_return(self):
|
|
# The dance is bind-with-port-0 + getsockname + close. By
|
|
# the time we return, the kernel has the port back in the
|
|
# free pool. We confirm by binding it ourselves immediately
|
|
# (we'll race with anyone else who races for it; the
|
|
# race-window caveat lives in the docstring).
|
|
port = allocate_loopback_port()
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
try:
|
|
s.bind(("127.0.0.1", port))
|
|
finally:
|
|
s.close()
|
|
|
|
def test_multiple_calls_return_distinct_ports(self):
|
|
# The kernel rotates ephemeral ports; consecutive calls
|
|
# almost certainly land different ports.
|
|
ports = {allocate_loopback_port() for _ in range(8)}
|
|
self.assertGreater(len(ports), 1)
|
|
|
|
|
|
class TestPreflight(unittest.TestCase):
|
|
def test_both_binaries_present_returns_none(self):
|
|
with patch(
|
|
"claude_bottle.backend.smolmachines.util.shutil.which",
|
|
side_effect=lambda name: f"/usr/local/bin/{name}",
|
|
):
|
|
self.assertIsNone(smolmachines_preflight())
|
|
|
|
def test_missing_smolvm_dies_with_pointer(self):
|
|
with patch(
|
|
"claude_bottle.backend.smolmachines.util.shutil.which",
|
|
side_effect=lambda name: None if name == "smolvm" else f"/x/{name}",
|
|
):
|
|
with self.assertRaises(SystemExit) as cm:
|
|
smolmachines_preflight()
|
|
self.assertNotEqual(0, cm.exception.code)
|
|
|
|
def test_missing_gvproxy_dies_with_pointer(self):
|
|
with patch(
|
|
"claude_bottle.backend.smolmachines.util.shutil.which",
|
|
side_effect=lambda name: None if name == "gvproxy" else f"/x/{name}",
|
|
):
|
|
with self.assertRaises(SystemExit):
|
|
smolmachines_preflight()
|
|
|
|
def test_missing_both_lists_both_in_message(self):
|
|
# When both are gone, the message names both binaries and
|
|
# gives both install pointers — operator shouldn't have to
|
|
# re-run to discover the second missing dep.
|
|
import io, sys
|
|
with patch(
|
|
"claude_bottle.backend.smolmachines.util.shutil.which",
|
|
return_value=None,
|
|
):
|
|
captured = io.StringIO()
|
|
with patch.object(sys, "stderr", captured):
|
|
with self.assertRaises(SystemExit):
|
|
smolmachines_preflight()
|
|
msg = captured.getvalue()
|
|
self.assertIn("smolvm", msg)
|
|
self.assertIn("gvproxy", msg)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|