From 0e260e8087b6f81098eb08b7a32d6322c90ab93f Mon Sep 17 00:00:00 2001 From: Pavel Kirilin Date: Tue, 3 Nov 2026 18:29:55 +0100 Subject: [PATCH 1/2] Added ipMode settings. Signed-off-by: Pavel Kirilin --- Cargo.lock | 1 + Cargo.toml | 1 + src/config.rs | 13 +++++++++---- src/consts.rs | 1 + src/error.rs | 3 +++ src/finalizers.rs | 2 +- src/ip_type.rs | 25 +++++++++++++++++++++++++ src/lb.rs | 25 +++++++++++++++++-------- src/main.rs | 24 ++++++++++++------------ 9 files changed, 70 insertions(+), 25 deletions(-) create mode 100644 src/ip_type.rs diff --git a/Cargo.lock b/Cargo.lock index f39e81c..adec76f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1886,6 +1886,7 @@ dependencies = [ "hcloud", "k8s-openapi", "kube", + "serde", "thiserror 2.0.3", "tikv-jemallocator", "tokio", diff --git a/Cargo.toml b/Cargo.toml index d52df18..654df72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ futures = "0.3.31" hcloud = "0.21.0" k8s-openapi = { version = "0.23.0", features = ["v1_31"] } kube = { version = "0.96.0", features = ["runtime"] } +serde = { version = "1.0.229", features = ["derive"] } thiserror = "2.0.3" tokio = { version = "1.43.1", features = ["macros", "rt-multi-thread"] } tracing = "0.1.40" diff --git a/src/config.rs b/src/config.rs index 6ce213b..58e2990 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +use crate::ip_type::IPMode; use clap::Parser; use tracing::level_filters::LevelFilter; @@ -30,13 +31,13 @@ pub struct OperatorConfig { pub default_lb_interval: i32, /// Default location of a load balancer. - /// https://docs.hetzner.com/cloud/general/locations/ + /// / #[arg(long, env = "ROBOTLB_DEFAULT_LB_LOCATION", default_value = "hel1")] pub default_lb_location: String, /// Type of a load balancer. It differs in price, number of connections, /// target servers, etc. The default value is the smallest balancer. - /// https://docs.hetzner.com/cloud/load-balancers/overview#pricing + /// #[arg(long, env = "ROBOTLB_DEFAULT_LB_TYPE", default_value = "lb11")] pub default_balancer_type: String, @@ -44,7 +45,8 @@ pub struct OperatorConfig { /// Possible values: /// * `least-connections` /// * `round-robin` - /// https://docs.hetzner.com/cloud/load-balancers/overview#load-balancers + /// + /// #[arg( long, env = "ROBOTLB_DEFAULT_LB_ALGORITHM", @@ -54,7 +56,7 @@ pub struct OperatorConfig { /// Default load balancer proxy mode. If enabled, the load balancer will /// act as a proxy for the target servers. The default value is `false`. - /// https://docs.hetzner.com/cloud/load-balancers/faq/#what-does-proxy-protocol-mean-and-should-i-enable-it + /// #[arg( long, env = "ROBOTLB_DEFAULT_LB_PROXY_MODE_ENABLED", @@ -62,6 +64,9 @@ pub struct OperatorConfig { )] pub default_lb_proxy_mode_enabled: bool, + #[arg(long, env = "ROBOTLB_DEFAULT_IP_MODE", default_value = "VIP")] + pub default_ip_mode: IPMode, + /// Whether to enable IPv6 ingress for the load balancer. /// If enabled, the load balancer's IPv6 will be attached to the service as an external IP along with IPv4. #[arg(long, env = "ROBOTLB_IPV6_INGRESS", default_value = "false")] diff --git a/src/consts.rs b/src/consts.rs index 198ffec..cb32c24 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -12,6 +12,7 @@ pub const LB_PRIVATE_IP_LABEL_NAME: &str = "robotlb/lb-private-ip"; pub const LB_LOCATION_LABEL_NAME: &str = "robotlb/lb-location"; pub const LB_ALGORITHM_LABEL_NAME: &str = "robotlb/lb-algorithm"; pub const LB_BALANCER_TYPE_LABEL_NAME: &str = "robotlb/balancer-type"; +pub const LB_IP_TYPE_LABEL_NAME: &str = "robotlb/ip-type"; pub const DEFAULT_LB_RETRIES: i32 = 3; pub const DEFAULT_LB_TIMEOUT: i32 = 10; diff --git a/src/error.rs b/src/error.rs index 35e1f9d..0f0f859 100644 --- a/src/error.rs +++ b/src/error.rs @@ -85,6 +85,8 @@ pub enum RobotLBError { HcloudListLoadBalancersError( #[from] hcloud::apis::Error, ), + #[error("Unknown IP Type. Only VIP or Proxy are allowed.")] + UnknownIPType, } impl RobotLBError { @@ -115,6 +117,7 @@ impl RobotLBError { | Self::HCloudError(_) | Self::KubeError(_) | Self::UnknownLBAlgorithm + | Self::UnknownIPType | Self::ServiceWithoutSelector | Self::RateLimited(_) => false, } diff --git a/src/finalizers.rs b/src/finalizers.rs index 2f65e5b..285bd8e 100644 --- a/src/finalizers.rs +++ b/src/finalizers.rs @@ -35,7 +35,7 @@ pub fn check(service: &Service) -> bool { .metadata .finalizers .as_ref() - .map_or(false, |finalizers| { + .is_some_and(|finalizers| { finalizers.contains(&consts::FINALIZER_NAME.to_string()) }) } diff --git a/src/ip_type.rs b/src/ip_type.rs new file mode 100644 index 0000000..2d5aa83 --- /dev/null +++ b/src/ip_type.rs @@ -0,0 +1,25 @@ +use std::str::FromStr; + +use crate::error::RobotLBError; + +/// IP Type set by the loadbancer in the status field. +#[derive(Clone, Debug, Copy, serde::Serialize)] +pub enum IPMode { + /// Type of an IP when the traffic is routed + /// with the loadbalancer IP and port. + VIP, + /// Type of an IP when the traffic is routed + /// with the node IP. + Proxy, +} + +impl FromStr for IPMode { + type Err = RobotLBError; + fn from_str(s: &str) -> Result { + match s { + "VIP" => Ok(Self::VIP), + "Proxy" => Ok(Self::Proxy), + _ => Err(RobotLBError::UnknownIPType), + } + } +} diff --git a/src/lb.rs b/src/lb.rs index f3cf34e..728a5e0 100644 --- a/src/lb.rs +++ b/src/lb.rs @@ -23,6 +23,7 @@ use std::{collections::HashMap, str::FromStr}; use crate::{ consts, error::{RobotLBError, RobotLBResult}, + ip_type::IPMode, CurrentContext, }; @@ -60,6 +61,7 @@ pub struct LoadBalancer { pub balancer_type: String, pub algorithm: LoadBalancerAlgorithm, pub network_name: Option, + pub ip_type: IPMode, pub hcloud_config: HcloudConfig, } @@ -135,13 +137,21 @@ impl LoadBalancer { .annotations() .get(consts::LB_NAME_LABEL_NAME) .cloned() - .unwrap_or(svc.name_any()); + .unwrap_or_else(|| svc.name_any()); let private_ip = svc .annotations() .get(consts::LB_PRIVATE_IP_LABEL_NAME) .cloned(); + let ip_type = svc + .annotations() + .get(consts::LB_IP_TYPE_LABEL_NAME) + .map(String::as_str) + .map(IPMode::from_str) + .transpose()? + .unwrap_or(context.config.default_ip_mode); + Ok(Self { name, private_ip, @@ -152,6 +162,7 @@ impl LoadBalancer { location, proxy_mode, network_name, + ip_type, algorithm: algorithm.into(), services: HashMap::default(), targets: Vec::default(), @@ -424,7 +435,7 @@ impl LoadBalancer { &self, hcloud_balancer: &hcloud::models::LoadBalancer, ) -> RobotLBResult<()> { - if *hcloud_balancer.algorithm == self.algorithm.clone().into() { + if *hcloud_balancer.algorithm == self.algorithm.clone() { return Ok(()); } tracing::info!( @@ -437,7 +448,7 @@ impl LoadBalancer { &self.hcloud_config, ChangeAlgorithmParams { id: hcloud_balancer.id, - body: Some(self.algorithm.clone().into()), + body: Some(self.algorithm.clone()), }, ) }) @@ -613,7 +624,7 @@ impl LoadBalancer { let hcloud_balancers = hcloud::apis::load_balancers_api::list_load_balancers( &self.hcloud_config, ListLoadBalancersParams { - name: Some(self.name.to_string()), + name: Some(self.name.clone()), ..Default::default() }, ) @@ -688,15 +699,13 @@ impl LoadBalancer { network_name ); return Err(RobotLBError::HCloudError(format!( - "Found more than one network with name {}", - network_name, + "Found more than one network with name {network_name}", ))); } if response.networks.is_empty() { tracing::warn!("Network with name {} not found", network_name); return Err(RobotLBError::HCloudError(format!( - "Network with name {} not found", - network_name, + "Network with name {network_name} not found", ))); } diff --git a/src/main.rs b/src/main.rs index feb4553..a05cd6a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,6 +53,7 @@ pub mod config; pub mod consts; pub mod error; pub mod finalizers; +pub mod ip_type; pub mod label_filter; pub mod lb; pub mod rate_limit; @@ -322,8 +323,7 @@ async fn sync_service(svc: Arc, context: Arc) -> RobotL .spec .as_ref() .and_then(|s| s.type_.as_ref()) - .map(String::as_str) - .unwrap_or("ClusterIP"); + .map_or("ClusterIP", String::as_str); if svc_type != "LoadBalancer" { tracing::debug!("Service type is not LoadBalancer. Skipping..."); return Err(RobotLBError::SkipService); @@ -333,8 +333,7 @@ async fn sync_service(svc: Arc, context: Arc) -> RobotL .spec .as_ref() .and_then(|s| s.load_balancer_class.as_ref()) - .map(String::as_str) - .unwrap_or(consts::ROBOTLB_LB_CLASS); + .map_or(consts::ROBOTLB_LB_CLASS, String::as_str); if lb_type != consts::ROBOTLB_LB_CLASS { tracing::debug!("Load balancer class is not robotlb. Skipping..."); return Err(RobotLBError::SkipService); @@ -544,10 +543,11 @@ pub async fn reconcile_load_balancer( svc: Arc, context: Arc, ) -> RobotLBResult { - let mut node_ip_type = "InternalIP"; - if lb.network_name.is_none() { - node_ip_type = "ExternalIP"; - } + let node_ip_type = if lb.network_name.is_none() { + "ExternalIP" + } else { + "InternalIP" + }; let nodes = match node_source(&svc, context.config.dynamic_node_selector) { NodeSource::Annotation => get_nodes_by_selector(&svc, &context).await?, @@ -602,16 +602,16 @@ pub async fn reconcile_load_balancer( ingress.push(json!({ "ip": ipv4, "dns": dns_ipv4, - "ip_mode": "VIP" - })) + "ipMode": &lb.ip_type + })); } if context.config.ipv6_ingress { if let Some(ipv6) = &ipv6 { ingress.push(json!({ "ip": ipv6, "dns": dns_ipv6, - "ip_mode": "VIP" - })) + "ipMode": &lb.ip_type + })); } } From ed3924dfbf3cecd91e8ab42c0525e615019ddfec Mon Sep 17 00:00:00 2001 From: Pavel Kirilin Date: Tue, 3 Nov 2026 18:50:55 +0100 Subject: [PATCH 2/2] Added tests. Signed-off-by: Pavel Kirilin --- src/config.rs | 29 +++++++++++++++++++ src/error.rs | 15 ++++++++++ src/finalizers.rs | 4 +-- src/ip_type.rs | 39 ++++++++++++++++++++++++++ src/lb.rs | 71 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 155 insertions(+), 3 deletions(-) diff --git a/src/config.rs b/src/config.rs index 58e2990..82e7462 100644 --- a/src/config.rs +++ b/src/config.rs @@ -92,6 +92,7 @@ pub struct OperatorConfig { #[cfg(test)] mod tests { use super::OperatorConfig; + use crate::ip_type::IPMode; use clap::Parser; fn parse(resync: &str) -> Result { @@ -104,6 +105,16 @@ mod tests { ]) } + fn parse_with_ip_mode(ip_mode: &str) -> Result { + OperatorConfig::try_parse_from([ + "robotlb", + "--hcloud-token", + "t", + "--default-ip-mode", + ip_mode, + ]) + } + // Zero would requeue every successful reconcile right away, spending Hetzner // API requests on every service all the time. The controller's delay queue // panics on delays past about two years. @@ -114,4 +125,22 @@ mod tests { assert_eq!(parse("31536000").unwrap().resync_interval, 31_536_000); assert_eq!(parse("1").unwrap().resync_interval, 1); } + + #[test] + fn the_ip_mode_defaults_to_vip() { + assert!(matches!(parse("300").unwrap().default_ip_mode, IPMode::VIP)); + } + + #[test] + fn the_ip_mode_can_be_set_to_proxy() { + assert!(matches!( + parse_with_ip_mode("Proxy").unwrap().default_ip_mode, + IPMode::Proxy + )); + } + + #[test] + fn an_unknown_ip_mode_is_rejected() { + assert!(parse_with_ip_mode("vip").is_err()); + } } diff --git a/src/error.rs b/src/error.rs index 0f0f859..e676284 100644 --- a/src/error.rs +++ b/src/error.rs @@ -285,6 +285,21 @@ mod tests { assert!(!RobotLBError::SkipService.is_rate_limited()); } + // A misconfigured annotation is the user's problem, not Hetzner's, so it + // must not close the rate limit gate. + #[test] + fn an_unknown_ip_type_is_not_a_rate_limit() { + assert!(!RobotLBError::UnknownIPType.is_rate_limited()); + } + + #[test] + fn the_unknown_ip_type_error_names_the_allowed_values() { + assert_eq!( + RobotLBError::UnknownIPType.to_string(), + "Unknown IP Type. Only VIP or Proxy are allowed." + ); + } + #[test] fn a_locked_balancer_is_temporary() { let body = r#"{"error": {"code": "locked", "message": "item is locked"}}"#; diff --git a/src/finalizers.rs b/src/finalizers.rs index 285bd8e..a2fdc74 100644 --- a/src/finalizers.rs +++ b/src/finalizers.rs @@ -35,9 +35,7 @@ pub fn check(service: &Service) -> bool { .metadata .finalizers .as_ref() - .is_some_and(|finalizers| { - finalizers.contains(&consts::FINALIZER_NAME.to_string()) - }) + .is_some_and(|finalizers| finalizers.contains(&consts::FINALIZER_NAME.to_string())) } /// Remove finalizer from the service. diff --git a/src/ip_type.rs b/src/ip_type.rs index 2d5aa83..a05cf5c 100644 --- a/src/ip_type.rs +++ b/src/ip_type.rs @@ -23,3 +23,42 @@ impl FromStr for IPMode { } } } + +#[cfg(test)] +mod tests { + use super::IPMode; + use crate::error::RobotLBError; + use std::str::FromStr; + + #[test] + fn vip_and_proxy_are_parsed() { + assert!(matches!(IPMode::from_str("VIP"), Ok(IPMode::VIP))); + assert!(matches!(IPMode::from_str("Proxy"), Ok(IPMode::Proxy))); + } + + // The values are case-sensitive, since the annotation is passed to the + // status verbatim and anything else would end up in the service. + #[test] + fn an_unknown_ip_mode_is_rejected() { + for input in ["vip", "proxy", "Vip", "EXTERNAL", ""] { + assert!( + matches!(IPMode::from_str(input), Err(RobotLBError::UnknownIPType)), + "{input:?} must be rejected" + ); + } + } + + // The mode is written into the service status `ipMode` field as-is, so the + // serialized form must match the accepted annotation values exactly. + #[test] + fn the_serialized_form_matches_the_accepted_values() { + assert_eq!( + k8s_openapi::serde_json::to_string(&IPMode::VIP).unwrap(), + "\"VIP\"" + ); + assert_eq!( + k8s_openapi::serde_json::to_string(&IPMode::Proxy).unwrap(), + "\"Proxy\"" + ); + } +} diff --git a/src/lb.rs b/src/lb.rs index 728a5e0..618025b 100644 --- a/src/lb.rs +++ b/src/lb.rs @@ -882,4 +882,75 @@ mod tests { let desired = vec!["192.168.100.2".to_string(), "192.168.100.3".to_string()]; assert_eq!(plan_targets(&desired, 25).len(), 2); } + + use super::LoadBalancer; + use crate::{ + config::OperatorConfig, consts, error::RobotLBError, ip_type::IPMode, CurrentContext, + }; + use clap::Parser; + use k8s_openapi::{api::core::v1::Service, apimachinery::pkg::apis::meta::v1::ObjectMeta}; + use std::collections::BTreeMap; + + fn context_with_ip_mode(default_ip_mode: IPMode) -> CurrentContext { + let mut config = + OperatorConfig::try_parse_from(["robotlb", "--hcloud-token", "t"]).unwrap(); + config.default_ip_mode = default_ip_mode; + // No cluster is needed: the client is only built, never used. + let client = + kube::Client::try_from(kube::Config::new("https://127.0.0.1:6443".parse().unwrap())) + .unwrap(); + let mut hcloud_config = super::HcloudConfig::new(); + hcloud_config.bearer_access_token = Some("t".to_string()); + CurrentContext::new(client, config, hcloud_config) + } + + fn service_with_ip_type(ip_type: Option<&str>) -> Service { + let mut annotations = BTreeMap::new(); + if let Some(ip_type) = ip_type { + annotations.insert( + consts::LB_IP_TYPE_LABEL_NAME.to_string(), + ip_type.to_string(), + ); + } + Service { + metadata: ObjectMeta { + name: Some("web".to_string()), + annotations: Some(annotations), + ..Default::default() + }, + ..Default::default() + } + } + + fn is_proxy(mode: IPMode) -> bool { + matches!(mode, IPMode::Proxy) + } + + #[tokio::test] + async fn a_missing_ip_type_annotation_falls_back_to_the_default() { + let svc = service_with_ip_type(None); + let lb = LoadBalancer::try_from_svc(&svc, &context_with_ip_mode(IPMode::VIP)).unwrap(); + assert!(!is_proxy(lb.ip_type)); + let lb = LoadBalancer::try_from_svc(&svc, &context_with_ip_mode(IPMode::Proxy)).unwrap(); + assert!(is_proxy(lb.ip_type)); + } + + #[tokio::test] + async fn an_ip_type_annotation_overrides_the_default() { + let svc = service_with_ip_type(Some("Proxy")); + let lb = LoadBalancer::try_from_svc(&svc, &context_with_ip_mode(IPMode::VIP)).unwrap(); + assert!(is_proxy(lb.ip_type)); + let svc = service_with_ip_type(Some("VIP")); + let lb = LoadBalancer::try_from_svc(&svc, &context_with_ip_mode(IPMode::Proxy)).unwrap(); + assert!(!is_proxy(lb.ip_type)); + } + + #[tokio::test] + async fn an_unknown_ip_type_annotation_is_rejected() { + let svc = service_with_ip_type(Some("vip")); + assert!(matches!( + LoadBalancer::try_from_svc(&svc, &context_with_ip_mode(IPMode::VIP)), + Err(RobotLBError::UnknownIPType) + )); + } }