feat(macos): consolidated per-host gateway for the Apple backend (PRD 0070)
Re-enables the macos-container backend on the shared per-host orchestrator + gateway, replacing the per-bottle companion container removed in #385. This is the last backend in PRD 0070's roadmap. Apple Container 1.0.0 forced three departures from the docker shape, each verified against the live CLI (findings recorded in the networking spike): - No `--ip`. The address is DHCP-assigned and knowable only once the container runs, so the order inverts: gateway up -> run agent -> read its address -> register. The identity token is minted by registration and therefore cannot be in the agent's run-time env; it rides the proxy URL applied at `container exec` time (bare `--env` names keep it off argv). - No container DNS. The gateway can only be handed the control plane's IP, so the orchestrator starts first and the gateway is pointed at its address. - No `network connect`. Networks are fixed at run time, so the shared host-only network is created up front; per-bottle networks would restart the gateway on every launch and defeat the consolidation. The agent runs with `--cap-drop CAP_NET_RAW`: Apple grants NET_RAW by default, which would let an agent forge a neighbour's source address on the shared segment. NET_ADMIN is already absent, so this closes the source-address half of PRD 0070's attribution invariant. Verified end-to-end on real Apple Container 1.0.0: both images build, the control plane comes up healthy, the gateway reaches it by IP, and a registered agent gets 200 for a host in its routes and 403 for one outside them. Bring-up is idempotent — a second launch does not churn the singletons. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
"""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}.MacosOrchestratorService", return_value=service):
|
||||
return ensure_gateway()
|
||||
|
||||
def _service(self) -> MagicMock:
|
||||
service = MagicMock()
|
||||
service.ensure_running.return_value = "http://192.168.128.2:8099"
|
||||
service.network = "bot-bottle-mac-gateway"
|
||||
service.gateway.return_value.ip_on_shared_network.return_value = "192.168.128.3"
|
||||
service.gateway.return_value.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.3", endpoint.gateway_ip)
|
||||
self.assertEqual("PEM", endpoint.gateway_ca_pem)
|
||||
self.assertEqual("bot-bottle-mac-gateway", endpoint.network)
|
||||
|
||||
def test_gateway_is_pointed_at_the_resolved_control_plane(self) -> None:
|
||||
"""Apple has no container DNS, so the gateway must be handed the
|
||||
control plane's *resolved URL* rather than a container name."""
|
||||
service = self._service()
|
||||
self._run(service)
|
||||
service.gateway.assert_called_with("http://192.168.128.2:8099")
|
||||
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Unit: macOS agent run wiring — the attribution invariant + token delivery
|
||||
(PRD 0070).
|
||||
|
||||
Replaces the argv coverage from the per-bottle companion-container era
|
||||
(`test_macos_container_launch.py`, removed with that architecture in #385).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.backend.macos_container.bottle import MacosContainerBottle
|
||||
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
|
||||
from bot_bottle.backend.macos_container.consolidated_launch import GatewayEndpoint
|
||||
from bot_bottle.backend.macos_container.launch import (
|
||||
_agent_run_argv,
|
||||
_identity_proxy_env,
|
||||
_proxy_url,
|
||||
)
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
|
||||
_BOTTLE = "bot_bottle.backend.macos_container.bottle"
|
||||
|
||||
_MANIFEST = ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": {}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
}).load_for_agent("demo")
|
||||
|
||||
|
||||
def _endpoint() -> GatewayEndpoint:
|
||||
return GatewayEndpoint(
|
||||
orchestrator_url="http://192.168.128.2:8099",
|
||||
gateway_ip="192.168.128.3",
|
||||
gateway_ca_pem="PEM",
|
||||
network="bot-bottle-mac-gateway",
|
||||
)
|
||||
|
||||
|
||||
def _plan(
|
||||
stage_dir: Path,
|
||||
*,
|
||||
agent_git_gate_url: str = "",
|
||||
agent_supervise_url: str = "",
|
||||
) -> MacosContainerBottlePlan:
|
||||
routes_path = stage_dir / "routes.yaml"
|
||||
routes_path.write_text("routes: []\n", encoding="utf-8")
|
||||
ca_path = stage_dir / "gateway-ca.pem"
|
||||
ca_path.write_text("ca\n", encoding="utf-8")
|
||||
egress_plan = SimpleNamespace(
|
||||
mitmproxy_ca_host_path=ca_path,
|
||||
routes_path=routes_path,
|
||||
routes=("route",),
|
||||
token_env_map={"EGRESS_TOKEN_0": "HOST_TOKEN"},
|
||||
canary="",
|
||||
canary_env="",
|
||||
)
|
||||
return cast(MacosContainerBottlePlan, SimpleNamespace(
|
||||
spec=SimpleNamespace(),
|
||||
manifest=_MANIFEST,
|
||||
stage_dir=stage_dir,
|
||||
slug="dev-abc",
|
||||
container_name="bot-bottle-dev-abc",
|
||||
image="bot-bottle-agent:latest",
|
||||
forwarded_env={"OAUTH_TOKEN": "host-value"},
|
||||
egress_plan=egress_plan,
|
||||
git_gate_plan=SimpleNamespace(upstreams=()),
|
||||
supervise_plan=None,
|
||||
agent_provision=SimpleNamespace(
|
||||
guest_env={"LITERAL": "value"},
|
||||
provisioned_env={},
|
||||
),
|
||||
agent_git_gate_url=agent_git_gate_url,
|
||||
agent_supervise_url=agent_supervise_url,
|
||||
))
|
||||
|
||||
|
||||
class TestAgentRunArgv(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.argv = _agent_run_argv(_plan(Path(self._tmp.name)), _endpoint())
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_drops_net_raw(self) -> None:
|
||||
"""The attribution invariant: Apple grants CAP_NET_RAW by default,
|
||||
which would let an agent forge a neighbour's source address with a raw
|
||||
socket and be attributed as that bottle."""
|
||||
self.assertIn("--cap-drop", self.argv)
|
||||
self.assertEqual("CAP_NET_RAW", self.argv[self.argv.index("--cap-drop") + 1])
|
||||
|
||||
def test_attaches_to_the_shared_gateway_network(self) -> None:
|
||||
self.assertEqual(
|
||||
"bot-bottle-mac-gateway", self.argv[self.argv.index("--network") + 1],
|
||||
)
|
||||
|
||||
def test_never_pins_an_ip(self) -> None:
|
||||
"""Apple Container 1.0.0 has no --ip: the address is DHCP-assigned and
|
||||
read back after start."""
|
||||
self.assertNotIn("--ip", self.argv)
|
||||
|
||||
def test_run_time_proxy_carries_no_identity_token(self) -> None:
|
||||
"""The token is minted by registration, which happens after this run —
|
||||
so it cannot be here. `/resolve` denies the token-less pair (#366),
|
||||
which is the safe direction; the real value arrives at exec time."""
|
||||
joined = " ".join(self.argv)
|
||||
self.assertIn(f"HTTP_PROXY={_proxy_url('192.168.128.3')}", joined)
|
||||
self.assertNotIn("bottle:", joined)
|
||||
|
||||
def test_gateway_bypasses_the_proxy(self) -> None:
|
||||
"""git-http + supervise live on the gateway and must be reached
|
||||
directly, not through its own egress proxy."""
|
||||
entry = next(a for a in self.argv if a.startswith("NO_PROXY="))
|
||||
self.assertIn("192.168.128.3", entry)
|
||||
|
||||
def test_forwarded_secrets_stay_off_argv(self) -> None:
|
||||
"""Bare name → inherited from the run process env, so the value never
|
||||
lands on the command line."""
|
||||
self.assertIn("OAUTH_TOKEN", self.argv)
|
||||
self.assertNotIn("host-value", " ".join(self.argv))
|
||||
|
||||
def test_agent_init_is_a_no_op(self) -> None:
|
||||
"""Every agent command arrives via `container exec`; the init process
|
||||
just holds the container open."""
|
||||
self.assertEqual("sleep", self.argv[-2])
|
||||
|
||||
|
||||
class TestIdentityTokenDelivery(unittest.TestCase):
|
||||
def test_exec_env_carries_the_token_as_proxy_credentials(self) -> None:
|
||||
env = _identity_proxy_env(_endpoint(), "s3cret")
|
||||
self.assertEqual(
|
||||
"http://bottle:s3cret@192.168.128.3:9099", env["HTTP_PROXY"],
|
||||
)
|
||||
self.assertEqual(env["HTTP_PROXY"], env["https_proxy"])
|
||||
|
||||
def test_no_token_means_no_override(self) -> None:
|
||||
self.assertEqual({}, _identity_proxy_env(_endpoint(), ""))
|
||||
|
||||
def test_token_value_never_reaches_argv(self) -> None:
|
||||
"""`ps` is world-readable: the token rides the child env behind a bare
|
||||
`--env` name, never the command line."""
|
||||
bottle = MacosContainerBottle(
|
||||
"bot-bottle-demo", lambda: None, None,
|
||||
exec_env=_identity_proxy_env(_endpoint(), "s3cret"),
|
||||
)
|
||||
argv = bottle.agent_argv(["--help"], tty=False)
|
||||
self.assertNotIn("s3cret", " ".join(argv))
|
||||
self.assertIn("HTTP_PROXY", argv)
|
||||
self.assertEqual("--env", argv[argv.index("HTTP_PROXY") - 1])
|
||||
|
||||
def test_bottle_without_exec_env_is_unchanged(self) -> None:
|
||||
bottle = MacosContainerBottle("bot-bottle-demo", lambda: None, None)
|
||||
argv = bottle.agent_argv(["--help"], tty=False)
|
||||
self.assertNotIn("--env", argv)
|
||||
|
||||
def test_provisioning_exec_also_carries_the_token(self) -> None:
|
||||
"""`provision` runs through `exec`; a provider whose provision step
|
||||
fetches anything would otherwise egress token-less and be denied."""
|
||||
bottle = MacosContainerBottle(
|
||||
"bot-bottle-demo", lambda: None, None,
|
||||
exec_env=_identity_proxy_env(_endpoint(), "s3cret"),
|
||||
)
|
||||
with patch(f"{_BOTTLE}.subprocess.run") as run:
|
||||
run.return_value = SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
bottle.exec("echo hi")
|
||||
argv, kwargs = run.call_args.args[0], run.call_args.kwargs
|
||||
self.assertIn("HTTP_PROXY", argv)
|
||||
self.assertNotIn("s3cret", " ".join(argv))
|
||||
self.assertEqual(
|
||||
"http://bottle:s3cret@192.168.128.3:9099", kwargs["env"]["HTTP_PROXY"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Unit: the Apple gateway + orchestrator 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.gateway import AppleGateway, GatewayError
|
||||
from bot_bottle.backend.macos_container.orchestrator_service import (
|
||||
MacosOrchestratorService,
|
||||
OrchestratorStartError,
|
||||
)
|
||||
|
||||
_GW = "bot_bottle.backend.macos_container.gateway"
|
||||
_ORCH = "bot_bottle.backend.macos_container.orchestrator_service"
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class TestAppleGatewayRun(unittest.TestCase):
|
||||
def _argv(self, run: Mock) -> list[str]:
|
||||
return run.call_args.args[0]
|
||||
|
||||
def _start(self, run: Mock) -> None:
|
||||
with patch(f"{_GW}.container_mod") as mod:
|
||||
mod.container_is_running.return_value = False
|
||||
mod.dns_server.return_value = "1.1.1.1"
|
||||
mod.run_container_argv = run
|
||||
AppleGateway(orchestrator_url="http://192.168.128.2:8099").ensure_running()
|
||||
|
||||
def test_nat_network_precedes_the_host_only_network(self) -> None:
|
||||
"""Apple Container makes the FIRST --network the default route, so the
|
||||
NAT network must lead or the gateway has no route out."""
|
||||
run = Mock(return_value=_ok())
|
||||
self._start(run)
|
||||
argv = self._argv(run)
|
||||
networks = [argv[i + 1] for i, a in enumerate(argv) if a == "--network"]
|
||||
self.assertEqual(["bot-bottle-mac-egress", "bot-bottle-mac-gateway"], networks)
|
||||
|
||||
def test_control_plane_url_is_passed_for_multi_tenancy(self) -> None:
|
||||
run = Mock(return_value=_ok())
|
||||
self._start(run)
|
||||
self.assertIn(
|
||||
"BOT_BOTTLE_ORCHESTRATOR_URL=http://192.168.128.2:8099", self._argv(run),
|
||||
)
|
||||
|
||||
def test_dns_is_explicit(self) -> None:
|
||||
"""The NAT gateway routes but does not resolve."""
|
||||
run = Mock(return_value=_ok())
|
||||
self._start(run)
|
||||
argv = self._argv(run)
|
||||
self.assertEqual("1.1.1.1", argv[argv.index("--dns") + 1])
|
||||
|
||||
def test_start_failure_raises(self) -> None:
|
||||
with self.assertRaises(GatewayError):
|
||||
self._start(Mock(return_value=_fail()))
|
||||
|
||||
def test_running_current_gateway_is_left_alone(self) -> None:
|
||||
"""Idempotent singleton: N launches must not restart the gateway and
|
||||
drop every other bottle's data plane."""
|
||||
run = Mock(return_value=_ok())
|
||||
with patch(f"{_GW}.container_mod") as mod:
|
||||
mod.container_is_running.return_value = True
|
||||
mod.container_image_digest.return_value = "abc"
|
||||
mod.image_digest.return_value = "abc"
|
||||
mod.run_container_argv = run
|
||||
AppleGateway().ensure_running()
|
||||
run.assert_not_called()
|
||||
|
||||
def test_stale_image_forces_a_recreate(self) -> None:
|
||||
"""A rebuilt image only takes effect if the running container is
|
||||
replaced — otherwise it keeps serving the OLD daemons."""
|
||||
run = Mock(return_value=_ok())
|
||||
with patch(f"{_GW}.container_mod") as mod:
|
||||
mod.container_is_running.return_value = True
|
||||
mod.container_image_digest.return_value = "old"
|
||||
mod.image_digest.return_value = "new"
|
||||
mod.dns_server.return_value = "1.1.1.1"
|
||||
mod.run_container_argv = run
|
||||
AppleGateway().ensure_running()
|
||||
run.assert_called_once()
|
||||
|
||||
def test_unreadable_digest_does_not_churn(self) -> None:
|
||||
run = Mock(return_value=_ok())
|
||||
with patch(f"{_GW}.container_mod") as mod:
|
||||
mod.container_is_running.return_value = True
|
||||
mod.container_image_digest.return_value = ""
|
||||
mod.image_digest.return_value = ""
|
||||
mod.run_container_argv = run
|
||||
AppleGateway().ensure_running()
|
||||
run.assert_not_called()
|
||||
|
||||
def _start_with_running_env(self, env: dict[str, str], url: str) -> Mock:
|
||||
run = Mock(return_value=_ok())
|
||||
with patch(f"{_GW}.container_mod") as mod:
|
||||
mod.container_is_running.return_value = True
|
||||
mod.container_image_digest.return_value = "abc"
|
||||
mod.image_digest.return_value = "abc"
|
||||
mod.container_env.return_value = env
|
||||
mod.dns_server.return_value = "1.1.1.1"
|
||||
mod.run_container_argv = run
|
||||
AppleGateway(orchestrator_url=url).ensure_running()
|
||||
return run
|
||||
|
||||
def test_moved_control_plane_forces_a_recreate(self) -> None:
|
||||
"""Docker hands the gateway a container *name*, stable across an
|
||||
orchestrator recreate. Apple has no DNS, so the URL is an IP baked into
|
||||
the gateway's env — if the orchestrator comes back on a new address and
|
||||
the gateway isn't recreated, every /resolve fails and every bottle on
|
||||
the host loses egress."""
|
||||
run = self._start_with_running_env(
|
||||
{"BOT_BOTTLE_ORCHESTRATOR_URL": "http://192.168.128.2:8099"},
|
||||
"http://192.168.128.7:8099",
|
||||
)
|
||||
run.assert_called_once()
|
||||
self.assertIn(
|
||||
"BOT_BOTTLE_ORCHESTRATOR_URL=http://192.168.128.7:8099",
|
||||
run.call_args.args[0],
|
||||
)
|
||||
|
||||
def test_unmoved_control_plane_does_not_churn(self) -> None:
|
||||
run = self._start_with_running_env(
|
||||
{"BOT_BOTTLE_ORCHESTRATOR_URL": "http://192.168.128.2:8099"},
|
||||
"http://192.168.128.2:8099",
|
||||
)
|
||||
run.assert_not_called()
|
||||
|
||||
def test_unreadable_env_does_not_churn(self) -> None:
|
||||
run = self._start_with_running_env({}, "http://192.168.128.2:8099")
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
class TestMacosOrchestratorService(unittest.TestCase):
|
||||
def test_orchestrator_starts_before_the_gateway(self) -> None:
|
||||
"""Apple has no container DNS, so the gateway can only be handed the
|
||||
control plane's IP — which does not exist until it is running. This
|
||||
ordering is the whole reason the macOS service diverges from docker's."""
|
||||
order: list[str] = []
|
||||
svc = MacosOrchestratorService(repo_root=Path("/r"), host_root=Path("/h"))
|
||||
gateway = Mock()
|
||||
gateway.ensure_running.side_effect = lambda: order.append("gateway")
|
||||
|
||||
def _record_orchestrator(_hash: str) -> None:
|
||||
order.append("orchestrator")
|
||||
|
||||
with patch(f"{_ORCH}.container_mod") as mod, \
|
||||
patch(f"{_ORCH}.source_hash", return_value="h1"), \
|
||||
patch.object(svc, "_run_orchestrator_container",
|
||||
side_effect=_record_orchestrator), \
|
||||
patch.object(svc, "gateway", return_value=gateway), \
|
||||
patch.object(svc, "is_healthy", return_value=True):
|
||||
mod.container_is_running.return_value = False
|
||||
mod.image_exists.return_value = True
|
||||
mod.try_container_ipv4_on_network.return_value = "192.168.128.2"
|
||||
url = svc.ensure_running()
|
||||
self.assertEqual(["orchestrator", "gateway"], order)
|
||||
self.assertEqual("http://192.168.128.2:8099", url)
|
||||
|
||||
def test_gateway_is_handed_the_resolved_url(self) -> None:
|
||||
svc = MacosOrchestratorService(repo_root=Path("/r"), host_root=Path("/h"))
|
||||
with patch(f"{_ORCH}.container_mod") as mod, \
|
||||
patch(f"{_ORCH}.source_hash", return_value="h1"), \
|
||||
patch.object(svc, "_run_orchestrator_container"), \
|
||||
patch.object(svc, "gateway") as gw, \
|
||||
patch.object(svc, "is_healthy", return_value=True):
|
||||
mod.container_is_running.return_value = False
|
||||
mod.image_exists.return_value = True
|
||||
mod.try_container_ipv4_on_network.return_value = "192.168.128.2"
|
||||
svc.ensure_running()
|
||||
gw.assert_called_with("http://192.168.128.2:8099")
|
||||
|
||||
def test_current_source_leaves_a_healthy_orchestrator_alone(self) -> None:
|
||||
"""Recreating on every launch would drop every other live bottle's
|
||||
in-memory egress tokens (#381)."""
|
||||
svc = MacosOrchestratorService(repo_root=Path("/r"), host_root=Path("/h"))
|
||||
run = Mock()
|
||||
with patch(f"{_ORCH}.container_mod") as mod, \
|
||||
patch(f"{_ORCH}.source_hash", return_value="h1"), \
|
||||
patch.object(svc, "_run_orchestrator_container", run), \
|
||||
patch.object(svc, "gateway"), \
|
||||
patch.object(svc, "is_healthy", return_value=True):
|
||||
mod.container_is_running.return_value = True
|
||||
mod.inspect_container.return_value = {
|
||||
"configuration": {"labels": {"bot-bottle-orchestrator-source-hash": "h1"}}
|
||||
}
|
||||
mod.try_container_ipv4_on_network.return_value = "192.168.128.2"
|
||||
svc.ensure_running()
|
||||
run.assert_not_called()
|
||||
|
||||
def test_changed_source_recreates_the_orchestrator(self) -> None:
|
||||
"""The control-plane process loaded its bind-mounted source at startup
|
||||
and won't reload it."""
|
||||
svc = MacosOrchestratorService(repo_root=Path("/r"), host_root=Path("/h"))
|
||||
run = Mock()
|
||||
with patch(f"{_ORCH}.container_mod") as mod, \
|
||||
patch(f"{_ORCH}.source_hash", return_value="h2"), \
|
||||
patch.object(svc, "_run_orchestrator_container", run), \
|
||||
patch.object(svc, "gateway"), \
|
||||
patch.object(svc, "is_healthy", return_value=True):
|
||||
mod.container_is_running.return_value = True
|
||||
mod.inspect_container.return_value = {
|
||||
"configuration": {"labels": {"bot-bottle-orchestrator-source-hash": "h1"}}
|
||||
}
|
||||
mod.image_exists.return_value = True
|
||||
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 = MacosOrchestratorService(repo_root=Path("/r"), host_root=Path("/h"))
|
||||
with patch(f"{_ORCH}.container_mod") as mod, \
|
||||
patch(f"{_ORCH}.source_hash", return_value="h1"), \
|
||||
patch.object(svc, "_run_orchestrator_container"), \
|
||||
patch.object(svc, "is_healthy", return_value=False):
|
||||
mod.container_is_running.return_value = False
|
||||
mod.image_exists.return_value = True
|
||||
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_control_plane_needs_no_route_out(self) -> None:
|
||||
"""The orchestrator sits only on the host-only network: the host
|
||||
reaches it there directly, so there is no --publish and no NAT leg."""
|
||||
svc = MacosOrchestratorService(repo_root=Path("/r"), host_root=Path("/h"))
|
||||
run = Mock(return_value=_ok())
|
||||
with patch(f"{_ORCH}.container_mod") as mod:
|
||||
mod.run_container_argv = run
|
||||
svc._run_orchestrator_container("h1")
|
||||
argv = run.call_args.args[0]
|
||||
networks = [argv[i + 1] for i, a in enumerate(argv) if a == "--network"]
|
||||
self.assertEqual(["bot-bottle-mac-gateway"], networks)
|
||||
self.assertNotIn("--publish", argv)
|
||||
|
||||
def test_orchestrator_start_failure_raises(self) -> None:
|
||||
svc = MacosOrchestratorService(repo_root=Path("/r"), host_root=Path("/h"))
|
||||
with patch(f"{_ORCH}.container_mod") as mod:
|
||||
mod.run_container_argv = Mock(return_value=_fail())
|
||||
with self.assertRaises(OrchestratorStartError):
|
||||
svc._run_orchestrator_container("h1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -14,7 +14,7 @@ from bot_bottle.orchestrator.lifecycle import (
|
||||
ORCHESTRATOR_SOURCE_HASH_LABEL,
|
||||
OrchestratorService,
|
||||
OrchestratorStartError,
|
||||
_source_hash,
|
||||
source_hash,
|
||||
)
|
||||
from tests.unit import use_bottle_root
|
||||
|
||||
@@ -57,7 +57,7 @@ class TestOrchestratorService(unittest.TestCase):
|
||||
# A healthy control plane already running the *current* bind-mounted
|
||||
# source is left alone — recreating it on every launch would drop
|
||||
# every other active bottle's in-memory egress tokens (#381).
|
||||
current = _source_hash(self.svc._repo_root)
|
||||
current = source_hash(self.svc._repo_root)
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake(argv: list[str]) -> Mock:
|
||||
@@ -98,7 +98,7 @@ class TestOrchestratorService(unittest.TestCase):
|
||||
self.assertEqual(1, len(runs))
|
||||
self.assertIn(ORCHESTRATOR_NAME, runs[0])
|
||||
# the fresh container is labeled with the current hash, not the stale one
|
||||
current = _source_hash(self.svc._repo_root)
|
||||
current = source_hash(self.svc._repo_root)
|
||||
self.assertIn(f"{ORCHESTRATOR_SOURCE_HASH_LABEL}={current}", runs[0])
|
||||
|
||||
def test_ensure_running_starts_orchestrator_container_when_absent(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user