feat: support smolmachines bottle commit
This commit is contained in:
@@ -5,9 +5,15 @@ from __future__ import annotations
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bot_bottle.cli.commit import cmd_commit, _committed_image_tag, _agent_container_name
|
||||
from bot_bottle.cli.commit import (
|
||||
cmd_commit,
|
||||
_agent_container_name,
|
||||
_committed_image_tag,
|
||||
_committed_smolmachine_artifact,
|
||||
_committed_smolmachine_output,
|
||||
)
|
||||
from bot_bottle import supervise
|
||||
from bot_bottle import bottle_state
|
||||
|
||||
@@ -41,6 +47,16 @@ class TestCommitHelpers(unittest.TestCase):
|
||||
_agent_container_name("dev-abc12"),
|
||||
)
|
||||
|
||||
def test_committed_smolmachine_paths(self):
|
||||
output = _committed_smolmachine_output("dev-abc12")
|
||||
artifact = _committed_smolmachine_artifact("dev-abc12")
|
||||
self.assertTrue(str(output).endswith(
|
||||
"/.bot-bottle/state/dev-abc12/committed-smolmachine"
|
||||
))
|
||||
self.assertTrue(str(artifact).endswith(
|
||||
"/.bot-bottle/state/dev-abc12/committed-smolmachine.smolmachine"
|
||||
))
|
||||
|
||||
|
||||
class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
|
||||
"""cmd_commit with an explicit slug bypasses the TUI picker."""
|
||||
@@ -117,14 +133,14 @@ class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
|
||||
mock_commit.assert_called_once()
|
||||
|
||||
|
||||
class TestCmdCommitNonDockerBackend(_FakeHomeMixin, unittest.TestCase):
|
||||
class TestCmdCommitSmolmachinesBackend(_FakeHomeMixin, unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._setup_fake_home()
|
||||
|
||||
def tearDown(self):
|
||||
self._teardown_fake_home()
|
||||
|
||||
def test_dies_for_smolmachines_backend(self):
|
||||
def test_packs_smolmachines_bottle(self):
|
||||
slug = "dev-abc12"
|
||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||
@@ -132,13 +148,30 @@ class TestCmdCommitNonDockerBackend(_FakeHomeMixin, unittest.TestCase):
|
||||
))
|
||||
|
||||
with patch(
|
||||
"bot_bottle.cli.commit.die", side_effect=SystemExit("die"),
|
||||
) as mock_die:
|
||||
with self.assertRaises(SystemExit):
|
||||
cmd_commit([slug])
|
||||
"bot_bottle.cli.commit.pack_create_from_vm",
|
||||
) as mock_pack, patch(
|
||||
"bot_bottle.cli.commit.info",
|
||||
):
|
||||
rc = cmd_commit([slug])
|
||||
|
||||
mock_die.assert_called_once()
|
||||
self.assertIn("smolmachines", mock_die.call_args.args[0])
|
||||
self.assertEqual(0, rc)
|
||||
mock_pack.assert_called_once_with(
|
||||
f"bot-bottle-{slug}",
|
||||
_committed_smolmachine_output(slug),
|
||||
)
|
||||
self.assertEqual(
|
||||
str(_committed_smolmachine_artifact(slug)),
|
||||
bottle_state.read_committed_image(slug),
|
||||
)
|
||||
self.assertTrue(bottle_state.is_preserved(slug))
|
||||
|
||||
|
||||
class TestCmdCommitUnsupportedBackend(_FakeHomeMixin, unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._setup_fake_home()
|
||||
|
||||
def tearDown(self):
|
||||
self._teardown_fake_home()
|
||||
|
||||
def test_dies_for_macos_container_backend(self):
|
||||
slug = "dev-abc12"
|
||||
|
||||
@@ -7,6 +7,7 @@ import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
from bot_bottle.agent_provider import AgentProvisionPlan
|
||||
@@ -73,36 +74,28 @@ def _plan(tmp: str) -> DockerBottlePlan:
|
||||
)
|
||||
|
||||
|
||||
def _std_mocks(test, plan):
|
||||
"""Context manager providing the standard launch-step mocks needed to
|
||||
get through the non-image parts of `launch()` without real Docker."""
|
||||
return mock.patch.multiple(
|
||||
launch_mod,
|
||||
egress_tls_init=mock.DEFAULT,
|
||||
network_mod=mock.DEFAULT,
|
||||
bottle_plan_to_compose=mock.DEFAULT,
|
||||
write_compose_file=mock.DEFAULT,
|
||||
compose_up=mock.DEFAULT,
|
||||
compose_dump_logs=mock.DEFAULT,
|
||||
compose_down=mock.DEFAULT,
|
||||
)
|
||||
|
||||
|
||||
class TestLaunchCommittedImage(unittest.TestCase):
|
||||
def setUp(self):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.mkdtemp(prefix="launch-committed-test.")
|
||||
|
||||
def tearDown(self):
|
||||
def tearDown(self) -> None:
|
||||
import shutil
|
||||
shutil.rmtree(self._tmp, ignore_errors=True)
|
||||
|
||||
def _run_launch(self, plan, *, committed_tag=None, image_present=True):
|
||||
def _run_launch(
|
||||
self,
|
||||
plan: DockerBottlePlan,
|
||||
*,
|
||||
committed_tag: str | None = None,
|
||||
image_present: bool = True,
|
||||
) -> list[str]:
|
||||
"""Drive launch() through its full sequence with the committed-image
|
||||
behaviour controlled by the arguments. Returns the images that were
|
||||
passed to `build_image` (empty list if it was never called)."""
|
||||
built = []
|
||||
built: list[str] = []
|
||||
|
||||
def fake_build(image, ctx, *, dockerfile=""):
|
||||
def fake_build(image: str, ctx: str, *, dockerfile: str = "") -> None:
|
||||
del ctx, dockerfile
|
||||
built.append(image)
|
||||
|
||||
with mock.patch.object(
|
||||
@@ -136,19 +129,19 @@ class TestLaunchCommittedImage(unittest.TestCase):
|
||||
|
||||
return built
|
||||
|
||||
def test_skips_build_when_committed_image_present(self):
|
||||
def test_skips_build_when_committed_image_present(self) -> None:
|
||||
plan = _plan(self._tmp)
|
||||
built = self._run_launch(plan, committed_tag=_COMMITTED_TAG, image_present=True)
|
||||
self.assertEqual([], built, "build_image should not be called when committed image exists")
|
||||
|
||||
def test_uses_committed_image_in_compose_spec(self):
|
||||
def test_uses_committed_image_in_compose_spec(self) -> None:
|
||||
"""The compose spec renderer receives the committed image tag via
|
||||
plan.image — captured here by checking what bottle_plan_to_compose
|
||||
was called with."""
|
||||
plan = _plan(self._tmp)
|
||||
captured_plans = []
|
||||
captured_plans: list[DockerBottlePlan] = []
|
||||
|
||||
def fake_compose(p):
|
||||
def fake_compose(p: DockerBottlePlan) -> dict[str, Any]:
|
||||
captured_plans.append(p)
|
||||
return {"services": {"agent": {}}}
|
||||
|
||||
@@ -183,12 +176,12 @@ class TestLaunchCommittedImage(unittest.TestCase):
|
||||
self.assertEqual(1, len(captured_plans))
|
||||
self.assertEqual(_COMMITTED_TAG, captured_plans[0].image)
|
||||
|
||||
def test_falls_back_to_build_when_no_committed_image(self):
|
||||
def test_falls_back_to_build_when_no_committed_image(self) -> None:
|
||||
plan = _plan(self._tmp)
|
||||
built = self._run_launch(plan, committed_tag=None)
|
||||
self.assertEqual([_DEFAULT_IMAGE], built)
|
||||
|
||||
def test_falls_back_to_build_when_committed_image_missing_from_daemon(self):
|
||||
def test_falls_back_to_build_when_committed_image_missing_from_daemon(self) -> None:
|
||||
plan = _plan(self._tmp)
|
||||
built = self._run_launch(
|
||||
plan, committed_tag=_COMMITTED_TAG, image_present=False,
|
||||
|
||||
@@ -16,6 +16,8 @@ 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
|
||||
@@ -141,5 +143,46 @@ class TestEnsureSmolmachine(unittest.TestCase):
|
||||
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()
|
||||
|
||||
@@ -24,6 +24,7 @@ from bot_bottle.backend.smolmachines.smolvm import (
|
||||
machine_start,
|
||||
machine_stop,
|
||||
pack_create,
|
||||
pack_create_from_vm,
|
||||
wait_exec_ready,
|
||||
)
|
||||
|
||||
@@ -63,6 +64,17 @@ class TestArgvShapes(unittest.TestCase):
|
||||
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")
|
||||
@@ -193,6 +205,14 @@ class TestErrorPath(unittest.TestCase):
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user