dee0121e8d
prd-number-check / require-numbered-prds (pull_request) Successful in 13s
tracker-policy-pr / check-pr (pull_request) Successful in 7s
test / image-input-builds (pull_request) Successful in 41s
test / unit (pull_request) Successful in 51s
test / integration-docker (pull_request) Successful in 58s
test / coverage (pull_request) Successful in 41s
test / image-input-builds (push) Successful in 48s
test / unit (push) Successful in 56s
lint / lint (push) Successful in 1m2s
Update Quality Badges / update-badges (push) Successful in 1m4s
test / integration-docker (push) Failing after 2m53s
test / coverage (push) Has been skipped
On a Firecracker gateway cold boot, reconcile every live agent VM against the fresh gateway: push the new mitmproxy CA into each agent's trust store, re-provision git-gate repos/creds from the persisted upstreams snapshot, and restore egress tokens. Per-bottle failures are logged and skipped so one unreachable VM does not block the rest. New modules / changes: - backend/firecracker/reconcile.py: attach_bottled_agents_to_gateway, _push_ca, _reprovision_git_gate, _guest_ip_from_config - git_gate/provision.py: write upstreams.json after key provisioning so the bring-up reconcile can reconstruct the upstream table without the manifest - backend/firecracker/infra.py: call attach_bottled_agents_to_gateway in the cold-boot branch of ensure_running() - backend/base.py: no-op default on BottleBackend - backend/firecracker/consolidated_launch.py: remove superseded _reprovision_running_bottles / _guest_ip_from_config - orchestrator: OrchestratorCore.update_agent_secret + POST /bottles/<id>/secret + client.update_agent_secret (single-secret in-place update, the reusable primitive from the 2026-07-26 hotfix) - tests: 15 new tests in test_firecracker_reconcile.py; updated test_firecracker_infra.py cold-boot cases; stale FC reprovision tests removed from test_backend_secret_reprovision.py Closes #516.
169 lines
7.0 KiB
Python
169 lines
7.0 KiB
Python
"""Unit: the macOS orchestrator (control plane) container lifecycle (PRD 0070)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import Mock, patch
|
|
|
|
from bot_bottle.backend.macos_container.orchestrator import (
|
|
ORCHESTRATOR_DB_VOLUME,
|
|
ORCHESTRATOR_NAME,
|
|
MacosOrchestrator,
|
|
probe_orchestrator_url,
|
|
)
|
|
from bot_bottle.orchestrator.lifecycle import OrchestratorStartError
|
|
|
|
_ORCH = "bot_bottle.backend.macos_container.orchestrator"
|
|
|
|
|
|
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 TestMacosOrchestratorRun(unittest.TestCase):
|
|
def _run(self) -> list[str]:
|
|
run = Mock(return_value=_ok())
|
|
with patch(f"{_ORCH}.container_mod") as mod, \
|
|
patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
|
mod.dns_server.return_value = "1.1.1.1"
|
|
mod.bind_mount_spec.side_effect = _spec
|
|
mod.run_container_argv = run
|
|
MacosOrchestrator(repo_root=Path("/r"))._run_container("h1")
|
|
return run.call_args.args[0]
|
|
|
|
def test_runs_on_the_control_network_only(self) -> None:
|
|
argv = self._run()
|
|
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()
|
|
vols = [argv[i + 1] for i, a in enumerate(argv) if a == "--volume"]
|
|
self.assertTrue(any(v.startswith(f"{ORCHESTRATOR_DB_VOLUME}:") for v in vols))
|
|
|
|
def test_no_ca_mount(self) -> None:
|
|
# The CA lives with the gateway, not the control plane.
|
|
argv = self._run()
|
|
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:
|
|
self.assertIn("BOT_BOTTLE_SOURCE_HASH=h1", self._run())
|
|
|
|
def test_start_failure_raises(self) -> None:
|
|
with patch(f"{_ORCH}.container_mod") as mod, \
|
|
patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
|
mod.dns_server.return_value = "1.1.1.1"
|
|
mod.run_container_argv = Mock(return_value=_fail())
|
|
with self.assertRaises(OrchestratorStartError):
|
|
MacosOrchestrator(repo_root=Path("/r"))._run_container("h1")
|
|
|
|
|
|
class TestMacosOrchestratorUrls(unittest.TestCase):
|
|
def test_url_and_gateway_url_are_the_control_net_address(self) -> None:
|
|
orch = MacosOrchestrator(port=8099)
|
|
with patch(f"{_ORCH}.container_mod") as mod:
|
|
mod.try_container_ipv4_on_network.return_value = "192.168.128.2"
|
|
self.assertEqual("http://192.168.128.2:8099", orch.url())
|
|
self.assertEqual("http://192.168.128.2:8099", orch.gateway_url())
|
|
|
|
def test_url_empty_when_no_address(self) -> None:
|
|
orch = MacosOrchestrator()
|
|
with patch(f"{_ORCH}.container_mod") as mod:
|
|
mod.try_container_ipv4_on_network.return_value = ""
|
|
self.assertEqual("", orch.url())
|
|
|
|
def test_is_healthy_false_when_no_address(self) -> None:
|
|
orch = MacosOrchestrator()
|
|
with patch(f"{_ORCH}.container_mod") as mod:
|
|
mod.try_container_ipv4_on_network.return_value = ""
|
|
self.assertFalse(orch.is_healthy())
|
|
|
|
|
|
class TestMacosOrchestratorEnsureRunning(unittest.TestCase):
|
|
def _orch(self) -> MacosOrchestrator:
|
|
return MacosOrchestrator(repo_root=Path("/r"))
|
|
|
|
def test_noop_when_current_and_healthy(self) -> None:
|
|
orch = self._orch()
|
|
with patch(f"{_ORCH}.source_hash", return_value="h1"), \
|
|
patch.object(orch, "_source_current", return_value=True), \
|
|
patch.object(orch, "is_healthy", return_value=True), \
|
|
patch.object(orch, "_run_container") as run:
|
|
orch.ensure_running()
|
|
run.assert_not_called()
|
|
|
|
def test_recreates_when_source_changed(self) -> None:
|
|
orch = self._orch()
|
|
with patch(f"{_ORCH}.source_hash", return_value="h2"), \
|
|
patch.object(orch, "_source_current", return_value=False), \
|
|
patch.object(orch, "_run_container") as run, \
|
|
patch.object(orch, "_wait_healthy"):
|
|
orch.ensure_running()
|
|
run.assert_called_once()
|
|
|
|
def test_recreates_when_current_but_wedged(self) -> None:
|
|
orch = self._orch()
|
|
with patch(f"{_ORCH}.source_hash", return_value="h1"), \
|
|
patch.object(orch, "_source_current", return_value=True), \
|
|
patch.object(orch, "is_healthy", return_value=False), \
|
|
patch.object(orch, "_run_container") as run, \
|
|
patch.object(orch, "_wait_healthy"):
|
|
orch.ensure_running()
|
|
run.assert_called_once()
|
|
|
|
def test_never_healthy_raises(self) -> None:
|
|
orch = self._orch()
|
|
with patch(f"{_ORCH}.source_hash", return_value="h1"), \
|
|
patch.object(orch, "_source_current", return_value=False), \
|
|
patch.object(orch, "_run_container"), \
|
|
patch.object(orch, "is_healthy", return_value=False), \
|
|
patch(f"{_ORCH}.time.sleep"), \
|
|
patch(f"{_ORCH}.time.monotonic", side_effect=[0.0, 0.5, 2.0]):
|
|
with self.assertRaises(OrchestratorStartError):
|
|
orch.ensure_running(startup_timeout=1.0)
|
|
|
|
|
|
class TestMacosOrchestratorBuildStop(unittest.TestCase):
|
|
def test_ensure_built_builds_the_orchestrator_image(self) -> None:
|
|
orch = MacosOrchestrator(repo_root=Path("/r"))
|
|
with patch(f"{_ORCH}.container_mod") as mod:
|
|
orch.ensure_built()
|
|
_args, kwargs = mod.build_image.call_args
|
|
self.assertEqual("Dockerfile.orchestrator", kwargs["dockerfile"])
|
|
|
|
def test_stop_removes_the_container(self) -> None:
|
|
orch = MacosOrchestrator()
|
|
with patch(f"{_ORCH}.container_mod") as mod:
|
|
orch.stop()
|
|
mod.force_remove_container.assert_called_once_with(ORCHESTRATOR_NAME)
|
|
|
|
|
|
class TestProbeOrchestrator(unittest.TestCase):
|
|
def test_returns_url_when_running(self) -> None:
|
|
with patch(f"{_ORCH}.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"{_ORCH}.container_mod") as mod:
|
|
mod.try_container_ipv4_on_network.return_value = ""
|
|
self.assertEqual("", probe_orchestrator_url())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|