use std::time::Duration;
use clap::Parser;
use jiff::SignedDuration;
use miette::{IntoDiagnostic, Result, bail};
use reqwest::{Client, Url};
use tracing::{debug, info, warn};
use bestool_tamanu::services::{self, ExpectedState, Expectation, Supervisor};
use crate::actions::{
Context,
tamanu::{
TamanuArgs,
lifecycle::{self, Instance, WaitForDb},
},
};
#[derive(Debug, Clone, Parser)]
#[clap(verbatim_doc_comment)]
pub struct RestartArgs {
pub names: Vec<String>,
#[arg(long, default_value = "30s", value_parser = parse_duration)]
pub cooldown: Duration,
#[arg(long)]
pub no_probe_http: bool,
#[arg(long, value_name = "URL")]
pub check_url: Option<Url>,
}
fn parse_duration(s: &str) -> Result<Duration, String> {
s.parse::<SignedDuration>()
.map_err(|e| e.to_string())
.and_then(|d| Duration::try_from(d).map_err(|e| e.to_string()))
}
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)?;
lifecycle::warn_unknown_expectations(&matched);
let discovered = lifecycle::discover(supervisor)?;
let groups = lifecycle::group_by_expectation(&matched, &discovered);
lifecycle::ensure_root_or_reexec(supervisor)?;
let Partitioned {
bulk,
bulk_behind_caddy,
rolling,
} = partition(supervisor, &groups);
let client = http_client()?;
if !bulk.is_empty() {
info!(targets = ?bulk, "bulk-restarting non-rolling services");
bulk_restart(supervisor, &bulk)?;
lifecycle::wait_running(supervisor, &bulk)?;
if bulk_behind_caddy {
lifecycle::reload_caddy().await;
}
} else {
debug!("no bulk-restart services");
}
for (i, (instance, behind_caddy)) in rolling.iter().enumerate() {
info!(
"rolling restart {}/{}: {}",
i + 1,
rolling.len(),
instance.display(),
);
lifecycle::restart_one(supervisor, instance)?;
lifecycle::wait_running_one(supervisor, instance, Duration::from_secs(60))?;
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 let Some(url) = &args.check_url {
info!(%url, "final end-to-end probe");
probe_url(&client, url, Duration::from_secs(60)).await?;
}
Ok(())
}
struct Partitioned {
bulk: Vec<String>,
bulk_behind_caddy: bool,
rolling: Vec<(Instance, bool)>,
}
fn partition(supervisor: Supervisor, groups: &[(&Expectation, Vec<Instance>)]) -> Partitioned {
let mut bulk = Vec::new();
let mut bulk_behind_caddy = false;
let mut rolling = Vec::new();
for (exp, instances) in groups {
if exp.state != ExpectedState::Up {
continue;
}
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 {
bulk_behind_caddy = true;
}
}
}
}
Partitioned {
bulk,
bulk_behind_caddy,
rolling,
}
}
fn bulk_restart(supervisor: Supervisor, targets: &[String]) -> Result<()> {
match supervisor {
Supervisor::Systemd => {
let status = std::process::Command::new("systemctl")
.arg("restart")
.args(targets)
.status()
.into_diagnostic()?;
if !status.success() {
bail!("systemctl restart failed: {status}");
}
Ok(())
}
Supervisor::Pm2 => lifecycle::pm2_restart_targets(targets),
}
}
fn http_client() -> Result<Client> {
Client::builder()
.timeout(Duration::from_secs(5))
.build()
.into_diagnostic()
}
async fn probe_instance(
supervisor: Supervisor,
instance: &Instance,
client: &Client,
) -> Result<bool> {
let url = match supervisor {
Supervisor::Systemd => {
let unit = instance.unit();
match lifecycle::container_ip_for_unit(&unit)? {
Some(ip) => format!("http://{ip}:3000/").parse().into_diagnostic()?,
None => {
warn!(unit, "no container IP discovered, skipping HTTP probe");
return Ok(false);
}
}
}
Supervisor::Pm2 => {
let Some(pm_id) = instance.pm_id else {
warn!(name = %instance.name, "pm2 instance has no pm_id, skipping HTTP probe");
return Ok(false);
};
match lifecycle::pm2_port_for(pm_id)? {
Some(port) => format!("http://127.0.0.1:{port}/").parse().into_diagnostic()?,
None => {
info!(name = %instance.name, pm_id, "no PORT in pm2 env, skipping HTTP probe");
return Ok(false);
}
}
}
};
probe_until_ready(client, &url).await;
Ok(true)
}
async fn probe_until_ready(client: &Client, url: &Url) {
loop {
match probe_once(client, url).await {
Ok(()) => {
debug!(%url, "probe OK");
return;
}
Err(err) => {
debug!(%url, err = %err, "probe not ready, retrying");
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
}
}
async fn probe_url(client: &Client, url: &Url, timeout: Duration) -> Result<()> {
let deadline = std::time::Instant::now() + timeout;
loop {
match probe_once(client, url).await {
Ok(()) => {
debug!(%url, "probe OK");
return Ok(());
}
Err(last_err) => {
if std::time::Instant::now() >= deadline {
bail!("HTTP probe of {url} failed: {last_err}");
}
debug!(%url, err = %last_err, "probe not ready, retrying");
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
}
}
async fn probe_once(client: &Client, url: &Url) -> std::result::Result<(), String> {
match client.get(url.clone()).send().await {
Ok(resp) if !resp.status().is_server_error() => Ok(()),
Ok(resp) => Err(format!("HTTP {}", resp.status())),
Err(e) => Err(e.to_string()),
}
}
#[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.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.bulk_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.bulk_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.bulk_behind_caddy);
}
}