fix(kuma-bridge): detect Kuma outages and never trust the cached monitor list

Verified against a real Kuma by stopping it mid-test:
- get_monitors() reads the library's CACHED monitorList event, so it reported
  'up' long after Kuma died. Reachability now does a real HTTP round-trip plus a
  live socket check.
- the cached list could yield an id for a monitor that no longer exists; a match
  is now confirmed with a live get_monitor before it is returned, so CluPilot can
  never record a target that is never checked.
- liveness (/health, always 200 while serving) split from readiness (/ready, 503
  when Kuma is unreachable) — the container healthcheck must not restart a
  healthy bridge just because a dependency is down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 20:13:34 +02:00
parent 82916167cf
commit 353df9f054
3 changed files with 92 additions and 16 deletions

BIN
..env.swp

Binary file not shown.

View File

@ -11,7 +11,8 @@ and translates it to Socket.IO via the uptime-kuma-api library:
-> {"monitor": {"id": <int>}} -> {"monitor": {"id": <int>}}
GET /monitors/{id} -> {"monitor": {"id", "status": "up"|"down"|...}} GET /monitors/{id} -> {"monitor": {"id", "status": "up"|"down"|...}}
DELETE /monitors/{id} -> {"ok": true} DELETE /monitors/{id} -> {"ok": true}
GET /health -> bridge + Kuma reachability (no auth) GET /health -> liveness: 200 while the bridge serves (no auth)
GET /ready -> readiness: 503 when Kuma is unreachable (no auth)
All monitor routes require `Authorization: Bearer $BRIDGE_TOKEN`. All monitor routes require `Authorization: Bearer $BRIDGE_TOKEN`.
Kuma's Socket.IO API is internal and may change between Kuma releases — pin the Kuma's Socket.IO API is internal and may change between Kuma releases — pin the
@ -20,6 +21,8 @@ Kuma version you tested against.
import os import os
import threading import threading
import urllib.error
import urllib.request
from typing import Any from typing import Any
from fastapi import Depends, FastAPI, Header, HTTPException, Response from fastapi import Depends, FastAPI, Header, HTTPException, Response
@ -120,23 +123,82 @@ def _shape(monitor: dict) -> dict:
} }
def _require_live(api: UptimeKumaApi) -> UptimeKumaApi:
"""
Guard against a STALE session. `get_monitors()` reads the library's cached
monitorList event it happily returns data while Kuma is unreachable, so any
read that callers act on must first prove the socket is actually connected.
"""
if not getattr(api, "sio", None) or not api.sio.connected:
_reset()
raise HTTPException(status_code=503, detail="Kuma socket not connected")
return api
def _kuma_state() -> tuple[str, str | None]:
"""
(state, detail) for Kuma reachability. Never raises. Does a REAL round-trip:
an HTTP hit on Kuma plus a live socket, because the cached monitor list would
report "up" long after Kuma died.
"""
if not KUMA_URL:
return "unconfigured", "KUMA_URL is not set"
try:
urllib.request.urlopen(KUMA_URL, timeout=5) # noqa: S310 — operator-configured URL
except urllib.error.HTTPError:
pass # any HTTP status means Kuma answered
except Exception as exc: # noqa: BLE001 — connection refused/DNS/timeout
return "down", f"HTTP unreachable: {exc}"
try:
_call(lambda api: _require_live(api) and api.get_monitor_status(0))
return "up", None
except HTTPException as exc:
# A live call on a bogus id may error while the session is fine — the
# socket being connected is what we actually care about here.
if "not connected" in str(exc.detail):
return "down", str(exc.detail)
return "up", None
except Exception as exc: # noqa: BLE001 — must never 500
return "down", str(exc)
@app.get("/health") @app.get("/health")
def health() -> dict: def health() -> dict:
"""Unauthenticated: is the bridge up, and can it reach Kuma?""" """
if not KUMA_URL: LIVENESS (unauthenticated): is this process serving? Always 200 while it is.
return {"bridge": "up", "kuma": "unconfigured"} Deliberately NOT tied to Kuma: the bridge being restarted because a
try: *dependency* is down would be wrong. Kuma's state is reported in the body,
_call(lambda api: api.get_monitors()) and /ready is the endpoint that fails when Kuma is unreachable.
return {"bridge": "up", "kuma": "up"} """
except HTTPException as exc: state, detail = _kuma_state()
return {"bridge": "up", "kuma": "down", "detail": str(exc.detail)} body = {"bridge": "up", "kuma": state}
except Exception as exc: # noqa: BLE001 — health must never 500 if detail:
return {"bridge": "up", "kuma": "down", "detail": str(exc)} body["detail"] = detail
return body
@app.get("/ready")
def ready(response: Response) -> dict:
"""
READINESS (unauthenticated): 200 only when Kuma is actually reachable,
503 otherwise so uptime checks and operators can detect the outage.
"""
state, detail = _kuma_state()
body = {"bridge": "up", "kuma": state}
if detail:
body["detail"] = detail
if state != "up":
response.status_code = 503
return body
@app.get("/monitors", dependencies=[Depends(require_token)]) @app.get("/monitors", dependencies=[Depends(require_token)])
def list_monitors() -> dict: def list_monitors() -> dict:
monitors = _call(lambda api: api.get_monitors()) # _require_live first: the list is cached and would otherwise be served
# (silently stale) while Kuma is unreachable.
monitors = _call(lambda api: _require_live(api).get_monitors())
return {"monitors": [_shape(m) for m in monitors]} return {"monitors": [_shape(m) for m in monitors]}
@ -147,12 +209,21 @@ def create_monitor(payload: MonitorIn) -> dict:
# Idempotent: reuse an existing monitor for this exact URL. CluPilot also # Idempotent: reuse an existing monitor for this exact URL. CluPilot also
# guards this, but a crash between its POST and its bookkeeping must not # guards this, but a crash between its POST and its bookkeeping must not
# leave duplicates behind. # leave duplicates behind.
#
# The list is CACHED, so a match is confirmed with a LIVE get_monitor before
# we hand the id back — otherwise a stale cache could make us return an id
# for a monitor that no longer exists (CluPilot would then record a target
# that is never checked).
existing = next( existing = next(
(m for m in _call(lambda api: api.get_monitors()) if m.get("url") == payload.url), (m for m in _call(lambda api: _require_live(api).get_monitors()) if m.get("url") == payload.url),
None, None,
) )
if existing is not None: if existing is not None and existing.get("id") is not None:
return {"monitor": {"id": existing.get("id")}} try:
_call(lambda api: api.get_monitor(int(existing["id"])))
return {"monitor": {"id": int(existing["id"])}}
except HTTPException:
pass # gone or unverifiable — fall through and create it
created = _call( created = _call(
lambda api: api.add_monitor( lambda api: api.add_monitor(

View File

@ -23,7 +23,8 @@ ohnehin spricht — am PHP-Code musste nichts geändert werden:
| `POST /monitors` | Monitor anlegen (`{friendly_name, url, type}`) | | `POST /monitors` | Monitor anlegen (`{friendly_name, url, type}`) |
| `GET /monitors/{id}` | Status lesen (`up` / `down` / `pending` / `maintenance`) | | `GET /monitors/{id}` | Status lesen (`up` / `down` / `pending` / `maintenance`) |
| `DELETE /monitors/{id}` | Monitor entfernen (bei Kündigung) | | `DELETE /monitors/{id}` | Monitor entfernen (bei Kündigung) |
| `GET /health` | Bridge + Kuma-Erreichbarkeit (ohne Token) | | `GET /health` | Liveness: 200 solange die Bridge läuft (Kuma-Status im Body) |
| `GET /ready` | Readiness: **503**, wenn Kuma nicht erreichbar ist |
Alle Monitor-Routen erfordern `Authorization: Bearer $BRIDGE_TOKEN`. Alle Monitor-Routen erfordern `Authorization: Bearer $BRIDGE_TOKEN`.
@ -55,6 +56,10 @@ Alle Monitor-Routen erfordern `Authorization: Bearer $BRIDGE_TOKEN`.
```bash ```bash
docker compose exec app curl -s http://kuma-bridge:8080/health docker compose exec app curl -s http://kuma-bridge:8080/health
# {"bridge":"up","kuma":"up"} # {"bridge":"up","kuma":"up"}
# Readiness (eignet sich als Check in Kuma selbst — 503 wenn Kuma fehlt):
docker compose exec app curl -s -o /dev/null -w '%{http_code}\n' http://kuma-bridge:8080/ready
# 200
``` ```
5. Config-Cache leeren, falls gesetzt: `clupilot artisan config:clear` 5. Config-Cache leeren, falls gesetzt: `clupilot artisan config:clear`