use zentinel_common::errors::{ZentinelError, ZentinelResult};
use zentinel_config::{Config, RouteConfig, UpstreamTarget};
fn config_error(message: String) -> ZentinelError {
ZentinelError::Config {
message,
source: None,
}
}
#[derive(Debug, Clone)]
pub enum ConfigChange {
AddUpstreamTarget {
upstream: String,
target: UpstreamTarget,
},
RemoveUpstreamTarget {
upstream: String,
address: String,
},
AddRoute(Box<RouteConfig>),
ReplaceRoute(Box<RouteConfig>),
RemoveRoute {
id: String,
},
}
impl ConfigChange {
pub fn summary(&self) -> String {
match self {
ConfigChange::AddUpstreamTarget { upstream, target } => {
format!("add target {} to upstream '{}'", target.address, upstream)
}
ConfigChange::RemoveUpstreamTarget { upstream, address } => {
format!("remove target {address} from upstream '{upstream}'")
}
ConfigChange::AddRoute(route) => format!("add route '{}'", route.id),
ConfigChange::ReplaceRoute(route) => format!("replace route '{}'", route.id),
ConfigChange::RemoveRoute { id } => format!("remove route '{id}'"),
}
}
pub fn apply_to(&self, config: &mut Config) -> ZentinelResult<()> {
match self {
ConfigChange::AddUpstreamTarget { upstream, target } => {
let pool = config.upstreams.get_mut(upstream).ok_or_else(|| {
config_error(format!(
"cannot add a target: upstream '{upstream}' does not exist"
))
})?;
if pool.targets.iter().any(|t| t.address == target.address) {
return Err(config_error(format!(
"upstream '{}' already has a target at {}",
upstream, target.address
)));
}
pool.targets.push(target.clone());
Ok(())
}
ConfigChange::RemoveUpstreamTarget { upstream, address } => {
let pool = config.upstreams.get_mut(upstream).ok_or_else(|| {
config_error(format!(
"cannot remove a target: upstream '{upstream}' does not exist"
))
})?;
let Some(position) = pool.targets.iter().position(|t| &t.address == address) else {
return Err(config_error(format!(
"upstream '{upstream}' has no target at {address}"
)));
};
if pool.targets.len() == 1 {
return Err(config_error(format!(
"removing {address} would leave upstream '{upstream}' with no targets; \
remove the routes that use it first, or replace the target"
)));
}
pool.targets.remove(position);
Ok(())
}
ConfigChange::AddRoute(route) => {
if config.routes.iter().any(|r| r.id == route.id) {
return Err(config_error(format!(
"route '{}' already exists; use ReplaceRoute to change it",
route.id
)));
}
config.routes.push((**route).clone());
Ok(())
}
ConfigChange::ReplaceRoute(route) => {
let existing = config
.routes
.iter_mut()
.find(|r| r.id == route.id)
.ok_or_else(|| {
config_error(format!(
"cannot replace route '{}': it does not exist",
route.id
))
})?;
*existing = (**route).clone();
Ok(())
}
ConfigChange::RemoveRoute { id } => {
let before = config.routes.len();
config.routes.retain(|r| &r.id != id);
if config.routes.len() == before {
return Err(config_error(format!("route '{id}' does not exist")));
}
Ok(())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config_with_pool() -> Config {
let mut config = Config::default_for_testing();
for pool in config.upstreams.values_mut() {
pool.targets = vec![target("10.0.0.1:8080"), target("10.0.0.2:8080")];
}
config
}
fn a_pool_name(config: &Config) -> String {
config.upstreams.keys().next().unwrap().clone()
}
fn target(address: &str) -> UpstreamTarget {
UpstreamTarget {
address: address.to_string(),
weight: 1,
max_requests: None,
metadata: std::collections::HashMap::new(),
}
}
#[test]
fn adding_a_target_appends_it() {
let mut config = config_with_pool();
let pool = a_pool_name(&config);
ConfigChange::AddUpstreamTarget {
upstream: pool.clone(),
target: target("10.0.0.3:8080"),
}
.apply_to(&mut config)
.expect("should apply");
let addresses: Vec<_> = config.upstreams[&pool]
.targets
.iter()
.map(|t| t.address.as_str())
.collect();
assert!(addresses.contains(&"10.0.0.3:8080"));
assert_eq!(addresses.len(), 3);
}
#[test]
fn adding_a_duplicate_target_is_refused() {
let mut config = config_with_pool();
let pool = a_pool_name(&config);
let err = ConfigChange::AddUpstreamTarget {
upstream: pool,
target: target("10.0.0.1:8080"),
}
.apply_to(&mut config)
.expect_err("a duplicate address should be refused");
assert!(err.to_string().contains("already has a target"));
}
#[test]
fn adding_to_an_unknown_upstream_is_refused() {
let mut config = config_with_pool();
let err = ConfigChange::AddUpstreamTarget {
upstream: "nonexistent".to_string(),
target: target("10.0.0.9:8080"),
}
.apply_to(&mut config)
.expect_err("should be refused");
assert!(err.to_string().contains("does not exist"));
}
#[test]
fn removing_a_target_removes_only_that_one() {
let mut config = config_with_pool();
let pool = a_pool_name(&config);
ConfigChange::RemoveUpstreamTarget {
upstream: pool.clone(),
address: "10.0.0.1:8080".to_string(),
}
.apply_to(&mut config)
.expect("should apply");
let addresses: Vec<_> = config.upstreams[&pool]
.targets
.iter()
.map(|t| t.address.as_str())
.collect();
assert_eq!(addresses, vec!["10.0.0.2:8080"]);
}
#[test]
fn removing_an_absent_target_is_an_error_not_a_quiet_success() {
let mut config = config_with_pool();
let pool = a_pool_name(&config);
let err = ConfigChange::RemoveUpstreamTarget {
upstream: pool,
address: "10.9.9.9:8080".to_string(),
}
.apply_to(&mut config)
.expect_err("removing something absent must not report success");
assert!(err.to_string().contains("has no target at"));
}
#[test]
fn emptying_a_pool_is_refused() {
let mut config = config_with_pool();
let pool = a_pool_name(&config);
ConfigChange::RemoveUpstreamTarget {
upstream: pool.clone(),
address: "10.0.0.1:8080".to_string(),
}
.apply_to(&mut config)
.expect("first removal is fine");
let err = ConfigChange::RemoveUpstreamTarget {
upstream: pool.clone(),
address: "10.0.0.2:8080".to_string(),
}
.apply_to(&mut config)
.expect_err("removing the last target should be refused");
assert!(err.to_string().contains("no targets"));
assert_eq!(config.upstreams[&pool].targets.len(), 1);
}
#[test]
fn routes_can_be_added_replaced_and_removed() {
let mut config = Config::default_for_testing();
let mut route = config.routes.first().cloned().expect("a route to copy");
route.id = "new-route".to_string();
ConfigChange::AddRoute(Box::new(route.clone()))
.apply_to(&mut config)
.expect("add");
assert!(config.routes.iter().any(|r| r.id == "new-route"));
let mut updated = route.clone();
updated.upstream = Some("changed".to_string());
ConfigChange::ReplaceRoute(Box::new(updated))
.apply_to(&mut config)
.expect("replace");
let stored = config.routes.iter().find(|r| r.id == "new-route").unwrap();
assert_eq!(stored.upstream.as_deref(), Some("changed"));
ConfigChange::RemoveRoute {
id: "new-route".to_string(),
}
.apply_to(&mut config)
.expect("remove");
assert!(!config.routes.iter().any(|r| r.id == "new-route"));
}
#[test]
fn adding_a_duplicate_route_id_is_refused() {
let mut config = Config::default_for_testing();
let existing = config.routes.first().cloned().expect("a route");
let err = ConfigChange::AddRoute(Box::new(existing))
.apply_to(&mut config)
.expect_err("a duplicate id should be refused");
assert!(err.to_string().contains("already exists"));
}
#[test]
fn replacing_or_removing_an_unknown_route_is_refused() {
let mut config = Config::default_for_testing();
let mut route = config.routes.first().cloned().expect("a route");
route.id = "not-present".to_string();
assert!(ConfigChange::ReplaceRoute(Box::new(route))
.apply_to(&mut config)
.is_err());
assert!(ConfigChange::RemoveRoute {
id: "not-present".to_string()
}
.apply_to(&mut config)
.is_err());
}
#[test]
fn a_rejected_change_does_not_mutate_the_configuration() {
let mut config = config_with_pool();
let pool = a_pool_name(&config);
let before = config.upstreams[&pool].targets.len();
let routes_before = config.routes.len();
let _ = ConfigChange::AddUpstreamTarget {
upstream: pool.clone(),
target: target("10.0.0.1:8080"),
}
.apply_to(&mut config);
let _ = ConfigChange::RemoveRoute {
id: "nope".to_string(),
}
.apply_to(&mut config);
assert_eq!(config.upstreams[&pool].targets.len(), before);
assert_eq!(config.routes.len(), routes_before);
}
}