From 244011e68d1279032ffad704e7a4e6017d02f589 Mon Sep 17 00:00:00 2001 From: Enrico Candino Date: Fri, 26 Jun 2026 11:16:54 +0200 Subject: [PATCH] Refactor kubeconfig URL generation (#938) * Refactor kubeconfig generation to remove unused port parameter and update related functions * Refactor kubeconfig generation to streamline error handling and remove unused imports * Refactor kubeconfig URL generation functions and deprecate old implementation * restore old behavior * Add 'k3kcli kubeconfig get' command and update documentation * Refactor URL generation by removing deprecated getURLFromService function and updating tests to use new implementation * Fix expected URL for LoadBalancer test case to include hostname * Refactor kubeconfig test documentation to clarify URL generation behavior for ClusterIP, NodePort, and LoadBalancer service types * Refactor kubeconfig URL generation functions to improve clarity and maintainability * Remove deprecated 'k3kcli kubeconfig get' command and update related documentation * Set logger to discard in NewRootCmd for improved logging control * Refactor getURLFromService to streamline ingress key retrieval --- cli/cmds/cluster_create.go | 18 +- cli/cmds/kubeconfig.go | 2 +- cli/cmds/root.go | 4 + pkg/controller/cluster/cluster.go | 6 +- pkg/controller/cluster/server/ingress.go | 6 +- pkg/controller/cluster/server/service.go | 4 +- pkg/controller/kubeconfig/kubeconfig.go | 177 +++++++++++-------- pkg/controller/kubeconfig/kubeconfig_test.go | 66 +++---- tests/e2e/common_test.go | 4 +- 9 files changed, 163 insertions(+), 124 deletions(-) diff --git a/cli/cmds/cluster_create.go b/cli/cmds/cluster_create.go index cb6af241..c112d602 100644 --- a/cli/cmds/cluster_create.go +++ b/cli/cmds/cluster_create.go @@ -14,7 +14,9 @@ import ( "github.com/sirupsen/logrus" "github.com/spf13/cobra" "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/util/retry" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" @@ -27,7 +29,6 @@ import ( "github.com/rancher/k3k/pkg/apis/k3k.io/v1beta1" "github.com/rancher/k3k/pkg/controller" k3kcluster "github.com/rancher/k3k/pkg/controller/cluster" - "github.com/rancher/k3k/pkg/controller/kubeconfig" ) type CreateConfig struct { @@ -174,12 +175,21 @@ func createAction(appCtx *AppContext, config *CreateConfig) func(cmd *cobra.Comm Steps: 25, } - cfg := kubeconfig.New() - var kubeconfig *clientcmdapi.Config if err := retry.OnError(availableBackoff, apierrors.IsNotFound, func() error { - kubeconfig, err = cfg.Generate(ctx, client, cluster, host[0], 0) + kubeconfigSecretKey := types.NamespacedName{ + Name: controller.SafeConcatNameWithPrefix(cluster.Name, "kubeconfig"), + Namespace: cluster.Namespace, + } + + var kubeconfigSecret corev1.Secret + if err := client.Get(ctx, kubeconfigSecretKey, &kubeconfigSecret); err != nil { + return err + } + + kubeconfig, err = clientcmd.Load(kubeconfigSecret.Data["kubeconfig.yaml"]) + return err }); err != nil { return err diff --git a/cli/cmds/kubeconfig.go b/cli/cmds/kubeconfig.go index 39763fd4..249472d5 100644 --- a/cli/cmds/kubeconfig.go +++ b/cli/cmds/kubeconfig.go @@ -118,7 +118,7 @@ func generate(appCtx *AppContext, cfg *GenerateKubeconfigConfig) func(cmd *cobra var kubeconfig *clientcmdapi.Config if err := retry.OnError(controller.Backoff, apierrors.IsNotFound, func() error { - kubeconfig, err = kubeCfg.Generate(ctx, client, &cluster, host[0], 0) + kubeconfig, err = kubeCfg.Generate(ctx, client, &cluster, host[0]) return err }); err != nil { return err diff --git a/cli/cmds/root.go b/cli/cmds/root.go index 1d65154b..fbe4e6c6 100644 --- a/cli/cmds/root.go +++ b/cli/cmds/root.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/go-logr/logr" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -15,6 +16,7 @@ import ( apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" "github.com/rancher/k3k/pkg/apis/k3k.io/v1beta1" "github.com/rancher/k3k/pkg/buildinfo" @@ -41,6 +43,8 @@ func NewRootCmd() *cobra.Command { PersistentPreRunE: func(cmd *cobra.Command, args []string) error { InitializeConfig(cmd) + ctrl.SetLogger(logr.Discard()) + if appCtx.Debug { logrus.SetLevel(logrus.DebugLevel) } diff --git a/pkg/controller/cluster/cluster.go b/pkg/controller/cluster/cluster.go index ba3946dc..14ee17a4 100644 --- a/pkg/controller/cluster/cluster.go +++ b/pkg/controller/cluster/cluster.go @@ -443,7 +443,7 @@ func (c *ClusterReconciler) reconcile(ctx context.Context, cluster *v1beta1.Clus return err } - if err := c.ensureKubeconfigSecret(ctx, cluster, serviceIP, 443); err != nil { + if err := c.ensureKubeconfigSecret(ctx, cluster, serviceIP); err != nil { return err } @@ -471,13 +471,13 @@ func (c *ClusterReconciler) ensureBootstrapSecret(ctx context.Context, cluster * } // ensureKubeconfigSecret will create or update the Secret containing the kubeconfig data from the k3s server -func (c *ClusterReconciler) ensureKubeconfigSecret(ctx context.Context, cluster *v1beta1.Cluster, serviceIP string, port int) error { +func (c *ClusterReconciler) ensureKubeconfigSecret(ctx context.Context, cluster *v1beta1.Cluster, serviceIP string) error { log := ctrl.LoggerFrom(ctx) log.V(1).Info("Ensuring Kubeconfig Secret") adminKubeconfig := kubeconfig.New() - kubeconfig, err := adminKubeconfig.Generate(ctx, c.Client, cluster, serviceIP, port) + kubeconfig, err := adminKubeconfig.Generate(ctx, c.Client, cluster, serviceIP) if err != nil { return err } diff --git a/pkg/controller/cluster/server/ingress.go b/pkg/controller/cluster/server/ingress.go index 53dceaf5..36a2d90e 100644 --- a/pkg/controller/cluster/server/ingress.go +++ b/pkg/controller/cluster/server/ingress.go @@ -13,9 +13,9 @@ import ( ) const ( - httpsPort = 443 - k3sServerPort = 6443 - etcdPort = 2379 + httpsPort int32 = 443 + k3sServerPort int32 = 6443 + etcdPort int32 = 2379 ) func IngressName(clusterName string) string { diff --git a/pkg/controller/cluster/server/service.go b/pkg/controller/cluster/server/service.go index f442b140..10fc406f 100644 --- a/pkg/controller/cluster/server/service.go +++ b/pkg/controller/cluster/server/service.go @@ -32,7 +32,7 @@ func Service(cluster *v1beta1.Cluster) *corev1.Service { Name: "k3s-server-port", Protocol: corev1.ProtocolTCP, Port: httpsPort, - TargetPort: intstr.FromInt(k3sServerPort), + TargetPort: intstr.FromInt32(k3sServerPort), } etcdPort := corev1.ServicePort{ @@ -142,7 +142,7 @@ func (s *Server) StatefulServerService() *corev1.Service { Name: "k3s-server-port", Protocol: corev1.ProtocolTCP, Port: httpsPort, - TargetPort: intstr.FromInt(k3sServerPort), + TargetPort: intstr.FromInt32(k3sServerPort), }, { Name: "k3s-etcd-port", diff --git a/pkg/controller/kubeconfig/kubeconfig.go b/pkg/controller/kubeconfig/kubeconfig.go index 44860314..b7a2cfc4 100644 --- a/pkg/controller/kubeconfig/kubeconfig.go +++ b/pkg/controller/kubeconfig/kubeconfig.go @@ -4,10 +4,12 @@ import ( "context" "crypto/x509" "fmt" + "net" + "net/url" "slices" + "strconv" "time" - "github.com/sirupsen/logrus" "k8s.io/apimachinery/pkg/types" "k8s.io/apiserver/pkg/authentication/user" "sigs.k8s.io/controller-runtime/pkg/client" @@ -16,6 +18,7 @@ import ( corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + ctrl "sigs.k8s.io/controller-runtime" "github.com/rancher/k3k/pkg/apis/k3k.io/v1beta1" "github.com/rancher/k3k/pkg/controller" @@ -39,7 +42,7 @@ func New() *KubeConfig { } } -func (k *KubeConfig) Generate(ctx context.Context, client client.Client, cluster *v1beta1.Cluster, hostServerIP string, port int) (*clientcmdapi.Config, error) { +func (k *KubeConfig) Generate(ctx context.Context, client client.Client, cluster *v1beta1.Cluster, hostServerIP string) (*clientcmdapi.Config, error) { bootstrapData, err := bootstrap.LoadFromSecret(ctx, client, cluster) if err != nil { return nil, err @@ -60,12 +63,12 @@ func (k *KubeConfig) Generate(ctx context.Context, client client.Client, cluster return nil, err } - url, err := getURLFromService(ctx, client, cluster, hostServerIP, port) + serverURL, err := getURLFromService(ctx, client, cluster, hostServerIP) if err != nil { return nil, err } - config := NewConfig(url, serverCACert, adminCert, adminKey) + config := NewConfig(serverURL.String(), serverCACert, adminCert, adminKey) return config, nil } @@ -93,84 +96,118 @@ func NewConfig(url string, serverCA, clientCert, clientKey []byte) *clientcmdapi return config } -func getURLFromService(ctx context.Context, client client.Client, cluster *v1beta1.Cluster, hostServerIP string, serverPort int) (string, error) { - // get the server service to extract the right IP +// getURLFromService generates the API server URL for the kubeconfig based on the service configuration. +// +// It handles internal vs external access patterns: +// - Internal access (hostServerIP == service.ClusterIP): uses the ClusterIP for direct pod-to-pod communication +// - External access (hostServerIP != service.ClusterIP): uses the appropriate external endpoint based on the service type +// +// Service type handling: +// - ClusterIP: uses service.Spec.ClusterIP (internal-only) +// - NodePort: uses hostServerIP:NodePort for external access, ClusterIP:Port for internal access +// - LoadBalancer: uses the LoadBalancer ingress IP, falling back to its hostname +// - Ingress (if configured): takes precedence over the service-based URL +// +// The hostServerIP parameter determines the access pattern: +// - Controller reconciliation: passes service.Spec.ClusterIP → internal access +// - CLI kubeconfig export: passes the external host → external access +func getURLFromService(ctx context.Context, c client.Client, cluster *v1beta1.Cluster, hostServerIP string) (*url.URL, error) { + log := ctrl.LoggerFrom(ctx) + key := types.NamespacedName{ Name: server.ServiceName(cluster.Name), Namespace: cluster.Namespace, } - var k3kService corev1.Service - if err := client.Get(ctx, key, &k3kService); err != nil { - return "", err - } - - ip := k3kService.Spec.ClusterIP - port := int32(443) - - if len(k3kService.Spec.Ports) == 0 { - logrus.Warn("No ports exposed by the cluster service.") - } - - switch k3kService.Spec.Type { - case corev1.ServiceTypeNodePort: - ip = hostServerIP - - if len(k3kService.Spec.Ports) > 0 { - port = k3kService.Spec.Ports[0].NodePort - } - case corev1.ServiceTypeLoadBalancer: - if len(k3kService.Status.LoadBalancer.Ingress) > 0 { - ip = k3kService.Status.LoadBalancer.Ingress[0].IP - } else { - logrus.Warn("No ingress found in LoadBalancer service.") - } - - if len(k3kService.Spec.Ports) > 0 { - port = k3kService.Spec.Ports[0].Port - } - } - - if serverPort != 0 { - port = int32(serverPort) - } - - if !slices.Contains(cluster.Status.TLSSANs, ip) { - logrus.Warnf("IP %s not in tlsSANs.", ip) - - if len(cluster.Spec.TLSSANs) > 0 { - logrus.Warnf("Using the first TLS SAN in the spec as a fallback: %s", cluster.Spec.TLSSANs[0]) - - ip = cluster.Spec.TLSSANs[0] - } else if len(cluster.Status.TLSSANs) > 0 { - logrus.Warnf("No explicit tlsSANs specified. Trying to use the first TLS SAN in the status: %s", cluster.Status.TLSSANs[0]) - - ip = cluster.Status.TLSSANs[0] - } else { - logrus.Warn("IP not found in tlsSANs. This could cause issue with the certificate validation.") - } - } - - url := "https://" + ip - if port != 443 { - url = fmt.Sprintf("%s:%d", url, port) - } - - // if ingress is specified, use the ingress host + // Check if ingress is configured if cluster.Spec.Expose != nil && cluster.Spec.Expose.Ingress != nil { - var k3kIngress networkingv1.Ingress - - ingressKey := types.NamespacedName{ + key := types.NamespacedName{ Name: server.IngressName(cluster.Name), Namespace: cluster.Namespace, } - if err := client.Get(ctx, ingressKey, &k3kIngress); err != nil { - return "", err + var k3kIngress networkingv1.Ingress + if err := c.Get(ctx, key, &k3kIngress); err != nil { + return nil, err } - url = fmt.Sprintf("https://%s", k3kIngress.Spec.Rules[0].Host) + if len(k3kIngress.Spec.Rules) > 0 && k3kIngress.Spec.Rules[0].Host != "" { + return url.Parse(fmt.Sprintf("https://%s", k3kIngress.Spec.Rules[0].Host)) + } + + log.V(1).Info("Ingress has no rule with a host set, falling back to the service URL.") } - return url, nil + // Fall back to Service-based URL + var k3kService corev1.Service + if err := c.Get(ctx, key, &k3kService); err != nil { + return nil, err + } + + // init to hostServerIP and 443 port + ip := hostServerIP + port := int32(443) + + // Use service port as default if available + if len(k3kService.Spec.Ports) > 0 { + port = k3kService.Spec.Ports[0].Port + } + + // Handle each service type separately + switch k3kService.Spec.Type { + case corev1.ServiceTypeClusterIP: + ip = k3kService.Spec.ClusterIP + + case corev1.ServiceTypeNodePort: + // Only use NodePort if hostServerIP is NOT the ClusterIP + // If hostServerIP == ClusterIP, this is an internal connection, use ClusterIP + if hostServerIP != k3kService.Spec.ClusterIP { + if len(k3kService.Spec.Ports) > 0 { + port = k3kService.Spec.Ports[0].NodePort + } + } else { + // Internal connection: use ClusterIP + ip = k3kService.Spec.ClusterIP + } + + case corev1.ServiceTypeLoadBalancer: + if len(k3kService.Status.LoadBalancer.Ingress) > 0 { + ingress := k3kService.Status.LoadBalancer.Ingress[0] + + switch { + case ingress.IP != "": + ip = ingress.IP + case ingress.Hostname != "": + ip = ingress.Hostname + default: + log.V(1).Info("No usable ingress address found in LoadBalancer service.") + } + } + } + + if !slices.Contains(cluster.Status.TLSSANs, ip) { + log.V(1).Info(fmt.Sprintf("IP %s not in tlsSANs.", ip)) + + if len(cluster.Spec.TLSSANs) > 0 { + log.V(1).Info("Using the first TLS SAN in the spec as a fallback: " + cluster.Spec.TLSSANs[0]) + + ip = cluster.Spec.TLSSANs[0] + } else if len(cluster.Status.TLSSANs) > 0 { + log.V(1).Info("No explicit tlsSANs specified. Trying to use the first TLS SAN in the status: " + cluster.Status.TLSSANs[0]) + + ip = cluster.Status.TLSSANs[0] + } else { + log.V(1).Info("IP not found in tlsSANs. This could cause issue with the certificate validation.") + } + } + + // Build URL with port only if not the default HTTPS port + var rawURL string + if port != int32(443) { + rawURL = fmt.Sprintf("https://%s", net.JoinHostPort(ip, strconv.Itoa(int(port)))) + } else { + rawURL = fmt.Sprintf("https://%s", ip) + } + + return url.Parse(rawURL) } diff --git a/pkg/controller/kubeconfig/kubeconfig_test.go b/pkg/controller/kubeconfig/kubeconfig_test.go index aac9385d..0f5ea5e2 100644 --- a/pkg/controller/kubeconfig/kubeconfig_test.go +++ b/pkg/controller/kubeconfig/kubeconfig_test.go @@ -1,26 +1,23 @@ package kubeconfig -// This file pins the current behavior of getURLFromService() across the different +// This file pins the behavior of getURLFromService() across the different // service types (ClusterIP, NodePort, LoadBalancer), Ingress exposure, and the // TLS SAN fallback logic. // -// Several test cases intentionally assert known quirks of the current implementation. -// They are documented here (and inline) so a future refactor knows exactly which -// behaviors it changes: +// The behaviors asserted here are: // // 1. ClusterIP port handling: -// Always uses Spec.ClusterIP and defaults to port 443; the service's declared -// port is ignored. Only the serverPort override changes the port. +// Uses Spec.ClusterIP with the service's declared port (Spec.Ports[0].Port). +// The port suffix is omitted only when it is the default 443. // // 2. NodePort access: -// Always uses hostServerIP:NodePort, with no internal/external detection. +// Internal/external aware. When hostServerIP == ClusterIP the connection is +// treated as internal and uses ClusterIP:Port; otherwise it uses +// hostServerIP:NodePort. // // 3. LoadBalancer hostname support: -// Reads only Status.LoadBalancer.Ingress[0].IP. The Hostname field is not -// consulted, so a hostname-only ingress produces the invalid URL "https://". -// -// 4. Port override: -// A non-zero serverPort parameter overrides the computed port. +// Reads Status.LoadBalancer.Ingress[0], preferring IP and falling back to +// Hostname, so a hostname-only ingress produces a valid URL. import ( "testing" @@ -45,30 +42,20 @@ func TestURLGeneration_ClusterIP(t *testing.T) { name string hostServerIP string servicePort int32 - serverPort int expectedURL string }{ { name: "ClusterIP with default port 443", hostServerIP: "10.0.0.1", servicePort: 443, - serverPort: 0, expectedURL: "https://10.43.0.100", }, { - // the service port is ignored for ClusterIP, so the URL has no port suffix - name: "ClusterIP ignores custom service port", + // the service's declared port is used, so it appears in the URL + name: "ClusterIP uses custom service port", hostServerIP: "10.0.0.1", servicePort: 8443, - serverPort: 0, - expectedURL: "https://10.43.0.100", - }, - { - name: "ClusterIP with serverPort override", - hostServerIP: "10.0.0.1", - servicePort: 443, - serverPort: 9443, - expectedURL: "https://10.43.0.100:9443", + expectedURL: "https://10.43.0.100:8443", }, } @@ -77,10 +64,10 @@ func TestURLGeneration_ClusterIP(t *testing.T) { cluster, svc := createClusterIPService("test-cluster", "default", tt.servicePort) fakeClient := createFakeClient(t, cluster, svc) - url, err := getURLFromService(t.Context(), fakeClient, cluster, tt.hostServerIP, tt.serverPort) + url, err := getURLFromService(t.Context(), fakeClient, cluster, tt.hostServerIP) require.NoError(t, err) - assert.Equal(t, tt.expectedURL, url) + assert.Equal(t, tt.expectedURL, url.String()) }) } } @@ -102,11 +89,13 @@ func TestURLGeneration_NodePort(t *testing.T) { expectedURL: "https://192.168.1.100:30443", }, { + // internal access: hostServerIP == ClusterIP, so ClusterIP:Port is used + // instead of the NodePort (port 443 is the default, so it is omitted) name: "NodePort internal access", hostServerIP: "10.43.0.100", // same as ClusterIP nodePort: 30443, servicePort: 443, - expectedURL: "https://10.43.0.100:30443", + expectedURL: "https://10.43.0.100", }, } @@ -115,10 +104,10 @@ func TestURLGeneration_NodePort(t *testing.T) { cluster, svc := createNodePortService("test-cluster", "default", tt.servicePort, tt.nodePort) fakeClient := createFakeClient(t, cluster, svc) - url, err := getURLFromService(t.Context(), fakeClient, cluster, tt.hostServerIP, 0) + url, err := getURLFromService(t.Context(), fakeClient, cluster, tt.hostServerIP) require.NoError(t, err) - assert.Equal(t, tt.expectedURL, url) + assert.Equal(t, tt.expectedURL, url.String()) }) } } @@ -142,14 +131,13 @@ func TestURLGeneration_LoadBalancer(t *testing.T) { expectedURL: "https://203.0.113.10", }, { - // quirk: the Hostname field is not read, so a hostname-only ingress - // produces the invalid URL "https://" (empty host) + // the Hostname field is used as a fallback when no IP is present name: "LoadBalancer with hostname ingress", hostServerIP: "10.0.0.1", lbIP: "", lbHostname: "cluster.example.com", servicePort: 443, - expectedURL: "https://", + expectedURL: "https://cluster.example.com", }, { name: "LoadBalancer with custom port", @@ -166,10 +154,10 @@ func TestURLGeneration_LoadBalancer(t *testing.T) { cluster, svc := createLoadBalancerService("test-cluster", "default", tt.servicePort, tt.lbIP, tt.lbHostname) fakeClient := createFakeClient(t, cluster, svc) - url, err := getURLFromService(t.Context(), fakeClient, cluster, tt.hostServerIP, 0) + url, err := getURLFromService(t.Context(), fakeClient, cluster, tt.hostServerIP) require.NoError(t, err) - assert.Equal(t, tt.expectedURL, url) + assert.Equal(t, tt.expectedURL, url.String()) }) } } @@ -193,10 +181,10 @@ func TestURLGeneration_Ingress(t *testing.T) { cluster, svc, ingress := createIngressService("test-cluster", "default", tt.ingressHost) fakeClient := createFakeClient(t, cluster, svc, ingress) - url, err := getURLFromService(t.Context(), fakeClient, cluster, "10.0.0.1", 0) + url, err := getURLFromService(t.Context(), fakeClient, cluster, "10.0.0.1") require.NoError(t, err) - assert.Equal(t, tt.expectedURL, url) + assert.Equal(t, tt.expectedURL, url.String()) }) } } @@ -268,10 +256,10 @@ func TestURLGeneration_TLSSANs(t *testing.T) { fakeClient := createFakeClient(t, cluster, svc) - url, err := getURLFromService(t.Context(), fakeClient, cluster, tt.hostServerIP, 0) + url, err := getURLFromService(t.Context(), fakeClient, cluster, tt.hostServerIP) require.NoError(t, err) - assert.Equal(t, tt.expectedURL, url) + assert.Equal(t, tt.expectedURL, url.String()) }) } } diff --git a/tests/e2e/common_test.go b/tests/e2e/common_test.go index 67668724..9c281385 100644 --- a/tests/e2e/common_test.go +++ b/tests/e2e/common_test.go @@ -191,7 +191,7 @@ func NewVirtualK8sClientAndConfig(cluster *v1beta1.Cluster) (*kubernetes.Clients vKubeconfig := kubeconfig.New() kubeletAltName := fmt.Sprintf("k3k-%s-kubelet", cluster.Name) vKubeconfig.AltNames = certs.AddSANs([]string{hostIP, kubeletAltName}) - config, err = vKubeconfig.Generate(ctx, k8sClient, cluster, hostIP, 0) + config, err = vKubeconfig.Generate(ctx, k8sClient, cluster, hostIP) return err }). @@ -225,7 +225,7 @@ func NewVirtualK8sClientAndKubeconfig(cluster *v1beta1.Cluster) (*kubernetes.Cli vKubeconfig := kubeconfig.New() kubeletAltName := fmt.Sprintf("k3k-%s-kubelet", cluster.Name) vKubeconfig.AltNames = certs.AddSANs([]string{hostIP, kubeletAltName}) - config, err = vKubeconfig.Generate(ctx, k8sClient, cluster, hostIP, 0) + config, err = vKubeconfig.Generate(ctx, k8sClient, cluster, hostIP) return err }).