7ab85e9ea6
prd-number-check / require-numbered-prds (pull_request) Failing after 12s
test / integration-docker (pull_request) Successful in 19s
tracker-policy-pr / check-pr (pull_request) Successful in 9s
test / unit (pull_request) Failing after 52s
lint / lint (push) Successful in 1m1s
test / coverage (pull_request) Has been skipped
Codex review on #496: - **High — ambiguous delivery no longer orphans a launched bottle.** A timeout / dropped response from the host controller is now the ambiguous BrokerUnavailableError (distinct from the definite BrokerAuthError / BrokerClientError). OrchestratorCore.launch_bottle keeps the registry row on the ambiguous case instead of deregistering — deregistering would orphan a running container with no record (reconcile reaps rows, never containers). The row is left for reconcile to reap iff the bottle is not actually live. Definite failures still roll back, so a real failure leaves no orphan row. - **Medium — the privileged endpoint bounds request bodies.** The host server rejects an oversized Content-Length with 413 before reading it, and sets a per-request socket timeout, so a caller that can merely reach the socket (no signed token) can't exhaust memory or a handler thread. Tests: ambiguous-keep vs definite-rollback in the launch path; the BrokerUnavailableError/BrokerClientError split in BrokerClient; the 413 body cap + handler error paths (driven in-thread, since daemon request threads lose coverage); and the __main__ entrypoint broker selection. Diff-coverage 98%; pyright clean; pylint 9.88. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
118 lines
4.9 KiB
Python
118 lines
4.9 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,
|
|
BrokerUnavailableError,
|
|
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_is_a_definite_client_error(self) -> None:
|
|
# The host responded — it processed the request and did not launch, so a
|
|
# definite BrokerClientError (the caller may safely roll back).
|
|
with patch(_URLOPEN, side_effect=_http_error(502, {"error": "docker down"})):
|
|
with self.assertRaises(BrokerClientError):
|
|
self.c.submit("tok")
|
|
|
|
def test_unreachable_is_ambiguous_unavailable(self) -> None:
|
|
# No response at all — the request may already have launched, so the
|
|
# AMBIGUOUS BrokerUnavailableError (the caller must NOT roll back).
|
|
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
|
|
with self.assertRaises(BrokerUnavailableError):
|
|
self.c.submit("tok")
|
|
|
|
def test_timeout_is_ambiguous_unavailable(self) -> None:
|
|
# A dropped/late response after the request was sent is the exact orphan
|
|
# risk: the host may have launched. Must be ambiguous, not a definite fail.
|
|
with patch(_URLOPEN, side_effect=TimeoutError("read timed out")):
|
|
with self.assertRaises(BrokerUnavailableError):
|
|
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")
|
|
|
|
def test_non_json_success_body_raises(self) -> None:
|
|
# A 200 whose body isn't JSON is tolerated into {} then fails the
|
|
# missing-field check — a definite client error, not a crash.
|
|
m = MagicMock()
|
|
m.__enter__.return_value.read.return_value = b"not json at all"
|
|
with patch(_URLOPEN, return_value=m):
|
|
with self.assertRaises(BrokerClientError):
|
|
self.c.submit("tok")
|
|
|
|
def test_unreadable_error_body_is_tolerated(self) -> None:
|
|
# An HTTPError whose body can't be read (fp=None) still classifies by
|
|
# status — the error detail is best-effort.
|
|
err = urllib.error.HTTPError(
|
|
"http://host/broker", 502, "err", {}, None) # type: ignore[arg-type]
|
|
with patch(_URLOPEN, side_effect=err):
|
|
with self.assertRaises(BrokerClientError):
|
|
self.c.submit("tok")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|