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 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);
}