hyperhive/hive-c0re/src/dashboard/health.rs
atlas 9c1cfafeb5 refactor(hive-c0re): one producer for the readiness verdict
`get_health_ready` computed "degraded iff any warning is crit" inline and
wrapped it in a private `ReadyBody`. The swarm status publisher needs the
same verdict, and the warnings module's own doc already states why it must
not compute its own: two systems independently deciding what counts as
unhealthy is how they end up disagreeing.

The disagreement would also be silent. Each side would look internally
consistent, and the day a second degraded condition is added to one of
them, the dashboard and the swarm view would report different things about
the same host with nothing to flag it.

`warnings::readiness()` is now the single producer and `Readiness` the
single type. `ReadyBody` is deleted rather than made public: the endpoint
keeps the part that genuinely is its own, the mapping onto an HTTP status
code, and serves the shared document as its body.
2026-08-16 13:12:59 +02:00

131 lines
5.2 KiB
Rust

//! 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"));
}
}