use crate::{
config::{Cluster, LoadBalancerStrategy, ParameterisedStrategy},
errors::ProxyError,
};
const MAX_RING_ENTRIES: u64 = 1_048_576;
pub(in crate::config::validate) fn validate_lb_strategy(cluster: &Cluster) -> Result<(), ProxyError> {
let name = &cluster.name;
if let LoadBalancerStrategy::Parameterised(param) = &cluster.load_balancer_strategy {
validate_parameterised(name, param)?;
if let ParameterisedStrategy::RingHash(opts) = param {
let entries: u64 = cluster
.endpoints
.iter()
.map(|ep| u64::from(ep.weight()) * u64::from(opts.virtual_nodes))
.sum();
if entries > MAX_RING_ENTRIES {
return Err(ProxyError::Config(format!(
"cluster '{name}': ring_hash ring would have {entries} entries \
(sum of endpoint weight × virtual_nodes); maximum is {MAX_RING_ENTRIES} — \
lower virtual_nodes or endpoint weights",
)));
}
}
}
Ok(())
}
#[expect(clippy::too_many_lines, reason = "match arms are individually simple")]
fn validate_parameterised(name: &str, param: &ParameterisedStrategy) -> Result<(), ProxyError> {
match param {
ParameterisedStrategy::RingHash(opts) if opts.virtual_nodes == 0 => {
return Err(ProxyError::Config(format!(
"cluster '{name}': ring_hash.virtual_nodes must be >= 1"
)));
},
ParameterisedStrategy::RingHash(opts) if opts.virtual_nodes > 10_000 => {
return Err(ProxyError::Config(format!(
"cluster '{name}': ring_hash.virtual_nodes must be <= 10000 (got {})",
opts.virtual_nodes,
)));
},
ParameterisedStrategy::ZoneAware(opts) if opts.min_local_healthy_pct > 100 => {
return Err(ProxyError::Config(format!(
"cluster '{name}': zone_aware.min_local_healthy_pct must be <= 100 (got {})",
opts.min_local_healthy_pct,
)));
},
ParameterisedStrategy::Priority(opts) if opts.overprovisioning_factor < 100 => {
return Err(ProxyError::Config(format!(
"cluster '{name}': priority.overprovisioning_factor must be >= 100 (got {})",
opts.overprovisioning_factor,
)));
},
ParameterisedStrategy::Subset(opts) if opts.selector.is_empty() => {
return Err(ProxyError::Config(format!(
"cluster '{name}': subset.selector must not be empty \
(an empty selector matches all endpoints, defeating the subset)"
)));
},
ParameterisedStrategy::ConsistentHash(_)
| ParameterisedStrategy::Maglev(_)
| ParameterisedStrategy::RingHash(_)
| ParameterisedStrategy::Subset(_)
| ParameterisedStrategy::ZoneAware(_)
| ParameterisedStrategy::Priority(_) => {},
}
Ok(())
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::needless_raw_strings,
clippy::needless_raw_string_hashes,
reason = "tests use unwrap/expect/indexing/raw strings for brevity"
)]
mod tests {
use super::validate_lb_strategy;
use crate::config::{
Cluster, HashFunction, LoadBalancerStrategy, ParameterisedStrategy, PriorityOpts, RingHashOpts, SimpleStrategy,
SubsetFallbackPolicy, SubsetOpts, ZoneAwareOpts,
};
#[test]
fn reject_ring_hash_zero_virtual_nodes() {
let cluster = cluster_with_strategy(LoadBalancerStrategy::Parameterised(ParameterisedStrategy::RingHash(
RingHashOpts {
header: None,
hash_function: HashFunction::default(),
virtual_nodes: 0,
},
)));
let err = validate_lb_strategy(&cluster).unwrap_err();
assert!(err.to_string().contains("virtual_nodes must be >= 1"), "got: {err}");
}
#[test]
fn accept_ring_hash_valid_virtual_nodes() {
let cluster = cluster_with_strategy(LoadBalancerStrategy::Parameterised(ParameterisedStrategy::RingHash(
RingHashOpts {
header: None,
hash_function: HashFunction::default(),
virtual_nodes: 100,
},
)));
validate_lb_strategy(&cluster).expect("valid virtual_nodes should pass");
}
#[test]
fn reject_ring_hash_virtual_nodes_above_max() {
let cluster = cluster_with_strategy(LoadBalancerStrategy::Parameterised(ParameterisedStrategy::RingHash(
RingHashOpts {
header: None,
hash_function: HashFunction::default(),
virtual_nodes: 10_001,
},
)));
let err = validate_lb_strategy(&cluster).unwrap_err();
assert!(err.to_string().contains("virtual_nodes must be <= 10000"), "got: {err}");
}
#[test]
fn reject_zone_aware_pct_above_100() {
let cluster = cluster_with_strategy(LoadBalancerStrategy::Parameterised(ParameterisedStrategy::ZoneAware(
ZoneAwareOpts {
local_zone: "us-east-1a".to_owned(),
inner_strategy: SimpleStrategy::default(),
min_local_healthy_pct: 101,
},
)));
let err = validate_lb_strategy(&cluster).unwrap_err();
assert!(
err.to_string().contains("min_local_healthy_pct must be <= 100"),
"got: {err}"
);
}
#[test]
fn accept_zone_aware_pct_at_100() {
let cluster = cluster_with_strategy(LoadBalancerStrategy::Parameterised(ParameterisedStrategy::ZoneAware(
ZoneAwareOpts {
local_zone: "us-east-1a".to_owned(),
inner_strategy: SimpleStrategy::default(),
min_local_healthy_pct: 100,
},
)));
validate_lb_strategy(&cluster).expect("pct=100 should pass");
}
#[test]
fn reject_priority_zero_overprovisioning() {
let cluster = cluster_with_strategy(LoadBalancerStrategy::Parameterised(ParameterisedStrategy::Priority(
PriorityOpts {
inner_strategy: SimpleStrategy::default(),
overprovisioning_factor: 0,
},
)));
let err = validate_lb_strategy(&cluster).unwrap_err();
assert!(
err.to_string().contains("overprovisioning_factor must be >= 100"),
"got: {err}"
);
}
#[test]
fn reject_priority_sub_100_overprovisioning() {
let cluster = cluster_with_strategy(LoadBalancerStrategy::Parameterised(ParameterisedStrategy::Priority(
PriorityOpts {
inner_strategy: SimpleStrategy::default(),
overprovisioning_factor: 99,
},
)));
let err = validate_lb_strategy(&cluster).unwrap_err();
assert!(
err.to_string().contains("overprovisioning_factor must be >= 100"),
"got: {err}"
);
}
#[test]
fn reject_ring_hash_oversized_ring() {
let mut cluster = cluster_with_strategy(LoadBalancerStrategy::Parameterised(ParameterisedStrategy::RingHash(
RingHashOpts {
hash_function: HashFunction::default(),
header: None,
virtual_nodes: 10_000,
},
)));
cluster.endpoints = (0..3)
.map(|i| crate::config::Endpoint::Weighted {
address: format!("10.0.0.{i}:80"),
weight: 1_000,
metadata: std::collections::HashMap::new(),
priority: 0,
zone: None,
})
.collect();
let err = validate_lb_strategy(&cluster).unwrap_err();
assert!(err.to_string().contains("ring_hash ring would have"), "got: {err}");
}
#[test]
fn accept_priority_valid_overprovisioning() {
let cluster = cluster_with_strategy(LoadBalancerStrategy::Parameterised(ParameterisedStrategy::Priority(
PriorityOpts {
inner_strategy: SimpleStrategy::default(),
overprovisioning_factor: 140,
},
)));
validate_lb_strategy(&cluster).expect("valid overprovisioning should pass");
}
#[test]
fn reject_subset_empty_selector() {
let cluster = cluster_with_strategy(LoadBalancerStrategy::Parameterised(ParameterisedStrategy::Subset(
SubsetOpts {
fallback_policy: SubsetFallbackPolicy::default(),
inner_strategy: SimpleStrategy::default(),
selector: std::collections::HashMap::new(),
},
)));
let err = validate_lb_strategy(&cluster).unwrap_err();
assert!(
err.to_string().contains("subset.selector must not be empty"),
"an empty selector matches all endpoints and must be rejected: {err}"
);
}
#[test]
fn accept_subset_with_selector() {
let cluster = cluster_with_strategy(LoadBalancerStrategy::Parameterised(ParameterisedStrategy::Subset(
SubsetOpts {
fallback_policy: SubsetFallbackPolicy::default(),
inner_strategy: SimpleStrategy::default(),
selector: std::collections::HashMap::from([("zone".to_owned(), "a".to_owned())]),
},
)));
validate_lb_strategy(&cluster).expect("a non-empty selector should pass");
}
#[test]
fn simple_strategy_always_valid() {
let cluster = Cluster::with_defaults("test", vec!["10.0.0.1:80".into()]);
validate_lb_strategy(&cluster).expect("simple strategies need no validation");
}
fn cluster_with_strategy(strategy: LoadBalancerStrategy) -> Cluster {
let mut cluster = Cluster::with_defaults("test", vec!["10.0.0.1:80".into()]);
cluster.load_balancer_strategy = strategy;
cluster
}
}