4a607ad098
Adopts the firecracker infra-VM pattern for macOS: the orchestrator control plane and the gateway data plane now run in a SINGLE Apple container instead of two. Apple Containers are lightweight VMs with separate kernels, so the prior two-container design had both guests writing one bot-bottle.db over virtiofs, where fcntl locks are not coherent across kernels — concurrent writes (the orchestrator's registry vs the gateway supervise daemon's queue) could corrupt it. One container = one kernel = coherent locking. The DB moves onto a container-only Apple volume (bot-bottle-mac-db), never bind-mounted from the host, so no host process opens the live file either. The host CLI already reaches registry + supervise state over the control-plane HTTP surface (cli/supervise.py uses OrchestratorClient), exactly as firecracker's VM-only DB requires. Two simplifications fall out of the single container: - No DNS dance: the control plane and gateway daemons reach each other over 127.0.0.1, so the orchestrator-before-gateway ordering (a workaround for Apple having no container DNS) is gone, along with the moved-IP recreate logic it needed. - Net -243 lines. Mechanics: the infra container runs from the gateway image with the control-plane source bind-mounted read-only (like the docker orchestrator, so a code change needs no rebuild) and a small sh -c init that starts both processes (mirrors firecracker's _infra_init). Also implements the macOS backend's ensure_orchestrator() and adds it to discover_orchestrator_url, so operator tools (supervise) can bring up / find the control plane on demand — previously the macOS backend died with "no orchestrator control plane". Verified end-to-end on real Apple Container 1.0.0: the single infra container comes up healthy (one address for control plane + gateway), both processes run, the DB is written on the container-only volume, host-side supervise works over HTTP, and a registered agent gets 200 for an allowed host / 403 for a denied one. 1824 unit tests pass with `container` absent (CI parity), pyright clean, pylint 9.89. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
137 lines
4.9 KiB
Python
137 lines
4.9 KiB
Python
"""Unit: macOS consolidated launch — register-after-start (PRD 0070)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, Mock, patch
|
|
|
|
from bot_bottle.backend.macos_container.consolidated_launch import (
|
|
GatewayEndpoint,
|
|
ensure_gateway,
|
|
register_agent,
|
|
teardown_consolidated,
|
|
)
|
|
from bot_bottle.egress import EgressPlan, EgressRoute
|
|
from bot_bottle.git_gate import GitGatePlan
|
|
from bot_bottle.orchestrator.client import RegisteredBottle
|
|
|
|
_MOD = "bot_bottle.backend.macos_container.consolidated_launch"
|
|
|
|
|
|
def _egress_plan() -> EgressPlan:
|
|
return EgressPlan(
|
|
slug="demo", routes_path=Path("/x"),
|
|
routes=(EgressRoute(host="api.example.com"),), token_env_map={},
|
|
)
|
|
|
|
|
|
def _git_plan() -> GitGatePlan:
|
|
return GitGatePlan(
|
|
slug="demo", entrypoint_script=Path(), hook_script=Path(),
|
|
access_hook_script=Path(), upstreams=(),
|
|
)
|
|
|
|
|
|
def _endpoint() -> GatewayEndpoint:
|
|
return GatewayEndpoint(
|
|
orchestrator_url="http://192.168.128.2:8099",
|
|
gateway_ip="192.168.128.3",
|
|
gateway_ca_pem="-----BEGIN CERTIFICATE-----\n",
|
|
network="bot-bottle-mac-gateway",
|
|
)
|
|
|
|
|
|
def _client() -> Mock:
|
|
c = Mock()
|
|
c.register_bottle.return_value = RegisteredBottle("b1", "tok")
|
|
return c
|
|
|
|
|
|
class TestEnsureGateway(unittest.TestCase):
|
|
def _run(self, service: MagicMock) -> GatewayEndpoint:
|
|
with patch(f"{_MOD}.MacosInfraService", return_value=service):
|
|
return ensure_gateway()
|
|
|
|
def _service(self) -> MagicMock:
|
|
from bot_bottle.backend.macos_container.infra import InfraEndpoint
|
|
service = MagicMock()
|
|
service.ensure_running.return_value = InfraEndpoint(
|
|
control_plane_url="http://192.168.128.2:8099",
|
|
gateway_ip="192.168.128.2",
|
|
)
|
|
service.network = "bot-bottle-mac-gateway"
|
|
service.ca_cert_pem.return_value = "PEM"
|
|
return service
|
|
|
|
def test_reports_gateway_endpoint(self) -> None:
|
|
endpoint = self._run(self._service())
|
|
self.assertEqual("http://192.168.128.2:8099", endpoint.orchestrator_url)
|
|
self.assertEqual("192.168.128.2", endpoint.gateway_ip)
|
|
self.assertEqual("PEM", endpoint.gateway_ca_pem)
|
|
self.assertEqual("bot-bottle-mac-gateway", endpoint.network)
|
|
|
|
def test_control_plane_and_gateway_share_one_address(self) -> None:
|
|
"""One infra container hosts both, so the gateway IP and the
|
|
control-plane host are the same."""
|
|
endpoint = self._run(self._service())
|
|
self.assertEqual(
|
|
endpoint.gateway_ip,
|
|
endpoint.orchestrator_url.split("://")[1].split(":")[0],
|
|
)
|
|
|
|
|
|
class TestRegisterAgent(unittest.TestCase):
|
|
def _run(
|
|
self, client: Mock, provision: Mock | None = None,
|
|
*, source_ip: str = "192.168.128.9",
|
|
):
|
|
with patch(f"{_MOD}.OrchestratorClient", return_value=client), \
|
|
patch(f"{_MOD}.provision_git_gate", provision or Mock()):
|
|
return register_agent(
|
|
_egress_plan(), _git_plan(),
|
|
source_ip=source_ip, endpoint=_endpoint(), image_ref="img:1",
|
|
)
|
|
|
|
def test_registers_by_the_address_read_from_the_live_container(self) -> None:
|
|
"""The attribution key is the DHCP-assigned address the caller read
|
|
back — there is no --ip to pin it up front."""
|
|
client = _client()
|
|
ctx = self._run(client, source_ip="192.168.128.9")
|
|
self.assertEqual("192.168.128.9", ctx.source_ip)
|
|
self.assertEqual("192.168.128.9", client.register_bottle.call_args.args[0])
|
|
|
|
def test_returns_identity_token_and_bottle_id(self) -> None:
|
|
ctx = self._run(_client())
|
|
self.assertEqual("b1", ctx.bottle_id)
|
|
self.assertEqual("tok", ctx.identity_token)
|
|
|
|
def test_provisions_git_gate_for_the_registered_bottle(self) -> None:
|
|
provision = Mock()
|
|
self._run(_client(), provision)
|
|
self.assertEqual("b1", provision.call_args.args[1])
|
|
|
|
def test_rolls_registration_back_when_provisioning_fails(self) -> None:
|
|
"""A provisioning failure must not leave an orphan registration
|
|
holding the source IP."""
|
|
client = _client()
|
|
provision = Mock(side_effect=RuntimeError("boom"))
|
|
with self.assertRaises(RuntimeError):
|
|
self._run(client, provision)
|
|
client.teardown_bottle.assert_called_once_with("b1")
|
|
|
|
|
|
class TestTeardown(unittest.TestCase):
|
|
def test_deregisters_and_deprovisions(self) -> None:
|
|
client = Mock()
|
|
deprovision = Mock()
|
|
with patch(f"{_MOD}.OrchestratorClient", return_value=client), \
|
|
patch(f"{_MOD}.deprovision_git_gate", deprovision):
|
|
teardown_consolidated("b1", orchestrator_url="http://o:8099")
|
|
client.teardown_bottle.assert_called_once_with("b1")
|
|
self.assertEqual("b1", deprovision.call_args.args[1])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|