From e39aaeb5c5da716329b8b7fc4d28df2ca2cfcbc1 Mon Sep 17 00:00:00 2001 From: Mirotin Artem Date: Tue, 9 Jun 2026 11:43:53 +0300 Subject: [PATCH] feat(config): classify_config_changes (hot vs restart) via overlay_hot_fields --- src/config/hot_reload.rs | 77 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/config/hot_reload.rs b/src/config/hot_reload.rs index c869e74..c4a4f44 100644 --- a/src/config/hot_reload.rs +++ b/src/config/hot_reload.rs @@ -1489,6 +1489,48 @@ pub fn spawn_config_watcher( (config_rx, log_rx) } +// ── Change classification ───────────────────────────────────────────────────── + +/// Which top-level config sections changed and whether any require a restart. +#[derive(Debug, Default, Clone, serde::Serialize)] +pub struct ChangeClassification { + pub changed: Vec, + pub restart_required: bool, +} + +/// Classify old->new using Telemt's OWN reload rule: overlay the hot fields and +/// see if anything non-hot remains different. This guarantees `restart_required` +/// matches actual runtime behavior and never drifts as new fields are added. +pub fn classify_config_changes(old: &ProxyConfig, new: &ProxyConfig) -> ChangeClassification { + let applied = overlay_hot_fields(old, new); + let restart_required = !config_equal(&applied, new); + ChangeClassification { + changed: changed_sections(old, new), + restart_required, + } +} + +/// Top-level config sections whose canonical serialized form differs between +/// old and new. Uses the same serialize+canonicalize path as `config_equal`. +fn changed_sections(old: &ProxyConfig, new: &ProxyConfig) -> Vec { + let mut lhs = serde_json::to_value(old).unwrap_or(serde_json::Value::Null); + let mut rhs = serde_json::to_value(new).unwrap_or(serde_json::Value::Null); + canonicalize_json(&mut lhs); + canonicalize_json(&mut rhs); + + let mut out = Vec::new(); + if let (Some(lo), Some(ro)) = (lhs.as_object(), rhs.as_object()) { + let mut keys: std::collections::BTreeSet<&String> = lo.keys().collect(); + keys.extend(ro.keys()); + for key in keys { + if lo.get(key) != ro.get(key) { + out.push(key.clone()); + } + } + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -1661,6 +1703,41 @@ mod tests { let _ = std::fs::remove_file(path); } + #[test] + fn classify_sni_change_requires_restart() { + // censorship.* is not in overlay_hot_fields -> restart. + let old = ProxyConfig::default(); + let mut new = ProxyConfig::default(); + new.censorship.tls_domain = "front.example".to_string(); + + let class = classify_config_changes(&old, &new); + assert!(class.restart_required); + assert!(class.changed.iter().any(|c| c == "censorship")); + } + + #[test] + fn classify_dns_overrides_change_is_hot() { + // network.dns_overrides IS in overlay_hot_fields -> no restart. + let old = ProxyConfig::default(); + let mut new = ProxyConfig::default(); + new.network.dns_overrides.push("1.1.1.1".to_string()); + + let class = classify_config_changes(&old, &new); + assert!(!class.restart_required); + assert!(class.changed.iter().any(|c| c == "network")); + } + + #[test] + fn classify_timeouts_change_requires_restart() { + // timeouts.* is NOT in overlay_hot_fields -> restart. + let old = ProxyConfig::default(); + let mut new = ProxyConfig::default(); + new.timeouts.client_handshake = old.timeouts.client_handshake + 1; + + let class = classify_config_changes(&old, &new); + assert!(class.restart_required); + } + #[test] fn reload_recovers_after_parse_error_on_next_attempt() { let initial_tag = "cccccccccccccccccccccccccccccccc";