Skip to main content

didbot_serve/
health.rs

1//! The periodic line that says what the deployment currently holds, and the
2//! state behind `GET /health`'s progress report.
3//!
4//! The development stack is operated by watching its log. Provisioning and
5//! record writes each announce themselves as they happen, but a developer who
6//! attaches to a quiet server sees nothing at all, and a developer scrolling
7//! back through a busy one has no marker to count against. One line every ten
8//! seconds fixes both: it is a heartbeat and a running total in the same
9//! place.
10//!
11//! # Liveness versus progress
12//!
13//! A process that answers HTTP is not the same claim as one whose log is
14//! advancing: a deadlocked lock, a registry stuck retrying a poisoned mutex,
15//! or a runtime with every worker parked behind a hung syscall can all leave
16//! axum answering `200` while nothing this tick would log is happening
17//! underneath it. [`HealthState`] is what lets `GET /health` say more than
18//! "a socket accepted" — it is the tick's own pulse, read back rather than
19//! re-derived, so the two can never disagree about whether the tick that
20//! *is* the deployment's sign of life is still ticking.
21//!
22//! `GET /health` itself stays the liveness check: it answers `200` whenever
23//! this process can schedule a task at all, which is what a load balancer
24//! should gate traffic on. `progress` in its body is the second claim,
25//! carried in the same response rather than a separate route, so a caller
26//! that only wants liveness reads one field and a caller diagnosing a stall
27//! reads the rest of the same document.
28
29use std::fmt;
30use std::path::PathBuf;
31use std::sync::atomic::{AtomicI64, AtomicU64, AtomicU8, Ordering};
32use std::sync::{Arc, OnceLock};
33use std::time::Duration;
34
35use didbot_pds::{Registry, RegistryStats};
36
37use didbot_tls::renew::AlertLevel;
38use futures_core::future::BoxFuture;
39use serde::Serialize;
40use time::OffsetDateTime;
41use tokio::sync::Notify;
42use tokio::task::JoinHandle;
43use tokio::time::MissedTickBehavior;
44use tracing::{info, warn};
45
46/// The commit this binary was built from, as `scripts/publish-image.sh`
47/// passes it to the image build in `DIDBOT_REVISION`. `None` for a build
48/// that was not given one, such as `cargo run`.
49pub(crate) const REVISION: Option<&str> = option_env!("DIDBOT_REVISION");
50
51/// How often the health line is written when a caller does not choose.
52pub const DEFAULT_HEALTH_INTERVAL: Duration = Duration::from_secs(10);
53
54/// The tick's pulse, shared with whoever answers `GET /health`.
55///
56/// Kept separate from [`HealthTick`] itself so a caller can hand the state to
57/// the HTTP router and the join handle to nothing but the shutdown path —
58/// the router has no business awaiting the task or aborting it, only reading
59/// the counters.
60#[derive(Default)]
61pub struct HealthState {
62    /// How many ticks have fired since the process started.
63    ticks: AtomicU64,
64    /// Unix seconds of the most recent tick, or `0` for "never yet" — the
65    /// first tick fires one interval after startup, by design; see
66    /// [`HealthTick::spawn`].
67    last_tick_unix: AtomicI64,
68    /// Unix seconds this state was created, which is startup for all
69    /// practical purposes: it is built and handed to [`HealthTick::spawn`]
70    /// in the same breath the tick task is.
71    started_unix: i64,
72    /// Whoever can answer for the certificates, once there is one — see
73    /// [`HealthState::attach_certificates`] for why this is filled in after
74    /// the router is already serving rather than passed to the constructor.
75    certificates: OnceLock<Arc<dyn CertificateAlerts>>,
76    /// The data directory whose volume [`HealthState::disk`] reads, and
77    /// the watermarks its free space is measured against; see
78    /// [`HealthState::attach_data_dir`].
79    data_dir: OnceLock<(PathBuf, DiskWatermarks)>,
80    /// The [`DiskAlert`] the last tick read, as its `u8`: `ok` before one
81    /// has.
82    disk_alert: AtomicU8,
83}
84
85impl fmt::Debug for HealthState {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        f.debug_struct("HealthState")
88            .field("ticks", &self.ticks)
89            .field("last_tick_unix", &self.last_tick_unix)
90            .field("started_unix", &self.started_unix)
91            .field("certificates", &self.certificates.get().is_some())
92            .field("data_dir", &self.data_dir.get())
93            .finish()
94    }
95}
96
97/// Reads back each zone's certificate alert level.
98///
99/// A trait rather than the concrete `didbot_tls::fleet::CertificateFleet`
100/// because that type is generic over its DNS provider, and `AppState` — one
101/// type, shared by every route — would otherwise carry that parameter
102/// through the whole router for the sake of one field on one response.
103///
104/// The future is boxed for the same reason the trait exists: an alert level
105/// is read from behind a `tokio::sync::Mutex` the renewal loop also holds,
106/// so answering is `async`, and an `async fn` in a trait is not object safe.
107pub trait CertificateAlerts: Send + Sync {
108    /// Each zone this deployment serves, and how loud its renewal state
109    /// currently is, in configuration order.
110    fn zone_alert_levels(&self) -> BoxFuture<'_, Vec<(String, AlertLevel)>>;
111}
112
113/// What `GET /health` reports about certificate renewal.
114///
115/// `worst` is one field an alarm expression can match on without parsing a
116/// list, and `zones` is what an operator reading the same response needs in
117/// order to know which zone to go and look at — the pair
118/// `didbot_tls::fleet::CertificateFleet` already draws the distinction
119/// between.
120///
121/// A deployment serving no configured certificate answers `ok` over an empty
122/// `zones`, which is `CertificateFleet::alert_level`'s own answer for a
123/// fleet of no zones: the worst level of nothing is `ok`.
124#[derive(Debug, Clone, Serialize)]
125#[serde(rename_all = "camelCase")]
126pub struct CertificateHealth {
127    /// The loudest level any zone is at.
128    pub worst: &'static str,
129    /// Each zone and its own level.
130    pub zones: Vec<ZoneCertificateHealth>,
131}
132
133/// What `GET /health` reports about the data volume.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
135#[serde(rename_all = "camelCase")]
136pub struct DiskHealth {
137    /// Bytes this server may still write there. See
138    /// [`didbot_pds::volume`] for which blocks that leaves out.
139    pub free_bytes: u64,
140    /// The volume's size.
141    pub total_bytes: u64,
142    /// The lowest watermark the free space is below. A string an alarm can
143    /// match on, as `certificates.worst` is.
144    pub alert: DiskAlert,
145}
146
147/// Which free-space watermark the data volume is below.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
149#[serde(rename_all = "lowercase")]
150#[repr(u8)]
151pub enum DiskAlert {
152    /// Above both.
153    Ok,
154    /// Below [`DiskWatermarks::warning_free_percent`].
155    Warning,
156    /// Below [`DiskWatermarks::critical_free_percent`].
157    Critical,
158}
159
160impl DiskAlert {
161    /// The name `GET /health` and the journal give it.
162    #[must_use]
163    pub fn as_str(self) -> &'static str {
164        match self {
165            Self::Ok => "ok",
166            Self::Warning => "warning",
167            Self::Critical => "critical",
168        }
169    }
170
171    fn from_u8(value: u8) -> Option<Self> {
172        [Self::Ok, Self::Warning, Self::Critical]
173            .into_iter()
174            .find(|alert| *alert as u8 == value)
175    }
176}
177
178/// The free space, as a percentage of the data volume, below which
179/// `GET /health`'s `disk.alert` and the journal raise an alert. The
180/// `[capacity] disk_*_free_percent` keys set them.
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub struct DiskWatermarks {
183    /// Below this, `warning`.
184    pub warning_free_percent: u8,
185    /// Below this, `critical`.
186    pub critical_free_percent: u8,
187}
188
189impl Default for DiskWatermarks {
190    fn default() -> Self {
191        Self {
192            warning_free_percent: 20,
193            critical_free_percent: 10,
194        }
195    }
196}
197
198impl DiskWatermarks {
199    /// The alert `free` bytes of `total` is at.
200    #[must_use]
201    pub fn alert(&self, free: u64, total: u64) -> DiskAlert {
202        let below = |percent: u8| u128::from(free) * 100 < u128::from(total) * u128::from(percent);
203        if below(self.critical_free_percent) {
204            DiskAlert::Critical
205        } else if below(self.warning_free_percent) {
206            DiskAlert::Warning
207        } else {
208            DiskAlert::Ok
209        }
210    }
211
212    /// The watermark `alert` is below, in percent. `None` for `ok`.
213    fn percent(&self, alert: DiskAlert) -> Option<u8> {
214        match alert {
215            DiskAlert::Ok => None,
216            DiskAlert::Warning => Some(self.warning_free_percent),
217            DiskAlert::Critical => Some(self.critical_free_percent),
218        }
219    }
220}
221
222/// One zone's entry in [`CertificateHealth::zones`].
223#[derive(Debug, Clone, Serialize)]
224#[serde(rename_all = "camelCase")]
225pub struct ZoneCertificateHealth {
226    /// The zone hostname, as configured.
227    pub zone: String,
228    /// That zone's current alert level.
229    pub level: &'static str,
230}
231
232impl HealthState {
233    /// A fresh state, stamped with the current time as its start.
234    pub fn new() -> Arc<Self> {
235        Arc::new(Self {
236            started_unix: OffsetDateTime::now_utc().unix_timestamp(),
237            ..Self::default()
238        })
239    }
240
241    /// Records that a tick just fired.
242    fn record_tick(&self) {
243        self.ticks.fetch_add(1, Ordering::Relaxed);
244        self.last_tick_unix.store(
245            OffsetDateTime::now_utc().unix_timestamp(),
246            Ordering::Relaxed,
247        );
248    }
249
250    /// Names who can answer for the certificates.
251    ///
252    /// Separate from [`HealthState::new`] because of the order the binary
253    /// builds things in: the router — and this state with it — exists before
254    /// the ACME fleet does, since the fleet is bootstrapped by the code that
255    /// is handed the finished router to serve. The first call wins and later
256    /// ones are ignored, so a second fleet cannot quietly replace the one the
257    /// listener is actually serving certificates from.
258    pub fn attach_certificates(&self, alerts: Arc<dyn CertificateAlerts>) {
259        let _ = self.certificates.set(alerts);
260    }
261
262    /// Names the data directory whose volume [`HealthState::disk`] reads,
263    /// and the watermarks its free space is measured against.
264    ///
265    /// The first call wins, as it does for
266    /// [`HealthState::attach_certificates`]. A state nothing names has no
267    /// volume to read, which is every in-memory run.
268    pub fn attach_data_dir(&self, dir: PathBuf, watermarks: DiskWatermarks) {
269        let _ = self.data_dir.set((dir, watermarks));
270    }
271
272    /// The data volume's free and total bytes, read as this is called.
273    ///
274    /// Read on the blocking pool, because `statvfs` asks the filesystem, and
275    /// a volume that has stopped answering must not hold an async worker.
276    /// `None` when no data directory was named, and when the read fails,
277    /// which is logged.
278    pub async fn disk(&self) -> Option<DiskHealth> {
279        let (dir, watermarks) = self.data_dir.get()?.clone();
280        match tokio::task::spawn_blocking(move || didbot_pds::volume::Space::of(&dir)).await {
281            Ok(Ok(space)) => Some(DiskHealth {
282                free_bytes: space.free,
283                total_bytes: space.total,
284                alert: watermarks.alert(space.free, space.total),
285            }),
286            Ok(Err(error)) => {
287                warn!(%error, "could not read the data volume's free space");
288                None
289            }
290            Err(error) => {
291                warn!(%error, "the read of the data volume's free space did not finish");
292                None
293            }
294        }
295    }
296
297    /// Writes a line when `disk` has crossed a watermark since the last
298    /// reading: `warn` going down, `info` coming back up. A first reading
299    /// below a watermark is a crossing. Answers the alert it wrote about.
300    pub(crate) fn watch_disk(&self, disk: &DiskHealth) -> Option<DiskAlert> {
301        let watermarks = self.data_dir.get()?.1;
302        let previous =
303            DiskAlert::from_u8(self.disk_alert.swap(disk.alert as u8, Ordering::Relaxed))
304                .unwrap_or(DiskAlert::Ok);
305        let free_percent = disk.free_bytes.saturating_mul(100) / disk.total_bytes.max(1);
306        if disk.alert > previous {
307            warn!(
308                free_bytes = disk.free_bytes,
309                total_bytes = disk.total_bytes,
310                free_percent,
311                alert = disk.alert.as_str(),
312                watermark_percent = watermarks.percent(disk.alert),
313                "the data volume's free space fell below a watermark"
314            );
315        } else if disk.alert < previous {
316            info!(
317                free_bytes = disk.free_bytes,
318                total_bytes = disk.total_bytes,
319                free_percent,
320                alert = disk.alert.as_str(),
321                watermark_percent = watermarks.percent(previous),
322                "the data volume's free space rose back above a watermark"
323            );
324        } else {
325            return None;
326        }
327        Some(disk.alert)
328    }
329
330    /// The certificate half of the health response.
331    ///
332    /// `async`, and so kept out of [`HealthState::snapshot`]: the levels are
333    /// read from the renewal trackers themselves rather than from a copy
334    /// something has to remember to refresh, which is what makes a `warning`
335    /// in this response and a `warning` in the log the same fact.
336    pub async fn certificates(&self) -> CertificateHealth {
337        let levels = match self.certificates.get() {
338            Some(alerts) => alerts.zone_alert_levels().await,
339            None => Vec::new(),
340        };
341        let worst = levels
342            .iter()
343            .map(|(_, level)| *level)
344            .max()
345            .unwrap_or(AlertLevel::Ok);
346        CertificateHealth {
347            worst: worst.as_str(),
348            zones: levels
349                .into_iter()
350                .map(|(zone, level)| ZoneCertificateHealth {
351                    zone,
352                    level: level.as_str(),
353                })
354                .collect(),
355        }
356    }
357
358    /// A point-in-time read, safe to serialize into an HTTP response.
359    pub fn snapshot(&self) -> HealthSnapshot {
360        let last = self.last_tick_unix.load(Ordering::Relaxed);
361        let now = OffsetDateTime::now_utc().unix_timestamp();
362        HealthSnapshot {
363            ticks: self.ticks.load(Ordering::Relaxed),
364            uptime_secs: (now - self.started_unix).max(0),
365            last_tick_secs_ago: (last != 0).then(|| (now - last).max(0)),
366        }
367    }
368}
369
370/// What `GET /health` reports beyond liveness: whether the tick that logs
371/// this deployment's own pulse is still advancing, and how recently.
372///
373/// `last_tick_secs_ago` absent means no tick has landed since startup yet,
374/// which is expected for the first [`DEFAULT_HEALTH_INTERVAL`] of a fresh
375/// process and worth alerting on past it — see `plan/deploy.md`'s "alert on
376/// the things that fail quietly".
377#[derive(Debug, Clone, Copy, Serialize)]
378#[serde(rename_all = "camelCase")]
379pub struct HealthSnapshot {
380    /// How many ticks have fired.
381    pub ticks: u64,
382    /// Seconds since this process started.
383    pub uptime_secs: i64,
384    /// Seconds since the last tick, or absent if none has fired yet.
385    pub last_tick_secs_ago: Option<i64>,
386}
387
388/// A running health tick, stopped by [`HealthTick::stop`] or by dropping it.
389///
390/// The task holds only an `Arc<dyn Registry>` and never keeps the process
391/// alive on its own: a tokio task dies with its runtime, and this handle
392/// aborts it on drop so a server that has finished shutting down does not
393/// leave a timer writing lines about a registry nobody is serving.
394#[derive(Debug)]
395pub struct HealthTick {
396    handle: JoinHandle<()>,
397    stop: Arc<Notify>,
398    state: Arc<HealthState>,
399}
400
401impl HealthTick {
402    /// Starts writing a health line every `interval`, with its own
403    /// [`HealthState`] a caller can read back through [`HealthTick::state`].
404    ///
405    /// The first line comes after one interval rather than immediately: at
406    /// startup the counts are all zero and the banner has just said as much.
407    ///
408    /// A tick that arrives late is delayed rather than replayed. Catching up
409    /// on missed ticks would print several identical lines in a row after the
410    /// machine came back from a suspend, which says nothing a single current
411    /// line does not.
412    pub fn spawn(registry: Arc<dyn Registry>, interval: Duration) -> Self {
413        let stop = Arc::new(Notify::new());
414        let notified = stop.clone();
415        let state = HealthState::new();
416        let ticked = state.clone();
417        let handle = tokio::spawn(async move {
418            let mut ticker = tokio::time::interval(interval);
419            ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
420            // `interval` yields its first tick immediately.
421            ticker.tick().await;
422            loop {
423                tokio::select! {
424                    // Biased so that a stop that arrives with a due tick wins.
425                    // A line written after the shutdown log line would read as
426                    // a server that had not actually stopped.
427                    biased;
428                    () = notified.notified() => break,
429                    _ = ticker.tick() => {
430                        let disk = ticked.disk().await;
431                        log_stats(&registry.stats(), disk.as_ref());
432                        if let Some(disk) = &disk {
433                            ticked.watch_disk(disk);
434                        }
435                        crate::refusal::flush();
436                        ticked.record_tick();
437                    }
438                }
439            }
440        });
441        Self {
442            handle,
443            stop,
444            state,
445        }
446    }
447
448    /// The pulse this tick is keeping, for wiring into the HTTP router.
449    pub fn state(&self) -> Arc<HealthState> {
450        self.state.clone()
451    }
452
453    /// Stops the tick and waits for the task to finish.
454    ///
455    /// Awaiting the join is what makes "stopped" true rather than "asked to
456    /// stop": a caller that returns immediately can still race one last line
457    /// into the log after its own shutdown message.
458    pub async fn stop(mut self) {
459        // `notify_one` rather than `notify_waiters`: it stores a permit, so a
460        // stop that arrives before the task reaches its `select!` still stops
461        // it instead of being dropped on the floor.
462        self.stop.notify_one();
463        // A task that has already finished, or whose runtime is going away,
464        // is not an error here; the abort in `Drop` covers what is left.
465        let _ = (&mut self.handle).await;
466    }
467}
468
469impl Drop for HealthTick {
470    fn drop(&mut self) {
471        self.handle.abort();
472    }
473}
474
475/// Writes one health line.
476///
477/// Counts only, and a fixed number of them. Nothing here can name an account
478/// or quote a record, so the line is safe to leave running for the life of a
479/// process — and nothing here grows with what the deployment holds, so it
480/// stays one line. A real account carries dozens of collections and their
481/// names are long; rendered inline, every ten seconds, they are most of the
482/// log. `bot.did.stats` answers which collections, on request.
483///
484/// `disk_free` and `disk_total` are the data volume's, in bytes, so the
485/// journal holds a history of how fast it filled. A run with no data
486/// directory writes neither.
487fn log_stats(stats: &RegistryStats, disk: Option<&DiskHealth>) {
488    info!(
489        accounts = stats.accounts,
490        records = stats.records.records,
491        bytes = stats.records.bytes,
492        collections = stats.records.collections.len(),
493        unreadable = stats.records.unreadable,
494        disk_free = disk.map(|disk| disk.free_bytes),
495        disk_total = disk.map(|disk| disk.total_bytes),
496        "health"
497    );
498}
499
500/// The fleet answers for its own zones.
501///
502/// Written here rather than in `didbot-tls` because [`CertificateAlerts`] is
503/// this crate's trait: the HTTP surface decides what `GET /health` says, and
504/// `didbot-tls` stays a library that knows nothing about a health route.
505impl<D> CertificateAlerts for didbot_tls::fleet::CertificateFleet<D>
506where
507    D: didbot_tls::dns::AcmeDnsChallenge + 'static,
508{
509    fn zone_alert_levels(&self) -> BoxFuture<'_, Vec<(String, AlertLevel)>> {
510        Box::pin(self.zone_alert_levels())
511    }
512}