feat(backend): remove smolmachines; firecracker is the Linux default
lint / lint (push) Successful in 1m54s
test / unit (pull_request) Successful in 58s
test / integration (pull_request) Successful in 18s
test / coverage (pull_request) Failing after 1m3s

Delete the smolmachines backend (the whole bot_bottle/backend/smolmachines
package and its tests). It had fatal Linux issues (TSI networking under
sustained use, exec-channel contention, no SIGWINCH) and is superseded by
the Firecracker backend (issue #342).

Backend selection now:
- default is macos-container on macOS, firecracker on KVM-capable Linux
  hosts, and docker as the last resort (was smolmachines).
- firecracker is selected on a KVM host even when the `firecracker`
  binary isn't installed, so start routes through its preflight and
  prints an install pointer (same UX as require_container), instead of
  silently falling back. Split is_host_capable() (Linux + KVM) out of
  is_available() (adds the binary check) to drive this.

Retarget the cross-backend tests (parity, print-parity, prepare,
workspace, freezer, selection) from smolmachines to firecracker rather
than dropping the coverage. Remove docker.util.image_id/save, which only
smolmachines used. Update README/AGENTS/example bottles and stale
comments; historical docs/prds are left as a point-in-time record.

BREAKING: BOT_BOTTLE_BACKEND=smolmachines now errors as unknown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
2026-07-11 13:24:47 -04:00
parent c276f7b0b1
commit c07ebca867
73 changed files with 305 additions and 6218 deletions
+38 -18
View File
@@ -12,7 +12,7 @@ from bot_bottle.backend import ActiveAgent
from bot_bottle.backend.freeze import get_freezer
from bot_bottle.backend.docker.freezer import DockerFreezer
from bot_bottle.backend.macos_container.freezer import MacosContainerFreezer
from bot_bottle.backend.smolmachines.freezer import SmolmachinesFreezer
from bot_bottle.backend.firecracker.freezer import FirecrackerFreezer
class _FakeHomeMixin:
@@ -51,8 +51,8 @@ class TestGetFreezer(unittest.TestCase):
def test_macos_container(self):
self.assertIsInstance(get_freezer("macos-container"), MacosContainerFreezer)
def test_smolmachines(self):
self.assertIsInstance(get_freezer("smolmachines"), SmolmachinesFreezer)
def test_firecracker(self):
self.assertIsInstance(get_freezer("firecracker"), FirecrackerFreezer)
def test_unknown_backend_dies(self):
with patch("bot_bottle.backend.freeze.die", side_effect=SystemExit("die")):
@@ -176,39 +176,59 @@ class TestMacosContainerFreezer(_FakeHomeMixin, unittest.TestCase):
self.assertTrue(bottle_state.is_preserved(slug))
class TestSmolmachinesFreezer(_FakeHomeMixin, unittest.TestCase):
class TestFirecrackerFreezer(_FakeHomeMixin, unittest.TestCase):
def setUp(self):
self._setup_fake_home()
# The freezer resolves the running VM's SSH key + config from
# the per-bottle run dir under the firecracker cache; point that
# at a temp dir so we can stage a fake live bottle.
self._cache = tempfile.TemporaryDirectory(prefix="fc-freezer-cache.")
self._cache_patch = patch(
"bot_bottle.backend.firecracker.freezer.util.cache_dir",
return_value=Path(self._cache.name),
)
self._cache_patch.start()
def tearDown(self):
self._cache_patch.stop()
self._cache.cleanup()
self._teardown_fake_home()
def _write_meta(self, slug: str) -> None:
bottle_state.write_metadata(bottle_state.BottleMetadata(
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
started_at="t", backend="smolmachines",
started_at="t", backend="firecracker",
))
def _stage_run_dir(self, slug: str, guest_ip: str = "100.64.0.1") -> None:
run_dir = Path(self._cache.name) / "run" / slug
run_dir.mkdir(parents=True)
(run_dir / "bottle_id_ed25519").write_text("KEY")
(run_dir / "config.json").write_text(
'{"boot-source": {"boot_args": '
f'"console=ttyS0 ip={guest_ip}::100.64.0.0:255.255.255.254::eth0:off"}}}}'
)
def test_snapshots_running_vm_without_stopping(self):
"""Commit should exec-tar the running VM, not stop it."""
"""Commit should tar the running guest rootfs over SSH, not stop it."""
slug = "dev-abc12"
self._write_meta(slug)
freezer = SmolmachinesFreezer()
agent = _make_agent(slug, "smolmachines")
self._stage_run_dir(slug)
freezer = FirecrackerFreezer()
agent = _make_agent(slug, "firecracker")
with patch("bot_bottle.backend.smolmachines.freezer._snapshot_running_vm") as mock_snap, \
with patch("bot_bottle.backend.firecracker.freezer._commit_via_ssh") as mock_commit, \
patch("bot_bottle.backend.freeze.info"), \
patch("bot_bottle.backend.smolmachines.freezer.info"):
patch("bot_bottle.backend.firecracker.freezer.info"):
freezer.commit(agent)
expected_binary = bottle_state.bottle_state_dir(slug) / "committed-smolmachine"
mock_snap.assert_called_once_with(
f"bot-bottle-{slug}",
f"bot-bottle-committed-{slug}:latest",
expected_binary,
)
expected_sidecar = str(expected_binary.with_suffix(".smolmachine"))
self.assertEqual(expected_sidecar, bottle_state.read_committed_image(slug))
image_tag = f"bot-bottle-committed-{slug}:latest"
self.assertEqual(1, mock_commit.call_count)
# (private_key, guest_ip, image_tag) — guest_ip parsed from config.
args = mock_commit.call_args.args
self.assertEqual("100.64.0.1", args[1])
self.assertEqual(image_tag, args[2])
self.assertEqual(image_tag, bottle_state.read_committed_image(slug))
self.assertTrue(bottle_state.is_preserved(slug))
+39 -44
View File
@@ -1,18 +1,18 @@
"""Cross-backend parity tests (PRD 0042).
Verifies that Docker and smolmachines bottles expose the same
Verifies that Docker and firecracker bottles expose the same
observable contracts for env injection, agent argv, and exec. Tests
use mock subprocess layers so no live VM or Docker daemon is needed.
The scenarios here document what must hold across both backends. As
PRDs 00380040 land these tests provide regression coverage for the
contracts they establish.
The scenarios here document what must hold across both backends and
provide regression coverage for those contracts.
"""
from __future__ import annotations
import subprocess
import unittest
from pathlib import Path
from typing import Callable
from unittest.mock import patch
@@ -31,10 +31,12 @@ def _docker_bottle(guest_env: dict[str, str]) -> "object":
)
def _smolmachines_bottle(guest_env: dict[str, str]) -> "object":
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
return SmolmachinesBottle(
def _firecracker_bottle(guest_env: dict[str, str]) -> "object":
from bot_bottle.backend.firecracker.bottle import FirecrackerBottle
return FirecrackerBottle(
"bot-bottle-test",
private_key=Path("/tmp/key"),
guest_ip="100.64.0.1",
guest_env=guest_env,
agent_command="claude",
)
@@ -43,7 +45,7 @@ def _smolmachines_bottle(guest_env: dict[str, str]) -> "object":
# One entry per backend: (label, factory).
_BACKENDS: list[tuple[str, Callable[[dict[str, str]], object]]] = [
("docker", _docker_bottle),
("smolmachines", _smolmachines_bottle),
("firecracker", _firecracker_bottle),
]
@@ -91,19 +93,16 @@ class TestAgentArgvParity(unittest.TestCase):
)
class TestSmolmachinesEnvInArgv(unittest.TestCase):
"""smolmachines bottle includes guest_env values in exec argv."""
class TestFirecrackerEnvInArgv(unittest.TestCase):
"""firecracker bottle includes guest_env values in the agent argv."""
def test_guest_env_in_exec_argv(self):
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
bottle = SmolmachinesBottle(
"bot-bottle-test",
guest_env={"TOKEN": "abc123", "PROXY": "http://proxy:8888"},
def test_guest_env_in_agent_argv(self):
bottle = _firecracker_bottle(
{"TOKEN": "abc123", "PROXY": "http://proxy:8888"},
)
argv = bottle.agent_argv([], tty=False)
joined = " ".join(argv)
self.assertIn("TOKEN=abc123", joined)
self.assertIn("PROXY=http://proxy:8888", joined)
argv = bottle.agent_argv([], tty=False) # type: ignore[union-attr]
self.assertIn("TOKEN=abc123", argv)
self.assertIn("PROXY=http://proxy:8888", argv)
# ---------------------------------------------------------------------------
@@ -129,17 +128,16 @@ class TestExecUserSwitching(unittest.TestCase):
self.assertIn("node", call_args,
"docker exec should use 'node' user by default")
def test_smolmachines_exec_uses_node_user_by_default(self):
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
bottle = SmolmachinesBottle("bot-bottle-test", guest_env={})
with patch("bot_bottle.backend.smolmachines.bottle.subprocess.run") as run:
def test_firecracker_exec_uses_node_user_by_default(self):
bottle = _firecracker_bottle({})
with patch("bot_bottle.backend.firecracker.bottle.subprocess.run") as run:
run.return_value = subprocess.CompletedProcess(
[], 0, stdout="", stderr="",
)
bottle.exec("echo hi")
call_args = run.call_args[0][0]
self.assertIn("node", call_args,
"smolvm exec should use 'node' user by default")
bottle.exec("echo hi") # type: ignore[union-attr]
call_args = " ".join(run.call_args[0][0])
self.assertIn("runuser -u node", call_args,
"firecracker exec should use 'node' user by default")
def test_docker_exec_respects_root_user(self):
from bot_bottle.backend.docker.bottle import DockerBottle
@@ -156,16 +154,15 @@ class TestExecUserSwitching(unittest.TestCase):
call_args = run.call_args[0][0]
self.assertIn("root", call_args)
def test_smolmachines_exec_respects_root_user(self):
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
bottle = SmolmachinesBottle("bot-bottle-test", guest_env={})
with patch("bot_bottle.backend.smolmachines.bottle.subprocess.run") as run:
def test_firecracker_exec_respects_root_user(self):
bottle = _firecracker_bottle({})
with patch("bot_bottle.backend.firecracker.bottle.subprocess.run") as run:
run.return_value = subprocess.CompletedProcess(
[], 0, stdout="", stderr="",
)
bottle.exec("id", user="root")
call_args = run.call_args[0][0]
self.assertIn("root", call_args)
bottle.exec("id", user="root") # type: ignore[union-attr]
call_args = " ".join(run.call_args[0][0])
self.assertIn("runuser -u root", call_args)
# ---------------------------------------------------------------------------
@@ -196,13 +193,12 @@ class TestExecResultParity(unittest.TestCase):
self.assertIsInstance(result.stdout, str)
self.assertIsInstance(result.stderr, str)
def test_smolmachines_exec_result_shape(self):
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
def test_firecracker_exec_result_shape(self):
from bot_bottle.backend import ExecResult
bottle = SmolmachinesBottle("bot-bottle-test", guest_env={})
with patch("bot_bottle.backend.smolmachines.bottle.subprocess.run",
bottle = _firecracker_bottle({})
with patch("bot_bottle.backend.firecracker.bottle.subprocess.run",
side_effect=self._stub_run):
result = bottle.exec("echo hi")
result = bottle.exec("echo hi") # type: ignore[union-attr]
self.assertIsInstance(result, ExecResult)
self.assertEqual(0, result.returncode)
self.assertIsInstance(result.stdout, str)
@@ -229,11 +225,10 @@ class TestCloseParity(unittest.TestCase):
# DockerBottle.close calls teardown — once per call is fine;
# what matters is it doesn't raise.
def test_smolmachines_close_is_noop(self):
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
bottle = SmolmachinesBottle("bot-bottle-test", guest_env={})
bottle.close()
bottle.close()
def test_firecracker_close_is_noop(self):
bottle = _firecracker_bottle({})
bottle.close() # type: ignore[union-attr]
bottle.close() # type: ignore[union-attr]
if __name__ == "__main__":
+9 -11
View File
@@ -17,7 +17,7 @@ from bot_bottle import supervise
from bot_bottle.backend import BottleSpec
from bot_bottle.backend.docker import DockerBottleBackend
from bot_bottle.backend.resolve_common import mint_slug
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
from bot_bottle.backend.firecracker import FirecrackerBottleBackend
from bot_bottle.manifest import ManifestIndex
@@ -90,30 +90,28 @@ class TestDockerPrepare(_FakeStateMixin, unittest.TestCase):
self.assertNotIn("FORWARDED_ENV", plan.agent_provision.guest_env)
class TestSmolmachinesPrepare(_FakeStateMixin, unittest.TestCase):
class TestFirecrackerPrepare(_FakeStateMixin, unittest.TestCase):
def test_records_backend_and_builds_guest_env(self) -> None:
backend = SmolmachinesBottleBackend()
spec = _spec(Path(self.tmp.name), identity="demo-smol")
backend = FirecrackerBottleBackend()
spec = _spec(Path(self.tmp.name), identity="demo-fc")
with (
patch.dict("os.environ", {"HOST_SECRET_ENV": "secret-value"}),
patch(
"bot_bottle.backend.smolmachines.resolve_plan.smolmachines_preflight",
"bot_bottle.backend.firecracker.resolve_plan.util.require_firecracker",
) as preflight,
):
plan = backend.prepare(spec, Path(self.tmp.name) / "stage")
preflight.assert_called_once_with()
metadata = bottle_state.read_metadata("demo-smol")
metadata = bottle_state.read_metadata("demo-fc")
self.assertIsNotNone(metadata)
assert metadata is not None
self.assertEqual("smolmachines", metadata.backend)
self.assertEqual("literal-value", plan.guest_env["LITERAL_ENV"])
self.assertEqual("secret-value", plan.guest_env["FORWARDED_ENV"])
self.assertEqual("firecracker", metadata.backend)
self.assertEqual(
"/etc/ssl/certs/ca-certificates.crt",
plan.guest_env["SSL_CERT_FILE"],
"literal-value", plan.agent_provision.guest_env["LITERAL_ENV"],
)
self.assertEqual({"FORWARDED_ENV": "secret-value"}, plan.forwarded_env)
class TestMintSlug(unittest.TestCase):
+34 -25
View File
@@ -23,14 +23,14 @@ from bot_bottle.backend import (
class TestGetBottleBackend(unittest.TestCase):
def test_explicit_name_wins_over_env(self):
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "smolmachines"}):
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "firecracker"}):
b = get_bottle_backend("docker")
self.assertEqual("docker", b.name)
def test_env_var_fallback(self):
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "smolmachines"}):
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "firecracker"}):
b = get_bottle_backend()
self.assertEqual("smolmachines", b.name)
self.assertEqual("firecracker", b.name)
def test_default_macos_container_when_available(self):
class _FakeBackend:
@@ -42,12 +42,12 @@ class TestGetBottleBackend(unittest.TestCase):
with patch.dict(os.environ, {}, clear=True), \
patch.object(backend_mod, "_BACKENDS", {
"macos-container": _FakeBackend(),
"smolmachines": _FakeBackend(),
"docker": _FakeBackend(),
}):
b = get_bottle_backend()
self.assertEqual("macos-container", b.name)
def test_default_smolmachines_when_macos_container_unavailable(self):
def test_default_docker_when_no_macos_and_host_not_kvm(self):
class _FakeBackend:
def __init__(self, name: str, available: bool) -> None:
self.name = name
@@ -56,15 +56,19 @@ class TestGetBottleBackend(unittest.TestCase):
def is_available(self) -> bool:
return self._available
# No macOS container and the host can't run firecracker (no
# KVM / not Linux) → docker is the last resort.
with patch.dict(os.environ, {}, clear=True), \
patch.object(backend_mod.FirecrackerBottleBackend,
"is_host_capable", classmethod(lambda cls: False)), \
patch.object(backend_mod, "_BACKENDS", {
"macos-container": _FakeBackend("macos-container", False),
"smolmachines": _FakeBackend("smolmachines", False),
"docker": _FakeBackend("docker", True),
}):
b = get_bottle_backend()
self.assertEqual("smolmachines", b.name)
self.assertEqual("docker", b.name)
def test_default_firecracker_when_macos_unavailable_but_fc_available(self):
def test_default_firecracker_on_kvm_host_even_when_binary_missing(self):
class _FakeBackend:
def __init__(self, name: str, available: bool) -> None:
self.name = name
@@ -73,11 +77,16 @@ class TestGetBottleBackend(unittest.TestCase):
def is_available(self) -> bool:
return self._available
# A KVM-capable Linux host defaults to firecracker even when the
# binary isn't installed (is_available False) — start then prints
# the install pointer instead of falling back to docker.
with patch.dict(os.environ, {}, clear=True), \
patch.object(backend_mod.FirecrackerBottleBackend,
"is_host_capable", classmethod(lambda cls: True)), \
patch.object(backend_mod, "_BACKENDS", {
"macos-container": _FakeBackend("macos-container", False),
"firecracker": _FakeBackend("firecracker", True),
"smolmachines": _FakeBackend("smolmachines", True),
"firecracker": _FakeBackend("firecracker", False),
"docker": _FakeBackend("docker", True),
}):
b = get_bottle_backend()
self.assertEqual("firecracker", b.name)
@@ -91,7 +100,7 @@ class TestGetBottleBackend(unittest.TestCase):
class TestKnownBackendNames(unittest.TestCase):
def test_returns_backends_sorted(self):
self.assertEqual(
("docker", "firecracker", "macos-container", "smolmachines"),
("docker", "firecracker", "macos-container"),
known_backend_names(),
)
@@ -99,8 +108,8 @@ class TestKnownBackendNames(unittest.TestCase):
class TestEnumerateActiveAgents(unittest.TestCase):
"""Combines each backend's `enumerate_active`. Each backend's
implementation has its own tests (`test_docker_enumerate_active`,
`test_smolmachines_*`); this just asserts the aggregator stitches
them together."""
`test_firecracker_backend`); this just asserts the aggregator
stitches them together."""
def test_concatenates_per_backend(self):
a = ActiveAgent(
@@ -108,7 +117,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
started_at="", services=("egress",),
)
b = ActiveAgent(
backend_name="smolmachines", slug="b-2", agent_name="research",
backend_name="firecracker", slug="b-2", agent_name="research",
started_at="", services=(),
)
@@ -125,7 +134,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
with patch.object(
backend_mod, "_BACKENDS",
{"docker": _FakeBackend([a]), "smolmachines": _FakeBackend([b])},
{"docker": _FakeBackend([a]), "firecracker": _FakeBackend([b])},
):
self.assertEqual([a, b], enumerate_active_agents())
@@ -139,11 +148,11 @@ class TestEnumerateActiveAgents(unittest.TestCase):
started_at="2026-06-02T11:00:00Z", services=(),
)
missing_metadata = ActiveAgent(
backend_name="smolmachines", slug="missing-metadata",
backend_name="firecracker", slug="missing-metadata",
agent_name="?", started_at="", services=(),
)
tie_a = ActiveAgent(
backend_name="smolmachines", slug="a-slug", agent_name="research",
backend_name="firecracker", slug="a-slug", agent_name="research",
started_at="2026-06-02T11:00:00Z", services=(),
)
@@ -161,7 +170,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
backend_mod, "_BACKENDS",
{
"docker": _FakeBackend([newer, tie_b]),
"smolmachines": _FakeBackend([missing_metadata, tie_a]),
"firecracker": _FakeBackend([missing_metadata, tie_a]),
},
):
self.assertEqual(
@@ -179,21 +188,21 @@ class TestEnumerateActiveAgents(unittest.TestCase):
with patch.object(
backend_mod, "_BACKENDS",
{"docker": _FakeBackend(), "smolmachines": _FakeBackend()},
{"docker": _FakeBackend(), "firecracker": _FakeBackend()},
):
self.assertEqual([], enumerate_active_agents())
def test_skips_unavailable_backends(self):
# If a backend's runtime isn't installed (smolvm missing on
# a docker-only host, or docker missing on a smolmachines-
# only host), the cross-backend enumerator skips it rather
# than dying — `has_backend` gates the iteration.
# If a backend's runtime isn't installed (docker missing on a
# firecracker host, or KVM missing on a docker-only host), the
# cross-backend enumerator skips it rather than dying —
# `has_backend` gates the iteration.
present = ActiveAgent(
backend_name="docker", slug="a-1", agent_name="impl",
started_at="", services=(),
)
hidden = ActiveAgent(
backend_name="smolmachines", slug="x", agent_name="x",
backend_name="firecracker", slug="x", agent_name="x",
started_at="", services=(),
)
@@ -212,7 +221,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
backend_mod, "_BACKENDS",
{
"docker": _FakeBackend([present], available=True),
"smolmachines": _FakeBackend([hidden], available=False),
"firecracker": _FakeBackend([hidden], available=False),
},
):
self.assertEqual([present], enumerate_active_agents())
+2 -2
View File
@@ -34,7 +34,7 @@ class TestPalettePrintf(unittest.TestCase):
class TestExecShellScript(unittest.TestCase):
_ARGV = ["smolvm", "machine", "exec", "--name", "x", "--", "claude"]
_ARGV = ["ssh", "-t", "100.64.0.1", "--", "claude"]
def test_no_decoration_returns_none(self):
self.assertIsNone(exec_shell_script(self._ARGV))
@@ -58,7 +58,7 @@ class TestExecShellScript(unittest.TestCase):
self.assertIn("\\033]111", script) # background reset
# No exec-replace when palette is active (shell must survive for reset)
parts = script.split("; ")
agent_part = next(p for p in parts if "smolvm" in p)
agent_part = next(p for p in parts if "ssh" in p)
self.assertFalse(agent_part.startswith("exec "))
def test_title_and_color_both_appear(self):
+7 -7
View File
@@ -16,7 +16,7 @@ from bot_bottle import bottle_state
from bot_bottle import supervise
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker import DockerBottleBackend
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
from bot_bottle.backend.firecracker import FirecrackerBottleBackend
from bot_bottle.manifest import ManifestIndex
@@ -114,13 +114,13 @@ class TestRuntimeWorkspaceProvisioning(_FakeStateMixin, unittest.TestCase):
bottle.exec.assert_not_called()
bottle.cp_in.assert_not_called()
def test_smolmachines_uses_same_running_bottle_method(self) -> None:
backend = SmolmachinesBottleBackend()
def test_firecracker_uses_same_running_bottle_method(self) -> None:
backend = FirecrackerBottleBackend()
with patch(
"bot_bottle.backend.smolmachines.resolve_plan.smolmachines_preflight",
"bot_bottle.backend.firecracker.resolve_plan.util.require_firecracker",
):
plan = backend.prepare(
_spec(self.tmp, identity="demo-smol-work"),
_spec(self.tmp, identity="demo-fc-work"),
self.tmp / "stage",
)
@@ -128,10 +128,10 @@ class TestRuntimeWorkspaceProvisioning(_FakeStateMixin, unittest.TestCase):
backend.provision_workspace(plan, bottle)
bottle.cp_in.assert_called_once_with(str(self.tmp), "/home/node/workspace")
metadata = bottle_state.read_metadata("demo-smol-work")
metadata = bottle_state.read_metadata("demo-fc-work")
self.assertIsNotNone(metadata)
assert metadata is not None
self.assertEqual("smolmachines", metadata.backend)
self.assertEqual("firecracker", metadata.backend)
class TestWorkspaceTrustPath(_FakeStateMixin, unittest.TestCase):
+3 -3
View File
@@ -241,7 +241,7 @@ class TestBottleMetadataBackend(_FakeHomeMixin, unittest.TestCase):
assert loaded is not None
self.assertEqual("docker", loaded.backend)
def test_backend_field_roundtrips_smolmachines(self):
def test_backend_field_roundtrips_firecracker(self):
meta = BottleMetadata(
identity="dev-b2",
agent_name="dev",
@@ -249,13 +249,13 @@ class TestBottleMetadataBackend(_FakeHomeMixin, unittest.TestCase):
copy_cwd=False,
started_at="2026-06-02T00:00:00+00:00",
compose_project="",
backend="smolmachines",
backend="firecracker",
)
write_metadata(meta)
loaded = read_metadata("dev-b2")
self.assertIsNotNone(loaded)
assert loaded is not None
self.assertEqual("smolmachines", loaded.backend)
self.assertEqual("firecracker", loaded.backend)
def test_missing_backend_field_defaults_to_empty(self):
# Old state dirs written before PRD 0040 have no backend key.
+18 -18
View File
@@ -23,12 +23,12 @@ def _make_backend(empty: bool = True):
class TestCmdCleanup(unittest.TestCase):
def test_iterates_every_backend(self):
docker, docker_plan = _make_backend(empty=False)
smol, smol_plan = _make_backend(empty=False)
backends_by_name = {"docker": docker, "smolmachines": smol}
fc, fc_plan = _make_backend(empty=False)
backends_by_name = {"docker": docker, "firecracker": fc}
with patch.object(
cmd, "known_backend_names",
return_value=("docker", "smolmachines"),
return_value=("docker", "firecracker"),
), patch.object(
cmd, "get_bottle_backend",
side_effect=lambda name: backends_by_name[name], # type: ignore
@@ -38,18 +38,18 @@ class TestCmdCleanup(unittest.TestCase):
self.assertEqual(0, cmd.cmd_cleanup([]))
docker.prepare_cleanup.assert_called_once()
smol.prepare_cleanup.assert_called_once()
fc.prepare_cleanup.assert_called_once()
docker.cleanup.assert_called_once_with(docker_plan)
smol.cleanup.assert_called_once_with(smol_plan)
fc.cleanup.assert_called_once_with(fc_plan)
def test_short_circuits_when_all_empty(self):
docker, _ = _make_backend(empty=True)
smol, _ = _make_backend(empty=True)
backends_by_name = {"docker": docker, "smolmachines": smol}
fc, _ = _make_backend(empty=True)
backends_by_name = {"docker": docker, "firecracker": fc}
with patch.object(
cmd, "known_backend_names",
return_value=("docker", "smolmachines"),
return_value=("docker", "firecracker"),
), patch.object(
cmd, "get_bottle_backend",
side_effect=lambda name: backends_by_name[name], # type: ignore
@@ -59,16 +59,16 @@ class TestCmdCleanup(unittest.TestCase):
self.assertEqual(0, cmd.cmd_cleanup([]))
prompt.assert_not_called()
docker.cleanup.assert_not_called()
smol.cleanup.assert_not_called()
fc.cleanup.assert_not_called()
def test_abort_at_prompt_runs_nothing(self):
docker, _ = _make_backend(empty=False)
smol, _ = _make_backend(empty=True)
backends_by_name = {"docker": docker, "smolmachines": smol}
fc, _ = _make_backend(empty=True)
backends_by_name = {"docker": docker, "firecracker": fc}
with patch.object(
cmd, "known_backend_names",
return_value=("docker", "smolmachines"),
return_value=("docker", "firecracker"),
), patch.object(
cmd, "get_bottle_backend",
side_effect=lambda name: backends_by_name[name], # type: ignore
@@ -77,18 +77,18 @@ class TestCmdCleanup(unittest.TestCase):
):
self.assertEqual(0, cmd.cmd_cleanup([]))
docker.cleanup.assert_not_called()
smol.cleanup.assert_not_called()
fc.cleanup.assert_not_called()
def test_skips_empty_plans_when_others_have_work(self):
# docker has work, smolmachines doesn't — only docker.cleanup
# docker has work, firecracker doesn't — only docker.cleanup
# is called.
docker, docker_plan = _make_backend(empty=False)
smol, _ = _make_backend(empty=True)
backends_by_name = {"docker": docker, "smolmachines": smol}
fc, _ = _make_backend(empty=True)
backends_by_name = {"docker": docker, "firecracker": fc}
with patch.object(
cmd, "known_backend_names",
return_value=("docker", "smolmachines"),
return_value=("docker", "firecracker"),
), patch.object(
cmd, "get_bottle_backend",
side_effect=lambda name: backends_by_name[name], # type: ignore
@@ -97,7 +97,7 @@ class TestCmdCleanup(unittest.TestCase):
):
cmd.cmd_cleanup([])
docker.cleanup.assert_called_once_with(docker_plan)
smol.cleanup.assert_not_called()
fc.cleanup.assert_not_called()
if __name__ == "__main__":
+3 -3
View File
@@ -83,9 +83,9 @@ class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
mock_gf.assert_called_once_with("macos-container")
mock_freezer.commit_slug.assert_called_once_with(slug)
def test_commits_smolmachines_bottle(self):
def test_commits_firecracker_bottle(self):
slug = "dev-abc12"
self._write_meta(slug, "smolmachines")
self._write_meta(slug, "firecracker")
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
mock_freezer = MagicMock()
@@ -93,7 +93,7 @@ class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
rc = cmd_commit([slug])
self.assertEqual(0, rc)
mock_gf.assert_called_once_with("smolmachines")
mock_gf.assert_called_once_with("firecracker")
def test_returns_zero_on_commit_cancelled(self):
slug = "dev-abc12"
+3 -3
View File
@@ -35,8 +35,8 @@ class TestStartBackendFlag(unittest.TestCase):
return parser
def test_flag_recognized(self):
args = self._build_parser().parse_args(["--backend=smolmachines", "researcher"])
self.assertEqual("smolmachines", args.backend)
args = self._build_parser().parse_args(["--backend=firecracker", "researcher"])
self.assertEqual("firecracker", args.backend)
self.assertEqual("researcher", args.name)
def test_flag_default_none_means_env_or_default_backend(self):
@@ -53,7 +53,7 @@ class TestStartBackendFlag(unittest.TestCase):
# `--backend` ultimately threads to) prefers the explicit
# name over BOT_BOTTLE_BACKEND.
from bot_bottle.backend import get_bottle_backend
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "smolmachines"}):
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "firecracker"}):
self.assertEqual("docker", get_bottle_backend("docker").name)
+7 -7
View File
@@ -99,25 +99,25 @@ class TestOrphanStateDirs(_FakeHomeMixin, unittest.TestCase):
def test_protected_identity_skips_dir(self):
# `protected_identities` carries slugs that are live in
# any backend (smolmachines included). docker's orphan
# detection respects them so a running smolmachines
# any backend (firecracker included). docker's orphan
# detection respects them so a running firecracker
# bottle's state dir isn't reaped while the VM is up.
bottle_state.write_per_bottle_dockerfile("smol-hhh", "FROM x\n")
bottle_state.write_per_bottle_dockerfile("fc-hhh", "FROM x\n")
self.assertEqual(
[],
_list_orphan_state_dirs(set(), {"smol-hhh"}),
_list_orphan_state_dirs(set(), {"fc-hhh"}),
)
def test_protected_overrides_no_live_project(self):
# A smolmachines bottle has no docker compose project but
# A firecracker bottle has no docker compose project but
# IS in the protected set; the absence of a project
# shouldn't cause a reap.
bottle_state.write_per_bottle_dockerfile("smol-iii", "FROM x\n")
bottle_state.write_per_bottle_dockerfile("fc-iii", "FROM x\n")
self.assertEqual(
[],
_list_orphan_state_dirs(
{"bot-bottle-something-else"}, # different project up
{"smol-iii"}, # but smol-iii is live
{"fc-iii"}, # but fc-iii is live
),
)
+1 -42
View File
@@ -1,4 +1,4 @@
"""Unit: image_id / tag / push helpers in
"""Unit: commit_container helper in
bot_bottle.backend.docker.util (PRD 0023 chunk 4c additions).
Tests mock `subprocess.run` and assert on argv shape + parsing.
@@ -26,47 +26,6 @@ def _fail(stderr: str = "boom") -> subprocess.CompletedProcess: # type: ignore
)
class TestImageId(unittest.TestCase):
def test_strips_trailing_newline(self):
# docker image inspect --format ... emits a trailing newline.
with patch.object(
docker_mod.subprocess, "run",
return_value=_ok(stdout="sha256:abcdef\n"),
) as run:
self.assertEqual(
"sha256:abcdef", docker_mod.image_id("bot-bottle-claude:latest")
)
argv = run.call_args.args[0]
self.assertEqual(
["docker", "image", "inspect", "--format", "{{.Id}}", "bot-bottle-claude:latest"],
argv,
)
def test_dies_on_inspect_failure(self):
with patch.object(
docker_mod.subprocess, "run", return_value=_fail("No such image"),
), patch.object(
docker_mod, "die", side_effect=SystemExit("die"),
) as die:
with self.assertRaises(SystemExit):
docker_mod.image_id("missing:tag")
die.assert_called_once()
self.assertIn("missing:tag", die.call_args.args[0])
class TestSave(unittest.TestCase):
def test_save_runs_docker_save(self):
with patch.object(
docker_mod.subprocess, "run", return_value=_ok(),
) as run:
docker_mod.save("bot-bottle-claude:latest", "/tmp/img.tar")
argv = run.call_args.args[0]
self.assertEqual(
["docker", "save", "bot-bottle-claude:latest", "-o", "/tmp/img.tar"],
argv,
)
class TestCommitContainer(unittest.TestCase):
def test_runs_docker_commit(self):
with patch.object(
+3 -3
View File
@@ -1,7 +1,7 @@
"""Unit: egress_entrypoint.sh argv construction (PRD 0023 chunk 3).
The egress entrypoint is a small POSIX-sh script that builds
the mitmdump argv from env vars. The smolmachines backend
the mitmdump argv from env vars. A VM backend (e.g. firecracker)
controls egress's bind address via EGRESS_LISTEN_HOST; the
docker backend leaves it unset and gets mitmdump's default
(all interfaces).
@@ -67,7 +67,7 @@ class TestEgressEntrypointArgv(unittest.TestCase):
)
def test_listen_host_127_0_0_1_emits_flag(self):
# smolmachines backend sets EGRESS_LISTEN_HOST=127.0.0.1
# A VM backend sets EGRESS_LISTEN_HOST=127.0.0.1
# to scope egress to localhost inside the bundle.
argv = _run_entrypoint({"EGRESS_LISTEN_HOST": "127.0.0.1"})
self.assertIn("--listen-host\n127.0.0.1", argv)
@@ -79,7 +79,7 @@ class TestEgressEntrypointArgv(unittest.TestCase):
self.assertNotIn("--listen-host", argv)
def test_upstream_mode_combined_with_listen_host(self):
# smolmachines mode also sets EGRESS_UPSTREAM_PROXY so
# VM-backend mode also sets EGRESS_UPSTREAM_PROXY so
# both flags should compose correctly.
argv = _run_entrypoint({
"EGRESS_UPSTREAM_PROXY": "http://192.168.50.2:8888",
+14 -17
View File
@@ -1,4 +1,4 @@
"""Unit: BottlePlan.print parity across Docker and smolmachines (PRD 0044).
"""Unit: BottlePlan.print parity across Docker and firecracker (PRD 0044).
Both backends inherit a single concrete print() from BottlePlan. These
tests verify that identical git_gate_plan and egress_plan inputs produce
@@ -16,7 +16,7 @@ from pathlib import Path
from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.backend import BottleSpec
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan
from bot_bottle.backend.firecracker.bottle_plan import FirecrackerBottlePlan
from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import Manifest, ManifestIndex
@@ -107,9 +107,9 @@ def _docker_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> DockerBottle
)
def _smolmachines_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> SmolmachinesBottlePlan:
def _firecracker_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> FirecrackerBottlePlan:
stage = Path(tmp)
return SmolmachinesBottlePlan(
return FirecrackerBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=stage,
@@ -118,14 +118,11 @@ def _smolmachines_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> Smolma
supervise_plan=None,
agent_provision=_agent_provision(tmp),
slug="test-00001",
bundle_subnet="10.99.0.0/24",
bundle_gateway="10.99.0.1",
bundle_ip="10.99.0.2",
guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"},
forwarded_env={},
)
def _capture_print(plan: DockerBottlePlan | SmolmachinesBottlePlan) -> list[str]:
def _capture_print(plan: DockerBottlePlan | FirecrackerBottlePlan) -> list[str]:
buf = io.StringIO()
orig = sys.stderr
sys.stderr = buf
@@ -144,7 +141,7 @@ class TestGitGatePrintParity(unittest.TestCase):
manifest = _INDEX.load_for_agent("demo")
spec = _spec(_INDEX, self._tmp)
self._docker_lines = _capture_print(_docker_plan(spec, manifest, self._tmp))
self._smol_lines = _capture_print(_smolmachines_plan(spec, manifest, self._tmp))
self._fc_lines = _capture_print(_firecracker_plan(spec, manifest, self._tmp))
def _git_gate_lines(self, lines: list[str]) -> list[str]:
return [ln for ln in lines if "git gate" in ln]
@@ -154,15 +151,15 @@ class TestGitGatePrintParity(unittest.TestCase):
self.assertEqual(1, len(git_lines))
self.assertIn("myrepo → gitea.example.com:30009", git_lines[0])
def test_smolmachines_renders_name_arrow_host_port(self) -> None:
git_lines = self._git_gate_lines(self._smol_lines)
def test_firecracker_renders_name_arrow_host_port(self) -> None:
git_lines = self._git_gate_lines(self._fc_lines)
self.assertEqual(1, len(git_lines))
self.assertIn("myrepo → gitea.example.com:30009", git_lines[0])
def test_git_gate_lines_match_across_backends(self) -> None:
self.assertEqual(
self._git_gate_lines(self._docker_lines),
self._git_gate_lines(self._smol_lines),
self._git_gate_lines(self._fc_lines),
)
@@ -174,7 +171,7 @@ class TestEgressPrintParity(unittest.TestCase):
manifest = _INDEX.load_for_agent("demo")
spec = _spec(_INDEX, self._tmp)
self._docker_lines = _capture_print(_docker_plan(spec, manifest, self._tmp))
self._smol_lines = _capture_print(_smolmachines_plan(spec, manifest, self._tmp))
self._fc_lines = _capture_print(_firecracker_plan(spec, manifest, self._tmp))
def _egress_section(self, lines: list[str]) -> list[str]:
"""Return lines from the egress label through the last route entry.
@@ -210,8 +207,8 @@ class TestEgressPrintParity(unittest.TestCase):
combined = "\n".join(self._egress_section(self._docker_lines))
self.assertIn("api.example.com [auth:bearer]", combined)
def test_smolmachines_includes_auth_annotation(self) -> None:
combined = "\n".join(self._egress_section(self._smol_lines))
def test_firecracker_includes_auth_annotation(self) -> None:
combined = "\n".join(self._egress_section(self._fc_lines))
self.assertIn("api.example.com [auth:bearer]", combined)
def test_unauthenticated_route_has_no_annotation(self) -> None:
@@ -222,7 +219,7 @@ class TestEgressPrintParity(unittest.TestCase):
def test_egress_lines_match_across_backends(self) -> None:
self.assertEqual(
self._egress_section(self._docker_lines),
self._egress_section(self._smol_lines),
self._egress_section(self._fc_lines),
)
+4 -3
View File
@@ -53,15 +53,16 @@ class TestGitGateGitconfigRender(unittest.TestCase):
self.assertNotIn("pushInsteadOf", out)
def test_gate_host_can_be_ip_port_form(self):
# The smolmachines backend's TSI-allowlisted guest has no
# DNS, so it dials git-gate via `<bundle_ip>:<port>`.
# A VM backend's guest may have no DNS (e.g. firecracker over
# the point-to-point TAP), so it dials git-gate via
# `<bundle_ip>:<port>`.
bottle = fixture_with_git().bottles["dev"]
out = git_gate_render_gitconfig(bottle.git, "192.168.20.2:9418")
self.assertIn(
'[url "git://192.168.20.2:9418/bot-bottle.git"]', out,
)
def test_scheme_can_be_http_for_smolmachines(self):
def test_scheme_can_be_http_for_vm_backend(self):
bottle = fixture_with_git().bottles["dev"]
out = git_gate_render_gitconfig(
bottle.git, "127.0.0.16:57001", scheme="http",
-195
View File
@@ -1,195 +0,0 @@
"""Unit: SmolmachinesBottle's `agent_argv` builder.
The dashboard's tmux pane-respawn path calls `bottle.agent_argv`
directly (it spawns claude inside a tmux pane rather than as a
child of the current process), so the argv shape is the
non-trivial part. `exec_agent` is a thin wrapper around the same
builder + `subprocess.run`; we lock the shape here.
The TTY-mode argv is wrapped in the pty_resize helper (issue #82
workaround); we assert both the wrapper presence and the wrapped
smolvm argv shape. Non-TTY mode skips the wrapper.
"""
from __future__ import annotations
import sys
import unittest
from bot_bottle.backend.smolmachines import pty_resize as _pty_resize
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
def _bottle(prompt_path: str | None = None, **env: str) -> SmolmachinesBottle:
return SmolmachinesBottle(
"bot-bottle-dev-abc",
prompt_path=prompt_path,
guest_env=env,
)
def _pi_bottle(prompt_path: str | None = None) -> SmolmachinesBottle:
return SmolmachinesBottle(
"bot-bottle-dev-abc",
prompt_path=prompt_path,
agent_command="pi",
agent_prompt_mode="append_system_prompt",
)
def _workspace_bottle() -> SmolmachinesBottle:
return SmolmachinesBottle(
"bot-bottle-dev-abc",
prompt_path=None,
agent_workdir="/home/node/workspace",
)
def _unwrap(argv: list[str]) -> list[str]:
"""Strip the pty_resize wrapper from the front of a TTY-mode
argv, return the inner smolvm argv. Mirrors what the kernel
sees inside the wrapper's `subprocess.Popen`."""
idx = argv.index("--")
return argv[idx + 1:]
class TestClaudeArgvWrapped(unittest.TestCase):
"""TTY-mode argv: pty_resize wrapper + inner smolvm exec."""
def test_pty_resize_wrapper_prefix(self):
argv = _bottle().agent_argv([])
# Absolute script path (not `-m <dotted>`) so the tmux
# pane's cwd doesn't matter — see the `_PTY_RESIZE_SCRIPT`
# docstring in bottle.py.
self.assertEqual(
[
sys.executable, _pty_resize.__file__,
"bot-bottle-dev-abc", "--",
],
argv[:4],
)
def test_minimal_inner_argv_no_prompt(self):
argv = _unwrap(_bottle().agent_argv([]))
self.assertEqual(
[
"smolvm", "machine", "exec", "--name",
"bot-bottle-dev-abc",
"-i", "-t",
"--",
"runuser", "-u", "node", "--",
"env", "HOME=/home/node", "USER=node",
(
"PATH=/home/node/.local/bin:"
"/home/node/.codex/packages/standalone/current/bin:"
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
),
"claude",
],
argv,
)
def test_appends_passed_args_after_claude(self):
argv = _unwrap(_bottle().agent_argv(
["--dangerously-skip-permissions", "--continue"],
))
self.assertEqual(
["claude", "--dangerously-skip-permissions", "--continue"],
argv[argv.index("claude"):],
)
def test_appends_prompt_file_flag_when_set(self):
argv = _unwrap(
_bottle("/home/node/.bot-bottle-prompt.txt").agent_argv(
["--dangerously-skip-permissions"],
)
)
self.assertEqual(
[
"claude",
"--append-system-prompt-file",
"/home/node/.bot-bottle-prompt.txt",
"--dangerously-skip-permissions",
],
argv[argv.index("claude"):],
)
def test_no_prompt_flag_when_none(self):
argv = _bottle(None).agent_argv(["--continue"])
self.assertNotIn("--append-system-prompt-file", argv)
def test_empty_prompt_string_is_treated_as_no_prompt(self):
argv = _bottle("").agent_argv(["--continue"])
self.assertNotIn("--append-system-prompt-file", argv)
def test_guest_env_forwarded_as_e_flags(self):
argv = _unwrap(_bottle(
None,
HTTPS_PROXY="http://127.0.0.1:1234",
NO_PROXY="localhost",
).agent_argv([]))
self.assertIn("env", argv)
self.assertIn("HTTPS_PROXY=http://127.0.0.1:1234", argv)
self.assertIn("NO_PROXY=localhost", argv)
def test_guest_env_path_overrides_default_path(self):
argv = _unwrap(_bottle(None, PATH="/custom/bin").agent_argv([]))
self.assertIn("PATH=/custom/bin", argv)
self.assertFalse(any(
item.startswith("PATH=/home/node/.local/bin")
for item in argv
))
def test_runuser_switch_precedes_claude(self):
# The dashboard's `_build_resume_argv_with_fallback` finds
# the `claude` token to split exec-framing from the claude
# tail. `runuser -u node --` must sit on the prefix side so
# the shell wrap inherits the UID switch.
argv = _bottle().agent_argv([])
runuser_idx = argv.index("runuser")
self.assertEqual(
["runuser", "-u", "node", "--", "env"],
argv[runuser_idx:runuser_idx + 5],
)
def test_pi_provider_appends_system_prompt_without_print_mode(self):
argv = _unwrap(
_pi_bottle("/home/node/.bot-bottle-prompt.txt").agent_argv([])
)
self.assertEqual(
["pi", "--append-system-prompt", "/home/node/.bot-bottle-prompt.txt"],
argv[argv.index("pi"):],
)
self.assertNotIn("-p", argv)
def test_workspace_workdir_wraps_agent_command(self):
argv = _unwrap(_workspace_bottle().agent_argv([]))
agent_idx = argv.index("claude")
self.assertEqual(
[
"sh", "-lc",
"cd /home/node/workspace && exec \"$@\"",
"bot-bottle-agent",
"claude",
],
argv[agent_idx - 4:agent_idx + 1],
)
class TestClaudeArgvNoTTY(unittest.TestCase):
"""`tty=False` paths skip the pty_resize wrapper — there's no
PTY whose SIGWINCH we'd need to bridge."""
def test_no_wrapper_when_tty_false(self):
argv = _bottle().agent_argv([], tty=False)
self.assertEqual("smolvm", argv[0])
self.assertFalse(any("pty_resize" in a for a in argv))
def test_tty_false_drops_it_flags(self):
argv = _bottle().agent_argv([], tty=False)
self.assertNotIn("-i", argv)
self.assertNotIn("-t", argv)
if __name__ == "__main__":
unittest.main()
-150
View File
@@ -1,150 +0,0 @@
"""Unit: smolmachines backend cleanup (`cleanup.py` +
`bottle_cleanup_plan.py`).
Tests mock `subprocess.run` + `has_backend` so they execute
without docker / smolvm on PATH. Each cleanup step verifies argv
shape; teardown verifies order (machines → bundles → networks)."""
from __future__ import annotations
import subprocess
import unittest
from unittest.mock import patch
from bot_bottle import backend as backend_mod
from bot_bottle.backend.smolmachines import cleanup
from bot_bottle.backend.smolmachines.bottle_cleanup_plan import (
SmolmachinesBottleCleanupPlan,
)
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess: # type: ignore
return subprocess.CompletedProcess(
args=[], returncode=0, stdout=stdout, stderr=stderr,
)
class TestPrepareCleanup(unittest.TestCase):
def test_empty_when_nothing_running(self):
with patch.object(cleanup, "_smolvm") as smolvm, \
patch.object(cleanup.subprocess, "run") as run, \
patch.object(backend_mod, "has_backend", return_value=True):
smolvm.is_available.return_value = True
run.return_value = _ok(stdout="[]")
plan = cleanup.prepare_cleanup()
self.assertTrue(plan.empty)
def test_lists_machines_bundles_networks(self):
def fake_run(argv, *a, **kw): # type: ignore
if argv[:3] == ["smolvm", "machine", "ls"]:
return _ok(stdout=(
'[{"name":"bot-bottle-a-1","state":"running"},'
' {"name":"bot-bottle-b-2","state":"created"},'
' {"name":"unrelated","state":"running"}]'
))
if argv[:2] == ["docker", "ps"]:
return _ok(stdout=(
"bot-bottle-sidecars-a-1\n"
"bot-bottle-sidecars-b-2\n"
))
if argv[:3] == ["docker", "network", "ls"]:
return _ok(stdout=(
"bot-bottle-bundle-a-1\n"
"bot-bottle-bundle-b-2\n"
))
return _ok()
with patch.object(cleanup, "_smolvm") as smolvm, \
patch.object(cleanup.subprocess, "run", side_effect=fake_run), \
patch.object(backend_mod, "has_backend", return_value=True):
smolvm.is_available.return_value = True
plan = cleanup.prepare_cleanup()
# `unrelated` filtered out (no bot-bottle- prefix).
self.assertEqual(
("bot-bottle-a-1", "bot-bottle-b-2"),
plan.machines,
)
self.assertEqual(
("bot-bottle-sidecars-a-1", "bot-bottle-sidecars-b-2"),
plan.bundles,
)
self.assertEqual(
("bot-bottle-bundle-a-1", "bot-bottle-bundle-b-2"),
plan.networks,
)
def test_no_smolvm_means_no_machines(self):
with patch.object(cleanup, "_smolvm") as smolvm, \
patch.object(cleanup.subprocess, "run", return_value=_ok()), \
patch.object(backend_mod, "has_backend", return_value=True):
smolvm.is_available.return_value = False
plan = cleanup.prepare_cleanup()
self.assertEqual((), plan.machines)
class TestCleanup(unittest.TestCase):
def test_machines_stopped_then_deleted_then_bundles_then_networks(self):
plan = SmolmachinesBottleCleanupPlan(
machines=("bot-bottle-a-1",),
bundles=("bot-bottle-sidecars-a-1",),
networks=("bot-bottle-bundle-a-1",),
)
calls: list[list[str]] = []
def fake_run(argv, *a, **kw): # type: ignore
calls.append(list(argv[:4]))
return _ok()
with patch.object(cleanup.subprocess, "run", side_effect=fake_run):
cleanup.cleanup(plan)
# Stop precedes delete precedes bundle rm precedes network rm.
self.assertEqual(
["smolvm", "machine", "stop", "--name"], calls[0],
)
self.assertEqual(
["smolvm", "machine", "delete", "-f"], calls[1],
)
self.assertEqual(
["docker", "rm", "-f", "bot-bottle-sidecars-a-1"], calls[2],
)
self.assertEqual(
["docker", "network", "rm", "bot-bottle-bundle-a-1"], calls[3],
)
def test_failures_are_warnings_not_fatal(self):
# smolvm machine delete -f returning non-zero should warn
# but continue with bundles + networks. The cleanup is
# idempotent on success and tries to remove every resource.
plan = SmolmachinesBottleCleanupPlan(
machines=("bot-bottle-a-1",),
bundles=("bot-bottle-sidecars-a-1",),
networks=(),
)
results = iter([
_ok(), # stop succeeds
subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr="boom"
), # delete fails
_ok(), # bundle rm succeeds
])
def fake_run(argv, *a, **kw): # type: ignore
return next(results)
with patch.object(cleanup.subprocess, "run", side_effect=fake_run), \
patch.object(cleanup, "warn") as warn:
cleanup.cleanup(plan)
# warn called once for the delete failure.
warn.assert_called_once()
def test_empty_plan_is_noop(self):
plan = SmolmachinesBottleCleanupPlan()
with patch.object(cleanup.subprocess, "run") as run:
cleanup.cleanup(plan)
run.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -1,192 +0,0 @@
"""Unit: smolmachines `_ensure_smolmachine` agent-image pipeline
(PRD 0023 chunk 4c).
Asserts that the cache-hit path returns without touching the
registry / pack pipeline, and that the cache-miss path runs
build → tag → push → pack in order against a registry port the
helper yields.
The pipeline lives in `launch.py` (moved from `prepare.py` so the
docker build doesn't run before the dashboard's preflight modal;
the curses-endwin / tmux pane-routing handoff happens around
`launch`)."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import patch
from bot_bottle.backend.smolmachines import launch as _launch_mod
class TestEnsureSmolmachine(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-cache.")
self._cache_patch = patch.object(
_launch_mod, "_SMOLMACHINE_CACHE_DIR", Path(self._tmp.name),
)
self._cache_patch.start()
def tearDown(self):
self._cache_patch.stop()
self._tmp.cleanup()
def test_cache_hit_skips_registry_and_pack(self):
# Pre-populate the cache for image id `sha256:abcdef0123456789...`.
digest = "abcdef0123456789"
sidecar = Path(self._tmp.name) / f"{digest}.smolmachine.smolmachine"
sidecar.write_text("")
with patch.object(
_launch_mod.docker_mod, "build_image",
) as build, patch.object(
_launch_mod.docker_mod, "image_id",
return_value=f"sha256:{digest}fffffffffffffffff",
), patch.object(
_launch_mod.docker_mod, "save",
) as save, patch.object(
_launch_mod, "ephemeral_registry",
) as registry, patch.object(
_launch_mod, "crane_push_tarball",
) as push, patch.object(
_launch_mod._smolvm, "pack_create",
) as pack:
result = _launch_mod._ensure_smolmachine("bot-bottle-claude:latest")
self.assertEqual(sidecar, result)
# build still runs (Dockerfile edits land without manual rmi).
build.assert_called_once()
# No save (500MB tarball), no registry, no push, no pack on
# cache hit.
save.assert_not_called()
registry.assert_not_called()
push.assert_not_called()
pack.assert_not_called()
def test_cache_miss_runs_build_save_push_pack_in_order(self):
digest = "0123456789abcdef"
# ephemeral_registry yields a RegistryHandle with the
# docker network + a push endpoint (container DNS) and
# pull endpoint (host port-forward).
from bot_bottle.backend.smolmachines.local_registry import (
RegistryHandle,
)
class _Reg:
def __enter__(self_inner): # type: ignore
return RegistryHandle(
network="cb-net-xyz",
push_endpoint="cb-registry-xyz:5000",
pull_endpoint="localhost:54321",
)
def __exit__(self_inner, *exc): # type: ignore
return False
calls: list[str] = []
def record(name): # type: ignore
def _f(*a, **kw): # type: ignore
calls.append(name)
return _f
def save_and_record(image_ref: str, path: str) -> None:
Path(path).touch()
calls.append("save")
with patch.object(
_launch_mod.docker_mod, "build_image",
side_effect=record("build"),
), patch.object(
_launch_mod.docker_mod, "image_id",
return_value=f"sha256:{digest}fffffffffffffffff",
), patch.object(
_launch_mod.docker_mod, "save",
side_effect=save_and_record,
) as save, patch.object(
_launch_mod, "ephemeral_registry",
return_value=_Reg(),
), patch.object(
_launch_mod, "crane_push_tarball",
side_effect=record("push"),
) as push, patch.object(
_launch_mod._smolvm, "pack_create",
side_effect=record("pack"),
) as pack:
_launch_mod._ensure_smolmachine("bot-bottle-claude:latest")
# Build → save → push → pack in that order. No `docker
# push` (the daemon's HTTPS-by-default path is what we're
# sidestepping).
self.assertEqual(["build", "save", "push", "pack"], calls)
# docker save targets a per-digest tarball alongside the
# cached sidecar.
save_args = save.call_args.args
self.assertEqual("bot-bottle-claude:latest", save_args[0])
self.assertTrue(save_args[1].endswith(f"{digest}.image.tar"))
# crane push runs against the push_endpoint (container DNS
# on the registry network) with the digest as the tag.
push_args = push.call_args.args
self.assertEqual(
f"cb-registry-xyz:5000/bot-bottle:{digest}", push_args[2],
)
# pack_create reads from the pull_endpoint (host port-
# forward, smolvm is on the host). Same repo+tag, just a
# different routing hostname — the registry stores one blob.
pack_args = pack.call_args.args
self.assertEqual(
f"localhost:54321/bot-bottle:{digest}", pack_args[0],
)
self.assertTrue(str(pack_args[1]).endswith(f"{digest}.smolmachine"))
class TestAgentFromPath(unittest.TestCase):
def _plan(self) -> Any:
return cast(Any, SimpleNamespace(
slug="dev-abc12",
agent_image="bot-bottle-claude:latest",
agent_dockerfile_path="/repo/Dockerfile",
))
def test_uses_committed_artifact_when_present(self):
with tempfile.TemporaryDirectory(prefix="committed-smolmachine.") as tmp:
artifact = Path(tmp) / "committed-smolmachine.smolmachine"
artifact.write_text("")
with patch.object(
_launch_mod, "read_committed_image", return_value=str(artifact),
), patch.object(
_launch_mod, "_ensure_smolmachine",
) as ensure, patch.object(
_launch_mod, "info",
):
result = _launch_mod._agent_from_path(self._plan())
self.assertEqual(artifact, result)
ensure.assert_not_called()
def test_falls_back_when_committed_artifact_missing(self):
packed = Path("/cache/agent.smolmachine")
with patch.object(
_launch_mod, "read_committed_image",
return_value="/missing/committed.smolmachine",
), patch.object(
_launch_mod, "_ensure_smolmachine", return_value=packed,
) as ensure:
result = _launch_mod._agent_from_path(self._plan())
self.assertEqual(packed, result)
ensure.assert_called_once_with(
"bot-bottle-claude:latest",
dockerfile="/repo/Dockerfile",
)
if __name__ == "__main__":
unittest.main()
@@ -1,250 +0,0 @@
"""Unit: ephemeral local-registry helper (PRD 0023 chunk 4c).
The helper brings up a `registry:2.8.3` container on a private
docker network with a random host-side port, yields a
`RegistryHandle`, and tears the container + network down on exit.
Tests mock `subprocess.run` + `socket.create_connection` so they
run without docker."""
from __future__ import annotations
import subprocess
import unittest
from unittest.mock import patch
from bot_bottle.backend.smolmachines import local_registry
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess: # type: ignore
return subprocess.CompletedProcess(
args=[], returncode=0, stdout=stdout, stderr=stderr,
)
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess: # type: ignore
return subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr=stderr,
)
# Run sequence per ephemeral_registry() call:
# docker network create -> ok
# docker run -d (registry) -> ok (container id)
# docker port (host port) -> ok (mapping line)
# docker rm -f (registry) -> ok (in finally)
# docker network rm -> ok (in finally)
def _stock_run_sequence(port_line: str = "0.0.0.0:54321\n"):
return [
_ok(), # docker network create
_ok(stdout="<container-id>\n"), # docker run
_ok(stdout=port_line), # docker port
_ok(), # docker rm -f
_ok(), # docker network rm
]
class TestEphemeralRegistry(unittest.TestCase):
def test_yields_handle_with_network_and_endpoints(self):
with patch.object(
local_registry.subprocess, "run",
side_effect=_stock_run_sequence(),
) as run, patch.object(
local_registry.socket, "create_connection",
return_value=_FakeSocket(),
):
with local_registry.ephemeral_registry() as handle:
# push_endpoint points at the registry container by
# its docker-network name on its container port.
self.assertTrue(
handle.push_endpoint.startswith(
"bot-bottle-registry-"
)
)
self.assertTrue(handle.push_endpoint.endswith(":5000"))
# pull_endpoint is the host-side mapping for smolvm.
self.assertEqual("localhost:54321", handle.pull_endpoint)
# network name is the per-session bridge crane joins.
self.assertTrue(
handle.network.startswith("bot-bottle-registry-net-")
)
# docker network create + docker run + docker port + rm -f + network rm
self.assertEqual(5, run.call_count)
def test_registry_run_publishes_random_port_across_interfaces(self):
with patch.object(
local_registry.subprocess, "run",
side_effect=_stock_run_sequence(),
) as run, patch.object(
local_registry.socket, "create_connection",
return_value=_FakeSocket(),
):
with local_registry.ephemeral_registry():
pass
# second call is the docker run for the registry
run_argv = run.call_args_list[1].args[0]
self.assertEqual(["docker", "run"], run_argv[:2])
self.assertIn("--rm", run_argv)
# `-p 5000` (no IP prefix) — needed so the host-published
# port is reachable from BOTH the host (for smolvm) and the
# docker daemon (for the docker port command to find it).
self.assertIn("5000", run_argv)
# And the registry is attached to the same per-session
# network the crane push container joins.
self.assertIn("--network", run_argv)
def test_force_removes_container_and_network_on_clean_exit(self):
with patch.object(
local_registry.subprocess, "run",
side_effect=_stock_run_sequence(),
) as run, patch.object(
local_registry.socket, "create_connection",
return_value=_FakeSocket(),
):
with local_registry.ephemeral_registry():
pass
# Last two calls are `docker rm -f <container>` then
# `docker network rm <network>`.
argvs = [c.args[0] for c in run.call_args_list]
self.assertEqual(["docker", "rm", "-f"], argvs[-2][:3])
self.assertEqual(["docker", "network", "rm"], argvs[-1][:3])
def test_force_removes_on_exception_inside_with(self):
with patch.object(
local_registry.subprocess, "run",
side_effect=_stock_run_sequence(),
) as run, patch.object(
local_registry.socket, "create_connection",
return_value=_FakeSocket(),
):
with self.assertRaises(RuntimeError):
with local_registry.ephemeral_registry():
raise RuntimeError("inside with")
# Both teardowns still ran.
argvs = [c.args[0] for c in run.call_args_list]
self.assertEqual(["docker", "rm", "-f"], argvs[-2][:3])
self.assertEqual(["docker", "network", "rm"], argvs[-1][:3])
def test_wait_ready_times_out(self):
with patch.object(local_registry, "_READY_TIMEOUT_S", 0.1), patch.object(
local_registry.subprocess, "run",
side_effect=_stock_run_sequence(),
) as run, patch.object(
local_registry.socket, "create_connection",
side_effect=OSError("conn refused"),
), patch.object(
local_registry, "die",
side_effect=SystemExit("die called"),
) as die:
with self.assertRaises(SystemExit):
with local_registry.ephemeral_registry():
self.fail("yield reached despite unreachable registry")
die.assert_called_once()
# Teardown still ran via the finally blocks.
argvs = [c.args[0] for c in run.call_args_list]
self.assertEqual(["docker", "rm", "-f"], argvs[-2][:3])
self.assertEqual(["docker", "network", "rm"], argvs[-1][:3])
def test_unique_session_ids_per_call(self):
sessions: list[tuple[str, str]] = []
def capture(argv, *a, **kw): # type: ignore
if argv[:3] == ["docker", "network", "create"]:
return _ok()
if argv[:2] == ["docker", "run"]:
# `--name <registry-name>` and `--network <net-name>`
# both encode the session id.
name = argv[argv.index("--name") + 1]
network = argv[argv.index("--network") + 1]
sessions.append((name, network))
return _ok(stdout="cid\n")
if argv[:2] == ["docker", "port"]:
return _ok(stdout="0.0.0.0:1\n")
return _ok()
with patch.object(
local_registry.subprocess, "run", side_effect=capture,
), patch.object(
local_registry.socket, "create_connection",
return_value=_FakeSocket(),
):
with local_registry.ephemeral_registry():
pass
with local_registry.ephemeral_registry():
pass
self.assertEqual(2, len(sessions))
self.assertNotEqual(sessions[0], sessions[1])
class TestCranePushTarball(unittest.TestCase):
def test_runs_crane_container_on_registry_network_with_insecure_flag(self):
handle = local_registry.RegistryHandle(
network="cb-registry-net-x",
push_endpoint="cb-registry-x:5000",
pull_endpoint="localhost:54321",
)
with patch.object(
local_registry.subprocess, "run", return_value=_ok(),
) as run:
local_registry.crane_push_tarball(
handle, "/tmp/img.tar", "cb-registry-x:5000/cb:abc",
)
argv = run.call_args.args[0]
# Joined to the same docker network so it can reach the
# registry by container name (no host port-forward needed
# for the push leg).
self.assertEqual("docker", argv[0])
self.assertEqual("run", argv[1])
self.assertIn("--rm", argv)
self.assertIn("--network", argv)
self.assertEqual(
"cb-registry-net-x", argv[argv.index("--network") + 1],
)
# The tarball is mounted read-only at /img.tar.
self.assertIn("-v", argv)
self.assertIn("/tmp/img.tar:/img.tar:ro", argv)
# And the crane command itself uses --insecure so plain
# HTTP is allowed against the registry container.
self.assertIn("push", argv)
self.assertIn("--insecure", argv)
self.assertIn("/img.tar", argv)
self.assertIn("cb-registry-x:5000/cb:abc", argv)
def test_dies_when_crane_returns_non_zero(self):
handle = local_registry.RegistryHandle(
network="cb-net", push_endpoint="cb:5000", pull_endpoint="localhost:1",
)
with patch.object(
local_registry.subprocess, "run",
return_value=_fail("push failed"),
), patch.object(
local_registry, "die", side_effect=SystemExit("die"),
) as die:
with self.assertRaises(SystemExit):
local_registry.crane_push_tarball(
handle, "/tmp/img.tar", "cb:5000/cb:abc",
)
die.assert_called_once()
# Error message names what was being pushed where.
msg = die.call_args.args[0]
self.assertIn("/tmp/img.tar", msg)
self.assertIn("cb:5000/cb:abc", msg)
class _FakeSocket:
"""Minimal context-manager stand-in for the socket
`create_connection` returns. The helper only uses `with` on it
and discards the value, so we don't need any real network."""
def __enter__(self):
return self
def __exit__(self, *exc): # type: ignore
return False
if __name__ == "__main__":
unittest.main()
@@ -1,430 +0,0 @@
"""Unit: per-bottle loopback alias pool (follow-up to the
Docker-Desktop fix in PR #74).
`ensure_pool` lazily sudo-adds missing aliases on macOS; no-ops
on Linux. `allocate` picks the lowest-numbered unused alias by
inspecting running bundle containers' port bindings."""
from __future__ import annotations
import json
import os
import sqlite3
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from bot_bottle.backend.smolmachines import loopback_alias
def _ok(stdout: str = "") -> subprocess.CompletedProcess: # type: ignore
return subprocess.CompletedProcess(
args=[], returncode=0, stdout=stdout, stderr="",
)
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess: # type: ignore
return subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr=stderr,
)
# `ifconfig lo0` on macOS with the default lo0 config: just
# 127.0.0.1. We craft fixtures around this shape.
_LO0_DEFAULT = (
"lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384\n"
"\tinet 127.0.0.1 netmask 0xff000000\n"
"\tinet6 ::1 prefixlen 128\n"
)
_LO0_PARTIAL = (
_LO0_DEFAULT
+ "\tinet 127.0.0.16 netmask 0xffffffff\n"
+ "\tinet 127.0.0.17 netmask 0xffffffff\n"
)
def _lo0_full() -> str:
"""All 16 pool addresses already aliased."""
aliases = "".join(
f"\tinet 127.0.0.{i} netmask 0xffffffff\n"
for i in range(16, 32)
)
return _LO0_DEFAULT + aliases
class TestEnsurePool(unittest.TestCase):
def test_noop_on_linux(self):
# `_is_macos` returns False on Linux; ensure_pool should
# never shell out to sudo.
with patch.object(loopback_alias, "_is_macos", return_value=False), \
patch.object(loopback_alias.subprocess, "run") as run:
loopback_alias.ensure_pool()
run.assert_not_called()
def test_all_present_skips_sudo(self):
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(
loopback_alias.subprocess, "run",
return_value=_ok(stdout=_lo0_full()),
) as run:
loopback_alias.ensure_pool()
# Just the ifconfig probe per pool address; no sudo at all.
for call in run.call_args_list:
self.assertNotIn("sudo", call.args[0])
def test_missing_aliases_dispatch_sudo(self):
# lo0 only has 16+17 already; sudo runs for 18..31 (14 missing).
runs: list[list[str]] = []
def fake_run(argv, *a, **kw): # type: ignore
runs.append(argv)
if argv[:2] == ["/sbin/ifconfig", "lo0"]:
return _ok(stdout=_LO0_PARTIAL)
return _ok()
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(loopback_alias.subprocess, "run", side_effect=fake_run):
loopback_alias.ensure_pool()
sudo_calls = [r for r in runs if r and r[0] == "sudo"]
self.assertEqual(14, len(sudo_calls))
sudo_ips = {call[call.index("alias") + 1].split("/")[0] for call in sudo_calls}
self.assertEqual(
{f"127.0.0.{i}" for i in range(18, 32)},
sudo_ips,
)
def test_sudo_failure_dies(self):
def fake_run(argv, *a, **kw): # type: ignore
if argv[:2] == ["/sbin/ifconfig", "lo0"]:
return _ok(stdout=_LO0_DEFAULT)
if argv[:1] == ["sudo"]:
return _fail()
return _ok()
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(loopback_alias.subprocess, "run", side_effect=fake_run), \
patch.object(loopback_alias, "die", side_effect=SystemExit("die")):
with self.assertRaises(SystemExit):
loopback_alias.ensure_pool()
class TestAllocate(unittest.TestCase):
def test_per_bottle_alias_on_linux(self):
# Linux gets the same per-bottle scoping as macOS (127/8 is
# already loopback, so no ifconfig is needed). A fresh host
# with no running bundles allocates the first pool entry.
with tempfile.TemporaryDirectory() as tmp:
lock_path = Path(tmp) / "smolmachines.lock"
with patch.object(loopback_alias, "_is_macos", return_value=False), \
patch.object(loopback_alias, "_ALLOC_LOCK_PATH", lock_path), \
patch.object(loopback_alias, "_aliases_in_use", return_value=set()):
self.assertEqual("127.0.0.16", loopback_alias.allocate("demo"))
def test_picks_lowest_unused_on_macos(self):
# No bundles running -> first pool entry.
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(loopback_alias, "_aliases_in_use", return_value=set()):
self.assertEqual("127.0.0.16", loopback_alias.allocate("demo-1"))
def test_skips_in_use_aliases(self):
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(
loopback_alias, "_aliases_in_use",
return_value={"127.0.0.16", "127.0.0.17", "127.0.0.19"},
):
# First unused = 127.0.0.18.
self.assertEqual("127.0.0.18", loopback_alias.allocate("demo-3"))
def test_dies_when_pool_exhausted(self):
all_in_use = {f"127.0.0.{i}" for i in range(16, 32)}
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(
loopback_alias, "_aliases_in_use",
return_value=all_in_use,
), patch.object(
loopback_alias, "die", side_effect=SystemExit("die"),
):
with self.assertRaises(SystemExit):
loopback_alias.allocate("demo-overflow")
class TestAllocateLock(unittest.TestCase):
"""allocate() on macOS acquires a file lock so concurrent calls
serialise rather than racing on docker state."""
def test_acquires_exclusive_lock_on_macos(self):
import fcntl as fcntl_mod
flock_calls: list[int] = []
def record_flock(fd, op): # type: ignore
flock_calls.append(op)
with tempfile.TemporaryDirectory() as tmp:
lock_path = Path(tmp) / "smolmachines.lock"
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(loopback_alias, "_ALLOC_LOCK_PATH", lock_path), \
patch.object(loopback_alias, "_aliases_in_use", return_value=set()), \
patch.object(loopback_alias.fcntl, "flock",
side_effect=record_flock):
loopback_alias.allocate("demo")
self.assertIn(fcntl_mod.LOCK_EX, flock_calls)
def test_acquires_exclusive_lock_on_linux(self):
# Linux allocates per-bottle too, so it must take the same
# lock to serialise concurrent launches.
import fcntl as fcntl_mod
flock_calls: list[int] = []
def record_flock(fd, op): # type: ignore
flock_calls.append(op)
with tempfile.TemporaryDirectory() as tmp:
lock_path = Path(tmp) / "smolmachines.lock"
with patch.object(loopback_alias, "_is_macos", return_value=False), \
patch.object(loopback_alias, "_ALLOC_LOCK_PATH", lock_path), \
patch.object(loopback_alias, "_aliases_in_use", return_value=set()), \
patch.object(loopback_alias.fcntl, "flock",
side_effect=record_flock):
loopback_alias.allocate("demo")
self.assertIn(fcntl_mod.LOCK_EX, flock_calls)
def test_sequential_allocations_with_shared_lock_are_serialised(self):
# Two sequential calls share the same lock file. The second
# call sees {127.0.0.16} in use (as if the first caller's
# docker run completed between the two lock acquisitions) and
# returns the next alias.
in_use_seq = [set(), {"127.0.0.16"}]
with tempfile.TemporaryDirectory() as tmp:
lock_path = Path(tmp) / "smolmachines.lock"
results: list[str] = []
for _ in range(2):
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(loopback_alias, "_ALLOC_LOCK_PATH", lock_path), \
patch.object(loopback_alias, "_aliases_in_use",
return_value=in_use_seq.pop(0)):
results.append(loopback_alias.allocate("demo"))
self.assertEqual(["127.0.0.16", "127.0.0.17"], results)
class TestAliasInUseDetection(unittest.TestCase):
"""`_aliases_in_use` inspects every running bundle and pulls
each container's port-binding `HostIp` out. The detection has
to survive: no running bundles, multiple bundles, docker
inspect failures."""
def test_no_bundles_returns_empty(self):
with patch.object(
loopback_alias.subprocess, "run",
return_value=_ok(stdout=""),
):
self.assertEqual(set(), loopback_alias._aliases_in_use())
def test_walks_bundles_and_pulls_host_ips(self):
# First call: docker ps -> two bundle names.
# Then docker inspect each, returning a port-bindings JSON
# blob with a HostIp on the per-bottle alias.
ps_out = "bot-bottle-sidecars-a\nbot-bottle-sidecars-b\n"
inspect_a = (
'{"8888/tcp":[{"HostIp":"127.0.0.16","HostPort":"54000"}]}'
)
inspect_b = (
'{"9099/tcp":[{"HostIp":"127.0.0.17","HostPort":"54001"}]}'
)
seq = [
_ok(stdout=ps_out),
_ok(stdout=inspect_a),
_ok(stdout=inspect_b),
]
with patch.object(
loopback_alias.subprocess, "run", side_effect=seq,
):
self.assertEqual(
{"127.0.0.16", "127.0.0.17"},
loopback_alias._aliases_in_use(),
)
def test_inspect_failures_are_skipped(self):
ps_out = "bot-bottle-sidecars-c\n"
with patch.object(
loopback_alias.subprocess, "run",
side_effect=[_ok(stdout=ps_out), _fail("inspect failed")],
):
self.assertEqual(set(), loopback_alias._aliases_in_use())
class TestForceAllowlist(unittest.TestCase):
"""Smolvm 0.8.0 silently drops `--allow-cidr` with `--from`, so
`force_allowlist` opens the state DB directly and sets the row's
`allowed_cidrs` field — on both macOS and Linux. It is
fail-closed: it dies rather than launching a VM whose allowlist
it can't confirm. Round-trip tests against a real SQLite DB to
lock down the BLOB encoding."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="smolvm-db.")
self.db = Path(self._tmp.name) / "smolvm.db"
con = sqlite3.connect(str(self.db))
con.execute(
"CREATE TABLE vms (name TEXT PRIMARY KEY NOT NULL, data BLOB NOT NULL)"
)
# Mimic smolvm's row shape (the JSON keys that exist on
# creation; allowed_cidrs is the field we patch).
cfg = {
"name": "demo-vm",
"cpus": 4,
"mem": 8192,
"network": True,
"allowed_cidrs": None,
}
con.execute(
"INSERT INTO vms (name, data) VALUES (?, ?)",
("demo-vm", sqlite3.Binary(json.dumps(cfg).encode())),
)
con.commit()
con.close()
def tearDown(self):
self._tmp.cleanup()
def test_patches_allowed_cidrs_on_row(self):
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db):
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
con = sqlite3.connect(str(self.db))
row = con.execute(
"SELECT typeof(data), data FROM vms WHERE name='demo-vm'",
).fetchone()
con.close()
# Must round-trip as BLOB (the column type smolvm reads).
self.assertEqual("blob", row[0])
cfg = json.loads(row[1])
self.assertEqual(["127.0.0.16/32"], cfg["allowed_cidrs"])
# Other fields preserved verbatim.
self.assertEqual(4, cfg["cpus"])
self.assertTrue(cfg["network"])
def test_patches_on_linux_too(self):
# force_allowlist no longer no-ops on Linux — the TSI
# allowlist must be enforced there as well.
with patch.object(loopback_alias, "_is_macos", return_value=False), \
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db):
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
con = sqlite3.connect(str(self.db))
cfg = json.loads(con.execute(
"SELECT data FROM vms WHERE name='demo-vm'",
).fetchone()[0])
con.close()
self.assertEqual(["127.0.0.16/32"], cfg["allowed_cidrs"])
def test_skips_write_when_already_matching(self):
# A newer smolvm that honors --allow-cidr at create leaves the
# row already correct; force_allowlist must not rewrite it. We
# detect a no-write by comparing the raw BLOB byte-for-byte
# (a rewrite re-serialises the JSON, changing key order/bytes
# is not guaranteed, but mtime/identity isn't observable — so
# we assert the stored bytes are exactly what we pre-seeded).
seeded = json.dumps({
"name": "demo-vm", "cpus": 4, "mem": 8192,
"network": True, "allowed_cidrs": ["127.0.0.16/32"],
}).encode()
con = sqlite3.connect(str(self.db))
con.execute(
"UPDATE vms SET data=? WHERE name='demo-vm'",
(sqlite3.Binary(seeded),),
)
con.commit()
con.close()
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db):
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
con = sqlite3.connect(str(self.db))
stored = con.execute(
"SELECT data FROM vms WHERE name='demo-vm'").fetchone()[0]
con.close()
self.assertEqual(seeded, bytes(stored))
def test_dies_when_patch_does_not_take(self):
# If the persisted allowlist still doesn't match after the
# patch (e.g. wrong schema / smolvm stores it elsewhere),
# force_allowlist must fail closed rather than boot the VM.
original = loopback_alias._read_machine_cfg
def stale_cfg(con: sqlite3.Connection, name: str) -> dict[str, object]:
# Always report the un-patched row so the post-write
# verification never sees the requested cidrs.
cfg = original(con, name)
cfg["allowed_cidrs"] = None
return cfg
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db), \
patch.object(loopback_alias, "_read_machine_cfg", side_effect=stale_cfg), \
patch.object(loopback_alias, "die", side_effect=SystemExit("die")):
with self.assertRaises(SystemExit):
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
def test_dies_on_missing_db(self):
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(
loopback_alias, "_SMOLVM_DB_PATH",
Path("/nonexistent/smolvm.db"),
), patch.object(
loopback_alias, "die", side_effect=SystemExit("die"),
):
with self.assertRaises(SystemExit):
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
def test_dies_on_missing_row(self):
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db), \
patch.object(
loopback_alias, "die", side_effect=SystemExit("die"),
):
with self.assertRaises(SystemExit):
loopback_alias.force_allowlist("not-in-db", ["127.0.0.16/32"])
class TestSmolvmDbPath(unittest.TestCase):
"""The smolvm state-DB path is platform-derived: Application
Support on macOS, XDG data dir on Linux."""
def test_macos_path(self):
with patch.object(loopback_alias.platform, "system", return_value="Darwin"):
p = loopback_alias._smolvm_db_path()
self.assertEqual(
("Library", "Application Support", "smolvm", "server", "smolvm.db"),
p.parts[-5:],
)
def test_linux_default_xdg_path(self):
env = {k: v for k, v in os.environ.items() if k != "XDG_DATA_HOME"}
with patch.object(loopback_alias.platform, "system", return_value="Linux"), \
patch.dict(loopback_alias.os.environ, env, clear=True):
p = loopback_alias._smolvm_db_path()
self.assertEqual(
(".local", "share", "smolvm", "server", "smolvm.db"),
p.parts[-5:],
)
def test_linux_respects_xdg_data_home(self):
with patch.object(loopback_alias.platform, "system", return_value="Linux"), \
patch.dict(loopback_alias.os.environ,
{"XDG_DATA_HOME": "/custom/data"}, clear=False):
p = loopback_alias._smolvm_db_path()
self.assertEqual(Path("/custom/data/smolvm/server/smolvm.db"), p)
if __name__ == "__main__":
unittest.main()
-559
View File
@@ -1,559 +0,0 @@
"""Unit: smolmachines provisioning helpers (PRD 0023 chunks 4a + 4d).
Tests mock `bottle.exec` / `bottle.cp_in` and assert on the
dispatched script shape. The real round-trip lives in the chunk-4
integration smoke."""
from __future__ import annotations
import subprocess
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from unittest.mock import MagicMock, patch
from bot_bottle.agent_provider import (
AgentProvider,
AgentProviderRuntime,
AgentProvisionCommand,
AgentProvisionDir,
AgentProvisionFile,
AgentProvisionPlan,
)
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
from bot_bottle.backend.smolmachines.bottle_plan import (
SmolmachinesBottlePlan,
)
from bot_bottle.backend.smolmachines import launch as _launch
from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
from bot_bottle.backend.util import AGENT_CA_PATH
from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import ManifestGitEntry, ManifestKeyConfig, ManifestIndex
from bot_bottle.supervise import SupervisePlan
class _Provider(AgentProvider):
"""Minimal concrete subclass for testing the default provision_ca/provision_git."""
@property
def runtime(self) -> AgentProviderRuntime:
return AgentProviderRuntime(
template="test", command="test", image="",
prompt_mode="append_file", bypass_args=(), resume_args=(),
)
def provision_plan(self, **kwargs): # type: ignore[override]
raise NotImplementedError
def provision_skills(self, plan, bottle): ... # type: ignore[override]
def provision_prompt(self, plan, bottle): ... # type: ignore[override]
def provision(self, plan, bottle): ... # type: ignore[override]
def provision_supervise_mcp(self, plan, bottle, supervise_url): ... # type: ignore[override]
def headless_prompt(self, prompt): return [] # type: ignore[override]
_PROVIDER = _Provider()
def _make_bottle(
name: str = "bot-bottle-demo-abc12",
exec_result: ExecResult | None = None,
) -> MagicMock:
bottle = MagicMock(spec=Bottle)
bottle.name = name
bottle.exec.return_value = (
exec_result if exec_result is not None
else ExecResult(returncode=0, stdout="", stderr="")
)
return bottle
def _exec_users(bottle: MagicMock) -> list[str]: # type: ignore
"""user= kwarg from each bottle.exec call, in order."""
return [c.kwargs.get("user", "node") for c in bottle.exec.call_args_list]
def _plan(
*,
agent_prompt: str = "",
skills: list[str] | None = None,
git: list[ManifestGitEntry] = (), # type: ignore
git_user: dict | None = None, # type: ignore
copy_cwd: bool = False,
user_cwd: str = "/tmp/x",
stage_dir: Path | None = None,
egress_routes: tuple[EgressRoute, ...] = (),
egress_ca_path: Path = Path(),
canary: bool = False,
supervise: bool = False,
bundle_ip: str = "192.168.50.2",
agent_git_gate_host: str = "127.0.0.1:55555",
agent_supervise_url: str = "http://127.0.0.1:55556/",
codex_auth_file: Path | None = None,
agent_provider_template: str = "claude",
guest_env: dict[str, str] | None = None,
) -> SmolmachinesBottlePlan:
bottle_json: dict = {} # type: ignore
git_gate_json: dict = {} # type: ignore
if git:
git_gate_json["repos"] = {
g.Name: {
"url": g.Upstream,
"key": {"provider": g.Key.provider or "static", "path": g.Key.path or g.IdentityFile},
}
for g in git
}
if git_user is not None:
git_gate_json["user"] = git_user
if git_gate_json:
bottle_json["git-gate"] = git_gate_json
if supervise:
bottle_json["supervise"] = True
index = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {
"demo": {
"skills": list(skills or []),
"prompt": agent_prompt,
"bottle": "dev",
},
},
})
manifest = index.load_for_agent("demo")
spec = BottleSpec(
manifest=index,
agent_name="demo",
copy_cwd=copy_cwd,
user_cwd=user_cwd,
)
supervise_plan = None
if supervise:
supervise_plan = SupervisePlan(
slug="demo-abc12",
db_path=Path("/tmp/bot-bottle.db"),
)
return SmolmachinesBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=stage_dir or Path("/tmp/stage"),
slug="demo-abc12",
bundle_subnet="192.168.50.0/24",
bundle_gateway="192.168.50.1",
bundle_ip=bundle_ip,
guest_env=dict(guest_env or {}),
git_gate_plan=GitGatePlan(
slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
hook_script=Path("/tmp/git-gate-hook"),
access_hook_script=Path("/tmp/git-gate-access-hook"),
upstreams=(),
),
egress_plan=EgressPlan(
slug="demo-abc12",
routes_path=Path("/tmp/routes.yaml"),
routes=egress_routes,
token_env_map={},
mitmproxy_ca_cert_only_host_path=egress_ca_path,
canary="fake-canary-value" if canary else "",
canary_env="CANON_ALPHA_SECRET" if canary else "",
),
supervise_plan=supervise_plan,
agent_git_gate_host=agent_git_gate_host,
agent_supervise_url=agent_supervise_url,
agent_provision=_agent_provision(
agent_provider_template,
codex_auth_file=codex_auth_file,
guest_env=dict(guest_env or {}),
),
)
def _agent_provision(
template: str,
*,
codex_auth_file: Path | None = None,
guest_env: dict[str, str] | None = None,
) -> AgentProvisionPlan:
if template != "codex":
return AgentProvisionPlan(
template=template,
command=template,
prompt_mode="append_file",
image="bot-bottle-claude:latest",
dockerfile="",
guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
guest_env=dict(guest_env or {}),
)
auth_dir = (guest_env or {}).get("CODEX_HOME", "/home/node/.codex")
files = [
AgentProvisionFile(
Path("/tmp/codex-config.toml"),
f"{auth_dir}/config.toml",
),
]
pre_copy: tuple[AgentProvisionCommand, ...] = ()
verify: tuple[AgentProvisionCommand, ...] = ()
if codex_auth_file is not None:
files.append(AgentProvisionFile(codex_auth_file, f"{auth_dir}/auth.json"))
pre_copy = (AgentProvisionCommand((
"find", auth_dir,
"-maxdepth", "1",
"-type", "f",
"(",
"-name", "*.sqlite",
"-o", "-name", "*.sqlite-*",
"-o", "-name", "*.codex-repair-*.bak",
")",
"-delete",
), "codex host credentials: could not reset runtime db files"),)
verify = (AgentProvisionCommand((
"runuser", "-u", "node", "--",
"env",
"HOME=/home/node",
f"CODEX_HOME={auth_dir}",
"codex", "login", "status",
), "codex host credentials: dummy auth was copied into the guest"),)
return AgentProvisionPlan(
template="codex",
command="codex",
prompt_mode="read_prompt_file",
image="bot-bottle-codex:latest",
dockerfile="",
guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
guest_env=dict(guest_env or {}),
dirs=(AgentProvisionDir(auth_dir),),
files=tuple(files),
pre_copy=pre_copy,
verify=verify,
)
def _write_self_signed_cert(path: Path) -> None:
"""Drop a real self-signed PEM at `path` so provision_ca's
fingerprint computation (PEM_cert_to_DER_cert + sha256) has
actual bytes to chew on. Generated once per test via openssl."""
subprocess.run(
["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", "/dev/null",
"-out", str(path),
"-days", "1",
"-subj", "/CN=test"],
check=True, capture_output=True,
)
class TestProvisionCA(unittest.TestCase):
"""provision_ca always uses the egress MITM CA and dispatches
cp_in + exec in the right order."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-ca.") # pylint: disable=consider-using-with
self.tmp = Path(self._tmp.name)
self.egress_ca = self.tmp / "egress-ca.pem"
_write_self_signed_cert(self.egress_ca)
def tearDown(self):
self._tmp.cleanup()
# provision_ca dies hard if update-ca-certificates' exit
# is non-zero; supply a stock success return so the bulk of
# the tests below exercise the happy path.
_UPDATE_OK = ExecResult(
returncode=0,
stdout="Updating certificates in /etc/ssl/certs...\n1 added, 0 removed; done.\n",
stderr="",
)
def test_egress_ca_always_installed(self):
plan = _plan(egress_ca_path=self.egress_ca)
bottle = _make_bottle(exec_result=self._UPDATE_OK)
_PROVIDER.provision_ca(bottle, plan)
bottle.cp_in.assert_called_once_with(
str(self.egress_ca),
AGENT_CA_PATH,
)
self.assertEqual(2, bottle.exec.call_count)
script = bottle.exec.call_args_list[1].args[0]
self.assertIn("chmod 644", script)
self.assertIn("update-ca-certificates", script)
self.assertEqual("root", bottle.exec.call_args.kwargs.get("user"))
def test_dies_when_egress_cert_missing(self):
plan = _plan(egress_ca_path=self.tmp / "does-not-exist.pem")
bottle = _make_bottle()
with self.assertRaises(SystemExit):
_PROVIDER.provision_ca(bottle, plan)
class TestSmolmachinesBottleExec(unittest.TestCase):
"""SmolmachinesBottle.exec retries once on SIGKILL (exit 137)."""
_SIGKILL = subprocess.CompletedProcess(
args=[], returncode=137, stdout="", stderr="",
)
_SUCCESS = subprocess.CompletedProcess(
args=[], returncode=0, stdout="done", stderr="",
)
def test_retries_on_sigkill(self):
bottle = SmolmachinesBottle("test-machine")
with patch(
"bot_bottle.backend.smolmachines.bottle.subprocess.run",
side_effect=[self._SIGKILL, self._SUCCESS],
) as mock_run, patch(
"bot_bottle.backend.smolmachines.bottle.time.sleep"
) as mock_sleep:
result = bottle.exec("echo hi")
self.assertEqual(0, result.returncode)
self.assertEqual(2, mock_run.call_count)
mock_sleep.assert_called_once_with(1.0)
def test_no_retry_on_success(self):
bottle = SmolmachinesBottle("test-machine")
with patch(
"bot_bottle.backend.smolmachines.bottle.subprocess.run",
return_value=self._SUCCESS,
) as mock_run:
result = bottle.exec("echo hi")
self.assertEqual(0, result.returncode)
self.assertEqual(1, mock_run.call_count)
def test_no_retry_on_other_error(self):
fail = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="err")
bottle = SmolmachinesBottle("test-machine")
with patch(
"bot_bottle.backend.smolmachines.bottle.subprocess.run",
return_value=fail,
) as mock_run:
result = bottle.exec("bad-cmd")
self.assertEqual(1, result.returncode)
self.assertEqual(1, mock_run.call_count)
class TestProvisionGit(unittest.TestCase):
"""provision_git writes gitconfig insteadOf rules when configured."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-git.") # pylint: disable=consider-using-with
self.stage = Path(self._tmp.name)
def tearDown(self):
self._tmp.cleanup()
def test_noop_when_no_cwd_and_no_git_entries(self):
bottle = _make_bottle()
_PROVIDER.provision_git(bottle, _plan(stage_dir=self.stage))
bottle.cp_in.assert_not_called()
bottle.exec.assert_not_called()
def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self):
# Smolmachines's TSI-allowlisted guest dials git-gate via
# smart HTTP at `127.0.0.1:<host port>` — the bundle's
# git HTTP port is published on host loopback at launch
# time, and the plan carries the discovered host port.
plan = _plan(
git=[ManifestGitEntry(
Name="bot-bottle",
Upstream="ssh://git@host/repo.git",
Key=ManifestKeyConfig(provider="static", path="~/.ssh/id_ed25519"),
IdentityFile="~/.ssh/id_ed25519",
)],
stage_dir=self.stage,
agent_git_gate_host="127.0.0.1:9418",
)
bottle = _make_bottle()
_PROVIDER.provision_git(bottle, plan)
# The staged gitconfig path is whatever NamedTemporaryFile
# picked; we read its contents.
cp_call = bottle.cp_in.call_args
staged_path = Path(cp_call.args[0])
self.assertEqual(self.stage, staged_path.parent)
content = staged_path.read_text(encoding="utf-8")
self.assertIn(
'[url "http://127.0.0.1:9418/bot-bottle.git"]', content,
)
self.assertIn(
"\tinsteadOf = ssh://git@host/repo.git", content,
)
class TestBundleLaunchSpec(unittest.TestCase):
def test_git_gate_uses_http_daemon_for_smolmachines(self):
plan = _plan()
plan = replace(
plan,
git_gate_plan=replace(
plan.git_gate_plan,
upstreams=(GitGateUpstream(
name="bot-bottle",
upstream_url="ssh://git@host/repo.git",
upstream_host="host",
upstream_port="22",
identity_file="/tmp/key",
known_host_key="",
),),
),
)
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
self.assertEqual(
"egress,git-gate,git-http",
spec.daemons_csv,
)
self.assertIn(9420, spec.ports_to_publish)
self.assertNotIn(9418, spec.ports_to_publish)
def test_canary_env_registered_as_sensitive_in_bundle(self):
plan = _plan(canary=True)
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
self.assertIn("CANON_ALPHA_SECRET=fake-canary-value", spec.environment)
self.assertIn(
"BOT_BOTTLE_SENSITIVE_PREFIXES=CANON_ALPHA_SECRET",
spec.environment,
)
def test_supervise_adds_daemon_volume_and_env(self):
from bot_bottle.supervise import DB_PATH_IN_CONTAINER
plan = _plan(supervise=True)
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
self.assertIn("supervise", spec.daemons_csv)
self.assertIn(f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", spec.environment)
self.assertIn(("/tmp/bot-bottle.db", DB_PATH_IN_CONTAINER, False), spec.volumes)
def test_canary_env_visible_to_smolvm_guest(self):
plan = _plan(canary=True)
with patch.object(
_launch._bundle,
"bundle_host_port",
return_value="65000",
):
stamped = _launch._discover_urls(plan, "127.0.0.16")
self.assertEqual(
"fake-canary-value",
stamped.guest_env["CANON_ALPHA_SECRET"],
)
class TestProvisionGitUser(unittest.TestCase):
"""`provision_git` runs `git config --global` inside the
guest as the node user. SmolmachinesBottle.exec sets HOME and
USER automatically for the requested user, so --global lands
in /home/node/.gitconfig. No-op when the bottle didn't declare
git_user (issue #86)."""
def _git_config_calls(self, bottle: MagicMock) -> list[tuple[str, str]]:
"""Filter bottle.exec calls down to git-config invocations,
return list of (script, user) tuples."""
out = []
for c in bottle.exec.call_args_list:
script = c.args[0] if c.args else ""
user = c.kwargs.get("user", "node")
if "git config" in script:
out.append((script, user))
return out
def test_noop_when_no_git_user(self):
bottle = _make_bottle()
_PROVIDER.provision_git(bottle, _plan())
self.assertEqual([], self._git_config_calls(bottle))
def test_sets_name_and_email_as_node(self):
plan = _plan(git_user={
"name": "Eric Bauerfeld",
"email": "eric@dideric.is",
})
bottle = _make_bottle()
_PROVIDER.provision_git(bottle, plan)
calls = self._git_config_calls(bottle)
self.assertEqual(2, len(calls))
# Both run as node so SmolmachinesBottle.exec sets HOME=/home/node
# automatically, ensuring --global writes to /home/node/.gitconfig.
for script, user in calls:
self.assertEqual("node", user)
self.assertIn("git config --global", script)
self.assertIn("user.name", calls[0][0])
self.assertIn("Eric Bauerfeld", calls[0][0])
self.assertIn("user.email", calls[1][0])
self.assertIn("eric@dideric.is", calls[1][0])
def test_name_only(self):
plan = _plan(git_user={"name": "Bot"})
bottle = _make_bottle()
_PROVIDER.provision_git(bottle, plan)
calls = self._git_config_calls(bottle)
self.assertEqual(1, len(calls))
self.assertIn("user.name", calls[0][0])
self.assertIn("Bot", calls[0][0])
def test_email_only(self):
plan = _plan(git_user={"email": "bot@example.com"})
bottle = _make_bottle()
_PROVIDER.provision_git(bottle, plan)
calls = self._git_config_calls(bottle)
self.assertEqual(1, len(calls))
self.assertIn("user.email", calls[0][0])
self.assertIn("bot@example.com", calls[0][0])
class TestProxyHost(unittest.TestCase):
"""_proxy_host returns the bridge gateway on Linux and the loopback
alias on other platforms."""
def test_linux_returns_bundle_gateway(self):
plan = _plan()
with patch("bot_bottle.backend.smolmachines.launch.platform.system",
return_value="Linux"):
result = _launch._proxy_host(plan, "127.0.0.16")
self.assertEqual(plan.bundle_gateway, result)
def test_non_linux_returns_loopback(self):
plan = _plan()
with patch("bot_bottle.backend.smolmachines.launch.platform.system",
return_value="Darwin"):
result = _launch._proxy_host(plan, "127.0.0.16")
self.assertEqual("127.0.0.16", result)
class TestDiscoverUrls(unittest.TestCase):
"""_discover_urls stamps git-gate host + supervise URL into the plan."""
def test_git_gate_host_set_when_upstreams_present(self):
plan = _plan()
plan = replace(
plan,
git_gate_plan=replace(
plan.git_gate_plan,
upstreams=(GitGateUpstream(
name="bot-bottle",
upstream_url="ssh://git@host/repo.git",
upstream_host="host",
upstream_port="22",
identity_file="/tmp/key",
known_host_key="",
),),
),
)
with patch.object(_launch._bundle, "bundle_host_port", return_value="9420"):
stamped = _launch._discover_urls(plan, "127.0.0.16")
self.assertEqual("127.0.0.16:9420", stamped.agent_git_gate_host)
def test_supervise_url_set_when_supervise_present(self):
plan = _plan(supervise=True)
with patch.object(_launch._bundle, "bundle_host_port", return_value="55556"):
stamped = _launch._discover_urls(plan, "127.0.0.16")
self.assertEqual("http://127.0.0.16:55556/", stamped.agent_supervise_url)
if __name__ == "__main__":
unittest.main()
-164
View File
@@ -1,164 +0,0 @@
"""Unit: smolmachines pty_resize bridge (issue #82).
Locks down the parts of the wrapper we can test without spawning
real children or signalling argument parsing, the side-channel
`smolvm machine exec` argv shape, and TTY-resolution fallback
across stdin/stdout/stderr.
"""
from __future__ import annotations
import io
import unittest
import unittest.mock
from unittest.mock import patch
from bot_bottle.backend.smolmachines import pty_resize
class TestPushSize(unittest.TestCase):
def test_emits_for_loop_over_all_pts_devices(self):
# The shell `for f in /dev/pts/*` handles multiple
# interactive sessions in the same VM (rare but cheap).
# Per-PTY `stty -F ... 2>/dev/null` swallows EBADF when a
# session has already exited.
with patch.object(pty_resize.subprocess, "run") as run:
pty_resize._push_size("bot-bottle-m", 50, 200)
argv = run.call_args.args[0]
self.assertEqual(
["smolvm", "machine", "exec", "--name",
"bot-bottle-m", "--", "sh", "-c"],
argv[:8],
)
# cols / rows land in the order stty wants them.
self.assertIn("cols 200", argv[8])
self.assertIn("rows 50", argv[8])
self.assertIn("for f in /dev/pts/*", argv[8])
def test_side_channel_uses_devnull_stdin(self):
# Load-bearing regression: under tmux, inheriting the
# pane PTY as the side-channel's stdin makes smolvm crash
# within ~100ms (concurrent smolvm processes sharing the
# PTY's FG-PG / input plumbing). DEVNULL stdin sidesteps
# the interaction.
with patch.object(pty_resize.subprocess, "run") as run:
pty_resize._push_size("bot-bottle-m", 24, 80)
self.assertEqual(
pty_resize.subprocess.DEVNULL,
run.call_args.kwargs.get("stdin"),
)
def test_swallows_subprocess_failures(self):
# `check=False` + DEVNULL streams: a side-channel failure
# mustn't break the operator's session.
with patch.object(
pty_resize.subprocess, "run",
side_effect=OSError("boom"),
):
with self.assertRaises(OSError):
pty_resize._push_size("m", 24, 80)
# The wrapper-level `sync()` is what swallows; `_push_size`
# itself raises so the test above documents that. The
# signal-handler-side `sync` in main wraps in try/except
# via the `if size is None: return` guard for the
# no-TTY case (no separate try needed because subprocess
# already has check=False; only fcntl.ioctl raising would
# surface, and _read_winsize handles that).
class TestReadWinsize(unittest.TestCase):
def test_returns_none_when_no_tty(self):
# Patch ioctl to always OSError — simulates the case where
# none of stdin/stdout/stderr is a TTY (e.g., tests, piped
# automation).
with patch.object(
pty_resize.fcntl, "ioctl",
side_effect=OSError("ENOTTY"),
):
self.assertIsNone(pty_resize._read_winsize())
def test_returns_first_tty_size(self):
# First fd that responds with a non-zero size wins —
# matches the "different surfaces give different TTYs"
# invariant noted in the module docstring.
import struct
calls: list[int] = []
def fake_ioctl(fd, req, buf): # type: ignore
calls.append(fd)
if fd == 0:
raise OSError("stdin not a tty")
return struct.pack("hhhh", 42, 137, 0, 0)
with patch.object(pty_resize.fcntl, "ioctl", side_effect=fake_ioctl):
self.assertEqual((42, 137), pty_resize._read_winsize())
def test_skips_zero_sizes(self):
# A TTY that reports `0 0` (the smolvm-allocated PTY's
# initial state, ironically) shouldn't be used as the
# source of truth — keep probing fallback fds.
import struct
responses = iter([
struct.pack("hhhh", 0, 0, 0, 0), # stdin: zero
struct.pack("hhhh", 24, 80, 0, 0), # stdout: real
])
def fake_ioctl(fd, req, buf): # type: ignore
return next(responses)
with patch.object(pty_resize.fcntl, "ioctl", side_effect=fake_ioctl):
self.assertEqual((24, 80), pty_resize._read_winsize())
class TestMainArgvParsing(unittest.TestCase):
def test_missing_separator_returns_error_exit_code(self):
# No `--` between machine name and inner argv.
with patch.object(pty_resize.sys, "stderr", new=io.StringIO()) as err:
rc = pty_resize.main(["bot-bottle-m", "smolvm", "machine"])
self.assertEqual(2, rc)
self.assertIn("usage:", err.getvalue())
def test_too_few_args_returns_error_exit_code(self):
with patch.object(pty_resize.sys, "stderr", new=io.StringIO()):
self.assertEqual(2, pty_resize.main([]))
self.assertEqual(2, pty_resize.main(["m"]))
self.assertEqual(2, pty_resize.main(["m", "--"]))
class TestStartupSyncDeferred(unittest.TestCase):
"""Regression: the initial sync MUST be deferred (timer), not
called synchronously between Popen + wait. Calling it
immediately races libkrun's per-exec OCI config write during
the main exec's bringup and crashes the child (rc=137 or
'parse error: trailing garbage')."""
def test_main_schedules_timer_does_not_call_sync_synchronously(self):
# Fake Popen + wait so main returns immediately. Patch
# Timer to record args without spawning a real thread.
# _push_size patched so any rogue synchronous call would
# be observable.
fake_proc = unittest.mock.MagicMock()
fake_proc.wait.return_value = 0
with patch.object(
pty_resize.subprocess, "Popen", return_value=fake_proc,
), patch.object(
pty_resize.threading, "Timer",
) as timer_cls, patch.object(
pty_resize, "_push_size",
) as push:
rc = pty_resize.main(["machine-name", "--", "echo", "hi"])
self.assertEqual(0, rc)
# Timer scheduled with the documented delay constant.
timer_cls.assert_called_once()
delay, callback = timer_cls.call_args.args # type: ignore
self.assertEqual(pty_resize._STARTUP_SYNC_DELAY_SEC, delay)
# _push_size never called synchronously — the only path to
# it is via the (mocked) timer's callback firing.
push.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -1,226 +0,0 @@
"""Unit: bundle bringup primitives for the smolmachines backend
(PRD 0023 chunk 2c).
Tests mock `subprocess.run` and assert on the docker argv shape.
The end-to-end integration smoke (real docker daemon, real
bundle image) lands in chunk 2d."""
from __future__ import annotations
import subprocess
import unittest
from pathlib import Path
from unittest.mock import patch
from bot_bottle.backend.smolmachines.sidecar_bundle import (
BundleLaunchSpec,
bundle_container_name,
bundle_network_name,
create_bundle_network,
ensure_bundle_image,
remove_bundle_network,
start_bundle,
stop_bundle,
)
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess: # type: ignore
return subprocess.CompletedProcess(
args=[], returncode=0, stdout=stdout, stderr=stderr,
)
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess: # type: ignore
return subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr=stderr,
)
def _spec(**kwargs) -> BundleLaunchSpec: # type: ignore
defaults = dict(
slug="demo-abc12",
network_name="bot-bottle-bundle-demo-abc12",
subnet="192.168.50.0/24",
gateway="192.168.50.1",
bundle_ip="192.168.50.2",
)
defaults.update(kwargs)
return BundleLaunchSpec(**defaults) # type: ignore
class TestNamingHelpers(unittest.TestCase):
def test_network_name_uses_bundle_prefix(self):
# Distinct from the docker backend's
# `bot-bottle-net-<slug>` so two backends running the
# same agent slug don't collide.
self.assertEqual(
"bot-bottle-bundle-myagent-xyz",
bundle_network_name("myagent-xyz"),
)
def test_container_name_matches_docker_bundle_shape(self):
# Same shape PRD 0024 chunk 5 set for the docker backend's
# bundle container — dashboard prefix-discovery covers
# both backends with one filter.
self.assertEqual(
"bot-bottle-sidecars-myagent-xyz",
bundle_container_name("myagent-xyz"),
)
class TestNetworkLifecycle(unittest.TestCase):
def _patch_run(self, **kwargs): # type: ignore
return patch(
"bot_bottle.backend.smolmachines.sidecar_bundle.subprocess.run",
**kwargs,
)
def test_create_argv_explicit_subnet_and_gateway(self):
with self._patch_run(return_value=_ok()) as m:
create_bundle_network("nn", "192.168.50.0/24", "192.168.50.1")
self.assertEqual(
["docker", "network", "create",
"--subnet", "192.168.50.0/24",
"--gateway", "192.168.50.1",
"nn"],
m.call_args.args[0],
)
def test_create_treats_existing_network_as_success(self):
with self._patch_run(return_value=_fail("network nn already exists")):
# No SystemExit.
create_bundle_network("nn", "192.168.50.0/24", "192.168.50.1")
def test_create_other_failure_is_fatal(self):
with self._patch_run(return_value=_fail("invalid subnet")):
with self.assertRaises(SystemExit):
create_bundle_network("nn", "bogus", "bogus")
def test_remove_missing_network_is_idempotent(self):
# No SystemExit / no warn-and-continue noise; missing
# network is the expected case during a partial teardown.
with self._patch_run(return_value=_fail("Error: No such network: nn")):
remove_bundle_network("nn")
def test_remove_clean_returns_success(self):
with self._patch_run(return_value=_ok()):
remove_bundle_network("nn")
class TestStartBundle(unittest.TestCase):
def _patch_run(self):
return patch(
"bot_bottle.backend.smolmachines.sidecar_bundle.subprocess.run",
return_value=_ok(),
)
def test_argv_pins_ip_on_network(self):
with self._patch_run() as m:
start_bundle(_spec())
argv = m.call_args.args[0]
# --network NETNAME --ip <bundle-ip> on the docker run.
self.assertIn("--network", argv)
self.assertIn("bot-bottle-bundle-demo-abc12", argv)
self.assertIn("--ip", argv)
self.assertIn("192.168.50.2", argv)
# Detached and auto-removed.
self.assertIn("--detach", argv)
self.assertIn("--rm", argv)
# Container name uses the per-slug bundle prefix.
i = argv.index("--name")
self.assertEqual("bot-bottle-sidecars-demo-abc12", argv[i + 1])
# Image at the end.
self.assertEqual("bot-bottle-sidecars:latest", argv[-1])
def test_daemons_env_passed_in(self):
with self._patch_run() as m:
start_bundle(_spec(daemons_csv="egress,supervise"))
argv = m.call_args.args[0]
self.assertIn("-e", argv)
self.assertIn(
"BOT_BOTTLE_SIDECAR_DAEMONS=egress,supervise",
argv,
)
def test_environment_entries_pass_through(self):
with self._patch_run() as m:
start_bundle(_spec(environment=(
"SUPERVISE_BOTTLE_SLUG=demo-abc12",
"EGRESS_TOKEN_0", # bare-name → host env inherit
)))
argv = m.call_args.args[0]
self.assertIn("SUPERVISE_BOTTLE_SLUG=demo-abc12", argv)
self.assertIn("EGRESS_TOKEN_0", argv)
def test_volumes_render_with_ro_flag(self):
with self._patch_run() as m:
start_bundle(_spec(volumes=(
("/host/egress-ca.pem", "/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", True),
("/host/queue", "/run/supervise/queue", False),
)))
argv = m.call_args.args[0]
self.assertIn(
"/host/egress-ca.pem:/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem:ro",
argv,
)
self.assertIn("/host/queue:/run/supervise/queue", argv)
def test_failure_dies(self):
with patch(
"bot_bottle.backend.smolmachines.sidecar_bundle.subprocess.run",
return_value=_fail("invalid mount"),
):
with self.assertRaises(SystemExit):
start_bundle(_spec())
def test_host_env_inherited_to_subprocess(self):
# Bare-name entries in spec.environment rely on the docker
# subprocess being run with the host env. Confirm `env=`
# threads through.
with patch(
"bot_bottle.backend.smolmachines.sidecar_bundle.subprocess.run",
return_value=_ok(),
) as m:
start_bundle(_spec(), env={"FOO": "bar"})
self.assertEqual({"FOO": "bar"}, m.call_args.kwargs["env"])
class TestEnsureBundleImage(unittest.TestCase):
def test_builds_sidecar_dockerfile_before_plain_docker_run(self):
with patch(
"bot_bottle.backend.smolmachines.sidecar_bundle.docker_mod.build_image",
) as build:
ensure_bundle_image()
build.assert_called_once()
args = build.call_args.args
kwargs = build.call_args.kwargs
self.assertEqual("bot-bottle-sidecars:latest", args[0])
self.assertTrue((Path(args[1]) / "Dockerfile.sidecars").is_file())
self.assertEqual("Dockerfile.sidecars", kwargs["dockerfile"])
class TestStopBundle(unittest.TestCase):
def _patch_run(self, **kwargs): # type: ignore
return patch(
"bot_bottle.backend.smolmachines.sidecar_bundle.subprocess.run",
**kwargs,
)
def test_argv_force_removes(self):
with self._patch_run(return_value=_ok()) as m:
stop_bundle("demo-abc12")
self.assertEqual(
["docker", "rm", "-f", "bot-bottle-sidecars-demo-abc12"],
m.call_args.args[0],
)
def test_missing_container_is_idempotent(self):
with self._patch_run(return_value=_fail(
"Error: No such container: bot-bottle-sidecars-demo-abc12"
)):
stop_bundle("demo-abc12") # no raise
if __name__ == "__main__":
unittest.main()
-284
View File
@@ -1,284 +0,0 @@
"""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 bot_bottle.backend.smolmachines import smolvm as smolvm_mod
from bot_bottle.backend.smolmachines.smolvm import (
SmolvmError,
SmolvmRunResult,
is_available,
machine_cp,
machine_create,
machine_delete,
machine_exec,
machine_start,
machine_stop,
pack_create,
pack_create_from_vm,
wait_exec_ready,
)
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess: # type: ignore
return subprocess.CompletedProcess(
args=[], returncode=0, stdout=stdout, stderr=stderr,
)
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess: # type: ignore
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(
"bot_bottle.backend.smolmachines.smolvm.subprocess.run",
return_value=_ok(),
)
def test_pack_create_argv(self):
with self._patch_run() as m:
pack_create("bot-bottle-claude:latest", Path("/tmp/agent.smolmachine"))
argv = m.call_args.args[0]
self.assertEqual(
["smolvm", "pack", "create",
"--image", "bot-bottle-claude:latest",
"-o", "/tmp/agent.smolmachine"],
argv,
)
def test_pack_create_from_vm_argv(self):
with self._patch_run() as m:
pack_create_from_vm("bot-bottle-dev-abc12", Path("/tmp/committed"))
argv = m.call_args.args[0]
self.assertEqual(
["smolvm", "pack", "create",
"--from-vm", "bot-bottle-dev-abc12",
"-o", "/tmp/committed"],
argv,
)
def test_machine_create_minimal(self):
with self._patch_run() as m:
machine_create("agent-xyz")
self.assertEqual(
["smolvm", "machine", "create", "--name", "agent-xyz"],
m.call_args.args[0],
)
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"),
allow_cidrs=["192.168.50.2/32"],
env={"HTTPS_PROXY": "http://192.168.50.2:8888"},
)
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)
# `--net` is explicit because smolvm 0.8.0's implied-net
# from --allow-cidr doesn't fire when --from is set.
self.assertIn("--net", 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.assertIn("--name", argv)
self.assertIn("agent-xyz", argv)
def test_machine_create_omits_net_when_no_allow_cidrs(self):
with self._patch_run() as m:
machine_create("agent-xyz", from_path=Path("/x.smolmachine"))
self.assertNotIn("--net", 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_name_flag_and_force(self):
# delete uses --name flag; -f required so no interactive
# confirmation blocks teardown.
with self._patch_run() as m:
machine_delete("agent-xyz")
self.assertEqual(
["smolvm", "machine", "delete", "--name", "agent-xyz", "-f"],
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(
"bot_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(
"bot_bottle.backend.smolmachines.smolvm.subprocess.run",
return_value=_fail("pack failed"),
):
with self.assertRaises(SmolvmError):
pack_create("missing:tag", Path("/tmp/out"))
def test_pack_create_from_vm_failure_raises(self):
with patch(
"bot_bottle.backend.smolmachines.smolvm.subprocess.run",
return_value=_fail("pack failed"),
):
with self.assertRaises(SmolvmError):
pack_create_from_vm("bot-bottle-dev-abc12", 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(
"bot_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 TestWaitExecReady(unittest.TestCase):
"""wait_exec_ready polls machine_exec(name, ["true"]) until it
returns 0, then exits. On timeout it calls die()."""
def test_returns_immediately_when_exec_succeeds_first_try(self):
with patch.object(smolvm_mod, "machine_exec",
return_value=SmolvmRunResult(0, "", "")) as m:
wait_exec_ready("vm-x")
m.assert_called_once_with("vm-x", ["true"])
def test_retries_on_nonzero_and_returns_on_success(self):
results = [
SmolvmRunResult(1, "", "not ready"),
SmolvmRunResult(1, "", "not ready"),
SmolvmRunResult(0, "", ""),
]
with patch.object(smolvm_mod, "machine_exec",
side_effect=results) as m, \
patch.object(smolvm_mod.time, "sleep"):
wait_exec_ready("vm-x")
self.assertEqual(3, m.call_count)
def test_raises_smolvm_error_on_timeout(self):
# machine_exec always returns non-zero; monotonic advances past
# the deadline after the first sleep so the loop exits.
ticks = [0.0, 0.0, 10.0] # third call puts us past deadline
with patch.object(smolvm_mod, "machine_exec",
return_value=SmolvmRunResult(1, "", "")), \
patch.object(smolvm_mod.time, "monotonic",
side_effect=ticks), \
patch.object(smolvm_mod.time, "sleep"):
with self.assertRaises(SmolvmError) as cm:
wait_exec_ready("vm-x", timeout=5.0)
self.assertIn("vm-x", str(cm.exception))
self.assertIn("not ready", str(cm.exception))
class TestIsAvailable(unittest.TestCase):
def test_true_when_on_path(self):
with patch(
"bot_bottle.backend.smolmachines.smolvm.shutil.which",
return_value="/usr/local/bin/smolvm",
):
self.assertTrue(is_available())
def test_false_when_missing(self):
with patch(
"bot_bottle.backend.smolmachines.smolvm.shutil.which",
return_value=None,
):
self.assertFalse(is_available())
if __name__ == "__main__":
unittest.main()
-155
View File
@@ -1,155 +0,0 @@
"""Unit: smolmachines backend util helpers (PRD 0023)."""
from __future__ import annotations
import unittest
from unittest.mock import patch
from bot_bottle.backend.smolmachines.util import (
smolmachines_bundle_subnet,
smolmachines_preflight,
)
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"))
# Gateway at .1, bundle at .2 — fixed convention.
self.assertTrue(gateway.endswith(".1"))
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: `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, collisions exist —
# but two arbitrary slugs shouldn't share a subnet in the
# typical case.
seen = {
smolmachines_bundle_subnet(s)[0]
for s in ("a", "b", "c", "d", "e", "alpha", "beta", "gamma")
}
self.assertGreater(len(seen), 1)
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, _, _ = smolmachines_bundle_subnet(slug)
self.assertNotEqual("192.168.17.0/24", subnet,
f"slug {slug!r} landed on the skipped octet")
class TestPreflight(unittest.TestCase):
def test_smolvm_present_returns_none(self):
# Pin macOS so the Linux KVM gate doesn't fire on a CI runner
# (ubuntu, no /dev/kvm) — this test isolates the PATH check.
with patch(
"bot_bottle.backend.smolmachines.util.shutil.which",
return_value="/usr/local/bin/smolvm",
), patch(
"bot_bottle.backend.smolmachines.util.platform.system",
return_value="Darwin",
):
self.assertIsNone(smolmachines_preflight())
def test_missing_smolvm_dies(self):
with patch(
"bot_bottle.backend.smolmachines.util.shutil.which",
return_value=None,
):
with self.assertRaises(SystemExit) as cm:
smolmachines_preflight()
self.assertNotEqual(0, cm.exception.code)
def test_install_pointer_in_error(self):
import io
import sys
with patch(
"bot_bottle.backend.smolmachines.util.shutil.which",
return_value=None,
):
captured = io.StringIO()
with patch.object(sys, "stderr", captured):
with self.assertRaises(SystemExit):
smolmachines_preflight()
msg = captured.getvalue()
self.assertIn("smolvm", msg)
self.assertIn("smolmachines.com/install.sh", msg)
self.assertIn("BOT_BOTTLE_BACKEND=docker", msg)
class TestKvmPreflight(unittest.TestCase):
"""Linux-only KVM gate: smolvm needs /dev/kvm present and
accessible. macOS skips this entirely (Hypervisor.framework)."""
def _run(self, *, system: str, exists: bool, access: bool) -> None:
with patch(
"bot_bottle.backend.smolmachines.util.shutil.which",
return_value="/usr/bin/smolvm",
), patch(
"bot_bottle.backend.smolmachines.util.platform.system",
return_value=system,
), patch(
"bot_bottle.backend.smolmachines.util.os.path.exists",
return_value=exists,
), patch(
"bot_bottle.backend.smolmachines.util.os.access",
return_value=access,
):
return smolmachines_preflight()
def test_macos_skips_kvm_check(self):
# Even with /dev/kvm absent, macOS must not run the gate.
self.assertIsNone(self._run(system="Darwin", exists=False, access=False))
def test_linux_ok_returns_none(self):
self.assertIsNone(self._run(system="Linux", exists=True, access=True))
def test_linux_missing_device_dies(self):
with self.assertRaises(SystemExit):
self._run(system="Linux", exists=False, access=False)
def test_linux_no_access_dies(self):
with self.assertRaises(SystemExit):
self._run(system="Linux", exists=True, access=False)
def test_linux_missing_device_message(self):
import io
import sys
captured = io.StringIO()
with patch.object(sys, "stderr", captured):
with self.assertRaises(SystemExit):
self._run(system="Linux", exists=False, access=False)
msg = captured.getvalue()
self.assertIn("/dev/kvm", msg)
self.assertIn("kvm-intel", msg)
def test_linux_no_access_message(self):
import io
import sys
captured = io.StringIO()
with patch.object(sys, "stderr", captured):
with self.assertRaises(SystemExit):
self._run(system="Linux", exists=True, access=False)
msg = captured.getvalue()
self.assertIn("kvm", msg)
self.assertIn("group", msg)
if __name__ == "__main__":
unittest.main()