1use 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
17static 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
37const DEFAULT_DRAIN_PROPAGATION_DELAY: Duration = Duration::from_secs(5);
43
44type DrainHook = Arc<dyn Fn() + Send + Sync>;
51
52#[derive(Clone)]
55pub struct Health {
56 ready: Arc<AtomicBool>,
57 draining: Arc<AtomicBool>,
60 on_drain: Option<DrainHook>,
63 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 #[must_use]
82 pub fn new() -> Self {
83 Self::default()
84 }
85
86 pub fn set_ready(&self, ready: bool) {
89 self.ready.store(ready, Ordering::Relaxed);
90 }
91
92 #[must_use]
97 pub fn with_on_drain(mut self, hook: DrainHook) -> Self {
98 self.on_drain = Some(hook);
99 self
100 }
101
102 #[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 #[must_use]
114 pub fn is_draining(&self) -> bool {
115 self.draining.load(Ordering::Relaxed)
116 }
117}
118
119pub 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
136pub 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 .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
167async 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
182async 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
207async 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 async fn status_line(addr: std::net::SocketAddr, path: &str) -> String {
237 method_status_line(addr, "GET", path).await
238 }
239
240 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 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 #[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 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 #[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}