From 43c87b70d07018338f3a2efbdca3ce416b41945c Mon Sep 17 00:00:00 2001 From: Kirill Ilin Date: Sat, 21 Mar 2026 11:48:52 +0500 Subject: [PATCH] feat: fall back to EndpointSlice-based node discovery for selectorless services When a Service has no spec.selector (e.g. services created by kubevirt cloud-controller-manager which manages EndpointSlices directly), the dynamic node selector now falls back to discovering target nodes from EndpointSlice resources instead of returning an error. This enables RobotLB to work with LoadBalancer services where an external controller manages EndpointSlices, such as tenant Kubernetes clusters running on KubeVirt with externalTrafficPolicy: Local. Assisted-By: Claude AI Signed-off-by: Kirill Ilin --- helm/values.yaml | 3 ++ src/main.rs | 73 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/helm/values.yaml b/helm/values.yaml index 739c7b7..4365f67 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -36,6 +36,9 @@ serviceAccount: - apiGroups: [""] resources: [nodes, pods] verbs: [get, list, watch] + - apiGroups: [discovery.k8s.io] + resources: [endpointslices] + verbs: [get, list, watch] podAnnotations: {} podLabels: {} diff --git a/src/main.rs b/src/main.rs index 395fea0..01510f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,7 +22,10 @@ use error::{RobotLBError, RobotLBResult}; use futures::StreamExt; use hcloud::apis::configuration::Configuration as HCloudConfig; use k8s_openapi::{ - api::core::v1::{Node, Pod, Service}, + api::{ + core::v1::{Node, Pod, Service}, + discovery::v1::EndpointSlice, + }, serde_json::json, }; use kube::{ @@ -181,8 +184,16 @@ async fn get_nodes_dynamically( .unwrap_or_else(|| context.client.default_namespace()), ); - let Some(pod_selector) = svc.spec.as_ref().and_then(|spec| spec.selector.clone()) else { - return Err(RobotLBError::ServiceWithoutSelector); + let Some(pod_selector) = svc + .spec + .as_ref() + .and_then(|spec| spec.selector.clone()) + .filter(|s| !s.is_empty()) + else { + tracing::info!( + "Service has no selector, falling back to EndpointSlice-based node discovery" + ); + return get_nodes_from_endpointslices(svc, context).await; }; let label_selector = pod_selector @@ -215,6 +226,62 @@ async fn get_nodes_dynamically( Ok(nodes) } +/// Get nodes from `EndpointSlice` resources associated with a Service. +/// This method is used as a fallback when the Service has no selector, +/// such as when `EndpointSlice` resources are managed by an external controller +/// (e.g. kubevirt cloud-controller-manager). +/// It discovers target nodes by reading the `nodeName` field from each endpoint. +async fn get_nodes_from_endpointslices( + svc: &Arc, + context: &Arc, +) -> RobotLBResult> { + let namespace = svc + .namespace() + .unwrap_or_else(|| context.client.default_namespace().to_string()); + let eps_api = kube::Api::::namespaced(context.client.clone(), &namespace); + let eps_list = eps_api + .list(&ListParams { + label_selector: Some(format!( + "kubernetes.io/service-name={}", + svc.name_any() + )), + ..Default::default() + }) + .await?; + + let target_nodes = eps_list + .into_iter() + .flat_map(|eps| eps.endpoints) + .filter(|ep| { + ep.conditions + .as_ref() + .and_then(|c| c.ready) + .unwrap_or(true) + }) + .filter_map(|ep| ep.node_name) + .collect::>(); + + if target_nodes.is_empty() { + tracing::warn!("No ready endpoints found in EndpointSlices for service"); + return Ok(vec![]); + } + + tracing::info!( + "Discovered {} target node(s) from EndpointSlices", + target_nodes.len() + ); + + let nodes_api = kube::Api::::all(context.client.clone()); + let nodes = nodes_api + .list(&ListParams::default()) + .await? + .into_iter() + .filter(|node| target_nodes.contains(&node.name_any())) + .collect::>(); + + Ok(nodes) +} + /// Get nodes based on the node selector. /// This method will find the nodes based on the node selector /// from the service annotations.