bdca1c8bea
Issue #249: in practice the per-bottle `supervise` flag was never turned off — all bottles should be supervised. Remove the manifest flag and make the supervise sidecar unconditional, mirroring egress. - Reject `supervise:` as a removed bottle key with a migration hint. - Drop the `supervise` field from ManifestBottle and the extends merge. - prepare_supervise always returns a SupervisePlan; the plan type is now non-optional and the per-backend `is None` guards are gone, so the supervise daemon, current-config mount, aliases, and MCP registration always render. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YcU7nerbg8cVj9R4EkpfLJ
198 lines
7.1 KiB
Python
198 lines
7.1 KiB
Python
"""Unit: Docker launch step uses committed image when available."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
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
|
|
from bot_bottle.backend import 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
|
|
from bot_bottle.git_gate import GitGatePlan
|
|
from bot_bottle.supervise import SupervisePlan
|
|
from bot_bottle.manifest import ManifestIndex
|
|
|
|
|
|
_SLUG = "dev-abc12"
|
|
_COMMITTED_TAG = f"bot-bottle-committed-{_SLUG}:latest"
|
|
_DEFAULT_IMAGE = "bot-bottle-claude:latest"
|
|
|
|
_IDX = ManifestIndex.from_json_obj({
|
|
"bottles": {"dev": {}},
|
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
|
})
|
|
|
|
|
|
def _plan(tmp: str) -> DockerBottlePlan:
|
|
stage = Path(tmp)
|
|
spec = BottleSpec(
|
|
manifest=_IDX,
|
|
agent_name="demo",
|
|
copy_cwd=False,
|
|
user_cwd=tmp,
|
|
identity=_SLUG,
|
|
)
|
|
return DockerBottlePlan(
|
|
spec=spec,
|
|
manifest=_IDX.load_for_agent("demo"),
|
|
stage_dir=stage,
|
|
git_gate_plan=GitGatePlan(
|
|
slug=_SLUG,
|
|
entrypoint_script=stage / "entrypoint.sh",
|
|
hook_script=stage / "hook.sh",
|
|
access_hook_script=stage / "access-hook.sh",
|
|
upstreams=(),
|
|
),
|
|
egress_plan=EgressPlan(
|
|
slug=_SLUG,
|
|
routes_path=stage / "egress.yaml",
|
|
routes=(),
|
|
token_env_map={},
|
|
),
|
|
supervise_plan=SupervisePlan(
|
|
slug=_SLUG,
|
|
queue_dir=stage / "supervise" / "queue",
|
|
current_config_dir=stage / "supervise" / "current-config",
|
|
),
|
|
agent_provision=AgentProvisionPlan(
|
|
template="claude",
|
|
command="claude",
|
|
prompt_mode="append_file",
|
|
image=_DEFAULT_IMAGE,
|
|
dockerfile="",
|
|
guest_home="/home/node",
|
|
instance_name=f"bot-bottle-{_SLUG}",
|
|
prompt_file=stage / "prompt.txt",
|
|
guest_env={},
|
|
),
|
|
slug=_SLUG,
|
|
forwarded_env={},
|
|
use_runsc=False,
|
|
)
|
|
|
|
|
|
class TestLaunchCommittedImage(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self._tmp = tempfile.mkdtemp(prefix="launch-committed-test.")
|
|
|
|
def tearDown(self) -> None:
|
|
import shutil
|
|
shutil.rmtree(self._tmp, ignore_errors=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: list[str] = []
|
|
|
|
def fake_build(image: str, ctx: str, *, dockerfile: str = "") -> None:
|
|
del ctx, dockerfile
|
|
built.append(image)
|
|
|
|
with mock.patch.object(
|
|
launch_mod, "read_committed_image", return_value=committed_tag,
|
|
), mock.patch.object(
|
|
launch_mod.docker_mod, "image_exists", return_value=image_present,
|
|
), mock.patch.object(
|
|
launch_mod.docker_mod, "build_image", side_effect=fake_build,
|
|
), mock.patch.object(
|
|
launch_mod, "egress_tls_init",
|
|
return_value=(Path("/egress_ca"), Path("/egress_cert")),
|
|
), mock.patch.object(
|
|
launch_mod.network_mod, "network_name_for_slug",
|
|
return_value="bb-internal",
|
|
), mock.patch.object(
|
|
launch_mod.network_mod, "network_egress_name_for_slug",
|
|
return_value="bb-egress",
|
|
), mock.patch.object(
|
|
launch_mod, "bottle_plan_to_compose",
|
|
return_value={"services": {"agent": {}}},
|
|
), mock.patch.object(
|
|
launch_mod, "write_compose_file",
|
|
return_value=Path("/tmp/compose.yml"),
|
|
), mock.patch.object(launch_mod, "compose_up"), \
|
|
mock.patch.object(launch_mod, "compose_dump_logs"), \
|
|
mock.patch.object(launch_mod, "compose_down"), \
|
|
contextlib.redirect_stderr(io.StringIO()):
|
|
provision = mock.Mock(return_value=None)
|
|
with launch_mod.launch(plan, provision=provision):
|
|
pass
|
|
|
|
return built
|
|
|
|
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) -> 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: list[DockerBottlePlan] = []
|
|
|
|
def fake_compose(p: DockerBottlePlan) -> dict[str, Any]:
|
|
captured_plans.append(p)
|
|
return {"services": {"agent": {}}}
|
|
|
|
with mock.patch.object(
|
|
launch_mod, "read_committed_image", return_value=_COMMITTED_TAG,
|
|
), mock.patch.object(
|
|
launch_mod.docker_mod, "image_exists", return_value=True,
|
|
), mock.patch.object(
|
|
launch_mod.docker_mod, "build_image",
|
|
), mock.patch.object(
|
|
launch_mod, "egress_tls_init",
|
|
return_value=(Path("/egress_ca"), Path("/egress_cert")),
|
|
), mock.patch.object(
|
|
launch_mod.network_mod, "network_name_for_slug",
|
|
return_value="bb-internal",
|
|
), mock.patch.object(
|
|
launch_mod.network_mod, "network_egress_name_for_slug",
|
|
return_value="bb-egress",
|
|
), mock.patch.object(
|
|
launch_mod, "bottle_plan_to_compose", side_effect=fake_compose,
|
|
), mock.patch.object(
|
|
launch_mod, "write_compose_file",
|
|
return_value=Path("/tmp/compose.yml"),
|
|
), mock.patch.object(launch_mod, "compose_up"), \
|
|
mock.patch.object(launch_mod, "compose_dump_logs"), \
|
|
mock.patch.object(launch_mod, "compose_down"), \
|
|
contextlib.redirect_stderr(io.StringIO()):
|
|
provision = mock.Mock(return_value=None)
|
|
with launch_mod.launch(plan, provision=provision):
|
|
pass
|
|
|
|
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) -> 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) -> None:
|
|
plan = _plan(self._tmp)
|
|
built = self._run_launch(
|
|
plan, committed_tag=_COMMITTED_TAG, image_present=False,
|
|
)
|
|
self.assertEqual([_DEFAULT_IMAGE], built)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|