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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user