Files
AWatch-rus/adk-rust/crates/prod-backup-restore/src/main.rs
T

476 lines
13 KiB
Rust

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<String>,
#[arg(long)]
server_host: Option<String>,
#[arg(long)]
server_user: Option<String>,
#[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<Requirement>,
required_commands: Vec<Requirement>,
required_files: Vec<Requirement>,
steps: Vec<Step>,
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<i32> {
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,
&timestamp,
&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<String, String>,
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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<Step>, 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<String, String>) -> Option<String> {
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<HashMap<String, String>> {
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);
}
}