From ab4f0c53ba9ded1f372ba6989503a3b5e341b499 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Tue, 6 Oct 2026 13:12:47 +0300 Subject: [PATCH] fix(lb): keep only balancers and networks whose name matches exactly The Hetzner API ignores an empty name filter: listing balancers or networks with name= returns everything in the project. A service annotated with an empty balancer name therefore saw every balancer as a candidate. With several it failed as ambiguous, but with a single unlabelled balancer on the same nodes it would adopt and reconfigure a balancer it never created. An empty network name likewise attached the balancer to the only network of the project. Drop every listed balancer or network whose name is not exactly the requested one. An empty name now matches nothing: robotlb goes on to create the balancer and Hetzner rejects the empty name itself, and an empty network name fails as a network that was not found. Assisted-by: LLM Signed-off-by: Aleksei Sviridkin --- src/lb.rs | 51 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/src/lb.rs b/src/lb.rs index 2ca91f1..1be6864 100644 --- a/src/lb.rs +++ b/src/lb.rs @@ -574,13 +574,13 @@ impl LoadBalancer { return Ok(Some(balancer)); } for (name, legacy) in candidate_names(purpose, &self.name, self.legacy_name.as_deref()) { - let named = self + let listed = self .list_hcloud_lbs(ListLoadBalancersParams { name: Some(name.to_string()), ..Default::default() }) .await?; - let Some(balancer) = single(named, name)? else { + let Some(balancer) = single(named(listed, name, |lb| &lb.name), name)? else { continue; }; match decide(&balancer, &self.service_uid, &self.targets, legacy) { @@ -685,8 +685,9 @@ impl LoadBalancer { }, ) .await?; + let networks = named(response.networks, &network_name, |network| &network.name); - if response.networks.len() > 1 { + if networks.len() > 1 { tracing::warn!( "Found more than one network with name {}, skipping", network_name @@ -696,7 +697,7 @@ impl LoadBalancer { network_name, ))); } - if response.networks.is_empty() { + if networks.is_empty() { tracing::warn!("Network with name {} not found", network_name); return Err(RobotLBError::HCloudError(format!( "Network with name {} not found", @@ -704,7 +705,7 @@ impl LoadBalancer { ))); } - Ok(response.networks.into_iter().next()) + Ok(networks.into_iter().next()) } } @@ -836,6 +837,13 @@ fn decide( } } +/// The API matches a name filter exactly, except that it ignores an empty one and +/// returns everything in the project. +fn named(mut found: Vec, name: &str, name_of: impl Fn(&T) -> &str) -> Vec { + found.retain(|item| name_of(item) == name); + found +} + fn single(mut found: Vec, what: &str) -> RobotLBResult> { if found.len() > 1 { return Err(RobotLBError::AmbiguousBalancer(what.to_string())); @@ -880,14 +888,14 @@ impl From for LoadBalancerAlgorithm { #[cfg(test)] mod tests { use super::{ - candidate_names, decide, default_name, fnv1a64, owner_labels, owner_selector, + candidate_names, decide, default_name, fnv1a64, named, owner_labels, owner_selector, parse_cluster_name, plan_targets, single, Decision, LoadBalancer, Purpose, }; use crate::{consts, error::RobotLBError}; use hcloud::apis::configuration::Configuration as HcloudConfig; use hcloud::models::{ load_balancer_target, LoadBalancer as HcloudBalancer, LoadBalancerTarget, - LoadBalancerTargetIp, + LoadBalancerTargetIp, Network, }; use k8s_openapi::{api::core::v1::Service, apimachinery::pkg::apis::meta::v1::ObjectMeta}; use std::collections::HashMap; @@ -1094,6 +1102,35 @@ mod tests { ); } + #[test] + fn only_balancers_with_exactly_the_name_are_kept() { + let found = ["web", "Web", "web ", "web.shop"] + .map(|name| HcloudBalancer { + name: name.to_string(), + ..Default::default() + }) + .to_vec(); + let kept = named(found.clone(), "web", |lb| &lb.name); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].name, "web"); + // The API ignores an empty name filter and returns every balancer. + assert!(named(found, "", |lb| &lb.name).is_empty()); + } + + #[test] + fn only_networks_with_exactly_the_name_are_kept() { + let found = ["lan", "LAN", "lan ", "lan2"] + .map(|name| Network { + name: name.to_string(), + ..Default::default() + }) + .to_vec(); + let kept = named(found.clone(), "lan", |network| &network.name); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].name, "lan"); + assert!(named(found, "", |network| &network.name).is_empty()); + } + #[test] fn more_than_one_match_is_an_error() { assert!(single(Vec::::new(), "x").unwrap().is_none());