From 5ca9e1738bec5c264f233355af1f2a42b64c0503 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Wed, 30 Sep 2026 02:33:08 +0300 Subject: [PATCH] feat(lb): prefix default balancer names with an optional cluster name Two clusters running robotlb in one Hetzner project pick the same default name for services with the same name and namespace, and the second one then finds a balancer that is not its own. The new ROBOTLB_CLUSTER_NAME setting puts the cluster in front of the default name. It is checked at startup as a DNS label, so it holds no dot and the full names of different clusters differ. With three labels the name can pass the 128 characters Hetzner allows; such a name is cut and ends in a 64-bit FNV-1a hash of the whole name, so long names that share the kept part still differ. The hash stays the same across releases, as balancers are looked up by name. Names from the balancer annotation are used as given. Unset, names stay as they were. Assisted-by: LLM Signed-off-by: Aleksei Sviridkin --- README.md | 4 +- helm/values.yaml | 3 + src/config.rs | 6 ++ src/lb.rs | 217 ++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 217 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 76fa2f3..12743b5 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Setting `ROBOTLB_DYNAMIC_NODE_SELECTOR` to `false` replaces both with the node s A balancer type caps how many targets it holds: `lb11`, the default type, holds 25. When more nodes are selected than the type holds, the extra ones are dropped in a stable order and a warning names the limit. Pick a bigger type through `ROBOTLB_DEFAULT_LB_TYPE` or the `robotlb/balancer-type` annotation to use the whole cluster. -robotlb labels every balancer it creates with `robotlb/service-uid` set to the UID of the service, finds the balancer of a service by that label, and changes or deletes only a balancer labelled for that service. A balancer is never deleted by its name. Without the `robotlb/balancer` annotation the balancer is named `.`. The name is only used to create the balancer: changing the annotation later does not rename it. When the name is taken by a balancer labelled for another service, or by an unlabelled balancer that does not target the nodes of the service, robotlb leaves that balancer alone and reports it in a warning event on the service. Two services therefore cannot share a balancer through the same `robotlb/balancer` annotation: the second one gets the warning. A service recreated with a new UID, for example restored from a backup, is another service to robotlb, because its old balancer is labelled with the old UID. When that balancer has the name the service asks for, the warning names the old UID. When it has the name of an earlier release, robotlb creates a new balancer with a new IP instead, without a warning, and the old one stays in the project and keeps being billed; `hcloud load-balancer list --selector robotlb/service-uid=` finds it. After checking that no service with the old UID is left, hand the balancer over with `hcloud load-balancer add-label --overwrite '' robotlb/service-uid=`, or delete it. An unlabelled balancer whose targets are all IPs, at least one of them a node of the service, is adopted: robotlb adds its label, keeps the name, and from then on manages and deletes it like its own. This is how balancers of earlier releases are taken over, and it applies to any balancer created later under that name that matches. +robotlb labels every balancer it creates with `robotlb/service-uid` set to the UID of the service, finds the balancer of a service by that label, and changes or deletes only a balancer labelled for that service. A balancer is never deleted by its name. Without the `robotlb/balancer` annotation the balancer is named `.`, or `..` when `ROBOTLB_CLUSTER_NAME` is set, so that clusters sharing a Hetzner project do not pick the same name. A name longer than the 128 characters Hetzner allows is cut and ends in `-` and a 16-digit hex hash of the whole name. The cluster name is not added to a name from the annotation. Balancers that already carry the `robotlb/service-uid` label are found by it, so setting a cluster name later does not rename them. The name is only used to create the balancer: changing the annotation later does not rename it. When the name is taken by a balancer labelled for another service, or by an unlabelled balancer that does not target the nodes of the service, robotlb leaves that balancer alone and reports it in a warning event on the service. Two services therefore cannot share a balancer through the same `robotlb/balancer` annotation: the second one gets the warning. A service recreated with a new UID, for example restored from a backup, is another service to robotlb, because its old balancer is labelled with the old UID. When that balancer has the name the service asks for, the warning names the old UID. When it has the name of an earlier release, robotlb creates a new balancer with a new IP instead, without a warning, and the old one stays in the project and keeps being billed; `hcloud load-balancer list --selector robotlb/service-uid=` finds it. After checking that no service with the old UID is left, hand the balancer over with `hcloud load-balancer add-label --overwrite '' robotlb/service-uid=`, or delete it. An unlabelled balancer whose targets are all IPs, at least one of them a node of the service, is adopted: robotlb adds its label, keeps the name, and from then on manages and deletes it like its own. This is how balancers of earlier releases are taken over, and it applies to any balancer created later under that name that matches. Every port of the service needs an allocated `nodePort`. A Hetzner load balancer forwards traffic to the IP of a node, so a port is reachable only through its `nodePort`: ports without one are skipped, and `allocateLoadBalancerNodePorts: false` is not supported. When no port of a service can be exposed, no balancer is created for it, an existing balancer and the service's external IP are kept as they are, and a warning event on the service reports the problem. @@ -83,6 +83,8 @@ Usage: robotlb [OPTIONS] --hcloud-token Options: -t, --hcloud-token `HCloud` API token [env: ROBOTLB_HCLOUD_TOKEN=] + --cluster-name + Name of the cluster, put in front of default balancer names so that clusters sharing a Hetzner project do not pick the same ones. A DNS label: lowercase letters, digits and `-`, at most 63 characters [env: ROBOTLB_CLUSTER_NAME=] --default-network Default network to use for load balancers. If not set, then only network from the service annotation will be used [env: ROBOTLB_DEFAULT_NETWORK=] --dynamic-node-selector diff --git a/helm/values.yaml b/helm/values.yaml index 865062a..7c46a8e 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -14,6 +14,9 @@ fullnameOverride: "" envs: ROBOTLB_LOG_LEVEL: "INFO" + # Put in front of default balancer names when several clusters share a Hetzner + # project. A DNS label: lowercase letters, digits and `-`, at most 63 characters. + # ROBOTLB_CLUSTER_NAME: "prod" existingSecrets: [] diff --git a/src/config.rs b/src/config.rs index 7c3a25f..907ef10 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,12 @@ pub struct OperatorConfig { #[arg(short = 't', long, env = "ROBOTLB_HCLOUD_TOKEN")] pub hcloud_token: String, + /// Name of the cluster, put in front of default balancer names so that clusters + /// sharing a Hetzner project do not pick the same ones. A DNS label: lowercase + /// letters, digits and `-`, at most 63 characters. + #[arg(long, env = "ROBOTLB_CLUSTER_NAME", value_parser = crate::lb::parse_cluster_name)] + pub cluster_name: Option, + /// Default network to use for load balancers. /// If not set, then only network from the service annotation will be used. #[arg(long, env = "ROBOTLB_DEFAULT_NETWORK", default_value = None)] diff --git a/src/lb.rs b/src/lb.rs index f2a61c7..2ca91f1 100644 --- a/src/lb.rs +++ b/src/lb.rs @@ -132,9 +132,13 @@ impl LoadBalancer { let annotated_name = svc.annotations().get(consts::LB_NAME_LABEL_NAME); let legacy_name = annotated_name.is_none().then(|| svc.name_any()); - let name = annotated_name - .cloned() - .unwrap_or_else(|| default_name(&svc.name_any(), &svc.namespace().unwrap_or_default())); + let name = annotated_name.cloned().unwrap_or_else(|| { + default_name( + context.config.cluster_name.as_deref(), + &svc.name_any(), + &svc.namespace().unwrap_or_default(), + ) + }); // The API server sets the UID on every object it stores. let service_uid = svc.uid().ok_or(RobotLBError::SkipService)?; @@ -704,10 +708,46 @@ impl LoadBalancer { } } -/// Service names and namespaces are DNS labels: at most 63 characters and no dots, -/// so the result fits the 128 Hetzner allows and cannot be split two ways. -fn default_name(service: &str, namespace: &str) -> String { - format!("{service}.{namespace}") +/// Service names, namespaces and the cluster name are DNS labels: at most 63 +/// characters and no dots, so the name cannot be split two ways. Without a cluster +/// name it fits the 128 Hetzner allows. A longer one is cut and ends in a 64-bit +/// hash of the whole name, so long names that share the kept part still differ. +fn default_name(cluster: Option<&str>, service: &str, namespace: &str) -> String { + let name = cluster.map_or_else( + || format!("{service}.{namespace}"), + |cluster| format!("{cluster}.{service}.{namespace}"), + ); + if name.len() <= MAX_NAME_LEN { + return name; + } + // DNS labels are ASCII, so the cut falls on a character boundary. + let hash = format!("-{:016x}", fnv1a64(&name)); + format!("{}{hash}", &name[..MAX_NAME_LEN - hash.len()]) +} + +const MAX_NAME_LEN: usize = 128; + +/// FNV-1a, 64 bit: the hash ends up in names balancers are looked up by, so it has +/// to stay the same across releases, which std's hashers do not promise. +fn fnv1a64(value: &str) -> u64 { + value.bytes().fold(0xcbf2_9ce4_8422_2325, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3) + }) +} + +/// Without dots, the cluster name cannot end in the middle of a `.` +/// prefix of another cluster. +pub fn parse_cluster_name(value: &str) -> Result { + let alphanumeric = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit(); + let valid = (1..=63).contains(&value.len()) + && value.chars().all(|c| alphanumeric(c) || c == '-') + && value.starts_with(alphanumeric) + && value.ends_with(alphanumeric); + if valid { + Ok(value.to_string()) + } else { + Err("must be a DNS label: 1 to 63 lowercase letters, digits or '-', starting and ending with a letter or digit".to_string()) + } } fn owner_selector(uid: &str) -> String { @@ -840,8 +880,8 @@ impl From for LoadBalancerAlgorithm { #[cfg(test)] mod tests { use super::{ - candidate_names, decide, default_name, owner_labels, owner_selector, plan_targets, single, - Decision, LoadBalancer, Purpose, + candidate_names, decide, default_name, fnv1a64, 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; @@ -884,15 +924,62 @@ mod tests { #[test] fn the_default_name_carries_the_namespace() { - assert_eq!(default_name("web", "shop"), "web.shop"); - assert_ne!(default_name("web", "shop"), default_name("web", "blog")); + assert_eq!(default_name(None, "web", "shop"), "web.shop"); + assert_ne!( + default_name(None, "web", "shop"), + default_name(None, "web", "blog") + ); } // Both parts are DNS labels of at most 63 characters, Hetzner takes 128. #[test] fn the_longest_default_name_fits_hetzner() { let part = "a".repeat(63); - assert_eq!(default_name(&part, &part).len(), 127); + assert_eq!(default_name(None, &part, &part).len(), 127); + } + + #[test] + fn the_default_name_starts_with_the_cluster_name() { + assert_eq!(default_name(Some("prod"), "web", "shop"), "prod.web.shop"); + } + + #[test] + fn a_long_default_name_is_cut_to_the_limit_and_hashed() { + let part = "a".repeat(63); + let full = format!("{part}.{part}.{part}"); + let name = default_name(Some(&part), &part, &part); + assert_eq!(name.len(), 128); + assert_eq!(name, format!("{}-{:016x}", &full[..111], fnv1a64(&full))); + assert_eq!(name.trim(), name); + } + + #[test] + fn a_default_name_of_128_characters_is_kept_and_one_of_129_is_cut() { + let cluster = "a".repeat(63); + let service = "b".repeat(62); + let kept = default_name(Some(&cluster), &service, "c"); + assert_eq!(kept, format!("{cluster}.{service}.c")); + assert_eq!(kept.len(), 128); + let full = format!("{cluster}.{service}.cc"); + let cut = default_name(Some(&cluster), &service, "cc"); + assert_eq!(cut, format!("{}-{:016x}", &full[..111], fnv1a64(&full))); + } + + #[test] + fn long_default_names_differing_after_the_cut_stay_different() { + let part = "a".repeat(63); + assert_ne!( + default_name(Some(&part), &part, &"b".repeat(63)), + default_name(Some(&part), &part, &"c".repeat(63)), + ); + } + + // The hash is part of a name balancers are looked up by, it must never change. + #[test] + fn the_name_hash_is_fnv1a_64() { + assert_eq!(fnv1a64(""), 0xcbf2_9ce4_8422_2325); + assert_eq!(fnv1a64("a"), 0xaf63_dc4c_8601_ec8c); + assert_eq!(fnv1a64("foobar"), 0x8594_4171_f739_67e8); } #[test] @@ -1031,4 +1118,110 @@ mod tests { let lb = LoadBalancer::for_release(&svc, HcloudConfig::default()).unwrap(); assert_eq!(lb.service_uid, "uid-1"); } + + fn context_with_cluster_name() -> crate::CurrentContext { + use clap::Parser; + let config = crate::config::OperatorConfig::try_parse_from([ + "robotlb", + "--hcloud-token", + "t", + "--cluster-name", + "prod", + ]) + .unwrap(); + let client = + kube::Client::try_from(kube::Config::new("http://127.0.0.1:1".parse().unwrap())) + .unwrap(); + crate::CurrentContext::new(client, config, HcloudConfig::default()) + } + + #[tokio::test] + async fn the_cluster_name_prefixes_the_default_name() { + let svc = Service { + metadata: ObjectMeta { + uid: Some("uid-1".to_string()), + name: Some("web".to_string()), + namespace: Some("shop".to_string()), + ..Default::default() + }, + ..Default::default() + }; + let lb = LoadBalancer::try_from_svc(&svc, &context_with_cluster_name()).unwrap(); + assert_eq!(lb.name, "prod.web.shop"); + } + + #[tokio::test] + async fn the_cluster_name_leaves_an_annotated_name_alone() { + let annotated = "a".repeat(128); + let svc = Service { + metadata: ObjectMeta { + uid: Some("uid-1".to_string()), + name: Some("web".to_string()), + namespace: Some("shop".to_string()), + annotations: Some( + [(consts::LB_NAME_LABEL_NAME.to_string(), annotated.clone())].into(), + ), + ..Default::default() + }, + ..Default::default() + }; + let lb = LoadBalancer::try_from_svc(&svc, &context_with_cluster_name()).unwrap(); + assert_eq!(lb.name, annotated); + } + + fn parse_cluster_name_arg( + cluster_name: &str, + ) -> Result { + use clap::Parser; + crate::config::OperatorConfig::try_parse_from([ + "robotlb".to_string(), + "--hcloud-token=t".to_string(), + format!("--cluster-name={cluster_name}"), + ]) + } + + #[test] + fn the_cluster_name_is_unset_by_default() { + use clap::Parser; + let config = + crate::config::OperatorConfig::try_parse_from(["robotlb", "--hcloud-token", "t"]) + .unwrap(); + assert_eq!(config.cluster_name, None); + } + + #[test] + fn a_dns_label_is_a_valid_cluster_name() { + for name in ["a", "prod", "eu-1", "0", &"a".repeat(63)] { + assert_eq!(parse_cluster_name(name).as_deref(), Ok(name)); + assert_eq!( + parse_cluster_name_arg(name) + .unwrap() + .cluster_name + .as_deref(), + Some(name) + ); + } + } + + #[test] + fn a_cluster_name_that_is_not_a_dns_label_is_rejected() { + for name in [ + "", + "Prod", + "eu.prod", + "-prod", + "prod-", + "pr_od", + "prød", + &"a".repeat(64), + ] { + assert!(parse_cluster_name(name).is_err(), "{name:?}"); + let error = parse_cluster_name_arg(name).unwrap_err(); + assert_eq!( + error.kind(), + clap::error::ErrorKind::ValueValidation, + "{name:?}" + ); + } + } }