//! Governed Ansible playbook execution. //! //! guildhouse-ops playbook run — validates corpus, runs ansible-playbook, Chronicle //! guildhouse-ops playbook list — lists governed playbooks use crate::session::SessionContext; use crate::traits::OrgCommands; use std::collections::HashMap; use std::process::Command; pub struct PlaybookCommands { pub playbook_base: String, pub chronicle_webhook: String, } impl PlaybookCommands { pub fn new(playbook_base: &str, chronicle_webhook: &str) -> Self { Self { playbook_base: playbook_base.into(), chronicle_webhook: chronicle_webhook.into(), } } fn get_corpus_entry(name: &str) -> Option { let out = Command::new("kubectl") .args(["get", "corpusentry", name, "-o", "json"]) .output() .ok()?; if !out.status.success() { return None; } serde_json::from_slice(&out.stdout).ok() } fn find_playbook(&self, name: &str) -> Option { let out = Command::new("find") .args([&self.playbook_base, "-name", &format!("{}.yml", name)]) .output() .ok()?; let path = String::from_utf8_lossy(&out.stdout).trim().lines().next()?.to_string(); if path.is_empty() { None } else { Some(path) } } fn emit_chronicle(&self, kind: &str, actor_did: &str, message: &str) -> bool { let body = serde_json::json!({ "pusher": {"login": actor_did}, "ref": format!("refs/playbook/{}", kind), "repository": {"full_name": "platform/ansible-governance"}, "commits": [{"message": format!("{}: {}", kind, message)}], }); reqwest::blocking::Client::new() .post(&self.chronicle_webhook) .header("X-Forgejo-Event", "push") .json(&body) .timeout(std::time::Duration::from_secs(5)) .send() .map(|r| r.status().is_success()) .unwrap_or(false) } fn cmd_run( &self, name: &str, target: Option<&str>, extra_vars: &HashMap, dry_run: bool, ctx: &SessionContext, ) -> anyhow::Result<()> { println!("-- Playbook governance check --"); println!(" Playbook: {}", name); // Find playbook file let path = self.find_playbook(name).ok_or_else(|| { anyhow::anyhow!("Playbook '{}' not found under {}", name, self.playbook_base) })?; println!(" Path: {}", path); // Validate corpus entry let entry = Self::get_corpus_entry(name) .ok_or_else(|| anyhow::anyhow!("No CorpusEntry for '{}'. Create one first.", name))?; let rs = entry.get("status").and_then(|s| s.get("riskScore")).cloned().unwrap_or_default(); let composite = rs.get("composite").and_then(|v| v.as_u64()).unwrap_or(0) as u8; let ceiling = rs.get("capabilityCeiling").and_then(|v| v.as_str()).unwrap_or("CAP_READ"); let triad = rs.get("bomTriadComplete").and_then(|v| v.as_bool()).unwrap_or(false); println!(" Score: {}/100 {} triad={}", composite, ceiling, if triad { "Y" } else { "N" }); if composite < 70 { println!("\n BLOCKED: score {} < 70 (CAP_MUTATE required)", composite); anyhow::bail!("Playbook blocked by governance check."); } println!(" Governance check passed."); if dry_run { println!(" [--check] Dry run mode."); } println!("--\n"); // Chronicle: PLAYBOOK_STARTED let actor_did = format!("did:web:{}/user/operator", ctx.trust_domain); let pb_cid = entry.get("spec").and_then(|s| s.get("cid")).and_then(|v| v.as_str()).unwrap_or("?"); self.emit_chronicle( "PLAYBOOK_STARTED", &actor_did, &format!("{} cid={} target={}", name, pb_cid, target.unwrap_or("all")), ); // Helper to build ansible-playbook command let build_cmd = |check_mode: bool| { let mut c = Command::new("ansible-playbook"); c.arg(&path); if let Some(hosts) = target { c.arg("--limit").arg(hosts); } for (k, v) in extra_vars { c.arg("--extra-vars").arg(format!("{}={}", k, v)); } if check_mode || dry_run { c.arg("--check").arg("--diff"); } c.env("GUILDHOUSE_DID", &actor_did); c }; // Load accord MFA policy — fail-closed on any error. let accord_name = std::env::var("GUILDHOUSE_ACCORD").unwrap_or("dev-operations".into()); let mfa_policy = match crate::apply_gate::AccordMfaPolicy::from_accord(&accord_name) { Ok(policy) => policy, Err(e) => { eprintln!("\n BLOCKED: Accord load failed: {}", e); self.emit_chronicle( "ACCORD_LOAD_FAILED", &actor_did, &format!("{} accord={} error={}", name, accord_name, e), ); anyhow::bail!( "Accord '{}' could not be loaded. Governed operation blocked. {}", accord_name, e ); } }; // Phase 1: --check to generate diff println!("Phase 1: Generating diff (--check)..."); let check_output = build_cmd(true).output()?; let diff = String::from_utf8_lossy(&check_output.stdout).to_string(); if dry_run { print!("{}", diff); println!("\n [--check] Dry run complete."); self.emit_chronicle("PLAYBOOK_COMPLETED", &actor_did, &format!("{} dry_run=true", name)); return Ok(()); } // Phase 2: Apply gate (if MFA required) if mfa_policy.mfa_required { println!("\n Accord: {} (MFA: {})", accord_name, mfa_policy.mfa_method); let diff_hash = crate::apply_gate::run_apply_gate( &diff, &mfa_policy, &actor_did, &self.chronicle_webhook, )?; // Re-verify diff hasn't changed (TOCTOU prevention) let recheck = build_cmd(true).output()?; let recheck_diff = String::from_utf8_lossy(&recheck.stdout).to_string(); let recheck_hash = crate::apply_gate::hash_diff(&recheck_diff); if recheck_hash != diff_hash { eprintln!("\n BLOCKED: Diff changed since MFA sign-off!"); self.emit_chronicle("DIFF_MISMATCH_DETECTED", &actor_did, &format!("{} signed={} actual={}", name, &diff_hash[..24], &recheck_hash[..24])); anyhow::bail!("Apply blocked: diff changed after MFA."); } } else if !diff.trim().is_empty() { println!("{}", diff); } // Phase 3: Apply println!("\nApplying changes..."); let start = std::time::Instant::now(); let status = build_cmd(false).status()?; let duration = start.elapsed(); let rc = status.code().unwrap_or(-1); // Chronicle: PLAYBOOK_COMPLETED self.emit_chronicle( "PLAYBOOK_COMPLETED", &actor_did, &format!("{} rc={} duration={}s", name, rc, duration.as_secs()), ); if status.success() { println!("\nPlaybook complete. ({:.1}s)", duration.as_secs_f64()); println!("Chronicle: PLAYBOOK_COMPLETED recorded"); } else { anyhow::bail!("ansible-playbook exited with code {}", rc); } Ok(()) } fn cmd_list(&self, _ctx: &SessionContext) -> anyhow::Result<()> { let out = Command::new("kubectl") .args([ "get", "corpusentry", "-l", "substrate.io/playbook-type=ansible", "-o", "custom-columns=NAME:.metadata.name,SCORE:.status.riskScore.composite,CEILING:.status.riskScore.capabilityCeiling,TRIAD:.status.riskScore.bomTriadComplete,OS:.metadata.labels.substrate\\.io/target-os", ]) .output()?; println!("{}", String::from_utf8_lossy(&out.stdout)); Ok(()) } } impl OrgCommands for PlaybookCommands { fn commands(&self) -> Vec { use clap::{Arg, ArgAction, Command}; vec![Command::new("playbook") .about("Governed Ansible playbook execution") .subcommand( Command::new("run") .about("Run governed playbook (corpus-validated + Chronicle)") .arg(Arg::new("name").required(true)) .arg(Arg::new("target").long("target").short('t')) .arg(Arg::new("var").long("var").short('e').action(ArgAction::Append)) .arg(Arg::new("check").long("check").action(ArgAction::SetTrue)), ) .subcommand(Command::new("list").about("List governed playbooks"))] } fn handles(&self, name: &str) -> bool { name == "playbook" } fn handle(&self, _name: &str, matches: &clap::ArgMatches, ctx: &SessionContext) -> anyhow::Result<()> { match matches.subcommand() { Some(("run", sub)) => { let name = sub.get_one::("name").unwrap(); let target = sub.get_one::("target").map(|s| s.as_str()); let check = sub.get_flag("check"); let vars: HashMap = sub .get_many::("var") .unwrap_or_default() .filter_map(|v| { let mut p = v.splitn(2, '='); Some((p.next()?.to_string(), p.next()?.to_string())) }) .collect(); self.cmd_run(name, target, &vars, check, ctx) } Some(("list", _)) => self.cmd_list(ctx), _ => { println!("Usage: playbook "); Ok(()) } } } }