use std::{path::PathBuf, sync::Arc};
use node_semver::Version;
use tokio_postgres::Client as PgClient;
use bestool_tamanu::{ApiServerKind, config::TamanuConfig};
use super::check::Check;
pub mod util;
pub mod btrfs;
pub mod caddy_certs;
pub mod caddy_version;
pub mod certificate_notification_errors;
pub mod db_connect;
pub mod db_version;
pub mod disk_free;
pub mod external_users;
pub mod fhir_job_errors;
pub mod fhir_jobs;
pub mod fhir_service_requests_unresolved;
pub mod http_errors;
pub mod inodes;
pub mod ips;
pub mod ips_errors;
pub mod kopia_backup;
pub mod load;
pub mod memory;
pub mod migrations;
pub mod patient_communication_errors;
pub mod report_errors;
pub mod sync_facility_stale;
pub mod sync_lookup;
pub mod sync_restart_loop;
pub mod sync_session_errors;
pub mod sync_sessions;
pub mod sync_snapshot_tables;
pub mod tailscale;
pub mod tamanu_http;
pub mod tamanu_service;
pub mod time_sync;
pub mod uptime;
pub mod version_drift;
#[derive(Clone)]
pub struct SweepContext {
pub tamanu: Option<CheckContext>,
pub http_client: reqwest::Client,
}
#[derive(Clone)]
pub struct CheckContext {
pub tamanu_version: Version,
pub tamanu_root: PathBuf,
pub config: Arc<TamanuConfig>,
pub kind: ApiServerKind,
pub database_url: String,
pub db: Option<Arc<PgClient>>,
pub http_client: reqwest::Client,
pub has_install: bool,
}
#[cfg(unix)]
fn is_root() -> bool {
rustix::process::geteuid().is_root()
}
#[cfg(not(unix))]
fn is_root() -> bool {
false
}
pub(crate) fn privileged(program: &str) -> tokio::process::Command {
if is_root() {
tokio::process::Command::new(program)
} else {
let mut cmd = tokio::process::Command::new("sudo");
cmd.arg("-n").arg(program);
cmd
}
}
pub fn fmt_db_error(err: &tokio_postgres::Error) -> String {
if let Some(db) = err.as_db_error() {
let mut s = format!("{}: {}", db.severity(), db.message());
if let Some(detail) = db.detail() {
s.push_str(" — ");
s.push_str(detail);
}
return s;
}
fmt_chain(err)
}
pub fn query_error_check(name: &'static str, err: &tokio_postgres::Error) -> Check {
let reason = fmt_db_error(err);
if err
.as_db_error()
.is_some_and(|db| db.code().code().starts_with("42"))
{
Check::broken(name, "healthcheck query broken", reason)
} else {
Check::fail(name, "query failed", reason)
}
}
pub fn fmt_chain<E: std::error::Error + ?Sized>(err: &E) -> String {
use std::error::Error;
let mut parts = vec![err.to_string()];
let mut src: Option<&dyn Error> = err.source();
while let Some(s) = src {
parts.push(s.to_string());
src = s.source();
}
parts.join(": ")
}
pub struct CheckEntry {
pub name: &'static str,
pub on_wire: bool,
pub run: fn(SweepContext) -> futures::future::BoxFuture<'static, Check>,
}
macro_rules! entry {
($name:literal, $module:ident) => {
CheckEntry {
name: $name,
on_wire: true,
run: |ctx| {
Box::pin(async move {
match ctx.tamanu {
Some(tamanu) => $module::run(tamanu).await,
None => Check::skip(
$name,
"no Tamanu on this host",
"check needs a Tamanu deployment, and this host has none",
),
}
})
},
}
};
($name:literal, $module:ident, off_wire) => {
CheckEntry {
name: $name,
on_wire: false,
run: |ctx| {
Box::pin(async move {
match ctx.tamanu {
Some(tamanu) => $module::run(tamanu).await,
None => Check::skip(
$name,
"no Tamanu on this host",
"check needs a Tamanu deployment, and this host has none",
),
}
})
},
}
};
($name:literal, $module:ident, host) => {
CheckEntry {
name: $name,
on_wire: true,
run: |ctx| Box::pin($module::run(ctx)),
}
};
($name:literal, $module:ident, host, off_wire) => {
CheckEntry {
name: $name,
on_wire: false,
run: |ctx| Box::pin($module::run(ctx)),
}
};
}
pub fn all() -> Vec<CheckEntry> {
vec![
entry!("db_connect", db_connect),
entry!("db_version", db_version, off_wire),
entry!("migrations", migrations),
entry!("disk_free", disk_free, host),
entry!("inodes", inodes, host),
entry!("btrfs", btrfs, host),
entry!("memory", memory, host),
entry!("load", load, host),
entry!("uptime", uptime, host, off_wire),
entry!("time_sync", time_sync, host),
entry!("tamanu_http", tamanu_http),
entry!("caddy_version", caddy_version, host),
entry!("caddy_certs", caddy_certs, host),
entry!("http_errors", http_errors, host),
entry!("tailscale", tailscale, host, off_wire),
entry!("ips", ips, host, off_wire),
entry!("tamanu_service", tamanu_service),
entry!("version_drift", version_drift),
entry!("external_users", external_users, host),
entry!("sync_sessions", sync_sessions),
entry!("fhir_jobs", fhir_jobs),
entry!("kopia_backup", kopia_backup, host),
entry!(
"certificate_notification_errors",
certificate_notification_errors
),
entry!("ips_errors", ips_errors),
entry!("patient_communication_errors", patient_communication_errors),
entry!("report_errors", report_errors),
entry!("fhir_job_errors", fhir_job_errors),
entry!("sync_session_errors", sync_session_errors),
entry!("sync_facility_stale", sync_facility_stale),
entry!("sync_snapshot_tables", sync_snapshot_tables),
entry!("sync_lookup", sync_lookup),
entry!("sync_restart_loop", sync_restart_loop),
entry!(
"fhir_service_requests_unresolved",
fhir_service_requests_unresolved
),
]
}
#[cfg(test)]
pub mod test_support {
use std::sync::Arc;
use node_semver::Version;
use bestool_tamanu::{ApiServerKind, config::TamanuConfig};
use super::CheckContext;
fn central_config() -> TamanuConfig {
serde_json::from_value(serde_json::json!({
"db": { "name": "tamanu-central", "username": "u", "password": "p" },
}))
.expect("central test config should parse")
}
fn facility_config() -> TamanuConfig {
serde_json::from_value(serde_json::json!({
"db": { "name": "tamanu-facility", "username": "u", "password": "p" },
"serverFacilityIds": ["facility-1"],
}))
.expect("facility test config should parse")
}
async fn connect(db_name: &str) -> Option<Arc<tokio_postgres::Client>> {
let url = format!("postgresql://localhost/{db_name}");
match bestool_postgres::pool::connect_one(&url, "bestool-alertd-test").await {
Ok(client) => Some(Arc::new(client)),
Err(_) => None,
}
}
pub async fn central_ctx() -> Option<CheckContext> {
let db = connect("tamanu-central").await?;
Some(CheckContext {
tamanu_version: Version::parse("0.0.0").unwrap(),
tamanu_root: std::path::PathBuf::from("/nonexistent"),
config: Arc::new(central_config()),
kind: ApiServerKind::Central,
database_url: "postgresql://localhost/tamanu-central".into(),
db: Some(db),
http_client: reqwest::Client::new(),
has_install: true,
})
}
pub fn facility_ctx() -> CheckContext {
CheckContext {
tamanu_version: Version::parse("0.0.0").unwrap(),
tamanu_root: std::path::PathBuf::from("/nonexistent"),
config: Arc::new(facility_config()),
kind: ApiServerKind::Facility,
database_url: "postgresql://localhost/tamanu-facility".into(),
db: None,
http_client: reqwest::Client::new(),
has_install: true,
}
}
}
#[cfg(test)]
mod tests {
use serde_json::Value;
use super::{SweepContext, all, query_error_check, test_support::central_ctx};
use crate::doctor::check::CheckStatus;
fn no_tamanu_ctx() -> SweepContext {
SweepContext {
tamanu: None,
http_client: reqwest::Client::new(),
}
}
fn db_only_ctx() -> SweepContext {
use std::sync::Arc;
use bestool_tamanu::{
ApiServerKind,
config::{Database, TamanuConfig},
};
use node_semver::Version;
let db = Database::from_url("postgresql://u@127.0.0.1:1/tamanu").unwrap();
SweepContext {
tamanu: Some(super::CheckContext {
tamanu_version: Version::parse("0.0.0").unwrap(),
tamanu_root: std::path::PathBuf::new(),
config: Arc::new(TamanuConfig::from_database(db)),
kind: ApiServerKind::Central,
database_url: "postgresql://u@127.0.0.1:1/tamanu".into(),
db: None,
http_client: reqwest::Client::new(),
has_install: false,
}),
http_client: reqwest::Client::new(),
}
}
#[tokio::test]
async fn checks_run_with_db_only_context() {
for name in [
"tamanu_http",
"tamanu_service",
"version_drift",
"caddy_version",
"caddy_certs",
"http_errors",
"kopia_backup",
] {
let entry = all().into_iter().find(|e| e.name == name).unwrap();
let check = (entry.run)(db_only_ctx()).await;
assert_ne!(
check.summary, "no Tamanu install on this host",
"{name} should not be install-gated"
);
}
}
#[tokio::test]
async fn db_connect_runs_with_db_only_context() {
let entry = all().into_iter().find(|e| e.name == "db_connect").unwrap();
let check = (entry.run)(db_only_ctx()).await;
assert!(
matches!(check.status, CheckStatus::Fail(_)),
"db_connect should run (and fail) with a db-only context, got {:?}",
check.to_wire()["result"]
);
}
#[tokio::test]
async fn tamanu_checks_skip_without_tamanu() {
for name in ["db_version", "version_drift", "tamanu_http"] {
let entry = all().into_iter().find(|e| e.name == name).unwrap();
let check = (entry.run)(no_tamanu_ctx()).await;
assert!(
matches!(check.status, CheckStatus::Skip(_)),
"{name} should skip without tamanu, got {:?}",
check.to_wire()["result"]
);
}
}
#[tokio::test]
async fn host_checks_run_without_tamanu() {
for name in ["memory", "disk_free", "uptime"] {
let entry = all().into_iter().find(|e| e.name == name).unwrap();
let check = (entry.run)(no_tamanu_ctx()).await;
assert!(
!matches!(check.status, CheckStatus::Skip(_)),
"{name} should run without tamanu"
);
}
}
async fn query_err(sql: &str) -> Option<tokio_postgres::Error> {
let ctx = central_ctx().await?;
let client = ctx.db.expect("central_ctx always has a client");
Some(
client
.query(sql, &[])
.await
.expect_err("query should error"),
)
}
#[tokio::test]
async fn schema_drift_is_broken() {
let Some(err) = query_err("SELECT nope FROM no_such_table_bestool_test").await else {
return;
};
let check = query_error_check("x", &err);
assert!(matches!(check.status, CheckStatus::Broken(_)));
assert_eq!(check.to_wire()["result"], Value::from("broken"));
}
#[tokio::test]
async fn syntax_error_is_broken() {
let Some(err) = query_err("SELECT FROM WHERE").await else {
return;
};
let check = query_error_check("x", &err);
assert!(matches!(check.status, CheckStatus::Broken(_)));
}
#[tokio::test]
async fn runtime_db_error_still_fails() {
let Some(err) = query_err("SELECT 1/0").await else {
return;
};
let check = query_error_check("x", &err);
assert!(check.status.is_fatal());
assert_eq!(check.to_wire()["result"], Value::from("failed"));
}
}