archy/core/archipelago/src/health_monitor.rs
Dorian 04f1f4a20f fix: decouple health monitor from webhook config
Health checks, auto-restarts, and WebSocket notifications now run
unconditionally. Previously the entire health loop was gated on
webhook config, so fresh installs (webhooks disabled) got zero
container monitoring. Webhook HTTP delivery is now fire-and-forget
after the notification is pushed to the UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:26:58 +00:00

362 lines
13 KiB
Rust

// Container Health Monitor
// Checks container health every 60s, auto-restarts unhealthy containers (max 3 times),
// and sends WebSocket notifications to the UI on failure.
use crate::data_model::{Notification, NotificationLevel};
use crate::state::StateManager;
use crate::webhooks::{self, WebhookEvent};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::{debug, info, warn};
const MAX_RESTART_ATTEMPTS: u32 = 3;
const CHECK_INTERVAL_SECS: u64 = 60;
/// Track restart attempts per container to avoid infinite restart loops.
struct RestartTracker {
attempts: HashMap<String, u32>,
}
impl RestartTracker {
fn new() -> Self {
Self {
attempts: HashMap::new(),
}
}
/// Record a restart attempt. Returns false if max attempts exceeded.
fn record_attempt(&mut self, name: &str) -> bool {
let count = self.attempts.entry(name.to_string()).or_insert(0);
*count += 1;
*count <= MAX_RESTART_ATTEMPTS
}
/// Clear restart count when a container is healthy again.
fn clear(&mut self, name: &str) {
self.attempts.remove(name);
}
fn attempt_count(&self, name: &str) -> u32 {
*self.attempts.get(name).unwrap_or(&0)
}
}
#[derive(Debug, Clone)]
struct ContainerHealth {
name: String,
app_id: String,
state: String,
healthy: bool,
}
/// Query all containers and their health status.
async fn check_containers() -> Vec<ContainerHealth> {
let output = match tokio::process::Command::new("sudo")
.args(["podman", "ps", "-a", "--format", "json"])
.output()
.await
{
Ok(o) if o.status.success() => o,
_ => return Vec::new(),
};
let stdout = String::from_utf8_lossy(&output.stdout);
let containers: Vec<serde_json::Value> =
serde_json::from_str(&stdout).unwrap_or_default();
// Backend services to skip
let skip = [
"btcpay-db", "nbxplorer", "mempool-db", "mempool-api",
"penpot-postgres", "penpot-backend", "penpot-exporter", "penpot-valkey",
"penpot-mailcatch", "immich_postgres", "immich_redis",
"endurain-db", "nextcloud-db",
];
containers
.iter()
.filter_map(|c| {
let name = c.get("Names").and_then(|v| {
if let Some(arr) = v.as_array() {
arr.first().and_then(|n| n.as_str()).map(|s| s.to_string())
} else {
v.as_str().map(|s| s.to_string())
}
})?;
let app_id = name
.strip_prefix("archy-")
.unwrap_or(&name)
.to_string();
if skip.contains(&app_id.as_str()) || app_id.ends_with("-ui") {
return None;
}
let state = c.get("State")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_lowercase();
let healthy = state == "running";
Some(ContainerHealth {
name,
app_id,
state,
healthy,
})
})
.collect()
}
/// Try to restart a container.
async fn restart_container(name: &str) -> bool {
info!("Auto-restarting unhealthy container: {}", name);
let result = tokio::process::Command::new("sudo")
.args(["podman", "start", name])
.output()
.await;
match result {
Ok(output) if output.status.success() => {
info!("Successfully restarted container: {}", name);
true
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("Failed to restart container {}: {}", name, stderr.trim());
false
}
Err(e) => {
warn!("Failed to execute podman start for {}: {}", name, e);
false
}
}
}
/// Spawn the health monitor background task.
pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
tokio::spawn(async move {
// Wait 2 minutes for containers to start up
tokio::time::sleep(std::time::Duration::from_secs(120)).await;
let mut tracker = RestartTracker::new();
let mut interval = tokio::time::interval(std::time::Duration::from_secs(CHECK_INTERVAL_SECS));
loop {
interval.tick().await;
let containers = check_containers().await;
if containers.is_empty() {
continue;
}
let mut state_changed = false;
let (mut data, _) = state.get_snapshot().await;
for container in &containers {
if container.healthy {
// Clear restart tracker if container recovered
if tracker.attempt_count(&container.name) > 0 {
info!("Container {} is healthy again after restart", container.name);
tracker.clear(&container.name);
}
continue;
}
// Container is unhealthy (exited/stopped)
// Only try auto-restart if we haven't exceeded max attempts
if container.state == "exited" || container.state == "stopped" {
let attempts = tracker.attempt_count(&container.name);
if attempts >= MAX_RESTART_ATTEMPTS {
// Already notified, skip
debug!("Container {} exceeded max restart attempts ({})", container.name, MAX_RESTART_ATTEMPTS);
continue;
}
if tracker.record_attempt(&container.name) {
let restarted = restart_container(&container.name).await;
let attempt = tracker.attempt_count(&container.name);
if !restarted || attempt >= MAX_RESTART_ATTEMPTS {
// Push notification to UI
let notification = Notification {
id: format!("health-{}-{}", container.app_id, chrono::Utc::now().timestamp()),
level: NotificationLevel::Error,
title: format!("{} is unhealthy", container.app_id),
message: if restarted {
format!(
"Container restarted ({}/{} attempts). May need manual attention.",
attempt, MAX_RESTART_ATTEMPTS
)
} else {
format!(
"Auto-restart failed (attempt {}/{}). Container state: {}",
attempt, MAX_RESTART_ATTEMPTS, container.state
)
},
timestamp: chrono::Utc::now().to_rfc3339(),
app_id: Some(container.app_id.clone()),
};
// Keep only the latest 20 notifications
data.notifications.push(notification.clone());
if data.notifications.len() > 20 {
data.notifications = data.notifications.split_off(data.notifications.len() - 20);
}
state_changed = true;
// Fire-and-forget webhook delivery (checks config internally)
let webhook_payload = webhooks::WebhookPayload {
event: WebhookEvent::ContainerCrash,
title: notification.title,
message: notification.message,
timestamp: notification.timestamp,
node_id: String::new(),
details: Some(serde_json::json!({
"container": container.name,
"app_id": container.app_id,
"state": container.state,
})),
};
webhooks::send_webhook(&data_dir, webhook_payload).await;
}
}
}
}
if state_changed {
state.update_data(data).await;
debug!("Health monitor: state updated with notifications");
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_restart_tracker_new_is_empty() {
let tracker = RestartTracker::new();
assert_eq!(tracker.attempt_count("any-container"), 0);
}
#[test]
fn test_restart_tracker_record_attempt_increments() {
let mut tracker = RestartTracker::new();
assert!(tracker.record_attempt("test-container"));
assert_eq!(tracker.attempt_count("test-container"), 1);
assert!(tracker.record_attempt("test-container"));
assert_eq!(tracker.attempt_count("test-container"), 2);
assert!(tracker.record_attempt("test-container"));
assert_eq!(tracker.attempt_count("test-container"), 3);
}
#[test]
fn test_restart_tracker_max_attempts_exceeded() {
let mut tracker = RestartTracker::new();
// First MAX_RESTART_ATTEMPTS attempts should return true
for i in 1..=MAX_RESTART_ATTEMPTS {
assert!(
tracker.record_attempt("container-a"),
"Attempt {} should be allowed",
i
);
}
// Next attempt exceeds max, returns false
assert!(!tracker.record_attempt("container-a"));
assert_eq!(tracker.attempt_count("container-a"), MAX_RESTART_ATTEMPTS + 1);
}
#[test]
fn test_restart_tracker_independent_containers() {
let mut tracker = RestartTracker::new();
tracker.record_attempt("container-a");
tracker.record_attempt("container-a");
tracker.record_attempt("container-b");
assert_eq!(tracker.attempt_count("container-a"), 2);
assert_eq!(tracker.attempt_count("container-b"), 1);
assert_eq!(tracker.attempt_count("container-c"), 0);
}
#[test]
fn test_restart_tracker_clear_resets_count() {
let mut tracker = RestartTracker::new();
tracker.record_attempt("container-x");
tracker.record_attempt("container-x");
assert_eq!(tracker.attempt_count("container-x"), 2);
tracker.clear("container-x");
assert_eq!(tracker.attempt_count("container-x"), 0);
}
#[test]
fn test_restart_tracker_clear_allows_new_attempts() {
let mut tracker = RestartTracker::new();
// Exhaust attempts
for _ in 0..=MAX_RESTART_ATTEMPTS {
tracker.record_attempt("container-y");
}
assert!(!tracker.record_attempt("container-y"));
// Clear and try again
tracker.clear("container-y");
assert!(tracker.record_attempt("container-y"));
assert_eq!(tracker.attempt_count("container-y"), 1);
}
#[test]
fn test_restart_tracker_clear_nonexistent_is_safe() {
let mut tracker = RestartTracker::new();
// Should not panic
tracker.clear("nonexistent");
assert_eq!(tracker.attempt_count("nonexistent"), 0);
}
#[test]
fn test_container_health_struct() {
let health = ContainerHealth {
name: "archy-bitcoin-knots".to_string(),
app_id: "bitcoin-knots".to_string(),
state: "running".to_string(),
healthy: true,
};
assert!(health.healthy);
assert_eq!(health.name, "archy-bitcoin-knots");
assert_eq!(health.app_id, "bitcoin-knots");
assert_eq!(health.state, "running");
}
#[test]
fn test_container_health_unhealthy() {
let health = ContainerHealth {
name: "archy-mempool-web".to_string(),
app_id: "mempool-web".to_string(),
state: "exited".to_string(),
healthy: false,
};
assert!(!health.healthy);
assert_eq!(health.state, "exited");
}
#[test]
fn test_max_restart_attempts_constant() {
// Ensure the constant is a reasonable value (not 0, not too high)
assert!(MAX_RESTART_ATTEMPTS >= 1);
assert!(MAX_RESTART_ATTEMPTS <= 10);
assert_eq!(MAX_RESTART_ATTEMPTS, 3);
}
#[test]
fn test_check_interval_constant() {
assert_eq!(CHECK_INTERVAL_SECS, 60);
}
}