The governed playbook runner now: 1. Requests an AC from the GSAP broker before execution 2. Validates corpus CID + parameters CID + single-use 3. Executes the ansible playbook (unchanged) 4. Posts a Completion Receipt to the broker after execution Environment variables: GSAP_BROKER_URL — Capstone broker endpoint GSAP_BEARER_TOKEN — JWT for broker auth GSAP_DRIVER_ID — identity driver (default: keycloak-guildhouse) GSAP_ACCORD_TEMPLATE — accord template (default: from GUILDHOUSE_ACCORD) GSAP_SESSION_DIR — local session state directory Self-authorized mode: If GSAP_BROKER_URL not set, execution proceeds without AC/CR. Valid for development (GSAP §1.3). Not for production. Error handling: ElevationRequired → shows activation instructions, aborts Denied → shows reason, aborts CorpusMismatch → shows CID diff, aborts CR delivery failure → stores locally, warns, does not abort 4/4 gsap_client unit tests passing. Build clean with zero errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
322 lines
13 KiB
Rust
322 lines
13 KiB
Rust
//! Governed Ansible playbook execution.
|
|
//!
|
|
//! guildhouse-ops playbook run <name> — validates corpus, runs ansible-playbook, Chronicle
|
|
//! guildhouse-ops playbook list — lists governed playbooks
|
|
|
|
use crate::gsap_client::{self, AuthorizeRequest, GsapClient, CompletionReceiptPayload};
|
|
use crate::session::SessionContext;
|
|
use crate::traits::OrgCommands;
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
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<serde_json::Value> {
|
|
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<String> {
|
|
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<String, String>,
|
|
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
|
|
);
|
|
}
|
|
};
|
|
|
|
// ── GSAP: Request Authorization Context ──────────
|
|
let gsap_ac = if let Ok(broker_url) = std::env::var("GSAP_BROKER_URL") {
|
|
let token = std::env::var("GSAP_BEARER_TOKEN").unwrap_or_default();
|
|
let driver_id = std::env::var("GSAP_DRIVER_ID")
|
|
.unwrap_or_else(|_| "keycloak-guildhouse".into());
|
|
let accord = std::env::var("GSAP_ACCORD_TEMPLATE")
|
|
.unwrap_or_else(|_| accord_name.clone());
|
|
let session_dir = std::env::var("GSAP_SESSION_DIR")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|_| std::env::temp_dir().join("bxnet-gsap"));
|
|
|
|
let client = GsapClient::new(broker_url, token, session_dir);
|
|
let params_json = serde_json::to_string(&extra_vars).unwrap_or_default();
|
|
let params_cid = gsap_client::compute_cid(params_json.as_bytes());
|
|
|
|
let request = AuthorizeRequest {
|
|
playbook: name.to_string(),
|
|
corpus_entry_cid: pb_cid.to_string(),
|
|
parameters_cid: params_cid.clone(),
|
|
accord_template: accord,
|
|
driver_id,
|
|
};
|
|
|
|
match client.authorize(&request, pb_cid, ¶ms_cid) {
|
|
Ok(ac) => {
|
|
println!("\x1b[32m✓ GSAP: Operation authorized\x1b[0m");
|
|
println!(" AC: {}...", &ac.context_id[..8]);
|
|
println!(" Principal: {}", ac.principal.did);
|
|
println!(" Accord: {}", ac.accord.template);
|
|
Some((client, ac))
|
|
}
|
|
Err(e) => {
|
|
let msg = format!("{}", e);
|
|
if msg.contains("Elevation required") {
|
|
eprintln!("\n\x1b[33m{}\x1b[0m", msg);
|
|
anyhow::bail!("Elevation required. See instructions above.");
|
|
}
|
|
eprintln!("\x1b[31mGSAP authorization failed: {}\x1b[0m", msg);
|
|
anyhow::bail!("GSAP: {}", msg);
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// 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()),
|
|
);
|
|
|
|
// ── GSAP: Post Completion Receipt ─────────────
|
|
if let Some((client, ref ac)) = gsap_ac {
|
|
let outcome = if status.success() { "completed" } else { "failed" };
|
|
let chronicle_session = std::env::var("CHRONICLE_SESSION_ID").unwrap_or_default();
|
|
|
|
match client.complete(ac, outcome, &chronicle_session) {
|
|
Ok(_) => {
|
|
println!("\x1b[32m✓ GSAP: Completion receipt posted\x1b[0m");
|
|
println!(" Session: /api/v1/governance/session/{}/", ac.context_id);
|
|
}
|
|
Err(e) => {
|
|
// CR delivery failure is recoverable (GSAP R-29).
|
|
// The operation already completed.
|
|
eprintln!("\x1b[33mGSAP: CR delivery failed (stored locally): {}\x1b[0m", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
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<clap::Command> {
|
|
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::<String>("name").unwrap();
|
|
let target = sub.get_one::<String>("target").map(|s| s.as_str());
|
|
let check = sub.get_flag("check");
|
|
let vars: HashMap<String, String> = sub
|
|
.get_many::<String>("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 <run|list>");
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
}
|