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
|
del plan
|
||||||
return ""
|
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
|
@abstractmethod
|
||||||
def prepare_cleanup(self) -> CleanupT:
|
def prepare_cleanup(self) -> CleanupT:
|
||||||
"""Enumerate orphaned resources from previous bottles. No side
|
"""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:
|
with _launch.launch(plan, provision=self.provision) as bottle:
|
||||||
yield 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:
|
def supervise_mcp_url(self, plan: DockerBottlePlan) -> str:
|
||||||
"""Docker bottles reach the supervise daemon via the
|
"""Docker bottles reach the supervise daemon via the
|
||||||
compose-network alias `supervise:9100`. No per-bottle URL
|
compose-network alias `supervise:9100`. No per-bottle URL
|
||||||
|
|||||||
@@ -110,3 +110,7 @@ class FirecrackerBottleBackend(
|
|||||||
|
|
||||||
def supervise_mcp_url(self, plan: FirecrackerBottlePlan) -> str:
|
def supervise_mcp_url(self, plan: FirecrackerBottlePlan) -> str:
|
||||||
return plan.agent_supervise_url
|
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
|
_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:
|
def _client() -> OrchestratorClient:
|
||||||
global _client_instance # noqa: PLW0603 — CLI-session singleton
|
global _client_instance # noqa: PLW0603 — CLI-session singleton
|
||||||
if _client_instance is None:
|
if _client_instance is None:
|
||||||
_client_instance = OrchestratorClient(discover_orchestrator_url())
|
_client_instance = OrchestratorClient(_resolve_orchestrator_url())
|
||||||
return _client_instance
|
return _client_instance
|
||||||
|
|
||||||
|
|
||||||
@@ -204,12 +220,18 @@ def cmd_supervise(argv: list[str]) -> int:
|
|||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
# Establish the orchestrator connection up front so a missing control
|
# 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:
|
try:
|
||||||
_client()
|
_client()
|
||||||
except OrchestratorClientError as e:
|
except OrchestratorClientError as e:
|
||||||
error(str(e))
|
error(str(e))
|
||||||
return 1
|
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:
|
if args.once:
|
||||||
return _list_once()
|
return _list_once()
|
||||||
|
|||||||
@@ -244,5 +244,40 @@ class TestHasBackend(unittest.TestCase):
|
|||||||
self.assertFalse(has_backend("nonexistent"))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -176,5 +176,31 @@ class TestEditInEditor(unittest.TestCase):
|
|||||||
os.environ["EDITOR"] = original_editor
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user