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 <tking@guildhouse.dev>
This commit is contained in:
Tyler J King 2026-04-12 06:53:26 -04:00
parent 92464b07c5
commit 869cc610b5

View file

@ -3,7 +3,8 @@
//! guildhouse-ops playbook run <name> — validates corpus, runs ansible-playbook, Chronicle //! guildhouse-ops playbook run <name> — validates corpus, runs ansible-playbook, Chronicle
//! guildhouse-ops playbook list — lists governed playbooks //! 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::session::SessionContext;
use crate::traits::OrgCommands; use crate::traits::OrgCommands;
use std::collections::HashMap; use std::collections::HashMap;
@ -43,21 +44,8 @@ impl PlaybookCommands {
if path.is_empty() { None } else { Some(path) } if path.is_empty() { None } else { Some(path) }
} }
fn emit_chronicle(&self, kind: &str, actor_did: &str, message: &str) -> bool { fn chronicle(&self) -> ChronicleClient {
let body = serde_json::json!({ ChronicleClient::from_legacy_webhook(&self.chronicle_webhook)
"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( fn cmd_run(
@ -103,10 +91,18 @@ impl PlaybookCommands {
let actor_did = format!("did:web:{}/user/operator", ctx.trust_domain); 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("?"); let pb_cid = entry.get("spec").and_then(|s| s.get("cid")).and_then(|v| v.as_str()).unwrap_or("?");
self.emit_chronicle( let chronicle = self.chronicle();
"PLAYBOOK_STARTED", chronicle.emit(
"GOV_PLAYBOOK_STARTED",
&actor_did, &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 // Helper to build ansible-playbook command
@ -132,10 +128,17 @@ impl PlaybookCommands {
Ok(policy) => policy, Ok(policy) => policy,
Err(e) => { Err(e) => {
eprintln!("\n BLOCKED: Accord load failed: {}", e); eprintln!("\n BLOCKED: Accord load failed: {}", e);
self.emit_chronicle( chronicle.emit(
"ACCORD_LOAD_FAILED", "GOV_ACCORD_LOAD_FAILED",
&actor_did, &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!( anyhow::bail!(
"Accord '{}' could not be loaded. Governed operation blocked. {}", "Accord '{}' could not be loaded. Governed operation blocked. {}",
@ -198,7 +201,17 @@ impl PlaybookCommands {
if dry_run { if dry_run {
print!("{}", diff); print!("{}", diff);
println!("\n [--check] Dry run complete."); 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(()); return Ok(());
} }
@ -215,8 +228,18 @@ impl PlaybookCommands {
let recheck_hash = crate::apply_gate::hash_diff(&recheck_diff); let recheck_hash = crate::apply_gate::hash_diff(&recheck_diff);
if recheck_hash != diff_hash { if recheck_hash != diff_hash {
eprintln!("\n BLOCKED: Diff changed since MFA sign-off!"); eprintln!("\n BLOCKED: Diff changed since MFA sign-off!");
self.emit_chronicle("DIFF_MISMATCH_DETECTED", &actor_did, chronicle.emit(
&format!("{} signed={} actual={}", name, &diff_hash[..24], &recheck_hash[..24])); "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."); anyhow::bail!("Apply blocked: diff changed after MFA.");
} }
} else if !diff.trim().is_empty() { } else if !diff.trim().is_empty() {
@ -230,11 +253,19 @@ impl PlaybookCommands {
let duration = start.elapsed(); let duration = start.elapsed();
let rc = status.code().unwrap_or(-1); let rc = status.code().unwrap_or(-1);
// Chronicle: PLAYBOOK_COMPLETED // Chronicle: GOV_PLAYBOOK_COMPLETED
self.emit_chronicle( chronicle.emit(
"PLAYBOOK_COMPLETED", "GOV_PLAYBOOK_COMPLETED",
&actor_did, &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 ───────────── // ── GSAP: Post Completion Receipt ─────────────
@ -257,7 +288,7 @@ impl PlaybookCommands {
if status.success() { if status.success() {
println!("\nPlaybook complete. ({:.1}s)", duration.as_secs_f64()); println!("\nPlaybook complete. ({:.1}s)", duration.as_secs_f64());
println!("Chronicle: PLAYBOOK_COMPLETED recorded"); println!("Chronicle: GOV_PLAYBOOK_COMPLETED recorded");
} else { } else {
anyhow::bail!("ansible-playbook exited with code {}", rc); anyhow::bail!("ansible-playbook exited with code {}", rc);
} }