Skip to main content

polyc_runtime/
health.rs

1//! The `axum` side-server (PRD §11): liveness, readiness, and metrics on a
2//! separate port from a binary's main surface.
3
4use std::{
5    net::SocketAddr,
6    sync::{
7        Arc, LazyLock,
8        atomic::{AtomicBool, Ordering},
9    },
10};
11
12use axum::{Router, extract::State, http::StatusCode, response::IntoResponse, routing::get};
13use prometheus::{Encoder, IntGaugeVec, TextEncoder, register_int_gauge_vec};
14use tokio_util::sync::CancellationToken;
15
16/// `1` while the process is up, labelled with the build's release version, so a
17/// `/metrics` scrape reports which release every process runs. This is the
18/// always-on, passive half of update detection: an operator scrapes it today
19/// and, paired with `polychrome_update_available` (control plane only), learns
20/// when a newer release exists. See docs/design/upgrade-operator-decision.md.
21///
22/// The version is the lockstep workspace version (`version.workspace = true`),
23/// so every binary built from this workspace reports the same `vX.Y.Z`.
24/// Registered to the default registry so it is always present in a scrape.
25static BUILD_INFO: LazyLock<IntGaugeVec> = LazyLock::new(|| {
26    let gauge = register_int_gauge_vec!(
27        "polychrome_build_info",
28        "Always 1 for a live process; the `version` label carries the build's release version.",
29        &["version"]
30    )
31    .expect("register polychrome_build_info");
32    gauge.with_label_values(&[env!("CARGO_PKG_VERSION")]).set(1);
33    gauge
34});
35
36/// Shared readiness flag, flipped on once the process is wired up and flipped
37/// off as shutdown begins.
38#[derive(Clone, Default)]
39pub struct Health {
40    ready: Arc<AtomicBool>,
41}
42
43impl Health {
44    /// A fresh, not-yet-ready health handle.
45    #[must_use]
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// Set readiness; `/readyz` returns 200 only while `true`.
51    pub fn set_ready(&self, ready: bool) {
52        self.ready.store(ready, Ordering::Relaxed);
53    }
54}
55
56/// Serve the side-server until `shutdown` is cancelled, binding `addr` here.
57///
58/// Callers that gate readiness on their listeners actually binding should
59/// bind up front and use [`serve_on`] instead.
60///
61/// # Errors
62///
63/// Returns an error if the listener cannot bind or `axum` serving fails.
64pub async fn serve(
65    addr: SocketAddr,
66    health: Health,
67    shutdown: CancellationToken,
68) -> anyhow::Result<()> {
69    let listener = tokio::net::TcpListener::bind(addr).await?;
70    serve_on(listener, health, shutdown).await
71}
72
73/// Serve the side-server on an already-bound listener until `shutdown` is
74/// cancelled. Binding is the caller's job, so readiness can be flipped on
75/// only once the socket actually exists.
76///
77/// # Errors
78///
79/// Returns an error if `axum` serving fails.
80pub async fn serve_on(
81    listener: tokio::net::TcpListener,
82    health: Health,
83    shutdown: CancellationToken,
84) -> anyhow::Result<()> {
85    let app = Router::new()
86        .route("/healthz", get(|| async { StatusCode::OK }))
87        .route("/livez", get(|| async { StatusCode::OK }))
88        .route("/readyz", get(readyz))
89        .route("/metrics", get(metrics))
90        .with_state(health);
91
92    let addr = listener.local_addr()?;
93    tracing::info!(%addr, "side-server listening");
94    axum::serve(listener, app)
95        .with_graceful_shutdown(async move { shutdown.cancelled().await })
96        .await?;
97    Ok(())
98}
99
100/// 200 once the process is ready, else 503.
101async fn readyz(State(health): State<Health>) -> impl IntoResponse {
102    if health.ready.load(Ordering::Relaxed) {
103        StatusCode::OK
104    } else {
105        StatusCode::SERVICE_UNAVAILABLE
106    }
107}
108
109/// Prometheus scrape target: encodes the default registry (process build info
110/// plus whatever the binary has registered).
111async fn metrics() -> impl IntoResponse {
112    LazyLock::force(&BUILD_INFO);
113    let families = prometheus::gather();
114    let mut buf = Vec::new();
115    let encoder = TextEncoder::new();
116    if encoder.encode(&families, &mut buf).is_err() {
117        return (StatusCode::INTERNAL_SERVER_ERROR, Vec::new()).into_response();
118    }
119    (
120        [(axum::http::header::CONTENT_TYPE, encoder.format_type())],
121        buf,
122    )
123        .into_response()
124}
125
126#[cfg(test)]
127mod tests {
128    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
129
130    use tokio::io::{AsyncReadExt, AsyncWriteExt};
131    use tokio_util::sync::CancellationToken;
132
133    use super::{Health, serve_on};
134
135    /// One HTTP/1.1 request over a raw socket; returns the status line.
136    async fn status_line(addr: std::net::SocketAddr, path: &str) -> String {
137        let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
138        stream
139            .write_all(
140                format!("GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
141                    .as_bytes(),
142            )
143            .await
144            .unwrap();
145        let mut buf = String::new();
146        stream.read_to_string(&mut buf).await.unwrap();
147        buf.lines().next().unwrap_or_default().to_owned()
148    }
149
150    #[tokio::test]
151    async fn serve_on_takes_a_prebound_listener_and_readyz_tracks_the_flag() {
152        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
153        let addr = listener.local_addr().unwrap();
154        let health = Health::new();
155        let shutdown = CancellationToken::new();
156        let srv = tokio::spawn(serve_on(listener, health.clone(), shutdown.clone()));
157
158        // The socket exists before the serving task is even polled (the
159        // bind-then-ready contract); readiness stays the flag's job.
160        assert!(status_line(addr, "/readyz").await.contains("503"));
161        assert!(status_line(addr, "/livez").await.contains("200"));
162        health.set_ready(true);
163        assert!(status_line(addr, "/readyz").await.contains("200"));
164        health.set_ready(false);
165        assert!(status_line(addr, "/readyz").await.contains("503"));
166
167        shutdown.cancel();
168        srv.await.unwrap().unwrap();
169    }
170
171    /// `/metrics` exposes `polychrome_build_info` carrying the build version as
172    /// a label — the surface an operator scrapes to learn which release runs.
173    #[tokio::test]
174    async fn metrics_reports_build_info_with_version_label() {
175        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
176        let addr = listener.local_addr().unwrap();
177        let shutdown = CancellationToken::new();
178        let srv = tokio::spawn(serve_on(listener, Health::new(), shutdown.clone()));
179
180        let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
181        stream
182            .write_all(b"GET /metrics HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
183            .await
184            .unwrap();
185        let mut buf = String::new();
186        stream.read_to_string(&mut buf).await.unwrap();
187
188        let expected = format!(
189            "polychrome_build_info{{version=\"{}\"}} 1",
190            env!("CARGO_PKG_VERSION")
191        );
192        assert!(
193            buf.contains(&expected),
194            "scrape missing labelled build_info:\n{buf}"
195        );
196
197        shutdown.cancel();
198        srv.await.unwrap().unwrap();
199    }
200}