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:
+58
-69
@@ -1,17 +1,14 @@
|
||||
"""Unit: Smolfile renderer for the smolmachines backend (PRD 0023).
|
||||
|
||||
Pure-function tests on `smolfile_build` + `smolfile_render`. The
|
||||
schema we emit is narrow (name + command + env + one inline-table
|
||||
per net device), so the tests exhaustively cover what lands on
|
||||
disk."""
|
||||
schema we emit is narrow (env list + `[network] allow_cidrs`), so
|
||||
the tests exhaustively cover what lands on disk."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from claude_bottle.backend.smolmachines.smolfile import (
|
||||
GVPROXY_PIPELOCK_GATEWAY_PORT,
|
||||
smolfile_build,
|
||||
smolfile_render,
|
||||
)
|
||||
@@ -20,104 +17,96 @@ from claude_bottle.backend.smolmachines.smolfile import (
|
||||
class TestSmolfileBuild(unittest.TestCase):
|
||||
def _build(self, **kwargs):
|
||||
defaults = dict(
|
||||
slug="demo-abc12",
|
||||
gvproxy_socket=Path("/tmp/cb-stage/gvproxy.sock"),
|
||||
env={"HTTPS_PROXY": "http://proxy.internal:8888"},
|
||||
env={"HTTPS_PROXY": "http://192.168.50.2:8888"},
|
||||
bundle_ip="192.168.50.2",
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return smolfile_build(**defaults)
|
||||
|
||||
def test_name_uses_claude_bottle_prefix(self):
|
||||
cfg = self._build(slug="myagent-xyz")
|
||||
self.assertEqual("claude-bottle-myagent-xyz", cfg["name"])
|
||||
|
||||
def test_command_defaults_to_sleep_infinity(self):
|
||||
# Chunk 1 placeholder; chunk 4 swaps in the real claude
|
||||
# entrypoint.
|
||||
cfg = self._build()
|
||||
self.assertEqual(["sleep", "infinity"], cfg["command"])
|
||||
|
||||
def test_command_can_be_overridden(self):
|
||||
cfg = self._build(command=("claude", "--no-banner"))
|
||||
self.assertEqual(["claude", "--no-banner"], cfg["command"])
|
||||
|
||||
def test_env_renders_as_sorted_KEY_VALUE_list(self):
|
||||
# Sorted by key so renderer output is deterministic.
|
||||
cfg = self._build(env={
|
||||
"ZED": "one",
|
||||
"ALPHA": "two",
|
||||
"HTTPS_PROXY": "http://proxy.internal:8888",
|
||||
"HTTPS_PROXY": "http://192.168.50.2:8888",
|
||||
})
|
||||
# Sorted by key so renderer output is deterministic.
|
||||
self.assertEqual(
|
||||
["ALPHA=two", "HTTPS_PROXY=http://proxy.internal:8888", "ZED=one"],
|
||||
[
|
||||
"ALPHA=two",
|
||||
"HTTPS_PROXY=http://192.168.50.2:8888",
|
||||
"ZED=one",
|
||||
],
|
||||
cfg["env"],
|
||||
)
|
||||
|
||||
def test_net_device_points_at_gvproxy_socket(self):
|
||||
cfg = self._build(gvproxy_socket=Path("/state/foo/gv.sock"))
|
||||
self.assertEqual(1, len(cfg["net"]))
|
||||
net = cfg["net"][0]
|
||||
self.assertEqual("virtio-net", net["type"])
|
||||
self.assertEqual("unixgram", net["attachment"])
|
||||
self.assertEqual("/state/foo/gv.sock", net["socket"])
|
||||
def test_allow_cidrs_is_single_slash_32(self):
|
||||
# TSI's single-IP allowlist. Anything else would
|
||||
# re-introduce the loopback / LAN reachability the PRD
|
||||
# design carefully avoids.
|
||||
cfg = self._build(bundle_ip="10.20.30.40")
|
||||
self.assertEqual(
|
||||
{"allow_cidrs": ["10.20.30.40/32"]},
|
||||
cfg["network"],
|
||||
)
|
||||
|
||||
def test_no_tsi_flags(self):
|
||||
# PRD 0023: TSI is explicitly rejected. The Smolfile must
|
||||
# never carry --allow-cidr / --allow-host /
|
||||
# --outbound-localhost-only — gvproxy is the policy layer.
|
||||
def test_no_image_or_command_emitted(self):
|
||||
# The chunk-1 renderer (under the abandoned gvproxy design)
|
||||
# emitted `name = ...` + `[[net]] attachment="unixgram"`.
|
||||
# The new renderer carries only the per-bottle overrides;
|
||||
# image / entrypoint / cmd come from the .smolmachine
|
||||
# artifact, not the Smolfile.
|
||||
cfg = self._build()
|
||||
rendered = smolfile_render(cfg)
|
||||
self.assertNotIn("--allow-cidr", rendered)
|
||||
self.assertNotIn("--allow-host", rendered)
|
||||
self.assertNotIn("--outbound-localhost-only", rendered)
|
||||
self.assertNotIn("tsi", rendered.lower())
|
||||
self.assertNotIn("image", cfg)
|
||||
self.assertNotIn("entrypoint", cfg)
|
||||
self.assertNotIn("cmd", cfg)
|
||||
self.assertNotIn("command", cfg)
|
||||
self.assertNotIn("name", cfg)
|
||||
|
||||
|
||||
class TestSmolfileRender(unittest.TestCase):
|
||||
"""The rendered TOML must be parseable by stdlib `tomllib` and
|
||||
contain the keys the smolmachines schema expects."""
|
||||
|
||||
def _render(self, **kwargs):
|
||||
cfg = smolfile_build(
|
||||
slug="demo-abc12",
|
||||
gvproxy_socket=Path("/tmp/gvp.sock"),
|
||||
env={"HTTPS_PROXY": "http://proxy.internal:8888"},
|
||||
**kwargs,
|
||||
defaults = dict(
|
||||
env={"HTTPS_PROXY": "http://192.168.50.2:8888"},
|
||||
bundle_ip="192.168.50.2",
|
||||
)
|
||||
return smolfile_render(cfg)
|
||||
defaults.update(kwargs)
|
||||
return smolfile_render(smolfile_build(**defaults))
|
||||
|
||||
def test_round_trip_through_tomllib(self):
|
||||
import tomllib # stdlib in 3.11+
|
||||
rendered = self._render()
|
||||
parsed = tomllib.loads(rendered)
|
||||
self.assertEqual("claude-bottle-demo-abc12", parsed["name"])
|
||||
self.assertEqual(["sleep", "infinity"], parsed["command"])
|
||||
self.assertIn("HTTPS_PROXY=http://proxy.internal:8888", parsed["env"])
|
||||
# net is an array of tables → list of dicts post-parse.
|
||||
self.assertEqual(1, len(parsed["net"]))
|
||||
self.assertEqual("/tmp/gvp.sock", parsed["net"][0]["socket"])
|
||||
self.assertIn(
|
||||
"HTTPS_PROXY=http://192.168.50.2:8888",
|
||||
parsed["env"],
|
||||
)
|
||||
self.assertEqual(
|
||||
["192.168.50.2/32"],
|
||||
parsed["network"]["allow_cidrs"],
|
||||
)
|
||||
|
||||
def test_special_chars_in_values_escape_correctly(self):
|
||||
def test_no_tsi_outbound_localhost_only(self):
|
||||
# Whole point of the design pivot: never emit
|
||||
# `--outbound-localhost-only` or similar that would
|
||||
# re-open host loopback.
|
||||
text = self._render()
|
||||
self.assertNotIn("outbound_localhost_only", text)
|
||||
self.assertNotIn("outbound-localhost-only", text)
|
||||
# And no gvproxy / virtio-net carve-out leaked from the
|
||||
# abandoned first draft.
|
||||
self.assertNotIn("unixgram", text)
|
||||
self.assertNotIn("gvproxy", text.lower())
|
||||
|
||||
def test_special_chars_in_env_value_escape(self):
|
||||
import tomllib
|
||||
cfg = smolfile_build(
|
||||
slug="demo",
|
||||
gvproxy_socket=Path("/tmp/path with spaces/gv.sock"),
|
||||
env={"WITH_QUOTES": 'has "double" quotes'},
|
||||
bundle_ip="10.0.0.1",
|
||||
)
|
||||
rendered = smolfile_render(cfg)
|
||||
parsed = tomllib.loads(rendered)
|
||||
self.assertEqual(
|
||||
"/tmp/path with spaces/gv.sock",
|
||||
parsed["net"][0]["socket"],
|
||||
)
|
||||
# The env entry survives the quote escape.
|
||||
self.assertIn('WITH_QUOTES=has "double" quotes', parsed["env"])
|
||||
|
||||
def test_constants_match_what_prepare_uses(self):
|
||||
# Lock the gateway-port constant so the prepare side and the
|
||||
# config-render side don't drift out of sync.
|
||||
self.assertEqual(8888, GVPROXY_PIPELOCK_GATEWAY_PORT)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user