663 lines
18 KiB
Rust
663 lines
18 KiB
Rust
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<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
struct Requirement {
|
|
name: String,
|
|
ok: bool,
|
|
detail: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
struct Step {
|
|
order: usize,
|
|
name: String,
|
|
command: Vec<String>,
|
|
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<Requirement>,
|
|
required_commands: Vec<Requirement>,
|
|
required_files: Vec<Requirement>,
|
|
steps: Vec<Step>,
|
|
missing_count: usize,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct StepResult {
|
|
order: usize,
|
|
name: String,
|
|
status: &'static str,
|
|
exit_code: Option<i32>,
|
|
log_file: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ApplyReport {
|
|
plan: Plan,
|
|
results: Vec<StepResult>,
|
|
}
|
|
|
|
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();
|
|
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<String, String>,
|
|
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::<Vec<_>>();
|
|
|
|
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::<Vec<_>>();
|
|
|
|
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::<Vec<_>>();
|
|
|
|
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<Step>,
|
|
name: &str,
|
|
command: Vec<String>,
|
|
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<String, String>) -> Result<ApplyReport> {
|
|
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<String, String>) -> Result<StepResult> {
|
|
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<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() {
|
|
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<String> {
|
|
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::<Vec<_>>()
|
|
.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();
|
|
}
|
|
}
|