use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result, bail}; use chrono::Local; use clap::Parser; use serde::Serialize; const DEFAULT_SERVER_HOST: &str = "192.0.2.13"; const DEFAULT_SERVER_USER: &str = "igor"; const DEFAULT_LEGACY_DB: &str = "/var/lib/activitywatch/aw-server-rust/sqlite.db"; const DEFAULT_TARGET_DB: &str = "/var/lib/activitywatch/.local/share/activitywatch/aw-server-rust/sqlite.db"; const DEFAULT_REMOTE_MERGE_BIN: &str = "/tmp/merge-aw-server-dbs"; #[derive(Debug, Parser)] #[command(about = "Safe planner/checker for the destructive prod backup restore flow")] struct Cli { #[arg(long, default_value = ".")] root: PathBuf, #[arg(long)] check_inputs: bool, #[arg(long)] json: bool, #[arg(long)] timestamp: Option, #[arg(long)] server_host: Option, #[arg(long)] server_user: Option, #[arg(long, default_value = "ansible/inventory.ini")] inventory: PathBuf, #[arg(long, default_value = DEFAULT_LEGACY_DB)] legacy_db: String, #[arg(long, default_value = DEFAULT_TARGET_DB)] target_db: String, #[arg(long, default_value = DEFAULT_REMOTE_MERGE_BIN)] remote_merge_bin: String, #[arg(long)] apply: bool, } #[derive(Debug, Serialize)] struct Plan { mode: &'static str, root: String, env_file: String, server_host: String, server_user: String, timestamp: String, remote_backup_dir: String, legacy_db: String, target_db: String, remote_merge_bin: String, required_env: Vec, required_commands: Vec, required_files: Vec, steps: Vec, missing_count: usize, } #[derive(Debug, Serialize)] struct Requirement { name: String, ok: bool, detail: String, } #[derive(Debug, Serialize)] struct Step { order: usize, kind: &'static str, command: String, destructive: bool, } fn main() { let code = match run() { Ok(code) => code, Err(err) => { eprintln!("{err:#}"); 1 } }; std::process::exit(code); } fn run() -> Result { let cli = Cli::parse(); if cli.apply { bail!( "Rust apply is intentionally disabled for this stage; run without --apply to review the safe restore plan" ); } let root = cli .root .canonicalize() .with_context(|| format!("canonicalize root {}", cli.root.display()))?; let env_file = root.join("private-config/runtime.env"); let env_values = read_env_file(&env_file).unwrap_or_default(); let timestamp = cli .timestamp .clone() .unwrap_or_else(|| Local::now().format("%Y%m%d-%H%M%S").to_string()); let server_host = cli .server_host .or_else(|| env_value("AW_SERVER_HOST", &env_values)) .unwrap_or_else(|| DEFAULT_SERVER_HOST.to_string()); let server_user = cli .server_user .or_else(|| env_value("AW_SERVER_USER", &env_values)) .unwrap_or_else(|| DEFAULT_SERVER_USER.to_string()); let remote_backup_dir = format!("/var/lib/activitywatch/backups/prod-restore-{timestamp}"); let inventory = if cli.inventory.is_absolute() { cli.inventory.clone() } else { root.join(&cli.inventory) }; let merge_bin = root.join("adk-rust/target/release/merge-aw-server-dbs"); let plan = build_plan( &root, &env_file, &env_values, &server_host, &server_user, ×tamp, &remote_backup_dir, &cli.legacy_db, &cli.target_db, &cli.remote_merge_bin, &inventory, &merge_bin, ); if cli.json { println!("{}", serde_json::to_string_pretty(&plan)?); } else { print_plan(&plan); } if cli.check_inputs && plan.missing_count > 0 { Ok(2) } else { Ok(0) } } #[allow(clippy::too_many_arguments)] fn build_plan( root: &Path, env_file: &Path, env_values: &HashMap, server_host: &str, server_user: &str, timestamp: &str, remote_backup_dir: &str, legacy_db: &str, target_db: &str, remote_merge_bin: &str, inventory: &Path, merge_bin: &Path, ) -> Plan { let required_env = ["AW_SSH_PASSWORD", "AW_WINRM_PASSWORD"] .into_iter() .map(|name| { let ok = env_value(name, env_values).is_some(); Requirement { name: name.to_string(), ok, detail: if ok { "present (value hidden)".to_string() } else { "missing".to_string() }, } }) .collect::>(); let required_commands = ["sshpass", "ansible-playbook"] .into_iter() .map(|name| Requirement { name: name.to_string(), ok: command_exists(name), detail: if command_exists(name) { "found in PATH".to_string() } else { "missing in PATH".to_string() }, }) .collect::>(); let required_files = [("inventory", inventory), ("merge-aw-server-dbs", merge_bin)] .into_iter() .map(|(name, path)| Requirement { name: name.to_string(), ok: path.is_file(), detail: path.display().to_string(), }) .collect::>(); let mut steps = Vec::new(); push_step( &mut steps, "scp", format!( "sshpass scp adk-rust/target/release/merge-aw-server-dbs {server_user}@{server_host}:{remote_merge_bin}" ), false, ); push_step( &mut steps, "ssh", format!( "sudo mkdir -p '{remote_backup_dir}' && sudo chown root:root '{remote_backup_dir}'" ), false, ); push_step( &mut steps, "ssh", format!("sudo test -f '{legacy_db}'"), false, ); push_step( &mut steps, "ssh", format!("sudo test -f '{target_db}'"), false, ); push_step( &mut steps, "ssh", format!( "sudo cp -a '{legacy_db}' '{remote_backup_dir}/legacy-root-sqlite.db' && sudo cp -a '{target_db}' '{remote_backup_dir}/target-before-merge-sqlite.db'" ), false, ); push_step( &mut steps, "ssh", "sudo systemctl stop activitywatch-server.service || true".to_string(), true, ); push_step( &mut steps, "ssh", format!( "sudo chmod 0755 '{remote_merge_bin}' && sudo '{remote_merge_bin}' --base '{legacy_db}' --overlay '{target_db}' --output '{remote_backup_dir}/sqlite.merged.db'" ), true, ); push_step( &mut steps, "ssh", format!( "sudo install -o activitywatch -g activitywatch -m 0644 '{remote_backup_dir}/sqlite.merged.db' '{target_db}'" ), true, ); for playbook in [ "ansible/deploy_aw_server.yml", "ansible/deploy_aw_windows.yml", "ansible/post_validate_aw_windows.yml", ] { push_step( &mut steps, "ansible-playbook", format!("ansible-playbook -i {} {playbook}", inventory.display()), true, ); } push_step( &mut steps, "validate", "query AW historical window data for 2026-04-29 and settings always_active_pattern" .to_string(), false, ); let missing_count = required_env .iter() .chain(required_commands.iter()) .chain(required_files.iter()) .filter(|item| !item.ok) .count(); Plan { mode: "plan-only", root: root.display().to_string(), env_file: env_file.display().to_string(), server_host: server_host.to_string(), server_user: server_user.to_string(), timestamp: timestamp.to_string(), remote_backup_dir: remote_backup_dir.to_string(), legacy_db: legacy_db.to_string(), target_db: target_db.to_string(), remote_merge_bin: remote_merge_bin.to_string(), required_env, required_commands, required_files, steps, missing_count, } } fn push_step(steps: &mut Vec, kind: &'static str, command: String, destructive: bool) { steps.push(Step { order: steps.len() + 1, kind, command, destructive, }); } fn print_plan(plan: &Plan) { println!("prod-backup-restore: {}", plan.mode); println!("root: {}", plan.root); println!("server: {}@{}", plan.server_user, plan.server_host); println!("remote_backup_dir: {}", plan.remote_backup_dir); println!("missing_inputs: {}", plan.missing_count); println!(); println!("Required env:"); for item in &plan.required_env { println!(" [{}] {} - {}", ok_mark(item.ok), item.name, item.detail); } println!("Required commands:"); for item in &plan.required_commands { println!(" [{}] {} - {}", ok_mark(item.ok), item.name, item.detail); } println!("Required files:"); for item in &plan.required_files { println!(" [{}] {} - {}", ok_mark(item.ok), item.name, item.detail); } println!(); println!("Planned steps, not executed:"); for step in &plan.steps { let risk = if step.destructive { "MUTATION" } else { "read/prepare" }; println!( " {:02}. {:<16} {:<12} {}", step.order, step.kind, risk, step.command ); } } fn ok_mark(ok: bool) -> &'static str { if ok { "OK" } else { "MISS" } } fn env_value(name: &str, env_values: &HashMap) -> Option { std::env::var(name) .ok() .filter(|value| !value.trim().is_empty()) .or_else(|| env_values.get(name).cloned()) .filter(|value| !value.trim().is_empty()) } fn read_env_file(path: &Path) -> Result> { let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; let mut values = HashMap::new(); for raw_line in text.lines() { let line = raw_line.trim(); if line.is_empty() || line.starts_with('#') || !line.contains('=') { continue; } let mut parts = line.splitn(2, '='); let Some(key) = parts.next().map(str::trim) else { continue; }; let Some(value) = parts.next().map(str::trim) else { continue; }; if key.is_empty() || key.starts_with("export ") { continue; } values.insert(key.to_string(), strip_shell_quotes(value).to_string()); } Ok(values) } fn strip_shell_quotes(value: &str) -> &str { if value.len() >= 2 && ((value.starts_with('"') && value.ends_with('"')) || (value.starts_with('\'') && value.ends_with('\''))) { &value[1..value.len() - 1] } else { value } } fn command_exists(name: &str) -> bool { Command::new("sh") .arg("-c") .arg(format!("command -v {name} >/dev/null 2>&1")) .status() .map(|status| status.success()) .unwrap_or(false) } #[cfg(test)] mod tests { use super::*; #[test] fn strips_simple_shell_quotes() { assert_eq!(strip_shell_quotes("'secret'"), "secret"); assert_eq!(strip_shell_quotes("\"secret\""), "secret"); assert_eq!(strip_shell_quotes("plain"), "plain"); } #[test] fn reads_simple_env_file_without_exposing_values() { let dir = tempfile::tempdir().unwrap(); let env = dir.path().join("runtime.env"); fs::write( &env, "AW_SSH_PASSWORD='one'\nAW_WINRM_PASSWORD=\"two\"\n# ignored\n", ) .unwrap(); let values = read_env_file(&env).unwrap(); assert_eq!(values.get("AW_SSH_PASSWORD").unwrap(), "one"); assert_eq!(values.get("AW_WINRM_PASSWORD").unwrap(), "two"); } #[test] fn plan_marks_destructive_steps() { let dir = tempfile::tempdir().unwrap(); let inventory = dir.path().join("ansible/inventory.ini"); let merge = dir .path() .join("adk-rust/target/release/merge-aw-server-dbs"); fs::create_dir_all(inventory.parent().unwrap()).unwrap(); fs::create_dir_all(merge.parent().unwrap()).unwrap(); fs::write(&inventory, "").unwrap(); fs::write(&merge, "").unwrap(); let mut env = HashMap::new(); env.insert("AW_SSH_PASSWORD".to_string(), "hidden".to_string()); env.insert("AW_WINRM_PASSWORD".to_string(), "hidden".to_string()); let plan = build_plan( dir.path(), &dir.path().join("private-config/runtime.env"), &env, "192.0.2.13", "igor", "20260602-000000", "/var/lib/activitywatch/backups/prod-restore-20260602-000000", DEFAULT_LEGACY_DB, DEFAULT_TARGET_DB, DEFAULT_REMOTE_MERGE_BIN, &inventory, &merge, ); assert!(plan.steps.iter().any(|step| step.destructive)); assert!( plan.steps .iter() .any(|step| step.command.contains("systemctl stop activitywatch-server")) ); assert_eq!(plan.required_env.iter().filter(|item| item.ok).count(), 2); } }