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>}}
|
-> {"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(
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue