feat(smolmachines): smolvm subprocess wrapper (PRD 0023 chunk 2b)
claude_bottle/backend/smolmachines/smolvm.py — one thin Python
function per smolvm CLI subcommand the launch flow needs:
- pack_create(image, output) → smolvm pack create
- machine_create(name, from_path,
smolfile) → smolvm machine create
- machine_start(name) → smolvm machine start
- machine_stop(name) → smolvm machine stop
- machine_delete(name) → smolvm machine delete -f
- machine_exec(name, argv, env,
workdir, timeout) → smolvm machine exec
- machine_cp(src, dst) → smolvm machine cp
- is_available() → shutil.which check
The wrapper hides the CLI's inconsistent name-flag style
(positional NAME on create/delete, --name on start/stop/exec/
status) behind a uniform `name=` kwarg.
Two return shapes:
- SmolvmRunResult (returncode + stdout + stderr) from
machine_exec, because callers care about the in-VM
command's exit code.
- Raises SmolvmError on non-zero for all other commands;
failure to create/start/stop a VM is fatal to the launch
flow, not branched on.
Tests:
- 15 unit cases mocking subprocess.run, covering argv shape
per subcommand (the --name vs positional inconsistency
locked down), SmolvmError on non-zero for non-exec paths,
SmolvmRunResult passthrough on exec, empty-path cp no-op.
- 2 integration cases against the real smolvm binary
(gated on Darwin + smolvm on PATH + not GITEA_ACTIONS):
smolvm --help responds, machine ls --json parses as a
list (the contract chunk 4's list_active will consume).
531 unit tests passing. Real-smolvm smoke green locally.
Bundle bringup + launch wiring + the localhost-reach /
egress-port-bypass probes land in chunks 2c + 2d.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
"""Unit: smolvm subprocess wrapper (PRD 0023 chunk 2b).
|
||||
|
||||
The wrapper is one thin function per smolvm CLI subcommand. Tests
|
||||
mock `subprocess.run` and assert on the constructed argv +
|
||||
SmolvmError raising on non-zero. The actual smolvm binary's
|
||||
behavior is exercised in chunk 2d's integration smoke test."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from claude_bottle.backend.smolmachines.smolvm import (
|
||||
SmolvmError,
|
||||
SmolvmRunResult,
|
||||
is_available,
|
||||
machine_cp,
|
||||
machine_create,
|
||||
machine_delete,
|
||||
machine_exec,
|
||||
machine_start,
|
||||
machine_stop,
|
||||
pack_create,
|
||||
)
|
||||
|
||||
|
||||
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess:
|
||||
return subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout=stdout, stderr=stderr,
|
||||
)
|
||||
|
||||
|
||||
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess:
|
||||
return subprocess.CompletedProcess(
|
||||
args=[], returncode=1, stdout="", stderr=stderr,
|
||||
)
|
||||
|
||||
|
||||
class TestArgvShapes(unittest.TestCase):
|
||||
"""The CLI mixes `--name NAME` and positional-NAME styles
|
||||
across subcommands. The wrapper hides that inconsistency
|
||||
behind a uniform `name=` kwarg; lock down which form lands
|
||||
in each argv."""
|
||||
|
||||
def _patch_run(self):
|
||||
return patch(
|
||||
"claude_bottle.backend.smolmachines.smolvm.subprocess.run",
|
||||
return_value=_ok(),
|
||||
)
|
||||
|
||||
def test_pack_create_argv(self):
|
||||
with self._patch_run() as m:
|
||||
pack_create("claude-bottle:latest", Path("/tmp/agent.smolmachine"))
|
||||
argv = m.call_args.args[0]
|
||||
self.assertEqual(
|
||||
["smolvm", "pack", "create",
|
||||
"--image", "claude-bottle:latest",
|
||||
"-o", "/tmp/agent.smolmachine"],
|
||||
argv,
|
||||
)
|
||||
|
||||
def test_machine_create_minimal(self):
|
||||
with self._patch_run() as m:
|
||||
machine_create("agent-xyz")
|
||||
self.assertEqual(
|
||||
["smolvm", "machine", "create", "agent-xyz"],
|
||||
m.call_args.args[0],
|
||||
)
|
||||
|
||||
def test_machine_create_with_from_and_smolfile(self):
|
||||
with self._patch_run() as m:
|
||||
machine_create(
|
||||
"agent-xyz",
|
||||
from_path=Path("/stage/agent.smolmachine"),
|
||||
smolfile=Path("/stage/smolfile.toml"),
|
||||
)
|
||||
self.assertEqual(
|
||||
["smolvm", "machine", "create",
|
||||
"--from", "/stage/agent.smolmachine",
|
||||
"--smolfile", "/stage/smolfile.toml",
|
||||
"agent-xyz"],
|
||||
m.call_args.args[0],
|
||||
)
|
||||
|
||||
def test_machine_start_uses_dash_name(self):
|
||||
# `start` is the --name flag form, NOT positional.
|
||||
with self._patch_run() as m:
|
||||
machine_start("agent-xyz")
|
||||
self.assertEqual(
|
||||
["smolvm", "machine", "start", "--name", "agent-xyz"],
|
||||
m.call_args.args[0],
|
||||
)
|
||||
|
||||
def test_machine_stop_uses_dash_name(self):
|
||||
with self._patch_run() as m:
|
||||
machine_stop("agent-xyz")
|
||||
self.assertEqual(
|
||||
["smolvm", "machine", "stop", "--name", "agent-xyz"],
|
||||
m.call_args.args[0],
|
||||
)
|
||||
|
||||
def test_machine_delete_uses_positional_name_and_force(self):
|
||||
# delete NAME is positional; -f required so no interactive
|
||||
# confirmation blocks teardown.
|
||||
with self._patch_run() as m:
|
||||
machine_delete("agent-xyz")
|
||||
self.assertEqual(
|
||||
["smolvm", "machine", "delete", "-f", "agent-xyz"],
|
||||
m.call_args.args[0],
|
||||
)
|
||||
|
||||
def test_machine_exec_argv_with_separator(self):
|
||||
# `--` separator before the command — smolvm's flag parser
|
||||
# would otherwise grab argv items that look like flags.
|
||||
with self._patch_run() as m:
|
||||
machine_exec("agent-xyz", ["echo", "hello"])
|
||||
self.assertEqual(
|
||||
["smolvm", "machine", "exec", "--name", "agent-xyz",
|
||||
"--", "echo", "hello"],
|
||||
m.call_args.args[0],
|
||||
)
|
||||
|
||||
def test_machine_exec_env_workdir_timeout(self):
|
||||
with self._patch_run() as m:
|
||||
machine_exec(
|
||||
"agent-xyz",
|
||||
["ls"],
|
||||
env={"FOO": "bar", "BAZ": "qux"},
|
||||
workdir="/app",
|
||||
timeout="30s",
|
||||
)
|
||||
argv = m.call_args.args[0]
|
||||
self.assertIn("-w", argv); self.assertIn("/app", argv)
|
||||
self.assertIn("--timeout", argv); self.assertIn("30s", argv)
|
||||
# Both env vars present as -e K=V pairs.
|
||||
for pair in ("FOO=bar", "BAZ=qux"):
|
||||
self.assertIn(pair, argv)
|
||||
|
||||
def test_machine_cp_positional_argv(self):
|
||||
with self._patch_run() as m:
|
||||
machine_cp("/host/file", "agent-xyz:/dest/file")
|
||||
self.assertEqual(
|
||||
["smolvm", "machine", "cp",
|
||||
"/host/file", "agent-xyz:/dest/file"],
|
||||
m.call_args.args[0],
|
||||
)
|
||||
|
||||
def test_machine_cp_empty_is_noop(self):
|
||||
# Guard against an upstream caller passing an unset path —
|
||||
# cp with an empty string is meaningless and would just
|
||||
# confuse smolvm's error message.
|
||||
with self._patch_run() as m:
|
||||
machine_cp("", "agent-xyz:/dest")
|
||||
machine_cp("/host", "")
|
||||
self.assertEqual(0, m.call_count)
|
||||
|
||||
|
||||
class TestErrorPath(unittest.TestCase):
|
||||
"""`check=True` paths raise SmolvmError on non-zero; `exec` is
|
||||
the one path that returns a result regardless."""
|
||||
|
||||
def test_create_failure_raises(self):
|
||||
with patch(
|
||||
"claude_bottle.backend.smolmachines.smolvm.subprocess.run",
|
||||
return_value=_fail("no such image"),
|
||||
):
|
||||
with self.assertRaises(SmolvmError) as cm:
|
||||
machine_create("agent-xyz")
|
||||
self.assertEqual(1, cm.exception.returncode)
|
||||
self.assertIn("no such image", str(cm.exception))
|
||||
|
||||
def test_pack_create_failure_raises(self):
|
||||
with patch(
|
||||
"claude_bottle.backend.smolmachines.smolvm.subprocess.run",
|
||||
return_value=_fail("pack failed"),
|
||||
):
|
||||
with self.assertRaises(SmolvmError):
|
||||
pack_create("missing:tag", Path("/tmp/out"))
|
||||
|
||||
def test_exec_failure_returns_result(self):
|
||||
# The in-VM command's exit code is what Bottle.exec sees;
|
||||
# `false` exiting non-zero is not a smolvm failure.
|
||||
with patch(
|
||||
"claude_bottle.backend.smolmachines.smolvm.subprocess.run",
|
||||
return_value=subprocess.CompletedProcess(
|
||||
args=[], returncode=42, stdout="", stderr="nope",
|
||||
),
|
||||
):
|
||||
r = machine_exec("agent-xyz", ["sh", "-c", "exit 42"])
|
||||
self.assertEqual(SmolvmRunResult(42, "", "nope"), r)
|
||||
|
||||
|
||||
class TestIsAvailable(unittest.TestCase):
|
||||
def test_true_when_on_path(self):
|
||||
with patch(
|
||||
"claude_bottle.backend.smolmachines.smolvm.shutil.which",
|
||||
return_value="/usr/local/bin/smolvm",
|
||||
):
|
||||
self.assertTrue(is_available())
|
||||
|
||||
def test_false_when_missing(self):
|
||||
with patch(
|
||||
"claude_bottle.backend.smolmachines.smolvm.shutil.which",
|
||||
return_value=None,
|
||||
):
|
||||
self.assertFalse(is_available())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user