//! Hive-wide health endpoints for external monitoring: `/health/live` //! (liveness) and `/health/ready` (readiness). //! //! Deliberately thin: reuses [`crate::warnings`] — the same registry the //! dashboard banner already reads — as the single source of truth for //! "is anything degraded", rather than tracking health separately. Two //! systems independently deciding what counts as unhealthy is how they //! end up disagreeing. //! //! Liveness is trivial by construction: if this handler runs at all, the //! process is up. A liveness probe that does real work risks flapping //! the *process* down over a transient dependency failure — that's what //! readiness is for. Readiness derives `"degraded"` from any `crit`-level //! warning currently set; `warn`-level entries ride along in the body for //! detail but don't flip the status, mirroring the banner's own severity //! split. //! //! Routed outside `/api/` (see `dashboard/mod.rs`) so the gateway can //! carve it out from `dashboardAuth` the same way `/webhook/` already is //! — an external monitor generally can't do interactive HTTP Basic. use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; use serde::Serialize; use utoipa::ToSchema; #[derive(Serialize, ToSchema)] struct LiveBody { status: &'static str, } /// Liveness. Always `200`; no further checks. #[utoipa::path( get, path = "/health/live", responses((status = 200, description = "process is up", body = LiveBody)), tag = "health" )] pub(super) async fn get_health_live() -> Response { (StatusCode::OK, axum::Json(LiveBody { status: "ok" })).into_response() } /// Readiness. /// /// `200` with `{"status":"ok", "warnings": [...]}` unless a `crit`-level /// warning is currently set, in which case `503` with /// `{"status":"degraded", ...}`. `warnings` always carries the full /// current list (including `warn`-level entries not affecting the status) /// so a poller gets detail either way. /// /// The body is [`crate::warnings::Readiness`] rather than a type of this /// module's own, and the verdict comes from /// [`crate::warnings::readiness`] rather than being computed here. The /// swarm status publisher offers that same document upward, and this /// endpoint deciding "unhealthy" for itself is precisely how the two /// would drift apart. What stays here is the only part that *is* this /// endpoint's: the mapping onto an HTTP status code. #[utoipa::path( get, path = "/health/ready", responses( (status = 200, description = "no crit-level warning set", body = crate::warnings::Readiness), (status = 503, description = "at least one crit-level warning set", body = crate::warnings::Readiness), ), tag = "health" )] pub(super) async fn get_health_ready() -> Response { let body = crate::warnings::readiness(); let code = if body.is_degraded() { StatusCode::SERVICE_UNAVAILABLE } else { StatusCode::OK }; (code, axum::Json(body)).into_response() } #[cfg(test)] mod tests { use super::*; use crate::warnings::set_warning; async fn ready_status_and_body(_serial: &()) -> (StatusCode, serde_json::Value) { let resp = get_health_ready().await; let status = resp.status(); let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) .await .expect("read body"); let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json body"); (status, body) } // The warnings registry is process-global (see `stats::warnings`), // so these two tests share a mutex to avoid racing each other under // cargo's parallel runner — same pattern `warnings::tests` uses via // per-test unique kinds, but here the *emptiness* of the whole // registry is exactly what's under test, so unique kinds aren't // enough on their own. `tokio::sync::Mutex`, not `std::sync::Mutex` // — the guard is held across the `.await` in // `ready_status_and_body`, and a std mutex guard can't cross an // await point (clippy's `await_holding_lock` catches exactly this). static SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); #[tokio::test] async fn live_is_always_ok() { let resp = get_health_live().await; assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn ready_is_ok_with_no_crit_warning() { let _lock = SERIAL.lock().await; // A `warn`-level entry must not flip readiness. let _g = set_warning("t_health_warn", "warn", "just a warn"); let (status, body) = ready_status_and_body(&()).await; assert_eq!(status, StatusCode::OK); assert_eq!(body["status"], "ok"); } #[tokio::test] async fn ready_is_degraded_with_a_crit_warning() { let _lock = SERIAL.lock().await; let _g = set_warning("t_health_crit", "crit", "on fire"); let (status, body) = ready_status_and_body(&()).await; assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); assert_eq!(body["status"], "degraded"); let warnings = body["warnings"].as_array().expect("warnings array"); assert!(warnings.iter().any(|w| w["kind"] == "t_health_crit")); } }