Skip to main content

spate_core/
admin.rs

1//! Admin HTTP server: `/metrics`, `/healthz`, and `/readyz`.
2//!
3//! One small hyper server per process, running on the I/O runtime. Probe
4//! semantics:
5//!
6//! - **`/readyz`** — 200 once the source has received its assignment and
7//!   the sinks are connected.
8//! - **`/healthz`** — 200 while every pipeline thread's poll-loop heartbeat
9//!   is fresh AND the checkpoint watermark is not stuck while data flows.
10//!   A stalled poll loop or a wedged watermark flips this to 503 so the
11//!   kubelet restarts the pod.
12//!
13//! The server takes the metrics renderer as a plain closure
14//! ([`RenderFn`]) and has no dependency on exporter internals. The probes
15//! are served regardless of the exporter; `/metrics` exists only when a
16//! renderer is supplied, and is a 404 otherwise.
17
18use std::io;
19use std::net::SocketAddr;
20use std::sync::Arc;
21use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
22use std::time::{Duration, Instant};
23
24use crate::clock::{SharedClock, SystemClock};
25
26use http_body_util::Full;
27use hyper::body::Bytes;
28use hyper::server::conn::http1;
29use hyper::service::service_fn;
30use hyper::{Method, Request, Response, StatusCode};
31use tokio::net::TcpListener;
32use tokio::sync::watch;
33use tokio::task::JoinSet;
34
35/// Renders the current metrics exposition snapshot.
36pub type RenderFn = Arc<dyn Fn() -> String + Send + Sync>;
37
38/// Staleness thresholds for the liveness probe.
39#[derive(Clone, Copy, Debug)]
40pub struct HealthThresholds {
41    /// A pipeline thread whose heartbeat is older than this is considered
42    /// stalled.
43    pub heartbeat_stale: Duration,
44    /// The watermark is considered stuck when its age exceeds this while
45    /// data is flowing.
46    pub watermark_stuck: Duration,
47}
48
49impl Default for HealthThresholds {
50    fn default() -> Self {
51        HealthThresholds {
52            heartbeat_stale: Duration::from_secs(30),
53            watermark_stuck: Duration::from_secs(300),
54        }
55    }
56}
57
58/// Shared health state, updated by the pipeline runtime and read by the
59/// probes. All methods are lock-free and callable from any thread.
60pub struct HealthState {
61    clock: SharedClock,
62    epoch: Instant,
63    thresholds: HealthThresholds,
64    assignment_received: AtomicBool,
65    sinks_connected: AtomicBool,
66    /// Per-pipeline-thread heartbeat, as millis since `epoch`.
67    heartbeats: Vec<AtomicU64>,
68    /// Watermark age reported by the checkpointer, in millis.
69    watermark_age_ms: AtomicU64,
70    data_flowing: AtomicBool,
71}
72
73impl std::fmt::Debug for HealthState {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.debug_struct("HealthState")
76            .field("epoch", &self.epoch)
77            .field("thresholds", &self.thresholds)
78            .field("assignment_received", &self.assignment_received)
79            .field("sinks_connected", &self.sinks_connected)
80            .field("heartbeats", &self.heartbeats)
81            .field("watermark_age_ms", &self.watermark_age_ms)
82            .field("data_flowing", &self.data_flowing)
83            .finish_non_exhaustive()
84    }
85}
86
87impl HealthState {
88    /// Create state for `pipeline_threads` heartbeating threads. All
89    /// heartbeats start "fresh" at creation time.
90    #[must_use]
91    pub fn new(pipeline_threads: usize, thresholds: HealthThresholds) -> Arc<Self> {
92        Self::with_clock(pipeline_threads, thresholds, Arc::new(SystemClock))
93    }
94
95    /// [`new`](Self::new), reading heartbeats and liveness on `clock`.
96    pub(crate) fn with_clock(
97        pipeline_threads: usize,
98        thresholds: HealthThresholds,
99        clock: SharedClock,
100    ) -> Arc<Self> {
101        Arc::new(HealthState {
102            epoch: clock.now(),
103            clock,
104            thresholds,
105            assignment_received: AtomicBool::new(false),
106            sinks_connected: AtomicBool::new(false),
107            heartbeats: (0..pipeline_threads).map(|_| AtomicU64::new(0)).collect(),
108            watermark_age_ms: AtomicU64::new(0),
109            data_flowing: AtomicBool::new(false),
110        })
111    }
112
113    fn millis_since_epoch(&self, now: Instant) -> u64 {
114        u64::try_from(now.saturating_duration_since(self.epoch).as_millis()).unwrap_or(u64::MAX)
115    }
116
117    /// Beat thread `thread`'s heart on the clock given at construction, the
118    /// system clock from [`new`](Self::new). Called once per poll iteration;
119    /// out of range indices are ignored.
120    pub fn heartbeat(&self, thread: usize) {
121        if let Some(hb) = self.heartbeats.get(thread) {
122            hb.store(self.millis_since_epoch(self.clock.now()), Ordering::Relaxed);
123        }
124    }
125
126    /// Mark whether the source has received its (initial) assignment.
127    pub fn set_assignment_received(&self, received: bool) {
128        self.assignment_received.store(received, Ordering::Relaxed);
129    }
130
131    /// Mark whether all sinks report connectivity.
132    pub fn set_sinks_connected(&self, connected: bool) {
133        self.sinks_connected.store(connected, Ordering::Relaxed);
134    }
135
136    /// Report the checkpointer's oldest-unacknowledged-batch age and
137    /// whether data is currently flowing (a stuck watermark only counts
138    /// against liveness while records move).
139    pub fn report_watermark(&self, age: Duration, data_flowing: bool) {
140        self.watermark_age_ms.store(
141            u64::try_from(age.as_millis()).unwrap_or(u64::MAX),
142            Ordering::Relaxed,
143        );
144        self.data_flowing.store(data_flowing, Ordering::Relaxed);
145    }
146
147    /// Readiness: assignment received and sinks connected.
148    #[must_use]
149    pub fn ready(&self) -> bool {
150        self.assignment_received.load(Ordering::Relaxed)
151            && self.sinks_connected.load(Ordering::Relaxed)
152    }
153
154    /// Liveness now, on the clock given at construction, the system clock
155    /// from [`new`](Self::new).
156    #[must_use]
157    pub fn healthy(&self) -> bool {
158        self.healthy_at(self.clock.now())
159    }
160
161    /// Liveness as of `now` (injectable for tests).
162    #[must_use]
163    pub fn healthy_at(&self, now: Instant) -> bool {
164        let now_ms = self.millis_since_epoch(now);
165        let stale = u64::try_from(self.thresholds.heartbeat_stale.as_millis()).unwrap_or(u64::MAX);
166        let hearts_fresh = self
167            .heartbeats
168            .iter()
169            .all(|hb| now_ms.saturating_sub(hb.load(Ordering::Relaxed)) <= stale);
170
171        let stuck_ms =
172            u64::try_from(self.thresholds.watermark_stuck.as_millis()).unwrap_or(u64::MAX);
173        let watermark_stuck = self.data_flowing.load(Ordering::Relaxed)
174            && self.watermark_age_ms.load(Ordering::Relaxed) > stuck_ms;
175
176        hearts_fresh && !watermark_stuck
177    }
178}
179
180/// The admin server, bound and ready to run.
181pub struct AdminServer {
182    listener: TcpListener,
183    local_addr: SocketAddr,
184    render: Option<RenderFn>,
185    health: Arc<HealthState>,
186}
187
188impl std::fmt::Debug for AdminServer {
189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190        f.debug_struct("AdminServer")
191            .field("local_addr", &self.local_addr)
192            .field("health", &self.health)
193            .finish_non_exhaustive()
194    }
195}
196
197impl AdminServer {
198    /// Bind the admin socket. Use port 0 to let the OS choose (tests).
199    ///
200    /// `render` of `None` serves the probes without `/metrics`.
201    pub async fn bind(
202        addr: SocketAddr,
203        render: Option<RenderFn>,
204        health: Arc<HealthState>,
205    ) -> io::Result<Self> {
206        let listener = TcpListener::bind(addr).await?;
207        let local_addr = listener.local_addr()?;
208        Ok(AdminServer {
209            listener,
210            local_addr,
211            render,
212            health,
213        })
214    }
215
216    /// The bound address (relevant with port 0).
217    #[must_use]
218    pub fn local_addr(&self) -> SocketAddr {
219        self.local_addr
220    }
221
222    /// Accept and serve connections until `shutdown` observes `true`.
223    /// Every open connection is closed when this returns.
224    pub async fn run(self, mut shutdown: watch::Receiver<bool>) -> io::Result<()> {
225        let mut conns = JoinSet::new();
226        let result = loop {
227            tokio::select! {
228                accepted = self.listener.accept() => {
229                    let (stream, _peer) = match accepted {
230                        Ok(accepted) => accepted,
231                        Err(err) => break Err(err),
232                    };
233                    let render = self.render.clone();
234                    let health = Arc::clone(&self.health);
235                    #[expect(
236                        clippy::disallowed_methods,
237                        reason = "the set is local to `run`, so returning drops it and aborts every open connection"
238                    )]
239                    conns.spawn(async move {
240                        let io = hyper_util::rt::TokioIo::new(stream);
241                        let service = service_fn(move |req| {
242                            let render = render.clone();
243                            let health = Arc::clone(&health);
244                            async move { respond(&req, render.as_ref(), &health) }
245                        });
246                        if let Err(err) = http1::Builder::new()
247                            .serve_connection(io, service)
248                            .await
249                        {
250                            tracing::debug!(error = %err, "admin connection error");
251                        }
252                    });
253                }
254                Some(joined) = conns.join_next(), if !conns.is_empty() => {
255                    if let Err(err) = joined
256                        && let Ok(payload) = err.try_into_panic()
257                    {
258                        tracing::error!(
259                            panic = crate::task::panic_text(&*payload),
260                            "admin connection panicked"
261                        );
262                    }
263                }
264                changed = shutdown.changed() => {
265                    if changed.is_err() || *shutdown.borrow() {
266                        break Ok(());
267                    }
268                }
269            }
270        };
271        // Aborting alone leaves each connection open until its task is next
272        // polled; waiting here closes them before `run` returns.
273        conns.shutdown().await;
274        result
275    }
276}
277
278fn respond(
279    req: &Request<hyper::body::Incoming>,
280    render: Option<&RenderFn>,
281    health: &HealthState,
282) -> Result<Response<Full<Bytes>>, hyper::Error> {
283    if req.method() != Method::GET {
284        return Ok(plain(StatusCode::METHOD_NOT_ALLOWED, "method not allowed"));
285    }
286    Ok(match (req.uri().path(), render) {
287        ("/metrics", Some(render)) => {
288            let body = render();
289            Response::builder()
290                .status(StatusCode::OK)
291                .header(
292                    hyper::header::CONTENT_TYPE,
293                    "text/plain; version=0.0.4; charset=utf-8",
294                )
295                .body(Full::new(Bytes::from(body)))
296                .expect("static response parts are valid")
297        }
298        ("/healthz", _) => probe(health.healthy()),
299        ("/readyz", _) => probe(health.ready()),
300        _ => plain(StatusCode::NOT_FOUND, "not found"),
301    })
302}
303
304fn probe(ok: bool) -> Response<Full<Bytes>> {
305    if ok {
306        plain(StatusCode::OK, "ok")
307    } else {
308        plain(StatusCode::SERVICE_UNAVAILABLE, "unavailable")
309    }
310}
311
312fn plain(status: StatusCode, body: &'static str) -> Response<Full<Bytes>> {
313    Response::builder()
314        .status(status)
315        .header(hyper::header::CONTENT_TYPE, "text/plain; charset=utf-8")
316        .body(Full::new(Bytes::from_static(body.as_bytes())))
317        .expect("static response parts are valid")
318}
319
320#[expect(
321    clippy::disallowed_methods,
322    reason = "a test task ends when the test's runtime drops"
323)]
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use tokio::sync::{mpsc, oneshot};
328
329    fn thresholds(stale_ms: u64, stuck_ms: u64) -> HealthThresholds {
330        HealthThresholds {
331            heartbeat_stale: Duration::from_millis(stale_ms),
332            watermark_stuck: Duration::from_millis(stuck_ms),
333        }
334    }
335
336    /// A heartbeat is stamped on the injected clock, and stays fresh up to
337    /// and including the stale threshold after it.
338    #[test]
339    fn a_heartbeat_reads_the_injected_clock() {
340        let clock = crate::clock::TestClock::frozen();
341        let health = HealthState::with_clock(1, thresholds(1000, 60_000), clock.clone());
342        clock.advance(Duration::from_millis(300));
343        health.heartbeat(0);
344        assert!(health.healthy_at(health.epoch + Duration::from_millis(1300)));
345        assert!(!health.healthy_at(health.epoch + Duration::from_millis(1301)));
346    }
347
348    /// Liveness reads the injected clock: a heartbeat goes stale once that
349    /// clock moves past the threshold, with no real time passing.
350    #[test]
351    fn liveness_reads_the_injected_clock() {
352        let clock = crate::clock::TestClock::frozen();
353        let health = HealthState::with_clock(1, thresholds(1000, 60_000), clock.clone());
354        health.heartbeat(0);
355        assert!(health.healthy());
356        clock.advance(Duration::from_millis(1001));
357        assert!(!health.healthy());
358    }
359
360    /// The clock it holds leaves `HealthState` `UnwindSafe` and
361    /// `RefUnwindSafe`.
362    #[test]
363    fn health_state_stays_unwind_safe() {
364        fn unwind_safe<T: std::panic::UnwindSafe + std::panic::RefUnwindSafe>() {}
365        unwind_safe::<HealthState>();
366    }
367
368    /// The hand-written `Debug` names the type and its thresholds.
369    #[test]
370    fn debug_names_the_health_state_and_its_thresholds() {
371        let text = format!("{:?}", HealthState::new(1, thresholds(1000, 2000)));
372        assert!(text.starts_with("HealthState {"), "{text}");
373        assert!(text.contains("heartbeat_stale: 1s"), "{text}");
374    }
375
376    #[test]
377    fn readiness_requires_assignment_and_sinks() {
378        let health = HealthState::new(2, HealthThresholds::default());
379        assert!(!health.ready());
380        health.set_assignment_received(true);
381        assert!(!health.ready());
382        health.set_sinks_connected(true);
383        assert!(health.ready());
384        health.set_sinks_connected(false);
385        assert!(!health.ready());
386    }
387
388    #[test]
389    fn liveness_detects_a_stale_heartbeat() {
390        let health = HealthState::new(2, thresholds(1_000, 300_000));
391        let start = health.epoch;
392        // Fresh at creation.
393        assert!(health.healthy_at(start + Duration::from_millis(500)));
394        // Thread 1 beats, thread 0 does not: stale after the threshold.
395        health.heartbeat(1);
396        assert!(!health.healthy_at(start + Duration::from_millis(1_501)));
397        // Both beating again: healthy shortly after.
398        health.heartbeat(0);
399        health.heartbeat(1);
400        assert!(health.healthy_at(Instant::now()));
401        // Out-of-range heartbeat is ignored, not a panic.
402        health.heartbeat(99);
403    }
404
405    #[test]
406    fn liveness_detects_a_stuck_watermark_only_while_data_flows() {
407        let health = HealthState::new(0, thresholds(60_000, 1_000));
408        let now = Instant::now();
409        health.report_watermark(Duration::from_secs(5), false);
410        assert!(health.healthy_at(now), "idle pipeline is not stuck");
411        health.report_watermark(Duration::from_secs(5), true);
412        assert!(!health.healthy_at(now), "wedged watermark under flow");
413        health.report_watermark(Duration::from_millis(500), true);
414        assert!(health.healthy_at(now));
415    }
416
417    /// Minimal HTTP/1.1 GET over the readiness API. Hand-rolled to avoid
418    /// adding tokio's `io-util` feature for one test.
419    async fn get(addr: SocketAddr, path: &str) -> (u16, String) {
420        let stream = tokio::net::TcpStream::connect(addr).await.expect("connect");
421        let req = format!("GET {path} HTTP/1.1\r\nHost: admin\r\nConnection: close\r\n\r\n");
422        let mut written = 0;
423        while written < req.len() {
424            stream.writable().await.expect("writable");
425            match stream.try_write(&req.as_bytes()[written..]) {
426                Ok(n) => written += n,
427                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
428                Err(e) => panic!("write failed: {e}"),
429            }
430        }
431        let mut buf = Vec::new();
432        loop {
433            stream.readable().await.expect("readable");
434            let mut chunk = [0u8; 4096];
435            match stream.try_read(&mut chunk) {
436                Ok(0) => break,
437                Ok(n) => buf.extend_from_slice(&chunk[..n]),
438                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
439                Err(e) => panic!("read failed: {e}"),
440            }
441        }
442        let text = String::from_utf8_lossy(&buf).into_owned();
443        let status = text
444            .split_whitespace()
445            .nth(1)
446            .and_then(|s| s.parse().ok())
447            .expect("status code");
448        let body = text
449            .split_once("\r\n\r\n")
450            .map(|(_, b)| b.to_owned())
451            .unwrap_or_default();
452        (status, body)
453    }
454
455    /// Writes all of `bytes` to `stream`.
456    async fn write_all(stream: &tokio::net::TcpStream, bytes: &[u8]) {
457        let mut written = 0;
458        while written < bytes.len() {
459            stream.writable().await.expect("writable");
460            match stream.try_write(&bytes[written..]) {
461                Ok(n) => written += n,
462                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
463                Err(e) => panic!("write failed: {e}"),
464            }
465        }
466    }
467
468    /// Reads from `stream` until `done` holds for what was read, or the peer
469    /// closes. Returns what was read.
470    async fn read_until(stream: &tokio::net::TcpStream, done: impl Fn(&[u8]) -> bool) -> Vec<u8> {
471        let mut buf = Vec::new();
472        while !done(&buf) {
473            stream.readable().await.expect("readable");
474            let mut chunk = [0u8; 4096];
475            match stream.try_read(&mut chunk) {
476                Ok(0) => break,
477                Ok(n) => buf.extend_from_slice(&chunk[..n]),
478                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
479                Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => break,
480                Err(e) => panic!("read failed: {e}"),
481            }
482        }
483        buf
484    }
485
486    /// Pins that a kept-alive connection is closed by the time `run` returns: the
487    /// client reads end of stream and the connection releases the health
488    /// state it held.
489    #[tokio::test]
490    async fn a_kept_alive_connection_ends_when_run_returns() {
491        let health = HealthState::new(0, HealthThresholds::default());
492        let server = AdminServer::bind(
493            "127.0.0.1:0".parse().expect("addr"),
494            None,
495            Arc::clone(&health),
496        )
497        .await
498        .expect("bind");
499        let addr = server.local_addr();
500        let (shutdown_tx, shutdown_rx) = watch::channel(false);
501        let (client_tx, client_rx) = oneshot::channel();
502        let _client = tokio::spawn(async move {
503            let stream = tokio::net::TcpStream::connect(addr).await.expect("connect");
504            write_all(&stream, b"GET /healthz HTTP/1.1\r\nHost: admin\r\n\r\n").await;
505            let response = read_until(&stream, |b| b.ends_with(b"\r\n\r\nok")).await;
506            shutdown_tx.send(true).expect("signal shutdown");
507            let _ = client_tx.send((stream, response));
508        });
509
510        // Awaited inline, so no other task runs between `run` returning and
511        // the count below.
512        server.run(shutdown_rx).await.expect("clean shutdown");
513        assert_eq!(
514            Arc::strong_count(&health),
515            1,
516            "the connection still holds the health state"
517        );
518
519        let (stream, response) = client_rx.await.expect("the client finished");
520        assert!(
521            response.starts_with(b"HTTP/1.1 200"),
522            "{}",
523            String::from_utf8_lossy(&response)
524        );
525        let rest = tokio::time::timeout(Duration::from_secs(30), read_until(&stream, |_| false))
526            .await
527            .expect("the connection closes when run returns");
528        assert_eq!(rest, b"");
529    }
530
531    /// Pins that a connection whose request panics is logged once at
532    /// `ERROR` with the panic's text, while the server keeps running.
533    #[test]
534    fn a_panicking_connection_is_logged() {
535        #[derive(Clone)]
536        struct Lines(mpsc::UnboundedSender<String>);
537        impl std::io::Write for Lines {
538            fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
539                let _ = self.0.send(String::from_utf8_lossy(bytes).into_owned());
540                Ok(bytes.len())
541            }
542            fn flush(&mut self) -> std::io::Result<()> {
543                Ok(())
544            }
545        }
546        let (lines_tx, mut lines_rx) = mpsc::unbounded_channel();
547        let writer = Lines(lines_tx);
548        let subscriber = tracing_subscriber::fmt()
549            .with_writer(move || writer.clone())
550            .with_ansi(false)
551            .without_time()
552            .with_target(false)
553            .with_max_level(tracing::Level::ERROR)
554            .finish();
555        let runtime = tokio::runtime::Builder::new_current_thread()
556            .enable_all()
557            .build()
558            .expect("runtime");
559        tracing::subscriber::with_default(subscriber, || {
560            runtime.block_on(async {
561                let health = HealthState::new(0, HealthThresholds::default());
562                let render: RenderFn = Arc::new(|| panic!("render boom"));
563                let server =
564                    AdminServer::bind("127.0.0.1:0".parse().expect("addr"), Some(render), health)
565                        .await
566                        .expect("bind");
567                let addr = server.local_addr();
568                let (shutdown_tx, shutdown_rx) = watch::channel(false);
569                let running = tokio::spawn(server.run(shutdown_rx));
570
571                let stream = tokio::net::TcpStream::connect(addr).await.expect("connect");
572                write_all(&stream, b"GET /metrics HTTP/1.1\r\nHost: admin\r\n\r\n").await;
573                let line = tokio::time::timeout(Duration::from_secs(30), lines_rx.recv())
574                    .await
575                    .expect("a line within the deadline")
576                    .expect("the subscriber is alive");
577                assert_eq!(
578                    line.trim(),
579                    r#"ERROR admin connection panicked panic="render boom""#
580                );
581
582                let (status, _) = get(addr, "/healthz").await;
583                assert_eq!(
584                    status, 200,
585                    "the server serves on after a connection panics"
586                );
587                shutdown_tx.send(true).expect("signal shutdown");
588                running
589                    .await
590                    .expect("server task joins")
591                    .expect("clean shutdown");
592                assert!(lines_rx.try_recv().is_err(), "one line only");
593            });
594        });
595    }
596
597    #[tokio::test]
598    async fn serves_probes_and_metrics_and_shuts_down() {
599        let health = HealthState::new(0, HealthThresholds::default());
600        let render: RenderFn = Arc::new(|| "spate_pipeline_info 1\n".to_owned());
601        let server = AdminServer::bind(
602            "127.0.0.1:0".parse().expect("addr"),
603            Some(render),
604            Arc::clone(&health),
605        )
606        .await
607        .expect("bind");
608        let addr = server.local_addr();
609        let (shutdown_tx, shutdown_rx) = watch::channel(false);
610        let running = tokio::spawn(server.run(shutdown_rx));
611
612        let (status, body) = get(addr, "/metrics").await;
613        assert_eq!(status, 200);
614        assert!(body.contains("spate_pipeline_info"));
615
616        let (status, _) = get(addr, "/readyz").await;
617        assert_eq!(status, 503, "not ready before assignment + sinks");
618        health.set_assignment_received(true);
619        health.set_sinks_connected(true);
620        let (status, body) = get(addr, "/readyz").await;
621        assert_eq!(status, 200);
622        assert_eq!(body, "ok");
623
624        let (status, _) = get(addr, "/healthz").await;
625        assert_eq!(status, 200);
626
627        let (status, _) = get(addr, "/nope").await;
628        assert_eq!(status, 404);
629
630        shutdown_tx.send(true).expect("signal shutdown");
631        running
632            .await
633            .expect("server task joins")
634            .expect("clean shutdown");
635    }
636
637    /// With no exporter there is no exposition, and `/metrics` says so. A 200
638    /// carrying an empty body would read to a scraper as a healthy target
639    /// delivering no series.
640    #[tokio::test]
641    async fn probes_serve_without_an_exporter_and_metrics_does_not() {
642        let health = HealthState::new(0, HealthThresholds::default());
643        let server = AdminServer::bind("127.0.0.1:0".parse().expect("addr"), None, health)
644            .await
645            .expect("bind");
646        let addr = server.local_addr();
647        let (shutdown_tx, shutdown_rx) = watch::channel(false);
648        let running = tokio::spawn(server.run(shutdown_rx));
649
650        let (status, _) = get(addr, "/metrics").await;
651        assert_eq!(status, 404, "no exporter, no exposition");
652
653        let (status, _) = get(addr, "/healthz").await;
654        assert_eq!(status, 200, "liveness does not depend on the exporter");
655        let (status, _) = get(addr, "/readyz").await;
656        assert_eq!(status, 503, "readiness does not depend on the exporter");
657
658        shutdown_tx.send(true).expect("signal shutdown");
659        running
660            .await
661            .expect("server task joins")
662            .expect("clean shutdown");
663    }
664}