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
parent
82916167cf
commit
353df9f054
|
|
@ -11,7 +11,8 @@ and translates it to Socket.IO via the uptime-kuma-api library:
|
|||
-> {"monitor": {"id": <int>}}
|
||||
GET /monitors/{id} -> {"monitor": {"id", "status": "up"|"down"|...}}
|
||||
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`.
|
||||
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 threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
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")
|
||||
def health() -> dict:
|
||||
"""Unauthenticated: is the bridge up, and can it reach Kuma?"""
|
||||
if not KUMA_URL:
|
||||
return {"bridge": "up", "kuma": "unconfigured"}
|
||||
try:
|
||||
_call(lambda api: api.get_monitors())
|
||||
return {"bridge": "up", "kuma": "up"}
|
||||
except HTTPException as exc:
|
||||
return {"bridge": "up", "kuma": "down", "detail": str(exc.detail)}
|
||||
except Exception as exc: # noqa: BLE001 — health must never 500
|
||||
return {"bridge": "up", "kuma": "down", "detail": str(exc)}
|
||||
"""
|
||||
LIVENESS (unauthenticated): is this process serving? Always 200 while it is.
|
||||
Deliberately NOT tied to Kuma: the bridge being restarted because a
|
||||
*dependency* is down would be wrong. Kuma's state is reported in the body,
|
||||
and /ready is the endpoint that fails when Kuma is unreachable.
|
||||
"""
|
||||
state, detail = _kuma_state()
|
||||
body = {"bridge": "up", "kuma": state}
|
||||
if detail:
|
||||
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)])
|
||||
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]}
|
||||
|
||||
|
||||
|
|
@ -147,12 +209,21 @@ def create_monitor(payload: MonitorIn) -> dict:
|
|||
# Idempotent: reuse an existing monitor for this exact URL. CluPilot also
|
||||
# guards this, but a crash between its POST and its bookkeeping must not
|
||||
# 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(
|
||||
(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,
|
||||
)
|
||||
if existing is not None:
|
||||
return {"monitor": {"id": existing.get("id")}}
|
||||
if existing is not None and existing.get("id") is not None:
|
||||
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(
|
||||
lambda api: api.add_monitor(
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ ohnehin spricht — am PHP-Code musste nichts geändert werden:
|
|||
| `POST /monitors` | Monitor anlegen (`{friendly_name, url, type}`) |
|
||||
| `GET /monitors/{id}` | Status lesen (`up` / `down` / `pending` / `maintenance`) |
|
||||
| `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`.
|
||||
|
||||
|
|
@ -55,6 +56,10 @@ Alle Monitor-Routen erfordern `Authorization: Bearer $BRIDGE_TOKEN`.
|
|||
```bash
|
||||
docker compose exec app curl -s http://kuma-bridge:8080/health
|
||||
# {"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`
|
||||
|
|
|
|||
Loading…
Reference in New Issue