use std::{collections::HashSet, time::Duration};
use clap::Parser;
use miette::Result;
use reqwest::{Client, Url};
use tracing::{debug, info, warn};
use bestool_tamanu::{
services::{self, ExpectedState, Expectation, Supervisor, parse_systemd_unit},
systemd,
};
use crate::actions::{
Context,
tamanu::{
TamanuArgs,
lifecycle::{self, Instance, WaitForDb},
probe,
},
};
#[derive(Debug, Clone, Parser)]
#[clap(verbatim_doc_comment)]
pub struct RestartArgs {
pub names: Vec<String>,
#[arg(long)]
pub ignore_unmatched: bool,
#[arg(long, default_value = "30s", value_parser = probe::parse_duration)]
pub cooldown: Duration,
#[arg(long)]
pub no_probe_http: bool,
#[arg(long, value_name = "URL")]
pub check_url: Option<Url>,
}
pub async fn run(args: RestartArgs, ctx: Context) -> Result<()> {
let tamanu = ctx.require::<TamanuArgs>();
let (supervisor, expectations) =
lifecycle::config_and_expectations(tamanu, WaitForDb::No).await?;
let names: Vec<&str> = args.names.iter().map(String::as_str).collect();
let matched = services::match_names(&expectations, &names, args.ignore_unmatched)?;
lifecycle::warn_unknown_expectations(&matched);
let discovered = lifecycle::discover(supervisor).await?;
let groups = lifecycle::group_by_expectation(supervisor, &matched, &discovered);
let retire = lifecycle::stale_shape_groups(supervisor, &matched, &discovered);
lifecycle::ensure_root_or_reexec(supervisor)?;
let Partitioned {
start,
bulk,
batch_behind_caddy,
rolling,
} = partition(supervisor, &groups);
let client = probe::http_client()?;
if !start.is_empty() {
info!(targets = ?start, "starting missing services");
lifecycle::start_targets(supervisor, &start).await?;
lifecycle::wait_running(supervisor, &start).await?;
}
if !bulk.is_empty() {
info!(targets = ?bulk, "bulk-restarting non-rolling services");
bulk_restart(supervisor, &bulk).await?;
lifecycle::wait_running(supervisor, &bulk).await?;
} else {
debug!("no bulk-restart services");
}
if batch_behind_caddy {
lifecycle::reload_caddy().await;
}
for (i, (instance, behind_caddy)) in rolling.iter().enumerate() {
info!(
"rolling restart {}/{}: {}",
i + 1,
rolling.len(),
instance.display(),
);
lifecycle::restart_one(supervisor, instance).await?;
lifecycle::wait_running_one(supervisor, instance, Duration::from_secs(60)).await?;
let probed_ready = if !args.no_probe_http {
probe_instance(supervisor, instance, &client).await?
} else {
false
};
if *behind_caddy {
lifecycle::reload_caddy().await;
}
if i + 1 < rolling.len() && !probed_ready {
debug!(seconds = args.cooldown.as_secs(), "cooldown");
tokio::time::sleep(args.cooldown).await;
}
}
if !retire.is_empty() {
for (exp, instances) in &retire {
let stale: Vec<String> = instances.iter().map(Instance::display).collect();
info!(
service = exp.name,
?stale,
"retiring leftover units after migration to instanced layout"
);
if !args.no_probe_http {
wait_replacements_ready(supervisor, exp, &client).await?;
} else {
debug!(seconds = args.cooldown.as_secs(), "cooldown before retiring");
tokio::time::sleep(args.cooldown).await;
}
}
let units: Vec<String> = retire
.iter()
.flat_map(|(_, instances)| instances.iter().map(Instance::unit))
.collect();
lifecycle::retire_systemd_units(&units).await;
}
if let Some(url) = &args.check_url {
info!(%url, "final end-to-end probe");
probe::probe_url(&client, url, Duration::from_secs(60)).await?;
}
Ok(())
}
async fn wait_replacements_ready(
supervisor: Supervisor,
exp: &Expectation,
client: &Client,
) -> Result<()> {
for unit in exp.instances.required_systemd_units(exp.name) {
let Some((base, instance)) = parse_systemd_unit(&unit) else {
continue;
};
let inst = Instance {
name: base.to_string(),
instance: instance.map(str::to_string),
pm_id: None,
running: true,
};
if let Some(url) = probe::instance_probe_url(supervisor, &inst)? {
probe::probe_until_ready(client, &url).await;
}
}
Ok(())
}
struct Partitioned {
start: Vec<String>,
bulk: Vec<String>,
batch_behind_caddy: bool,
rolling: Vec<(Instance, bool)>,
}
fn partition(supervisor: Supervisor, groups: &[(&Expectation, Vec<Instance>)]) -> Partitioned {
let mut start = Vec::new();
let mut bulk = Vec::new();
let mut batch_behind_caddy = false;
let mut rolling = Vec::new();
for (exp, instances) in groups {
if exp.state != ExpectedState::Up {
continue;
}
let start_before = start.len();
match supervisor {
Supervisor::Systemd => {
let running: HashSet<String> = instances
.iter()
.filter(|i| i.running)
.map(Instance::unit)
.collect();
for unit in exp.instances.required_systemd_units(exp.name) {
if !running.contains(&unit) {
start.push(unit);
}
}
}
Supervisor::Pm2 => {
let registered = instances.len();
let needed = exp.instances.min_count();
if registered < needed {
warn!(
"`{}` needs at least {needed} pm2 process(es) but only {registered} are \
registered; restart can't add new entries — that's the ops setup \
playbook's job",
exp.name,
);
}
for inst in instances {
if !inst.running {
start.push(inst.name.clone());
}
}
}
}
if start.len() > start_before && exp.behind_caddy {
batch_behind_caddy = true;
}
for inst in instances {
if !inst.running {
continue;
}
if exp.rolling_restart() {
rolling.push((inst.clone(), exp.behind_caddy));
} else {
bulk.push(match supervisor {
Supervisor::Systemd => inst.unit(),
Supervisor::Pm2 => inst.name.clone(),
});
if exp.behind_caddy {
batch_behind_caddy = true;
}
}
}
}
Partitioned {
start,
bulk,
batch_behind_caddy,
rolling,
}
}
async fn bulk_restart(supervisor: Supervisor, targets: &[String]) -> Result<()> {
match supervisor {
Supervisor::Systemd => systemd::restart_all(targets).await,
Supervisor::Pm2 => lifecycle::pm2_restart_targets(targets),
}
}
async fn probe_instance(
supervisor: Supervisor,
instance: &Instance,
client: &Client,
) -> Result<bool> {
let Some(url) = probe::instance_probe_url(supervisor, instance)? else {
return Ok(false);
};
probe::probe_until_ready(client, &url).await;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use bestool_tamanu::services::Instances;
fn up_exp(name: &'static str, instances: Instances, behind_caddy: bool) -> Expectation {
Expectation {
name,
instances,
state: ExpectedState::Up,
reason: "test".into(),
legacy: false,
behind_caddy,
}
}
fn inst(name: &str, instance: Option<&str>, running: bool) -> Instance {
Instance {
name: name.into(),
instance: instance.map(Into::into),
pm_id: None,
running,
}
}
#[test]
fn partition_rolls_patient_portal_a_b() {
let portal = up_exp("tamanu-patientportal", Instances::Named(&["a", "b"]), true);
let groups: Vec<(&Expectation, Vec<Instance>)> = vec![(
&portal,
vec![
inst("tamanu-patientportal", Some("a"), true),
inst("tamanu-patientportal", Some("b"), true),
],
)];
let p = partition(Supervisor::Systemd, &groups);
assert!(p.start.is_empty());
assert!(p.bulk.is_empty());
assert_eq!(p.rolling.len(), 2);
assert!(p.rolling.iter().all(|(_, behind_caddy)| *behind_caddy));
}
#[test]
fn partition_flags_bulk_behind_caddy_when_singleton_portal_runs() {
let portal = up_exp("tamanu-patientportal", Instances::Single, true);
let tasks = up_exp("tamanu-central-tasks", Instances::Single, false);
let groups: Vec<(&Expectation, Vec<Instance>)> = vec![
(&portal, vec![inst("tamanu-patientportal", None, true)]),
(&tasks, vec![inst("tamanu-central-tasks", None, true)]),
];
let p = partition(Supervisor::Systemd, &groups);
assert!(p.batch_behind_caddy);
assert_eq!(p.bulk.len(), 2);
assert!(p.rolling.is_empty());
}
#[test]
fn partition_no_bulk_behind_caddy_when_no_caddy_service_in_bulk() {
let tasks = up_exp("tamanu-central-tasks", Instances::Single, false);
let sync = up_exp("tamanu-facility-sync", Instances::Single, false);
let groups: Vec<(&Expectation, Vec<Instance>)> = vec![
(&tasks, vec![inst("tamanu-central-tasks", None, true)]),
(&sync, vec![inst("tamanu-facility-sync", None, true)]),
];
let p = partition(Supervisor::Systemd, &groups);
assert!(!p.batch_behind_caddy);
}
#[test]
fn partition_carries_behind_caddy_per_rolling_instance() {
let api = up_exp("tamanu-central-api", Instances::NumericAtLeast(2), true);
let groups: Vec<(&Expectation, Vec<Instance>)> = vec![(
&api,
vec![
inst("tamanu-central-api", Some("1"), true),
inst("tamanu-central-api", Some("2"), true),
],
)];
let p = partition(Supervisor::Systemd, &groups);
assert_eq!(p.rolling.len(), 2);
assert!(p.rolling.iter().all(|(_, behind_caddy)| *behind_caddy));
assert!(p.bulk.is_empty());
}
#[test]
fn partition_singleton_portal_is_bulk_not_rolling() {
let portal = up_exp("tamanu-patientportal", Instances::Single, true);
let groups: Vec<(&Expectation, Vec<Instance>)> =
vec![(&portal, vec![inst("tamanu-patientportal", None, true)])];
let p = partition(Supervisor::Systemd, &groups);
assert!(p.rolling.is_empty(), "singleton must not roll");
assert_eq!(p.bulk.len(), 1);
assert!(p.batch_behind_caddy);
}
#[test]
fn partition_starts_unit_missing_from_discovery() {
let tasks = up_exp("tamanu-central-tasks", Instances::Single, false);
let groups: Vec<(&Expectation, Vec<Instance>)> = vec![(&tasks, vec![])];
let p = partition(Supervisor::Systemd, &groups);
assert_eq!(p.start, vec!["tamanu-central-tasks.service"]);
assert!(p.bulk.is_empty());
assert!(p.rolling.is_empty());
assert!(!p.batch_behind_caddy);
}
#[test]
fn partition_starts_stopped_discovered_instance() {
let tasks = up_exp("tamanu-central-tasks", Instances::Single, false);
let groups: Vec<(&Expectation, Vec<Instance>)> =
vec![(&tasks, vec![inst("tamanu-central-tasks", None, false)])];
let p = partition(Supervisor::Systemd, &groups);
assert_eq!(p.start, vec!["tamanu-central-tasks.service"]);
assert!(p.bulk.is_empty());
}
#[test]
fn partition_starts_missing_instance_and_rolls_running_one() {
let api = up_exp("tamanu-central-api", Instances::NumericAtLeast(2), true);
let groups: Vec<(&Expectation, Vec<Instance>)> =
vec![(&api, vec![inst("tamanu-central-api", Some("1"), true)])];
let p = partition(Supervisor::Systemd, &groups);
assert_eq!(p.start, vec!["tamanu-central-api@2.service"]);
assert_eq!(p.rolling.len(), 1);
assert!(p.batch_behind_caddy, "started behind-caddy unit needs a reload");
}
#[test]
fn partition_does_not_start_down_or_unknown() {
let down = Expectation {
name: "tamanu-patientportal",
instances: Instances::Single,
state: ExpectedState::Down,
reason: "test".into(),
legacy: false,
behind_caddy: true,
};
let unknown = Expectation {
name: "tamanu-fhir-worker",
instances: Instances::Single,
state: ExpectedState::Unknown,
reason: "test".into(),
legacy: false,
behind_caddy: false,
};
let groups: Vec<(&Expectation, Vec<Instance>)> =
vec![(&down, vec![]), (&unknown, vec![])];
let p = partition(Supervisor::Systemd, &groups);
assert!(p.start.is_empty());
assert!(!p.batch_behind_caddy);
}
#[test]
fn partition_pm2_starts_stopped_registered_process() {
let tasks = up_exp("tamanu-tasks", Instances::Single, false);
let groups: Vec<(&Expectation, Vec<Instance>)> = vec![(
&tasks,
vec![Instance {
name: "tamanu-tasks".into(),
instance: None,
pm_id: Some(2),
running: false,
}],
)];
let p = partition(Supervisor::Pm2, &groups);
assert_eq!(p.start, vec!["tamanu-tasks"]);
assert!(p.bulk.is_empty());
}
#[test]
fn partition_pm2_under_registration_still_restarts_running() {
let api = up_exp("tamanu-api", Instances::NumericAtLeast(2), true);
let groups: Vec<(&Expectation, Vec<Instance>)> = vec![(
&api,
vec![Instance {
name: "tamanu-api".into(),
instance: None,
pm_id: Some(0),
running: true,
}],
)];
let p = partition(Supervisor::Pm2, &groups);
assert!(p.start.is_empty());
assert_eq!(p.rolling.len(), 1);
}
}