use serde_json::{Value, json};
use bestool_tamanu::{
pm2,
services::{
Expectation, ExpectedState, Instances, Supervisor, expected, parse_systemd_unit,
systemd_patient_portal_instanced,
},
systemd,
};
use super::CheckContext;
use crate::doctor::check::Check;
pub async fn run(ctx: CheckContext) -> Check {
let supervisor = if cfg!(target_os = "linux") {
Supervisor::Systemd
} else if cfg!(target_os = "windows") {
Supervisor::Pm2
} else {
return Check::skip(
"tamanu_service",
"service check skipped on this platform",
"no supervisor support on this platform",
);
};
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 config = ctx.has_install.then(|| ctx.config.as_ref());
let expectations = expected(
supervisor,
ctx.kind,
config,
patient_portal_enabled,
patient_portal_instanced,
);
let mut pm2_source: Option<pm2::Source> = None;
let mut discovered = match supervisor {
Supervisor::Systemd => match discover_systemd().await {
Ok(d) => d,
Err(err) => {
return Check::skip("tamanu_service", "systemd unavailable", err)
.with_detail("supervisor", "systemd");
}
},
Supervisor::Pm2 => match discover_pm2() {
Ok((d, source)) => {
pm2_source = Some(source);
d
}
Err(err) => {
return Check::warning(
"tamanu_service",
"pm2 status could not be queried",
format!(
"pm2 unavailable ({err}); services may be running but we can't tell from this user. Run elevated to confirm."
),
)
.with_detail("supervisor", "pm2");
}
},
};
if let Some(check) = pm2_dump_fallback_indeterminate(pm2_source, &discovered) {
return check;
}
if matches!(supervisor, Supervisor::Systemd) {
let candidates: Vec<String> = expectations
.iter()
.filter(|e| matches!(e.state, ExpectedState::Down))
.map(|e| format!("{}.service", e.name))
.collect();
let enabled = systemd::collect_enabled(candidates).await;
reconcile_down_with_enabled(&expectations, &mut discovered, |unit| {
enabled.contains(unit)
});
}
evaluate_with_source(supervisor, &expectations, &discovered, pm2_source)
}
fn pm2_dump_fallback_indeterminate(
pm2_source: Option<pm2::Source>,
discovered: &[Discovered],
) -> Option<Check> {
if matches!(pm2_source, Some(pm2::Source::Dump))
&& !discovered.is_empty()
&& discovered.iter().all(|d| !d.running)
{
Some(
Check::warning(
"tamanu_service",
"pm2 process state indeterminate",
"read pm2's dump file but couldn't verify any process is alive — likely a permissions issue (try running elevated)",
)
.with_detail("supervisor", "pm2")
.with_detail("pm2_source", pm2::Source::Dump.as_str()),
)
} else {
None
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct Discovered {
name: String,
instance: Option<String>,
running: bool,
present: bool,
raw: String,
}
async fn discover_systemd() -> Result<Vec<Discovered>, String> {
let units = systemd::list_units(&["tamanu-*.service"])
.await
.map_err(|e| e.to_string())?;
let mut out = Vec::new();
for u in units {
let Some((base, instance)) = parse_systemd_unit(&u.name) else {
continue;
};
out.push(Discovered {
name: base.to_string(),
instance: instance.map(str::to_string),
running: u.running(),
present: true,
raw: u.name,
});
}
Ok(out)
}
fn discover_pm2() -> Result<(Vec<Discovered>, pm2::Source), String> {
let (procs, source) = pm2::list()?;
let mut out = Vec::new();
for p in procs {
if !p.name.starts_with("tamanu-") {
continue;
}
let raw = match p.pm_id {
Some(id) => format!("{}#{id}", p.name),
None => p.name.clone(),
};
out.push(Discovered {
name: p.name,
instance: None,
running: p.running,
present: true,
raw,
});
}
Ok((out, source))
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum Outcome {
Ok,
Missing,
Shortfall {
running: usize,
needed: usize,
not_running: Vec<String>,
missing_named: Vec<String>,
},
Forbidden {
units: Vec<String>,
},
Indeterminate {
discovered: Vec<String>,
},
}
fn reconcile_down_with_enabled(
expectations: &[Expectation],
discovered: &mut Vec<Discovered>,
is_enabled: impl Fn(&str) -> bool,
) {
for exp in expectations {
if !matches!(exp.state, ExpectedState::Down) {
continue;
}
let unit = format!("{}.service", exp.name);
let pos = discovered.iter().position(|d| d.name == exp.name);
match pos {
None => {
if is_enabled(&unit) {
discovered.push(Discovered {
name: exp.name.to_string(),
instance: None,
running: false,
present: true,
raw: format!("{}.service (enabled)", exp.name),
});
}
}
Some(idx) if !discovered[idx].running => {
if !is_enabled(&unit) {
discovered.remove(idx);
}
}
Some(_) => {}
}
}
}
fn match_expectation(
supervisor: Supervisor,
exp: &Expectation,
discovered: &[Discovered],
) -> (Outcome, Vec<usize>) {
let matched_idx: Vec<usize> = discovered
.iter()
.enumerate()
.filter(|(_, d)| {
d.name == exp.name
&& exp
.instances
.admits_instance(supervisor, d.instance.as_deref())
})
.map(|(i, _)| i)
.collect();
match exp.state {
ExpectedState::Unknown => {
let units: Vec<String> = matched_idx
.iter()
.map(|i| discovered[*i].raw.clone())
.collect();
(Outcome::Indeterminate { discovered: units }, matched_idx)
}
ExpectedState::Down => {
if matched_idx.is_empty() {
(Outcome::Ok, matched_idx)
} else {
let units: Vec<String> = matched_idx
.iter()
.map(|i| discovered[*i].raw.clone())
.collect();
(Outcome::Forbidden { units }, matched_idx)
}
}
ExpectedState::Up => {
if matched_idx.is_empty() {
return (Outcome::Missing, matched_idx);
}
let running: Vec<&Discovered> = matched_idx
.iter()
.map(|i| &discovered[*i])
.filter(|d| d.running)
.collect();
let not_running: Vec<String> = matched_idx
.iter()
.map(|i| &discovered[*i])
.filter(|d| !d.running)
.map(|d| d.raw.clone())
.collect();
let needed = exp.instances.min_count();
let missing_named = match &exp.instances {
Instances::Named(names) => names
.iter()
.filter(|n| {
!matched_idx.iter().any(|i| {
discovered[*i].running
&& discovered[*i].instance.as_deref() == Some(**n)
})
})
.map(|n| format!("{}@{}", exp.name, n))
.collect(),
_ => Vec::new(),
};
if running.len() >= needed && missing_named.is_empty() {
(Outcome::Ok, matched_idx)
} else {
(
Outcome::Shortfall {
running: running.len(),
needed,
not_running,
missing_named,
},
matched_idx,
)
}
}
}
}
fn evaluate(
supervisor: Supervisor,
expectations: &[Expectation],
discovered: &[Discovered],
) -> Check {
let mut matched_any: Vec<bool> = vec![false; discovered.len()];
let mut per_expectation: Vec<Value> = Vec::new();
let mut diagnostics: Vec<Value> = Vec::new();
let mut failures: Vec<String> = Vec::new();
for exp in expectations {
let (outcome, idxs) = match_expectation(supervisor, exp, discovered);
for i in idxs {
matched_any[i] = true;
}
per_expectation.push(json!({
"name": exp.name,
"state": expected_state_label(exp.state),
"instances": instances_to_json(&exp.instances),
"outcome": outcome_to_json(&outcome),
"reason": exp.reason,
"legacy": exp.legacy,
"behind_caddy": exp.behind_caddy,
}));
if matches!(outcome, Outcome::Indeterminate { .. }) {
let (actual, detail) = actual_for_outcome(exp, &outcome);
let mut diag = json!({
"name": exp.name,
"expected": expected_state_label(exp.state),
"reason": exp.reason,
"actual": actual,
});
if let Some(d) = detail {
diag["detail"] = Value::String(d);
}
diagnostics.push(diag);
} else if !matches!(outcome, Outcome::Ok) {
let (actual, detail) = actual_for_outcome(exp, &outcome);
let expected_label = expected_state_label(exp.state);
let mut diag = json!({
"name": exp.name,
"expected": expected_label,
"reason": exp.reason,
"actual": actual,
});
if let Some(ref d) = detail {
diag["detail"] = Value::String(d.clone());
}
diagnostics.push(diag);
let mut line = format!(
"{}: expected {expected_label} ({reason}), got {actual}",
exp.name,
reason = exp.reason,
);
if let Some(d) = detail {
line.push_str(&format!(" ({d})"));
}
failures.push(line);
}
}
let extras: Vec<String> = discovered
.iter()
.zip(matched_any.iter())
.filter(|(_, m)| !**m)
.map(|(d, _)| d.raw.clone())
.collect();
let supervisor_label = match supervisor {
Supervisor::Systemd => "systemd",
Supervisor::Pm2 => "pm2",
};
let services_json: Value = Value::Array(
discovered
.iter()
.map(|d| {
json!({
"name": d.name,
"instance": d.instance,
"running": d.running,
"present": d.present,
"raw": d.raw,
})
})
.collect(),
);
let summary = if failures.is_empty() {
format!("{} expectation(s) met", expectations.len())
} else {
format!("{} expectation(s) unmet", failures.len())
};
let check = if failures.is_empty() {
Check::pass("tamanu_service", summary)
} else {
Check::fail("tamanu_service", summary, failures.join("; "))
};
let payload_services = json!({
"supervisor": supervisor_label,
"expectations": Value::Array(per_expectation),
"discovered": services_json,
"extras": Value::Array(extras.into_iter().map(Value::String).collect()),
});
check
.with_detail("supervisor", supervisor_label)
.with_detail("diagnostics", Value::Array(diagnostics))
.with_payload_extra("services", payload_services)
}
fn expected_state_label(s: ExpectedState) -> &'static str {
match s {
ExpectedState::Up => "up",
ExpectedState::Down => "down",
ExpectedState::Unknown => "unknown",
}
}
fn actual_for_outcome(exp: &Expectation, outcome: &Outcome) -> (&'static str, Option<String>) {
match outcome {
Outcome::Ok => (expected_state_label(exp.state), None),
Outcome::Missing => ("missing", None),
Outcome::Shortfall {
running,
needed,
not_running,
missing_named,
} => {
let mut parts = vec![format!("{running}/{needed} instance(s) running")];
if !missing_named.is_empty() {
parts.push(format!("missing {}", missing_named.join(", ")));
}
if !not_running.is_empty() {
parts.push(format!("not running: {}", not_running.join(", ")));
}
("partial", Some(parts.join("; ")))
}
Outcome::Forbidden { units } => ("up", Some(units.join(", "))),
Outcome::Indeterminate { discovered } => {
let detail = if discovered.is_empty() {
None
} else {
Some(discovered.join(", "))
};
("indeterminate", detail)
}
}
}
fn evaluate_with_source(
supervisor: Supervisor,
expectations: &[Expectation],
discovered: &[Discovered],
pm2_source: Option<pm2::Source>,
) -> Check {
let check = evaluate(supervisor, expectations, discovered);
match pm2_source {
Some(s) => check.with_detail("pm2_source", s.as_str()),
None => check,
}
}
fn instances_to_json(i: &Instances) -> Value {
match i {
Instances::Single => json!({"kind": "single"}),
Instances::NumericAtLeast(n) => json!({"kind": "numeric_at_least", "min": n}),
Instances::Named(xs) => json!({"kind": "named", "names": xs}),
}
}
fn outcome_to_json(o: &Outcome) -> Value {
match o {
Outcome::Ok => json!({"kind": "ok"}),
Outcome::Missing => json!({"kind": "missing"}),
Outcome::Shortfall {
running,
needed,
not_running,
missing_named,
} => json!({
"kind": "shortfall",
"running": running,
"needed": needed,
"not_running": not_running,
"missing_named": missing_named,
}),
Outcome::Forbidden { units } => json!({"kind": "forbidden", "units": units}),
Outcome::Indeterminate { discovered } => {
json!({"kind": "indeterminate", "discovered": discovered})
}
}
}
#[cfg(test)]
mod tests {
use bestool_tamanu::{ApiServerKind, config::TamanuConfig};
use super::*;
use crate::doctor::check::CheckStatus;
fn cfg(fhir_worker: bool) -> TamanuConfig {
let json = serde_json::json!({
"db": { "name": "x", "username": "u", "password": "p" },
"serverFacilityIds": ["facility-x"],
"integrations": { "fhir": { "worker": { "enabled": fhir_worker } } },
});
serde_json::from_value(json).unwrap()
}
fn central_cfg(fhir_worker: bool) -> TamanuConfig {
let json = serde_json::json!({
"db": { "name": "x", "username": "u", "password": "p" },
"integrations": { "fhir": { "worker": { "enabled": fhir_worker } } },
});
serde_json::from_value(json).unwrap()
}
fn d(name: &str, instance: Option<&str>, running: bool) -> Discovered {
let raw = match instance {
Some(i) => format!("{name}@{i}.service"),
None => format!("{name}.service"),
};
Discovered {
name: name.to_string(),
instance: instance.map(str::to_string),
running,
present: true,
raw,
}
}
#[test]
fn happy_facility_systemd() {
let cfg = cfg(false);
let exps = expected(
Supervisor::Systemd,
ApiServerKind::Facility,
Some(&cfg),
Some(false),
false,
);
let discovered = vec![
d("tamanu-facility-tasks", None, true),
d("tamanu-frontend", Some("a"), true),
d("tamanu-frontend", Some("b"), true),
d("tamanu-facility-api", Some("1"), true),
d("tamanu-facility-api", Some("2"), true),
d("tamanu-facility-sync", None, true),
];
let check = evaluate(Supervisor::Systemd, &exps, &discovered);
assert!(matches!(check.status, CheckStatus::Pass), "{check:?}");
}
#[test]
fn fails_when_tasks_missing() {
let cfg = cfg(false);
let exps = expected(
Supervisor::Systemd,
ApiServerKind::Facility,
Some(&cfg),
Some(false),
false,
);
let discovered = vec![
d("tamanu-frontend", Some("a"), true),
d("tamanu-frontend", Some("b"), true),
d("tamanu-facility-api", Some("1"), true),
d("tamanu-facility-api", Some("2"), true),
d("tamanu-facility-sync", None, true),
];
let check = evaluate(Supervisor::Systemd, &exps, &discovered);
match &check.status {
CheckStatus::Fail(reason) => assert!(
reason.contains("tamanu-facility-tasks"),
"reason was {reason:?}"
),
other => panic!("expected fail, got {other:?}"),
}
}
#[test]
fn fails_on_api_shortfall() {
let cfg = cfg(false);
let exps = expected(
Supervisor::Systemd,
ApiServerKind::Facility,
Some(&cfg),
Some(false),
false,
);
let discovered = vec![
d("tamanu-facility-tasks", None, true),
d("tamanu-frontend", Some("a"), true),
d("tamanu-frontend", Some("b"), true),
d("tamanu-facility-api", Some("1"), true),
d("tamanu-facility-sync", None, true),
];
let check = evaluate(Supervisor::Systemd, &exps, &discovered);
match &check.status {
CheckStatus::Fail(reason) => {
assert!(reason.contains("1/2"), "reason was {reason:?}");
}
other => panic!("{other:?}"),
}
}
#[test]
fn fails_on_frontend_named_missing() {
let cfg = cfg(false);
let exps = expected(
Supervisor::Systemd,
ApiServerKind::Facility,
Some(&cfg),
Some(false),
false,
);
let discovered = vec![
d("tamanu-facility-tasks", None, true),
d("tamanu-frontend", Some("a"), true),
d("tamanu-facility-api", Some("1"), true),
d("tamanu-facility-api", Some("2"), true),
d("tamanu-facility-sync", None, true),
];
let check = evaluate(Supervisor::Systemd, &exps, &discovered);
match &check.status {
CheckStatus::Fail(reason) => {
assert!(
reason.contains("tamanu-frontend@b"),
"reason was {reason:?}"
);
}
other => panic!("{other:?}"),
}
}
#[test]
fn fails_when_forbidden_facility_singleton_present() {
let cfg = cfg(false);
let exps = expected(
Supervisor::Systemd,
ApiServerKind::Facility,
Some(&cfg),
Some(false),
false,
);
let discovered = vec![
d("tamanu-facility-tasks", None, true),
d("tamanu-frontend", Some("a"), true),
d("tamanu-frontend", Some("b"), true),
d("tamanu-facility-api", Some("1"), true),
d("tamanu-facility-api", Some("2"), true),
d("tamanu-facility-sync", None, true),
d("tamanu-facility", None, true),
];
let check = evaluate(Supervisor::Systemd, &exps, &discovered);
match &check.status {
CheckStatus::Fail(reason) => {
assert!(reason.contains("expected down"), "reason was {reason:?}");
assert!(reason.contains("tamanu-facility"), "reason was {reason:?}");
assert!(
reason.contains("legacy singleton unit must not be present"),
"reason was {reason:?}"
);
}
other => panic!("{other:?}"),
}
}
fn portal_down_exp() -> Expectation {
Expectation {
name: "tamanu-patientportal",
instances: Instances::Single,
state: ExpectedState::Down,
reason: "test".into(),
legacy: false,
behind_caddy: false,
}
}
#[test]
fn reconcile_drops_stopped_and_disabled_down_unit() {
let exps = vec![portal_down_exp()];
let mut discovered = vec![d("tamanu-patientportal", None, false)];
reconcile_down_with_enabled(&exps, &mut discovered, |_unit| false);
assert!(
discovered.is_empty(),
"stopped+disabled unit should be dropped: {discovered:?}",
);
}
#[test]
fn reconcile_keeps_stopped_but_enabled_down_unit() {
let exps = vec![portal_down_exp()];
let mut discovered = vec![d("tamanu-patientportal", None, false)];
reconcile_down_with_enabled(&exps, &mut discovered, |_unit| true);
assert_eq!(discovered.len(), 1);
}
#[test]
fn reconcile_keeps_running_down_unit_regardless_of_is_enabled() {
let exps = vec![portal_down_exp()];
let mut discovered = vec![d("tamanu-patientportal", None, true)];
reconcile_down_with_enabled(&exps, &mut discovered, |unit| {
panic!("is_enabled should not be called for running unit, got {unit}");
});
assert_eq!(discovered.len(), 1);
}
#[test]
fn reconcile_adds_enabled_but_not_loaded_down_unit() {
let exps = vec![portal_down_exp()];
let mut discovered: Vec<Discovered> = Vec::new();
reconcile_down_with_enabled(&exps, &mut discovered, |unit| {
unit == "tamanu-patientportal.service"
});
let portal = discovered
.iter()
.find(|d| d.name == "tamanu-patientportal")
.expect("portal should be synthesised");
assert!(!portal.running);
assert!(portal.raw.contains("enabled"));
}
#[test]
fn extras_recorded_but_dont_fail() {
let cfg = cfg(false);
let exps = expected(
Supervisor::Systemd,
ApiServerKind::Facility,
Some(&cfg),
Some(false),
false,
);
let mut discovered = vec![
d("tamanu-facility-tasks", None, true),
d("tamanu-frontend", Some("a"), true),
d("tamanu-frontend", Some("b"), true),
d("tamanu-facility-api", Some("1"), true),
d("tamanu-facility-api", Some("2"), true),
d("tamanu-facility-sync", None, true),
];
discovered.push(d("tamanu-patientportal", None, true));
let check = evaluate(Supervisor::Systemd, &exps, &discovered);
assert!(matches!(check.status, CheckStatus::Pass), "{check:?}");
let services = check
.payload_extras
.get("services")
.expect("services payload_extra");
let extras = services
.get("extras")
.and_then(Value::as_array)
.expect("extras array");
assert_eq!(extras.len(), 1);
assert_eq!(extras[0].as_str().unwrap(), "tamanu-patientportal.service");
}
#[test]
fn leftover_singleton_does_not_satisfy_instanced_portal() {
let cfg = central_cfg(false);
let exps = expected(
Supervisor::Systemd,
ApiServerKind::Central,
Some(&cfg),
Some(true),
true,
);
let discovered = vec![
d("tamanu-central-tasks", None, true),
d("tamanu-frontend", Some("a"), true),
d("tamanu-frontend", Some("b"), true),
d("tamanu-central-api", Some("1"), true),
d("tamanu-central-api", Some("2"), true),
d("tamanu-central-fhir-resolve", None, true),
d("tamanu-central-fhir-refresh", None, true),
d("tamanu-patientportal", None, true),
];
let check = evaluate(Supervisor::Systemd, &exps, &discovered);
match &check.status {
CheckStatus::Fail(reason) => assert!(
reason.contains("tamanu-patientportal"),
"reason was {reason:?}"
),
other => panic!("expected fail, got {other:?}"),
}
let extras = check
.payload_extras
.get("services")
.and_then(|s| s.get("extras"))
.and_then(Value::as_array)
.expect("extras array");
assert_eq!(extras.len(), 1);
assert_eq!(extras[0].as_str().unwrap(), "tamanu-patientportal.service");
}
#[test]
fn unknown_portal_expectation_does_not_fail_check() {
let cfg = central_cfg(true);
let exps = expected(
Supervisor::Systemd,
ApiServerKind::Central,
Some(&cfg),
None,
false,
);
let discovered = vec![
d("tamanu-central-tasks", None, true),
d("tamanu-frontend", Some("a"), true),
d("tamanu-frontend", Some("b"), true),
d("tamanu-central-api", Some("1"), true),
d("tamanu-central-api", Some("2"), true),
d("tamanu-central-fhir-resolve", None, true),
d("tamanu-central-fhir-refresh", None, true),
d("tamanu-patientportal", None, true),
];
let check = evaluate(Supervisor::Systemd, &exps, &discovered);
assert!(matches!(check.status, CheckStatus::Pass), "{check:?}");
}
#[test]
fn central_with_fhir_requires_workers() {
let cfg = central_cfg(true);
let exps = expected(
Supervisor::Systemd,
ApiServerKind::Central,
Some(&cfg),
Some(false),
false,
);
let discovered = vec![
d("tamanu-central-tasks", None, true),
d("tamanu-frontend", Some("a"), true),
d("tamanu-frontend", Some("b"), true),
d("tamanu-central-api", Some("1"), true),
d("tamanu-central-api", Some("2"), true),
];
let check = evaluate(Supervisor::Systemd, &exps, &discovered);
match &check.status {
CheckStatus::Fail(reason) => {
assert!(
reason.contains("tamanu-central-fhir-resolve"),
"reason was {reason:?}"
);
assert!(
reason.contains("tamanu-central-fhir-refresh"),
"reason was {reason:?}"
);
}
other => panic!("{other:?}"),
}
}
#[test]
fn central_without_fhir_doesnt_require_workers() {
let cfg = central_cfg(false);
let exps = expected(
Supervisor::Systemd,
ApiServerKind::Central,
Some(&cfg),
Some(false),
false,
);
let discovered = vec![
d("tamanu-central-tasks", None, true),
d("tamanu-frontend", Some("a"), true),
d("tamanu-frontend", Some("b"), true),
d("tamanu-central-api", Some("1"), true),
d("tamanu-central-api", Some("2"), true),
];
let check = evaluate(Supervisor::Systemd, &exps, &discovered);
assert!(matches!(check.status, CheckStatus::Pass), "{check:?}");
}
#[test]
fn pm2_facility_happy() {
let cfg = cfg(false);
let exps = expected(
Supervisor::Pm2,
ApiServerKind::Facility,
Some(&cfg),
Some(false),
false,
);
let discovered = vec![
Discovered {
name: "tamanu-tasks".into(),
instance: None,
running: true,
present: true,
raw: "tamanu-tasks#0".into(),
},
Discovered {
name: "tamanu-api".into(),
instance: None,
running: true,
present: true,
raw: "tamanu-api#1".into(),
},
Discovered {
name: "tamanu-api".into(),
instance: None,
running: true,
present: true,
raw: "tamanu-api#2".into(),
},
Discovered {
name: "tamanu-sync".into(),
instance: None,
running: true,
present: true,
raw: "tamanu-sync#3".into(),
},
];
let check = evaluate(Supervisor::Pm2, &exps, &discovered);
assert!(matches!(check.status, CheckStatus::Pass), "{check:?}");
}
#[test]
fn pm2_dump_fallback_with_all_not_running_yields_warning() {
let discovered = vec![
Discovered {
name: "tamanu-api".into(),
instance: None,
running: false,
present: true,
raw: "tamanu-api".into(),
},
Discovered {
name: "tamanu-tasks".into(),
instance: None,
running: false,
present: true,
raw: "tamanu-tasks".into(),
},
];
let check =
pm2_dump_fallback_indeterminate(Some(pm2::Source::Dump), &discovered).expect("warn");
assert!(matches!(check.status, CheckStatus::Warning(_)));
}
#[test]
fn pm2_dump_fallback_with_any_running_does_not_skip() {
let discovered = vec![
Discovered {
name: "tamanu-api".into(),
instance: None,
running: true,
present: true,
raw: "tamanu-api".into(),
},
Discovered {
name: "tamanu-tasks".into(),
instance: None,
running: false,
present: true,
raw: "tamanu-tasks".into(),
},
];
assert!(pm2_dump_fallback_indeterminate(Some(pm2::Source::Dump), &discovered).is_none());
}
#[test]
fn pm2_cli_source_skips_dump_fallback_heuristic() {
let discovered = vec![Discovered {
name: "tamanu-api".into(),
instance: None,
running: false,
present: true,
raw: "tamanu-api".into(),
}];
assert!(pm2_dump_fallback_indeterminate(Some(pm2::Source::Cli), &discovered).is_none());
}
#[test]
fn not_running_listed_as_diagnosis() {
let cfg = cfg(false);
let exps = expected(
Supervisor::Systemd,
ApiServerKind::Facility,
Some(&cfg),
Some(false),
false,
);
let discovered = vec![
d("tamanu-facility-tasks", None, false), d("tamanu-frontend", Some("a"), true),
d("tamanu-frontend", Some("b"), true),
d("tamanu-facility-api", Some("1"), true),
d("tamanu-facility-api", Some("2"), true),
d("tamanu-facility-sync", None, true),
];
let check = evaluate(Supervisor::Systemd, &exps, &discovered);
match &check.status {
CheckStatus::Fail(reason) => {
assert!(reason.contains("not running"), "reason was {reason:?}");
assert!(
reason.contains("tamanu-facility-tasks"),
"reason was {reason:?}"
);
}
other => panic!("{other:?}"),
}
}
#[test]
fn diagnostics_carry_per_service_reason_and_state() {
let cfg = central_cfg(true);
let exps = expected(
Supervisor::Systemd,
ApiServerKind::Central,
Some(&cfg),
Some(false),
false,
);
let discovered = vec![
d("tamanu-central-tasks", None, true),
d("tamanu-frontend", Some("a"), true),
d("tamanu-frontend", Some("b"), true),
d("tamanu-central-api", Some("1"), true),
d("tamanu-central-api", Some("2"), true),
d("tamanu-central-fhir-resolve", None, true),
d("tamanu-central-fhir-refresh", None, true),
d("tamanu-patientportal", None, true),
];
let check = evaluate(Supervisor::Systemd, &exps, &discovered);
let diagnostics = check
.details
.get("diagnostics")
.and_then(Value::as_array)
.expect("diagnostics array");
assert_eq!(diagnostics.len(), 1);
let portal = &diagnostics[0];
assert_eq!(
portal.get("name").and_then(Value::as_str),
Some("tamanu-patientportal")
);
assert_eq!(portal.get("expected").and_then(Value::as_str), Some("down"));
assert_eq!(portal.get("actual").and_then(Value::as_str), Some("up"));
assert_eq!(
portal.get("reason").and_then(Value::as_str),
Some("DB setting features.patientPortal is false")
);
assert_eq!(
portal.get("detail").and_then(Value::as_str),
Some("tamanu-patientportal.service")
);
}
#[test]
fn diagnostics_empty_when_everything_matches() {
let cfg = cfg(false);
let exps = expected(
Supervisor::Systemd,
ApiServerKind::Facility,
Some(&cfg),
Some(false),
false,
);
let discovered = vec![
d("tamanu-facility-tasks", None, true),
d("tamanu-frontend", Some("a"), true),
d("tamanu-frontend", Some("b"), true),
d("tamanu-facility-api", Some("1"), true),
d("tamanu-facility-api", Some("2"), true),
d("tamanu-facility-sync", None, true),
];
let check = evaluate(Supervisor::Systemd, &exps, &discovered);
assert!(matches!(check.status, CheckStatus::Pass));
let diagnostics = check
.details
.get("diagnostics")
.and_then(Value::as_array)
.expect("diagnostics array");
assert!(diagnostics.is_empty(), "{diagnostics:?}");
assert!(check.payload_extras.get("services").is_some());
}
#[test]
fn raw_data_lives_in_payload_extras_not_check_details() {
let cfg = cfg(false);
let exps = expected(
Supervisor::Systemd,
ApiServerKind::Facility,
Some(&cfg),
Some(false),
false,
);
let discovered = vec![
d("tamanu-facility-tasks", None, true),
d("tamanu-frontend", Some("a"), true),
d("tamanu-frontend", Some("b"), true),
d("tamanu-facility-api", Some("1"), true),
d("tamanu-facility-api", Some("2"), true),
d("tamanu-facility-sync", None, true),
];
let check = evaluate(Supervisor::Systemd, &exps, &discovered);
assert!(!check.details.contains_key("expectations"));
assert!(!check.details.contains_key("extras"));
assert!(!check.details.contains_key("services"));
let services = check
.payload_extras
.get("services")
.expect("services payload extra");
assert_eq!(
services.get("supervisor").and_then(Value::as_str),
Some("systemd")
);
let raw_exps = services
.get("expectations")
.and_then(Value::as_array)
.expect("raw expectations");
assert!(!raw_exps.is_empty());
assert!(
raw_exps
.iter()
.all(|e| e.get("reason").and_then(Value::as_str).is_some())
);
assert!(services.get("discovered").is_some());
assert!(services.get("extras").is_some());
}
}