Skip to main content

bestool_alertd/doctor/
task.rs

1use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration};
2
3use bestool_canopy::schema::CheckSeverity;
4use futures::{StreamExt, future::BoxFuture, stream::BoxStream};
5use jiff::Timestamp;
6use miette::{Result, miette};
7use serde_json::{Value, json};
8use tokio::sync::{Mutex, mpsc};
9use tracing::warn;
10
11use crate::doctor::{
12	self,
13	check::{Check, CheckStatus},
14	progress::DoctorEvent,
15	stat::{MetricsSnapshot, StatusCounts},
16};
17use crate::tasks::TaskEndpointHandler;
18use crate::{BackgroundTask, TaskContext, TaskEndpoint, TaskEndpointResponse};
19
20const DOCTOR_INTERVAL: Duration = Duration::from_secs(60);
21
22/// Invoked with the `backup_now` list from canopy's status response.
23///
24/// alertd has no backup logic of its own; the bestool binary supplies this to
25/// run the in-process backup driver. Fire-and-forget: the callback spawns its
26/// own work and guards against overlapping runs.
27pub type BackupDispatch = Arc<dyn Fn(Vec<String>) + Send + Sync>;
28
29/// Apply the effective-severity ceiling to a single streamed check, if a
30/// mapping is available (a no-op otherwise). Mirrors
31/// [`doctor::SweepResult::apply_severities`] for the one-check streaming case.
32fn cap_check(check: Check, severities: Option<&HashMap<String, CheckSeverity>>) -> Check {
33	match severities {
34		Some(map) => {
35			let ceiling = doctor::sweep::severity_ceiling(map, check.name);
36			Check {
37				status: check.status.cap_to(ceiling),
38				..check
39			}
40		}
41		None => check,
42	}
43}
44
45/// Periodic doctor sweep, plus on-demand `latest` / `recompute` HTTP endpoints.
46///
47/// The outer struct just holds an `Arc<Inner>` so we can hand inner clones to
48/// the `'static` HTTP endpoint handlers without forcing the trait method
49/// `http_endpoints` to take `self: Arc<Self>`.
50pub struct DoctorTask {
51	inner: Arc<DoctorTaskInner>,
52}
53
54/// Where a sweep's Tamanu context comes from.
55enum TamanuSource {
56	/// Whatever was handed to [`DoctorTask::new`], for the lifetime of the
57	/// daemon. This build has no Tamanu integration wired up, so there's nothing
58	/// to discover.
59	Fixed,
60	/// Re-discovered before every sweep from `root` (the `--root` override, when
61	/// one was given), so an in-place upgrade lands without a daemon restart.
62	Discover { root: Option<PathBuf> },
63}
64
65struct DoctorTaskInner {
66	binary_version: String,
67	/// Tamanu context for the next sweep, refreshed by
68	/// [`DoctorTaskInner::resolve_tamanu`] when discovery is enabled. `None` on
69	/// hosts with no Tamanu deployment: sweeps still run (and post), with all
70	/// Tamanu-dependent checks skipped.
71	tamanu: Mutex<Option<doctor::SweepTamanu>>,
72	tamanu_source: TamanuSource,
73	/// `SELECT version()` result, populated on the first tick that succeeds in
74	/// reaching the database. Stable for the lifetime of the PG instance, so we
75	/// reuse it across ticks instead of re-querying every minute.
76	pg_version_cache: Mutex<Option<String>>,
77	/// Latest sweep, captured on every successful tick. Served by the `latest`
78	/// HTTP endpoint so `bestool tamanu doctor` can read what the daemon
79	/// already computed instead of re-running the checks itself.
80	latest: Mutex<Option<LatestSweep>>,
81	/// Effective-severity ceilings canopy last returned on a status push, keyed
82	/// by check name. `None` until the first successful push. Applied to the
83	/// sweeps this daemon serves locally (`latest` / `recompute`) so operators
84	/// see the same severities the CLI and canopy show; the payload posted to
85	/// canopy stays raw. See [`doctor::SweepResult::apply_severities`].
86	check_severities: Mutex<Option<HashMap<String, CheckSeverity>>>,
87	/// Runs the backup driver for the types canopy asks for via `backup_now`.
88	/// `None` when backups aren't compiled in.
89	backup_dispatch: Option<BackupDispatch>,
90}
91
92#[derive(Clone)]
93struct LatestSweep {
94	computed_at: Timestamp,
95	/// The raw sweep result, kept typed so the `latest` endpoint can apply the
96	/// current severity ceilings on read rather than baking them in at sweep time.
97	sweep: doctor::SweepResult,
98}
99
100impl DoctorTask {
101	pub fn new(binary_version: String, tamanu: Option<doctor::SweepTamanu>) -> Self {
102		Self {
103			inner: Arc::new(DoctorTaskInner {
104				binary_version,
105				tamanu: Mutex::new(tamanu),
106				tamanu_source: TamanuSource::Fixed,
107				pg_version_cache: Mutex::new(None),
108				latest: Mutex::new(None),
109				check_severities: Mutex::new(None),
110				backup_dispatch: None,
111			}),
112		}
113	}
114
115	/// Re-discover the Tamanu install before every sweep instead of reusing the
116	/// context passed to [`DoctorTask::new`], with `root` as the `--root`
117	/// override.
118	///
119	/// Call right after [`DoctorTask::new`] (before the task is shared).
120	pub fn with_tamanu_discovery(self, root: Option<PathBuf>) -> Self {
121		let mut inner =
122			Arc::try_unwrap(self.inner).unwrap_or_else(|_| panic!("DoctorTask already shared"));
123		inner.tamanu_source = TamanuSource::Discover { root };
124		Self {
125			inner: Arc::new(inner),
126		}
127	}
128
129	/// Attach the backup dispatcher invoked when canopy requests a backup.
130	///
131	/// Call right after [`DoctorTask::new`] (before the task is shared).
132	pub fn with_backup_dispatch(self, dispatch: BackupDispatch) -> Self {
133		let mut inner =
134			Arc::try_unwrap(self.inner).unwrap_or_else(|_| panic!("DoctorTask already shared"));
135		inner.backup_dispatch = Some(dispatch);
136		Self {
137			inner: Arc::new(inner),
138		}
139	}
140
141	/// A cloneable handle the HTTP `/metrics` endpoint uses to read the latest
142	/// sweep's declared stats and status census.
143	pub fn metrics_handle(&self) -> DoctorMetricsHandle {
144		DoctorMetricsHandle {
145			inner: self.inner.clone(),
146		}
147	}
148}
149
150/// Read-only view of the doctor task's latest sweep for the metrics endpoint.
151///
152/// Capping is applied on read (via [`DoctorTaskInner::capped`]) so the status
153/// census reflects canopy's current severity ceilings, matching what the
154/// `latest` endpoint and the CLI show.
155#[derive(Clone)]
156pub struct DoctorMetricsHandle {
157	inner: Arc<DoctorTaskInner>,
158}
159
160impl DoctorMetricsHandle {
161	/// The latest sweep rendered into a [`MetricsSnapshot`], or `None` if the
162	/// daemon hasn't completed a sweep yet.
163	pub async fn snapshot(&self) -> Option<MetricsSnapshot> {
164		let latest = self.inner.latest.lock().await.clone()?;
165		let sweep = self.inner.capped(latest.sweep).await;
166
167		let counts = census(&sweep.results);
168		let stats = sweep
169			.results
170			.iter()
171			.flat_map(|(check, _)| check.stats.iter().map(|stat| (check.name, stat.clone())))
172			.collect();
173
174		Some(MetricsSnapshot {
175			computed_at: latest.computed_at,
176			stats,
177			counts,
178		})
179	}
180}
181
182/// Tally check outcomes into a [`StatusCounts`]. Expects statuses already capped
183/// to canopy's ceilings, so the census matches what operators see elsewhere.
184fn census(results: &[(Check, bool)]) -> StatusCounts {
185	let mut counts = StatusCounts::default();
186	for (check, _) in results {
187		match &check.status {
188			CheckStatus::Pass => counts.passing += 1,
189			CheckStatus::Warning(_) => counts.warning += 1,
190			CheckStatus::Fail(_) => counts.failing += 1,
191			CheckStatus::Skip(_) => counts.skipped += 1,
192			CheckStatus::Broken(_) => counts.broken += 1,
193		}
194	}
195	counts
196}
197
198impl DoctorTaskInner {
199	/// The Tamanu context to sweep against.
200	///
201	/// A Tamanu upgrade replaces the version, the install root and the config
202	/// under a running daemon. Resolving once at startup would pin us to the
203	/// pre-upgrade snapshot for the life of the process: the status payload would
204	/// keep reporting the old `tamanuVersion` and `tamanuRoot`, and every
205	/// version-aware check would compare against a stale baseline. So re-discover
206	/// per sweep, keeping the last good answer when discovery errors — a
207	/// transient failure shouldn't blank out every Tamanu check.
208	async fn resolve_tamanu(&self) -> Option<doctor::SweepTamanu> {
209		let TamanuSource::Discover { root } = &self.tamanu_source else {
210			return self.tamanu.lock().await.clone();
211		};
212
213		self.apply_discovery(doctor::discover_sweep_tamanu(root.as_deref()).await)
214			.await
215	}
216
217	/// Fold a discovery attempt into the stored context and return what the sweep
218	/// should use. `Ok(None)` is recorded as-is: Tamanu really is gone from this
219	/// host, and continuing to report the install we last saw would be a lie.
220	async fn apply_discovery(
221		&self,
222		discovered: Result<Option<doctor::SweepTamanu>>,
223	) -> Option<doctor::SweepTamanu> {
224		let mut guard = self.tamanu.lock().await;
225		match discovered {
226			Ok(resolved) => *guard = resolved,
227			Err(err) => warn!(
228				%err,
229				"could not resolve the Tamanu install; sweeping against the last known context"
230			),
231		}
232		guard.clone()
233	}
234
235	async fn run_sweep(
236		self: &Arc<Self>,
237		ctx: &TaskContext,
238		progress: Option<doctor::progress::ProgressSender>,
239		enable_heal: bool,
240	) -> Result<doctor::SweepResult> {
241		let cached = self.pg_version_cache.lock().await.clone();
242		let tamanu = self.resolve_tamanu().await;
243		// Hand checks the shared canopy client so a heal action can reach canopy;
244		// only the periodic tick enables healing, so an on-demand recompute
245		// driven by `doctor --fresh` stays side-effect-free. See
246		// [`crate::doctor::heal`].
247		let sweep = doctor::perform_sweep(
248			&self.binary_version,
249			tamanu,
250			ctx.http_client.clone(),
251			&[],
252			&[],
253			cached,
254			progress,
255			ctx.canopy_client.clone(),
256			enable_heal,
257		)
258		.await?;
259
260		if let Some(ref version) = sweep.pg_version {
261			let mut guard = self.pg_version_cache.lock().await;
262			if guard.is_none() {
263				*guard = Some(version.clone());
264			}
265		}
266
267		let latest = LatestSweep {
268			computed_at: Timestamp::now(),
269			sweep: sweep.clone(),
270		};
271		*self.latest.lock().await = Some(latest);
272
273		Ok(sweep)
274	}
275
276	/// Snapshot the severity ceilings canopy last returned, if any.
277	async fn severities_snapshot(&self) -> Option<HashMap<String, CheckSeverity>> {
278		self.check_severities.lock().await.clone()
279	}
280
281	/// Apply the current severity ceilings to a sweep, if we have any. A no-op
282	/// (leaving the raw sweep) until canopy has returned a mapping.
283	async fn capped(&self, mut sweep: doctor::SweepResult) -> doctor::SweepResult {
284		if let Some(severities) = self.severities_snapshot().await {
285			sweep.apply_severities(&severities);
286		}
287		sweep
288	}
289
290	async fn tick(self: &Arc<Self>, ctx: &TaskContext) -> Result<()> {
291		let sweep = self.run_sweep(ctx, None, true).await?;
292
293		let Some(server_id) = sweep.server_id else {
294			warn!("no metaServerId available; skipping canopy status push");
295			return Ok(());
296		};
297
298		let Some(canopy) = ctx.canopy_client.as_ref() else {
299			warn!("no canopy client available; skipping canopy status push");
300			return Ok(());
301		};
302
303		let response = canopy
304			.status(&server_id, &sweep.payload)
305			.await
306			.map_err(|err| miette!("posting doctor status to canopy: {err}"))?;
307
308		// Cache the effective-severity ceilings for the sweeps we serve locally.
309		// The payload we just posted stays raw: canopy is the source of truth and
310		// maps severities itself.
311		*self.check_severities.lock().await = Some(response.check_severities);
312
313		// Refresh the on-disk tags cache from the effective tags canopy echoes
314		// back. Checks that read tags (e.g. billing_tags) and offline `canopy
315		// tags` consult this cache; without this the daemon would never update it.
316		let tags = response.tags.0.into_iter().collect();
317		if let Err(err) = bestool_tamanu::server_info::save_cached_tags(&tags) {
318			warn!(%err, "could not refresh tags cache from status response");
319		}
320
321		let backup_now = response.backup_now;
322
323		if !backup_now.is_empty() {
324			match &self.backup_dispatch {
325				Some(dispatch) => dispatch(backup_now),
326				None => warn!(
327					?backup_now,
328					"canopy requested a backup but no backup dispatcher is configured"
329				),
330			}
331		}
332
333		Ok(())
334	}
335
336	/// `GET /tasks/doctor/latest` — return the last sweep this daemon
337	/// computed, or 404 if it hasn't ticked yet.
338	async fn endpoint_latest(self: Arc<Self>) -> TaskEndpointResponse {
339		let snapshot = self.latest.lock().await.clone();
340		match snapshot {
341			Some(s) => {
342				let sweep = self.capped(s.sweep).await;
343				TaskEndpointResponse::Json(json!({
344					"computedAt": s.computed_at.to_string(),
345					"serverId": sweep.server_id,
346					"payload": sweep.payload,
347				}))
348			}
349			None => TaskEndpointResponse::Error {
350				status: 503,
351				message: "no doctor sweep cached yet (daemon may have just started)".into(),
352			},
353		}
354	}
355
356	/// `GET /tasks/doctor/recompute` — drive a fresh sweep and stream each
357	/// progress event back as NDJSON. Final line is the full sweep result.
358	async fn endpoint_recompute(self: Arc<Self>, ctx: TaskContext) -> TaskEndpointResponse {
359		let (progress_tx, mut progress_rx) = mpsc::unbounded_channel::<DoctorEvent>();
360		let (out_tx, out_rx) = mpsc::unbounded_channel::<Value>();
361
362		// Snapshot the ceilings once so the streamed per-check events and the
363		// final payload are capped consistently, matching what `latest` serves.
364		let severities = self.severities_snapshot().await;
365
366		let task_self = self.clone();
367		tokio::spawn(async move {
368			let progress_forward_tx = out_tx.clone();
369			let stream_severities = severities.clone();
370			let forwarder = tokio::spawn(async move {
371				while let Some(event) = progress_rx.recv().await {
372					let DoctorEvent::Completed(check) = event;
373					let check = cap_check(check, stream_severities.as_ref());
374					let _ = progress_forward_tx.send(json!({
375						"event": "check",
376						"check": check.to_streaming_json(),
377					}));
378				}
379			});
380
381			match task_self.run_sweep(&ctx, Some(progress_tx), false).await {
382				Ok(mut sweep) => {
383					if let Some(severities) = &severities {
384						sweep.apply_severities(severities);
385					}
386					// Make sure all `Completed` events arrived before we emit
387					// `done` — perform_sweep drops the sender on return, which
388					// closes the forwarder loop above.
389					let _ = forwarder.await;
390					let _ = out_tx.send(json!({
391						"event": "done",
392						"computedAt": Timestamp::now().to_string(),
393						"serverId": sweep.server_id,
394						"payload": sweep.payload,
395					}));
396				}
397				Err(err) => {
398					let _ = forwarder.await;
399					let _ = out_tx.send(json!({
400						"event": "error",
401						"message": format!("{err:?}"),
402					}));
403				}
404			}
405		});
406
407		let stream: BoxStream<'static, Value> =
408			Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(out_rx).map(|v| v));
409		TaskEndpointResponse::JsonLines(stream)
410	}
411}
412
413impl BackgroundTask for DoctorTask {
414	fn name(&self) -> &'static str {
415		"doctor"
416	}
417
418	fn interval(&self) -> Duration {
419		DOCTOR_INTERVAL
420	}
421
422	fn run<'a>(&'a self, ctx: &'a TaskContext) -> BoxFuture<'a, Result<()>> {
423		let inner = self.inner.clone();
424		Box::pin(async move { inner.tick(ctx).await })
425	}
426
427	fn http_endpoints(&self) -> Vec<TaskEndpoint> {
428		let latest_handler: TaskEndpointHandler = {
429			let inner = self.inner.clone();
430			Arc::new(move |_ctx| {
431				let inner = inner.clone();
432				Box::pin(async move { inner.endpoint_latest().await })
433			})
434		};
435
436		let recompute_handler: TaskEndpointHandler = {
437			let inner = self.inner.clone();
438			Arc::new(move |ctx| {
439				let inner = inner.clone();
440				Box::pin(async move { inner.endpoint_recompute(ctx).await })
441			})
442		};
443
444		vec![
445			TaskEndpoint {
446				name: "latest",
447				handler: latest_handler,
448			},
449			TaskEndpoint {
450				name: "recompute",
451				handler: recompute_handler,
452			},
453		]
454	}
455}
456
457#[cfg(test)]
458mod tests {
459	use node_semver::Version;
460
461	use bestool_tamanu::config::{Database, TamanuConfig};
462
463	use super::*;
464	use crate::doctor::check::CheckStatus;
465
466	const DB_URL: &str = "postgres://u:p@localhost/tamanu";
467
468	fn sweep_tamanu(version: &str) -> doctor::SweepTamanu {
469		doctor::SweepTamanu {
470			version: Version::parse(version).unwrap(),
471			root: PathBuf::from("/opt/tamanu"),
472			config: Arc::new(TamanuConfig::from_database(
473				Database::from_url(DB_URL).unwrap(),
474			)),
475			database_url: DB_URL.into(),
476			has_install: true,
477			is_tamanu: true,
478		}
479	}
480
481	fn inner(tamanu: Option<doctor::SweepTamanu>, tamanu_source: TamanuSource) -> DoctorTaskInner {
482		DoctorTaskInner {
483			binary_version: "0.0.0-test".into(),
484			tamanu: Mutex::new(tamanu),
485			tamanu_source,
486			pg_version_cache: Mutex::new(None),
487			latest: Mutex::new(None),
488			check_severities: Mutex::new(None),
489			backup_dispatch: None,
490		}
491	}
492
493	#[tokio::test]
494	async fn discovery_replaces_the_previous_tamanu_context() {
495		// The upgrade case: the daemon started on 2.54.0 and Tamanu has since been
496		// upgraded in place. The sweep must run against the version now on disk,
497		// and the new context must stick for subsequent sweeps too.
498		let inner = inner(Some(sweep_tamanu("2.54.0")), TamanuSource::Fixed);
499		let resolved = inner
500			.apply_discovery(Ok(Some(sweep_tamanu("2.55.0"))))
501			.await
502			.expect("a context");
503		assert_eq!(resolved.version, Version::parse("2.55.0").unwrap());
504		assert_eq!(
505			inner.tamanu.lock().await.as_ref().unwrap().version,
506			Version::parse("2.55.0").unwrap()
507		);
508	}
509
510	#[tokio::test]
511	async fn discovery_failure_keeps_the_last_known_context() {
512		// Discovery can fail transiently (an unreadable root, a config that won't
513		// parse mid-write). Falling back to `None` would skip every Tamanu check;
514		// the last known install is the better answer.
515		let inner = inner(Some(sweep_tamanu("2.54.0")), TamanuSource::Fixed);
516		let resolved = inner
517			.apply_discovery(Err(miette!("no tamanu discovered")))
518			.await
519			.expect("the last known context");
520		assert_eq!(resolved.version, Version::parse("2.54.0").unwrap());
521	}
522
523	#[tokio::test]
524	async fn discovery_clears_the_context_when_tamanu_is_gone() {
525		// A successful discovery that finds nothing is a fact, not a failure:
526		// Tamanu is no longer on this host, so stop reporting the install.
527		let inner = inner(Some(sweep_tamanu("2.54.0")), TamanuSource::Fixed);
528		assert!(inner.apply_discovery(Ok(None)).await.is_none());
529		assert!(inner.tamanu.lock().await.is_none());
530	}
531
532	#[tokio::test]
533	async fn fixed_source_reuses_the_context_it_was_given() {
534		// Builds with no Tamanu integration wired up have nothing to discover, so
535		// `resolve_tamanu` must not go looking for an install.
536		let inner = inner(Some(sweep_tamanu("2.54.0")), TamanuSource::Fixed);
537		let resolved = inner.resolve_tamanu().await.expect("a context");
538		assert_eq!(resolved.version, Version::parse("2.54.0").unwrap());
539	}
540
541	#[test]
542	fn cap_check_applies_ceiling_when_present() {
543		let mut severities = HashMap::new();
544		severities.insert("disk_free".to_string(), CheckSeverity::Warn);
545		let check = Check::fail("disk_free", "1% free", "out of space");
546		let capped = cap_check(check, Some(&severities));
547		match capped.status {
548			CheckStatus::Warning(r) => assert_eq!(r, "out of space"),
549			other => panic!("expected Warning, got {other:?}"),
550		}
551	}
552
553	#[test]
554	fn cap_check_absent_check_defaults_to_warn() {
555		// No entry for this check: canopy's default ceiling is warn, so a fail
556		// streams as a warning.
557		let check = Check::fail("brand_new", "bad", "reason");
558		let capped = cap_check(check, Some(&HashMap::new()));
559		assert!(matches!(capped.status, CheckStatus::Warning(_)));
560	}
561
562	#[test]
563	fn cap_check_no_mapping_is_a_noop() {
564		let check = Check::fail("disk_free", "1% free", "out of space");
565		let capped = cap_check(check, None);
566		assert!(matches!(capped.status, CheckStatus::Fail(_)));
567	}
568
569	#[test]
570	fn census_counts_each_status() {
571		let results = vec![
572			(Check::pass("a", ""), true),
573			(Check::pass("b", ""), true),
574			(Check::warning("c", "", "w"), true),
575			(Check::fail("d", "", "f"), true),
576			(Check::skip("e", "", "s"), true),
577			(Check::broken("g", "", "b"), true),
578		];
579		let c = census(&results);
580		assert_eq!(c.passing, 2);
581		assert_eq!(c.warning, 1);
582		assert_eq!(c.failing, 1);
583		assert_eq!(c.skipped, 1);
584		assert_eq!(c.broken, 1);
585		assert_eq!(c.total(), 6);
586		// active = ran (everything but skipped)
587		assert_eq!(c.active(), 5);
588	}
589
590	#[test]
591	fn census_reflects_severity_capping() {
592		// A fail capped to a warn ceiling must count as warning, not failing —
593		// the census tracks what operators see after capping.
594		let mut sweep = doctor::SweepResult {
595			server_id: None,
596			results: vec![(Check::fail("disk_free", "1% free", "out of space"), true)],
597			overall: doctor::check::OverallResult::Failing,
598			payload: json!({}),
599			pg_version: None,
600		};
601		let mut severities = HashMap::new();
602		severities.insert("disk_free".to_string(), CheckSeverity::Warn);
603		sweep.apply_severities(&severities);
604
605		let c = census(&sweep.results);
606		assert_eq!(c.failing, 0);
607		assert_eq!(c.warning, 1);
608	}
609}