Skip to main content

zentinel_proxy/reload/
diff.rs

1//! Incremental configuration changes.
2//!
3//! A full reload replaces every route, upstream and listener at once. That is
4//! the right model for a config file, and the wrong one for "add a backend to
5//! this pool": it re-reads a file that may have drifted, re-validates
6//! everything, and lets an unrelated typo take down routes that were working.
7//!
8//! A [`ConfigChange`] names one mutation. Applying it clones the live
9//! configuration, makes that single change, and hands the result to
10//! [`super::ConfigManager::apply_config`], which already validates, swaps
11//! atomically, emits events and rolls back on failure. Nothing here
12//! re-implements any of
13//! that — the value is in describing the change precisely and refusing the
14//! ones that do not make sense.
15//!
16//! Every change reports what it did. A removal that matched nothing is an
17//! error, not a quiet success: an operator asking to remove a backend and
18//! being told "done" when no such backend existed has been misinformed about
19//! the state of their proxy.
20//!
21//! Part of zentinelproxy/zentinel#127.
22
23use zentinel_common::errors::{ZentinelError, ZentinelResult};
24use zentinel_config::{Config, RouteConfig, UpstreamTarget};
25
26/// Build a configuration error with no underlying cause.
27fn config_error(message: String) -> ZentinelError {
28    ZentinelError::Config {
29        message,
30        source: None,
31    }
32}
33
34/// A single, atomic configuration change.
35#[derive(Debug, Clone)]
36pub enum ConfigChange {
37    /// Add a target to an existing upstream pool.
38    AddUpstreamTarget {
39        /// Upstream to add to. Must already exist.
40        upstream: String,
41        /// The target to add. Its address must not already be present.
42        target: UpstreamTarget,
43    },
44    /// Remove a target from an upstream pool by address.
45    RemoveUpstreamTarget {
46        /// Upstream to remove from.
47        upstream: String,
48        /// Address of the target to remove.
49        address: String,
50    },
51    /// Add a route. Its ID must not already exist.
52    AddRoute(Box<RouteConfig>),
53    /// Replace an existing route wholesale, matched by ID.
54    ReplaceRoute(Box<RouteConfig>),
55    /// Remove a route by ID.
56    RemoveRoute {
57        /// ID of the route to remove.
58        id: String,
59    },
60}
61
62impl ConfigChange {
63    /// A short description for logs and audit trails.
64    pub fn summary(&self) -> String {
65        match self {
66            ConfigChange::AddUpstreamTarget { upstream, target } => {
67                format!("add target {} to upstream '{}'", target.address, upstream)
68            }
69            ConfigChange::RemoveUpstreamTarget { upstream, address } => {
70                format!("remove target {address} from upstream '{upstream}'")
71            }
72            ConfigChange::AddRoute(route) => format!("add route '{}'", route.id),
73            ConfigChange::ReplaceRoute(route) => format!("replace route '{}'", route.id),
74            ConfigChange::RemoveRoute { id } => format!("remove route '{id}'"),
75        }
76    }
77
78    /// Apply this change to a configuration.
79    ///
80    /// Returns an error rather than mutating when the change does not apply:
81    /// adding something that exists, or removing something that does not. Both
82    /// are cases where succeeding would tell the caller their configuration is
83    /// in a state it is not.
84    pub fn apply_to(&self, config: &mut Config) -> ZentinelResult<()> {
85        match self {
86            ConfigChange::AddUpstreamTarget { upstream, target } => {
87                let pool = config.upstreams.get_mut(upstream).ok_or_else(|| {
88                    config_error(format!(
89                        "cannot add a target: upstream '{upstream}' does not exist"
90                    ))
91                })?;
92
93                if pool.targets.iter().any(|t| t.address == target.address) {
94                    return Err(config_error(format!(
95                        "upstream '{}' already has a target at {}",
96                        upstream, target.address
97                    )));
98                }
99
100                pool.targets.push(target.clone());
101                Ok(())
102            }
103
104            ConfigChange::RemoveUpstreamTarget { upstream, address } => {
105                let pool = config.upstreams.get_mut(upstream).ok_or_else(|| {
106                    config_error(format!(
107                        "cannot remove a target: upstream '{upstream}' does not exist"
108                    ))
109                })?;
110
111                // Both checks run before anything is removed. Retaining first
112                // and validating after would leave the pool emptied even when
113                // the change is rejected.
114                let Some(position) = pool.targets.iter().position(|t| &t.address == address) else {
115                    return Err(config_error(format!(
116                        "upstream '{upstream}' has no target at {address}"
117                    )));
118                };
119
120                // An upstream with no targets accepts requests it can never
121                // route. Refuse rather than let a pool be emptied one target
122                // at a time without anyone noticing.
123                if pool.targets.len() == 1 {
124                    return Err(config_error(format!(
125                        "removing {address} would leave upstream '{upstream}' with no targets; \
126                         remove the routes that use it first, or replace the target"
127                    )));
128                }
129
130                pool.targets.remove(position);
131                Ok(())
132            }
133
134            ConfigChange::AddRoute(route) => {
135                if config.routes.iter().any(|r| r.id == route.id) {
136                    return Err(config_error(format!(
137                        "route '{}' already exists; use ReplaceRoute to change it",
138                        route.id
139                    )));
140                }
141                config.routes.push((**route).clone());
142                Ok(())
143            }
144
145            ConfigChange::ReplaceRoute(route) => {
146                let existing = config
147                    .routes
148                    .iter_mut()
149                    .find(|r| r.id == route.id)
150                    .ok_or_else(|| {
151                        config_error(format!(
152                            "cannot replace route '{}': it does not exist",
153                            route.id
154                        ))
155                    })?;
156                *existing = (**route).clone();
157                Ok(())
158            }
159
160            ConfigChange::RemoveRoute { id } => {
161                let before = config.routes.len();
162                config.routes.retain(|r| &r.id != id);
163
164                if config.routes.len() == before {
165                    return Err(config_error(format!("route '{id}' does not exist")));
166                }
167                Ok(())
168            }
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    fn config_with_pool() -> Config {
178        let mut config = Config::default_for_testing();
179        for pool in config.upstreams.values_mut() {
180            pool.targets = vec![target("10.0.0.1:8080"), target("10.0.0.2:8080")];
181        }
182        config
183    }
184
185    fn a_pool_name(config: &Config) -> String {
186        config.upstreams.keys().next().unwrap().clone()
187    }
188
189    // Fields spelled out rather than `..Default::default()`, so adding a
190    // field to UpstreamTarget breaks this test instead of silently defaulting.
191    fn target(address: &str) -> UpstreamTarget {
192        UpstreamTarget {
193            address: address.to_string(),
194            weight: 1,
195            max_requests: None,
196            metadata: std::collections::HashMap::new(),
197        }
198    }
199
200    #[test]
201    fn adding_a_target_appends_it() {
202        let mut config = config_with_pool();
203        let pool = a_pool_name(&config);
204
205        ConfigChange::AddUpstreamTarget {
206            upstream: pool.clone(),
207            target: target("10.0.0.3:8080"),
208        }
209        .apply_to(&mut config)
210        .expect("should apply");
211
212        let addresses: Vec<_> = config.upstreams[&pool]
213            .targets
214            .iter()
215            .map(|t| t.address.as_str())
216            .collect();
217        assert!(addresses.contains(&"10.0.0.3:8080"));
218        assert_eq!(addresses.len(), 3);
219    }
220
221    /// Adding a duplicate would silently create a pool that sends twice the
222    /// share of traffic to one backend.
223    #[test]
224    fn adding_a_duplicate_target_is_refused() {
225        let mut config = config_with_pool();
226        let pool = a_pool_name(&config);
227
228        let err = ConfigChange::AddUpstreamTarget {
229            upstream: pool,
230            target: target("10.0.0.1:8080"),
231        }
232        .apply_to(&mut config)
233        .expect_err("a duplicate address should be refused");
234
235        assert!(err.to_string().contains("already has a target"));
236    }
237
238    #[test]
239    fn adding_to_an_unknown_upstream_is_refused() {
240        let mut config = config_with_pool();
241        let err = ConfigChange::AddUpstreamTarget {
242            upstream: "nonexistent".to_string(),
243            target: target("10.0.0.9:8080"),
244        }
245        .apply_to(&mut config)
246        .expect_err("should be refused");
247        assert!(err.to_string().contains("does not exist"));
248    }
249
250    #[test]
251    fn removing_a_target_removes_only_that_one() {
252        let mut config = config_with_pool();
253        let pool = a_pool_name(&config);
254
255        ConfigChange::RemoveUpstreamTarget {
256            upstream: pool.clone(),
257            address: "10.0.0.1:8080".to_string(),
258        }
259        .apply_to(&mut config)
260        .expect("should apply");
261
262        let addresses: Vec<_> = config.upstreams[&pool]
263            .targets
264            .iter()
265            .map(|t| t.address.as_str())
266            .collect();
267        assert_eq!(addresses, vec!["10.0.0.2:8080"]);
268    }
269
270    /// The case this whole module is careful about: an operator asks to remove
271    /// a backend that is not there. Reporting success would tell them their
272    /// proxy is in a state it is not in.
273    #[test]
274    fn removing_an_absent_target_is_an_error_not_a_quiet_success() {
275        let mut config = config_with_pool();
276        let pool = a_pool_name(&config);
277
278        let err = ConfigChange::RemoveUpstreamTarget {
279            upstream: pool,
280            address: "10.9.9.9:8080".to_string(),
281        }
282        .apply_to(&mut config)
283        .expect_err("removing something absent must not report success");
284
285        assert!(err.to_string().contains("has no target at"));
286    }
287
288    /// An upstream with no targets accepts requests it can never route, so the
289    /// last one cannot be removed by this path.
290    #[test]
291    fn emptying_a_pool_is_refused() {
292        let mut config = config_with_pool();
293        let pool = a_pool_name(&config);
294
295        ConfigChange::RemoveUpstreamTarget {
296            upstream: pool.clone(),
297            address: "10.0.0.1:8080".to_string(),
298        }
299        .apply_to(&mut config)
300        .expect("first removal is fine");
301
302        let err = ConfigChange::RemoveUpstreamTarget {
303            upstream: pool.clone(),
304            address: "10.0.0.2:8080".to_string(),
305        }
306        .apply_to(&mut config)
307        .expect_err("removing the last target should be refused");
308
309        assert!(err.to_string().contains("no targets"));
310        // And the pool is left as it was, not emptied.
311        assert_eq!(config.upstreams[&pool].targets.len(), 1);
312    }
313
314    #[test]
315    fn routes_can_be_added_replaced_and_removed() {
316        let mut config = Config::default_for_testing();
317        let mut route = config.routes.first().cloned().expect("a route to copy");
318        route.id = "new-route".to_string();
319
320        ConfigChange::AddRoute(Box::new(route.clone()))
321            .apply_to(&mut config)
322            .expect("add");
323        assert!(config.routes.iter().any(|r| r.id == "new-route"));
324
325        let mut updated = route.clone();
326        updated.upstream = Some("changed".to_string());
327        ConfigChange::ReplaceRoute(Box::new(updated))
328            .apply_to(&mut config)
329            .expect("replace");
330        let stored = config.routes.iter().find(|r| r.id == "new-route").unwrap();
331        assert_eq!(stored.upstream.as_deref(), Some("changed"));
332
333        ConfigChange::RemoveRoute {
334            id: "new-route".to_string(),
335        }
336        .apply_to(&mut config)
337        .expect("remove");
338        assert!(!config.routes.iter().any(|r| r.id == "new-route"));
339    }
340
341    #[test]
342    fn adding_a_duplicate_route_id_is_refused() {
343        let mut config = Config::default_for_testing();
344        let existing = config.routes.first().cloned().expect("a route");
345
346        let err = ConfigChange::AddRoute(Box::new(existing))
347            .apply_to(&mut config)
348            .expect_err("a duplicate id should be refused");
349        assert!(err.to_string().contains("already exists"));
350    }
351
352    #[test]
353    fn replacing_or_removing_an_unknown_route_is_refused() {
354        let mut config = Config::default_for_testing();
355        let mut route = config.routes.first().cloned().expect("a route");
356        route.id = "not-present".to_string();
357
358        assert!(ConfigChange::ReplaceRoute(Box::new(route))
359            .apply_to(&mut config)
360            .is_err());
361        assert!(ConfigChange::RemoveRoute {
362            id: "not-present".to_string()
363        }
364        .apply_to(&mut config)
365        .is_err());
366    }
367
368    /// A rejected change must leave the configuration untouched, or a failed
369    /// command would still have altered the proxy.
370    #[test]
371    fn a_rejected_change_does_not_mutate_the_configuration() {
372        let mut config = config_with_pool();
373        let pool = a_pool_name(&config);
374        let before = config.upstreams[&pool].targets.len();
375        let routes_before = config.routes.len();
376
377        let _ = ConfigChange::AddUpstreamTarget {
378            upstream: pool.clone(),
379            target: target("10.0.0.1:8080"),
380        }
381        .apply_to(&mut config);
382        let _ = ConfigChange::RemoveRoute {
383            id: "nope".to_string(),
384        }
385        .apply_to(&mut config);
386
387        assert_eq!(config.upstreams[&pool].targets.len(), before);
388        assert_eq!(config.routes.len(), routes_before);
389    }
390}