use std::{
net::SocketAddr,
path::{Path, PathBuf},
sync::Arc,
};
use clap::{Parser, Subcommand};
use miette::Result;
use tracing::{debug, info};
use bestool_tamanu::{
config::{TamanuConfig, load_config},
connection_url::ConnectionUrlBuilder,
server_info::fetch_device_key,
};
use super::{TamanuArgs, find_tamanu};
use crate::actions::Context;
mod doctor_task;
use doctor_task::DoctorTask;
#[derive(Debug, Clone, Parser)]
#[clap(verbatim_doc_comment)]
pub struct AlertdArgs {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Clone, Parser)]
struct DaemonArgs {
#[arg(long)]
glob: Vec<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
no_server: bool,
#[arg(long)]
server_addr: Vec<SocketAddr>,
#[arg(long, default_value = "600")]
watchdog_timeout: u64,
#[arg(long)]
no_watchdog: bool,
#[arg(long)]
no_healthchecks: bool,
}
#[derive(Debug, Clone, Subcommand)]
enum Command {
Run {
#[command(flatten)]
daemon: DaemonArgs,
},
Status {
#[arg(long)]
server_addr: Vec<SocketAddr>,
},
Reload {
#[arg(long)]
server_addr: Vec<SocketAddr>,
},
LoadedAlerts {
#[arg(long)]
server_addr: Vec<SocketAddr>,
#[arg(long)]
detail: bool,
},
PauseAlert {
alert: String,
#[arg(long)]
until: Option<String>,
#[arg(long)]
server_addr: Vec<SocketAddr>,
},
Validate {
file: PathBuf,
#[arg(long)]
server_addr: Vec<SocketAddr>,
},
#[cfg(windows)]
Install,
#[cfg(windows)]
Uninstall,
#[cfg(windows)]
ConfigureRecovery,
#[cfg(windows)]
#[command(hide = true)]
Service {
#[command(flatten)]
daemon: DaemonArgs,
},
}
pub async fn run(args: AlertdArgs, ctx: Context) -> Result<()> {
match args.command {
Command::Status { server_addr } => {
let addrs = resolve_addrs(server_addr);
bestool_alertd::commands::get_status(&addrs).await
}
Command::Validate { file, server_addr } => {
let addrs = resolve_addrs(server_addr);
bestool_alertd::commands::validate_alert(&file, &addrs).await
}
Command::Reload { server_addr } => {
let addrs = resolve_addrs(server_addr);
bestool_alertd::commands::send_reload(&addrs).await
}
Command::LoadedAlerts {
server_addr,
detail,
} => {
let addrs = resolve_addrs(server_addr);
bestool_alertd::commands::get_loaded_alerts(&addrs, detail).await
}
Command::PauseAlert {
alert,
until,
server_addr,
} => {
let addrs = resolve_addrs(server_addr);
bestool_alertd::commands::pause_alert(&alert, until.as_deref(), &addrs).await
}
Command::Run { daemon } => {
let (version, root) = find_tamanu(ctx.require::<TamanuArgs>())?;
let config = load_config(&root, None)?;
debug!(?config, "parsed Tamanu config");
let daemon_config = build_config(&root, &version, config, daemon).await?;
bestool_alertd::run(daemon_config).await
}
#[cfg(windows)]
Command::Install => {
use std::ffi::OsString;
bestool_alertd::windows_service::install_service_with_args(&[
OsString::from("tamanu"),
OsString::from("alertd"),
OsString::from("service"),
])
}
#[cfg(windows)]
Command::Uninstall => bestool_alertd::windows_service::uninstall_service(),
#[cfg(windows)]
Command::ConfigureRecovery => bestool_alertd::windows_service::configure_recovery(),
#[cfg(windows)]
Command::Service { daemon } => {
let (version, root) = find_tamanu(ctx.require::<TamanuArgs>())?;
let config = load_config(&root, None)?;
debug!(?config, "parsed Tamanu config");
match bestool_alertd::windows_service::is_recovery_configured() {
Ok(false) => {
info!("failure recovery not configured, applying automatically");
if let Err(e) = bestool_alertd::windows_service::configure_recovery() {
tracing::warn!("failed to auto-configure recovery: {e}");
}
}
Err(e) => {
tracing::warn!("failed to check recovery configuration: {e}");
}
Ok(true) => {}
}
let daemon_config = build_config(&root, &version, config, daemon).await?;
bestool_alertd::windows_service::run_service(daemon_config)
}
}
}
fn resolve_addrs(server_addr: Vec<SocketAddr>) -> Vec<SocketAddr> {
if server_addr.is_empty() {
bestool_alertd::commands::default_server_addrs()
} else {
server_addr
}
}
async fn build_config(
root: &Path,
tamanu_version: &node_semver::Version,
config: TamanuConfig,
DaemonArgs {
glob,
dry_run,
no_server,
server_addr,
watchdog_timeout,
no_watchdog,
no_healthchecks,
}: DaemonArgs,
) -> Result<bestool_alertd::DaemonConfig> {
let dirs = if glob.is_empty() {
default_dirs(root).await
} else {
glob
};
debug!(?dirs, "alert directories");
if dirs.is_empty() {
return Err(miette::miette!("no alert directories found or specified"));
}
info!("starting alertd daemon");
let database_url = ConnectionUrlBuilder {
username: config.db.username.clone(),
password: Some(config.db.password.clone()),
host: config
.db
.host
.clone()
.unwrap_or_else(|| "localhost".to_string()),
port: config.db.port,
database: config.db.name.clone(),
ssl_mode: None,
}
.build();
let email = config
.mailgun
.as_ref()
.map(|mg| bestool_alertd::EmailConfig {
from: mg.sender.clone(),
mailgun_api_key: mg.api_key.clone(),
mailgun_domain: mg.domain.clone(),
});
let watchdog = if no_watchdog {
None
} else {
Some(std::time::Duration::from_secs(watchdog_timeout))
};
let device_key_pem = fetch_device_key(&database_url).await;
let config = Arc::new(config);
let mut daemon_config =
bestool_alertd::DaemonConfig::new(dirs, database_url.clone(), tamanu_version.to_string())
.with_dry_run(dry_run)
.with_no_server(no_server)
.with_server_addrs(server_addr)
.with_watchdog_timeout(watchdog)
.with_server_kind(detect_server_kind(&config, &database_url).await);
if !no_healthchecks {
daemon_config = daemon_config.with_task(Arc::new(DoctorTask::new(
tamanu_version.clone(),
root.to_path_buf(),
config.clone(),
database_url,
)));
}
if let Some(email) = email {
daemon_config = daemon_config.with_email(email);
}
if let Some(pem) = device_key_pem {
daemon_config = daemon_config.with_device_key_pem(pem);
}
Ok(daemon_config)
}
async fn detect_server_kind(
config: &bestool_tamanu::config::TamanuConfig,
database_url: &str,
) -> &'static str {
let db = match tokio_postgres::connect(database_url, tokio_postgres::NoTls).await {
Ok((client, conn)) => {
tokio::spawn(async move {
if let Err(err) = conn.await {
tracing::warn!("kind-detection connection error: {err}");
}
});
Some(client)
}
Err(err) => {
tracing::debug!(%err, "no DB for kind detection; falling back to config-only");
None
}
};
match bestool_tamanu::detect_kind(config, db.as_ref()).await {
bestool_tamanu::ApiServerKind::Central => "central",
bestool_tamanu::ApiServerKind::Facility => "facility",
}
}
async fn default_dirs(root: &std::path::Path) -> Vec<String> {
use futures::future::join_all;
let mut dirs = vec![
PathBuf::from(r"C:\Tamanu\alerts"),
root.join("alerts"),
PathBuf::from("/opt/tamanu-toolbox/alerts"),
PathBuf::from("/etc/tamanu/alerts"),
PathBuf::from("/alerts"),
];
if let Ok(cwd) = std::env::current_dir() {
dirs.push(cwd.join("alerts"));
}
join_all(
dirs.into_iter()
.map(|dir| async { if dir.exists() { Some(dir) } else { None } }),
)
.await
.into_iter()
.flatten()
.map(|p| p.display().to_string())
.collect()
}