Merge pull request 'feat(smolmachines): smolvm subprocess wrapper (PRD 0023 chunk 2b)' (#65) from prd-0023-chunk-2b-smolvm-wrapper into main
This commit was merged in pull request #65.
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
"""Thin subprocess wrapper around the `smolvm` CLI (PRD 0023).
|
||||
|
||||
One thin Python function per smolvm subcommand the launch flow
|
||||
needs. Two design choices worth flagging:
|
||||
|
||||
- **No daemon, no SDK.** smolvm 0.8.0 ships a `smolvm serve`
|
||||
HTTP API as the long-term-clean integration target. The
|
||||
project's stdlib-first ethos + the lower-overhead CLI calls
|
||||
push v1 to shell out via `subprocess.run`. If a future
|
||||
smolvm release makes `serve` mandatory (or significantly
|
||||
faster), revisit.
|
||||
|
||||
- **Two return shapes.** `SmolvmRunResult` (returncode + stdout
|
||||
+ stderr captured) is returned by `machine_exec` because the
|
||||
caller cares about the in-VM command's exit status, and by
|
||||
test helpers that introspect output. The other calls
|
||||
(`machine_start`, `machine_stop`, `pack_create`, etc.) raise
|
||||
`SmolvmError` on non-zero exit — failure to start a VM is
|
||||
fatal to the launch flow, not something callers want to
|
||||
branch on.
|
||||
|
||||
The wrapper is unit-tested with `subprocess.run` mocked; the
|
||||
integration smoke test (chunk 2d) exercises against a real
|
||||
smolvm binary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
|
||||
_SMOLVM = "smolvm"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SmolvmRunResult:
|
||||
"""Captured result of an in-VM command. Mirrors the structure
|
||||
`Bottle.exec` returns so callers can hand it straight through."""
|
||||
returncode: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
|
||||
class SmolvmError(RuntimeError):
|
||||
"""Raised when a smolvm subprocess returns non-zero on a path
|
||||
where the caller has no useful branch to take (start failed,
|
||||
pack failed, etc.). Carries the captured stderr for the
|
||||
operator-facing log line."""
|
||||
|
||||
def __init__(self, argv: Sequence[str], result: subprocess.CompletedProcess):
|
||||
self.argv = list(argv)
|
||||
self.returncode = result.returncode
|
||||
self.stdout = result.stdout
|
||||
self.stderr = result.stderr
|
||||
cmd = " ".join(self.argv)
|
||||
super().__init__(
|
||||
f"{cmd!r} failed (exit {result.returncode}): "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
|
||||
|
||||
def _smolvm(*args: str, env: Mapping[str, str] | None = None,
|
||||
check: bool = True) -> subprocess.CompletedProcess:
|
||||
"""One subprocess call into the smolvm CLI. `check=True`
|
||||
raises SmolvmError on non-zero; `check=False` returns the
|
||||
CompletedProcess for the caller to inspect."""
|
||||
argv = [_SMOLVM, *args]
|
||||
result = subprocess.run(
|
||||
argv,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=dict(env) if env is not None else None,
|
||||
check=False,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise SmolvmError(argv, result)
|
||||
return result
|
||||
|
||||
|
||||
# --- Pack ----------------------------------------------------------------
|
||||
|
||||
|
||||
def pack_create(image: str, output: Path) -> None:
|
||||
"""`smolvm pack create --image <image> -o <output>`. Converts
|
||||
an OCI image into a self-contained `.smolmachine` artifact
|
||||
smolvm can boot via `machine create --from`. Idempotent on the
|
||||
smolvm side — re-running with the same image+output rebuilds
|
||||
from layer cache."""
|
||||
_smolvm("pack", "create", "--image", image, "-o", str(output))
|
||||
|
||||
|
||||
# --- Machine lifecycle ---------------------------------------------------
|
||||
|
||||
|
||||
def machine_create(
|
||||
name: str,
|
||||
*,
|
||||
from_path: Path | None = None,
|
||||
smolfile: Path | None = None,
|
||||
) -> None:
|
||||
"""`smolvm machine create NAME [--from PATH] [--smolfile PATH]`.
|
||||
NAME is positional (the CLI's exception to the `--name`
|
||||
pattern other subcommands use)."""
|
||||
args: list[str] = ["machine", "create"]
|
||||
if from_path is not None:
|
||||
args += ["--from", str(from_path)]
|
||||
if smolfile is not None:
|
||||
args += ["--smolfile", str(smolfile)]
|
||||
args.append(name)
|
||||
_smolvm(*args)
|
||||
|
||||
|
||||
def machine_start(name: str) -> None:
|
||||
"""`smolvm machine start --name NAME`."""
|
||||
_smolvm("machine", "start", "--name", name)
|
||||
|
||||
|
||||
def machine_stop(name: str) -> None:
|
||||
"""`smolvm machine stop --name NAME`. Idempotent against
|
||||
already-stopped machines: smolvm prints a notice and exits 0
|
||||
in that case, so no special handling here."""
|
||||
_smolvm("machine", "stop", "--name", name)
|
||||
|
||||
|
||||
def machine_delete(name: str) -> None:
|
||||
"""`smolvm machine delete -f NAME`. NAME is positional. `-f`
|
||||
skips the interactive confirmation — required for
|
||||
non-interactive teardown."""
|
||||
_smolvm("machine", "delete", "-f", name)
|
||||
|
||||
|
||||
def machine_exec(
|
||||
name: str,
|
||||
argv: Sequence[str],
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
workdir: str | None = None,
|
||||
timeout: str | None = None,
|
||||
) -> SmolvmRunResult:
|
||||
"""`smolvm machine exec --name NAME [-w DIR] [--timeout DUR]
|
||||
[-e K=V ...] -- ARGV...`. Returns the captured result rather
|
||||
than raising — callers (including `Bottle.exec`) care about
|
||||
the in-VM command's exit code, not just whether smolvm ran.
|
||||
|
||||
`env` here is in-VM env vars (`-e K=V`), not the host
|
||||
subprocess env — smolvm's own argv carries them through the
|
||||
VMM."""
|
||||
flags: list[str] = ["machine", "exec", "--name", name]
|
||||
if workdir is not None:
|
||||
flags += ["-w", workdir]
|
||||
if timeout is not None:
|
||||
flags += ["--timeout", timeout]
|
||||
if env:
|
||||
for k, v in env.items():
|
||||
flags += ["-e", f"{k}={v}"]
|
||||
# `--` separator before the command. smolvm's CLI requires it
|
||||
# so its own flag parser doesn't grab argv items that look
|
||||
# like flags.
|
||||
flags.append("--")
|
||||
flags += list(argv)
|
||||
result = _smolvm(*flags, check=False)
|
||||
return SmolvmRunResult(
|
||||
returncode=result.returncode,
|
||||
stdout=result.stdout or "",
|
||||
stderr=result.stderr or "",
|
||||
)
|
||||
|
||||
|
||||
def machine_cp(src: str, dst: str) -> None:
|
||||
"""`smolvm machine cp SRC DST`. Path syntax: `machine:path` to
|
||||
reference a path inside the VM, bare path for the host. Both
|
||||
SRC and DST are positional; either side can be machine: or
|
||||
bare. Empty path is a no-op (returns immediately without
|
||||
invoking smolvm)."""
|
||||
if not src or not dst:
|
||||
return
|
||||
_smolvm("machine", "cp", src, dst)
|
||||
|
||||
|
||||
# --- Discovery -----------------------------------------------------------
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""True iff `smolvm` is on PATH. Used by the integration test
|
||||
suite's skip-guards."""
|
||||
return shutil.which(_SMOLVM) is not None
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Integration: PRD 0023 chunk 2b — smolvm subprocess wrapper
|
||||
exercised against the real binary.
|
||||
|
||||
The full machine-lifecycle round trip (create → start → exec →
|
||||
delete) is gated behind macOS + Darwin platform check and lives
|
||||
in chunk 2d's smoke. This file just verifies `is_available()`
|
||||
correctly reports presence and `_smolvm()` can run a no-op
|
||||
subcommand without errors — enough to flag wrapper drift if
|
||||
smolvm's flag parser changes shape across versions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from claude_bottle.backend.smolmachines.smolvm import is_available
|
||||
|
||||
|
||||
@unittest.skipIf(
|
||||
os.environ.get("GITEA_ACTIONS") == "true",
|
||||
"skipped under act_runner: smolvm not installed on the runner",
|
||||
)
|
||||
@unittest.skipUnless(
|
||||
platform.system() == "Darwin",
|
||||
"smolvm is macOS-only for v1; Linux+KVM path is a future PRD",
|
||||
)
|
||||
@unittest.skipUnless(
|
||||
is_available(),
|
||||
"smolvm not on PATH; install via "
|
||||
"curl -sSL https://smolmachines.com/install.sh | sh",
|
||||
)
|
||||
class TestSmolvmSmoke(unittest.TestCase):
|
||||
def test_smolvm_help_responds(self):
|
||||
# `smolvm --help` exits 0 (per `smolvm machine --help`
|
||||
# convention) — verifies the binary launches and the
|
||||
# top-level parser is intact.
|
||||
r = subprocess.run(
|
||||
["smolvm", "--help"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
# Either exit-code 0 (clean) or 1 (some CLIs return 1
|
||||
# from --help by convention; smolvm 0.8.0 does this). The
|
||||
# point is the binary runs and emits help text.
|
||||
self.assertIn("smolvm", r.stdout)
|
||||
self.assertIn("machine", r.stdout)
|
||||
|
||||
def test_machine_ls_empty_returns_json_array(self):
|
||||
# `machine ls --json` is the contract chunk 4's
|
||||
# list_active wires to. Lock in that the JSON shape is
|
||||
# parseable now so chunk 4 doesn't surprise us.
|
||||
import json
|
||||
r = subprocess.run(
|
||||
["smolvm", "machine", "ls", "--json"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
self.assertEqual(0, r.returncode, r.stderr)
|
||||
parsed = json.loads(r.stdout)
|
||||
self.assertIsInstance(parsed, list)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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