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    time::Duration,
11};
12
13use axum::{Router, extract::State, http::StatusCode, response::IntoResponse, routing::get};
14use prometheus::{Encoder, IntGaugeVec, TextEncoder, register_int_gauge_vec};
15use tokio_util::sync::CancellationToken;
16
17/// `1` while the process is up, labelled with the build's release version, so a
18/// `/metrics` scrape reports which release every process runs. This is the
19/// always-on, passive half of update detection: an operator scrapes it today
20/// and, paired with `polychrome_update_available` (control plane only), learns
21/// when a newer release exists. See docs/design/upgrade-operator-decision.md.
22///
23/// The version is the lockstep workspace version (`version.workspace = true`),
24/// so every binary built from this workspace reports the same `vX.Y.Z`.
25/// Registered to the default registry so it is always present in a scrape.
26static BUILD_INFO: LazyLock<IntGaugeVec> = LazyLock::new(|| {
27    let gauge = register_int_gauge_vec!(
28        "polychrome_build_info",
29        "Always 1 for a live process; the `version` label carries the build's release version.",
30        &["version"]
31    )
32    .expect("register polychrome_build_info");
33    gauge.with_label_values(&[env!("CARGO_PKG_VERSION")]).set(1);
34    gauge
35});
36
37/// Default delay `drain` sleeps after firing the drain hook, before
38/// responding 200 — long enough that the endpoints controller / kube-proxy
39/// observes the readiness flip (see [`Health::with_drain_propagation_delay`]
40/// for why this exists) before a `preStop` caller's response returns and
41/// SIGTERM follows.
42const DEFAULT_DRAIN_PROPAGATION_DELAY: Duration = Duration::from_secs(5);
43
44/// A hook a binary installs to run its own drain-specific work (e.g.
45/// flipping a native gRPC health check to `NOT_SERVING`, refusing new
46/// work admission) the first time `/drain` is hit. Boxed as `Arc<dyn Fn>`
47/// so [`Health`] stays `Clone` and generic over whatever a binary needs to
48/// do — this crate has no knowledge of gRPC health checks, lease
49/// acquisition, or any other binary-specific concern.
50type DrainHook = Arc<dyn Fn() + Send + Sync>;
51
52/// Shared readiness flag, flipped on once the process is wired up and flipped
53/// off as shutdown begins.
54#[derive(Clone)]
55pub struct Health {
56    ready: Arc<AtomicBool>,
57    /// Set once `/drain` has been hit; `/readyz` returns 503 unconditionally
58    /// while this is `true`, regardless of `ready`.
59    draining: Arc<AtomicBool>,
60    /// Optional binary-provided hook, fired once (on the FIRST `/drain` hit
61    /// only — see `drain`).
62    on_drain: Option<DrainHook>,
63    /// How long `/drain` sleeps before responding; see
64    /// [`Health::with_drain_propagation_delay`].
65    drain_propagation_delay: Duration,
66}
67
68impl Default for Health {
69    fn default() -> Self {
70        Self {
71            ready: Arc::default(),
72            draining: Arc::default(),
73            on_drain: None,
74            drain_propagation_delay: DEFAULT_DRAIN_PROPAGATION_DELAY,
75        }
76    }
77}
78
79impl Health {
80    /// A fresh, not-yet-ready health handle.
81    #[must_use]
82    pub fn new() -> Self {
83        Self::default()
84    }
85
86    /// Set readiness; `/readyz` returns 200 only while `true` AND the
87    /// process is not draining (see [`Health::is_draining`]).
88    pub fn set_ready(&self, ready: bool) {
89        self.ready.store(ready, Ordering::Relaxed);
90    }
91
92    /// Install a hook `/drain` fires once, the first time it's hit — never
93    /// on a repeat call, so a lifecycle-hook retry or a human re-`curl`ing
94    /// `/drain` doesn't repeat binary-specific side effects (e.g. don't
95    /// flip a gRPC health check twice). Builder-style.
96    #[must_use]
97    pub fn with_on_drain(mut self, hook: DrainHook) -> Self {
98        self.on_drain = Some(hook);
99        self
100    }
101
102    /// Override the delay `drain` sleeps before responding (production
103    /// default: `DEFAULT_DRAIN_PROPAGATION_DELAY`, 5s). Tests set this to
104    /// [`Duration::ZERO`] to keep the delay out of the critical path.
105    /// Builder-style.
106    #[must_use]
107    pub const fn with_drain_propagation_delay(mut self, delay: Duration) -> Self {
108        self.drain_propagation_delay = delay;
109        self
110    }
111
112    /// `true` once `/drain` has been hit at least once.
113    #[must_use]
114    pub fn is_draining(&self) -> bool {
115        self.draining.load(Ordering::Relaxed)
116    }
117}
118
119/// Serve the side-server until `shutdown` is cancelled, binding `addr` here.
120///
121/// Callers that gate readiness on their listeners actually binding should
122/// bind up front and use [`serve_on`] instead.
123///
124/// # Errors
125///
126/// Returns an error if the listener cannot bind or `axum` serving fails.
127pub async fn serve(
128    addr: SocketAddr,
129    health: Health,
130    shutdown: CancellationToken,
131) -> anyhow::Result<()> {
132    let listener = tokio::net::TcpListener::bind(addr).await?;
133    serve_on(listener, health, shutdown).await
134}
135
136/// Serve the side-server on an already-bound listener until `shutdown` is
137/// cancelled. Binding is the caller's job, so readiness can be flipped on
138/// only once the socket actually exists.
139///
140/// # Errors
141///
142/// Returns an error if `axum` serving fails.
143pub async fn serve_on(
144    listener: tokio::net::TcpListener,
145    health: Health,
146    shutdown: CancellationToken,
147) -> anyhow::Result<()> {
148    let app = Router::new()
149        .route("/healthz", get(|| async { StatusCode::OK }))
150        .route("/livez", get(|| async { StatusCode::OK }))
151        .route("/readyz", get(readyz))
152        .route("/metrics", get(metrics))
153        // Both verbs: Kubernetes' `preStop.httpGet` lifecycle hook issues a
154        // GET (`httpGet` has no method override); POST is for a human or
155        // script draining a pod out-of-band ahead of a manual restart.
156        .route("/drain", get(drain).post(drain))
157        .with_state(health);
158
159    let addr = listener.local_addr()?;
160    tracing::info!(%addr, "side-server listening");
161    axum::serve(listener, app)
162        .with_graceful_shutdown(async move { shutdown.cancelled().await })
163        .await?;
164    Ok(())
165}
166
167/// 200 once the process is ready, else 503. Draining always wins: once
168/// `/drain` has fired, `/readyz` reports 503 unconditionally, even if
169/// `set_ready(true)` is called afterward — there is no coming back from
170/// draining within a process's lifetime.
171async fn readyz(State(health): State<Health>) -> impl IntoResponse {
172    if health.draining.load(Ordering::Relaxed) {
173        return StatusCode::SERVICE_UNAVAILABLE;
174    }
175    if health.ready.load(Ordering::Relaxed) {
176        StatusCode::OK
177    } else {
178        StatusCode::SERVICE_UNAVAILABLE
179    }
180}
181
182/// Begin draining: flip the draining flag (idempotent — only the first call
183/// fires the hook), fire the binary-provided [`DrainHook`] if one was
184/// installed, then sleep [`Health::drain_propagation_delay`] before
185/// responding.
186///
187/// The sleep matters for the `preStop` use case specifically: Kubernetes
188/// only sends SIGTERM to the container AFTER the `preStop` hook's HTTP call
189/// returns, but the endpoints controller learns the pod is `NotReady`
190/// asynchronously (it has to observe the `/readyz` flip via its own probe
191/// cycle, then reprogram kube-proxy / the Service's endpoint slice). Without
192/// this delay, SIGTERM — and the bounded shutdown drain it starts — can
193/// arrive before traffic has actually stopped being routed to this pod,
194/// so a very short window of new connections could still land on a pod
195/// that's about to stop accepting them.
196async fn drain(State(health): State<Health>) -> impl IntoResponse {
197    let already_draining = health.draining.swap(true, Ordering::SeqCst);
198    if !already_draining && let Some(hook) = &health.on_drain {
199        hook();
200    }
201    if !health.drain_propagation_delay.is_zero() {
202        tokio::time::sleep(health.drain_propagation_delay).await;
203    }
204    (StatusCode::OK, "draining\n")
205}
206
207/// Prometheus scrape target: encodes the default registry (process build info
208/// plus whatever the binary has registered).
209async fn metrics() -> impl IntoResponse {
210    LazyLock::force(&BUILD_INFO);
211    let families = prometheus::gather();
212    let mut buf = Vec::new();
213    let encoder = TextEncoder::new();
214    if encoder.encode(&families, &mut buf).is_err() {
215        return (StatusCode::INTERNAL_SERVER_ERROR, Vec::new()).into_response();
216    }
217    (
218        [(axum::http::header::CONTENT_TYPE, encoder.format_type())],
219        buf,
220    )
221        .into_response()
222}
223
224#[cfg(test)]
225mod tests {
226    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
227
228    use std::sync::atomic::{AtomicUsize, Ordering};
229
230    use tokio::io::{AsyncReadExt, AsyncWriteExt};
231    use tokio_util::sync::CancellationToken;
232
233    use super::{Duration, Health, serve_on};
234
235    /// One HTTP/1.1 request over a raw socket; returns the status line.
236    async fn status_line(addr: std::net::SocketAddr, path: &str) -> String {
237        method_status_line(addr, "GET", path).await
238    }
239
240    /// Like [`status_line`], but with an explicit HTTP method — used to
241    /// prove `/drain` answers both GET (the Kubernetes `preStop.httpGet`
242    /// shape) and POST (a human/script hitting it out-of-band).
243    async fn method_status_line(addr: std::net::SocketAddr, method: &str, path: &str) -> String {
244        let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
245        stream
246            .write_all(
247                format!(
248                    "{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
249                )
250                .as_bytes(),
251            )
252            .await
253            .unwrap();
254        let mut buf = String::new();
255        stream.read_to_string(&mut buf).await.unwrap();
256        buf.lines().next().unwrap_or_default().to_owned()
257    }
258
259    #[tokio::test]
260    async fn serve_on_takes_a_prebound_listener_and_readyz_tracks_the_flag() {
261        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
262        let addr = listener.local_addr().unwrap();
263        let health = Health::new();
264        let shutdown = CancellationToken::new();
265        let srv = tokio::spawn(serve_on(listener, health.clone(), shutdown.clone()));
266
267        // The socket exists before the serving task is even polled (the
268        // bind-then-ready contract); readiness stays the flag's job.
269        assert!(status_line(addr, "/readyz").await.contains("503"));
270        assert!(status_line(addr, "/livez").await.contains("200"));
271        health.set_ready(true);
272        assert!(status_line(addr, "/readyz").await.contains("200"));
273        health.set_ready(false);
274        assert!(status_line(addr, "/readyz").await.contains("503"));
275
276        shutdown.cancel();
277        srv.await.unwrap().unwrap();
278    }
279
280    /// GET `/drain` flips readiness to 503 (even though `set_ready(true)`
281    /// was never undone) and fires the installed hook exactly once — a
282    /// second call (GET or POST) must not fire it again. The propagation
283    /// delay is zeroed so the test doesn't pay it.
284    #[tokio::test]
285    async fn drain_flips_readiness_and_fires_hook_once() {
286        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
287        let addr = listener.local_addr().unwrap();
288        let hook_calls = std::sync::Arc::new(AtomicUsize::new(0));
289        let health = Health::new()
290            .with_drain_propagation_delay(Duration::ZERO)
291            .with_on_drain({
292                let hook_calls = hook_calls.clone();
293                std::sync::Arc::new(move || {
294                    hook_calls.fetch_add(1, Ordering::SeqCst);
295                })
296            });
297        health.set_ready(true);
298        let shutdown = CancellationToken::new();
299        let srv = tokio::spawn(serve_on(listener, health.clone(), shutdown.clone()));
300
301        assert!(status_line(addr, "/readyz").await.contains("200"));
302        assert!(!health.is_draining());
303
304        assert!(
305            method_status_line(addr, "GET", "/drain")
306                .await
307                .contains("200")
308        );
309        assert_eq!(hook_calls.load(Ordering::SeqCst), 1);
310        assert!(health.is_draining());
311        assert!(
312            status_line(addr, "/readyz").await.contains("503"),
313            "readyz must report 503 while draining even though set_ready(true) was never undone"
314        );
315
316        // A second hit — via POST this time — must be idempotent: no second
317        // hook call, still draining.
318        assert!(
319            method_status_line(addr, "POST", "/drain")
320                .await
321                .contains("200")
322        );
323        assert_eq!(
324            hook_calls.load(Ordering::SeqCst),
325            1,
326            "the drain hook must fire only once, on the first /drain hit"
327        );
328        assert!(status_line(addr, "/readyz").await.contains("503"));
329
330        shutdown.cancel();
331        srv.await.unwrap().unwrap();
332    }
333
334    /// `/metrics` exposes `polychrome_build_info` carrying the build version as
335    /// a label — the surface an operator scrapes to learn which release runs.
336    #[tokio::test]
337    async fn metrics_reports_build_info_with_version_label() {
338        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
339        let addr = listener.local_addr().unwrap();
340        let shutdown = CancellationToken::new();
341        let srv = tokio::spawn(serve_on(listener, Health::new(), shutdown.clone()));
342
343        let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
344        stream
345            .write_all(b"GET /metrics HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
346            .await
347            .unwrap();
348        let mut buf = String::new();
349        stream.read_to_string(&mut buf).await.unwrap();
350
351        let expected = format!(
352            "polychrome_build_info{{version=\"{}\"}} 1",
353            env!("CARGO_PKG_VERSION")
354        );
355        assert!(
356            buf.contains(&expected),
357            "scrape missing labelled build_info:\n{buf}"
358        );
359
360        shutdown.cancel();
361        srv.await.unwrap().unwrap();
362    }
363}