use std::{net::SocketAddr, sync::Arc};
use clap::{Parser, Subcommand};
use miette::Result;
use tracing::warn;
use bestool_alertd::doctor::DoctorTask;
use crate::actions::Context;
#[derive(Debug, Clone, Parser)]
#[clap(verbatim_doc_comment)]
pub struct AlertdArgs {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Clone, Parser)]
struct DaemonArgs {
#[arg(long, value_name = "GLOB")]
glob: Vec<String>,
#[arg(long)]
no_server: bool,
#[arg(long)]
server_addr: Vec<SocketAddr>,
#[arg(long, default_value = "600")]
watchdog_timeout: u64,
#[arg(long)]
no_watchdog: 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>,
},
Restart {
#[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 = if server_addr.is_empty() {
bestool_alertd::commands::default_server_addrs()
} else {
server_addr
};
bestool_alertd::commands::get_status(&addrs).await
}
Command::Reload { server_addr } => {
let addrs = if server_addr.is_empty() {
bestool_alertd::commands::default_server_addrs()
} else {
server_addr
};
bestool_alertd::commands::reload(&addrs).await
}
Command::Restart { server_addr } => {
let addrs = if server_addr.is_empty() {
bestool_alertd::commands::default_server_addrs()
} else {
server_addr
};
bestool_alertd::commands::restart(&addrs).await
}
Command::Run { daemon } => {
let daemon_config = build_config(&ctx, 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("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 } => {
match bestool_alertd::windows_service::is_recovery_configured() {
Ok(false) => {
tracing::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(&ctx, daemon).await?;
bestool_alertd::windows_service::run_service(daemon_config)
}
}
}
fn doctor_task(
version: String,
tamanu: Option<bestool_alertd::doctor::SweepTamanu>,
) -> DoctorTask {
DoctorTask::new(version, tamanu)
}
fn with_daemon_tasks(
config: bestool_alertd::DaemonConfig,
doctor: DoctorTask,
) -> bestool_alertd::DaemonConfig {
#[cfg(feature = "canopy-backup")]
let (config, doctor) = {
let registry = bestool_alertd::BackupRegistry::new(backup_runner());
let doctor = doctor.with_backup_dispatch(backup_dispatch(registry.clone()));
let config = config
.with_backups(registry.clone())
.with_task(Arc::new(bestool_alertd::BackupTask::new(registry.clone())))
.with_task(Arc::new(backup::BackupCapabilitiesTask::new(registry)));
(config, doctor)
};
#[cfg(all(windows, feature = "self-update"))]
let config = config.with_task(Arc::new(
crate::actions::self_update::task::SelfUpdateTask::new(),
));
config.with_task(Arc::new(doctor))
}
#[cfg(feature = "canopy-backup")]
fn backup_dispatch(
registry: Arc<bestool_alertd::BackupRegistry>,
) -> bestool_alertd::doctor::BackupDispatch {
use futures::StreamExt as _;
Arc::new(move |types: Vec<String>| {
for backup_type in types {
let registry = registry.clone();
tokio::spawn(async move {
let mut stream = registry.ensure_run(backup_type).await;
while stream.next().await.is_some() {}
});
}
})
}
#[cfg(feature = "canopy-backup")]
fn backup_runner() -> bestool_alertd::BackupRunner {
use serde_json::{Value, json};
use tokio::sync::mpsc;
use crate::actions::canopy::backup::{BackupEvent, run_backup};
fn to_json(event: BackupEvent) -> Value {
match event {
BackupEvent::Started { run_id } => json!({"event": "started", "runId": run_id}),
BackupEvent::Phase(phase) => json!({"event": "phase", "phase": phase}),
BackupEvent::Progress(status) => json!({"event": "progress", "status": status}),
BackupEvent::Done {
snapshot_id,
bytes_uploaded,
} => json!({
"event": "done", "success": true,
"snapshotId": snapshot_id, "uploadedBytes": bytes_uploaded,
}),
BackupEvent::Failed { error } => {
json!({"event": "error", "success": false, "message": error})
}
}
}
Arc::new(
move |backup_type: String,
out: mpsc::UnboundedSender<Value>|
-> futures::future::BoxFuture<'static, ()> {
Box::pin(async move {
let (tx, mut rx) = mpsc::unbounded_channel::<BackupEvent>();
let run =
tokio::spawn(async move { run_backup(&backup_type, None, None, Some(tx)).await });
let mut terminal = false;
while let Some(event) = rx.recv().await {
terminal |= matches!(event, BackupEvent::Done { .. } | BackupEvent::Failed { .. });
let _ = out.send(to_json(event));
}
if !terminal {
let event = match run.await {
Ok(Ok(())) => json!({"event": "done", "success": true}),
Ok(Err(err)) => {
json!({"event": "error", "success": false, "message": format!("{err}")})
}
Err(join) => json!({
"event": "error", "success": false,
"message": format!("backup task aborted: {join}"),
}),
};
let _ = out.send(event);
}
})
},
)
}
#[cfg(feature = "canopy-backup")]
mod backup {
use std::{sync::Arc, time::Duration};
use futures::future::BoxFuture;
use miette::Result;
use tokio::sync::mpsc;
use tracing::{info, warn};
use crate::actions::canopy::backup::config;
const REREGISTER_INTERVAL: Duration = Duration::from_secs(3600);
pub(super) struct BackupCapabilitiesTask {
registry: Arc<bestool_alertd::BackupRegistry>,
}
impl BackupCapabilitiesTask {
pub(super) fn new(registry: Arc<bestool_alertd::BackupRegistry>) -> Self {
Self { registry }
}
}
async fn register(
registry: &bestool_alertd::BackupRegistry,
ctx: &bestool_alertd::TaskContext,
) -> Result<()> {
let defs = config::load_dir(&config::backups_dir()).await?;
let types: Vec<String> = defs.into_iter().map(|d| d.r#type).collect();
registry.set_configured(types.clone()).await;
let Some(client) = ctx.canopy_client.as_ref() else {
return Ok(());
};
if types.is_empty() {
return Ok(());
}
client
.backup_capabilities(
&bestool_canopy::schema::BackupCapabilitiesArgs::builder()
.types(types.clone())
.build(),
)
.await?;
info!(?types, "registered backup capabilities with canopy");
Ok(())
}
async fn reregister(
reason: &str,
registry: &bestool_alertd::BackupRegistry,
ctx: &bestool_alertd::TaskContext,
) {
info!(reason, "registering backup capabilities");
if let Err(err) = register(registry, ctx).await {
warn!("registering backup capabilities failed (will retry): {err}");
}
}
fn is_real_change(event: ¬ify::Event) -> bool {
use notify::{EventKind, event::ModifyKind};
!matches!(
event.kind,
EventKind::Access(_) | EventKind::Modify(ModifyKind::Metadata(_))
)
}
fn watch_backups_dir(tx: mpsc::UnboundedSender<()>) -> Option<notify::RecommendedWatcher> {
use notify::{RecursiveMode, Watcher as _};
let dir = config::backups_dir();
let mut watcher =
notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(event) = res
&& is_real_change(&event)
{
let _ = tx.send(());
}
})
.inspect_err(|err| warn!("could not create backups-dir watcher: {err}"))
.ok()?;
match watcher.watch(&dir, RecursiveMode::NonRecursive) {
Ok(()) => Some(watcher),
Err(err) => {
warn!(dir = %dir.display(), "could not watch backups dir (using timer/signals): {err}");
None
}
}
}
fn drain(rx: &mut mpsc::UnboundedReceiver<()>) {
while rx.try_recv().is_ok() {}
}
async fn event_loop<F, Fut>(
mut fs_rx: mpsc::UnboundedReceiver<()>,
mut reload: tokio::sync::watch::Receiver<u64>,
interval: Duration,
mut on_trigger: F,
) where
F: FnMut(&'static str) -> Fut,
Fut: std::future::Future<Output = ()>,
{
let mut periodic = tokio::time::interval(interval);
periodic.tick().await;
loop {
tokio::select! {
_ = periodic.tick() => on_trigger("periodic").await,
Some(()) = fs_rx.recv() => {
drain(&mut fs_rx);
on_trigger("backups dir changed").await;
}
changed = reload.changed() => match changed {
Ok(()) => on_trigger("reload signal").await,
Err(_) => break,
},
}
}
}
impl bestool_alertd::BackgroundTask for BackupCapabilitiesTask {
fn name(&self) -> &'static str {
"backup-capabilities"
}
fn interval(&self) -> Duration {
REREGISTER_INTERVAL
}
fn run<'a>(&'a self, ctx: &'a bestool_alertd::TaskContext) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
reregister("startup", &self.registry, ctx).await;
let (tx, rx) = mpsc::unbounded_channel();
let _watcher = watch_backups_dir(tx);
event_loop(rx, ctx.reload.clone(), REREGISTER_INTERVAL, |reason| {
reregister(reason, &self.registry, ctx)
})
.await;
Ok(())
})
}
}
#[cfg(test)]
mod tests {
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use super::*;
#[test]
fn read_noise_is_not_a_real_change() {
use notify::{
Event, EventKind,
event::{AccessKind, AccessMode, CreateKind, DataChange, MetadataKind, ModifyKind, RemoveKind},
};
let noise = [
EventKind::Access(AccessKind::Open(AccessMode::Any)),
EventKind::Access(AccessKind::Close(AccessMode::Read)),
EventKind::Access(AccessKind::Read),
EventKind::Modify(ModifyKind::Metadata(MetadataKind::Any)),
];
for kind in noise {
assert!(
!is_real_change(&Event::new(kind)),
"{kind:?} should be ignored as read-noise"
);
}
let real = [
EventKind::Create(CreateKind::File),
EventKind::Remove(RemoveKind::File),
EventKind::Modify(ModifyKind::Data(DataChange::Any)),
EventKind::Modify(ModifyKind::Any),
];
for kind in real {
assert!(
is_real_change(&Event::new(kind)),
"{kind:?} should trigger re-registration"
);
}
}
#[tokio::test]
async fn closed_fs_channel_does_not_storm() {
let (fs_tx, fs_rx) = mpsc::unbounded_channel::<()>();
drop(fs_tx); let (reload_tx, reload_rx) = tokio::sync::watch::channel(0u64);
let count = Arc::new(AtomicUsize::new(0));
let triggers = count.clone();
let loop_fut = event_loop(fs_rx, reload_rx, Duration::from_secs(3600), move |_| {
let triggers = triggers.clone();
async move {
triggers.fetch_add(1, Ordering::SeqCst);
}
});
let _ = tokio::time::timeout(Duration::from_millis(200), loop_fut).await;
drop(reload_tx);
assert_eq!(
count.load(Ordering::SeqCst),
0,
"a closed fs channel must not trigger re-registration"
);
}
#[tokio::test]
async fn reload_signal_triggers_once_then_shutdown_ends_loop() {
let (_fs_tx, fs_rx) = mpsc::unbounded_channel::<()>();
let (reload_tx, reload_rx) = tokio::sync::watch::channel(0u64);
let count = Arc::new(AtomicUsize::new(0));
let triggers = count.clone();
let handle = tokio::spawn(event_loop(
fs_rx,
reload_rx,
Duration::from_secs(3600),
move |_| {
let triggers = triggers.clone();
async move {
triggers.fetch_add(1, Ordering::SeqCst);
}
},
));
reload_tx.send_modify(|n| *n = n.wrapping_add(1));
tokio::time::sleep(Duration::from_millis(50)).await;
drop(reload_tx);
handle.await.expect("loop task panicked");
assert_eq!(count.load(Ordering::SeqCst), 1);
}
}
}
#[cfg(feature = "alertd-tamanu")]
async fn build_config(ctx: &Context, daemon: DaemonArgs) -> Result<bestool_alertd::DaemonConfig> {
use std::path::PathBuf;
use node_semver::Version;
use tracing::debug;
use bestool_alertd::doctor::resolve_sweep_tamanu;
use bestool_tamanu::server_info::fetch_device_key;
let DaemonArgs {
glob,
no_server,
server_addr,
watchdog_timeout,
no_watchdog,
} = daemon;
if !glob.is_empty() {
warn!("--glob is deprecated and does nothing; alert definitions are no longer loaded");
}
let root = ctx
.get::<crate::actions::tamanu::TamanuArgs>()
.and_then(|t| t.root.clone());
let install: Option<(Version, PathBuf)> = bestool_tamanu::try_find_tamanu(root.as_deref()).await?;
let tamanu = resolve_sweep_tamanu(install)?;
match &tamanu {
Some(t) => debug!(has_install = t.has_install, "resolved Tamanu sweep context"),
None => warn!("no Tamanu install and no TAMANU_DATABASE_URL; Tamanu checks will skip"),
}
let pg_pool = match &tamanu {
Some(t) => match bestool_postgres::pool::create_pool(&t.database_url, "bestool-alertd").await
{
Ok(pool) => Some(pool),
Err(err) => {
warn!(%err, "postgres not reachable at startup; db_connect will report it");
None
}
},
None => None,
};
let watchdog = if no_watchdog {
None
} else {
Some(std::time::Duration::from_secs(watchdog_timeout))
};
let device_key_pem = fetch_device_key().await;
let base = bestool_alertd::DaemonConfig::new(
pg_pool.clone(),
tamanu.as_ref().map(|t| t.database_url.clone()),
)
.with_binary_version(env!("CARGO_PKG_VERSION").to_string())
.with_no_server(no_server)
.with_server_addrs(server_addr)
.with_watchdog_timeout(watchdog);
let doctor = doctor_task(env!("CARGO_PKG_VERSION").to_string(), tamanu);
let mut daemon_config = with_daemon_tasks(base, doctor);
if let Some(pem) = device_key_pem {
daemon_config = daemon_config.with_device_key_pem(pem);
}
Ok(daemon_config)
}
#[cfg(not(feature = "alertd-tamanu"))]
async fn build_config(_ctx: &Context, daemon: DaemonArgs) -> Result<bestool_alertd::DaemonConfig> {
let DaemonArgs {
glob,
no_server,
server_addr,
watchdog_timeout,
no_watchdog,
} = daemon;
if !glob.is_empty() {
warn!("--glob is deprecated and does nothing; alert definitions are no longer loaded");
}
warn!("this build has no Tamanu support; doctor sweeps will skip Tamanu checks");
let watchdog = if no_watchdog {
None
} else {
Some(std::time::Duration::from_secs(watchdog_timeout))
};
let device_key_pem = bestool_canopy::registration::load()
.await
.ok()
.flatten()
.and_then(|reg| reg.device_key);
let base = bestool_alertd::DaemonConfig::new(None, None)
.with_binary_version(env!("CARGO_PKG_VERSION").to_string())
.with_no_server(no_server)
.with_server_addrs(server_addr)
.with_watchdog_timeout(watchdog);
let doctor = doctor_task(env!("CARGO_PKG_VERSION").to_string(), None);
let mut daemon_config = with_daemon_tasks(base, doctor);
if let Some(pem) = device_key_pem {
daemon_config = daemon_config.with_device_key_pem(pem);
}
Ok(daemon_config)
}