feat(smolmachines): end-to-end launch + Bottle.exec + smoke + probes (PRD 0023 chunk 2d)
End-to-end launch flow for the smolmachines backend. Brings up
the per-bottle docker bridge + sidecar bundle, creates and
starts the smolvm guest pointed at the bundle's pinned IP via
TSI's `--allow-cidr <bundle-ip>/32`, yields a SmolmachinesBottle
handle that routes exec/cp through `smolvm machine exec / cp`,
tears everything down on context exit.
launch.py:
- ExitStack-managed: create_bundle_network → start_bundle →
machine_create → machine_start (each registered for reverse
teardown).
- daemons_csv="" for chunk 2d — bundle init logs "no daemons
selected" and idles. Real daemon bringup with inner-Plan-driven
env + volumes lands in chunk 4.
bottle.py:
- SmolmachinesBottle.exec → smolvm.machine_exec (captured).
- SmolmachinesBottle.exec_claude → direct subprocess.run with
inherited TTY for interactive sessions.
- SmolmachinesBottle.cp_in → smolvm.machine_cp.
Architecture pivots forced by smolvm 0.8.0's CLI shape:
1. `--from <smolmachine>` and `--smolfile <toml>` are MUTUALLY
EXCLUSIVE in smolvm 0.8.0. We need --from to avoid the
registry-pull race that bit us on machine_start (libkrun
agent's network attempt got refused by macOS with
"connect: permission denied" on IPv6). So Smolfile is dropped
entirely; per-bottle env + allow_cidrs flow as CLI flags
(`--allow-cidr CIDR`, `-e K=V`) directly to machine_create.
2. `smolvm pack create --image` doesn't pull from the local
docker daemon — only OCI registries via crane. The real
claude-bottle:latest image lives in the local docker daemon
and isn't reachable that way. Chunk 2d ships with an alpine
placeholder; the agent-image-conversion gap belongs to
chunk 4 (push the image to a registry, or smolvm grows a
docker-daemon transport).
Other changes:
- machine_create grew `image=` / `from_path=` / `allow_cidrs=`
/ `env=` kwargs; smolfile= dropped.
- bottle_plan: smolfile_path → agent_from_path + guest_env.
- prepare: pack_create against `alpine:latest`, cached under
~/.cache/claude-bottle/smolmachines/ keyed by image ref.
- Deleted smolfile.py + test_smolfile.py (dead code now).
Tests:
- Unit: 540 passing (smolvm wrapper grew 4 new flag forms; one
test renamed to reflect --from + --allow-cidr + -e combo).
- Integration: 3 new cases in tests/integration/
test_smolmachines_launch.py, gated on Darwin + smolvm on PATH
+ docker + not GITEA_ACTIONS:
* smoke: bottle.exec("echo hello-from-vm") round-trips with
the correct stdout + returncode.
* localhost-reach probe: agent dials 127.0.0.1:9 → connect
refused (TSI's <bundle-ip>/32 allowlist doesn't include
loopback). The regression test for the gap the PRD design
pivot was about.
* egress-port-bypass probe: agent dials <bundle-ip>:9099
(egress's port) → connect refused. Chunk 2d has no
daemons running so nothing's listening anyway; chunk 3
will preserve this property once egress is up but bound
to 127.0.0.1 inside the bundle.
End-to-end smoke + both probes green locally on macOS with
smolvm 0.8.0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit was merged in pull request #67.
This commit is contained in:
@@ -1,112 +0,0 @@
|
||||
"""Unit: Smolfile renderer for the smolmachines backend (PRD 0023).
|
||||
|
||||
Pure-function tests on `smolfile_build` + `smolfile_render`. The
|
||||
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 claude_bottle.backend.smolmachines.smolfile import (
|
||||
smolfile_build,
|
||||
smolfile_render,
|
||||
)
|
||||
|
||||
|
||||
class TestSmolfileBuild(unittest.TestCase):
|
||||
def _build(self, **kwargs):
|
||||
defaults = dict(
|
||||
env={"HTTPS_PROXY": "http://192.168.50.2:8888"},
|
||||
bundle_ip="192.168.50.2",
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return smolfile_build(**defaults)
|
||||
|
||||
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://192.168.50.2:8888",
|
||||
})
|
||||
self.assertEqual(
|
||||
[
|
||||
"ALPHA=two",
|
||||
"HTTPS_PROXY=http://192.168.50.2:8888",
|
||||
"ZED=one",
|
||||
],
|
||||
cfg["env"],
|
||||
)
|
||||
|
||||
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_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()
|
||||
self.assertNotIn("image", cfg)
|
||||
self.assertNotIn("entrypoint", cfg)
|
||||
self.assertNotIn("cmd", cfg)
|
||||
self.assertNotIn("command", cfg)
|
||||
self.assertNotIn("name", cfg)
|
||||
|
||||
|
||||
class TestSmolfileRender(unittest.TestCase):
|
||||
def _render(self, **kwargs):
|
||||
defaults = dict(
|
||||
env={"HTTPS_PROXY": "http://192.168.50.2:8888"},
|
||||
bundle_ip="192.168.50.2",
|
||||
)
|
||||
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.assertIn(
|
||||
"HTTPS_PROXY=http://192.168.50.2:8888",
|
||||
parsed["env"],
|
||||
)
|
||||
self.assertEqual(
|
||||
["192.168.50.2/32"],
|
||||
parsed["network"]["allow_cidrs"],
|
||||
)
|
||||
|
||||
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(
|
||||
env={"WITH_QUOTES": 'has "double" quotes'},
|
||||
bundle_ip="10.0.0.1",
|
||||
)
|
||||
rendered = smolfile_render(cfg)
|
||||
parsed = tomllib.loads(rendered)
|
||||
self.assertIn('WITH_QUOTES=has "double" quotes', parsed["env"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -69,20 +69,24 @@ class TestArgvShapes(unittest.TestCase):
|
||||
m.call_args.args[0],
|
||||
)
|
||||
|
||||
def test_machine_create_with_from_and_smolfile(self):
|
||||
def test_machine_create_with_from_and_allow_cidr_and_env(self):
|
||||
with self._patch_run() as m:
|
||||
machine_create(
|
||||
"agent-xyz",
|
||||
from_path=Path("/stage/agent.smolmachine"),
|
||||
smolfile=Path("/stage/smolfile.toml"),
|
||||
allow_cidrs=["192.168.50.2/32"],
|
||||
env={"HTTPS_PROXY": "http://192.168.50.2:8888"},
|
||||
)
|
||||
self.assertEqual(
|
||||
["smolvm", "machine", "create",
|
||||
"--from", "/stage/agent.smolmachine",
|
||||
"--smolfile", "/stage/smolfile.toml",
|
||||
"agent-xyz"],
|
||||
m.call_args.args[0],
|
||||
)
|
||||
argv = m.call_args.args[0]
|
||||
# --from + --allow-cidr + -e are all flags, name is positional.
|
||||
self.assertEqual("smolvm", argv[0])
|
||||
self.assertIn("--from", argv)
|
||||
self.assertIn("/stage/agent.smolmachine", argv)
|
||||
self.assertIn("--allow-cidr", argv)
|
||||
self.assertIn("192.168.50.2/32", argv)
|
||||
self.assertIn("-e", argv)
|
||||
self.assertIn("HTTPS_PROXY=http://192.168.50.2:8888", argv)
|
||||
self.assertEqual("agent-xyz", argv[-1])
|
||||
|
||||
def test_machine_start_uses_dash_name(self):
|
||||
# `start` is the --name flag form, NOT positional.
|
||||
|
||||
Reference in New Issue
Block a user