`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.
131 lines
5.2 KiB
Rust
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"));
|
|
}
|
|
}
|