zentinel_proxy/reload/
diff.rs1use zentinel_common::errors::{ZentinelError, ZentinelResult};
24use zentinel_config::{Config, RouteConfig, UpstreamTarget};
25
26fn config_error(message: String) -> ZentinelError {
28 ZentinelError::Config {
29 message,
30 source: None,
31 }
32}
33
34#[derive(Debug, Clone)]
36pub enum ConfigChange {
37 AddUpstreamTarget {
39 upstream: String,
41 target: UpstreamTarget,
43 },
44 RemoveUpstreamTarget {
46 upstream: String,
48 address: String,
50 },
51 AddRoute(Box<RouteConfig>),
53 ReplaceRoute(Box<RouteConfig>),
55 RemoveRoute {
57 id: String,
59 },
60}
61
62impl ConfigChange {
63 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 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 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 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 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 #[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 #[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 #[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 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 #[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}