184 lines
5.6 KiB
Rust
184 lines
5.6 KiB
Rust
//! `GET /api/health` — is each of the three upstreams answering.
|
|
//!
|
|
//! The three probed here are the ones DESIGN.md §9.5 calls "Broken": without
|
|
//! Prowlarr nothing is found, without Transmission nothing is fetched, and
|
|
//! without TMDB nothing is identified. The endpoint always answers `200` —
|
|
//! the body carries the verdict, so a degraded service can still explain
|
|
//! itself to the UI instead of looking like a fourth outage.
|
|
|
|
use axum::extract::State;
|
|
use axum::Json;
|
|
use serde::Serialize;
|
|
use utoipa::ToSchema;
|
|
|
|
use crate::state::AppState;
|
|
|
|
/// Whether the service as a whole can do its job.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ToSchema)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Health {
|
|
/// Every upstream answered.
|
|
Ok,
|
|
/// At least one upstream is unreachable or unconfigured.
|
|
Degraded,
|
|
}
|
|
|
|
/// The verdict for a single upstream.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ToSchema)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Status {
|
|
/// Answered as expected.
|
|
Ok,
|
|
/// Did not answer, or answered with an unexpected status.
|
|
Unreachable,
|
|
/// No API key configured, so it was not probed.
|
|
Unconfigured,
|
|
}
|
|
|
|
/// One upstream's result.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
|
|
pub struct Check {
|
|
pub status: Status,
|
|
/// Why, when the status is not `ok`. Never contains the probed URL: it
|
|
/// can carry an API key in the query string.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub detail: Option<String>,
|
|
}
|
|
|
|
impl Check {
|
|
fn ok() -> Self {
|
|
Self {
|
|
status: Status::Ok,
|
|
detail: None,
|
|
}
|
|
}
|
|
|
|
fn unreachable(detail: impl Into<String>) -> Self {
|
|
Self {
|
|
status: Status::Unreachable,
|
|
detail: Some(detail.into()),
|
|
}
|
|
}
|
|
|
|
fn unconfigured(detail: impl Into<String>) -> Self {
|
|
Self {
|
|
status: Status::Unconfigured,
|
|
detail: Some(detail.into()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The body of `GET /api/health`.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
|
|
pub struct HealthReport {
|
|
pub status: Health,
|
|
/// The running binary's version.
|
|
#[schema(example = "0.1.0")]
|
|
pub version: String,
|
|
pub prowlarr: Check,
|
|
pub transmission: Check,
|
|
pub tmdb: Check,
|
|
}
|
|
|
|
/// Report reachability of Prowlarr, Transmission and TMDB.
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/health",
|
|
tag = "system",
|
|
responses(
|
|
(status = 200, description = "Per-upstream reachability", body = HealthReport),
|
|
),
|
|
)]
|
|
pub async fn health(State(state): State<AppState>) -> Json<HealthReport> {
|
|
// Three independent network probes; serialising them would make the
|
|
// endpoint as slow as the sum of the timeouts.
|
|
let (prowlarr, transmission, tmdb) = tokio::join!(
|
|
probe_prowlarr(&state),
|
|
probe_transmission(&state),
|
|
probe_tmdb(&state),
|
|
);
|
|
|
|
let status = if [prowlarr.status, transmission.status, tmdb.status]
|
|
.iter()
|
|
.all(|s| *s == Status::Ok)
|
|
{
|
|
Health::Ok
|
|
} else {
|
|
Health::Degraded
|
|
};
|
|
|
|
Json(HealthReport {
|
|
status,
|
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
|
prowlarr,
|
|
transmission,
|
|
tmdb,
|
|
})
|
|
}
|
|
|
|
/// Prowlarr answers `/ping` without a key; the key is sent anyway so a
|
|
/// misconfigured one shows up here rather than at the first search.
|
|
async fn probe_prowlarr(state: &AppState) -> Check {
|
|
let url = format!(
|
|
"{}/ping",
|
|
state.upstreams().prowlarr_url.trim_end_matches('/')
|
|
);
|
|
let mut request = state.http().get(url);
|
|
if let Some(key) = &state.upstreams().prowlarr_api_key {
|
|
request = request.header("X-Api-Key", key);
|
|
}
|
|
match request.send().await {
|
|
Ok(response) if response.status().is_success() => Check::ok(),
|
|
Ok(response) => Check::unreachable(format!("http {}", response.status().as_u16())),
|
|
Err(err) => Check::unreachable(describe(err)),
|
|
}
|
|
}
|
|
|
|
/// Transmission answers an RPC call without a session id with `409` plus the
|
|
/// id to retry with. That is a live daemon, so it counts as reachable.
|
|
async fn probe_transmission(state: &AppState) -> Check {
|
|
let request = state
|
|
.http()
|
|
.post(&state.upstreams().transmission_url)
|
|
.json(&serde_json::json!({ "method": "session-get" }));
|
|
match request.send().await {
|
|
Ok(response)
|
|
if response.status().is_success()
|
|
|| response.status() == reqwest::StatusCode::CONFLICT =>
|
|
{
|
|
Check::ok()
|
|
}
|
|
Ok(response) => Check::unreachable(format!("http {}", response.status().as_u16())),
|
|
Err(err) => Check::unreachable(describe(err)),
|
|
}
|
|
}
|
|
|
|
/// TMDB is the only upstream that cannot be probed at all without a key, so
|
|
/// a missing key is reported as its own state rather than as an outage.
|
|
async fn probe_tmdb(state: &AppState) -> Check {
|
|
let Some(key) = &state.upstreams().tmdb_api_key else {
|
|
return Check::unconfigured("no ARR_TMDB_API_KEY set");
|
|
};
|
|
let url = format!(
|
|
"{}/configuration",
|
|
state.upstreams().tmdb_url.trim_end_matches('/')
|
|
);
|
|
match state
|
|
.http()
|
|
.get(url)
|
|
.query(&[("api_key", key)])
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(response) if response.status().is_success() => Check::ok(),
|
|
Ok(response) => Check::unreachable(format!("http {}", response.status().as_u16())),
|
|
Err(err) => Check::unreachable(describe(err)),
|
|
}
|
|
}
|
|
|
|
/// `reqwest`'s own `Display` includes the URL, and the TMDB URL carries the
|
|
/// API key. `without_url` is what keeps the key out of the response body.
|
|
fn describe(err: reqwest::Error) -> String {
|
|
err.without_url().to_string()
|
|
}
|