From 2641ab70fd750bdb71ac54b48bbba15f253a1eea Mon Sep 17 00:00:00 2001 From: didericis Date: Thu, 16 Jul 2026 21:52:11 -0400 Subject: [PATCH] feat(supervise): start orchestrator on demand via backend-agnostic bring-up Add BottleBackend.ensure_orchestrator() -> str: the backend-agnostic entry point that brings up the per-host orchestrator + shared gateway (idempotent) and returns its host-reachable control-plane URL. Docker starts the orchestrator + gateway containers (OrchestratorService); firecracker boots the infra VM; macos-container dies with a pointer (no orchestrator). Previously bring-up was reachable only through each backend's consolidated_launch, with no shared handle. Wire it into `bot-bottle supervise`: supervise is often the first thing an operator runs, before any bottle has booted the control plane, so `_resolve_orchestrator_url` now starts the selected backend's orchestrator on demand when discovery finds nothing, instead of failing with "launch a bottle first". Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ --- bot_bottle/backend/__init__.py | 12 ++++++++ bot_bottle/backend/docker/backend.py | 4 +++ bot_bottle/backend/firecracker/backend.py | 4 +++ bot_bottle/cli/supervise.py | 26 +++++++++++++++-- tests/unit/test_backend_selection.py | 35 +++++++++++++++++++++++ tests/unit/test_supervise_cli.py | 26 +++++++++++++++++ 6 files changed, 105 insertions(+), 2 deletions(-) diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index 1efff36..a1381e4 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -511,6 +511,18 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): del plan return "" + def ensure_orchestrator(self) -> str: + """Bring up this backend's per-host orchestrator + shared gateway + (idempotent) and return the host-reachable control-plane URL. + + This is the backend-agnostic bring-up entry point: `launch` calls + it as part of starting a bottle, and operator tools (`supervise`) + call it to start the control plane on demand when none is running + yet. Docker starts the orchestrator + gateway containers; + firecracker boots the infra VM. Backends with no orchestrator + (macos-container) die with a pointer — the default here.""" + die(f"backend {self.name!r} has no orchestrator control plane") + @abstractmethod def prepare_cleanup(self) -> CleanupT: """Enumerate orphaned resources from previous bottles. No side diff --git a/bot_bottle/backend/docker/backend.py b/bot_bottle/backend/docker/backend.py index abff088..3626802 100644 --- a/bot_bottle/backend/docker/backend.py +++ b/bot_bottle/backend/docker/backend.py @@ -105,6 +105,10 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup with _launch.launch(plan, provision=self.provision) as bottle: yield bottle + def ensure_orchestrator(self) -> str: + from ...orchestrator.lifecycle import OrchestratorService + return OrchestratorService().ensure_running() + def supervise_mcp_url(self, plan: DockerBottlePlan) -> str: """Docker bottles reach the supervise daemon via the compose-network alias `supervise:9100`. No per-bottle URL diff --git a/bot_bottle/backend/firecracker/backend.py b/bot_bottle/backend/firecracker/backend.py index e751ad2..de33f90 100644 --- a/bot_bottle/backend/firecracker/backend.py +++ b/bot_bottle/backend/firecracker/backend.py @@ -110,3 +110,7 @@ class FirecrackerBottleBackend( def supervise_mcp_url(self, plan: FirecrackerBottlePlan) -> str: return plan.agent_supervise_url + + def ensure_orchestrator(self) -> str: + from . import infra_vm + return infra_vm.ensure_running().control_plane_url diff --git a/bot_bottle/cli/supervise.py b/bot_bottle/cli/supervise.py index add3f6c..f8d8055 100644 --- a/bot_bottle/cli/supervise.py +++ b/bot_bottle/cli/supervise.py @@ -65,10 +65,26 @@ ApplyError = (OrchestratorClientError,) _client_instance: OrchestratorClient | None = None +def _resolve_orchestrator_url() -> str: + """URL of the running orchestrator control plane, starting one on demand. + + Supervise is often the first thing an operator runs — before any bottle + has booted the control plane. So when discovery finds nothing, bring up + the selected backend's orchestrator + gateway (idempotent) rather than + failing with "launch a bottle first".""" + try: + return discover_orchestrator_url() + except OrchestratorClientError: + from ..backend import get_bottle_backend + backend = get_bottle_backend() + info(f"no orchestrator control plane running; starting one ({backend.name})…") + return backend.ensure_orchestrator() + + def _client() -> OrchestratorClient: global _client_instance # noqa: PLW0603 — CLI-session singleton if _client_instance is None: - _client_instance = OrchestratorClient(discover_orchestrator_url()) + _client_instance = OrchestratorClient(_resolve_orchestrator_url()) return _client_instance @@ -204,12 +220,18 @@ def cmd_supervise(argv: list[str]) -> int: args = parser.parse_args(argv) # Establish the orchestrator connection up front so a missing control - # plane is a clean one-line error, not a curses crash mid-loop. + # plane is a clean one-line error, not a curses crash mid-loop. This also + # starts the orchestrator on demand when none is running (see `_client`). try: _client() except OrchestratorClientError as e: error(str(e)) return 1 + except Die as e: + # Backend has no orchestrator to start (e.g. macos-container). + if e.message: + error(e.message) + return e.code if isinstance(e.code, int) else 1 if args.once: return _list_once() diff --git a/tests/unit/test_backend_selection.py b/tests/unit/test_backend_selection.py index f6718c9..6edfe08 100644 --- a/tests/unit/test_backend_selection.py +++ b/tests/unit/test_backend_selection.py @@ -244,5 +244,40 @@ class TestHasBackend(unittest.TestCase): self.assertFalse(has_backend("nonexistent")) +class TestEnsureOrchestrator(unittest.TestCase): + """The backend-agnostic orchestrator bring-up entry point. Docker starts + the orchestrator + gateway containers; firecracker boots the infra VM; + backends without one (macos-container) die with a pointer.""" + + def test_docker_delegates_to_orchestrator_service(self): + b = get_bottle_backend("docker") + with patch( + "bot_bottle.orchestrator.lifecycle.OrchestratorService" + ) as service_cls: + service_cls.return_value.ensure_running.return_value = ( + "http://127.0.0.1:8099" + ) + url = b.ensure_orchestrator() + self.assertEqual(url, "http://127.0.0.1:8099") + service_cls.return_value.ensure_running.assert_called_once_with() + + def test_firecracker_delegates_to_infra_vm(self): + b = get_bottle_backend("firecracker") + with patch( + "bot_bottle.backend.firecracker.infra_vm.ensure_running" + ) as ensure_running: + ensure_running.return_value.control_plane_url = ( + "http://10.243.255.1:8099" + ) + url = b.ensure_orchestrator() + self.assertEqual(url, "http://10.243.255.1:8099") + + def test_macos_default_dies(self): + from bot_bottle.log import Die + b = get_bottle_backend("macos-container") + with self.assertRaises(Die): + b.ensure_orchestrator() + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_supervise_cli.py b/tests/unit/test_supervise_cli.py index c1d1d2a..2e2bd65 100644 --- a/tests/unit/test_supervise_cli.py +++ b/tests/unit/test_supervise_cli.py @@ -176,5 +176,31 @@ class TestEditInEditor(unittest.TestCase): os.environ["EDITOR"] = original_editor +class TestResolveOrchestratorUrl(unittest.TestCase): + """`_resolve_orchestrator_url` starts the backend orchestrator on demand + when discovery finds nothing — supervise is often the first thing run.""" + + def test_returns_discovered_url_without_starting(self) -> None: + with patch.object( + supervise_cli, "discover_orchestrator_url", + return_value="http://127.0.0.1:8099", + ), patch("bot_bottle.backend.get_bottle_backend") as get_backend: + url = supervise_cli._resolve_orchestrator_url() + self.assertEqual(url, "http://127.0.0.1:8099") + get_backend.assert_not_called() # nothing to start; discovery won + + def test_starts_backend_orchestrator_when_none_running(self) -> None: + backend = MagicMock() + backend.name = "firecracker" + backend.ensure_orchestrator.return_value = "http://10.243.255.1:8099" + with patch.object( + supervise_cli, "discover_orchestrator_url", + side_effect=supervise_cli.OrchestratorClientError("none"), + ), patch("bot_bottle.backend.get_bottle_backend", return_value=backend): + url = supervise_cli._resolve_orchestrator_url() + self.assertEqual(url, "http://10.243.255.1:8099") + backend.ensure_orchestrator.assert_called_once_with() + + if __name__ == "__main__": unittest.main()