use std::collections::HashMap; use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result, bail}; use chrono::Local; use clap::Parser; use serde::Serialize; #[derive(Debug, Parser)] #[command(about = "Safe planner/orchestrator for the production ActivityWatch rollout")] struct Cli { #[arg(long, default_value = ".")] root: PathBuf, #[arg(long, default_value = "ansible/inventory.ini")] inventory: PathBuf, #[arg(long)] check_inputs: bool, #[arg(long)] json: bool, #[arg(long)] apply: bool, #[arg(long)] skip_quality_gate: bool, #[arg(long)] timestamp: Option, } #[derive(Debug, Clone, Serialize)] struct Requirement { name: String, ok: bool, detail: String, } #[derive(Debug, Clone, Serialize)] struct Step { order: usize, name: String, command: Vec, log_file: String, mutation: bool, } #[derive(Debug, Serialize)] struct Plan { mode: &'static str, root: String, branch: String, env_file: String, inventory: String, log_dir: String, required_env: Vec, required_commands: Vec, required_files: Vec, steps: Vec, missing_count: usize, } #[derive(Debug, Serialize)] struct StepResult { order: usize, name: String, status: &'static str, exit_code: Option, log_file: String, } #[derive(Debug, Serialize)] struct ApplyReport { plan: Plan, results: Vec, } 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(); let root = cli .root .canonicalize() .with_context(|| format!("canonicalize root {}", cli.root.display()))?; let timestamp = cli .timestamp .clone() .unwrap_or_else(|| Local::now().format("%Y%m%d-%H%M%S").to_string()); let env_file = root.join("private-config/runtime.env"); let env_values = read_env_file(&env_file).unwrap_or_default(); let inventory = absolute_path(&root, &cli.inventory); let log_dir = root.join(".rollout-logs").join(×tamp); let plan = build_plan( &root, &env_file, &env_values, &inventory, &log_dir, cli.apply, cli.skip_quality_gate, ); if !cli.apply { if cli.json { println!("{}", serde_json::to_string_pretty(&plan)?); } else { print_plan(&plan); } return if cli.check_inputs && plan.missing_count > 0 { Ok(2) } else { Ok(0) }; } if plan.missing_count > 0 { if cli.json { println!("{}", serde_json::to_string_pretty(&plan)?); } else { print_plan(&plan); } bail!("refusing --apply because required inputs are missing"); } let report = apply_plan(plan, env_values)?; if cli.json { println!("{}", serde_json::to_string_pretty(&report)?); } else { print_apply_report(&report); } Ok(if report.results.iter().all(|item| item.status == "ok") { 0 } else { 1 }) } fn absolute_path(root: &Path, path: &Path) -> PathBuf { if path.is_absolute() { path.to_path_buf() } else { root.join(path) } } fn build_plan( root: &Path, env_file: &Path, env_values: &HashMap, inventory: &Path, log_dir: &Path, apply: bool, skip_quality_gate: bool, ) -> 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 = ["git", "ansible", "ansible-playbook"] .into_iter() .map(|name| { let ok = command_exists(name); Requirement { name: name.to_string(), ok, detail: if ok { "found in PATH".to_string() } else { "missing in PATH".to_string() }, } }) .collect::>(); let quality_gate = root.join("scripts/quality-gate.sh"); let deploy_aw_server = root.join("ansible/deploy_aw_server.yml"); let deploy_aw_windows = root.join("ansible/deploy_aw_windows.yml"); let post_validate_aw_windows = root.join("ansible/post_validate_aw_windows.yml"); let required_files = [ ("inventory", inventory), ("scripts/quality-gate.sh", &quality_gate), ("ansible/deploy_aw_server.yml", &deploy_aw_server), ("ansible/deploy_aw_windows.yml", &deploy_aw_windows), ( "ansible/post_validate_aw_windows.yml", &post_validate_aw_windows, ), ] .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(); if !skip_quality_gate { push_step( &mut steps, "quality-gate", vec!["./scripts/quality-gate.sh".to_string()], "quality-gate.log", false, ); } push_step( &mut steps, "ping-aw-server", vec![ "ansible".to_string(), "-i".to_string(), "ansible/inventory.ini".to_string(), "aw_server".to_string(), "-m".to_string(), "ping".to_string(), ], "ping_aw_server.log", false, ); push_step( &mut steps, "ping-aw-windows", vec![ "ansible".to_string(), "-i".to_string(), "ansible/inventory.ini".to_string(), "aw_windows".to_string(), "-m".to_string(), "win_ping".to_string(), ], "ping_aw_windows.log", false, ); push_step( &mut steps, "check-aw-server", vec![ "ansible-playbook".to_string(), "-i".to_string(), "ansible/inventory.ini".to_string(), "ansible/deploy_aw_server.yml".to_string(), "--check".to_string(), "--diff".to_string(), ], "check_aw_server.log", false, ); push_step( &mut steps, "deploy-aw-server", vec![ "ansible-playbook".to_string(), "-i".to_string(), "ansible/inventory.ini".to_string(), "ansible/deploy_aw_server.yml".to_string(), ], "deploy_aw_server.log", true, ); push_step( &mut steps, "check-aw-windows", vec![ "ansible-playbook".to_string(), "-i".to_string(), "ansible/inventory.ini".to_string(), "ansible/deploy_aw_windows.yml".to_string(), "--check".to_string(), "--diff".to_string(), ], "check_aw_windows.log", false, ); push_step( &mut steps, "deploy-aw-windows", vec![ "ansible-playbook".to_string(), "-i".to_string(), "ansible/inventory.ini".to_string(), "ansible/deploy_aw_windows.yml".to_string(), ], "deploy_aw_windows.log", true, ); push_step( &mut steps, "post-validate-aw-windows", vec![ "ansible-playbook".to_string(), "-i".to_string(), "ansible/inventory.ini".to_string(), "ansible/post_validate_aw_windows.yml".to_string(), ], "post_validate_aw_windows.log", false, ); let missing_count = required_env .iter() .chain(required_commands.iter()) .chain(required_files.iter()) .filter(|item| !item.ok) .count(); Plan { mode: if apply { "apply" } else { "plan-only" }, root: root.display().to_string(), branch: git_branch(root).unwrap_or_else(|| "unknown".to_string()), env_file: env_file.display().to_string(), inventory: inventory.display().to_string(), log_dir: log_dir.display().to_string(), required_env, required_commands, required_files, steps, missing_count, } } fn push_step( steps: &mut Vec, name: &str, command: Vec, log_file: &str, mutation: bool, ) { steps.push(Step { order: steps.len() + 1, name: name.to_string(), command, log_file: log_file.to_string(), mutation, }); } fn apply_plan(plan: Plan, env_values: HashMap) -> Result { let log_dir = PathBuf::from(&plan.log_dir); fs::create_dir_all(&log_dir).with_context(|| format!("create {}", log_dir.display()))?; let mut results = Vec::new(); for step in &plan.steps { log_line(&log_dir, &format!("START {} {}", step.order, step.name))?; let result = run_step(&plan, step, &env_values)?; log_line( &log_dir, &format!( "END {} {} status={} exit={:?}", step.order, step.name, result.status, result.exit_code ), )?; let ok = result.status == "ok"; results.push(result); if !ok { break; } } Ok(ApplyReport { plan, results }) } fn run_step(plan: &Plan, step: &Step, env_values: &HashMap) -> Result { let Some(program) = step.command.first() else { bail!("empty command for step {}", step.name); }; let args = &step.command[1..]; let output = Command::new(program) .args(args) .current_dir(&plan.root) .envs(env_values) .output() .with_context(|| format!("run {}", shell_join(&step.command)))?; let log_file = PathBuf::from(&plan.log_dir).join(&step.log_file); let mut file = OpenOptions::new() .create(true) .append(true) .open(&log_file) .with_context(|| format!("open {}", log_file.display()))?; file.write_all(&output.stdout)?; file.write_all(&output.stderr)?; std::io::stdout().write_all(&output.stdout)?; std::io::stderr().write_all(&output.stderr)?; Ok(StepResult { order: step.order, name: step.name.clone(), status: if output.status.success() { "ok" } else { "failed" }, exit_code: output.status.code(), log_file: log_file.display().to_string(), }) } fn print_plan(plan: &Plan) { println!("prod-rollout: {}", plan.mode); println!("root: {}", plan.root); println!("branch: {}", plan.branch); println!("inventory: {}", plan.inventory); println!("log_dir: {}", plan.log_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:"); for step in &plan.steps { let risk = if step.mutation { "MUTATION" } else { "check" }; println!( " {:02}. {:<24} {:<8} {}", step.order, step.name, risk, shell_join(&step.command) ); } if plan.mode == "plan-only" { println!(); println!("No steps executed. Use --apply for explicit rollout execution."); } } fn print_apply_report(report: &ApplyReport) { println!("prod-rollout apply result:"); println!("log_dir: {}", report.plan.log_dir); for item in &report.results { println!( " {:02}. {:<24} {:<6} exit={:?} log={}", item.order, item.name, item.status, item.exit_code, item.log_file ); } } fn log_line(log_dir: &Path, line: &str) -> Result<()> { fs::create_dir_all(log_dir).with_context(|| format!("create {}", log_dir.display()))?; let path = log_dir.join("rollout.log"); let mut file = OpenOptions::new() .create(true) .append(true) .open(&path) .with_context(|| format!("open {}", path.display()))?; writeln!( file, "{} {}", Local::now().format("%Y-%m-%d %H:%M:%S"), line )?; Ok(()) } 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() { continue; } let key = key.strip_prefix("export ").unwrap_or(key).trim(); if key.is_empty() { 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) } fn git_branch(root: &Path) -> Option { let output = Command::new("git") .args(["branch", "--show-current"]) .current_dir(root) .output() .ok()?; if !output.status.success() { return None; } let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); if branch.is_empty() { None } else { Some(branch) } } fn shell_join(command: &[String]) -> String { command .iter() .map(|part| { if part .chars() .all(|ch| ch.is_ascii_alphanumeric() || "-_./:=,".contains(ch)) { part.clone() } else { format!("'{}'", part.replace('\'', "'\\''")) } }) .collect::>() .join(" ") } #[cfg(test)] mod tests { use super::*; #[test] fn reads_exported_env_and_strips_quotes() { let dir = tempfile::tempdir().unwrap(); let env = dir.path().join("runtime.env"); fs::write( &env, "export AW_SSH_PASSWORD='one'\nAW_WINRM_PASSWORD=\"two\"\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_rollout_mutations() { let dir = tempfile::tempdir().unwrap(); create_file(dir.path().join("scripts/quality-gate.sh")); create_file(dir.path().join("ansible/inventory.ini")); create_file(dir.path().join("ansible/deploy_aw_server.yml")); create_file(dir.path().join("ansible/deploy_aw_windows.yml")); create_file(dir.path().join("ansible/post_validate_aw_windows.yml")); 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, &dir.path().join("ansible/inventory.ini"), &dir.path().join(".rollout-logs/test"), false, false, ); assert!( plan.steps .iter() .any(|step| step.name == "deploy-aw-server") ); assert!( plan.steps .iter() .any(|step| step.name == "deploy-aw-windows") ); assert!( plan.steps .iter() .filter(|step| step.mutation) .all(|step| step.name.starts_with("deploy-")) ); assert_eq!(plan.required_env.iter().filter(|item| item.ok).count(), 2); } #[test] fn skip_quality_gate_removes_first_step() { let dir = tempfile::tempdir().unwrap(); create_file(dir.path().join("scripts/quality-gate.sh")); create_file(dir.path().join("ansible/inventory.ini")); create_file(dir.path().join("ansible/deploy_aw_server.yml")); create_file(dir.path().join("ansible/deploy_aw_windows.yml")); create_file(dir.path().join("ansible/post_validate_aw_windows.yml")); let plan = build_plan( dir.path(), &dir.path().join("private-config/runtime.env"), &HashMap::new(), &dir.path().join("ansible/inventory.ini"), &dir.path().join(".rollout-logs/test"), false, true, ); assert_ne!(plan.steps.first().unwrap().name, "quality-gate"); } fn create_file(path: PathBuf) { fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, "").unwrap(); } }