ded9cb3bf3
Chunk 1 of the host-control-server stack: close the PRD's **transport** gap. Today LaunchBroker.submit(token) is an in-process method call from OrchestratorCore; this makes it a real out-of-process service reached over HTTP. - host_server.py: the host control server. A pure dispatch() (POST /broker verifies a signed token via the existing verify_request + _launch/_teardown path, GET /health) wrapped by a thin http.server adapter, mirroring orchestrator/server.py. Only the signed token crosses the wire; provenance/schema failures are fail-closed 401s that never touch the backend, a backend launch failure is a 502. - broker_client.py: BrokerClient — a drop-in submit(token) that POSTs the signed token to the host controller. A 401 re-raises as BrokerAuthError so the launch path's rollback is identical local or remote. - broker.py: SubmitBroker Protocol — the one method OrchestratorCore depends on, satisfied by both LaunchBroker and BrokerClient, so the core is unchanged (service.py annotation only). - __main__.py: wire `--broker http` behind the shared-secret env var (BOT_BOTTLE_BROKER_SECRET, hex) — a chunk-1 stopgap the durable TrustDomain key (chunk 2, #476) replaces. Tested: pure-dispatch cases, BrokerClient with HTTP mocked, and a real-socket sign -> POST -> verify -> act round-trip (incl. fail-closed forged token). pyright clean; pylint 9.86. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
85 lines
3.3 KiB
Python
85 lines
3.3 KiB
Python
"""Unit: orchestrator-side broker client (issue #468, chunk 1). HTTP mocked."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import unittest
|
|
import urllib.error
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from bot_bottle.orchestrator.broker import BrokerAuthError, LaunchRequest
|
|
from bot_bottle.orchestrator.broker_client import BrokerClient, BrokerClientError
|
|
|
|
_URLOPEN = "bot_bottle.orchestrator.broker_client.urllib.request.urlopen"
|
|
|
|
|
|
def _resp(payload: object) -> MagicMock:
|
|
m = MagicMock()
|
|
m.__enter__.return_value.read.return_value = json.dumps(payload).encode()
|
|
return m
|
|
|
|
|
|
def _http_error(code: int, payload: object = None) -> urllib.error.HTTPError:
|
|
body = json.dumps(payload).encode() if payload is not None else b""
|
|
return urllib.error.HTTPError(
|
|
"http://host/broker", code, "err", {}, io.BytesIO(body)) # type: ignore[arg-type]
|
|
|
|
|
|
class TestSubmit(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.c = BrokerClient("http://host:8091")
|
|
|
|
def test_returns_the_verified_request(self) -> None:
|
|
echo = {
|
|
"op": "launch", "bottle_id": "b1", "source_ip": "10.0.0.1",
|
|
"image_ref": "img", "slot": 3,
|
|
}
|
|
with patch(_URLOPEN, return_value=_resp(echo)):
|
|
got = self.c.submit("tok")
|
|
self.assertEqual(
|
|
LaunchRequest(op="launch", bottle_id="b1", source_ip="10.0.0.1",
|
|
image_ref="img", slot=3),
|
|
got,
|
|
)
|
|
|
|
def test_posts_token_to_broker_endpoint(self) -> None:
|
|
with patch(_URLOPEN, return_value=_resp({"op": "teardown", "bottle_id": "b1"})) as m:
|
|
self.c.submit("signed-token")
|
|
request = m.call_args.args[0]
|
|
self.assertEqual("POST", request.get_method())
|
|
self.assertTrue(request.full_url.endswith("/broker"))
|
|
self.assertEqual({"token": "signed-token"}, json.loads(request.data))
|
|
|
|
def test_401_raises_broker_auth_error(self) -> None:
|
|
# A fail-closed provenance/schema rejection surfaces as the SAME exception
|
|
# the in-process broker raises, so the launch path's rollback is identical.
|
|
with patch(_URLOPEN, side_effect=_http_error(401, {"error": "bad signature"})):
|
|
with self.assertRaises(BrokerAuthError):
|
|
self.c.submit("forged")
|
|
|
|
def test_502_raises_broker_client_error(self) -> None:
|
|
with patch(_URLOPEN, side_effect=_http_error(502, {"error": "docker down"})):
|
|
with self.assertRaises(BrokerClientError):
|
|
self.c.submit("tok")
|
|
|
|
def test_unreachable_raises_broker_client_error(self) -> None:
|
|
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
|
|
with self.assertRaises(BrokerClientError):
|
|
self.c.submit("tok")
|
|
|
|
def test_malformed_success_body_raises(self) -> None:
|
|
with patch(_URLOPEN, return_value=_resp({"op": "launch"})): # missing bottle_id
|
|
with self.assertRaises(BrokerClientError):
|
|
self.c.submit("tok")
|
|
|
|
def test_empty_error_body_is_tolerated(self) -> None:
|
|
# An error with no readable JSON body still classifies by status code.
|
|
with patch(_URLOPEN, side_effect=_http_error(401)):
|
|
with self.assertRaises(BrokerAuthError):
|
|
self.c.submit("forged")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|