// Crash Recovery Module // Detects unexpected shutdowns and restarts containers that were running before the crash. // // How it works: // 1. On startup, write a PID file as a "running" marker // 2. Periodically snapshot which containers are running to a state file // 3. On clean shutdown, remove the PID file // 4. On next startup, if the PID file exists → previous instance crashed // 5. On crash recovery: read the saved container list, restart them, log actions use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::process::Output; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; use tokio::fs; use tracing::{info, warn}; const PID_FILE: &str = "archipelago.pid"; const CONTAINER_STATE_FILE: &str = "running-containers.json"; const USER_STOPPED_FILE: &str = "user-stopped.json"; /// Shared flag: true once boot recovery is complete. Health monitor should wait for this. pub static RECOVERY_COMPLETE: AtomicBool = AtomicBool::new(false); /// Process start time for uptime calculation. static START_TIME: std::sync::OnceLock = std::sync::OnceLock::new(); /// Initialize the start time. Call once at startup. pub fn init_start_time() { START_TIME.get_or_init(Instant::now); } /// Get uptime in seconds since process start. pub fn uptime_seconds() -> u64 { START_TIME.get().map(|t| t.elapsed().as_secs()).unwrap_or(0) } /// Mark boot recovery as complete. Call after crash recovery + start_stopped_containers finish. pub fn mark_recovery_complete() { RECOVERY_COMPLETE.store(true, Ordering::SeqCst); info!("Boot recovery complete — health monitor may proceed"); } /// Check if boot recovery is done. pub fn is_recovery_complete() -> bool { RECOVERY_COMPLETE.load(Ordering::SeqCst) } // ── User-stopped tracking ─────────────────────────────────────────────── // When a user explicitly stops a container via the UI, we record it here // so crash recovery and health monitor don't auto-restart it. /// Load the set of user-stopped containers from disk. pub async fn load_user_stopped(data_dir: &Path) -> std::collections::HashSet { let path = data_dir.join(USER_STOPPED_FILE); match fs::read_to_string(&path).await { Ok(content) => serde_json::from_str(&content).unwrap_or_default(), Err(_) => std::collections::HashSet::new(), } } /// Save the set of user-stopped containers to disk. pub async fn save_user_stopped(data_dir: &Path, stopped: &std::collections::HashSet) { let path = data_dir.join(USER_STOPPED_FILE); if let Ok(json) = serde_json::to_string_pretty(stopped) { let _ = fs::write(&path, json).await; } } /// Mark a container as user-stopped (won't be auto-restarted). pub async fn mark_user_stopped(data_dir: &Path, name: &str) { let mut stopped = load_user_stopped(data_dir).await; stopped.insert(name.to_string()); save_user_stopped(data_dir, &stopped).await; } /// Clear user-stopped flag (container was manually started by user). pub async fn clear_user_stopped(data_dir: &Path, name: &str) { let mut stopped = load_user_stopped(data_dir).await; if stopped.remove(name) { save_user_stopped(data_dir, &stopped).await; } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RunningContainerRecord { pub name: String, pub image: String, } #[derive(Debug, Clone, Serialize, Deserialize)] struct ContainerSnapshot { pub timestamp: u64, pub containers: Vec, } /// Check if the previous instance crashed (PID file exists without a clean shutdown). /// Returns the list of containers that were running before the crash, if any. pub async fn check_for_crash(data_dir: &Path) -> Result>> { let pid_path = data_dir.join(PID_FILE); if !pid_path.exists() { return Ok(None); } // PID file exists — previous instance didn't shut down cleanly let old_pid = fs::read_to_string(&pid_path) .await .unwrap_or_default() .trim() .to_string(); warn!( "Crash detected: previous instance (PID {}) did not shut down cleanly", old_pid ); // Check if that PID is actually still running (zombie/stuck process) if !old_pid.is_empty() { if let Ok(pid) = old_pid.parse::() { if is_process_running(pid) { warn!( "Previous process (PID {}) is still running — not a crash, skipping recovery", pid ); // Remove stale PID file and skip recovery let _ = fs::remove_file(&pid_path).await; return Ok(None); } } } // Load the saved container snapshot let state_path = data_dir.join(CONTAINER_STATE_FILE); let containers = if state_path.exists() { match fs::read_to_string(&state_path).await { Ok(content) => match serde_json::from_str::(&content) { Ok(snapshot) => { info!( "Found {} containers from pre-crash snapshot (saved at {})", snapshot.containers.len(), snapshot.timestamp ); snapshot.containers } Err(e) => { warn!("Failed to parse container snapshot: {}", e); Vec::new() } }, Err(e) => { warn!("Failed to read container snapshot: {}", e); Vec::new() } } } else { info!("No container snapshot found — cannot determine which containers were running"); Vec::new() }; // Clean up the stale PID file let _ = fs::remove_file(&pid_path).await; if containers.is_empty() { Ok(None) } else { Ok(Some(containers)) } } /// Write the PID file to mark the current instance as running. pub async fn write_pid_marker(data_dir: &Path) -> Result<()> { let pid = std::process::id(); let pid_path = data_dir.join(PID_FILE); fs::write(&pid_path, pid.to_string()) .await .context("Failed to write PID marker")?; Ok(()) } /// Remove the PID file on clean shutdown. pub async fn remove_pid_marker(data_dir: &Path) { let pid_path = data_dir.join(PID_FILE); if let Err(e) = fs::remove_file(&pid_path).await { warn!("Failed to remove PID marker: {}", e); } } /// Save a snapshot of currently running containers to disk. /// Called periodically so we know what to restart after a crash. pub async fn save_container_snapshot(data_dir: &Path) -> Result<()> { let mut cmd = tokio::process::Command::new("podman"); cmd.args(["ps", "--format", "json"]); let output = command_with_timeout(cmd, Duration::from_secs(30), "podman ps").await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); anyhow::bail!("podman ps failed: {}", stderr); } let stdout = String::from_utf8_lossy(&output.stdout); let containers: Vec = serde_json::from_str(&stdout).unwrap_or_default(); let records: Vec = containers .iter() .filter_map(|c| { let name = c.get("Names").and_then(|v| { // Podman returns Names as an array 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 image = c .get("Image") .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(); Some(RunningContainerRecord { name, image }) }) .collect(); let snapshot = ContainerSnapshot { timestamp: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(), containers: records, }; let state_path = data_dir.join(CONTAINER_STATE_FILE); let json = serde_json::to_string_pretty(&snapshot) .context("Failed to serialize container snapshot")?; fs::write(&state_path, json) .await .context("Failed to write container snapshot")?; Ok(()) } /// Recover containers that were running before a crash. /// Attempts to start each container, logging success/failure. pub async fn recover_containers(containers: &[RunningContainerRecord]) -> RecoveryReport { let mut report = RecoveryReport { total: containers.len(), recovered: 0, failed: Vec::new(), }; for (i, record) in containers.iter().enumerate() { info!( "Recovering container: {} (image: {})", record.name, record.image ); // Rate-limit container starts to avoid overwhelming podman on low-resource systems if i > 0 { tokio::time::sleep(std::time::Duration::from_secs(3)).await; } // Try up to 2 attempts with increasing timeout (120s first, 180s retry) let mut started = false; for attempt in 0..2u32 { let timeout_secs = if attempt == 0 { 120 } else { 180 }; if attempt > 0 { info!( "Retrying container {} (attempt {})", record.name, attempt + 1 ); tokio::time::sleep(std::time::Duration::from_secs(10)).await; } let mut cmd = tokio::process::Command::new("podman"); cmd.args(["start", &record.name]); let result = command_with_timeout( cmd, Duration::from_secs(timeout_secs), &format!("podman start {}", record.name), ) .await; match result { Ok(output) if output.status.success() => { info!("Successfully restarted container: {}", record.name); report.recovered += 1; started = true; break; } Ok(output) => { let stderr = String::from_utf8_lossy(&output.stderr); warn!( "Failed to restart container {} (attempt {}): {}", record.name, attempt + 1, stderr.trim() ); } Err(e) => { warn!( "Failed to start container {} ({}s, attempt {}): {}", record.name, timeout_secs, attempt + 1, e ); } } } if !started { report.failed.push(record.name.clone()); } } report } #[derive(Debug)] pub struct RecoveryReport { pub total: usize, pub recovered: usize, pub failed: Vec, } /// Check if a process with the given PID is still running. fn is_process_running(pid: u32) -> bool { // Check /proc/{pid} on Linux std::path::Path::new(&format!("/proc/{}", pid)).exists() } /// Start all stopped containers that were previously installed. /// Runs on every startup to ensure containers come back after clean reboots. /// The crash recovery (PID-based) handles dirty shutdowns; this handles clean ones. /// Skips containers that the user intentionally stopped via the UI. pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport { start_stopped_containers_for(data_dir, false).await } /// Start stopped multi-container stack members after the backend is already /// ready. These can take minutes after a reboot, so they must not block /// systemd readiness. pub async fn start_stopped_stack_containers(data_dir: &Path) -> RecoveryReport { start_stopped_app_stacks(data_dir).await } async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport { let user_stopped = load_user_stopped(data_dir).await; let mut report = RecoveryReport { total: 0, recovered: 0, failed: Vec::new(), }; for stack in stack_recovery_specs() { if !stack_has_any_container(stack).await { continue; } info!( "Recovering stopped {} stack containers after boot", stack.name ); repair_stack_network_aliases(stack).await; for container in stack.containers { if user_stopped.contains(*container) { info!("Skipping user-stopped container: {}", container); continue; } match container_state(container).await { Some(state) if state == "running" => continue, Some(_) => {} None => continue, } repair_stack_network_aliases(stack).await; wait_before_stack_container_recovery(stack, container).await; report.total += 1; if start_existing_container(container).await { report.recovered += 1; } else { report.failed.push((*container).to_string()); } } } report } async fn wait_before_stack_container_recovery(stack: &StackRecoverySpec, container: &str) { if stack.name != "indeedhub" || container != "indeedhub" { return; } for _ in 0..60 { if indeedhub_recovery_dependencies_running().await { repair_stack_network_aliases(stack).await; break; } tokio::time::sleep(Duration::from_secs(2)).await; } for _ in 0..60 { let ready = podman_output( &["exec", "indeedhub-api", "getent", "hosts", "minio"], Duration::from_secs(5), ) .await .map(|output| output.status.success()) .unwrap_or(false); if ready { return; } tokio::time::sleep(Duration::from_secs(2)).await; } } async fn indeedhub_recovery_dependencies_running() -> bool { for name in ["indeedhub-redis", "indeedhub-minio", "indeedhub-api"] { if container_state(name).await.as_deref() != Some("running") { return false; } } true } async fn start_stopped_containers_for( data_dir: &Path, include_stack_members: bool, ) -> RecoveryReport { let mut cmd = tokio::process::Command::new("podman"); cmd.args([ "ps", "-a", "--filter", "status=exited", "--filter", "status=created", "--format", "{{.Names}}", ]); let output = match command_with_timeout(cmd, Duration::from_secs(60), "podman ps stopped").await { Ok(output) => output, Err(e) => { warn!("Failed listing stopped containers: {}", e); return RecoveryReport { total: 0, recovered: 0, failed: Vec::new(), }; } }; let all_names: Vec = if output.status.success() { String::from_utf8_lossy(&output.stdout) .lines() .filter(|l| !l.is_empty()) .map(|s| s.to_string()) .collect() } else { Vec::new() }; if all_names.is_empty() { return RecoveryReport { total: 0, recovered: 0, failed: Vec::new(), }; } // Filter out user-stopped containers let user_stopped = load_user_stopped(data_dir).await; let names: Vec = all_names .into_iter() .filter(|n| { if user_stopped.contains(n) { info!("Skipping user-stopped container: {}", n); false } else { true } }) .collect(); if names.is_empty() { return RecoveryReport { total: 0, recovered: 0, failed: Vec::new(), }; } let names: Vec = names .into_iter() .filter(|n| should_auto_start_stopped_container(n, include_stack_members)) .collect(); if names.is_empty() { return RecoveryReport { total: 0, recovered: 0, failed: Vec::new(), }; } // Sort by startup tier: databases first, then core, then dependent services, then apps let mut records: Vec = names .iter() .map(|n| RunningContainerRecord { name: n.clone(), image: String::new(), }) .collect(); records.sort_by_key(|r| container_boot_tier(&r.name)); info!( "Starting {} stopped containers after boot (skipped {} user-stopped)...", records.len(), user_stopped.len() ); recover_containers(&records).await } fn should_auto_start_stopped_container(name: &str, include_stack_members: bool) -> bool { // Keep generic boot recovery narrow. The Rust manifest reconciler owns // managed app stacks; starting every exited Podman container here races // it and resurrects legacy/orphan helper containers. if matches!(name, "filebrowser" | "nostr-rs-relay") { return true; } include_stack_members && matches!( name, "immich_postgres" | "immich_redis" | "immich_server" | "indeedhub-postgres" | "indeedhub-redis" | "indeedhub-minio" | "indeedhub-relay" | "indeedhub-api" | "indeedhub-ffmpeg" | "indeedhub" | "netbird-server" | "netbird-dashboard" | "netbird" ) } struct StackRecoverySpec { name: &'static str, network: &'static str, aliases: &'static [(&'static str, &'static str)], containers: &'static [&'static str], } fn stack_recovery_specs() -> &'static [StackRecoverySpec] { &[ StackRecoverySpec { name: "immich", network: "immich-net", aliases: &[ ("immich_postgres", "immich_postgres"), ("immich_redis", "immich_redis"), ("immich_server", "immich_server"), ], containers: &["immich_postgres", "immich_redis", "immich_server"], }, StackRecoverySpec { name: "indeedhub", network: "indeedhub-net", aliases: &[ ("indeedhub-postgres", "postgres"), ("indeedhub-redis", "redis"), ("indeedhub-minio", "minio"), ("indeedhub-relay", "relay"), ("indeedhub-api", "api"), ("indeedhub", "indeedhub"), ], containers: &[ "indeedhub-postgres", "indeedhub-redis", "indeedhub-minio", "indeedhub-relay", "indeedhub-api", "indeedhub-ffmpeg", "indeedhub", ], }, StackRecoverySpec { name: "netbird", network: "netbird-net", aliases: &[ ("netbird-server", "netbird-server"), ("netbird-dashboard", "netbird-dashboard"), ("netbird", "netbird"), ], containers: &["netbird-server", "netbird-dashboard", "netbird"], }, ] } async fn stack_has_any_container(stack: &StackRecoverySpec) -> bool { for container in stack.containers { if container_state(container).await.is_some() { return true; } } false } async fn repair_stack_network_aliases(stack: &StackRecoverySpec) { let _ = podman_status( &["network", "create", stack.network], Duration::from_secs(15), ) .await; for (container, alias) in stack.aliases { if container_state(container).await.is_none() { continue; } if network_alias_present(stack.network, container, alias).await { continue; } let _ = podman_status( &["network", "disconnect", "-f", stack.network, container], Duration::from_secs(15), ) .await; let _ = podman_status( &[ "network", "connect", "--alias", alias, stack.network, container, ], Duration::from_secs(15), ) .await; } } async fn network_alias_present(network_name: &str, container: &str, alias: &str) -> bool { let output = match podman_output( &[ "inspect", container, "--format", "{{json .NetworkSettings.Networks}}", ], Duration::from_secs(10), ) .await { Ok(output) if output.status.success() => output, _ => return false, }; let Ok(networks) = serde_json::from_slice::(&output.stdout) else { return false; }; networks .get(network_name) .and_then(|network| network.get("Aliases")) .and_then(|aliases| aliases.as_array()) .map(|aliases| aliases.iter().any(|value| value.as_str() == Some(alias))) .unwrap_or(false) } async fn container_state(container: &str) -> Option { let output = podman_output( &["inspect", container, "--format", "{{.State.Status}}"], Duration::from_secs(10), ) .await .ok()?; output .status .success() .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) } async fn start_existing_container(container: &str) -> bool { info!("Recovering stack container: {}", container); let timeout = match container { "immich_server" | "netbird-server" => Duration::from_secs(120), _ => Duration::from_secs(90), }; if container_state(container).await.as_deref() == Some("initialized") { cleanup_container_runtime_state(container).await; } match podman_output(&["start", container], timeout).await { Ok(output) if output.status.success() => { tokio::time::sleep(Duration::from_secs(3)).await; if container_state(container).await.as_deref() == Some("exited") { warn!("Stack container {} exited shortly after start", container); false } else { info!("Successfully recovered stack container: {}", container); true } } Ok(output) => { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); if stderr.contains("exec.fifo") || stderr.contains("failed to start container") { cleanup_container_runtime_state(container).await; if let Ok(retry) = podman_output(&["start", container], timeout).await { if retry.status.success() { info!( "Successfully recovered stack container after cleanup: {}", container ); return true; } warn!( "Failed to recover stack container {} after cleanup: {}", container, String::from_utf8_lossy(&retry.stderr).trim() ); return false; } } warn!( "Failed to recover stack container {}: {}", container, stderr ); false } Err(e) => { warn!("Failed to recover stack container {}: {}", container, e); false } } } async fn cleanup_container_runtime_state(container: &str) { let _ = podman_output( &["container", "cleanup", container], Duration::from_secs(30), ) .await; } async fn podman_status(args: &[&str], timeout: Duration) -> Option { podman_output(args, timeout) .await .ok() .map(|output| output.status) } async fn podman_output(args: &[&str], timeout: Duration) -> Result { let mut cmd = tokio::process::Command::new("podman"); cmd.args(args); command_with_timeout(cmd, timeout, &format!("podman {}", args.join(" "))).await } /// Simple tier ordering for boot recovery (mirrors health_monitor tiers). fn container_boot_tier(name: &str) -> u8 { let id = name.strip_prefix("archy-").unwrap_or(name); match id { // Tier 0: Databases and data stores "btcpay-db" | "mempool-db" | "mysql-mempool" | "penpot-postgres" | "immich_postgres" | "immich_redis" | "penpot-valkey" | "endurain-db" | "nextcloud-db" | "indeedhub-postgres" | "indeedhub-redis" | "indeedhub-minio" => 0, // Tier 1: Core infrastructure "bitcoin-knots" | "bitcoin-core" | "bitcoin" => 1, // Tier 2: Dependent services "lnd" | "electrumx" | "mempool-electrs" | "electrs" | "nbxplorer" | "mempool-api" | "indeedhub-api" => 2, // Tier 4: Frontend/UI "mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "penpot-frontend" | "penpot-exporter" | "indeedhub" => 4, // Tier 3: Everything else _ => 3, } } /// Run the reconciliation script after boot to fix any config drift. /// Ensures all containers match their canonical specs from container-specs.sh. #[allow(dead_code)] pub async fn run_boot_reconciliation() { let script = "/home/archipelago/archy/scripts/reconcile-containers.sh"; if !std::path::Path::new(script).exists() { info!("Reconciliation script not found (dev mode?) — skipping boot reconciliation"); return; } info!("Running boot reconciliation..."); let cmd = tokio::process::Command::new(script); let result = command_with_timeout(cmd, Duration::from_secs(300), script).await; match result { Ok(output) if output.status.success() => { info!("Boot reconciliation complete"); } Ok(output) => { let stderr = String::from_utf8_lossy(&output.stderr); warn!( "Boot reconciliation had failures: {}", stderr.chars().take(500).collect::() ); } Err(e) => warn!("Boot reconciliation failed: {}", e), } } async fn command_with_timeout( mut cmd: tokio::process::Command, timeout: Duration, description: &str, ) -> Result { cmd.kill_on_drop(true); tokio::time::timeout(timeout, cmd.output()) .await .with_context(|| format!("{} timed out after {}s", description, timeout.as_secs()))? .with_context(|| format!("Failed to run {}", description)) } /// Spawn a background task that periodically saves the container snapshot. pub fn spawn_snapshot_task(data_dir: PathBuf) { tokio::spawn(async move { // Wait 2 minutes before first snapshot (let crash recovery finish and containers stabilize) tokio::time::sleep(std::time::Duration::from_secs(120)).await; let mut interval = tokio::time::interval(std::time::Duration::from_secs(120)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { interval.tick().await; if let Err(e) = save_container_snapshot(&data_dir).await { tracing::debug!("Container snapshot (non-fatal): {}", e); } } }); } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[tokio::test] async fn test_no_crash_without_pid_file() { let tmp = TempDir::new().unwrap(); let result = check_for_crash(tmp.path()).await.unwrap(); assert!(result.is_none()); } #[tokio::test] async fn test_crash_detected_with_pid_file() { let tmp = TempDir::new().unwrap(); // Write a PID file with a non-existent PID fs::write(tmp.path().join(PID_FILE), "999999999") .await .unwrap(); let result = check_for_crash(tmp.path()).await.unwrap(); // No snapshot file → crash detected but no containers to recover assert!(result.is_none()); } #[tokio::test] async fn test_crash_with_snapshot() { let tmp = TempDir::new().unwrap(); // Write PID file fs::write(tmp.path().join(PID_FILE), "999999999") .await .unwrap(); // Write container snapshot let snapshot = ContainerSnapshot { timestamp: 1000, containers: vec![ RunningContainerRecord { name: "archy-bitcoin-knots".to_string(), image: "bitcoin-knots:27.1".to_string(), }, RunningContainerRecord { name: "archy-mempool-web".to_string(), image: "mempool/frontend:3.0".to_string(), }, ], }; let json = serde_json::to_string(&snapshot).unwrap(); fs::write(tmp.path().join(CONTAINER_STATE_FILE), json) .await .unwrap(); let result = check_for_crash(tmp.path()).await.unwrap(); assert!(result.is_some()); let containers = result.unwrap(); assert_eq!(containers.len(), 2); assert_eq!(containers[0].name, "archy-bitcoin-knots"); assert_eq!(containers[1].name, "archy-mempool-web"); } #[tokio::test] async fn test_write_and_remove_pid_marker() { let tmp = TempDir::new().unwrap(); write_pid_marker(tmp.path()).await.unwrap(); assert!(tmp.path().join(PID_FILE).exists()); remove_pid_marker(tmp.path()).await; assert!(!tmp.path().join(PID_FILE).exists()); } #[tokio::test] async fn test_pid_marker_contains_current_pid() { let tmp = TempDir::new().unwrap(); write_pid_marker(tmp.path()).await.unwrap(); let content = fs::read_to_string(tmp.path().join(PID_FILE)).await.unwrap(); let saved_pid: u32 = content.parse().unwrap(); assert_eq!(saved_pid, std::process::id()); } #[tokio::test] async fn test_clean_shutdown_no_crash_on_restart() { let tmp = TempDir::new().unwrap(); // Simulate: startup → write PID → clean shutdown → remove PID write_pid_marker(tmp.path()).await.unwrap(); remove_pid_marker(tmp.path()).await; // Next startup should detect no crash let result = check_for_crash(tmp.path()).await.unwrap(); assert!(result.is_none()); } #[tokio::test] async fn test_corrupt_snapshot_handled() { let tmp = TempDir::new().unwrap(); fs::write(tmp.path().join(PID_FILE), "999999999") .await .unwrap(); fs::write(tmp.path().join(CONTAINER_STATE_FILE), "not valid json") .await .unwrap(); // Should not crash, returns None (no recoverable containers) let result = check_for_crash(tmp.path()).await.unwrap(); assert!(result.is_none()); } #[test] fn generic_boot_recovery_skips_manifest_owned_and_legacy_stacks() { assert!(should_auto_start_stopped_container("filebrowser", false)); assert!(should_auto_start_stopped_container("nostr-rs-relay", false)); assert!(!should_auto_start_stopped_container("bitcoin-knots", false)); assert!(!should_auto_start_stopped_container("lnd", false)); assert!(!should_auto_start_stopped_container( "indeedhub-postgres", false )); assert!(should_auto_start_stopped_container( "indeedhub-postgres", true )); } }