feat(org-ops-core): add ChronicleClient for CloudEvents emission
Replaces fake Forgejo push webhook pattern with structured CloudEvents 1.0. Git-originated events use commit SHA as event id. Non-git events use UUID v4. No new dependencies — constructs CloudEvents JSON manually using serde_json. Endpoint auto-derived from legacy webhook URL. Ref: cid-reconciliation-audit.md Phase 1 Signed-off-by: Tyler King <tking@guildhouse.dev>
This commit is contained in:
parent
d39fd692eb
commit
cf744dd909
2 changed files with 234 additions and 0 deletions
233
org-ops-core/src/chronicle_client.rs
Normal file
233
org-ops-core/src/chronicle_client.rs
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
// Copyright 2026 Guildhouse Dev
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! CloudEvents 1.0 Chronicle emitter.
|
||||
//!
|
||||
//! Replaces the fake Forgejo push webhook pattern used previously.
|
||||
//! Git-originated events use the commit SHA as the CloudEvent `id`.
|
||||
//! Non-git events use a UUID v4.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// CloudEvents type prefix for Guildhouse Chronicle events.
|
||||
const CE_TYPE_PREFIX: &str = "dev.guildhouse.chronicle.";
|
||||
|
||||
/// CloudEvents 1.0 Chronicle emitter.
|
||||
///
|
||||
/// Emits structured governance events to the Chronicle webhook receiver's
|
||||
/// `/webhook/cloudevents` endpoint. Each event is a valid CloudEvents 1.0
|
||||
/// JSON envelope.
|
||||
pub struct ChronicleClient {
|
||||
endpoint: String,
|
||||
http: reqwest::blocking::Client,
|
||||
}
|
||||
|
||||
impl ChronicleClient {
|
||||
/// Create a new client targeting the given CloudEvents endpoint.
|
||||
///
|
||||
/// The `endpoint` should be the full URL including path, e.g.
|
||||
/// `http://chronicle-receiver:8090/webhook/cloudevents`.
|
||||
pub fn new(endpoint: &str) -> Self {
|
||||
Self {
|
||||
endpoint: endpoint.to_string(),
|
||||
http: reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("HTTP client"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the CloudEvents endpoint from a legacy webhook URL.
|
||||
///
|
||||
/// The old pattern used `/webhook/forgejo`. This replaces the path
|
||||
/// component with `/webhook/cloudevents`.
|
||||
pub fn from_legacy_webhook(webhook_url: &str) -> Self {
|
||||
let endpoint = if let Some(base) = webhook_url.rfind("/webhook/") {
|
||||
format!("{}/webhook/cloudevents", &webhook_url[..base])
|
||||
} else {
|
||||
// No /webhook/ path found — append /webhook/cloudevents
|
||||
format!("{}/webhook/cloudevents", webhook_url.trim_end_matches('/'))
|
||||
};
|
||||
Self::new(&endpoint)
|
||||
}
|
||||
|
||||
/// Emit a governance event to Chronicle via CloudEvents 1.0.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `kind` — Event kind, e.g. `"GOV_COMMIT_CREATED"`. Will be appended
|
||||
/// to the type prefix `dev.guildhouse.chronicle.`.
|
||||
/// * `source` — Actor DID, e.g. `"did:web:guildhouse.dev:user:tking"`.
|
||||
/// * `event_id` — For git events, the commit SHA. For non-git events,
|
||||
/// call [`Self::generate_id()`] for a UUID v4.
|
||||
/// * `data` — Structured event payload. Must include a `"kind"` field
|
||||
/// matching the `kind` argument (the receiver uses `data.kind` first).
|
||||
pub fn emit(
|
||||
&self,
|
||||
kind: &str,
|
||||
source: &str,
|
||||
event_id: &str,
|
||||
data: serde_json::Value,
|
||||
) -> bool {
|
||||
let envelope = serde_json::json!({
|
||||
"specversion": "1.0",
|
||||
"type": format!("{}{}", CE_TYPE_PREFIX, kind),
|
||||
"source": source,
|
||||
"id": event_id,
|
||||
"time": now_rfc3339(),
|
||||
"datacontenttype": "application/json",
|
||||
"data": data,
|
||||
});
|
||||
|
||||
self.http
|
||||
.post(&self.endpoint)
|
||||
.header("Content-Type", "application/cloudevents+json; charset=utf-8")
|
||||
.json(&envelope)
|
||||
.send()
|
||||
.map(|r| r.status().is_success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Generate a UUID v4 event ID for non-git events.
|
||||
pub fn generate_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 3339 timestamp for CloudEvents `time` field.
|
||||
fn now_rfc3339() -> String {
|
||||
let dur = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
let secs = dur.as_secs();
|
||||
// Simple UTC RFC 3339 without pulling in chrono.
|
||||
// Format: days since epoch -> y/m/d, remaining secs -> h:m:s
|
||||
let days = secs / 86400;
|
||||
let time_secs = secs % 86400;
|
||||
let h = time_secs / 3600;
|
||||
let m = (time_secs % 3600) / 60;
|
||||
let s = time_secs % 60;
|
||||
|
||||
// days -> year/month/day (simplified Gregorian)
|
||||
let (y, mo, d) = days_to_ymd(days);
|
||||
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
|
||||
}
|
||||
|
||||
/// Convert days since Unix epoch to (year, month, day).
|
||||
fn days_to_ymd(days: u64) -> (u64, u64, u64) {
|
||||
// Algorithm from Howard Hinnant's chrono-compatible date library.
|
||||
let z = days + 719468;
|
||||
let era = z / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if mo <= 2 { y + 1 } else { y };
|
||||
(y, mo, d)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_cloudevents_envelope_git_event() {
|
||||
// Verify a git-originated event produces valid CloudEvents 1.0 JSON
|
||||
let commit_sha = "abc123def456abc123def456abc123def456abc1";
|
||||
let data = serde_json::json!({
|
||||
"kind": "GOV_COMMIT_CREATED",
|
||||
"description": "sha=abc123 msg=test commit",
|
||||
"repo": "tking/example",
|
||||
"git_ref": "refs/heads/main",
|
||||
});
|
||||
|
||||
let envelope = serde_json::json!({
|
||||
"specversion": "1.0",
|
||||
"type": format!("{}GOV_COMMIT_CREATED", CE_TYPE_PREFIX),
|
||||
"source": "did:web:guildhouse.dev:user:tking",
|
||||
"id": commit_sha,
|
||||
"time": now_rfc3339(),
|
||||
"datacontenttype": "application/json",
|
||||
"data": data,
|
||||
});
|
||||
|
||||
// Required CloudEvents 1.0 fields
|
||||
assert_eq!(envelope["specversion"], "1.0");
|
||||
assert!(envelope["type"].as_str().unwrap().starts_with("dev.guildhouse.chronicle."));
|
||||
assert!(!envelope["source"].as_str().unwrap().is_empty());
|
||||
assert_eq!(envelope["id"], commit_sha);
|
||||
assert!(envelope["time"].as_str().unwrap().ends_with('Z'));
|
||||
assert!(envelope["data"]["kind"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cloudevents_envelope_non_git_event() {
|
||||
// Verify a non-git event uses UUID as id
|
||||
let event_id = ChronicleClient::generate_id();
|
||||
let data = serde_json::json!({
|
||||
"kind": "GOV_PLAYBOOK_STARTED",
|
||||
"playbook": "deploy-web",
|
||||
"target": "all",
|
||||
});
|
||||
|
||||
let envelope = serde_json::json!({
|
||||
"specversion": "1.0",
|
||||
"type": format!("{}GOV_PLAYBOOK_STARTED", CE_TYPE_PREFIX),
|
||||
"source": "did:web:guildhouse.dev:user:operator",
|
||||
"id": &event_id,
|
||||
"time": now_rfc3339(),
|
||||
"datacontenttype": "application/json",
|
||||
"data": data,
|
||||
});
|
||||
|
||||
assert_eq!(envelope["specversion"], "1.0");
|
||||
assert!(envelope["type"].as_str().unwrap().contains("GOV_PLAYBOOK_STARTED"));
|
||||
// UUID v4 is 36 chars with hyphens
|
||||
assert_eq!(event_id.len(), 36);
|
||||
assert!(event_id.contains('-'));
|
||||
assert!(envelope["data"]["kind"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_legacy_webhook() {
|
||||
let client = ChronicleClient::from_legacy_webhook(
|
||||
"http://localhost:8090/webhook/forgejo",
|
||||
);
|
||||
assert_eq!(client.endpoint, "http://localhost:8090/webhook/cloudevents");
|
||||
|
||||
let client2 = ChronicleClient::from_legacy_webhook(
|
||||
"http://chronicle:8090/webhook/forgejo",
|
||||
);
|
||||
assert_eq!(client2.endpoint, "http://chronicle:8090/webhook/cloudevents");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_now_rfc3339_format() {
|
||||
let ts = now_rfc3339();
|
||||
// Should match YYYY-MM-DDTHH:MM:SSZ
|
||||
assert_eq!(ts.len(), 20);
|
||||
assert_eq!(&ts[4..5], "-");
|
||||
assert_eq!(&ts[7..8], "-");
|
||||
assert_eq!(&ts[10..11], "T");
|
||||
assert_eq!(&ts[13..14], ":");
|
||||
assert_eq!(&ts[16..17], ":");
|
||||
assert!(ts.ends_with('Z'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_days_to_ymd_epoch() {
|
||||
// 1970-01-01
|
||||
let (y, m, d) = days_to_ymd(0);
|
||||
assert_eq!((y, m, d), (1970, 1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_days_to_ymd_known_date() {
|
||||
// 2026-04-12 = day 20555 since epoch
|
||||
let (y, m, d) = days_to_ymd(20555);
|
||||
assert_eq!((y, m, d), (2026, 4, 12));
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
pub mod ai_risk_analysis;
|
||||
pub mod apply_gate;
|
||||
pub mod auth_commands;
|
||||
pub mod chronicle_client;
|
||||
pub mod config;
|
||||
pub mod test_evidence;
|
||||
pub mod display;
|
||||
|
|
|
|||
Loading…
Reference in a new issue