use std::collections::HashMap;
use serde_json::{Value, json};
use bestool_tamanu::{
services::{
Expectation, Supervisor, expected, parse_systemd_unit, systemd_patient_portal_instanced,
},
versions::{self, ExpectedVersions},
};
use super::CheckContext;
use crate::doctor::check::Check;
pub async fn run(ctx: CheckContext) -> Check {
if ctx.tamanu_version.major == 0
&& ctx.tamanu_version.minor == 0
&& ctx.tamanu_version.patch == 0
{
return Check::skip(
"version_drift",
"Tamanu version unknown",
"no install on disk and the database has no recorded currentVersion to compare against",
);
}
let supervisor = if cfg!(target_os = "linux") {
Supervisor::Systemd
} else if cfg!(target_os = "windows") {
Supervisor::Pm2
} else {
return Check::skip(
"version_drift",
"version drift check skipped on this platform",
"only Linux/systemd and Windows/pm2 deployments carry version metadata",
);
};
if matches!(supervisor, Supervisor::Pm2) {
return Check::pass(
"version_drift",
format!(
"pm2 install at v{}; no per-process drift",
ctx.tamanu_version
),
)
.with_detail("supervisor", "pm2")
.with_detail("install_version", ctx.tamanu_version.to_string());
}
let running = match versions::running_versions_linux().await {
Ok(map) => map,
Err(reason) => return unreadable_check(&reason),
};
let expected_versions = versions::expected_for_supervisor(supervisor, &ctx.tamanu_version);
let patient_portal_enabled = match ctx.db.as_deref() {
Some(client) => bestool_tamanu::server_info::query_patient_portal_enabled(client).await,
None => None,
};
let patient_portal_instanced =
matches!(supervisor, Supervisor::Systemd) && systemd_patient_portal_instanced().await;
let expectations = expected(
supervisor,
ctx.kind,
Some(ctx.config.as_ref()),
patient_portal_enabled,
patient_portal_instanced,
);
evaluate_drift(&running, &expected_versions, &expectations)
}
fn unreadable_check(reason: &str) -> Check {
Check::broken(
"version_drift",
"could not read running container versions",
format!("`podman ps` failed, so version drift can't be checked: {reason}"),
)
.with_detail("supervisor", "systemd")
}
fn evaluate_drift(
running: &HashMap<String, String>,
expected_versions: &ExpectedVersions,
expectations: &[Expectation],
) -> Check {
let mut rows: Vec<Value> = Vec::new();
let mut drifted: Vec<String> = Vec::new();
let mut total_running = 0usize;
for (unit, actual) in running {
let Some((base, _instance)) = parse_systemd_unit(unit) else {
continue;
};
let Some(exp) = expectations.iter().find(|e| e.name == base) else {
continue;
};
total_running += 1;
let exp_v = expected_versions.for_service(exp.name);
let status = versions::classify(Some(actual.as_str()), exp_v);
rows.push(json!({
"unit": unit,
"expected": exp_v,
"actual": actual,
"status": match status {
versions::VersionStatus::Match => "match",
versions::VersionStatus::Mismatch => "mismatch",
versions::VersionStatus::Unknown => "unknown",
},
}));
if status.is_mismatch() {
drifted.push(format!(
"{unit}: expected {} but running {actual}",
exp_v.unwrap_or("?"),
));
}
}
let expected_summary = json!({
"tamanu": expected_versions.tamanu,
"frontend": expected_versions.frontend,
});
if drifted.is_empty() {
let summary = if total_running == 0 {
"no running tamanu containers detected".to_string()
} else {
let tag = expected_versions.tamanu.as_deref().unwrap_or("(unknown)");
format!("{total_running} container(s) on expected version {tag}")
};
Check::pass("version_drift", summary)
.with_detail("expected", expected_summary)
.with_detail("instances", Value::Array(rows))
} else {
let summary = format!("{} container(s) on a stale version", drifted.len());
Check::fail("version_drift", summary, drifted.join("; "))
.with_detail("expected", expected_summary)
.with_detail("instances", Value::Array(rows))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::doctor::check::CheckStatus;
use bestool_tamanu::services::{ExpectedState, Instances};
fn exp(name: &'static str, instances: Instances) -> Expectation {
Expectation {
name,
instances,
state: ExpectedState::Up,
reason: "test".into(),
legacy: false,
behind_caddy: false,
}
}
fn ev(tamanu: &str, frontend: Option<&str>) -> ExpectedVersions {
ExpectedVersions {
tamanu: Some(tamanu.into()),
frontend: frontend.map(Into::into),
}
}
#[test]
fn unreadable_is_broken_not_pass() {
let check = unreadable_check("podman not found on PATH");
assert!(matches!(check.status, CheckStatus::Broken(_)), "{check:?}");
}
#[test]
fn empty_running_is_pass() {
let exps = [exp("tamanu-central-api", Instances::NumericAtLeast(2))];
let check = evaluate_drift(&HashMap::new(), &ev("v2.54.7", None), &exps);
assert!(matches!(check.status, CheckStatus::Pass), "{check:?}");
}
#[test]
fn matching_versions_pass() {
let exps = [exp("tamanu-central-api", Instances::NumericAtLeast(2))];
let running = HashMap::from([
(
"tamanu-central-api@1.service".to_string(),
"v2.54.7".to_string(),
),
(
"tamanu-central-api@2.service".to_string(),
"v2.54.7".to_string(),
),
]);
let check = evaluate_drift(&running, &ev("v2.54.7", None), &exps);
assert!(matches!(check.status, CheckStatus::Pass), "{check:?}");
}
#[test]
fn drifted_frontend_fails_naming_the_unit() {
let exps = [
exp("tamanu-frontend", Instances::Named(&["a", "b"])),
exp("tamanu-central-api", Instances::NumericAtLeast(2)),
];
let running = HashMap::from([(
"tamanu-frontend@a.service".to_string(),
"v2.54.7".to_string(),
)]);
let check = evaluate_drift(&running, &ev("v2.54.7", Some("v2.54.12")), &exps);
match &check.status {
CheckStatus::Fail(reason) => {
assert!(reason.contains("tamanu-frontend@a"), "{reason}")
}
other => panic!("expected fail, got {other:?}"),
}
}
#[test]
fn unexpected_unit_is_not_drift() {
let exps = [exp("tamanu-central-api", Instances::NumericAtLeast(2))];
let running =
HashMap::from([("tamanu-orphan@1.service".to_string(), "v1.0.0".to_string())]);
let check = evaluate_drift(&running, &ev("v2.54.7", None), &exps);
assert!(matches!(check.status, CheckStatus::Pass), "{check:?}");
}
}