use std::collections::BTreeMap;
use std::sync::{Arc, OnceLock};
use axum::Router;
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use crate::application::lifecycle::{Lifecycle, LifecycleState};
use crate::application::resources::Resources;
use crate::routing::RouterState;
pub const DEFAULT_PREFIX: &str = "/up";
#[derive(Clone)]
pub struct Health(Arc<Inner>);
struct Inner {
prefix: String,
lifecycle: Lifecycle,
resources: OnceLock<Resources>,
}
impl Health {
#[must_use]
pub fn new(prefix: impl Into<String>, lifecycle: Lifecycle) -> Self {
let mut prefix = prefix.into();
while prefix.ends_with('/') {
prefix.pop();
}
if !prefix.starts_with('/') {
prefix.insert(0, '/');
}
Health(Arc::new(Inner {
prefix,
lifecycle,
resources: OnceLock::new(),
}))
}
#[must_use]
pub fn prefix(&self) -> &str {
&self.0.prefix
}
#[must_use]
pub fn covers(&self, path: &str) -> bool {
let prefix = &self.0.prefix;
path == prefix
|| (path.len() > prefix.len()
&& path.starts_with(prefix.as_str())
&& path.as_bytes()[prefix.len()] == b'/')
}
pub fn publish(&self, resources: Resources) {
let _ = self.0.resources.set(resources);
}
#[must_use]
pub fn lifecycle(&self) -> &Lifecycle {
&self.0.lifecycle
}
pub fn router<S: RouterState>(&self) -> Router<S> {
use axum::routing::get;
let summary = self.clone();
let live = self.clone();
let ready = self.clone();
Router::new()
.route(
&self.0.prefix,
get(move || {
let health = summary.clone();
async move { health.report().await.into_response() }
}),
)
.route(
&format!("{}/live", self.0.prefix),
get(move || {
let health = live.clone();
async move { health.live().into_response() }
}),
)
.route(
&format!("{}/ready", self.0.prefix),
get(move || {
let health = ready.clone();
async move { health.report().await.into_response() }
}),
)
}
#[must_use]
pub fn live(&self) -> HealthReport {
HealthReport {
state: self.0.lifecycle.state(),
ready: false,
checks: BTreeMap::new(),
live_only: true,
}
}
pub async fn report(&self) -> HealthReport {
let state = self.0.lifecycle.state();
let _resources = self.0.resources.get();
#[cfg_attr(
not(any(
feature = "database",
feature = "cache",
feature = "storage-fs",
feature = "jobs"
)),
expect(unused_mut, reason = "every insertion is behind a subsystem feature")
)]
let mut checks: BTreeMap<String, Check> = BTreeMap::new();
#[cfg(feature = "database")]
if let Some(db) = _resources.and_then(Resources::db) {
checks.insert("database".to_owned(), Check::from_probe(db.ping().await));
}
#[cfg(feature = "cache")]
if let Some(cache) = _resources.and_then(Resources::cache) {
checks.insert("cache".to_owned(), Check::from_probe(cache.ping().await));
}
#[cfg(feature = "storage-fs")]
if let Some(storage) = _resources.and_then(Resources::storage) {
for name in storage.disk_names() {
let Some(disk) = storage.try_disk(name) else {
continue;
};
checks.insert(
format!("storage:{name}"),
Check::from_probe(disk.operator().check().await),
);
}
}
#[cfg(feature = "jobs")]
if _resources.and_then(Resources::jobs).is_some() {
checks.insert("jobs".to_owned(), Check::up());
}
let ready = state == LifecycleState::Ready && checks.values().all(Check::is_up);
HealthReport {
state,
ready,
checks,
live_only: false,
}
}
}
impl std::fmt::Debug for Health {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Health")
.field("prefix", &self.0.prefix)
.field("state", &self.0.lifecycle.state())
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Check {
pub status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl Check {
#[must_use]
pub fn up() -> Self {
Check {
status: "up",
reason: None,
}
}
#[must_use]
pub fn down(reason: impl std::fmt::Display) -> Self {
Check {
status: "down",
reason: Some(reason.to_string()),
}
}
#[must_use]
pub fn is_up(&self) -> bool {
self.status == "up"
}
#[cfg_attr(
not(any(feature = "database", feature = "cache", feature = "storage-fs")),
expect(dead_code, reason = "every caller is behind a subsystem feature")
)]
fn from_probe<T, E: std::fmt::Display>(result: Result<T, E>) -> Self {
match result {
Ok(_) => Check::up(),
Err(error) => Check::down(error),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct HealthReport {
#[serde(serialize_with = "serialize_state")]
pub state: LifecycleState,
pub ready: bool,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub checks: BTreeMap<String, Check>,
#[serde(skip)]
live_only: bool,
}
impl HealthReport {
#[must_use]
pub fn status(&self) -> StatusCode {
let ok = if self.live_only {
self.state.is_live()
} else {
self.ready
};
if ok {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
}
}
}
impl IntoResponse for HealthReport {
fn into_response(self) -> Response {
let status = self.status();
let mut response = (status, axum::Json(self)).into_response();
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("no-store, max-age=0"),
);
response
}
}
fn serialize_state<S: serde::Serializer>(
state: &LifecycleState,
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.serialize_str(state.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
fn health() -> Health {
Health::new(DEFAULT_PREFIX, Lifecycle::new())
}
#[test]
fn a_prefix_is_normalised_to_one_leading_and_no_trailing_slash() {
for given in ["up", "/up", "/up/", "up//"] {
assert_eq!(Health::new(given, Lifecycle::new()).prefix(), "/up");
}
}
#[test]
fn covers_matches_the_prefix_and_what_is_under_it() {
let health = health();
assert!(health.covers("/up"));
assert!(health.covers("/up/live"));
assert!(health.covers("/up/ready"));
}
#[test]
fn covers_does_not_match_a_path_that_merely_starts_with_the_same_letters() {
let health = health();
assert!(!health.covers("/upload"));
assert!(!health.covers("/"));
assert!(!health.covers("/updates"));
}
#[tokio::test]
async fn a_starting_application_is_live_but_not_ready() {
let health = health();
assert_eq!(health.live().status(), StatusCode::OK);
assert_eq!(
health.report().await.status(),
StatusCode::SERVICE_UNAVAILABLE
);
}
#[tokio::test]
async fn readiness_follows_the_lifecycle() {
let lifecycle = Lifecycle::new();
let health = Health::new(DEFAULT_PREFIX, lifecycle.clone());
lifecycle.mark_ready();
assert!(health.report().await.ready);
assert_eq!(health.report().await.status(), StatusCode::OK);
lifecycle.begin_drain();
assert!(!health.report().await.ready);
assert_eq!(
health.report().await.status(),
StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(health.live().status(), StatusCode::OK);
}
#[tokio::test]
async fn a_stopped_application_is_not_live() {
let lifecycle = Lifecycle::new();
let health = Health::new(DEFAULT_PREFIX, lifecycle.clone());
lifecycle.mark_ready();
lifecycle.begin_drain();
lifecycle.mark_stopped();
assert_eq!(health.live().status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn a_down_check_carries_its_reason_and_an_up_one_does_not() {
assert_eq!(Check::up().reason, None);
let down = Check::down("connection refused");
assert!(!down.is_up());
assert_eq!(down.reason.as_deref(), Some("connection refused"));
}
#[test]
fn publishing_twice_keeps_the_first_bundle() {
let health = health();
health.publish(Resources::empty());
health.publish(Resources::empty());
}
}