refactor(orchestrator): macOS orchestrator under the Orchestrator ABC

Extract the Apple-Container control-plane lifecycle out of `MacosInfraService`
into `MacosOrchestrator` (backend/macos_container/orchestrator.py): the
container run on the host-only control network, the container-only DB volume,
the source-hash recreate gate, health polling against the resolved
control-network address, and `probe_orchestrator_url`. `url()` == `gateway_url()`
here (Apple has no container DNS, so one resolved address serves the CLI and the
gateway).

`MacosInfraService` now composes `orchestrator()` + `gateway()`: ensure the
networks, build both images (each service self-builds via `ensure_built` — added
to `MacosGateway` too), bring the orchestrator up first, then connect the gateway
with the orchestrator-minted token. `ORCHESTRATOR_IMAGE` + the DB-volume constant
move to the orchestrator module (with back-compat aliases where imported).

Container-lifecycle tests split into test_macos_orchestrator; test_macos_infra
now covers the composition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 14:44:21 -04:00
parent cc4e29c3da
commit 5465379654
5 changed files with 486 additions and 301 deletions
+48 -144
View File
@@ -1,153 +1,59 @@
"""Unit: macOS orchestrator + gateway containers (PRD 0070 plane split)."""
"""Unit: MacosInfraService composes the orchestrator + gateway services.
`MacosInfraService` no longer owns the container lifecycles — it composes the
`MacosOrchestrator` (control plane) and `MacosGateway` (data plane) services,
each tested in its own module. These tests isolate the *composition*: the
networks are ensured, both images built, the orchestrator comes up first, then
the gateway is connected to it with a freshly minted token."""
from __future__ import annotations
import unittest
from pathlib import Path
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import MagicMock, patch
from bot_bottle.backend.macos_container.infra import (
INFRA_DB_VOLUME,
MacosInfraService,
OrchestratorStartError,
probe_orchestrator_url,
)
from bot_bottle.backend.macos_container.infra import MacosInfraService
_INFRA = "bot_bottle.backend.macos_container.infra"
def _ok(stdout: str = "") -> Mock:
return Mock(returncode=0, stdout=stdout, stderr="")
def _fail(stderr: str = "boom") -> Mock:
return Mock(returncode=1, stdout="", stderr=stderr)
def _spec(src: str, tgt: str, readonly: bool = False) -> str:
return f"type=bind,source={src},target={tgt}" + (",readonly" if readonly else "")
class TestOrchestratorRun(unittest.TestCase):
def _run(self, svc: MacosInfraService) -> list[str]:
run = Mock(return_value=_ok())
with patch(f"{_INFRA}.container_mod") as mod, \
patch(f"{_INFRA}.host_orchestrator_token", return_value="k"):
mod.dns_server.return_value = "1.1.1.1"
mod.bind_mount_spec.side_effect = _spec
mod.run_container_argv = run
svc._run_orchestrator_container("h1")
return run.call_args.args[0]
def test_runs_the_orchestrator_on_the_control_network_only(self) -> None:
argv = self._run(MacosInfraService(repo_root=Path("/r")))
nets = [argv[i + 1] for i, a in enumerate(argv) if a == "--network"]
self.assertEqual(["bot-bottle-mac-control"], nets)
# Image ENTRYPOINT is `-m bot_bottle.orchestrator`; these are its args.
self.assertIn("--broker", argv)
self.assertIn("stub", argv)
self.assertIn("bot-bottle-orchestrator:latest", argv)
def test_db_is_a_container_only_volume(self) -> None:
argv = self._run(MacosInfraService(repo_root=Path("/r")))
vols = [argv[i + 1] for i, a in enumerate(argv) if a == "--volume"]
self.assertTrue(any(v.startswith(f"{INFRA_DB_VOLUME}:") for v in vols))
def test_orchestrator_has_no_ca_mount(self) -> None:
# The CA lives with the gateway, not the control plane.
argv = self._run(MacosInfraService(repo_root=Path("/r")))
mounts = [argv[i + 1] for i, a in enumerate(argv) if a == "--mount"]
self.assertFalse([m for m in mounts if "/home/mitmproxy" in m])
def test_source_hash_is_labelled_for_recreate(self) -> None:
argv = self._run(MacosInfraService(repo_root=Path("/r")))
self.assertIn("BOT_BOTTLE_SOURCE_HASH=h1", argv)
def test_start_failure_raises(self) -> None:
svc = MacosInfraService(repo_root=Path("/r"))
with patch(f"{_INFRA}.container_mod") as mod, \
patch(f"{_INFRA}.host_orchestrator_token", return_value="k"):
mod.dns_server.return_value = "1.1.1.1"
mod.run_container_argv = Mock(return_value=_fail())
with self.assertRaises(OrchestratorStartError):
svc._run_orchestrator_container("h1")
class TestInfraEnsureRunning(unittest.TestCase):
"""Isolate the orchestrator-container logic; the gateway is brought up by
`MacosGateway` (its own module, tested separately), so mock `svc.gateway`.
`ensure_running` mints the `gateway` token, so `mint` /
`host_orchestrator_token` are stubbed."""
def setUp(self) -> None:
self.svc = MacosInfraService(repo_root=Path("/r"))
self.orch = MagicMock()
self.orch.url.return_value = "http://192.168.128.2:8099"
self.orch.mint_gateway_token.return_value = "gw.jwt"
self.gw = MagicMock()
for name, mock in (("orchestrator", self.orch), ("gateway", self.gw)):
p = patch.object(self.svc, name, return_value=mock)
p.start()
self.addCleanup(p.stop)
def _patches(self, svc: MacosInfraService):
gw = MagicMock()
return gw, (
patch.object(svc, "ensure_built"),
patch.object(svc, "gateway", return_value=gw),
patch(f"{_INFRA}.host_orchestrator_token", return_value="k"),
patch(f"{_INFRA}.mint", return_value="jwt"),
)
def test_current_healthy_orchestrator_left_alone(self) -> None:
svc = MacosInfraService(repo_root=Path("/r"))
run = Mock()
gw, extra = self._patches(svc)
with patch(f"{_INFRA}.container_mod") as mod, \
patch(f"{_INFRA}.source_hash", return_value="h1"), \
patch.object(svc, "_run_orchestrator_container", run), \
patch.object(svc, "is_healthy", return_value=True), \
extra[0], extra[1], extra[2], extra[3]:
mod.container_is_running.return_value = True
mod.container_env.return_value = {"BOT_BOTTLE_SOURCE_HASH": "h1"}
mod.try_container_ipv4_on_network.return_value = "192.168.128.2"
endpoint = svc.ensure_running()
run.assert_not_called()
gw.connect_to_orchestrator.assert_called_once()
def test_composes_networks_builds_and_brings_up_orchestrator_first(self) -> None:
with patch(f"{_INFRA}.ensure_networks") as nets, \
patch(f"{_INFRA}.container_mod") as mod:
mod.try_container_ipv4_on_network.return_value = "192.168.128.3"
endpoint = self.svc.ensure_running()
nets.assert_called_once()
self.orch.ensure_built.assert_called_once()
self.gw.ensure_built.assert_called_once()
self.orch.ensure_running.assert_called_once()
self.assertEqual("http://192.168.128.2:8099", endpoint.orchestrator_url)
self.assertEqual("192.168.128.2", endpoint.gateway_ip)
self.assertEqual("192.168.128.3", endpoint.gateway_ip)
def test_changed_source_recreates_orchestrator(self) -> None:
svc = MacosInfraService(repo_root=Path("/r"))
run = Mock()
_gw, extra = self._patches(svc)
with patch(f"{_INFRA}.container_mod") as mod, \
patch(f"{_INFRA}.source_hash", return_value="h2"), \
patch.object(svc, "_run_orchestrator_container", run), \
patch.object(svc, "is_healthy", return_value=True), \
extra[0], extra[1], extra[2], extra[3]:
mod.container_is_running.return_value = True
mod.container_env.return_value = {"BOT_BOTTLE_SOURCE_HASH": "h1"}
mod.try_container_ipv4_on_network.return_value = "192.168.128.2"
svc.ensure_running()
run.assert_called_once()
def test_connects_gateway_with_orchestrator_url_and_minted_token(self) -> None:
with patch(f"{_INFRA}.ensure_networks"), \
patch(f"{_INFRA}.container_mod") as mod:
mod.try_container_ipv4_on_network.return_value = "192.168.128.3"
self.svc.ensure_running()
self.gw.connect_to_orchestrator.assert_called_once_with(
"http://192.168.128.2:8099", "gw.jwt")
self.orch.mint_gateway_token.assert_called_once()
def test_wedged_but_current_orchestrator_recreated(self) -> None:
svc = MacosInfraService(repo_root=Path("/r"))
run = Mock()
_gw, extra = self._patches(svc)
with patch(f"{_INFRA}.container_mod") as mod, \
patch(f"{_INFRA}.source_hash", return_value="h1"), \
patch.object(svc, "_run_orchestrator_container", run), \
patch.object(svc, "is_healthy", Mock(side_effect=[False, True])), \
extra[0], extra[1], extra[2], extra[3]:
mod.container_is_running.return_value = True
mod.container_env.return_value = {"BOT_BOTTLE_SOURCE_HASH": "h1"}
mod.try_container_ipv4_on_network.return_value = "192.168.128.2"
svc.ensure_running()
run.assert_called_once()
def test_never_healthy_raises(self) -> None:
svc = MacosInfraService(repo_root=Path("/r"))
_gw, extra = self._patches(svc)
with patch(f"{_INFRA}.container_mod") as mod, \
patch(f"{_INFRA}.source_hash", return_value="h1"), \
patch.object(svc, "_run_orchestrator_container"), \
patch.object(svc, "is_healthy", return_value=False), \
extra[0], extra[1], extra[2], extra[3]:
mod.container_is_running.return_value = False
mod.try_container_ipv4_on_network.return_value = "192.168.128.2"
with self.assertRaises(OrchestratorStartError):
svc.ensure_running(startup_timeout=0.01)
def test_is_healthy_delegates_to_the_orchestrator(self) -> None:
self.orch.is_healthy.return_value = True
self.assertTrue(self.svc.is_healthy())
self.orch.is_healthy.assert_called_once()
class TestCaCertPem(unittest.TestCase):
@@ -161,16 +67,14 @@ class TestCaCertPem(unittest.TestCase):
gw.ca_cert_pem.assert_called_once_with(timeout=5)
class TestProbeOrchestrator(unittest.TestCase):
def test_returns_url_when_running(self) -> None:
class TestStop(unittest.TestCase):
def test_removes_both_containers(self) -> None:
svc = MacosInfraService(repo_root=Path("/r"))
with patch(f"{_INFRA}.container_mod") as mod:
mod.try_container_ipv4_on_network.return_value = "192.168.128.2"
self.assertEqual("http://192.168.128.2:8099", probe_orchestrator_url())
def test_empty_when_absent(self) -> None:
with patch(f"{_INFRA}.container_mod") as mod:
mod.try_container_ipv4_on_network.return_value = ""
self.assertEqual("", probe_orchestrator_url())
svc.stop()
removed = {c.args[0] for c in mod.force_remove_container.call_args_list}
self.assertIn("bot-bottle-mac-infra", removed) # gateway
self.assertIn("bot-bottle-mac-orchestrator", removed) # orchestrator
if __name__ == "__main__":