From 869cc610b5c97ca8f8deab9174a4fde27dca7dec3f705ef5549d24332689a9ce Mon Sep 17 00:00:00 2001 From: Tyler J King Date: Sun, 12 Apr 2026 06:53:26 -0400 Subject: [PATCH] refactor(playbook_commands): migrate Chronicle emission to CloudEvents Replace fake Forgejo push webhook construction with structured CloudEvents 1.0 via ChronicleClient. All playbook governance events now carry structured data fields (playbook name, corpus CID, exit code, duration) instead of unstructured message strings. Event renames: - PLAYBOOK_STARTED -> GOV_PLAYBOOK_STARTED - PLAYBOOK_COMPLETED -> GOV_PLAYBOOK_COMPLETED - ACCORD_LOAD_FAILED -> GOV_ACCORD_LOAD_FAILED - DIFF_MISMATCH_DETECTED -> GOV_DIFF_MISMATCH_DETECTED Signed-off-by: Tyler King --- org-ops-core/src/playbook_commands.rs | 91 ++++++++++++++++++--------- 1 file changed, 61 insertions(+), 30 deletions(-) diff --git a/org-ops-core/src/playbook_commands.rs b/org-ops-core/src/playbook_commands.rs index 69ad3e6..9bc2038 100644 --- a/org-ops-core/src/playbook_commands.rs +++ b/org-ops-core/src/playbook_commands.rs @@ -3,7 +3,8 @@ //! guildhouse-ops playbook run — validates corpus, runs ansible-playbook, Chronicle //! guildhouse-ops playbook list — lists governed playbooks -use crate::gsap_client::{self, AuthorizeRequest, GsapClient, CompletionReceiptPayload}; +use crate::chronicle_client::ChronicleClient; +use crate::gsap_client::{self, AuthorizeRequest, GsapClient}; use crate::session::SessionContext; use crate::traits::OrgCommands; use std::collections::HashMap; @@ -43,21 +44,8 @@ impl PlaybookCommands { 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 chronicle(&self) -> ChronicleClient { + ChronicleClient::from_legacy_webhook(&self.chronicle_webhook) } fn cmd_run( @@ -103,10 +91,18 @@ impl PlaybookCommands { 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", + let chronicle = self.chronicle(); + chronicle.emit( + "GOV_PLAYBOOK_STARTED", &actor_did, - &format!("{} cid={} target={}", name, pb_cid, target.unwrap_or("all")), + &ChronicleClient::generate_id(), + serde_json::json!({ + "kind": "GOV_PLAYBOOK_STARTED", + "description": format!("{} cid={} target={}", name, pb_cid, target.unwrap_or("all")), + "playbook": name, + "corpus_entry_cid": pb_cid, + "target": target.unwrap_or("all"), + }), ); // Helper to build ansible-playbook command @@ -132,10 +128,17 @@ impl PlaybookCommands { Ok(policy) => policy, Err(e) => { eprintln!("\n BLOCKED: Accord load failed: {}", e); - self.emit_chronicle( - "ACCORD_LOAD_FAILED", + chronicle.emit( + "GOV_ACCORD_LOAD_FAILED", &actor_did, - &format!("{} accord={} error={}", name, accord_name, e), + &ChronicleClient::generate_id(), + serde_json::json!({ + "kind": "GOV_ACCORD_LOAD_FAILED", + "description": format!("{} accord={} error={}", name, accord_name, e), + "playbook": name, + "accord": accord_name, + "error": e.to_string(), + }), ); anyhow::bail!( "Accord '{}' could not be loaded. Governed operation blocked. {}", @@ -198,7 +201,17 @@ impl PlaybookCommands { if dry_run { print!("{}", diff); println!("\n [--check] Dry run complete."); - self.emit_chronicle("PLAYBOOK_COMPLETED", &actor_did, &format!("{} dry_run=true", name)); + chronicle.emit( + "GOV_PLAYBOOK_COMPLETED", + &actor_did, + &ChronicleClient::generate_id(), + serde_json::json!({ + "kind": "GOV_PLAYBOOK_COMPLETED", + "description": format!("{} dry_run=true", name), + "playbook": name, + "dry_run": true, + }), + ); return Ok(()); } @@ -215,8 +228,18 @@ impl PlaybookCommands { 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])); + chronicle.emit( + "GOV_DIFF_MISMATCH_DETECTED", + &actor_did, + &ChronicleClient::generate_id(), + serde_json::json!({ + "kind": "GOV_DIFF_MISMATCH_DETECTED", + "description": format!("{} signed={} actual={}", name, &diff_hash[..24], &recheck_hash[..24]), + "playbook": name, + "signed_hash": &diff_hash[..24], + "actual_hash": &recheck_hash[..24], + }), + ); anyhow::bail!("Apply blocked: diff changed after MFA."); } } else if !diff.trim().is_empty() { @@ -230,11 +253,19 @@ impl PlaybookCommands { let duration = start.elapsed(); let rc = status.code().unwrap_or(-1); - // Chronicle: PLAYBOOK_COMPLETED - self.emit_chronicle( - "PLAYBOOK_COMPLETED", + // Chronicle: GOV_PLAYBOOK_COMPLETED + chronicle.emit( + "GOV_PLAYBOOK_COMPLETED", &actor_did, - &format!("{} rc={} duration={}s", name, rc, duration.as_secs()), + &ChronicleClient::generate_id(), + serde_json::json!({ + "kind": "GOV_PLAYBOOK_COMPLETED", + "description": format!("{} rc={} duration={}s", name, rc, duration.as_secs()), + "playbook": name, + "exit_code": rc, + "duration_secs": duration.as_secs(), + "success": status.success(), + }), ); // ── GSAP: Post Completion Receipt ───────────── @@ -257,7 +288,7 @@ impl PlaybookCommands { if status.success() { println!("\nPlaybook complete. ({:.1}s)", duration.as_secs_f64()); - println!("Chronicle: PLAYBOOK_COMPLETED recorded"); + println!("Chronicle: GOV_PLAYBOOK_COMPLETED recorded"); } else { anyhow::bail!("ansible-playbook exited with code {}", rc); }