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
22pub type BackupDispatch = Arc<dyn Fn(Vec<String>) + Send + Sync>;
28
29fn 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
45pub struct DoctorTask {
51 inner: Arc<DoctorTaskInner>,
52}
53
54enum TamanuSource {
56 Fixed,
60 Discover { root: Option<PathBuf> },
63}
64
65struct DoctorTaskInner {
66 binary_version: String,
67 tamanu: Mutex<Option<doctor::SweepTamanu>>,
72 tamanu_source: TamanuSource,
73 pg_version_cache: Mutex<Option<String>>,
77 latest: Mutex<Option<LatestSweep>>,
81 check_severities: Mutex<Option<HashMap<String, CheckSeverity>>>,
87 backup_dispatch: Option<BackupDispatch>,
90}
91
92#[derive(Clone)]
93struct LatestSweep {
94 computed_at: Timestamp,
95 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 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 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 pub fn metrics_handle(&self) -> DoctorMetricsHandle {
144 DoctorMetricsHandle {
145 inner: self.inner.clone(),
146 }
147 }
148}
149
150#[derive(Clone)]
156pub struct DoctorMetricsHandle {
157 inner: Arc<DoctorTaskInner>,
158}
159
160impl DoctorMetricsHandle {
161 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
182fn 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 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 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 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 async fn severities_snapshot(&self) -> Option<HashMap<String, CheckSeverity>> {
278 self.check_severities.lock().await.clone()
279 }
280
281 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 *self.check_severities.lock().await = Some(response.check_severities);
312
313 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 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 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 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 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 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 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 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 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 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 assert_eq!(c.active(), 5);
588 }
589
590 #[test]
591 fn census_reflects_severity_capping() {
592 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}