From cf744dd90912704fd10a0bd4f94fa1c8b92b4f2cbaac5233cb4d8f53205b1a5c Mon Sep 17 00:00:00 2001 From: Tyler J King Date: Sun, 12 Apr 2026 06:45:19 -0400 Subject: [PATCH] feat(org-ops-core): add ChronicleClient for CloudEvents emission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- org-ops-core/src/chronicle_client.rs | 233 +++++++++++++++++++++++++++ org-ops-core/src/lib.rs | 1 + 2 files changed, 234 insertions(+) create mode 100644 org-ops-core/src/chronicle_client.rs diff --git a/org-ops-core/src/chronicle_client.rs b/org-ops-core/src/chronicle_client.rs new file mode 100644 index 0000000..1bc4d0a --- /dev/null +++ b/org-ops-core/src/chronicle_client.rs @@ -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)); + } +} diff --git a/org-ops-core/src/lib.rs b/org-ops-core/src/lib.rs index 245b901..a9b136d 100644 --- a/org-ops-core/src/lib.rs +++ b/org-ops-core/src/lib.rs @@ -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;