diff --git a/products/06-runbook-automation/agent/src/main.rs b/products/06-runbook-automation/agent/src/main.rs index a2b1391..e9b2f17 100644 --- a/products/06-runbook-automation/agent/src/main.rs +++ b/products/06-runbook-automation/agent/src/main.rs @@ -1,5 +1,6 @@ use clap::{Parser, Subcommand}; use tracing::info; +use std::collections::HashMap; pub mod parser; pub mod classifier; @@ -32,25 +33,25 @@ enum Commands { /// Dry run (classify only, don't execute) #[arg(long)] dry_run: bool, + + /// Variable overrides (key=value) + #[arg(short, long, value_parser = parse_var)] + var: Vec<(String, String)>, }, /// Classify a single command Classify { /// Command to classify command: String, }, - /// Verify agent binary signature - Verify { - /// Path to signature file - #[arg(short, long)] - sig: String, - /// Path to public key - #[arg(short, long)] - pubkey: String, - }, /// Print version Version, } +fn parse_var(s: &str) -> Result<(String, String), String> { + let pos = s.find('=').ok_or_else(|| format!("invalid KEY=VALUE: no `=` found in `{s}`"))?; + Ok((s[..pos].to_string(), s[pos + 1..].to_string())) +} + #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() @@ -64,18 +65,143 @@ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); match cli.command { - Commands::Run { runbook, endpoint, api_key, dry_run } => { + Commands::Run { runbook, endpoint, api_key, dry_run, var } => { info!(runbook = %runbook, dry_run, "Starting runbook execution"); - // TODO: Parse runbook → classify steps → execute with approval gates + + // Parse runbook YAML + let content = std::fs::read_to_string(&runbook) + .map_err(|e| anyhow::anyhow!("Failed to read runbook '{}': {}", runbook, e))?; + let rb = parser::parse_yaml(&content)?; + + info!(name = %rb.name, steps = rb.steps.len(), "Runbook parsed"); + + // Build variable map from CLI args + let variables: HashMap = var.into_iter().collect(); + + // Validate required variables + for (name, spec) in &rb.variables { + if spec.required && !variables.contains_key(name) && spec.default.is_none() { + anyhow::bail!("Required variable '{}' not provided (use --var {}=VALUE)", name, name); + } + } + + // Merge defaults + let mut merged_vars = HashMap::new(); + for (name, spec) in &rb.variables { + if let Some(val) = variables.get(name) { + merged_vars.insert(name.clone(), val.clone()); + } else if let Some(default) = &spec.default { + merged_vars.insert(name.clone(), default.clone()); + } + } + + // Auto-approve callback for read-only commands; reject everything else in dry-run + let approval_callback = |cmd: &str, safety: classifier::SafetyLevel| -> bool { + match safety { + classifier::SafetyLevel::ReadOnly => { + info!(command = %cmd, "Auto-approved (read-only)"); + true + } + _ if dry_run => { + info!(command = %cmd, safety = ?safety, "Would require approval (dry run)"); + false + } + _ => { + // In real mode, this would wait for SaaS approval via Redis pub/sub + // For now, prompt on stdin + eprintln!("\n⚠️ Command requires approval ({:?}):", safety); + eprintln!(" {}", cmd); + eprint!(" Approve? [y/N] "); + let mut input = String::new(); + std::io::stdin().read_line(&mut input).unwrap_or(0); + input.trim().eq_ignore_ascii_case("y") + } + } + }; + + // Execute steps + let mut failed = false; + for step in &rb.steps { + let command = parser::substitute_variables(&step.command, &merged_vars)?; + let classification = classifier::classify(&command); + + info!( + step = step.index, + command = %command, + safety = ?classification.safety, + description = %step.description, + "Executing step" + ); + + let result = executor::execute_step(&command, dry_run, &approval_callback).await; + + match result.status { + executor::StepStatus::Success => { + info!(step = step.index, exit_code = result.exit_code, duration_ms = result.duration_ms, "Step completed"); + } + executor::StepStatus::Skipped => { + info!(step = step.index, "Step skipped (dry run)"); + } + executor::StepStatus::Rejected => { + info!(step = step.index, "Step rejected"); + match step.on_failure { + parser::FailureAction::Abort => { + eprintln!("Step {} rejected — aborting runbook", step.index); + failed = true; + break; + } + parser::FailureAction::Continue => { + eprintln!("Step {} rejected — continuing", step.index); + } + parser::FailureAction::Retry { .. } => { + eprintln!("Step {} rejected — cannot retry rejection, aborting", step.index); + failed = true; + break; + } + } + } + executor::StepStatus::Failed | executor::StepStatus::TimedOut => { + eprintln!("Step {} failed (exit code {})", step.index, result.exit_code); + if !result.stderr.is_empty() { + eprintln!(" stderr: {}", result.stderr.trim()); + } + match &step.on_failure { + parser::FailureAction::Abort => { + failed = true; + break; + } + parser::FailureAction::Continue => {} + parser::FailureAction::Retry { max_attempts } => { + let mut retried = false; + for attempt in 1..=*max_attempts { + info!(step = step.index, attempt, "Retrying"); + let retry = executor::execute_step(&command, dry_run, &approval_callback).await; + if matches!(retry.status, executor::StepStatus::Success) { + retried = true; + break; + } + } + if !retried { + eprintln!("Step {} failed after {} retries — aborting", step.index, max_attempts); + failed = true; + break; + } + } + } + } + _ => {} + } + } + + if failed { + std::process::exit(1); + } + info!("Runbook '{}' completed successfully", rb.name); } Commands::Classify { command } => { let result = classifier::classify(&command); println!("{}", serde_json::to_string_pretty(&result)?); } - Commands::Verify { sig, pubkey } => { - // TODO: Ed25519 signature verification - println!("Signature verification not yet implemented"); - } Commands::Version => { println!("dd0c/run agent v{}", env!("CARGO_PKG_VERSION")); }