The dashboard's top-of-page warning banner is gone. The warning registry
stays, because /health/ready, the swarm status view and the OTEL gauges
read it. What it feeds is now alerted on in Grafana:
- hyperhive.hive.warning{kind,level}: one series per raised warning, so an
alert names the cause. Raising a warning also logs one `hive warning
raised` line with the message.
- hyperhive.hive.nix_store.utilization: the used fraction of the nix
store's filesystem, probed in the gauge callback.
- Alerts: hh-hive-degraded on the existing degraded gauge, hh-hive-warning
per kind (disk excluded), hh-host-disk-high at 85% and
hh-host-disk-critical at 95%.
- pending_logins is now a registry warning, refreshed from the container
rescan, so it reaches the per-kind gauge.
Frontend: server_warnings leaves the state snapshot, and the dashboard,
flow and stats pages keep only the sticky chrome wrapper. The shared
hive-warn and WarnBanner components stay, since other views use them.
A module-eval case checks each alert's metric name against the exporter's
source, so a rename on either side fails the build.
130 lines
5.1 KiB
Rust
130 lines
5.1 KiB
Rust
//! Hive-wide health endpoints for external monitoring: `/health/live`
|
|
//! (liveness) and `/health/ready` (readiness).
|
|
//!
|
|
//! Deliberately thin: reuses [`crate::warnings`] 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 warning registry'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"));
|
|
}
|
|
}
|