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(®istry.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}