Skip to main content

bestool_alertd/doctor/
task.rs

1use std::{collections::HashMap, 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
54struct DoctorTaskInner {
55	binary_version: String,
56	/// `None` on hosts with no Tamanu deployment: sweeps still run (and post),
57	/// with all Tamanu-dependent checks skipped.
58	tamanu: Option<doctor::SweepTamanu>,
59	/// `SELECT version()` result, populated on the first tick that succeeds in
60	/// reaching the database. Stable for the lifetime of the PG instance, so we
61	/// reuse it across ticks instead of re-querying every minute.
62	pg_version_cache: Mutex<Option<String>>,
63	/// Latest sweep, captured on every successful tick. Served by the `latest`
64	/// HTTP endpoint so `bestool tamanu doctor` can read what the daemon
65	/// already computed instead of re-running the checks itself.
66	latest: Mutex<Option<LatestSweep>>,
67	/// Effective-severity ceilings canopy last returned on a status push, keyed
68	/// by check name. `None` until the first successful push. Applied to the
69	/// sweeps this daemon serves locally (`latest` / `recompute`) so operators
70	/// see the same severities the CLI and canopy show; the payload posted to
71	/// canopy stays raw. See [`doctor::SweepResult::apply_severities`].
72	check_severities: Mutex<Option<HashMap<String, CheckSeverity>>>,
73	/// Runs the backup driver for the types canopy asks for via `backup_now`.
74	/// `None` when backups aren't compiled in.
75	backup_dispatch: Option<BackupDispatch>,
76}
77
78#[derive(Clone)]
79struct LatestSweep {
80	computed_at: Timestamp,
81	/// The raw sweep result, kept typed so the `latest` endpoint can apply the
82	/// current severity ceilings on read rather than baking them in at sweep time.
83	sweep: doctor::SweepResult,
84}
85
86impl DoctorTask {
87	pub fn new(binary_version: String, tamanu: Option<doctor::SweepTamanu>) -> Self {
88		Self {
89			inner: Arc::new(DoctorTaskInner {
90				binary_version,
91				tamanu,
92				pg_version_cache: Mutex::new(None),
93				latest: Mutex::new(None),
94				check_severities: Mutex::new(None),
95				backup_dispatch: None,
96			}),
97		}
98	}
99
100	/// Attach the backup dispatcher invoked when canopy requests a backup.
101	///
102	/// Call right after [`DoctorTask::new`] (before the task is shared).
103	pub fn with_backup_dispatch(self, dispatch: BackupDispatch) -> Self {
104		let mut inner =
105			Arc::try_unwrap(self.inner).unwrap_or_else(|_| panic!("DoctorTask already shared"));
106		inner.backup_dispatch = Some(dispatch);
107		Self {
108			inner: Arc::new(inner),
109		}
110	}
111
112	/// A cloneable handle the HTTP `/metrics` endpoint uses to read the latest
113	/// sweep's declared stats and status census.
114	pub fn metrics_handle(&self) -> DoctorMetricsHandle {
115		DoctorMetricsHandle {
116			inner: self.inner.clone(),
117		}
118	}
119}
120
121/// Read-only view of the doctor task's latest sweep for the metrics endpoint.
122///
123/// Capping is applied on read (via [`DoctorTaskInner::capped`]) so the status
124/// census reflects canopy's current severity ceilings, matching what the
125/// `latest` endpoint and the CLI show.
126#[derive(Clone)]
127pub struct DoctorMetricsHandle {
128	inner: Arc<DoctorTaskInner>,
129}
130
131impl DoctorMetricsHandle {
132	/// The latest sweep rendered into a [`MetricsSnapshot`], or `None` if the
133	/// daemon hasn't completed a sweep yet.
134	pub async fn snapshot(&self) -> Option<MetricsSnapshot> {
135		let latest = self.inner.latest.lock().await.clone()?;
136		let sweep = self.inner.capped(latest.sweep).await;
137
138		let counts = census(&sweep.results);
139		let stats = sweep
140			.results
141			.iter()
142			.flat_map(|(check, _)| check.stats.iter().map(|stat| (check.name, stat.clone())))
143			.collect();
144
145		Some(MetricsSnapshot {
146			computed_at: latest.computed_at,
147			stats,
148			counts,
149		})
150	}
151}
152
153/// Tally check outcomes into a [`StatusCounts`]. Expects statuses already capped
154/// to canopy's ceilings, so the census matches what operators see elsewhere.
155fn census(results: &[(Check, bool)]) -> StatusCounts {
156	let mut counts = StatusCounts::default();
157	for (check, _) in results {
158		match &check.status {
159			CheckStatus::Pass => counts.passing += 1,
160			CheckStatus::Warning(_) => counts.warning += 1,
161			CheckStatus::Fail(_) => counts.failing += 1,
162			CheckStatus::Skip(_) => counts.skipped += 1,
163			CheckStatus::Broken(_) => counts.broken += 1,
164		}
165	}
166	counts
167}
168
169impl DoctorTaskInner {
170	async fn run_sweep(
171		self: &Arc<Self>,
172		ctx: &TaskContext,
173		progress: Option<doctor::progress::ProgressSender>,
174		enable_heal: bool,
175	) -> Result<doctor::SweepResult> {
176		let cached = self.pg_version_cache.lock().await.clone();
177		// Hand checks the shared canopy client so a heal action can reach canopy;
178		// only the periodic tick enables healing, so an on-demand recompute
179		// driven by `doctor --fresh` stays side-effect-free. See
180		// [`crate::doctor::heal`].
181		let sweep = doctor::perform_sweep(
182			&self.binary_version,
183			self.tamanu.clone(),
184			ctx.http_client.clone(),
185			&[],
186			&[],
187			cached,
188			progress,
189			ctx.canopy_client.clone(),
190			enable_heal,
191		)
192		.await?;
193
194		if let Some(ref version) = sweep.pg_version {
195			let mut guard = self.pg_version_cache.lock().await;
196			if guard.is_none() {
197				*guard = Some(version.clone());
198			}
199		}
200
201		let latest = LatestSweep {
202			computed_at: Timestamp::now(),
203			sweep: sweep.clone(),
204		};
205		*self.latest.lock().await = Some(latest);
206
207		Ok(sweep)
208	}
209
210	/// Snapshot the severity ceilings canopy last returned, if any.
211	async fn severities_snapshot(&self) -> Option<HashMap<String, CheckSeverity>> {
212		self.check_severities.lock().await.clone()
213	}
214
215	/// Apply the current severity ceilings to a sweep, if we have any. A no-op
216	/// (leaving the raw sweep) until canopy has returned a mapping.
217	async fn capped(&self, mut sweep: doctor::SweepResult) -> doctor::SweepResult {
218		if let Some(severities) = self.severities_snapshot().await {
219			sweep.apply_severities(&severities);
220		}
221		sweep
222	}
223
224	async fn tick(self: &Arc<Self>, ctx: &TaskContext) -> Result<()> {
225		let sweep = self.run_sweep(ctx, None, true).await?;
226
227		let Some(server_id) = sweep.server_id else {
228			warn!("no metaServerId available; skipping canopy status push");
229			return Ok(());
230		};
231
232		let Some(canopy) = ctx.canopy_client.as_ref() else {
233			warn!("no canopy client available; skipping canopy status push");
234			return Ok(());
235		};
236
237		let response = canopy
238			.status(&server_id, &sweep.payload)
239			.await
240			.map_err(|err| miette!("posting doctor status to canopy: {err}"))?;
241
242		// Cache the effective-severity ceilings for the sweeps we serve locally.
243		// The payload we just posted stays raw: canopy is the source of truth and
244		// maps severities itself.
245		*self.check_severities.lock().await = Some(response.check_severities);
246
247		// Refresh the on-disk tags cache from the effective tags canopy echoes
248		// back. Checks that read tags (e.g. billing_tags) and offline `canopy
249		// tags` consult this cache; without this the daemon would never update it.
250		let tags = response.tags.0.into_iter().collect();
251		if let Err(err) = bestool_tamanu::server_info::save_cached_tags(&tags) {
252			warn!(%err, "could not refresh tags cache from status response");
253		}
254
255		let backup_now = response.backup_now;
256
257		if !backup_now.is_empty() {
258			match &self.backup_dispatch {
259				Some(dispatch) => dispatch(backup_now),
260				None => warn!(
261					?backup_now,
262					"canopy requested a backup but no backup dispatcher is configured"
263				),
264			}
265		}
266
267		Ok(())
268	}
269
270	/// `GET /tasks/doctor/latest` — return the last sweep this daemon
271	/// computed, or 404 if it hasn't ticked yet.
272	async fn endpoint_latest(self: Arc<Self>) -> TaskEndpointResponse {
273		let snapshot = self.latest.lock().await.clone();
274		match snapshot {
275			Some(s) => {
276				let sweep = self.capped(s.sweep).await;
277				TaskEndpointResponse::Json(json!({
278					"computedAt": s.computed_at.to_string(),
279					"serverId": sweep.server_id,
280					"payload": sweep.payload,
281				}))
282			}
283			None => TaskEndpointResponse::Error {
284				status: 503,
285				message: "no doctor sweep cached yet (daemon may have just started)".into(),
286			},
287		}
288	}
289
290	/// `GET /tasks/doctor/recompute` — drive a fresh sweep and stream each
291	/// progress event back as NDJSON. Final line is the full sweep result.
292	async fn endpoint_recompute(self: Arc<Self>, ctx: TaskContext) -> TaskEndpointResponse {
293		let (progress_tx, mut progress_rx) = mpsc::unbounded_channel::<DoctorEvent>();
294		let (out_tx, out_rx) = mpsc::unbounded_channel::<Value>();
295
296		// Snapshot the ceilings once so the streamed per-check events and the
297		// final payload are capped consistently, matching what `latest` serves.
298		let severities = self.severities_snapshot().await;
299
300		let task_self = self.clone();
301		tokio::spawn(async move {
302			let progress_forward_tx = out_tx.clone();
303			let stream_severities = severities.clone();
304			let forwarder = tokio::spawn(async move {
305				while let Some(event) = progress_rx.recv().await {
306					let DoctorEvent::Completed(check) = event;
307					let check = cap_check(check, stream_severities.as_ref());
308					let _ = progress_forward_tx.send(json!({
309						"event": "check",
310						"check": check.to_streaming_json(),
311					}));
312				}
313			});
314
315			match task_self.run_sweep(&ctx, Some(progress_tx), false).await {
316				Ok(mut sweep) => {
317					if let Some(severities) = &severities {
318						sweep.apply_severities(severities);
319					}
320					// Make sure all `Completed` events arrived before we emit
321					// `done` — perform_sweep drops the sender on return, which
322					// closes the forwarder loop above.
323					let _ = forwarder.await;
324					let _ = out_tx.send(json!({
325						"event": "done",
326						"computedAt": Timestamp::now().to_string(),
327						"serverId": sweep.server_id,
328						"payload": sweep.payload,
329					}));
330				}
331				Err(err) => {
332					let _ = forwarder.await;
333					let _ = out_tx.send(json!({
334						"event": "error",
335						"message": format!("{err:?}"),
336					}));
337				}
338			}
339		});
340
341		let stream: BoxStream<'static, Value> =
342			Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(out_rx).map(|v| v));
343		TaskEndpointResponse::JsonLines(stream)
344	}
345}
346
347impl BackgroundTask for DoctorTask {
348	fn name(&self) -> &'static str {
349		"doctor"
350	}
351
352	fn interval(&self) -> Duration {
353		DOCTOR_INTERVAL
354	}
355
356	fn run<'a>(&'a self, ctx: &'a TaskContext) -> BoxFuture<'a, Result<()>> {
357		let inner = self.inner.clone();
358		Box::pin(async move { inner.tick(ctx).await })
359	}
360
361	fn http_endpoints(&self) -> Vec<TaskEndpoint> {
362		let latest_handler: TaskEndpointHandler = {
363			let inner = self.inner.clone();
364			Arc::new(move |_ctx| {
365				let inner = inner.clone();
366				Box::pin(async move { inner.endpoint_latest().await })
367			})
368		};
369
370		let recompute_handler: TaskEndpointHandler = {
371			let inner = self.inner.clone();
372			Arc::new(move |ctx| {
373				let inner = inner.clone();
374				Box::pin(async move { inner.endpoint_recompute(ctx).await })
375			})
376		};
377
378		vec![
379			TaskEndpoint {
380				name: "latest",
381				handler: latest_handler,
382			},
383			TaskEndpoint {
384				name: "recompute",
385				handler: recompute_handler,
386			},
387		]
388	}
389}
390
391#[cfg(test)]
392mod tests {
393	use super::*;
394	use crate::doctor::check::CheckStatus;
395
396	#[test]
397	fn cap_check_applies_ceiling_when_present() {
398		let mut severities = HashMap::new();
399		severities.insert("disk_free".to_string(), CheckSeverity::Warn);
400		let check = Check::fail("disk_free", "1% free", "out of space");
401		let capped = cap_check(check, Some(&severities));
402		match capped.status {
403			CheckStatus::Warning(r) => assert_eq!(r, "out of space"),
404			other => panic!("expected Warning, got {other:?}"),
405		}
406	}
407
408	#[test]
409	fn cap_check_absent_check_defaults_to_warn() {
410		// No entry for this check: canopy's default ceiling is warn, so a fail
411		// streams as a warning.
412		let check = Check::fail("brand_new", "bad", "reason");
413		let capped = cap_check(check, Some(&HashMap::new()));
414		assert!(matches!(capped.status, CheckStatus::Warning(_)));
415	}
416
417	#[test]
418	fn cap_check_no_mapping_is_a_noop() {
419		let check = Check::fail("disk_free", "1% free", "out of space");
420		let capped = cap_check(check, None);
421		assert!(matches!(capped.status, CheckStatus::Fail(_)));
422	}
423
424	#[test]
425	fn census_counts_each_status() {
426		let results = vec![
427			(Check::pass("a", ""), true),
428			(Check::pass("b", ""), true),
429			(Check::warning("c", "", "w"), true),
430			(Check::fail("d", "", "f"), true),
431			(Check::skip("e", "", "s"), true),
432			(Check::broken("g", "", "b"), true),
433		];
434		let c = census(&results);
435		assert_eq!(c.passing, 2);
436		assert_eq!(c.warning, 1);
437		assert_eq!(c.failing, 1);
438		assert_eq!(c.skipped, 1);
439		assert_eq!(c.broken, 1);
440		assert_eq!(c.total(), 6);
441		// active = ran (everything but skipped)
442		assert_eq!(c.active(), 5);
443	}
444
445	#[test]
446	fn census_reflects_severity_capping() {
447		// A fail capped to a warn ceiling must count as warning, not failing —
448		// the census tracks what operators see after capping.
449		let mut sweep = doctor::SweepResult {
450			server_id: None,
451			results: vec![(Check::fail("disk_free", "1% free", "out of space"), true)],
452			overall: doctor::check::OverallResult::Failing,
453			payload: json!({}),
454			pg_version: None,
455		};
456		let mut severities = HashMap::new();
457		severities.insert("disk_free".to_string(), CheckSeverity::Warn);
458		sweep.apply_severities(&severities);
459
460		let c = census(&sweep.results);
461		assert_eq!(c.failing, 0);
462		assert_eq!(c.warning, 1);
463	}
464}