feat(smolmachines): rewrite Smolfile to smolvm 0.8.0 schema + drop gvproxy (PRD 0023 chunk 2a)
First sub-PR of chunk 2: rewrite the renderer chunk 1 shipped to match smolvm 0.8.0's actual Smolfile shape, delete the dead gvproxy renderer + its tests, simplify the prepare flow now that there's no gvproxy socket + no loopback-port allocation. Smolfile renderer: - Old shape (under the abandoned gvproxy design): name = ..., command = [...], [[net]] attachment = "unixgram", socket = "...". - New shape (smolvm 0.8.0): env = [...] (sorted K=V pairs), [network] allow_cidrs = ["<bundle-ip>/32"]. Nothing else. image / entrypoint / cmd come from the .smolmachine artifact built in chunk 2b; cpus / memory left at smolvm defaults. - Tests assert no leakage of TSI's --outbound-localhost-only or the old gvproxy/unixgram keys. util.py: - smolmachines_gvproxy_subnet → smolmachines_bundle_subnet, returning (subnet, gateway, bundle_ip). bundle_ip is always at .2 (gateway .1); subnet is /24, third octet derived from the slug hash, skipping the docker-default 17 to avoid the common 192.168.17.x collision. - allocate_loopback_port: deleted. The bundle gets a pinned docker IP now; the agent dials that IP directly through TSI. - smolmachines_preflight: dropped the gvproxy check; only smolvm is required. prepare.py: - Drops the gvproxy.yaml render + the loopback port allocation + the gvproxy_socket field on the plan. - Derives subnet / gateway / bundle_ip from the slug and populates the new SmolmachinesBottlePlan fields. - Agent env now uses IP-literal URLs (http://<bundle-ip>:8888 etc) since the guest will have no DNS resolver inside TSI's allowlist. bottle_plan.py: - Old fields: gvproxy_config_path, gvproxy_socket, gvproxy_subnet, gvproxy_gateway, host_port_map. - New fields: bundle_subnet, bundle_gateway, bundle_ip, smolfile_path. (smolmachine artifact path lands in chunk 2b.) Net: -410 lines. Full unit suite: 516 passing. The VM lifecycle + bundle bringup + launch wiring + smoke tests land in chunk 2b. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,117 +1,79 @@
|
||||
"""Unit: smolmachines backend util helpers (PRD 0023 chunk 1)."""
|
||||
"""Unit: smolmachines backend util helpers (PRD 0023)."""
|
||||
|
||||
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_bundle_subnet,
|
||||
smolmachines_preflight,
|
||||
)
|
||||
|
||||
|
||||
class TestGvproxySubnet(unittest.TestCase):
|
||||
def test_returns_192_168_X_format(self):
|
||||
subnet, gateway = smolmachines_gvproxy_subnet("demo-abc12")
|
||||
class TestBundleSubnet(unittest.TestCase):
|
||||
def test_returns_subnet_gateway_and_bundle_ip(self):
|
||||
subnet, gateway, bundle_ip = smolmachines_bundle_subnet("demo-abc12")
|
||||
self.assertTrue(subnet.startswith("192.168."))
|
||||
self.assertTrue(subnet.endswith(".0/24"))
|
||||
self.assertTrue(gateway.startswith("192.168."))
|
||||
# Gateway at .1, bundle at .2 — fixed convention.
|
||||
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)
|
||||
self.assertTrue(bundle_ip.endswith(".2"))
|
||||
# All three share the same third octet.
|
||||
third = subnet.split(".")[2]
|
||||
self.assertEqual(third, gateway.split(".")[2])
|
||||
self.assertEqual(third, bundle_ip.split(".")[2])
|
||||
|
||||
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")
|
||||
# Recoverability: `cli.py resume` reuses the slug and
|
||||
# expects to find the same per-bottle subnet (a fresh
|
||||
# docker bridge would mean a different IP, and smolvm's
|
||||
# allow_cidrs would no longer match).
|
||||
a = smolmachines_bundle_subnet("demo-abc12")
|
||||
b = smolmachines_bundle_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),
|
||||
# Not a guarantee — it's hash-mod-254, collisions exist —
|
||||
# but two arbitrary slugs shouldn't share a subnet in the
|
||||
# typical case.
|
||||
seen = {
|
||||
smolmachines_gvproxy_subnet(s)
|
||||
smolmachines_bundle_subnet(s)[0]
|
||||
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.
|
||||
def test_skips_docker_default_octet(self):
|
||||
# docker's default bridge sits at 172.17.x.x; operators
|
||||
# often also see 192.168.17.x from VPN clients on macOS.
|
||||
# The util 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)
|
||||
subnet, _, _ = smolmachines_bundle_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):
|
||||
def test_smolvm_present_returns_none(self):
|
||||
with patch(
|
||||
"claude_bottle.backend.smolmachines.util.shutil.which",
|
||||
side_effect=lambda name: f"/usr/local/bin/{name}",
|
||||
return_value="/usr/local/bin/smolvm",
|
||||
):
|
||||
self.assertIsNone(smolmachines_preflight())
|
||||
|
||||
def test_missing_smolvm_dies_with_pointer(self):
|
||||
def test_missing_smolvm_dies(self):
|
||||
with patch(
|
||||
"claude_bottle.backend.smolmachines.util.shutil.which",
|
||||
side_effect=lambda name: None if name == "smolvm" else f"/x/{name}",
|
||||
return_value=None,
|
||||
):
|
||||
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
|
||||
def test_install_pointer_in_error(self):
|
||||
import io
|
||||
import sys
|
||||
with patch(
|
||||
"claude_bottle.backend.smolmachines.util.shutil.which",
|
||||
return_value=None,
|
||||
@@ -122,7 +84,7 @@ class TestPreflight(unittest.TestCase):
|
||||
smolmachines_preflight()
|
||||
msg = captured.getvalue()
|
||||
self.assertIn("smolvm", msg)
|
||||
self.assertIn("gvproxy", msg)
|
||||
self.assertIn("smolmachines.com/install.sh", msg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user