fix(diagnostics): make optional failures observable and safe

This commit is contained in:
2026-07-26 06:31:38 +00:00
parent e29b79d517
commit 22dde95561
12 changed files with 180 additions and 46 deletions
+18 -18
View File
@@ -368,7 +368,7 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore # pragm
elif key in (curses.KEY_UP, ord("k")): elif key in (curses.KEY_UP, ord("k")):
selected = max(selected - 1, 0) selected = max(selected - 1, 0)
elif key in (curses.KEY_ENTER, 10, 13): elif key in (curses.KEY_ENTER, 10, 13):
_detail_view(stdscr, qp, green_attr=green_attr) status_line = _detail_view(stdscr, qp, green_attr=green_attr)
elif key == ord("a"): elif key == ord("a"):
try: try:
status_line = _approve_from_tui(stdscr, qp) status_line = _approve_from_tui(stdscr, qp)
@@ -456,7 +456,7 @@ def _detail_view(
qp: QueuedProposal, qp: QueuedProposal,
*, *,
green_attr: int = 0, green_attr: int = 0,
) -> None: # pragma: no cover ) -> str: # pragma: no cover
"""Render the full proposal. Scrollable. Press q to return.""" """Render the full proposal. Scrollable. Press q to return."""
lines = _detail_lines(qp, green_attr=green_attr) lines = _detail_lines(qp, green_attr=green_attr)
offset = 0 offset = 0
@@ -473,7 +473,7 @@ def _detail_view(
stdscr.refresh() stdscr.refresh()
key = stdscr.getch() key = stdscr.getch()
if key in (ord("q"), 27): if key in (ord("q"), 27):
return return ""
if key in (curses.KEY_DOWN, ord("j")): if key in (curses.KEY_DOWN, ord("j")):
offset = min(offset + 1, max(0, len(lines) - 1)) offset = min(offset + 1, max(0, len(lines) - 1))
elif key in (curses.KEY_UP, ord("k")): elif key in (curses.KEY_UP, ord("k")):
@@ -484,28 +484,28 @@ def _detail_view(
offset = max(0, len(lines) - 1) offset = max(0, len(lines) - 1)
elif key == ord("a"): elif key == ord("a"):
try: try:
_approve_from_tui(stdscr, qp) return _approve_from_tui(stdscr, qp)
except ApplyError: except ApplyError as exc:
pass return f"apply failed: {exc}"
return
elif key == ord("m"): elif key == ord("m"):
if qp.proposal.tool in _REPORT_ONLY_TOOLS: if qp.proposal.tool in _REPORT_ONLY_TOOLS:
return return f"modify unavailable for {qp.proposal.tool}"
edited = _modify(stdscr, qp) edited = _modify(stdscr, qp)
if edited is not None: if edited is None:
try: return "modify aborted (no change)"
_approve_from_tui( try:
stdscr, qp, final_file=edited, return _approve_from_tui(
notes="operator modified before approving", stdscr, qp, final_file=edited,
) notes="operator modified before approving",
except ApplyError: )
pass except ApplyError as exc:
return return f"apply failed: {exc}"
elif key == ord("r"): elif key == ord("r"):
reason = _prompt(stdscr, "reject reason: ") reason = _prompt(stdscr, "reject reason: ")
if reason: if reason:
reject(qp, reason=reason) reject(qp, reason=reason)
return return f"rejected {qp.proposal.tool} for [{qp.label}]"
return "reject aborted (empty reason)"
def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None: # type: ignore # pragma: no cover def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None: # type: ignore # pragma: no cover
+32 -6
View File
@@ -16,6 +16,8 @@ import os
import sys import sys
from typing import Any, Optional from typing import Any, Optional
from ..log import debug
def filter_multiselect( def filter_multiselect(
items: list[str], items: list[str],
@@ -42,7 +44,11 @@ def filter_multiselect(
try: try:
tty_fd = open(tty_path, "r+b", buffering=0) tty_fd = open(tty_path, "r+b", buffering=0)
except OSError: except OSError as exc:
debug(
"multi-select unavailable; treating it as cancellation",
context={"error_type": type(exc).__name__, "tty": tty_path},
)
return None return None
try: try:
@@ -73,7 +79,11 @@ def filter_select(
try: try:
tty_fd = open(tty_path, "r+b", buffering=0) tty_fd = open(tty_path, "r+b", buffering=0)
except OSError: except OSError as exc:
debug(
"filter-select unavailable; treating it as cancellation",
context={"error_type": type(exc).__name__, "tty": tty_path},
)
return None return None
try: try:
@@ -129,7 +139,11 @@ def _run_picker(items: list[str], *, title: str, tty_fd: int) -> Optional[str]:
curses.nocbreak() curses.nocbreak()
curses.echo() curses.echo()
curses.endwin() curses.endwin()
except Exception: # noqa: W0718 — curses can raise many error types except Exception as exc: # noqa: W0718 — curses can raise many error types
debug(
"filter-select display failed; treating it as cancellation",
context={"error_type": type(exc).__name__},
)
return None return None
finally: finally:
sys.__stdin__ = orig_stdin # type: ignore[assignment] sys.__stdin__ = orig_stdin # type: ignore[assignment]
@@ -292,7 +306,11 @@ def _run_multiselect(
curses.nocbreak() curses.nocbreak()
curses.echo() curses.echo()
curses.endwin() curses.endwin()
except Exception: # noqa: W0718 except Exception as exc: # noqa: W0718
debug(
"multi-select display failed; treating it as cancellation",
context={"error_type": type(exc).__name__},
)
return None return None
finally: finally:
sys.__stdin__ = orig_stdin # type: ignore[assignment] sys.__stdin__ = orig_stdin # type: ignore[assignment]
@@ -558,13 +576,21 @@ def name_color_modal(
""" """
try: try:
tty_fd = open(tty_path, "r+b", buffering=0) # pylint: disable=consider-using-with tty_fd = open(tty_path, "r+b", buffering=0) # pylint: disable=consider-using-with
except OSError: except OSError as exc:
debug(
"name/color picker unavailable; using defaults",
context={"error_type": type(exc).__name__, "tty": tty_path},
)
return default_label, "" return default_label, ""
try: try:
fd_dup = os.dup(tty_fd.fileno()) fd_dup = os.dup(tty_fd.fileno())
return _run_name_color(default_label, tty_fd=fd_dup, disclaimer=disclaimer) return _run_name_color(default_label, tty_fd=fd_dup, disclaimer=disclaimer)
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught
debug(
"name/color picker failed; using defaults",
context={"error_type": type(exc).__name__},
)
return default_label, "" return default_label, ""
finally: finally:
tty_fd.close() tty_fd.close()
+11 -2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import typing import typing
from ...log import debug
from .types import Config from .types import Config
@@ -52,7 +53,11 @@ def resolve_client_config(
) -> Config: ) -> Config:
try: try:
policy = resolver.resolve(client_ip, identity_token) policy = resolver.resolve(client_ip, identity_token)
except Exception: # noqa: BLE001 - a policy lookup failure must deny except Exception as exc: # noqa: BLE001 - a policy lookup failure must deny
debug(
"egress policy resolution failed; applying deny-all",
context={"error_type": type(exc).__name__},
)
return Config(routes=(), deny_reason=DENY_RESOLVER_ERROR) return Config(routes=(), deny_reason=DENY_RESOLVER_ERROR)
return _config_from_policy(policy) return _config_from_policy(policy)
@@ -63,6 +68,10 @@ def resolve_client_context(
try: try:
policy, bottle_id, tokens = resolver.resolve_policy_and_bottle_id( policy, bottle_id, tokens = resolver.resolve_policy_and_bottle_id(
client_ip, identity_token) client_ip, identity_token)
except Exception: # noqa: BLE001 - a policy lookup failure must deny except Exception as exc: # noqa: BLE001 - a policy lookup failure must deny
debug(
"egress context resolution failed; applying deny-all",
context={"error_type": type(exc).__name__},
)
return Config(routes=(), deny_reason=DENY_RESOLVER_ERROR), "", {} return Config(routes=(), deny_reason=DENY_RESOLVER_ERROR), "", {}
return _config_from_policy(policy), (bottle_id or ""), tokens return _config_from_policy(policy), (bottle_id or ""), tokens
+33 -6
View File
@@ -18,6 +18,7 @@ import urllib.request
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from ..log import debug
from ..orchestrator_auth import ROLE_CLI from ..orchestrator_auth import ROLE_CLI
from ..trust_domain import CONTROL_PLANE from ..trust_domain import CONTROL_PLANE
from .server import ORCHESTRATOR_AUTH_HEADER from .server import ORCHESTRATOR_AUTH_HEADER
@@ -53,6 +54,23 @@ class RegisteredBottle:
env_var_secret: str = "" env_var_secret: str = ""
@dataclass(frozen=True)
class BackendProbeFailure:
"""Safe diagnostic for an optional backend discovery probe."""
backend: str
error_type: str
def _probe_failure(backend: str, exc: BaseException) -> BackendProbeFailure:
failure = BackendProbeFailure(backend, type(exc).__name__)
debug(
"orchestrator discovery probe unavailable",
context={"backend": failure.backend, "error_type": failure.error_type},
)
return failure
class OrchestratorClient: class OrchestratorClient:
"""Trusted host-side client for the orchestrator control plane. """Trusted host-side client for the orchestrator control plane.
@@ -245,32 +263,41 @@ def discover_orchestrator_url(*, timeout: float = 2.0) -> str:
orchestrator TAP. Returns the first that answers `/health`; raises if none orchestrator TAP. Returns the first that answers `/health`; raises if none
do (no orchestrator up launch a bottle first).""" do (no orchestrator up launch a bottle first)."""
candidates: list[str] = [] candidates: list[str] = []
failures: list[BackendProbeFailure] = []
try: # docker: loopback-published control plane try: # docker: loopback-published control plane
from .lifecycle import DEFAULT_PORT as _DOCKER_PORT from .lifecycle import DEFAULT_PORT as _DOCKER_PORT
candidates.append(f"http://127.0.0.1:{_DOCKER_PORT}") candidates.append(f"http://127.0.0.1:{_DOCKER_PORT}")
except Exception: # noqa: BLE001 — backend optional except Exception as exc: # noqa: BLE001 — backend optional
failures.append(_probe_failure("docker", exc))
candidates.append("http://127.0.0.1:8099") candidates.append("http://127.0.0.1:8099")
try: # firecracker: infra VM control plane on the orchestrator TAP try: # firecracker: infra VM control plane on the orchestrator TAP
from ..backend.firecracker import netpool from ..backend.firecracker import netpool
from ..backend.firecracker.infra_vm import ORCHESTRATOR_PORT from ..backend.firecracker.infra_vm import ORCHESTRATOR_PORT
candidates.append( candidates.append(
f"http://{netpool.orch_slot().guest_ip}:{ORCHESTRATOR_PORT}") f"http://{netpool.orch_slot().guest_ip}:{ORCHESTRATOR_PORT}")
except Exception: # noqa: BLE001 — backend optional / not firecracker except Exception as exc: # noqa: BLE001 — backend optional / not firecracker
pass failures.append(_probe_failure("firecracker", exc))
try: # macOS: orchestrator container on its host-only address try: # macOS: orchestrator container on its host-only address
from ..backend.macos_container.infra import probe_orchestrator_url from ..backend.macos_container.infra import probe_orchestrator_url
url = probe_orchestrator_url() url = probe_orchestrator_url()
if url: if url:
candidates.append(url) candidates.append(url)
except Exception: # noqa: BLE001 — backend optional / not macOS except Exception as exc: # noqa: BLE001 — backend optional / not macOS
pass failures.append(_probe_failure("macos-container", exc))
for url in candidates: for url in candidates:
if OrchestratorClient(url, timeout=timeout).health(): if OrchestratorClient(url, timeout=timeout).health():
return url return url
detail = ""
if failures:
detail = "; optional probes unavailable: " + ", ".join(
f"{failure.backend} ({failure.error_type})" for failure in failures
)
raise OrchestratorClientError( raise OrchestratorClientError(
"no running orchestrator control plane found (tried " "no running orchestrator control plane found (tried "
+ ", ".join(candidates) + ", ".join(candidates)
+ "); launch a bottle first" + ")"
+ detail
+ "; launch a bottle first"
) )
+9 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from ..log import debug
from .client import OrchestratorClient, OrchestratorClientError from .client import OrchestratorClient, OrchestratorClientError
@@ -27,7 +28,14 @@ def reprovision_bottles(
try: try:
if client.reprovision_gateway(bottle_id, secret): if client.reprovision_gateway(bottle_id, secret):
restored += 1 restored += 1
except OrchestratorClientError: except OrchestratorClientError as exc:
debug(
"gateway secret reprovision failed; continuing with other bottles",
context={
"bottle_id": bottle_id,
"error_type": type(exc).__name__,
},
)
continue continue
return restored return restored
+8 -2
View File
@@ -379,9 +379,15 @@ class Handler(http.server.BaseHTTPRequestHandler):
status, payload = dispatch( status, payload = dispatch(
server.orchestrator, method, self.path, body, role=role) server.orchestrator, method, self.path, body, role=role)
except Exception as e: # noqa: BLE001 — the control plane must stay up except Exception as e: # noqa: BLE001 — the control plane must stay up
sys.stderr.write(f"orchestrator: {method} {self.path} failed: {e!r}\n") # Do not echo exception messages to the caller or logs: broker and
# persistence exceptions can contain request data. The operation,
# route, and exception type are enough to correlate a traceback.
sys.stderr.write(
f"orchestrator: {method} {self.path} failed "
f"[error_type={type(e).__name__}]\n"
)
sys.stderr.flush() sys.stderr.flush()
status, payload = 500, {"error": f"internal error: {e}"} status, payload = 500, {"error": "internal error"}
data = json.dumps(payload).encode() data = json.dumps(payload).encode()
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json") self.send_header("Content-Type", "application/json")
@@ -34,6 +34,20 @@ class TestCliBackendBoundaries(unittest.TestCase):
class TestRuntimeModuleSizes(unittest.TestCase): class TestRuntimeModuleSizes(unittest.TestCase):
def test_no_runtime_module_grows_beyond_global_ceiling(self) -> None:
"""A coarse ceiling catches new monoliths; focused caps stay tighter."""
ceiling = 850
oversized = [
f"{path.relative_to(ROOT)} ({len(path.read_text().splitlines())})"
for path in (ROOT / "bot_bottle").rglob("*.py")
if len(path.read_text().splitlines()) > ceiling
]
self.assertEqual(
[], oversized,
f"runtime modules must stay at or below {ceiling} lines: "
+ ", ".join(oversized),
)
def test_egress_modules_stay_focused(self) -> None: def test_egress_modules_stay_focused(self) -> None:
caps = { caps = {
"addon_core.py": 100, "addon_core.py": 100,
@@ -48,12 +48,13 @@ class TestSharedReprovision(unittest.TestCase):
client.reprovision_gateway.side_effect = [ client.reprovision_gateway.side_effect = [
OrchestratorClientError("bad key"), True, OrchestratorClientError("bad key"), True,
] ]
self.assertEqual( with patch("bot_bottle.orchestrator.reprovision.debug") as debug:
1, count = reprovision_bottles(
reprovision_bottles(
client, {"10.0.0.1": "key-1", "10.0.0.2": "key-2"}, client, {"10.0.0.1": "key-1", "10.0.0.2": "key-2"},
), )
) self.assertEqual(1, count)
self.assertEqual("b1", debug.call_args.kwargs["context"]["bottle_id"])
self.assertNotIn("bad key", repr(debug.call_args))
class TestMacosReprovision(unittest.TestCase): class TestMacosReprovision(unittest.TestCase):
+7 -2
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import unittest import unittest
from typing import Any, Optional from typing import Any, Optional
from unittest.mock import patch
from bot_bottle.cli.tui import _filter_items, _multiselect_loop, filter_multiselect, filter_select from bot_bottle.cli.tui import _filter_items, _multiselect_loop, filter_multiselect, filter_select
@@ -49,8 +50,10 @@ class TestFilterSelectEmptyItems(unittest.TestCase):
def test_returns_none_when_tty_unavailable(self): def test_returns_none_when_tty_unavailable(self):
# /nonexistent is guaranteed to not open. # /nonexistent is guaranteed to not open.
result = filter_select(["a", "b"], tty_path="/nonexistent/tty") with patch("bot_bottle.cli.tui.debug") as debug:
result = filter_select(["a", "b"], tty_path="/nonexistent/tty")
self.assertIsNone(result) self.assertIsNone(result)
self.assertEqual("FileNotFoundError", debug.call_args.kwargs["context"]["error_type"])
class TestFilterMultiselectEmptyItems(unittest.TestCase): class TestFilterMultiselectEmptyItems(unittest.TestCase):
@@ -60,8 +63,10 @@ class TestFilterMultiselectEmptyItems(unittest.TestCase):
self.assertEqual([], result) self.assertEqual([], result)
def test_returns_none_when_tty_unavailable(self): def test_returns_none_when_tty_unavailable(self):
result = filter_multiselect(["a", "b"], tty_path="/nonexistent/tty") with patch("bot_bottle.cli.tui.debug") as debug:
result = filter_multiselect(["a", "b"], tty_path="/nonexistent/tty")
self.assertIsNone(result) self.assertIsNone(result)
self.assertEqual("FileNotFoundError", debug.call_args.kwargs["context"]["error_type"])
class TestMultiselectLoopReordering(unittest.TestCase): class TestMultiselectLoopReordering(unittest.TestCase):
+10 -3
View File
@@ -3,15 +3,16 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from unittest.mock import patch
from bot_bottle.gateway.egress.addon_core import ( from bot_bottle.gateway.egress.context import (
DENY_RESOLVER_ERROR, DENY_RESOLVER_ERROR,
DENY_UNATTRIBUTED, DENY_UNATTRIBUTED,
DENY_UNPARSEABLE, DENY_UNPARSEABLE,
decide,
resolve_client_config, resolve_client_config,
resolve_client_context, resolve_client_context,
) )
from bot_bottle.gateway.egress.matching import decide
from bot_bottle.gateway.policy_resolver import PolicyResolveError from bot_bottle.gateway.policy_resolver import PolicyResolveError
@@ -44,7 +45,13 @@ class TestResolveClientConfig(unittest.TestCase):
def test_resolver_error_denies_all(self) -> None: def test_resolver_error_denies_all(self) -> None:
# Orchestrator unreachable/errored must never widen egress. # Orchestrator unreachable/errored must never widen egress.
self.assertEqual((), resolve_client_config(_FakeResolver(raises=True), "10.243.0.1").routes) with patch("bot_bottle.gateway.egress.context.debug") as debug:
config = resolve_client_config(_FakeResolver(raises=True), "10.243.0.1")
self.assertEqual((), config.routes)
self.assertEqual(
"PolicyResolveError", debug.call_args.kwargs["context"]["error_type"],
)
self.assertNotIn("orchestrator down", repr(debug.call_args))
def test_unparseable_policy_denies_all(self) -> None: def test_unparseable_policy_denies_all(self) -> None:
cfg = resolve_client_config(_FakeResolver(result="routes: notalist\n"), "10.243.0.1") cfg = resolve_client_config(_FakeResolver(result="routes: notalist\n"), "10.243.0.1")
+11
View File
@@ -12,7 +12,9 @@ from bot_bottle.orchestrator.client import (
OrchestratorClient, OrchestratorClient,
OrchestratorClientError, OrchestratorClientError,
RegisteredBottle, RegisteredBottle,
BackendProbeFailure,
_host_auth_token, _host_auth_token,
_probe_failure,
) )
_URLOPEN = "bot_bottle.orchestrator.client.urllib.request.urlopen" _URLOPEN = "bot_bottle.orchestrator.client.urllib.request.urlopen"
@@ -33,6 +35,15 @@ class TestHostAuthToken(unittest.TestCase):
self.assertEqual("", _host_auth_token()) self.assertEqual("", _host_auth_token())
class TestBackendProbeFailure(unittest.TestCase):
def test_records_safe_typed_diagnostic(self) -> None:
with patch("bot_bottle.orchestrator.client.debug") as debug:
result = _probe_failure("firecracker", RuntimeError("secret detail"))
self.assertEqual(BackendProbeFailure("firecracker", "RuntimeError"), result)
rendered = repr(debug.call_args)
self.assertNotIn("secret detail", rendered)
def _resp(status: int, payload: object) -> MagicMock: def _resp(status: int, payload: object) -> MagicMock:
m = MagicMock() m = MagicMock()
inner = m.__enter__.return_value inner = m.__enter__.return_value
+21 -1
View File
@@ -7,6 +7,7 @@ server tests), plus one real-socket round-trip to prove the handler wiring.
from __future__ import annotations from __future__ import annotations
import base64 import base64
import io
import json import json
import secrets import secrets
import sqlite3 import sqlite3
@@ -17,7 +18,7 @@ import urllib.error
import urllib.request import urllib.request
from contextlib import closing from contextlib import closing
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import MagicMock, patch
from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY, mint from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY, mint
from bot_bottle.orchestrator.broker import StubBroker from bot_bottle.orchestrator.broker import StubBroker
@@ -283,6 +284,25 @@ class TestServerRoundTrip(unittest.TestCase):
)) ))
self.assertEqual(reg["bottle_id"], attr["bottle_id"]) self.assertEqual(reg["bottle_id"], attr["bottle_id"])
def test_internal_failure_is_contextual_but_redacted(self) -> None:
orch = MagicMock()
orch.registry.all.side_effect = RuntimeError("SENSITIVE request value")
with patch("sys.stderr", io.StringIO()) as stderr:
server = make_server(orch, "127.0.0.1", 0)
self.addCleanup(server.server_close)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
self.addCleanup(server.shutdown)
host, port = server.server_address[0], server.server_address[1]
with self.assertRaises(urllib.error.HTTPError) as raised:
urllib.request.urlopen(f"http://{host}:{port}/bottles", timeout=5)
payload = json.loads(raised.exception.read())
output = stderr.getvalue()
self.assertEqual({"error": "internal error"}, payload)
self.assertIn("GET /bottles", output)
self.assertIn("RuntimeError", output)
self.assertNotIn("SENSITIVE", output)
class TestOrchestratorAuth(unittest.TestCase): class TestOrchestratorAuth(unittest.TestCase):
"""Role-scoped control-plane tokens (issue #400 / #469 review): every route """Role-scoped control-plane tokens (issue #400 / #469 review): every route