feat(orchestrator): list_live broker op + reconcile via broker (#468)
Chunk 3 of the host-control-server stack: grow the broker op vocabulary
(PRD gap 3), starting with `list_live`, and invert `reconcile` onto it.
- broker: split the closed op vocabulary into mutation (`launch`/`teardown`,
carry a bottle id + static flags) and query (`list_live`, carries nothing
but its op name) kinds. `verify_request` now enforces a **strict schema**
(open question 1, resolved yes): unknown claim keys are rejected, a mutation
must name its bottle, and a query that smuggles any id/flag is refused.
- broker verb: `LaunchBroker.list_live` / `SubmitBroker.list_live` return the
backend's live source IPs; a backend enumeration failure is converted to the
single `BrokerUnavailableError` "live set unknown" signal. `DockerBroker`
enumerates its labelled containers; `StubBroker` derives from launches (or a
test override).
- host controller: `POST /broker/live` verifies a signed `list_live` token and
returns `{source_ips}`; `BrokerClient.list_live` is its drop-in client.
- reconcile: `OrchestratorCore.reconcile()` drops the `live_source_ips`
parameter and pulls the live set from the broker itself — the tell that the
orchestrator couldn't see the backend goes away. **Fail-safe**: if the broker
can't return an authoritative set the sweep is skipped, never run against an
empty/partial set (which would reap healthy rows). The `/reconcile` HTTP
contract + `OrchestratorClient.reconcile` become a bare trigger.
The macOS launcher's Apple-container enumeration stays for now; it becomes the
host controller's `list_live` when launch itself moves behind the broker (the
pulled-forward chunk 5, next in the stack).
Tests + pyright clean; pylint 10.0 on broker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,9 @@ class _FailingBroker(LaunchBroker):
|
||||
def _teardown(self, req: LaunchRequest) -> None:
|
||||
pass
|
||||
|
||||
def _list_live(self) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
class _UnavailableBroker(LaunchBroker):
|
||||
"""Verifies the token, then raises the *ambiguous* BrokerUnavailableError —
|
||||
@@ -51,6 +54,9 @@ class _UnavailableBroker(LaunchBroker):
|
||||
def _teardown(self, req: LaunchRequest) -> None:
|
||||
pass
|
||||
|
||||
def _list_live(self) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
class TestOrchestrator(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
@@ -365,8 +371,9 @@ class TestOrchestratorSupervise(unittest.TestCase):
|
||||
pid = self.orch.supervise_queue_proposal(
|
||||
rec.bottle_id, tool=TOOL_EGRESS_ALLOW,
|
||||
proposed_file="routes:\n - host: google.com\n", justification="j")
|
||||
# No live source IPs -> the bottle is reaped (grace 0 so it's immediate).
|
||||
self.assertEqual([rec.bottle_id], self.orch.reconcile([], grace_seconds=0))
|
||||
# No live source IPs (nothing launched through the stub) -> the bottle
|
||||
# is reaped (grace 0 so it's immediate).
|
||||
self.assertEqual([rec.bottle_id], self.orch.reconcile(grace_seconds=0))
|
||||
self.assertEqual(
|
||||
{"status": "unknown"}, self.orch.supervise_poll_response(rec.bottle_id, pid))
|
||||
|
||||
@@ -411,7 +418,9 @@ class TestOrchestratorReconcile(unittest.TestCase):
|
||||
live = self.orch.launch_bottle("10.243.0.2", tokens={"EGRESS_TOKEN_0": "keep"})
|
||||
self._age_all(600)
|
||||
|
||||
self.assertEqual([dead.bottle_id], self.orch.reconcile(["10.243.0.2"]))
|
||||
# The broker reports only .2 as still running -> .1 is reaped.
|
||||
self.broker.live_source_ips = ["10.243.0.2"]
|
||||
self.assertEqual([dead.bottle_id], self.orch.reconcile())
|
||||
self.assertIsNone(self.store.get(dead.bottle_id))
|
||||
self.assertIsNotNone(self.store.get(live.bottle_id))
|
||||
# The in-memory egress credential goes with the row.
|
||||
@@ -423,14 +432,31 @@ class TestOrchestratorReconcile(unittest.TestCase):
|
||||
broker error must not stop the sweep clearing the row."""
|
||||
self.orch.launch_bottle("10.243.0.1")
|
||||
self._age_all(600)
|
||||
self.broker.launched.clear()
|
||||
self.orch.reconcile([])
|
||||
self.broker.live_source_ips = [] # broker reports nothing running
|
||||
self.orch.reconcile()
|
||||
self.assertEqual([], self.broker.torn_down)
|
||||
|
||||
def test_reconcile_keeps_everything_when_all_are_live(self) -> None:
|
||||
a = self.orch.launch_bottle("10.243.0.1")
|
||||
b = self.orch.launch_bottle("10.243.0.2")
|
||||
self._age_all(600)
|
||||
self.assertEqual([], self.orch.reconcile(["10.243.0.1", "10.243.0.2"]))
|
||||
self.broker.live_source_ips = ["10.243.0.1", "10.243.0.2"]
|
||||
self.assertEqual([], self.orch.reconcile())
|
||||
self.assertIsNotNone(self.store.get(a.bottle_id))
|
||||
self.assertIsNotNone(self.store.get(b.bottle_id))
|
||||
|
||||
def test_reconcile_skipped_when_broker_cannot_enumerate(self) -> None:
|
||||
"""A broker that can't return an authoritative live set must NOT be
|
||||
treated as "nothing is live" — that would reap every healthy bottle.
|
||||
The sweep is skipped instead."""
|
||||
a = self.orch.launch_bottle("10.243.0.1")
|
||||
b = self.orch.launch_bottle("10.243.0.2")
|
||||
self._age_all(600)
|
||||
|
||||
def _boom() -> list[str]:
|
||||
raise RuntimeError("docker ps failed")
|
||||
|
||||
self.broker._list_live = _boom # type: ignore[method-assign]
|
||||
self.assertEqual([], self.orch.reconcile())
|
||||
self.assertIsNotNone(self.store.get(a.bottle_id))
|
||||
self.assertIsNotNone(self.store.get(b.bottle_id))
|
||||
|
||||
Reference in New Issue
Block a user