//! 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; use crate::host_stats::ServerWarning; /// Liveness. Always `200`; no further checks. #[utoipa::path( get, path = "/health/live", responses((status = 200, description = "process is up", body = serde_json::Value)), tag = "health" )] pub(super) async fn get_health_live() -> Response { ( StatusCode::OK, axum::Json(serde_json::json!({ "status": "ok" })), ) .into_response() } #[derive(Serialize, ToSchema)] struct ReadyBody { status: &'static str, warnings: Vec, } /// Readiness. /// /// `200` with `{"status":"ok", "warnings": [...]}` unless a `crit`-level /// warning is currently set in [`crate::warnings::snapshot`], 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. #[utoipa::path( get, path = "/health/ready", responses( (status = 200, description = "no crit-level warning set", body = ReadyBody), (status = 503, description = "at least one crit-level warning set", body = ReadyBody), ), tag = "health" )] pub(super) async fn get_health_ready() -> Response { let warnings = crate::warnings::snapshot(); let degraded = warnings.iter().any(|w| w.level == "crit"); let code = if degraded { StatusCode::SERVICE_UNAVAILABLE } else { StatusCode::OK }; let body = ReadyBody { status: if degraded { "degraded" } else { "ok" }, warnings, }; (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")); } }