From d5d257c535192c0f45e787190ea557a00039cf16 Mon Sep 17 00:00:00 2001 From: gloskull Date: Sun, 26 Jul 2026 06:26:04 +0100 Subject: [PATCH] Add hot-reload configuration management --- docs/configuration-management.md | 24 +++ docs/runbooks/configuration-hot-reload.md | 22 +++ src/config.rs | 222 ++++++++++++++++++++++ src/lib.rs | 3 + tests/config_management_test.rs | 106 +++++++++++ 5 files changed, 377 insertions(+) create mode 100644 docs/configuration-management.md create mode 100644 docs/runbooks/configuration-hot-reload.md create mode 100644 src/config.rs create mode 100644 tests/config_management_test.rs diff --git a/docs/configuration-management.md b/docs/configuration-management.md new file mode 100644 index 0000000..26386c9 --- /dev/null +++ b/docs/configuration-management.md @@ -0,0 +1,24 @@ +# Configuration Management Architecture + +VeriNode Core uses a schema-first configuration subsystem that validates every +candidate configuration before activation. `ConfigManager` owns the active +`SystemConfig`, applies monotonic version checks, rejects invalid service and +operational settings, and records a compact change event for monitoring. + +## Hot-reload flow + +1. Load a candidate configuration from the operator source of truth. +2. Run `validate_reload(current, candidate)`. +3. Reject changes that fail schema validation, version monotonicity, or attempt + to mutate a service whose `hot_reload` flag is disabled. +4. Activate the candidate atomically and emit a `ConfigChangeEvent`. +5. Let deployment automation progress blue-green/canary rollout according to + the validated `DeploymentConfig`. + +## Operational bounds + +- Critical-path P99 target must be between 1 and 100 ms. +- Availability target must be 99.99% or higher. +- Metrics and alerting must remain enabled for every accepted configuration. +- Security review is represented by the `security_review_required` schema flag + and must not be disabled. diff --git a/docs/runbooks/configuration-hot-reload.md b/docs/runbooks/configuration-hot-reload.md new file mode 100644 index 0000000..f83403e --- /dev/null +++ b/docs/runbooks/configuration-hot-reload.md @@ -0,0 +1,22 @@ +# Configuration Hot-Reload Runbook + +## Pre-checks + +- Confirm the candidate version is greater than the active version. +- Confirm security review approval is attached to the change request. +- Confirm metrics, alerting, and dashboards are healthy. + +## Rollout + +1. Apply the candidate to a green environment. +2. Run schema validation and hot-reload validation. +3. Start canary at the configured percentage. +4. Watch P99 latency and error budget alerts for at least one dashboard refresh + interval. +5. Promote green to blue only when canary analysis stays within budget. + +## Rollback + +If validation fails or alerts fire, keep the active configuration unchanged, +stop the canary, and open an incident with the rejected config version and +validation error. diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..cb009b6 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,222 @@ +//! Configuration management with schema validation and deterministic hot reload. +//! +//! The module is intentionally runtime-agnostic: node processes can wire +//! `ConfigManager::reload` to a file watcher, governance event stream, or +//! blue-green/canary deployment controller while tests and contracts can drive +//! reloads deterministically. Validation is pure and bounded by the number of +//! fields in `SystemConfig`, keeping critical-path checks small and predictable. + +use alloc::string::String; +use alloc::vec::Vec; + +pub const MAX_SERVICE_NAME_LEN: usize = 64; +pub const MAX_SERVICES: usize = 64; +pub const MAX_CONFIG_VERSION_JUMP: u64 = 1_000; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ServiceConfig { + pub name: String, + pub enabled: bool, + pub critical_path_timeout_ms: u64, + pub max_inflight_requests: u32, + pub hot_reload: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MonitoringConfig { + pub metrics_enabled: bool, + pub alerting_enabled: bool, + pub dashboard_refresh_seconds: u64, + pub p99_latency_alert_ms: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeploymentConfig { + pub blue_green_enabled: bool, + pub canary_percent: u8, + pub canary_error_budget_bps: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SystemConfig { + pub version: u64, + pub availability_target_bps: u32, + pub critical_path_p99_ms: u64, + pub security_review_required: bool, + pub services: Vec, + pub monitoring: MonitoringConfig, + pub deployment: DeploymentConfig, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ConfigError { + EmptyServiceName, + ServiceNameTooLong, + DuplicateServiceName, + TooManyServices, + InvalidAvailabilityTarget, + CriticalPathTargetTooHigh, + ServiceTimeoutExceedsTarget, + ZeroInflightLimit, + MonitoringDisabled, + InvalidDashboardRefresh, + InvalidCanaryPercent, + InvalidCanaryErrorBudget, + SecurityReviewMissing, + NonMonotonicVersion, + VersionJumpTooLarge, + HotReloadDisabled(String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConfigChangeEvent { + pub previous_version: u64, + pub current_version: u64, + pub changed_services: Vec, + pub canary_percent: u8, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConfigManager { + active: SystemConfig, + history: Vec, +} + +impl ConfigManager { + pub fn new(config: SystemConfig) -> Result { + validate_config(&config)?; + Ok(Self { + active: config, + history: Vec::new(), + }) + } + + pub fn active(&self) -> &SystemConfig { + &self.active + } + + pub fn history(&self) -> &[ConfigChangeEvent] { + &self.history + } + + pub fn reload(&mut self, candidate: SystemConfig) -> Result { + validate_reload(&self.active, &candidate)?; + let event = ConfigChangeEvent { + previous_version: self.active.version, + current_version: candidate.version, + changed_services: changed_services(&self.active.services, &candidate.services), + canary_percent: candidate.deployment.canary_percent, + }; + self.active = candidate; + self.history.push(event.clone()); + Ok(event) + } +} + +pub fn validate_reload( + current: &SystemConfig, + candidate: &SystemConfig, +) -> Result<(), ConfigError> { + validate_config(candidate)?; + if candidate.version <= current.version { + return Err(ConfigError::NonMonotonicVersion); + } + if candidate.version - current.version > MAX_CONFIG_VERSION_JUMP { + return Err(ConfigError::VersionJumpTooLarge); + } + for service in &candidate.services { + if service_changed(¤t.services, service) && !service.hot_reload { + return Err(ConfigError::HotReloadDisabled(service.name.clone())); + } + } + Ok(()) +} + +pub fn validate_config(config: &SystemConfig) -> Result<(), ConfigError> { + if config.services.len() > MAX_SERVICES { + return Err(ConfigError::TooManyServices); + } + if !(9_999..=10_000).contains(&config.availability_target_bps) { + return Err(ConfigError::InvalidAvailabilityTarget); + } + if config.critical_path_p99_ms == 0 || config.critical_path_p99_ms > 100 { + return Err(ConfigError::CriticalPathTargetTooHigh); + } + if !config.security_review_required { + return Err(ConfigError::SecurityReviewMissing); + } + if !config.monitoring.metrics_enabled || !config.monitoring.alerting_enabled { + return Err(ConfigError::MonitoringDisabled); + } + if config.monitoring.dashboard_refresh_seconds == 0 + || config.monitoring.dashboard_refresh_seconds > 300 + { + return Err(ConfigError::InvalidDashboardRefresh); + } + if config.monitoring.p99_latency_alert_ms > config.critical_path_p99_ms { + return Err(ConfigError::CriticalPathTargetTooHigh); + } + if config.deployment.canary_percent > 100 { + return Err(ConfigError::InvalidCanaryPercent); + } + if config.deployment.canary_error_budget_bps > 10_000 { + return Err(ConfigError::InvalidCanaryErrorBudget); + } + + let mut seen: Vec<&str> = Vec::new(); + for service in &config.services { + if service.name.is_empty() { + return Err(ConfigError::EmptyServiceName); + } + if service.name.len() > MAX_SERVICE_NAME_LEN { + return Err(ConfigError::ServiceNameTooLong); + } + if seen.iter().any(|name| *name == service.name.as_str()) { + return Err(ConfigError::DuplicateServiceName); + } + seen.push(service.name.as_str()); + if service.critical_path_timeout_ms > config.critical_path_p99_ms { + return Err(ConfigError::ServiceTimeoutExceedsTarget); + } + if service.max_inflight_requests == 0 { + return Err(ConfigError::ZeroInflightLimit); + } + } + Ok(()) +} + +fn service_changed(current: &[ServiceConfig], candidate: &ServiceConfig) -> bool { + current + .iter() + .find(|service| service.name == candidate.name) + .map_or(true, |service| service != candidate) +} + +fn changed_services(current: &[ServiceConfig], candidate: &[ServiceConfig]) -> Vec { + candidate + .iter() + .filter(|service| service_changed(current, service)) + .map(|service| service.name.clone()) + .collect() +} + +impl Default for MonitoringConfig { + fn default() -> Self { + Self { + metrics_enabled: true, + alerting_enabled: true, + dashboard_refresh_seconds: 30, + p99_latency_alert_ms: 100, + } + } +} + +impl Default for DeploymentConfig { + fn default() -> Self { + Self { + blue_green_enabled: true, + canary_percent: 5, + canary_error_budget_bps: 100, + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 4ac6c55..5b6eec2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53,6 +53,9 @@ pub mod pool_manager; // span semantics for ingestion by observability pipelines. pub mod tracing; +// System-wide configuration management with schema validation and hot reload. +pub mod config; + // --- ERROR CODES --- #[contracterror] diff --git a/tests/config_management_test.rs b/tests/config_management_test.rs new file mode 100644 index 0000000..18f84ef --- /dev/null +++ b/tests/config_management_test.rs @@ -0,0 +1,106 @@ +use sorosusu_contracts::config::{ + ConfigError, ConfigManager, DeploymentConfig, MonitoringConfig, ServiceConfig, SystemConfig, +}; + +fn valid_config(version: u64) -> SystemConfig { + SystemConfig { + version, + availability_target_bps: 9_999, + critical_path_p99_ms: 100, + security_review_required: true, + services: vec![ + ServiceConfig { + name: "mempool".to_string(), + enabled: true, + critical_path_timeout_ms: 50, + max_inflight_requests: 128, + hot_reload: true, + }, + ServiceConfig { + name: "attestation".to_string(), + enabled: true, + critical_path_timeout_ms: 80, + max_inflight_requests: 256, + hot_reload: true, + }, + ], + monitoring: MonitoringConfig::default(), + deployment: DeploymentConfig::default(), + } +} + +#[test] +fn accepts_valid_config_and_records_hot_reload_event() { + let mut manager = ConfigManager::new(valid_config(1)).expect("valid initial config"); + let mut next = valid_config(2); + next.deployment.canary_percent = 10; + next.services[0].max_inflight_requests = 512; + + let event = manager.reload(next).expect("hot reload succeeds"); + + assert_eq!(event.previous_version, 1); + assert_eq!(event.current_version, 2); + assert_eq!(event.changed_services, vec!["mempool".to_string()]); + assert_eq!(event.canary_percent, 10); + assert_eq!(manager.history(), &[event]); + assert_eq!(manager.active().version, 2); +} + +#[test] +fn rejects_schema_violations_before_activation() { + let mut invalid = valid_config(1); + invalid.critical_path_p99_ms = 101; + assert_eq!( + ConfigManager::new(invalid), + Err(ConfigError::CriticalPathTargetTooHigh) + ); + + let mut invalid = valid_config(1); + invalid.monitoring.alerting_enabled = false; + assert_eq!( + ConfigManager::new(invalid), + Err(ConfigError::MonitoringDisabled) + ); + + let mut invalid = valid_config(1); + invalid.security_review_required = false; + assert_eq!( + ConfigManager::new(invalid), + Err(ConfigError::SecurityReviewMissing) + ); +} + +#[test] +fn rejects_non_monotonic_versions_and_non_reloadable_service_changes() { + let mut manager = ConfigManager::new(valid_config(7)).expect("valid initial config"); + assert_eq!( + manager.reload(valid_config(7)), + Err(ConfigError::NonMonotonicVersion) + ); + + let mut blocked = valid_config(8); + blocked.services[1].hot_reload = false; + blocked.services[1].critical_path_timeout_ms = 70; + assert_eq!( + manager.reload(blocked), + Err(ConfigError::HotReloadDisabled("attestation".to_string())) + ); + assert_eq!(manager.active().version, 7); +} + +#[test] +fn rejects_duplicate_services_and_invalid_canary_settings() { + let mut duplicate = valid_config(1); + duplicate.services[1].name = "mempool".to_string(); + assert_eq!( + ConfigManager::new(duplicate), + Err(ConfigError::DuplicateServiceName) + ); + + let mut invalid_canary = valid_config(1); + invalid_canary.deployment.canary_percent = 101; + assert_eq!( + ConfigManager::new(invalid_canary), + Err(ConfigError::InvalidCanaryPercent) + ); +}