refactor: BottleImages dataclass, prelaunch_checks, build_or_load_images

Addresses review comments 3098, 3099, 3100 on PR #336:

- Add BottleImages(agent, sidecar) dataclass to backend/__init__.py.
  Docker/macOS backends use str image refs; smolmachines uses Path
  artifacts. Replaces the singular `image` variable from the canonical
  pattern in comment 3100.

- Replace _image_stale_checks/skip_stale with public prelaunch_checks().
  CLI now calls backend.prelaunch_checks(plan) before backend.launch(plan);
  if StaleImageError is raised and the operator confirms, launch proceeds
  without re-checking. Removes the while-True/skip_stale retry loop.

- Add abstract _build_or_load_images(plan) -> BottleImages to
  BottleBackend. launch() calls it then passes images to _launch_impl.
  Each backend implements both methods.

- Fix comment 3098 (macos-container): _build_images is removed.
  build_or_load_images() has separate fresh/cached code paths — the
  cached path never calls a build helper.

- Update _start_bundle (smolmachines) to accept sidecar_artifact: Path
  directly. Sidecar artifact resolution moves to _sidecar_from_path(),
  called by build_or_load_images alongside _agent_from_path().
This commit is contained in:
2026-07-10 20:25:24 +00:00
parent ecd260b5ef
commit 715e474306
14 changed files with 243 additions and 228 deletions
+19 -37
View File
@@ -1,9 +1,9 @@
"""Unit: _launch_bottle StaleImageError handling.
Exercises the while-True / try-except loop around backend.launch:
Exercises prelaunch_checks / backend.launch flow:
- headless mode → die on stale
- interactive mode, user declines → stop without launching
- interactive mode, user confirms → retry with skip_stale=True
- interactive mode, user confirms → skip stale check and launch once
"""
from __future__ import annotations
@@ -28,14 +28,6 @@ def _fake_plan() -> Any:
))
def _stale_cm() -> MagicMock:
"""Return a context-manager mock whose __enter__ raises StaleImageError."""
cm = MagicMock()
cm.__enter__ = MagicMock(side_effect=StaleImageError("image is 5 day(s) old"))
cm.__exit__ = MagicMock(return_value=False)
return cm
def _ok_cm(bottle: Any) -> MagicMock:
"""Return a context-manager mock that yields `bottle`."""
cm = MagicMock()
@@ -78,23 +70,25 @@ class TestLaunchBottleStaleHandling(unittest.TestCase):
)
def test_headless_stale_calls_die(self) -> None:
"""In headless mode (assume_yes=True), a StaleImageError must call die()."""
"""In headless mode (assume_yes=True), a StaleImageError from prelaunch_checks must call die()."""
import bot_bottle.cli.start as start_mod
backend_mock = MagicMock()
backend_mock.launch.return_value = _stale_cm()
backend_mock.prelaunch_checks.side_effect = StaleImageError("image is 5 day(s) old")
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
patch.object(start_mod, "die", side_effect=Die()):
with self.assertRaises(Die):
self._run_launch(assume_yes=True)
def test_interactive_user_declines_stops_loop(self) -> None:
"""Interactive user answering 'n' → loop exits without a second launch."""
backend_mock.launch.assert_not_called()
def test_interactive_user_declines_stops_before_launch(self) -> None:
"""Interactive user answering 'n' → launch is never called."""
import bot_bottle.cli.start as start_mod
backend_mock = MagicMock()
backend_mock.launch.return_value = _stale_cm()
backend_mock.prelaunch_checks.side_effect = StaleImageError("image is 5 day(s) old")
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
patch.object(start_mod, "read_tty_line", return_value="n"), \
@@ -102,23 +96,18 @@ class TestLaunchBottleStaleHandling(unittest.TestCase):
rc = self._run_launch(assume_yes=False)
self.assertEqual(0, rc)
# launch was called once (stale), then the user declined → no second call.
backend_mock.launch.assert_called_once()
backend_mock.launch.assert_not_called()
def test_interactive_user_confirms_retries_with_skip_stale(self) -> None:
"""Interactive user answering 'y'second launch with skip_stale=True."""
def test_interactive_user_confirms_launches_once(self) -> None:
"""Interactive user answering 'y'prelaunch stale error is bypassed; launch called once."""
import bot_bottle.cli.start as start_mod
bottle_mock = MagicMock()
bottle_mock.name = "dev-abc"
def launch_side_effect(plan: Any, *, skip_stale: bool = False) -> Any:
if not skip_stale:
return _stale_cm()
return _ok_cm(bottle_mock)
backend_mock = MagicMock()
backend_mock.launch.side_effect = launch_side_effect
backend_mock.prelaunch_checks.side_effect = StaleImageError("image is 5 day(s) old")
backend_mock.launch.return_value = _ok_cm(bottle_mock)
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
patch.object(start_mod, "read_tty_line", return_value="y"), \
@@ -128,11 +117,8 @@ class TestLaunchBottleStaleHandling(unittest.TestCase):
rc = self._run_launch(assume_yes=False)
self.assertEqual(0, rc)
# First call: skip_stale=False → stale. Second call: skip_stale=True → ok.
self.assertEqual(2, backend_mock.launch.call_count)
calls = backend_mock.launch.call_args_list
self.assertFalse(calls[0].kwargs.get("skip_stale", False))
self.assertTrue(calls[1].kwargs.get("skip_stale", False))
backend_mock.prelaunch_checks.assert_called_once()
backend_mock.launch.assert_called_once()
def test_interactive_yes_uppercase_also_accepted(self) -> None:
"""'Y' or 'YES' should also be accepted as confirmation."""
@@ -141,13 +127,9 @@ class TestLaunchBottleStaleHandling(unittest.TestCase):
bottle_mock = MagicMock()
bottle_mock.name = "dev-abc"
def launch_side_effect(plan: Any, *, skip_stale: bool = False) -> Any:
if not skip_stale:
return _stale_cm()
return _ok_cm(bottle_mock)
backend_mock = MagicMock()
backend_mock.launch.side_effect = launch_side_effect
backend_mock.prelaunch_checks.side_effect = StaleImageError("image is 5 day(s) old")
backend_mock.launch.return_value = _ok_cm(bottle_mock)
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
patch.object(start_mod, "read_tty_line", return_value="YES"), \
@@ -157,7 +139,7 @@ class TestLaunchBottleStaleHandling(unittest.TestCase):
rc = self._run_launch(assume_yes=False)
self.assertEqual(0, rc)
self.assertEqual(2, backend_mock.launch.call_count)
backend_mock.launch.assert_called_once()
if __name__ == "__main__":
@@ -12,7 +12,7 @@ from typing import Any
from unittest import mock
from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.backend import BottleSpec
from bot_bottle.backend import BottleImages, BottleSpec
from bot_bottle.backend.docker import launch as launch_mod
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan
@@ -90,7 +90,7 @@ class TestLaunchCommittedImage(unittest.TestCase):
committed_tag: str | None = None,
image_present: bool = True,
) -> list[str]:
"""Drive launch() through its full sequence with the committed-image
"""Drive build_or_load_images() + launch() 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: list[str] = []
@@ -124,8 +124,9 @@ class TestLaunchCommittedImage(unittest.TestCase):
mock.patch.object(launch_mod, "compose_dump_logs"), \
mock.patch.object(launch_mod, "compose_down"), \
contextlib.redirect_stderr(io.StringIO()):
images = launch_mod.build_or_load_images(plan)
provision = mock.Mock(return_value=None)
with launch_mod.launch(plan, provision=provision):
with launch_mod.launch(plan, images, provision=provision):
pass
return built
@@ -170,8 +171,9 @@ class TestLaunchCommittedImage(unittest.TestCase):
mock.patch.object(launch_mod, "compose_dump_logs"), \
mock.patch.object(launch_mod, "compose_down"), \
contextlib.redirect_stderr(io.StringIO()):
images = launch_mod.build_or_load_images(plan)
provision = mock.Mock(return_value=None)
with launch_mod.launch(plan, provision=provision):
with launch_mod.launch(plan, images, provision=provision):
pass
self.assertEqual(1, len(captured_plans))
+4 -2
View File
@@ -16,7 +16,7 @@ from pathlib import Path
from unittest import mock
from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.backend import BottleSpec
from bot_bottle.backend import BottleImages, BottleSpec
from bot_bottle.backend.docker import launch as launch_mod
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan
@@ -86,6 +86,8 @@ class TestTeardownWarning(unittest.TestCase):
plan = _plan(self._tmp)
buf = io.StringIO()
images = BottleImages(agent="bot-bottle-claude:latest", sidecar="bot-bottle-sidecars:latest")
with mock.patch.object(launch_mod.docker_mod, "build_image"), \
mock.patch.object(
launch_mod, "egress_tls_init",
@@ -115,7 +117,7 @@ class TestTeardownWarning(unittest.TestCase):
), \
contextlib.redirect_stderr(buf):
provision = mock.Mock(return_value=None)
with launch_mod.launch(plan, provision=provision):
with launch_mod.launch(plan, images, provision=provision):
pass
output = buf.getvalue()
+11 -11
View File
@@ -11,7 +11,7 @@ from typing import cast
from unittest.mock import patch
from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.backend import BottleSpec
from bot_bottle.backend import BottleImages, BottleSpec
from bot_bottle.backend.macos_container import launch
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
from bot_bottle.egress import EgressPlan
@@ -339,9 +339,9 @@ class TestMacosContainerLaunchCommittedImage(unittest.TestCase):
), patch.object(
launch.container_mod, "build_image", side_effect=fake_build,
), patch.object(launch, "info"):
updated = launch._build_images(plan)
images = launch.build_or_load_images(plan)
self.assertEqual("bot-bottle-committed-dev-abc:latest", updated.image)
self.assertEqual("bot-bottle-committed-dev-abc:latest", images.agent)
self.assertEqual(1, len(calls))
self.assertEqual(launch.SIDECAR_BUNDLE_IMAGE, calls[0][0])
@@ -360,9 +360,9 @@ class TestMacosContainerLaunchCommittedImage(unittest.TestCase):
), patch.object(
launch.container_mod, "build_image", side_effect=fake_build,
):
updated = launch._build_images(plan)
images = launch.build_or_load_images(plan)
self.assertEqual("bot-bottle-agent:latest", updated.image)
self.assertEqual("bot-bottle-agent:latest", images.agent)
self.assertEqual(2, len(calls))
self.assertEqual("bot-bottle-agent:latest", calls[1][0])
@@ -395,7 +395,7 @@ class TestMacosContainerLaunchCachedImages(unittest.TestCase):
launch, "die", side_effect=Die(),
):
with self.assertRaises(Die):
launch._build_images(plan)
launch.build_or_load_images(plan)
def test_cached_mode_dies_when_sidecar_image_missing(self) -> None:
plan = self._cached_plan()
@@ -411,7 +411,7 @@ class TestMacosContainerLaunchCachedImages(unittest.TestCase):
launch, "die", side_effect=Die(),
):
with self.assertRaises(Die):
launch._build_images(plan)
launch.build_or_load_images(plan)
def test_cached_mode_both_present_returns_unchanged_plan(self) -> None:
plan = self._cached_plan()
@@ -422,10 +422,10 @@ class TestMacosContainerLaunchCachedImages(unittest.TestCase):
), patch.object(
launch.container_mod, "build_image",
) as build, patch.object(launch, "info"):
result = launch._build_images(plan)
images = launch.build_or_load_images(plan)
build.assert_not_called()
self.assertEqual(plan.image, result.image)
self.assertEqual(plan.image, images.agent)
def test_committed_image_plus_cached_skips_sidecar_build(self) -> None:
plan = self._cached_plan()
@@ -437,11 +437,11 @@ class TestMacosContainerLaunchCachedImages(unittest.TestCase):
), patch.object(
launch.container_mod, "build_image",
) as build, patch.object(launch, "info"):
result = launch._build_images(plan)
images = launch.build_or_load_images(plan)
# In cached mode with a committed image, no builds should fire.
build.assert_not_called()
self.assertEqual("bot-bottle-committed-dev-abc:latest", result.image)
self.assertEqual("bot-bottle-committed-dev-abc:latest", images.agent)
if __name__ == "__main__":
+4 -6
View File
@@ -560,13 +560,12 @@ class TestLaunchResourceWiring(unittest.TestCase):
),
)
sidecar_artifact = Path("/cache/sidecar.smolmachine")
with patch(
"bot_bottle.backend.smolmachines.launch._provision_git_gate_keys",
return_value=plan,
), patch(
"bot_bottle.backend.smolmachines.launch._ensure_smolmachine",
return_value=Path("/cache/sidecar.smolmachine"),
) as ensure, patch(
"bot_bottle.backend.smolmachines.launch._bundle.start_bundle_vm",
return_value=raw_launch,
) as start_vm, patch(
@@ -576,11 +575,10 @@ class TestLaunchResourceWiring(unittest.TestCase):
"bot_bottle.backend.smolmachines.launch._forward.start_forwarder",
return_value=handle,
) as start_forwarder:
stamped = _launch._start_bundle(plan, "net", "127.0.0.16", stack)
stamped = _launch._start_bundle(plan, "net", "127.0.0.16", sidecar_artifact, stack)
ensure.assert_called_once()
start_vm.assert_called_once()
self.assertEqual(Path("/cache/sidecar.smolmachine"), start_vm.call_args.kwargs["from_path"])
self.assertEqual(sidecar_artifact, start_vm.call_args.kwargs["from_path"])
specs = start_forwarder.call_args.args[0]
self.assertEqual(
(
+21 -31
View File
@@ -1,5 +1,5 @@
"""Unit: stale-image check functions across backends, and the
BottleBackend.launch template method (skip_stale flag).
BottleBackend.launch template method (prelaunch_checks + build_or_load_images).
No real images or containers are used — all Docker/container/smolmachine
calls are mocked at the module boundary."""
@@ -23,51 +23,41 @@ def _bottle_cm(bottle: Any) -> MagicMock:
# ---------------------------------------------------------------------------
# BottleBackend.launch template — _image_stale_checks + skip_stale
# BottleBackend.launch template — prelaunch_checks + _build_or_load_images
# ---------------------------------------------------------------------------
class TestBottleBackendLaunchTemplate(unittest.TestCase):
"""Verify the concrete launch() method on BottleBackend calls
_image_stale_checks unless skip_stale=True, then delegates to _launch_impl."""
_build_or_load_images and _launch_impl, and that prelaunch_checks is a no-op
on the base class."""
def _make_backend(self) -> Any:
from bot_bottle.backend.docker.backend import DockerBottleBackend
return DockerBottleBackend()
def test_stale_checks_called_by_default(self) -> None:
def test_launch_delegates_to_build_or_load_and_launch_impl(self) -> None:
from bot_bottle.backend import BottleImages
backend = self._make_backend()
plan = cast(Any, SimpleNamespace())
bottle = MagicMock()
images = BottleImages(agent="agent:latest", sidecar="sidecar:latest")
with patch.object(
backend, "_image_stale_checks",
) as stale_mock, patch.object(
backend, "_build_or_load_images", return_value=images,
) as build_mock, patch.object(
backend, "_launch_impl",
return_value=_bottle_cm(bottle),
):
) as impl_mock:
with backend.launch(plan):
pass
stale_mock.assert_called_once_with(plan)
build_mock.assert_called_once_with(plan)
impl_mock.assert_called_once_with(plan, images)
def test_skip_stale_bypasses_stale_checks(self) -> None:
backend = self._make_backend()
plan = cast(Any, SimpleNamespace())
bottle = MagicMock()
with patch.object(
backend, "_image_stale_checks",
) as stale_mock, patch.object(
backend, "_launch_impl",
return_value=_bottle_cm(bottle),
):
with backend.launch(plan, skip_stale=True):
pass
stale_mock.assert_not_called()
def test_noop_default_image_stale_checks(self) -> None:
def test_noop_default_prelaunch_checks(self) -> None:
from bot_bottle.backend.docker.backend import DockerBottleBackend
from bot_bottle.backend import BottleBackend
backend = DockerBottleBackend()
# Call the base-class _image_stale_checks directly to verify it's a no-op.
BottleBackend._image_stale_checks(backend, cast(Any, SimpleNamespace())) # type: ignore[arg-type]
# Base-class prelaunch_checks is a no-op — must not raise.
BottleBackend.prelaunch_checks(backend, cast(Any, SimpleNamespace())) # type: ignore[arg-type]
# ---------------------------------------------------------------------------
@@ -142,13 +132,13 @@ class TestDockerStaleChecks(unittest.TestCase):
self.assertEqual(1, cs.call_count)
self.assertIn(plan.image, cs.call_args.args[0])
def test_backend_image_stale_checks_delegates(self) -> None:
def test_backend_prelaunch_checks_delegates(self) -> None:
from bot_bottle.backend.docker.backend import DockerBottleBackend
from bot_bottle.backend.docker import launch as mod
backend = DockerBottleBackend()
plan = self._plan()
with patch.object(mod, "stale_checks") as sc:
backend._image_stale_checks(plan) # type: ignore[arg-type]
backend.prelaunch_checks(plan) # type: ignore[arg-type]
sc.assert_called_once_with(plan)
@@ -227,13 +217,13 @@ class TestSmolmachinesStaleChecks(unittest.TestCase):
sidecar_calls = [c for c in csp.call_args_list if "sidecar" in c.args[0]]
self.assertGreaterEqual(len(sidecar_calls), 1)
def test_backend_image_stale_checks_delegates(self) -> None:
def test_backend_prelaunch_checks_delegates(self) -> None:
from bot_bottle.backend.smolmachines.backend import SmolmachinesBottleBackend
from bot_bottle.backend.smolmachines import launch as mod
backend = SmolmachinesBottleBackend()
plan = self._plan()
with patch.object(mod, "stale_checks") as sc:
backend._image_stale_checks(plan) # type: ignore[arg-type]
backend.prelaunch_checks(plan) # type: ignore[arg-type]
sc.assert_called_once_with(plan)
@@ -288,13 +278,13 @@ class TestMacosContainerStaleChecks(unittest.TestCase):
mod.stale_checks(self._plan())
cs.assert_not_called()
def test_backend_image_stale_checks_delegates(self) -> None:
def test_backend_prelaunch_checks_delegates(self) -> None:
from bot_bottle.backend.macos_container.backend import MacosContainerBottleBackend
from bot_bottle.backend.macos_container import launch as mod
backend = MacosContainerBottleBackend()
plan = self._plan()
with patch.object(mod, "stale_checks") as sc:
backend._image_stale_checks(plan) # type: ignore[arg-type]
backend.prelaunch_checks(plan) # type: ignore[arg-type]
sc.assert_called_once_with(plan)