diff --git a/..env.swp b/..env.swp index cae078b..1efd28c 100644 Binary files a/..env.swp and b/..env.swp differ diff --git a/docker/kuma-bridge/app.py b/docker/kuma-bridge/app.py index fb498b7..22e7f0d 100644 --- a/docker/kuma-bridge/app.py +++ b/docker/kuma-bridge/app.py @@ -11,7 +11,8 @@ and translates it to Socket.IO via the uptime-kuma-api library: -> {"monitor": {"id": }} 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( diff --git a/docs/monitoring-uptime-kuma.md b/docs/monitoring-uptime-kuma.md index f6d9c08..c09d78f 100644 --- a/docs/monitoring-uptime-kuma.md +++ b/docs/monitoring-uptime-kuma.md @@ -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`