From de5175959a1afe6a4e15e9d422a8f85fd12b34d6 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Wed, 11 Mar 2026 20:21:52 -0500 Subject: [PATCH 01/31] Add IPv6 public backends for NodeBalancers --- cloud/annotations/annotations.go | 2 + cloud/linode/loadbalancers.go | 117 ++++++++++++++++---- cloud/linode/loadbalancers_test.go | 171 +++++++++++++++++++++++++++++ cloud/linode/options/options.go | 1 + main.go | 1 + 5 files changed, 268 insertions(+), 24 deletions(-) diff --git a/cloud/annotations/annotations.go b/cloud/annotations/annotations.go index 197542bb..3e849458 100644 --- a/cloud/annotations/annotations.go +++ b/cloud/annotations/annotations.go @@ -41,6 +41,8 @@ const ( // AnnLinodeEnableIPv6Ingress is the annotation used to specify that a service should include both IPv4 and IPv6 // addresses for its LoadBalancer ingress. When set to "true", both addresses will be included in the status. AnnLinodeEnableIPv6Ingress = "service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-ingress" + // AnnLinodeEnableIPv6Backends controls whether a non-VPC NodeBalancer service should use public IPv6 backend nodes. + AnnLinodeEnableIPv6Backends = "service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends" AnnLinodeNodePrivateIP = "node.k8s.linode.com/private-ip" AnnLinodeHostUUID = "node.k8s.linode.com/host-uuid" diff --git a/cloud/linode/loadbalancers.go b/cloud/linode/loadbalancers.go index b97d6399..560f1ddc 100644 --- a/cloud/linode/loadbalancers.go +++ b/cloud/linode/loadbalancers.go @@ -264,8 +264,6 @@ func (l *loadbalancers) GetLoadBalancer(ctx context.Context, clusterName string, // EnsureLoadBalancer ensures that the cluster is running a load balancer for // service. -// -// EnsureLoadBalancer will not modify service or nodes. func (l *loadbalancers) EnsureLoadBalancer(ctx context.Context, clusterName string, service *v1.Service, nodes []*v1.Node) (lbStatus *v1.LoadBalancerStatus, err error) { ctx = sentry.SetHubOnContext(ctx) sentry.SetTag(ctx, "cluster_name", clusterName) @@ -469,9 +467,9 @@ func (l *loadbalancers) updateNodeBalancer( } } oldNBNodeIDs := make(map[string]int) + var currentNBNodes []linodego.NodeBalancerNode if currentNBCfg != nil { // Obtain list of current NB nodes and convert it to map of node IDs - var currentNBNodes []linodego.NodeBalancerNode currentNBNodes, err = l.client.ListNodeBalancerNodes(ctx, nb.ID, currentNBCfg.ID, nil) if err != nil { // This error can be ignored, because if we fail to get nodes we can anyway rebuild the config from scratch, @@ -485,8 +483,8 @@ func (l *loadbalancers) updateNodeBalancer( } else { klog.Infof("No preexisting nodebalancer for port %v found.", port.Port) } + // Add all of the Nodes to the config - newNBNodes := make([]linodego.NodeBalancerConfigRebuildNodeOptions, 0, len(nodes)) subnetID := 0 if options.Options.NodeBalancerBackendIPv4SubnetID != 0 { subnetID = options.Options.NodeBalancerBackendIPv4SubnetID @@ -506,21 +504,13 @@ func (l *loadbalancers) updateNodeBalancer( } subnetID = id } - for _, node := range nodes { - var newNodeOpts *linodego.NodeBalancerConfigRebuildNodeOptions - newNodeOpts, err = l.buildNodeBalancerNodeConfigRebuildOptions(node, port.NodePort, subnetID, newNBCfg.Protocol) - if err != nil { - sentry.CaptureError(ctx, err) - return fmt.Errorf("failed to build NodeBalancer node config options for node %s: %w", node.Name, err) - } - oldNodeID, ok := oldNBNodeIDs[newNodeOpts.Address] - if ok { - newNodeOpts.ID = oldNodeID - } else { - klog.Infof("No preexisting node id for %v found.", newNodeOpts.Address) - } - newNBNodes = append(newNBNodes, *newNodeOpts) + + useIPv6Backends := shouldUseIPv6NodeBalancerBackends(service, currentNBNodes) + newNBNodes, err := l.buildNodeBalancerConfigNodes(service, nodes, port.NodePort, subnetID, useIPv6Backends, newNBCfg.Protocol, oldNBNodeIDs) + if err != nil { + return err } + // If there's no existing config, create it var rebuildOpts linodego.NodeBalancerConfigRebuildOptions if currentNBCfg == nil { @@ -582,7 +572,8 @@ func (l *loadbalancers) UpdateLoadBalancer(ctx context.Context, clusterName stri serviceWithStatus := service.DeepCopy() serviceWithStatus.Status.LoadBalancer, err = l.getLatestServiceLoadBalancerStatus(ctx, service) if err != nil { - return fmt.Errorf("failed to get latest LoadBalancer status for service (%s): %w", getServiceNn(service), err) + klog.Warningf("failed to get latest LoadBalancer status for service (%s), using provided status instead: %v", getServiceNn(service), err) + serviceWithStatus.Status.LoadBalancer = service.Status.LoadBalancer } nb, err := l.getNodeBalancerForService(ctx, serviceWithStatus) @@ -1157,6 +1148,7 @@ func (l *loadbalancers) buildLoadBalancerRequest(ctx context.Context, clusterNam } ports := service.Spec.Ports configs := make([]*linodego.NodeBalancerConfigCreateOptions, 0, len(ports)) + useIPv6Backends := shouldUseIPv6NodeBalancerBackends(service, nil) subnetID := 0 if options.Options.NodeBalancerBackendIPv4SubnetID != 0 { @@ -1185,7 +1177,7 @@ func (l *loadbalancers) buildLoadBalancerRequest(ctx context.Context, clusterNam createOpt := config.GetCreateOptions() for _, node := range nodes { - newNodeOpts, err := l.buildNodeBalancerNodeConfigRebuildOptions(node, port.NodePort, subnetID, config.Protocol) + newNodeOpts, err := l.buildNodeBalancerNodeConfigRebuildOptions(service, node, port.NodePort, subnetID, useIPv6Backends, config.Protocol) if err != nil { sentry.CaptureError(ctx, err) return nil, fmt.Errorf("failed to build NodeBalancer node config options for node %s: %w", node.Name, err) @@ -1210,14 +1202,14 @@ func coerceString(str string, minLen, maxLen int, padding string) string { return str } -func (l *loadbalancers) buildNodeBalancerNodeConfigRebuildOptions(node *v1.Node, nodePort int32, subnetID int, protocol linodego.ConfigProtocol) (*linodego.NodeBalancerConfigRebuildNodeOptions, error) { - nodeIP, err := getNodePrivateIP(node, subnetID) +func (l *loadbalancers) buildNodeBalancerNodeConfigRebuildOptions(service *v1.Service, node *v1.Node, nodePort int32, subnetID int, useIPv6Backends bool, protocol linodego.ConfigProtocol) (*linodego.NodeBalancerConfigRebuildNodeOptions, error) { + nodeIP, err := getNodeBackendIP(service, node, subnetID, useIPv6Backends) if err != nil { - return nil, fmt.Errorf("node %s does not have a private IP address: %w", node.Name, err) + return nil, err } nodeOptions := &linodego.NodeBalancerConfigRebuildNodeOptions{ NodeBalancerNodeCreateOptions: linodego.NodeBalancerNodeCreateOptions{ - Address: fmt.Sprintf("%v:%v", nodeIP, nodePort), + Address: formatNodeBalancerBackendAddress(nodeIP, nodePort), // NodeBalancer backends must be 3-32 chars in length // If < 3 chars, pad node name with "node-" prefix Label: coerceString(node.Name, 3, 32, "node-"), @@ -1234,6 +1226,65 @@ func (l *loadbalancers) buildNodeBalancerNodeConfigRebuildOptions(node *v1.Node, return nodeOptions, nil } +func shouldUseIPv6NodeBalancerBackends(service *v1.Service, currentNBNodes []linodego.NodeBalancerNode) bool { + useIPv6 := getServiceBoolAnnotation(service, annotations.AnnLinodeEnableIPv6Backends) + if useIPv6 != nil { + return *useIPv6 + } + + if len(currentNBNodes) > 0 { + return isNodeBalancerBackendIPv6(currentNBNodes[0].Address) + } + + return options.Options.EnableIPv6ForNodeBalancerBackends +} + +func formatNodeBalancerBackendAddress(ip string, nodePort int32) string { + return net.JoinHostPort(ip, strconv.Itoa(int(nodePort))) +} + +func isNodeBalancerBackendIPv6(address string) bool { + host := address + if parsedHost, _, err := net.SplitHostPort(address); err == nil { + host = parsedHost + } else if strings.Count(address, ":") == 1 { + lastColon := strings.LastIndex(address, ":") + if lastColon != -1 { + host = address[:lastColon] + } + } + + parsedIP := net.ParseIP(host) + return parsedIP != nil && parsedIP.To4() == nil +} + +func (l *loadbalancers) buildNodeBalancerConfigNodes( + service *v1.Service, + nodes []*v1.Node, + nodePort int32, + subnetID int, + useIPv6Backends bool, + protocol linodego.ConfigProtocol, + oldNBNodeIDs map[string]int, +) ([]linodego.NodeBalancerConfigRebuildNodeOptions, error) { + newNBNodes := make([]linodego.NodeBalancerConfigRebuildNodeOptions, 0, len(nodes)) + for _, node := range nodes { + newNodeOpts, err := l.buildNodeBalancerNodeConfigRebuildOptions(service, node, nodePort, subnetID, useIPv6Backends, protocol) + if err != nil { + return nil, fmt.Errorf("failed to build NodeBalancer node config options for node %s: %w", node.Name, err) + } + oldNodeID, ok := oldNBNodeIDs[newNodeOpts.Address] + if ok { + newNodeOpts.ID = oldNodeID + } else { + klog.Infof("No preexisting node id for %v found.", newNodeOpts.Address) + } + newNBNodes = append(newNBNodes, *newNodeOpts) + } + + return newNBNodes, nil +} + func (l *loadbalancers) retrieveKubeClient() error { if l.kubeClient != nil { return nil @@ -1379,6 +1430,24 @@ func getNodePrivateIP(node *v1.Node, subnetID int) (string, error) { return "", fmt.Errorf("no internal IP found for node %s", node.Name) } +func getNodeBackendIP(service *v1.Service, node *v1.Node, subnetID int, useIPv6Backends bool) (string, error) { + if subnetID != 0 || !useIPv6Backends { + return getNodePrivateIP(node, subnetID) + } + + for _, addr := range node.Status.Addresses { + if addr.Type != v1.NodeExternalIP { + continue + } + if parsed := net.ParseIP(addr.Address); parsed != nil && parsed.To4() == nil { + return addr.Address, nil + } + } + + klog.Errorf("Service %s requested IPv6 backends but node %s does not have a public IPv6 address", getServiceNn(service), node.Name) + return "", fmt.Errorf("service %s requested IPv6 backends but node %s does not have a public IPv6 address", getServiceNn(service), node.Name) +} + func getTLSCertInfo(ctx context.Context, kubeClient kubernetes.Interface, namespace string, config portConfig) (string, string, error) { if config.TLSSecretName == "" { return "", "", fmt.Errorf("TLS secret name for port %v is not specified", config.Port) diff --git a/cloud/linode/loadbalancers_test.go b/cloud/linode/loadbalancers_test.go index d662dbd5..702a0c10 100644 --- a/cloud/linode/loadbalancers_test.go +++ b/cloud/linode/loadbalancers_test.go @@ -8,6 +8,7 @@ import ( stderrors "errors" "fmt" "math/rand" + "net" "net/http" "net/http/httptest" "os" @@ -4075,6 +4076,176 @@ func Test_getNodePrivateIP(t *testing.T) { } } +func Test_getNodeBackendIP(t *testing.T) { + service := &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "svc", + Namespace: "default", + }, + } + + testcases := []struct { + name string + node *v1.Node + subnetID int + useIPv6Backends bool + expectedIP string + expectErr bool + }{ + { + name: "uses existing IPv4 path for non-vpc services", + node: &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node-1", + Annotations: map[string]string{ + annotations.AnnLinodeNodePrivateIP: "192.168.10.10", + }, + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeInternalIP, Address: "10.0.0.2"}, + {Type: v1.NodeExternalIP, Address: "2600:3c06::1"}, + }, + }, + }, + expectedIP: "192.168.10.10", + }, + { + name: "uses IPv6 external ip when IPv6 backends are enabled", + node: &v1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "172.232.0.2"}, + {Type: v1.NodeExternalIP, Address: "2600:3c06::1"}, + }, + }, + }, + useIPv6Backends: true, + expectedIP: "2600:3c06::1", + }, + { + name: "preserves VPC backend behavior even when IPv6 is requested", + node: &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node-1", + Annotations: map[string]string{ + annotations.AnnLinodeNodePrivateIP: "192.168.10.10", + }, + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeInternalIP, Address: "10.0.0.2"}, + {Type: v1.NodeExternalIP, Address: "2600:3c06::1"}, + }, + }, + }, + subnetID: 100, + useIPv6Backends: true, + expectedIP: "10.0.0.2", + }, + { + name: "errors when IPv6 backends are requested and node lacks public IPv6", + node: &v1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "172.232.0.2"}, + }, + }, + }, + useIPv6Backends: true, + expectErr: true, + }, + } + + for _, test := range testcases { + t.Run(test.name, func(t *testing.T) { + ip, err := getNodeBackendIP(service, test.node, test.subnetID, test.useIPv6Backends) + if test.expectErr { + if err == nil { + t.Fatal("expected error") + } + return + } + + if err != nil { + t.Fatal(err) + } + + if ip != test.expectedIP { + t.Fatalf("expected backend address %q, got %q", test.expectedIP, ip) + } + }) + } +} + +func Test_shouldUseIPv6NodeBalancerBackends(t *testing.T) { + prev := options.Options.EnableIPv6ForNodeBalancerBackends + defer func() { + options.Options.EnableIPv6ForNodeBalancerBackends = prev + }() + + t.Run("service annotation overrides global flag", func(t *testing.T) { + options.Options.EnableIPv6ForNodeBalancerBackends = false + service := &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + annotations.AnnLinodeEnableIPv6Backends: "true", + }, + }, + } + if got := shouldUseIPv6NodeBalancerBackends(service, nil); !got { + t.Fatal("expected service annotation to enable IPv6 backends") + } + }) + + t.Run("new services respect global IPv6 backend flag", func(t *testing.T) { + options.Options.EnableIPv6ForNodeBalancerBackends = true + service := &v1.Service{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}}} + if got := shouldUseIPv6NodeBalancerBackends(service, nil); !got { + t.Fatal("expected global flag to enable IPv6 backends for new service") + } + }) + + t.Run("existing IPv4 nodebalancer backends stay IPv4 by default", func(t *testing.T) { + options.Options.EnableIPv6ForNodeBalancerBackends = true + service := &v1.Service{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}}} + currentNBNodes := []linodego.NodeBalancerNode{ + {Address: "10.0.0.10:30000"}, + } + if got := shouldUseIPv6NodeBalancerBackends(service, currentNBNodes); got { + t.Fatal("expected existing IPv4 nodebalancer backends to remain IPv4") + } + }) + + t.Run("existing IPv6 nodebalancer backends stay IPv6", func(t *testing.T) { + options.Options.EnableIPv6ForNodeBalancerBackends = false + service := &v1.Service{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}}} + currentNBNodes := []linodego.NodeBalancerNode{ + {Address: "[2600:3c06::1]:30000"}, + } + if got := shouldUseIPv6NodeBalancerBackends(service, currentNBNodes); !got { + t.Fatal("expected existing IPv6 nodebalancer backends to remain IPv6") + } + }) +} + +func Test_formatNodeBalancerBackendAddress(t *testing.T) { + if got := formatNodeBalancerBackendAddress("192.168.0.10", 30000); got != "192.168.0.10:30000" { + t.Fatalf("unexpected IPv4 backend address format: %s", got) + } + + got := formatNodeBalancerBackendAddress("2600:3c06::1", 30000) + host, port, err := net.SplitHostPort(got) + if err != nil { + t.Fatal(err) + } + if host != "2600:3c06::1" || port != "30000" { + t.Fatalf("unexpected IPv6 backend address format: host=%s port=%s", host, port) + } +} + func testBuildLoadBalancerRequest(t *testing.T, client *linodego.Client, _ *fakeAPI) { t.Helper() diff --git a/cloud/linode/options/options.go b/cloud/linode/options/options.go index 9a72d5f2..aac562f9 100644 --- a/cloud/linode/options/options.go +++ b/cloud/linode/options/options.go @@ -30,6 +30,7 @@ var Options struct { DisableNodeBalancerVPCBackends bool GlobalStopChannel chan<- struct{} EnableIPv6ForLoadBalancers bool + EnableIPv6ForNodeBalancerBackends bool AllocateNodeCIDRs bool DisableIPv6NodeCIDRAllocation bool ClusterCIDRIPv4 string diff --git a/main.go b/main.go index d477ea80..6e843d16 100644 --- a/main.go +++ b/main.go @@ -95,6 +95,7 @@ func main() { command.Flags().StringVar(&ccmOptions.Options.NodeBalancerBackendIPv4Subnet, "nodebalancer-backend-ipv4-subnet", "", "ipv4 subnet to use for NodeBalancer backends") command.Flags().StringSliceVar(&ccmOptions.Options.NodeBalancerTags, "nodebalancer-tags", []string{}, "Linode tags to apply to all NodeBalancers") command.Flags().BoolVar(&ccmOptions.Options.EnableIPv6ForLoadBalancers, "enable-ipv6-for-loadbalancers", false, "set both IPv4 and IPv6 addresses for all LoadBalancer services (when disabled, only IPv4 is used)") + command.Flags().BoolVar(&ccmOptions.Options.EnableIPv6ForNodeBalancerBackends, "enable-ipv6-for-nodebalancer-backends", false, "use public IPv6 addresses for new non-VPC NodeBalancer service backends (when enabled, all selected backend nodes must have public IPv6)") command.Flags().IntVar(&ccmOptions.Options.NodeCIDRMaskSizeIPv4, "node-cidr-mask-size-ipv4", 0, "ipv4 cidr mask size for pod cidrs allocated to nodes") command.Flags().IntVar(&ccmOptions.Options.NodeCIDRMaskSizeIPv6, "node-cidr-mask-size-ipv6", 0, "ipv6 cidr mask size for pod cidrs allocated to nodes") command.Flags().IntVar(&ccmOptions.Options.NodeBalancerBackendIPv4SubnetID, "nodebalancer-backend-ipv4-subnet-id", 0, "ipv4 subnet id to use for NodeBalancer backends") From 37c5a012ca55a95bc2b1d6c4238cf543e870bd8c Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Wed, 11 Mar 2026 20:22:17 -0500 Subject: [PATCH 02/31] Document IPv6 NodeBalancer backends --- deploy/chart/templates/daemonset.yaml | 3 +++ deploy/chart/values.yaml | 5 +++++ docs/configuration/annotations.md | 1 + docs/configuration/environment.md | 1 + docs/configuration/loadbalancer.md | 32 +++++++++++++++++++++++++++ 5 files changed, 42 insertions(+) diff --git a/deploy/chart/templates/daemonset.yaml b/deploy/chart/templates/daemonset.yaml index fa9387b3..f1f37e4e 100644 --- a/deploy/chart/templates/daemonset.yaml +++ b/deploy/chart/templates/daemonset.yaml @@ -172,6 +172,9 @@ spec: {{- if .Values.enableIPv6ForLoadBalancers }} - --enable-ipv6-for-loadbalancers={{ .Values.enableIPv6ForLoadBalancers }} {{- end }} + {{- if .Values.enableIPv6ForNodeBalancerBackends }} + - --enable-ipv6-for-nodebalancer-backends={{ .Values.enableIPv6ForNodeBalancerBackends }} + {{- end }} {{- if .Values.nodeBalancerBackendIPv4Subnet }} - --nodebalancer-backend-ipv4-subnet={{ .Values.nodeBalancerBackendIPv4Subnet }} {{- end }} diff --git a/deploy/chart/values.yaml b/deploy/chart/values.yaml index 2f55f503..9aea51c5 100644 --- a/deploy/chart/values.yaml +++ b/deploy/chart/values.yaml @@ -108,6 +108,11 @@ tolerations: # This can also be controlled per-service using the "service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-ingress" annotation # enableIPv6ForLoadBalancers: true +# Enable public IPv6 backend addresses for newly created non-VPC NodeBalancer services +# Existing services are not migrated automatically, and all selected backend nodes must have public IPv6 +# This can also be controlled per-service using the "service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends" annotation +# enableIPv6ForNodeBalancerBackends: false + # disableNodeBalancerVPCBackends is used to disable the use of VPC backends for NodeBalancers. # When set to true, NodeBalancers will use linode private IPs for backends instead of VPC IPs. # disableNodeBalancerVPCBackends: false diff --git a/docs/configuration/annotations.md b/docs/configuration/annotations.md index 69144cf3..443ccd49 100644 --- a/docs/configuration/annotations.md +++ b/docs/configuration/annotations.md @@ -40,6 +40,7 @@ The keys and the values in [annotations must be strings](https://kubernetes.io/d | `firewall-acl` | string | | The Firewall rules to be applied to the NodeBalancer. See [Firewall Configuration](#firewall-configuration) | | `nodebalancer-type` | string | | The type of NodeBalancer to create (options: common, premium, premium_40gb). See [NodeBalancer Types](#nodebalancer-type). Note: NodeBalancer types should always be specified in lowercase. | | `enable-ipv6-ingress` | bool | `false` | When `true`, both IPv4 and IPv6 addresses will be included in the LoadBalancerStatus ingress | +| `enable-ipv6-backends` | bool | `false` | When `true`, non-VPC NodeBalancer services use public IPv6 backend nodes, and reconciliation fails if a selected backend node does not have public IPv6. | | `backend-ipv4-range` | string | | The IPv4 range from VPC subnet to be applied to the NodeBalancer backend. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) | | `backend-vpc-name` | string | | VPC which is connected to the NodeBalancer backend. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) | | `backend-subnet-name` | string | | Subnet within VPC which is connected to the NodeBalancer backend. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) | diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index cbed0124..c287a0f6 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -53,6 +53,7 @@ The CCM supports the following flags: | `--nodebalancer-backend-ipv4-subnet-name` | String | `""` | ipv4 subnet name to use for NodeBalancer backends | | `--disable-nodebalancer-vpc-backends` | Boolean | `false` | don't use VPC specific ip-addresses for nodebalancer backend ips when running in VPC (set to `true` for backward compatibility if needed) | | `--enable-ipv6-for-loadbalancers` | Boolean | `false` | Set both IPv4 and IPv6 addresses for all LoadBalancer services (when disabled, only IPv4 is used). This can also be configured per-service using the `service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-ingress` annotation. | +| `--enable-ipv6-for-nodebalancer-backends` | Boolean | `false` | Use public IPv6 addresses for non-VPC NodeBalancer service backends. If enabled, every selected backend node must have public IPv6 or reconciliation will fail. This can also be configured per-service using the `service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends` annotation. | | `--node-cidr-mask-size-ipv4` | Int | `24` | ipv4 cidr mask size for pod cidrs allocated to nodes | | `--node-cidr-mask-size-ipv6` | Int | `64` | ipv6 cidr mask size for pod cidrs allocated to nodes | | `--nodebalancer-prefix` | String | `ccm` | Name prefix for NoadBalancers. | diff --git a/docs/configuration/loadbalancer.md b/docs/configuration/loadbalancer.md index 742d3e37..1cdbf4f5 100644 --- a/docs/configuration/loadbalancer.md +++ b/docs/configuration/loadbalancer.md @@ -44,6 +44,38 @@ metadata: When IPv6 is enabled (either globally or per-service), both IPv4 and IPv6 addresses will be included in the service's LoadBalancer status. +### IPv6 Backend Support + +IPv6 frontends and IPv6 backends are configured independently. Frontend IPv6 controls what the Service publishes in `status.loadBalancer.ingress`, while backend IPv6 controls which node addresses a NodeBalancer targets. + +For newly created non-VPC NodeBalancer services, you can enable public IPv6 backends globally: + +```yaml +spec: + template: + spec: + containers: + - name: ccm-linode + args: + - --enable-ipv6-for-nodebalancer-backends=true +``` + +Or per service: + +```yaml +metadata: + annotations: + service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends: "true" +``` + +When IPv6 backends are enabled: +- only non-VPC NodeBalancer services are affected +- existing services are not migrated automatically +- every selected backend node must have public IPv6 +- reconciliation fails and CCM logs an error if a selected backend node does not have public IPv6 + +VPC backend behavior is unchanged and continues to use the existing IPv4/VPC backend flow. + ### Basic Configuration Create a LoadBalancer service: From a7643997946ecfd80c7a4a95b2792cf04d5e63cd Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Wed, 11 Mar 2026 20:22:33 -0500 Subject: [PATCH 03/31] Add Chainsaw test for IPv6 NodeBalancer backends --- .../lb-with-ipv6-backends/chainsaw-test.yaml | 126 ++++++++++++++++++ .../create-pods-services.yaml | 49 +++++++ 2 files changed, 175 insertions(+) create mode 100644 e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml create mode 100644 e2e/test/lb-with-ipv6-backends/create-pods-services.yaml diff --git a/e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml b/e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml new file mode 100644 index 00000000..beb877ef --- /dev/null +++ b/e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml @@ -0,0 +1,126 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/kyverno/chainsaw/main/.schemas/json/test-chainsaw-v1alpha1.json +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: lb-with-ipv6-backends + labels: + all: +spec: + namespace: "lb-with-ipv6-backends" + catch: + - script: + content: | + set -euo pipefail + echo "Test failed. Fetching CCM logs..." + kubectl logs -n kube-system daemonsets/ccm-linode | grep "lb-with-ipv6-backends" | tail -100 + steps: + - name: Create pods and service + try: + - apply: + file: create-pods-services.yaml + catch: + - describe: + apiVersion: v1 + kind: Pod + - describe: + apiVersion: v1 + kind: Service + - name: Check that loadbalancer ip is assigned + try: + - assert: + resource: + apiVersion: v1 + kind: Service + metadata: + name: svc-test + status: + (loadBalancer.ingress[0].ip != null): true + - name: Check NodeBalancer backend addresses are IPv6 + try: + - script: + content: | + set -euo pipefail + + nbid=$(KUBECONFIG=$KUBECONFIG NAMESPACE=$NAMESPACE LINODE_TOKEN=$LINODE_TOKEN ../scripts/get-nb-id.sh) + + nbconfig=$(curl -s \ + -H "Authorization: Bearer $LINODE_TOKEN" \ + -H "Content-Type: application/json" --fail-early --retry 3 \ + "$LINODE_URL/v4/nodebalancers/$nbid/configs") + + config_id=$(echo "$nbconfig" | jq -r '.data[] | select(.port == 80) | .id') + + nodes=$(curl -s \ + -H "Authorization: Bearer $LINODE_TOKEN" \ + -H "Content-Type: application/json" --fail-early --retry 3 \ + "$LINODE_URL/v4/nodebalancers/$nbid/configs/$config_id/nodes") + + addresses=$(echo "$nodes" | jq -r '.data[].address') + + for address in $addresses; do + if [[ $address =~ ^\[(.*)\]:([0-9]+)$ ]]; then + host="${BASH_REMATCH[1]}" + else + host="${address%:*}" + fi + + if [[ $host == *:* ]]; then + echo "$address is IPv6" + else + echo "$address is NOT IPv6" + fi + done + check: + ($error): ~ + (contains($stdout, 'is NOT IPv6')): false + - name: Fetch loadbalancer ip and check both pods reachable + try: + - script: + content: | + set -euo pipefail + IP=$(kubectl get svc svc-test -n $NAMESPACE -o json | jq -r .status.loadBalancer.ingress[0].ip) + + podnames=() + + for i in {1..10}; do + if [[ ${#podnames[@]} -lt 2 ]]; then + output=$(curl -s $IP:80 | jq -e .podName || true) + + if [[ "$output" == *"test-"* ]]; then + unique=true + for existing in "${podnames[@]}"; do + if [[ "$existing" == "$output" ]]; then + unique=false + break + fi + done + if [[ "$unique" == true ]]; then + podnames+=($output) + fi + fi + else + break + fi + sleep 10 + done + + if [[ ${#podnames[@]} -lt 2 ]]; then + echo "all pods failed to respond" + else + echo "all pods responded" + fi + check: + ($error == null): true + (contains($stdout, 'all pods responded')): true + - name: Delete Pods + try: + - delete: + ref: + apiVersion: v1 + kind: Pod + - name: Delete Service + try: + - delete: + ref: + apiVersion: v1 + kind: Service diff --git a/e2e/test/lb-with-ipv6-backends/create-pods-services.yaml b/e2e/test/lb-with-ipv6-backends/create-pods-services.yaml new file mode 100644 index 00000000..b4147041 --- /dev/null +++ b/e2e/test/lb-with-ipv6-backends/create-pods-services.yaml @@ -0,0 +1,49 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: lb-ipv6-backends + name: test +spec: + replicas: 2 + selector: + matchLabels: + app: lb-ipv6-backends + template: + metadata: + labels: + app: lb-ipv6-backends + spec: + containers: + - image: appscode/test-server:2.3 + name: test + ports: + - name: http-1 + containerPort: 8080 + protocol: TCP + env: + - name: POD_NAME + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: metadata.name +--- +apiVersion: v1 +kind: Service +metadata: + name: svc-test + annotations: + service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends: "true" + labels: + app: lb-ipv6-backends +spec: + type: LoadBalancer + selector: + app: lb-ipv6-backends + ports: + - name: http-1 + protocol: TCP + port: 80 + targetPort: 8080 + sessionAffinity: None From 8f6ae70d77cb42a32fa7e5c449e3cf940f920de5 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Wed, 11 Mar 2026 22:35:14 -0500 Subject: [PATCH 04/31] Ignore local development artifacts --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index d23661ad..83aae1f5 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,8 @@ coverage.txt junit.xml .DS_Store + +# Local cluster artifacts +capl-cluster-manifests.yaml +*-kubeconfig.yaml +.opencode/ From 1ab373800cc941ff6bf78019bbd47d909445db12 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Thu, 12 Mar 2026 00:27:25 -0500 Subject: [PATCH 05/31] Honor IPv6 backend annotation over VPC backends --- cloud/linode/loadbalancers.go | 23 ++++--- cloud/linode/loadbalancers_test.go | 101 +++++++++++++++++------------ 2 files changed, 75 insertions(+), 49 deletions(-) diff --git a/cloud/linode/loadbalancers.go b/cloud/linode/loadbalancers.go index 560f1ddc..dc120c85 100644 --- a/cloud/linode/loadbalancers.go +++ b/cloud/linode/loadbalancers.go @@ -505,7 +505,10 @@ func (l *loadbalancers) updateNodeBalancer( subnetID = id } - useIPv6Backends := shouldUseIPv6NodeBalancerBackends(service, currentNBNodes) + useIPv6Backends, ignoreVPCBackends := resolveIPv6NodeBalancerBackendState(service, currentNBNodes) + if ignoreVPCBackends { + subnetID = 0 + } newNBNodes, err := l.buildNodeBalancerConfigNodes(service, nodes, port.NodePort, subnetID, useIPv6Backends, newNBCfg.Protocol, oldNBNodeIDs) if err != nil { return err @@ -949,7 +952,8 @@ func (l *loadbalancers) createNodeBalancer(ctx context.Context, clusterName stri Type: nbType, } - if len(options.Options.VPCNames) > 0 && !options.Options.DisableNodeBalancerVPCBackends { + _, ignoreVPCBackends := resolveIPv6NodeBalancerBackendState(service, nil) + if len(options.Options.VPCNames) > 0 && !options.Options.DisableNodeBalancerVPCBackends && !ignoreVPCBackends { createOpts.VPCs, err = l.getVPCCreateOptions(ctx, service) if err != nil { return nil, err @@ -1148,7 +1152,7 @@ func (l *loadbalancers) buildLoadBalancerRequest(ctx context.Context, clusterNam } ports := service.Spec.Ports configs := make([]*linodego.NodeBalancerConfigCreateOptions, 0, len(ports)) - useIPv6Backends := shouldUseIPv6NodeBalancerBackends(service, nil) + useIPv6Backends, ignoreVPCBackends := resolveIPv6NodeBalancerBackendState(service, nil) subnetID := 0 if options.Options.NodeBalancerBackendIPv4SubnetID != 0 { @@ -1161,7 +1165,7 @@ func (l *loadbalancers) buildLoadBalancerRequest(ctx context.Context, clusterNam return nil, err } } - if len(options.Options.VPCNames) > 0 && !options.Options.DisableNodeBalancerVPCBackends { + if len(options.Options.VPCNames) > 0 && !options.Options.DisableNodeBalancerVPCBackends && !ignoreVPCBackends { id, err := l.getSubnetIDForSVC(ctx, service) if err != nil { return nil, err @@ -1226,17 +1230,20 @@ func (l *loadbalancers) buildNodeBalancerNodeConfigRebuildOptions(service *v1.Se return nodeOptions, nil } -func shouldUseIPv6NodeBalancerBackends(service *v1.Service, currentNBNodes []linodego.NodeBalancerNode) bool { +func resolveIPv6NodeBalancerBackendState(service *v1.Service, currentNBNodes []linodego.NodeBalancerNode) (useIPv6Backends bool, ignoreVPCBackends bool) { useIPv6 := getServiceBoolAnnotation(service, annotations.AnnLinodeEnableIPv6Backends) if useIPv6 != nil { - return *useIPv6 + return *useIPv6, *useIPv6 } if len(currentNBNodes) > 0 { - return isNodeBalancerBackendIPv6(currentNBNodes[0].Address) + if isNodeBalancerBackendIPv6(currentNBNodes[0].Address) { + return true, true + } + return false, false } - return options.Options.EnableIPv6ForNodeBalancerBackends + return options.Options.EnableIPv6ForNodeBalancerBackends, false } func formatNodeBalancerBackendAddress(ip string, nodePort int32) string { diff --git a/cloud/linode/loadbalancers_test.go b/cloud/linode/loadbalancers_test.go index 702a0c10..29642e3f 100644 --- a/cloud/linode/loadbalancers_test.go +++ b/cloud/linode/loadbalancers_test.go @@ -4180,55 +4180,74 @@ func Test_getNodeBackendIP(t *testing.T) { } } -func Test_shouldUseIPv6NodeBalancerBackends(t *testing.T) { +func Test_resolveIPv6NodeBalancerBackendState(t *testing.T) { prev := options.Options.EnableIPv6ForNodeBalancerBackends defer func() { options.Options.EnableIPv6ForNodeBalancerBackends = prev }() - t.Run("service annotation overrides global flag", func(t *testing.T) { - options.Options.EnableIPv6ForNodeBalancerBackends = false - service := &v1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Annotations: map[string]string{ - annotations.AnnLinodeEnableIPv6Backends: "true", + testcases := []struct { + name string + globalFlag bool + service *v1.Service + currentNBNodes []linodego.NodeBalancerNode + expectedUseIPv6 bool + expectedIgnoreVPC bool + }{ + { + name: "service annotation enables IPv6 and overrides VPC behavior", + globalFlag: false, + service: &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + annotations.AnnLinodeEnableIPv6Backends: "true", + }, }, }, - } - if got := shouldUseIPv6NodeBalancerBackends(service, nil); !got { - t.Fatal("expected service annotation to enable IPv6 backends") - } - }) - - t.Run("new services respect global IPv6 backend flag", func(t *testing.T) { - options.Options.EnableIPv6ForNodeBalancerBackends = true - service := &v1.Service{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}}} - if got := shouldUseIPv6NodeBalancerBackends(service, nil); !got { - t.Fatal("expected global flag to enable IPv6 backends for new service") - } - }) - - t.Run("existing IPv4 nodebalancer backends stay IPv4 by default", func(t *testing.T) { - options.Options.EnableIPv6ForNodeBalancerBackends = true - service := &v1.Service{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}}} - currentNBNodes := []linodego.NodeBalancerNode{ - {Address: "10.0.0.10:30000"}, - } - if got := shouldUseIPv6NodeBalancerBackends(service, currentNBNodes); got { - t.Fatal("expected existing IPv4 nodebalancer backends to remain IPv4") - } - }) + expectedUseIPv6: true, + expectedIgnoreVPC: true, + }, + { + name: "new services respect global IPv6 backend flag without ignoring VPC", + globalFlag: true, + service: &v1.Service{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}}}, + expectedUseIPv6: true, + expectedIgnoreVPC: false, + }, + { + name: "existing IPv4 nodebalancer backends stay IPv4 by default", + globalFlag: true, + service: &v1.Service{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}}}, + currentNBNodes: []linodego.NodeBalancerNode{ + {Address: "10.0.0.10:30000"}, + }, + expectedUseIPv6: false, + expectedIgnoreVPC: false, + }, + { + name: "existing IPv6 nodebalancer backends stay IPv6 and ignore VPC", + globalFlag: false, + service: &v1.Service{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}}}, + currentNBNodes: []linodego.NodeBalancerNode{ + {Address: "[2600:3c06::1]:30000"}, + }, + expectedUseIPv6: true, + expectedIgnoreVPC: true, + }, + } - t.Run("existing IPv6 nodebalancer backends stay IPv6", func(t *testing.T) { - options.Options.EnableIPv6ForNodeBalancerBackends = false - service := &v1.Service{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}}} - currentNBNodes := []linodego.NodeBalancerNode{ - {Address: "[2600:3c06::1]:30000"}, - } - if got := shouldUseIPv6NodeBalancerBackends(service, currentNBNodes); !got { - t.Fatal("expected existing IPv6 nodebalancer backends to remain IPv6") - } - }) + for _, test := range testcases { + t.Run(test.name, func(t *testing.T) { + options.Options.EnableIPv6ForNodeBalancerBackends = test.globalFlag + useIPv6Backends, ignoreVPCBackends := resolveIPv6NodeBalancerBackendState(test.service, test.currentNBNodes) + if useIPv6Backends != test.expectedUseIPv6 { + t.Fatalf("expected useIPv6Backends=%t, got %t", test.expectedUseIPv6, useIPv6Backends) + } + if ignoreVPCBackends != test.expectedIgnoreVPC { + t.Fatalf("expected ignoreVPCBackends=%t, got %t", test.expectedIgnoreVPC, ignoreVPCBackends) + } + }) + } } func Test_formatNodeBalancerBackendAddress(t *testing.T) { From b33692105f4f1f4168cec023a50827c3d7ed28b0 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Thu, 12 Mar 2026 00:27:25 -0500 Subject: [PATCH 06/31] Use dual-stack CAPL flavor for IPv6 backend validation --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index da2600ac..c79c0cd2 100644 --- a/Makefile +++ b/Makefile @@ -170,7 +170,7 @@ generate-capl-cluster-manifests: # Create the CAPL cluster manifests without any CSI driver stuff LINODE_FIREWALL_ENABLED=$(LINODE_FIREWALL_ENABLED) LINODE_OS=$(LINODE_OS) VPC_NAME=$(VPC_NAME) clusterctl generate cluster $(CLUSTER_NAME) \ --kubernetes-version $(K8S_VERSION) --infrastructure linode-linode:$(CAPL_VERSION) \ - --control-plane-machine-count $(CONTROLPLANE_NODES) --worker-machine-count $(WORKER_NODES) > $(MANIFEST_NAME).yaml + --control-plane-machine-count $(CONTROLPLANE_NODES) --worker-machine-count $(WORKER_NODES) --flavor kubeadm-dual-stack > $(MANIFEST_NAME).yaml yq -i e 'select(.kind == "LinodeVPC").spec.subnets = [{"ipv4": "10.0.0.0/8", "label": "default"}, {"ipv4": "172.16.0.0/16", "label": "testing"}]' $(MANIFEST_NAME).yaml .PHONY: create-capl-cluster From 9ed47f71fd2a51881901783f586e2b794e8e9cf8 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Thu, 12 Mar 2026 00:27:25 -0500 Subject: [PATCH 07/31] Make IPv6 backend e2e service dual-stack --- e2e/test/lb-with-ipv6-backends/create-pods-services.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/e2e/test/lb-with-ipv6-backends/create-pods-services.yaml b/e2e/test/lb-with-ipv6-backends/create-pods-services.yaml index b4147041..dcbb1962 100644 --- a/e2e/test/lb-with-ipv6-backends/create-pods-services.yaml +++ b/e2e/test/lb-with-ipv6-backends/create-pods-services.yaml @@ -39,6 +39,10 @@ metadata: app: lb-ipv6-backends spec: type: LoadBalancer + ipFamilyPolicy: RequireDualStack + ipFamilies: + - IPv4 + - IPv6 selector: app: lb-ipv6-backends ports: From de017406c8e3478eff1a42c4766f93fe3a971eee Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Thu, 12 Mar 2026 00:27:25 -0500 Subject: [PATCH 08/31] Fix IPv6 backend Chainsaw reachability check --- e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml b/e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml index beb877ef..9c8c1324 100644 --- a/e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml +++ b/e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml @@ -77,14 +77,13 @@ spec: try: - script: content: | - set -euo pipefail - IP=$(kubectl get svc svc-test -n $NAMESPACE -o json | jq -r .status.loadBalancer.ingress[0].ip) - + bash -ce ' + IP=$(kubectl get svc svc-test -n "$NAMESPACE" -o json | jq -r .status.loadBalancer.ingress[0].ip) podnames=() for i in {1..10}; do if [[ ${#podnames[@]} -lt 2 ]]; then - output=$(curl -s $IP:80 | jq -e .podName || true) + output=$(curl -s "$IP":80 | jq -e .podName || true) if [[ "$output" == *"test-"* ]]; then unique=true @@ -95,7 +94,7 @@ spec: fi done if [[ "$unique" == true ]]; then - podnames+=($output) + podnames+=("$output") fi fi else @@ -109,6 +108,7 @@ spec: else echo "all pods responded" fi + ' check: ($error == null): true (contains($stdout, 'all pods responded')): true From d874876a0c5313cf61a0a7a544205e16d8965fa7 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Thu, 12 Mar 2026 00:27:26 -0500 Subject: [PATCH 09/31] Document dual-stack requirement for IPv6 backends --- docs/configuration/annotations.md | 2 +- docs/configuration/environment.md | 2 +- docs/configuration/loadbalancer.md | 27 +++++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/configuration/annotations.md b/docs/configuration/annotations.md index 443ccd49..ceb2e3ab 100644 --- a/docs/configuration/annotations.md +++ b/docs/configuration/annotations.md @@ -40,7 +40,7 @@ The keys and the values in [annotations must be strings](https://kubernetes.io/d | `firewall-acl` | string | | The Firewall rules to be applied to the NodeBalancer. See [Firewall Configuration](#firewall-configuration) | | `nodebalancer-type` | string | | The type of NodeBalancer to create (options: common, premium, premium_40gb). See [NodeBalancer Types](#nodebalancer-type). Note: NodeBalancer types should always be specified in lowercase. | | `enable-ipv6-ingress` | bool | `false` | When `true`, both IPv4 and IPv6 addresses will be included in the LoadBalancerStatus ingress | -| `enable-ipv6-backends` | bool | `false` | When `true`, non-VPC NodeBalancer services use public IPv6 backend nodes, and reconciliation fails if a selected backend node does not have public IPv6. | +| `enable-ipv6-backends` | bool | `false` | When `true`, non-VPC NodeBalancer services use public IPv6 backend nodes. This requires a dual-stack cluster and a dual-stack Service configuration. Reconciliation fails if a selected backend node does not have public IPv6. | | `backend-ipv4-range` | string | | The IPv4 range from VPC subnet to be applied to the NodeBalancer backend. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) | | `backend-vpc-name` | string | | VPC which is connected to the NodeBalancer backend. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) | | `backend-subnet-name` | string | | Subnet within VPC which is connected to the NodeBalancer backend. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) | diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index c287a0f6..3a07d61b 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -53,7 +53,7 @@ The CCM supports the following flags: | `--nodebalancer-backend-ipv4-subnet-name` | String | `""` | ipv4 subnet name to use for NodeBalancer backends | | `--disable-nodebalancer-vpc-backends` | Boolean | `false` | don't use VPC specific ip-addresses for nodebalancer backend ips when running in VPC (set to `true` for backward compatibility if needed) | | `--enable-ipv6-for-loadbalancers` | Boolean | `false` | Set both IPv4 and IPv6 addresses for all LoadBalancer services (when disabled, only IPv4 is used). This can also be configured per-service using the `service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-ingress` annotation. | -| `--enable-ipv6-for-nodebalancer-backends` | Boolean | `false` | Use public IPv6 addresses for non-VPC NodeBalancer service backends. If enabled, every selected backend node must have public IPv6 or reconciliation will fail. This can also be configured per-service using the `service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends` annotation. | +| `--enable-ipv6-for-nodebalancer-backends` | Boolean | `false` | Use public IPv6 addresses for non-VPC NodeBalancer service backends. This requires a dual-stack cluster and dual-stack Service configuration. If enabled, every selected backend node must have public IPv6 or reconciliation will fail. This can also be configured per-service using the `service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends` annotation. | | `--node-cidr-mask-size-ipv4` | Int | `24` | ipv4 cidr mask size for pod cidrs allocated to nodes | | `--node-cidr-mask-size-ipv6` | Int | `64` | ipv6 cidr mask size for pod cidrs allocated to nodes | | `--nodebalancer-prefix` | String | `ccm` | Name prefix for NoadBalancers. | diff --git a/docs/configuration/loadbalancer.md b/docs/configuration/loadbalancer.md index 1cdbf4f5..8931c12f 100644 --- a/docs/configuration/loadbalancer.md +++ b/docs/configuration/loadbalancer.md @@ -48,6 +48,8 @@ When IPv6 is enabled (either globally or per-service), both IPv4 and IPv6 addres IPv6 frontends and IPv6 backends are configured independently. Frontend IPv6 controls what the Service publishes in `status.loadBalancer.ingress`, while backend IPv6 controls which node addresses a NodeBalancer targets. +IPv6 backends require a dual-stack workload cluster. In practice, the cluster networking stack must support IPv6 NodePort traffic, and the Service itself should be created as dual-stack. A single-stack IPv4 `LoadBalancer` Service can still be annotated for IPv6 backends, but the NodeBalancer health checks and traffic path may fail because the backend NodePort is not exposed over IPv6. + For newly created non-VPC NodeBalancer services, you can enable public IPv6 backends globally: ```yaml @@ -72,10 +74,35 @@ When IPv6 backends are enabled: - only non-VPC NodeBalancer services are affected - existing services are not migrated automatically - every selected backend node must have public IPv6 +- the workload cluster and Service must be configured for dual-stack networking - reconciliation fails and CCM logs an error if a selected backend node does not have public IPv6 VPC backend behavior is unchanged and continues to use the existing IPv4/VPC backend flow. +Recommended Service configuration for IPv6 backends: + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: my-service + annotations: + service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends: "true" +spec: + type: LoadBalancer + ipFamilyPolicy: RequireDualStack + ipFamilies: + - IPv4 + - IPv6 + ports: + - port: 80 + targetPort: 8080 + selector: + app: my-app +``` + +If your cluster does not provide IPv6-capable NodePort routing, the NodeBalancer may still be created with IPv6 backend addresses, but the backends will not become healthy. + ### Basic Configuration Create a LoadBalancer service: From efe810eb42d01232d26e01ed69256a531a42436c Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Thu, 12 Mar 2026 14:52:32 -0500 Subject: [PATCH 10/31] Bump CAPL management cluster toolchain --- Makefile | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index c79c0cd2..964632b0 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,8 @@ LOCALBIN ?= $(CACHE_BIN) DEVBOX_BIN ?= $(DEVBOX_PACKAGES_DIR)/bin HELM ?= $(LOCALBIN)/helm HELM_VERSION ?= v3.16.3 +CLUSTERCTL ?= $(LOCALBIN)/clusterctl +CLUSTERCTL_VERSION ?= v1.12.3 GOLANGCI_LINT ?= $(LOCALBIN)/golangci-lint GOLANGCI_LINT_NILAWAY ?= $(CACHE_BIN)/golangci-lint-nilaway @@ -26,13 +28,13 @@ SUBNET_MANIFEST_NAME ?= subnet-testing-manifests K8S_VERSION ?= "v1.31.2" # renovate: datasource=github-tags depName=kubernetes-sigs/cluster-api -CAPI_VERSION ?= "v1.8.5" +CAPI_VERSION ?= "v1.12.3" # renovate: datasource=github-tags depName=kubernetes-sigs/cluster-api-addon-provider-helm -CAAPH_VERSION ?= "v0.2.1" +CAAPH_VERSION ?= "v0.6.1" # renovate: datasource=github-tags depName=linode/cluster-api-provider-linode -CAPL_VERSION ?= "v0.8.5" +CAPL_VERSION ?= "v0.10.1" # renovate: datasource=github-tags depName=golangci/golangci-lint GOLANGCI_LINT_VERSION ?= "v2.7.2" @@ -168,7 +170,7 @@ capl-cluster: generate-capl-cluster-manifests create-capl-cluster patch-linode-c .PHONY: generate-capl-cluster-manifests generate-capl-cluster-manifests: # Create the CAPL cluster manifests without any CSI driver stuff - LINODE_FIREWALL_ENABLED=$(LINODE_FIREWALL_ENABLED) LINODE_OS=$(LINODE_OS) VPC_NAME=$(VPC_NAME) clusterctl generate cluster $(CLUSTER_NAME) \ + LINODE_FIREWALL_ENABLED=$(LINODE_FIREWALL_ENABLED) LINODE_OS=$(LINODE_OS) VPC_NAME=$(VPC_NAME) $(CLUSTERCTL) generate cluster $(CLUSTER_NAME) \ --kubernetes-version $(K8S_VERSION) --infrastructure linode-linode:$(CAPL_VERSION) \ --control-plane-machine-count $(CONTROLPLANE_NODES) --worker-machine-count $(WORKER_NODES) --flavor kubeadm-dual-stack > $(MANIFEST_NAME).yaml yq -i e 'select(.kind == "LinodeVPC").spec.subnets = [{"ipv4": "10.0.0.0/8", "label": "default"}, {"ipv4": "172.16.0.0/16", "label": "testing"}]' $(MANIFEST_NAME).yaml @@ -179,7 +181,7 @@ create-capl-cluster: kubectl apply -f $(MANIFEST_NAME).yaml kubectl wait --for=condition=ControlPlaneReady cluster/$(CLUSTER_NAME) --timeout=600s || (kubectl get cluster -o yaml; kubectl get linodecluster -o yaml; kubectl get linodemachines -o yaml; kubectl logs -n capl-system deployments/capl-controller-manager --tail=50) kubectl wait --for=condition=NodeHealthy=true machines -l cluster.x-k8s.io/cluster-name=$(CLUSTER_NAME) --timeout=900s - clusterctl get kubeconfig $(CLUSTER_NAME) > $(KUBECONFIG_PATH) + $(CLUSTERCTL) get kubeconfig $(CLUSTER_NAME) > $(KUBECONFIG_PATH) KUBECONFIG=$(KUBECONFIG_PATH) kubectl wait --for=condition=Ready nodes --all --timeout=600s # Remove all taints from control plane node so that pods scheduled on it by tests can run (without this, some tests fail) KUBECONFIG=$(KUBECONFIG_PATH) kubectl taint nodes -l node-role.kubernetes.io/control-plane node-role.kubernetes.io/control-plane- @@ -192,10 +194,10 @@ patch-linode-ccm: KUBECONFIG=$(KUBECONFIG_PATH) kubectl -n kube-system get daemonset/ccm-linode -o yaml .PHONY: mgmt-cluster -mgmt-cluster: +mgmt-cluster: clusterctl # Create a mgmt cluster ctlptl apply -f e2e/setup/ctlptl-config.yaml - clusterctl init \ + $(CLUSTERCTL) init \ --wait-providers \ --wait-provider-timeout 600 \ --core cluster-api:$(CAPI_VERSION) \ @@ -295,13 +297,13 @@ helm-template: helm .PHONY: kubectl kubectl: $(KUBECTL) ## Download kubectl locally if necessary. $(KUBECTL): $(LOCALBIN) - curl -fsSL https://dl.k8s.io/release/$(KUBECTL_VERSION)/bin/$(OS)/$(ARCH_SHORT)/kubectl -o $(KUBECTL) + curl -fsSL https://dl.k8s.io/release/$(KUBECTL_VERSION)/bin/$(HOSTOS)/$(ARCH_SHORT)/kubectl -o $(KUBECTL) chmod +x $(KUBECTL) .PHONY: clusterctl clusterctl: $(CLUSTERCTL) ## Download clusterctl locally if necessary. $(CLUSTERCTL): $(LOCALBIN) - curl -fsSL https://github.com/kubernetes-sigs/cluster-api/releases/download/$(CLUSTERCTL_VERSION)/clusterctl-$(OS)-$(ARCH_SHORT) -o $(CLUSTERCTL) + curl -fsSL https://github.com/kubernetes-sigs/cluster-api/releases/download/$(CLUSTERCTL_VERSION)/clusterctl-$(HOSTOS)-$(ARCH_SHORT) -o $(CLUSTERCTL) chmod +x $(CLUSTERCTL) .phony: golangci-lint-nilaway From bf1107088d4e0fe2ed05e0e8e587cc2c1e6c5581 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Thu, 12 Mar 2026 15:04:35 -0500 Subject: [PATCH 11/31] Pin devbox clusterctl for v1beta2 CAPL --- devbox.json | 4 ++-- devbox.lock | 24 ++++++++++++------------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/devbox.json b/devbox.json index cd496300..7d9f29a4 100644 --- a/devbox.json +++ b/devbox.json @@ -1,7 +1,6 @@ { "packages": [ "ctlptl@latest", - "clusterctl@latest", "docker@29.2.0", "envsubst@latest", "go@1.25.1", @@ -12,7 +11,8 @@ "kustomize@5.8.0", "kyverno-chainsaw@latest", "mockgen@1.6.0", - "yq-go@4.52.2" + "yq-go@4.52.2", + "clusterctl@1.12.2" ], "shell": { "init_hook": [ diff --git a/devbox.lock b/devbox.lock index 47f5c5db..d171cedc 100644 --- a/devbox.lock +++ b/devbox.lock @@ -1,51 +1,51 @@ { "lockfile_version": "1", "packages": { - "clusterctl@latest": { - "last_modified": "2025-05-16T20:19:48Z", - "resolved": "github:NixOS/nixpkgs/12a55407652e04dcf2309436eb06fef0d3713ef3#clusterctl", + "clusterctl@1.12.2": { + "last_modified": "2026-02-23T15:40:43Z", + "resolved": "github:NixOS/nixpkgs/80d901ec0377e19ac3f7bb8c035201e2e098cc97#clusterctl", "source": "devbox-search", - "version": "1.10.1", + "version": "1.12.2", "systems": { "aarch64-darwin": { "outputs": [ { "name": "out", - "path": "/nix/store/yqjd06qrwmh64rfcrjsvhfkn555gnl77-clusterctl-1.10.1", + "path": "/nix/store/778jxvqkkq2417wf7khzfwm3w1w9z4n1-clusterctl-1.12.2", "default": true } ], - "store_path": "/nix/store/yqjd06qrwmh64rfcrjsvhfkn555gnl77-clusterctl-1.10.1" + "store_path": "/nix/store/778jxvqkkq2417wf7khzfwm3w1w9z4n1-clusterctl-1.12.2" }, "aarch64-linux": { "outputs": [ { "name": "out", - "path": "/nix/store/fkdlkd4gr4jj18g1zq87yj7whq2msn2a-clusterctl-1.10.1", + "path": "/nix/store/8dkagw89sav6kk19sfbvpjqa0il0pw6l-clusterctl-1.12.2", "default": true } ], - "store_path": "/nix/store/fkdlkd4gr4jj18g1zq87yj7whq2msn2a-clusterctl-1.10.1" + "store_path": "/nix/store/8dkagw89sav6kk19sfbvpjqa0il0pw6l-clusterctl-1.12.2" }, "x86_64-darwin": { "outputs": [ { "name": "out", - "path": "/nix/store/v66xr4x87ac25k0w9lvy0x56m2ldplvs-clusterctl-1.10.1", + "path": "/nix/store/8844gbxh2cfkpgszmnwhxplm43pg7z1r-clusterctl-1.12.2", "default": true } ], - "store_path": "/nix/store/v66xr4x87ac25k0w9lvy0x56m2ldplvs-clusterctl-1.10.1" + "store_path": "/nix/store/8844gbxh2cfkpgszmnwhxplm43pg7z1r-clusterctl-1.12.2" }, "x86_64-linux": { "outputs": [ { "name": "out", - "path": "/nix/store/zlds3iyclypjmkpbr88wkw69vg6pvmi7-clusterctl-1.10.1", + "path": "/nix/store/b9sid26w0lnafiyzxpyxcink9crcvzh6-clusterctl-1.12.2", "default": true } ], - "store_path": "/nix/store/zlds3iyclypjmkpbr88wkw69vg6pvmi7-clusterctl-1.10.1" + "store_path": "/nix/store/b9sid26w0lnafiyzxpyxcink9crcvzh6-clusterctl-1.12.2" } } }, From 791b6a8de5815167b8d8933f3044d4a7deccd049 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Fri, 13 Mar 2026 14:03:51 -0500 Subject: [PATCH 12/31] Enable IPv6 auto for generated VPC subnets --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 964632b0..3f0b854e 100644 --- a/Makefile +++ b/Makefile @@ -173,7 +173,7 @@ generate-capl-cluster-manifests: LINODE_FIREWALL_ENABLED=$(LINODE_FIREWALL_ENABLED) LINODE_OS=$(LINODE_OS) VPC_NAME=$(VPC_NAME) $(CLUSTERCTL) generate cluster $(CLUSTER_NAME) \ --kubernetes-version $(K8S_VERSION) --infrastructure linode-linode:$(CAPL_VERSION) \ --control-plane-machine-count $(CONTROLPLANE_NODES) --worker-machine-count $(WORKER_NODES) --flavor kubeadm-dual-stack > $(MANIFEST_NAME).yaml - yq -i e 'select(.kind == "LinodeVPC").spec.subnets = [{"ipv4": "10.0.0.0/8", "label": "default"}, {"ipv4": "172.16.0.0/16", "label": "testing"}]' $(MANIFEST_NAME).yaml + yq -i e 'select(.kind == "LinodeVPC").spec.ipv6Range = [{"range": "auto"}] | select(.kind == "LinodeVPC").spec.subnets = [{"ipv4": "10.0.0.0/8", "label": "default", "ipv6Range": [{"range": "auto"}]}, {"ipv4": "172.16.0.0/16", "label": "testing", "ipv6Range": [{"range": "auto"}]}]' $(MANIFEST_NAME).yaml .PHONY: create-capl-cluster create-capl-cluster: From b6e521fff49984e88f11d090ab7a7a64741d57d1 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Fri, 13 Mar 2026 14:53:49 -0500 Subject: [PATCH 13/31] fix ipv6 backend for vpcs --- Makefile | 4 +- cloud/linode/loadbalancers.go | 27 ++--- cloud/linode/loadbalancers_test.go | 184 +++++++++++++++++++++++------ docs/configuration/annotations.md | 2 +- docs/configuration/environment.md | 2 +- docs/configuration/loadbalancer.md | 13 +- 6 files changed, 167 insertions(+), 65 deletions(-) diff --git a/Makefile b/Makefile index 3f0b854e..47bea8fc 100644 --- a/Makefile +++ b/Makefile @@ -168,7 +168,7 @@ mgmt-and-capl-cluster: docker-setup mgmt-cluster capl-cluster capl-cluster: generate-capl-cluster-manifests create-capl-cluster patch-linode-ccm .PHONY: generate-capl-cluster-manifests -generate-capl-cluster-manifests: +generate-capl-cluster-manifests: clusterctl # Create the CAPL cluster manifests without any CSI driver stuff LINODE_FIREWALL_ENABLED=$(LINODE_FIREWALL_ENABLED) LINODE_OS=$(LINODE_OS) VPC_NAME=$(VPC_NAME) $(CLUSTERCTL) generate cluster $(CLUSTER_NAME) \ --kubernetes-version $(K8S_VERSION) --infrastructure linode-linode:$(CAPL_VERSION) \ @@ -176,7 +176,7 @@ generate-capl-cluster-manifests: yq -i e 'select(.kind == "LinodeVPC").spec.ipv6Range = [{"range": "auto"}] | select(.kind == "LinodeVPC").spec.subnets = [{"ipv4": "10.0.0.0/8", "label": "default", "ipv6Range": [{"range": "auto"}]}, {"ipv4": "172.16.0.0/16", "label": "testing", "ipv6Range": [{"range": "auto"}]}]' $(MANIFEST_NAME).yaml .PHONY: create-capl-cluster -create-capl-cluster: +create-capl-cluster: clusterctl # Create a CAPL cluster with updated CCM and wait for it to be ready kubectl apply -f $(MANIFEST_NAME).yaml kubectl wait --for=condition=ControlPlaneReady cluster/$(CLUSTER_NAME) --timeout=600s || (kubectl get cluster -o yaml; kubectl get linodecluster -o yaml; kubectl get linodemachines -o yaml; kubectl logs -n capl-system deployments/capl-controller-manager --tail=50) diff --git a/cloud/linode/loadbalancers.go b/cloud/linode/loadbalancers.go index dc120c85..9f3e98c4 100644 --- a/cloud/linode/loadbalancers.go +++ b/cloud/linode/loadbalancers.go @@ -505,10 +505,7 @@ func (l *loadbalancers) updateNodeBalancer( subnetID = id } - useIPv6Backends, ignoreVPCBackends := resolveIPv6NodeBalancerBackendState(service, currentNBNodes) - if ignoreVPCBackends { - subnetID = 0 - } + useIPv6Backends := resolveIPv6NodeBalancerBackendState(service) newNBNodes, err := l.buildNodeBalancerConfigNodes(service, nodes, port.NodePort, subnetID, useIPv6Backends, newNBCfg.Protocol, oldNBNodeIDs) if err != nil { return err @@ -952,8 +949,7 @@ func (l *loadbalancers) createNodeBalancer(ctx context.Context, clusterName stri Type: nbType, } - _, ignoreVPCBackends := resolveIPv6NodeBalancerBackendState(service, nil) - if len(options.Options.VPCNames) > 0 && !options.Options.DisableNodeBalancerVPCBackends && !ignoreVPCBackends { + if len(options.Options.VPCNames) > 0 && !options.Options.DisableNodeBalancerVPCBackends { createOpts.VPCs, err = l.getVPCCreateOptions(ctx, service) if err != nil { return nil, err @@ -1152,7 +1148,7 @@ func (l *loadbalancers) buildLoadBalancerRequest(ctx context.Context, clusterNam } ports := service.Spec.Ports configs := make([]*linodego.NodeBalancerConfigCreateOptions, 0, len(ports)) - useIPv6Backends, ignoreVPCBackends := resolveIPv6NodeBalancerBackendState(service, nil) + useIPv6Backends := resolveIPv6NodeBalancerBackendState(service) subnetID := 0 if options.Options.NodeBalancerBackendIPv4SubnetID != 0 { @@ -1165,7 +1161,7 @@ func (l *loadbalancers) buildLoadBalancerRequest(ctx context.Context, clusterNam return nil, err } } - if len(options.Options.VPCNames) > 0 && !options.Options.DisableNodeBalancerVPCBackends && !ignoreVPCBackends { + if len(options.Options.VPCNames) > 0 && !options.Options.DisableNodeBalancerVPCBackends { id, err := l.getSubnetIDForSVC(ctx, service) if err != nil { return nil, err @@ -1230,20 +1226,13 @@ func (l *loadbalancers) buildNodeBalancerNodeConfigRebuildOptions(service *v1.Se return nodeOptions, nil } -func resolveIPv6NodeBalancerBackendState(service *v1.Service, currentNBNodes []linodego.NodeBalancerNode) (useIPv6Backends bool, ignoreVPCBackends bool) { +func resolveIPv6NodeBalancerBackendState(service *v1.Service) bool { useIPv6 := getServiceBoolAnnotation(service, annotations.AnnLinodeEnableIPv6Backends) if useIPv6 != nil { - return *useIPv6, *useIPv6 - } - - if len(currentNBNodes) > 0 { - if isNodeBalancerBackendIPv6(currentNBNodes[0].Address) { - return true, true - } - return false, false + return *useIPv6 } - return options.Options.EnableIPv6ForNodeBalancerBackends, false + return options.Options.EnableIPv6ForNodeBalancerBackends } func formatNodeBalancerBackendAddress(ip string, nodePort int32) string { @@ -1438,7 +1427,7 @@ func getNodePrivateIP(node *v1.Node, subnetID int) (string, error) { } func getNodeBackendIP(service *v1.Service, node *v1.Node, subnetID int, useIPv6Backends bool) (string, error) { - if subnetID != 0 || !useIPv6Backends { + if !useIPv6Backends { return getNodePrivateIP(node, subnetID) } diff --git a/cloud/linode/loadbalancers_test.go b/cloud/linode/loadbalancers_test.go index 29642e3f..67c65327 100644 --- a/cloud/linode/loadbalancers_test.go +++ b/cloud/linode/loadbalancers_test.go @@ -4125,7 +4125,7 @@ func Test_getNodeBackendIP(t *testing.T) { expectedIP: "2600:3c06::1", }, { - name: "preserves VPC backend behavior even when IPv6 is requested", + name: "uses IPv6 backend address even when VPC backends are enabled", node: &v1.Node{ ObjectMeta: metav1.ObjectMeta{ Name: "node-1", @@ -4142,7 +4142,7 @@ func Test_getNodeBackendIP(t *testing.T) { }, subnetID: 100, useIPv6Backends: true, - expectedIP: "10.0.0.2", + expectedIP: "2600:3c06::1", }, { name: "errors when IPv6 backends are requested and node lacks public IPv6", @@ -4187,15 +4187,13 @@ func Test_resolveIPv6NodeBalancerBackendState(t *testing.T) { }() testcases := []struct { - name string - globalFlag bool - service *v1.Service - currentNBNodes []linodego.NodeBalancerNode - expectedUseIPv6 bool - expectedIgnoreVPC bool + name string + globalFlag bool + service *v1.Service + expectedUseIPv6 bool }{ { - name: "service annotation enables IPv6 and overrides VPC behavior", + name: "service annotation enables IPv6", globalFlag: false, service: &v1.Service{ ObjectMeta: metav1.ObjectMeta{ @@ -4204,47 +4202,163 @@ func Test_resolveIPv6NodeBalancerBackendState(t *testing.T) { }, }, }, - expectedUseIPv6: true, - expectedIgnoreVPC: true, + expectedUseIPv6: true, }, { - name: "new services respect global IPv6 backend flag without ignoring VPC", - globalFlag: true, - service: &v1.Service{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}}}, - expectedUseIPv6: true, - expectedIgnoreVPC: false, + name: "service annotation disables IPv6 even when global flag is enabled", + globalFlag: true, + service: &v1.Service{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{annotations.AnnLinodeEnableIPv6Backends: "false"}}}, + expectedUseIPv6: false, }, { - name: "existing IPv4 nodebalancer backends stay IPv4 by default", - globalFlag: true, - service: &v1.Service{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}}}, - currentNBNodes: []linodego.NodeBalancerNode{ - {Address: "10.0.0.10:30000"}, - }, - expectedUseIPv6: false, - expectedIgnoreVPC: false, + name: "services use global IPv6 backend flag when annotation is absent", + globalFlag: true, + service: &v1.Service{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}}}, + expectedUseIPv6: true, }, { - name: "existing IPv6 nodebalancer backends stay IPv6 and ignore VPC", - globalFlag: false, - service: &v1.Service{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}}}, - currentNBNodes: []linodego.NodeBalancerNode{ - {Address: "[2600:3c06::1]:30000"}, - }, - expectedUseIPv6: true, - expectedIgnoreVPC: true, + name: "services do not use IPv6 when annotation is absent and global flag is disabled", + globalFlag: false, + service: &v1.Service{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}}}, + expectedUseIPv6: false, }, } for _, test := range testcases { t.Run(test.name, func(t *testing.T) { options.Options.EnableIPv6ForNodeBalancerBackends = test.globalFlag - useIPv6Backends, ignoreVPCBackends := resolveIPv6NodeBalancerBackendState(test.service, test.currentNBNodes) + useIPv6Backends := resolveIPv6NodeBalancerBackendState(test.service) if useIPv6Backends != test.expectedUseIPv6 { t.Fatalf("expected useIPv6Backends=%t, got %t", test.expectedUseIPv6, useIPv6Backends) } - if ignoreVPCBackends != test.expectedIgnoreVPC { - t.Fatalf("expected ignoreVPCBackends=%t, got %t", test.expectedIgnoreVPC, ignoreVPCBackends) + }) + } +} + +func Test_buildLoadBalancerRequestPreservesVPCConfigForIPv6Backends(t *testing.T) { + prevVPCNames := options.Options.VPCNames + prevSubnetNames := options.Options.SubnetNames + prevDisableVPC := options.Options.DisableNodeBalancerVPCBackends + prevEnableIPv6Backends := options.Options.EnableIPv6ForNodeBalancerBackends + defer func() { + options.Options.VPCNames = prevVPCNames + options.Options.SubnetNames = prevSubnetNames + options.Options.DisableNodeBalancerVPCBackends = prevDisableVPC + options.Options.EnableIPv6ForNodeBalancerBackends = prevEnableIPv6Backends + }() + + options.Options.VPCNames = []string{"test-vpc"} + options.Options.SubnetNames = []string{"default"} + options.Options.DisableNodeBalancerVPCBackends = false + + testcases := []struct { + name string + globalFlag bool + annotations map[string]string + }{ + { + name: "service annotation preserves VPC config for IPv6 backends", + globalFlag: false, + annotations: map[string]string{ + annotations.AnnLinodeDefaultProtocol: "tcp", + annotations.AnnLinodeEnableIPv6Backends: "true", + }, + }, + { + name: "global flag preserves VPC config for IPv6 backends", + globalFlag: true, + annotations: map[string]string{ + annotations.AnnLinodeDefaultProtocol: "tcp", + }, + }, + } + + for _, test := range testcases { + t.Run(test.name, func(t *testing.T) { + options.Options.EnableIPv6ForNodeBalancerBackends = test.globalFlag + + fake := newFake(t) + ts := httptest.NewServer(fake) + defer ts.Close() + + client := linodego.NewClient(http.DefaultClient) + client.SetBaseURL(ts.URL) + lb := newLoadbalancers(&client, "us-west").(*loadbalancers) + + fake.vpc[1] = &linodego.VPC{ + ID: 1, + Label: "test-vpc", + Subnets: []linodego.VPCSubnet{ + { + ID: 101, + Label: "default", + IPv4: "10.0.0.0/8", + }, + }, + } + fake.subnet[101] = &linodego.VPCSubnet{ + ID: 101, + Label: "default", + IPv4: "10.0.0.0/8", + } + + svc := &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + UID: "foobar123", + Annotations: test.annotations, + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{ + { + Name: "test", + Protocol: "TCP", + Port: int32(80), + NodePort: int32(30000), + }, + }, + }, + } + nodes := []*v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeInternalIP, Address: "10.0.0.2"}, + {Type: v1.NodeExternalIP, Address: "2600:3c06:e727:1::1"}, + }, + }, + }, + } + + _, err := lb.buildLoadBalancerRequest(t.Context(), "linodelb", svc, nodes) + if err != nil { + t.Fatal(err) + } + + var req *fakeRequest + for request := range fake.requests { + if request.Method == http.MethodPost && request.Path == "/nodebalancers" { + req = &request + break + } + } + if req == nil { + t.Fatal("expected nodebalancer create request") + } + + var createOpts linodego.NodeBalancerCreateOptions + if err := json.Unmarshal([]byte(req.Body), &createOpts); err != nil { + t.Fatalf("unable to unmarshal create request body %#v, error: %#v", req.Body, err) + } + if len(createOpts.VPCs) != 1 || createOpts.VPCs[0].SubnetID == 0 { + t.Fatalf("expected nodebalancer create request to preserve VPC config, got %#v", createOpts.VPCs) + } + if len(createOpts.Configs) != 1 || len(createOpts.Configs[0].Nodes) != 1 { + t.Fatalf("expected a single nodebalancer config with one backend node, got %#v", createOpts.Configs) + } + if !isNodeBalancerBackendIPv6(createOpts.Configs[0].Nodes[0].Address) { + t.Fatalf("expected IPv6 backend node address, got %q", createOpts.Configs[0].Nodes[0].Address) } }) } diff --git a/docs/configuration/annotations.md b/docs/configuration/annotations.md index ceb2e3ab..3709fbdb 100644 --- a/docs/configuration/annotations.md +++ b/docs/configuration/annotations.md @@ -40,7 +40,7 @@ The keys and the values in [annotations must be strings](https://kubernetes.io/d | `firewall-acl` | string | | The Firewall rules to be applied to the NodeBalancer. See [Firewall Configuration](#firewall-configuration) | | `nodebalancer-type` | string | | The type of NodeBalancer to create (options: common, premium, premium_40gb). See [NodeBalancer Types](#nodebalancer-type). Note: NodeBalancer types should always be specified in lowercase. | | `enable-ipv6-ingress` | bool | `false` | When `true`, both IPv4 and IPv6 addresses will be included in the LoadBalancerStatus ingress | -| `enable-ipv6-backends` | bool | `false` | When `true`, non-VPC NodeBalancer services use public IPv6 backend nodes. This requires a dual-stack cluster and a dual-stack Service configuration. Reconciliation fails if a selected backend node does not have public IPv6. | +| `enable-ipv6-backends` | bool | `false` | When `true`, NodeBalancer services use IPv6 backend nodes. If VPC-backed NodeBalancers are enabled, CCM preserves the NodeBalancer VPC configuration. This requires a dual-stack cluster and a dual-stack Service configuration. Reconciliation fails if a selected backend node does not have the required IPv6 address. | | `backend-ipv4-range` | string | | The IPv4 range from VPC subnet to be applied to the NodeBalancer backend. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) | | `backend-vpc-name` | string | | VPC which is connected to the NodeBalancer backend. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) | | `backend-subnet-name` | string | | Subnet within VPC which is connected to the NodeBalancer backend. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) | diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index 3a07d61b..81f6baa9 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -53,7 +53,7 @@ The CCM supports the following flags: | `--nodebalancer-backend-ipv4-subnet-name` | String | `""` | ipv4 subnet name to use for NodeBalancer backends | | `--disable-nodebalancer-vpc-backends` | Boolean | `false` | don't use VPC specific ip-addresses for nodebalancer backend ips when running in VPC (set to `true` for backward compatibility if needed) | | `--enable-ipv6-for-loadbalancers` | Boolean | `false` | Set both IPv4 and IPv6 addresses for all LoadBalancer services (when disabled, only IPv4 is used). This can also be configured per-service using the `service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-ingress` annotation. | -| `--enable-ipv6-for-nodebalancer-backends` | Boolean | `false` | Use public IPv6 addresses for non-VPC NodeBalancer service backends. This requires a dual-stack cluster and dual-stack Service configuration. If enabled, every selected backend node must have public IPv6 or reconciliation will fail. This can also be configured per-service using the `service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends` annotation. | +| `--enable-ipv6-for-nodebalancer-backends` | Boolean | `false` | Use IPv6 addresses for NodeBalancer service backends. If VPC-backed NodeBalancers are enabled, CCM preserves the NodeBalancer VPC configuration. Enabling this flag can migrate existing eligible NodeBalancer services from IPv4 to IPv6 backends during reconcile. This requires a dual-stack cluster and dual-stack Service configuration. If enabled, every selected backend node must have the required IPv6 address or reconciliation will fail. This can also be configured per-service using the `service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends` annotation. | | `--node-cidr-mask-size-ipv4` | Int | `24` | ipv4 cidr mask size for pod cidrs allocated to nodes | | `--node-cidr-mask-size-ipv6` | Int | `64` | ipv6 cidr mask size for pod cidrs allocated to nodes | | `--nodebalancer-prefix` | String | `ccm` | Name prefix for NoadBalancers. | diff --git a/docs/configuration/loadbalancer.md b/docs/configuration/loadbalancer.md index 8931c12f..d1af38a7 100644 --- a/docs/configuration/loadbalancer.md +++ b/docs/configuration/loadbalancer.md @@ -50,7 +50,7 @@ IPv6 frontends and IPv6 backends are configured independently. Frontend IPv6 con IPv6 backends require a dual-stack workload cluster. In practice, the cluster networking stack must support IPv6 NodePort traffic, and the Service itself should be created as dual-stack. A single-stack IPv4 `LoadBalancer` Service can still be annotated for IPv6 backends, but the NodeBalancer health checks and traffic path may fail because the backend NodePort is not exposed over IPv6. -For newly created non-VPC NodeBalancer services, you can enable public IPv6 backends globally: +You can enable IPv6 backends globally for NodeBalancer services: ```yaml spec: @@ -71,13 +71,12 @@ metadata: ``` When IPv6 backends are enabled: -- only non-VPC NodeBalancer services are affected -- existing services are not migrated automatically -- every selected backend node must have public IPv6 +- both VPC-backed and non-VPC-backed NodeBalancer services are affected +- when VPC-backed NodeBalancers are enabled, CCM preserves the NodeBalancer VPC configuration instead of dropping it +- enabling the global `--enable-ipv6-for-nodebalancer-backends` flag can migrate existing eligible NodeBalancer services from IPv4 to IPv6 backends during reconcile +- every selected backend node must have an IPv6 address in the currently selected backend path - the workload cluster and Service must be configured for dual-stack networking -- reconciliation fails and CCM logs an error if a selected backend node does not have public IPv6 - -VPC backend behavior is unchanged and continues to use the existing IPv4/VPC backend flow. +- reconciliation fails and CCM logs an error if a selected backend node does not have the required IPv6 address Recommended Service configuration for IPv6 backends: From c89d74e30280057388ad5debf12f844aa2b08206 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Fri, 13 Mar 2026 15:05:39 -0500 Subject: [PATCH 14/31] Fix IPv6 backend lint and docs wording --- cloud/linode/loadbalancers_test.go | 5 ++++- deploy/chart/values.yaml | 4 ++-- docs/configuration/loadbalancer.md | 1 + 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/cloud/linode/loadbalancers_test.go b/cloud/linode/loadbalancers_test.go index 67c65327..85b6557f 100644 --- a/cloud/linode/loadbalancers_test.go +++ b/cloud/linode/loadbalancers_test.go @@ -4283,7 +4283,10 @@ func Test_buildLoadBalancerRequestPreservesVPCConfigForIPv6Backends(t *testing.T client := linodego.NewClient(http.DefaultClient) client.SetBaseURL(ts.URL) - lb := newLoadbalancers(&client, "us-west").(*loadbalancers) + lb, ok := newLoadbalancers(&client, "us-west").(*loadbalancers) + if !ok { + t.Fatal("type assertion failed") + } fake.vpc[1] = &linodego.VPC{ ID: 1, diff --git a/deploy/chart/values.yaml b/deploy/chart/values.yaml index 9aea51c5..f433d250 100644 --- a/deploy/chart/values.yaml +++ b/deploy/chart/values.yaml @@ -108,8 +108,8 @@ tolerations: # This can also be controlled per-service using the "service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-ingress" annotation # enableIPv6ForLoadBalancers: true -# Enable public IPv6 backend addresses for newly created non-VPC NodeBalancer services -# Existing services are not migrated automatically, and all selected backend nodes must have public IPv6 +# Enable IPv6 backend addresses for NodeBalancer services +# Existing services can be kept on IPv4 by setting service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends: "false", and all selected backend nodes must have the required IPv6 address # This can also be controlled per-service using the "service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends" annotation # enableIPv6ForNodeBalancerBackends: false diff --git a/docs/configuration/loadbalancer.md b/docs/configuration/loadbalancer.md index d1af38a7..d6888ed5 100644 --- a/docs/configuration/loadbalancer.md +++ b/docs/configuration/loadbalancer.md @@ -74,6 +74,7 @@ When IPv6 backends are enabled: - both VPC-backed and non-VPC-backed NodeBalancer services are affected - when VPC-backed NodeBalancers are enabled, CCM preserves the NodeBalancer VPC configuration instead of dropping it - enabling the global `--enable-ipv6-for-nodebalancer-backends` flag can migrate existing eligible NodeBalancer services from IPv4 to IPv6 backends during reconcile +- to keep an existing Service on IPv4 while the global flag is enabled, set `service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends: "false"` on that Service - every selected backend node must have an IPv6 address in the currently selected backend path - the workload cluster and Service must be configured for dual-stack networking - reconciliation fails and CCM logs an error if a selected backend node does not have the required IPv6 address From 808822fc865cb301642e53ae6635d4f801f18135 Mon Sep 17 00:00:00 2001 From: Khaja Omer <56000175+komer3@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:09:58 -0500 Subject: [PATCH 15/31] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 6e843d16..afe5941c 100644 --- a/main.go +++ b/main.go @@ -95,7 +95,7 @@ func main() { command.Flags().StringVar(&ccmOptions.Options.NodeBalancerBackendIPv4Subnet, "nodebalancer-backend-ipv4-subnet", "", "ipv4 subnet to use for NodeBalancer backends") command.Flags().StringSliceVar(&ccmOptions.Options.NodeBalancerTags, "nodebalancer-tags", []string{}, "Linode tags to apply to all NodeBalancers") command.Flags().BoolVar(&ccmOptions.Options.EnableIPv6ForLoadBalancers, "enable-ipv6-for-loadbalancers", false, "set both IPv4 and IPv6 addresses for all LoadBalancer services (when disabled, only IPv4 is used)") - command.Flags().BoolVar(&ccmOptions.Options.EnableIPv6ForNodeBalancerBackends, "enable-ipv6-for-nodebalancer-backends", false, "use public IPv6 addresses for new non-VPC NodeBalancer service backends (when enabled, all selected backend nodes must have public IPv6)") + command.Flags().BoolVar(&ccmOptions.Options.EnableIPv6ForNodeBalancerBackends, "enable-ipv6-for-nodebalancer-backends", false, "use public IPv6 addresses for NodeBalancer service backends, including VPC-backed NodeBalancers (when enabled, may update existing services during reconciliation and all selected backend nodes must have public IPv6)") command.Flags().IntVar(&ccmOptions.Options.NodeCIDRMaskSizeIPv4, "node-cidr-mask-size-ipv4", 0, "ipv4 cidr mask size for pod cidrs allocated to nodes") command.Flags().IntVar(&ccmOptions.Options.NodeCIDRMaskSizeIPv6, "node-cidr-mask-size-ipv6", 0, "ipv6 cidr mask size for pod cidrs allocated to nodes") command.Flags().IntVar(&ccmOptions.Options.NodeBalancerBackendIPv4SubnetID, "nodebalancer-backend-ipv4-subnet-id", 0, "ipv4 subnet id to use for NodeBalancer backends") From cbf9d1b7e98539f1ddec354107f938b4d97be8af Mon Sep 17 00:00:00 2001 From: Khaja Omer <56000175+komer3@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:10:07 -0500 Subject: [PATCH 16/31] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cloud/linode/loadbalancers.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cloud/linode/loadbalancers.go b/cloud/linode/loadbalancers.go index 9f3e98c4..45d5bb2e 100644 --- a/cloud/linode/loadbalancers.go +++ b/cloud/linode/loadbalancers.go @@ -508,7 +508,8 @@ func (l *loadbalancers) updateNodeBalancer( useIPv6Backends := resolveIPv6NodeBalancerBackendState(service) newNBNodes, err := l.buildNodeBalancerConfigNodes(service, nodes, port.NodePort, subnetID, useIPv6Backends, newNBCfg.Protocol, oldNBNodeIDs) if err != nil { - return err + sentry.CaptureError(ctx, err) + return fmt.Errorf("[port %d] error building NodeBalancer backend node configs: %w", int(port.Port), err) } // If there's no existing config, create it From c8f48023296fb50d4c80fdaecb627fe3a2eb1460 Mon Sep 17 00:00:00 2001 From: Khaja Omer <56000175+komer3@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:10:24 -0500 Subject: [PATCH 17/31] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- deploy/chart/values.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/deploy/chart/values.yaml b/deploy/chart/values.yaml index f433d250..4064d2fb 100644 --- a/deploy/chart/values.yaml +++ b/deploy/chart/values.yaml @@ -108,9 +108,10 @@ tolerations: # This can also be controlled per-service using the "service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-ingress" annotation # enableIPv6ForLoadBalancers: true -# Enable IPv6 backend addresses for NodeBalancer services -# Existing services can be kept on IPv4 by setting service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends: "false", and all selected backend nodes must have the required IPv6 address -# This can also be controlled per-service using the "service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends" annotation +# Enable IPv6 backend addresses for NodeBalancer services (including VPC-backed NodeBalancers). +# When enabled globally, both newly created and existing eligible services may be reconciled to use IPv6 backends. +# Per-service behavior can be overridden with the "service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends" annotation; set it to "false" to keep a service on IPv4 backends only. +# All selected backend nodes must have the required IPv6 address (public or VPC, depending on the NodeBalancer configuration). # enableIPv6ForNodeBalancerBackends: false # disableNodeBalancerVPCBackends is used to disable the use of VPC backends for NodeBalancers. From 2cf942ed967523daab98b795d22d403f0a220b60 Mon Sep 17 00:00:00 2001 From: Khaja Omer <56000175+komer3@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:12:14 -0500 Subject: [PATCH 18/31] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cloud/annotations/annotations.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloud/annotations/annotations.go b/cloud/annotations/annotations.go index 3e849458..5e608294 100644 --- a/cloud/annotations/annotations.go +++ b/cloud/annotations/annotations.go @@ -41,7 +41,7 @@ const ( // AnnLinodeEnableIPv6Ingress is the annotation used to specify that a service should include both IPv4 and IPv6 // addresses for its LoadBalancer ingress. When set to "true", both addresses will be included in the status. AnnLinodeEnableIPv6Ingress = "service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-ingress" - // AnnLinodeEnableIPv6Backends controls whether a non-VPC NodeBalancer service should use public IPv6 backend nodes. + // AnnLinodeEnableIPv6Backends controls whether a NodeBalancer service should use public IPv6 backend nodes. AnnLinodeEnableIPv6Backends = "service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends" AnnLinodeNodePrivateIP = "node.k8s.linode.com/private-ip" From 74118bef00c0bed443834c567954ad4da852a48f Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Fri, 13 Mar 2026 15:14:36 -0500 Subject: [PATCH 19/31] Address IPv6 backend review comments --- Makefile | 2 +- cloud/linode/loadbalancers.go | 15 --------------- cloud/linode/loadbalancers_test.go | 6 +++++- 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index 47bea8fc..18ce5b63 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ DEVBOX_BIN ?= $(DEVBOX_PACKAGES_DIR)/bin HELM ?= $(LOCALBIN)/helm HELM_VERSION ?= v3.16.3 CLUSTERCTL ?= $(LOCALBIN)/clusterctl -CLUSTERCTL_VERSION ?= v1.12.3 +CLUSTERCTL_VERSION ?= v1.12.2 GOLANGCI_LINT ?= $(LOCALBIN)/golangci-lint GOLANGCI_LINT_NILAWAY ?= $(CACHE_BIN)/golangci-lint-nilaway diff --git a/cloud/linode/loadbalancers.go b/cloud/linode/loadbalancers.go index 45d5bb2e..2672a4bc 100644 --- a/cloud/linode/loadbalancers.go +++ b/cloud/linode/loadbalancers.go @@ -1240,21 +1240,6 @@ func formatNodeBalancerBackendAddress(ip string, nodePort int32) string { return net.JoinHostPort(ip, strconv.Itoa(int(nodePort))) } -func isNodeBalancerBackendIPv6(address string) bool { - host := address - if parsedHost, _, err := net.SplitHostPort(address); err == nil { - host = parsedHost - } else if strings.Count(address, ":") == 1 { - lastColon := strings.LastIndex(address, ":") - if lastColon != -1 { - host = address[:lastColon] - } - } - - parsedIP := net.ParseIP(host) - return parsedIP != nil && parsedIP.To4() == nil -} - func (l *loadbalancers) buildNodeBalancerConfigNodes( service *v1.Service, nodes []*v1.Node, diff --git a/cloud/linode/loadbalancers_test.go b/cloud/linode/loadbalancers_test.go index 85b6557f..2a7a6a58 100644 --- a/cloud/linode/loadbalancers_test.go +++ b/cloud/linode/loadbalancers_test.go @@ -4360,7 +4360,11 @@ func Test_buildLoadBalancerRequestPreservesVPCConfigForIPv6Backends(t *testing.T if len(createOpts.Configs) != 1 || len(createOpts.Configs[0].Nodes) != 1 { t.Fatalf("expected a single nodebalancer config with one backend node, got %#v", createOpts.Configs) } - if !isNodeBalancerBackendIPv6(createOpts.Configs[0].Nodes[0].Address) { + host, _, err := net.SplitHostPort(createOpts.Configs[0].Nodes[0].Address) + if err != nil { + t.Fatal(err) + } + if parsedIP := net.ParseIP(host); parsedIP == nil || parsedIP.To4() != nil { t.Fatalf("expected IPv6 backend node address, got %q", createOpts.Configs[0].Nodes[0].Address) } }) From 410a4da2c6dd3def8f9f2bd84c649deeb108ffe4 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Fri, 13 Mar 2026 15:28:07 -0500 Subject: [PATCH 20/31] Fix IPv6 backend test vet error --- cloud/linode/loadbalancers_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cloud/linode/loadbalancers_test.go b/cloud/linode/loadbalancers_test.go index 2a7a6a58..f01b2809 100644 --- a/cloud/linode/loadbalancers_test.go +++ b/cloud/linode/loadbalancers_test.go @@ -4360,9 +4360,9 @@ func Test_buildLoadBalancerRequestPreservesVPCConfigForIPv6Backends(t *testing.T if len(createOpts.Configs) != 1 || len(createOpts.Configs[0].Nodes) != 1 { t.Fatalf("expected a single nodebalancer config with one backend node, got %#v", createOpts.Configs) } - host, _, err := net.SplitHostPort(createOpts.Configs[0].Nodes[0].Address) - if err != nil { - t.Fatal(err) + host, _, hostPortErr := net.SplitHostPort(createOpts.Configs[0].Nodes[0].Address) + if hostPortErr != nil { + t.Fatal(hostPortErr) } if parsedIP := net.ParseIP(host); parsedIP == nil || parsedIP.To4() != nil { t.Fatalf("expected IPv6 backend node address, got %q", createOpts.Configs[0].Nodes[0].Address) From 4735bcfdbfe6c78d3cf6b2219dd877e9ec6ab29f Mon Sep 17 00:00:00 2001 From: Khaja Omer <56000175+komer3@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:29:32 -0500 Subject: [PATCH 21/31] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cloud/linode/loadbalancers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloud/linode/loadbalancers.go b/cloud/linode/loadbalancers.go index 2672a4bc..37f8b6d9 100644 --- a/cloud/linode/loadbalancers.go +++ b/cloud/linode/loadbalancers.go @@ -1426,7 +1426,7 @@ func getNodeBackendIP(service *v1.Service, node *v1.Node, subnetID int, useIPv6B } } - klog.Errorf("Service %s requested IPv6 backends but node %s does not have a public IPv6 address", getServiceNn(service), node.Name) + klog.V(4).Infof("Service %s requested IPv6 backends but node %s does not have a public IPv6 address", getServiceNn(service), node.Name) return "", fmt.Errorf("service %s requested IPv6 backends but node %s does not have a public IPv6 address", getServiceNn(service), node.Name) } From 513ac9428279c2d4e505887bfcb7971a7e605863 Mon Sep 17 00:00:00 2001 From: Khaja Omer <56000175+komer3@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:31:43 -0500 Subject: [PATCH 22/31] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml b/e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml index 9c8c1324..691c6351 100644 --- a/e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml +++ b/e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml @@ -57,6 +57,10 @@ spec: addresses=$(echo "$nodes" | jq -r '.data[].address') + if [[ -z "$addresses" ]]; then + echo "NO_BACKEND_ADDRESSES" + fi + for address in $addresses; do if [[ $address =~ ^\[(.*)\]:([0-9]+)$ ]]; then host="${BASH_REMATCH[1]}" @@ -72,6 +76,7 @@ spec: done check: ($error): ~ + (contains($stdout, 'NO_BACKEND_ADDRESSES')): false (contains($stdout, 'is NOT IPv6')): false - name: Fetch loadbalancer ip and check both pods reachable try: From 8099f2a9d7686e3dc7593e7c690fc8af56035742 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Tue, 17 Mar 2026 11:26:59 -0700 Subject: [PATCH 23/31] use public IPv6 annotation for ipv6 backend. updated tests. Will need to add vpc ipv6 support when it is available --- cloud/linode/loadbalancers.go | 9 ++---- cloud/linode/loadbalancers_test.go | 28 +++++++++++++------ .../lb-with-ipv6-backends/chainsaw-test.yaml | 7 ++++- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/cloud/linode/loadbalancers.go b/cloud/linode/loadbalancers.go index 37f8b6d9..7a73fabf 100644 --- a/cloud/linode/loadbalancers.go +++ b/cloud/linode/loadbalancers.go @@ -1417,12 +1417,9 @@ func getNodeBackendIP(service *v1.Service, node *v1.Node, subnetID int, useIPv6B return getNodePrivateIP(node, subnetID) } - for _, addr := range node.Status.Addresses { - if addr.Type != v1.NodeExternalIP { - continue - } - if parsed := net.ParseIP(addr.Address); parsed != nil && parsed.To4() == nil { - return addr.Address, nil + if publicIPv6, exists := node.Annotations[annotations.AnnLinodeNodePublicIPv6]; exists { + if parsed := net.ParseIP(publicIPv6); parsed != nil && parsed.To4() == nil { + return publicIPv6, nil } } diff --git a/cloud/linode/loadbalancers_test.go b/cloud/linode/loadbalancers_test.go index f01b2809..e21d5e4d 100644 --- a/cloud/linode/loadbalancers_test.go +++ b/cloud/linode/loadbalancers_test.go @@ -4111,13 +4111,18 @@ func Test_getNodeBackendIP(t *testing.T) { expectedIP: "192.168.10.10", }, { - name: "uses IPv6 external ip when IPv6 backends are enabled", + name: "uses public IPv6 annotation when IPv6 backends are enabled", node: &v1.Node{ - ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "node-1", + Annotations: map[string]string{ + annotations.AnnLinodeNodePublicIPv6: "2600:3c06::1", + }, + }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "172.232.0.2"}, - {Type: v1.NodeExternalIP, Address: "2600:3c06::1"}, + {Type: v1.NodeExternalIP, Address: "fd00::10"}, }, }, }, @@ -4125,18 +4130,19 @@ func Test_getNodeBackendIP(t *testing.T) { expectedIP: "2600:3c06::1", }, { - name: "uses IPv6 backend address even when VPC backends are enabled", + name: "uses public IPv6 annotation even when VPC backends are enabled", node: &v1.Node{ ObjectMeta: metav1.ObjectMeta{ Name: "node-1", Annotations: map[string]string{ annotations.AnnLinodeNodePrivateIP: "192.168.10.10", + annotations.AnnLinodeNodePublicIPv6: "2600:3c06::1", }, }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeInternalIP, Address: "10.0.0.2"}, - {Type: v1.NodeExternalIP, Address: "2600:3c06::1"}, + {Type: v1.NodeExternalIP, Address: "fd00::20"}, }, }, }, @@ -4145,12 +4151,13 @@ func Test_getNodeBackendIP(t *testing.T) { expectedIP: "2600:3c06::1", }, { - name: "errors when IPv6 backends are requested and node lacks public IPv6", + name: "errors when IPv6 backends are requested and node lacks public IPv6 annotation", node: &v1.Node{ ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "172.232.0.2"}, + {Type: v1.NodeExternalIP, Address: "2600:3c06::1"}, }, }, }, @@ -4324,11 +4331,16 @@ func Test_buildLoadBalancerRequestPreservesVPCConfigForIPv6Backends(t *testing.T } nodes := []*v1.Node{ { - ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "node-1", + Annotations: map[string]string{ + annotations.AnnLinodeNodePublicIPv6: "2600:3c06:e727:1::1", + }, + }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeInternalIP, Address: "10.0.0.2"}, - {Type: v1.NodeExternalIP, Address: "2600:3c06:e727:1::1"}, + {Type: v1.NodeExternalIP, Address: "fd00::30"}, }, }, }, diff --git a/e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml b/e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml index 691c6351..95275902 100644 --- a/e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml +++ b/e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml @@ -84,11 +84,16 @@ spec: content: | bash -ce ' IP=$(kubectl get svc svc-test -n "$NAMESPACE" -o json | jq -r .status.loadBalancer.ingress[0].ip) + if [[ "$IP" == *:* ]]; then + TARGET="[$IP]" + else + TARGET="$IP" + fi podnames=() for i in {1..10}; do if [[ ${#podnames[@]} -lt 2 ]]; then - output=$(curl -s "$IP":80 | jq -e .podName || true) + output=$(curl -s "${TARGET}:80" | jq -e .podName || true) if [[ "$output" == *"test-"* ]]; then unique=true From a598b51ae9b18202618cbd0d49e88ff2a6e4aca0 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Wed, 18 Mar 2026 09:33:41 -0700 Subject: [PATCH 24/31] Fix ipv6 handling and update the docs with behavior --- cloud/linode/loadbalancers.go | 6 ++-- cloud/linode/loadbalancers_test.go | 44 ++++++++++++++++++++++++++++-- deploy/chart/values.yaml | 5 ++-- docs/configuration/annotations.md | 2 +- docs/configuration/environment.md | 2 +- docs/configuration/loadbalancer.md | 9 ++++-- 6 files changed, 56 insertions(+), 12 deletions(-) diff --git a/cloud/linode/loadbalancers.go b/cloud/linode/loadbalancers.go index 7a73fabf..bda9f3e3 100644 --- a/cloud/linode/loadbalancers.go +++ b/cloud/linode/loadbalancers.go @@ -1418,8 +1418,10 @@ func getNodeBackendIP(service *v1.Service, node *v1.Node, subnetID int, useIPv6B } if publicIPv6, exists := node.Annotations[annotations.AnnLinodeNodePublicIPv6]; exists { - if parsed := net.ParseIP(publicIPv6); parsed != nil && parsed.To4() == nil { - return publicIPv6, nil + if prefix, err := netip.ParsePrefix(publicIPv6); err == nil { + if addr := prefix.Addr(); addr.Is6() { + return addr.String(), nil + } } } diff --git a/cloud/linode/loadbalancers_test.go b/cloud/linode/loadbalancers_test.go index e21d5e4d..58d579de 100644 --- a/cloud/linode/loadbalancers_test.go +++ b/cloud/linode/loadbalancers_test.go @@ -4116,7 +4116,7 @@ func Test_getNodeBackendIP(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "node-1", Annotations: map[string]string{ - annotations.AnnLinodeNodePublicIPv6: "2600:3c06::1", + annotations.AnnLinodeNodePublicIPv6: "2600:3c06::1/128", }, }, Status: v1.NodeStatus{ @@ -4136,7 +4136,7 @@ func Test_getNodeBackendIP(t *testing.T) { Name: "node-1", Annotations: map[string]string{ annotations.AnnLinodeNodePrivateIP: "192.168.10.10", - annotations.AnnLinodeNodePublicIPv6: "2600:3c06::1", + annotations.AnnLinodeNodePublicIPv6: "2600:3c06::1/128", }, }, Status: v1.NodeStatus{ @@ -4150,6 +4150,44 @@ func Test_getNodeBackendIP(t *testing.T) { useIPv6Backends: true, expectedIP: "2600:3c06::1", }, + { + name: "errors when public IPv6 annotation is missing prefix length", + node: &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node-1", + Annotations: map[string]string{ + annotations.AnnLinodeNodePublicIPv6: "2600:3c06::2", + }, + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "172.232.0.2"}, + {Type: v1.NodeExternalIP, Address: "fd00::11"}, + }, + }, + }, + useIPv6Backends: true, + expectErr: true, + }, + { + name: "errors when public IPv6 annotation is not a valid IPv6 value", + node: &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node-1", + Annotations: map[string]string{ + annotations.AnnLinodeNodePublicIPv6: "not-an-ip", + }, + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "172.232.0.2"}, + {Type: v1.NodeExternalIP, Address: "2600:3c06::1"}, + }, + }, + }, + useIPv6Backends: true, + expectErr: true, + }, { name: "errors when IPv6 backends are requested and node lacks public IPv6 annotation", node: &v1.Node{ @@ -4334,7 +4372,7 @@ func Test_buildLoadBalancerRequestPreservesVPCConfigForIPv6Backends(t *testing.T ObjectMeta: metav1.ObjectMeta{ Name: "node-1", Annotations: map[string]string{ - annotations.AnnLinodeNodePublicIPv6: "2600:3c06:e727:1::1", + annotations.AnnLinodeNodePublicIPv6: "2600:3c06:e727:1::1/128", }, }, Status: v1.NodeStatus{ diff --git a/deploy/chart/values.yaml b/deploy/chart/values.yaml index 4064d2fb..17c9d4ed 100644 --- a/deploy/chart/values.yaml +++ b/deploy/chart/values.yaml @@ -108,10 +108,11 @@ tolerations: # This can also be controlled per-service using the "service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-ingress" annotation # enableIPv6ForLoadBalancers: true -# Enable IPv6 backend addresses for NodeBalancer services (including VPC-backed NodeBalancers). +# Enable public IPv6 backend addresses for NodeBalancer services. +# VPC IPv6 backend addresses are not currently supported. # When enabled globally, both newly created and existing eligible services may be reconciled to use IPv6 backends. # Per-service behavior can be overridden with the "service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends" annotation; set it to "false" to keep a service on IPv4 backends only. -# All selected backend nodes must have the required IPv6 address (public or VPC, depending on the NodeBalancer configuration). +# If your cluster uses VPC-backed NodeBalancers, the nodes must still expose public IPv6 endpoints so CCM can read the node.k8s.linode.com/public-ipv6 annotation and program IPv6 backends. # enableIPv6ForNodeBalancerBackends: false # disableNodeBalancerVPCBackends is used to disable the use of VPC backends for NodeBalancers. diff --git a/docs/configuration/annotations.md b/docs/configuration/annotations.md index 3709fbdb..dc708cfc 100644 --- a/docs/configuration/annotations.md +++ b/docs/configuration/annotations.md @@ -40,7 +40,7 @@ The keys and the values in [annotations must be strings](https://kubernetes.io/d | `firewall-acl` | string | | The Firewall rules to be applied to the NodeBalancer. See [Firewall Configuration](#firewall-configuration) | | `nodebalancer-type` | string | | The type of NodeBalancer to create (options: common, premium, premium_40gb). See [NodeBalancer Types](#nodebalancer-type). Note: NodeBalancer types should always be specified in lowercase. | | `enable-ipv6-ingress` | bool | `false` | When `true`, both IPv4 and IPv6 addresses will be included in the LoadBalancerStatus ingress | -| `enable-ipv6-backends` | bool | `false` | When `true`, NodeBalancer services use IPv6 backend nodes. If VPC-backed NodeBalancers are enabled, CCM preserves the NodeBalancer VPC configuration. This requires a dual-stack cluster and a dual-stack Service configuration. Reconciliation fails if a selected backend node does not have the required IPv6 address. | +| `enable-ipv6-backends` | bool | `false` | When `true`, NodeBalancer services use node public IPv6 addresses as backend targets. VPC IPv6 backend addresses are not supported. | | `backend-ipv4-range` | string | | The IPv4 range from VPC subnet to be applied to the NodeBalancer backend. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) | | `backend-vpc-name` | string | | VPC which is connected to the NodeBalancer backend. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) | | `backend-subnet-name` | string | | Subnet within VPC which is connected to the NodeBalancer backend. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) | diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index 81f6baa9..31b5b4c3 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -53,7 +53,7 @@ The CCM supports the following flags: | `--nodebalancer-backend-ipv4-subnet-name` | String | `""` | ipv4 subnet name to use for NodeBalancer backends | | `--disable-nodebalancer-vpc-backends` | Boolean | `false` | don't use VPC specific ip-addresses for nodebalancer backend ips when running in VPC (set to `true` for backward compatibility if needed) | | `--enable-ipv6-for-loadbalancers` | Boolean | `false` | Set both IPv4 and IPv6 addresses for all LoadBalancer services (when disabled, only IPv4 is used). This can also be configured per-service using the `service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-ingress` annotation. | -| `--enable-ipv6-for-nodebalancer-backends` | Boolean | `false` | Use IPv6 addresses for NodeBalancer service backends. If VPC-backed NodeBalancers are enabled, CCM preserves the NodeBalancer VPC configuration. Enabling this flag can migrate existing eligible NodeBalancer services from IPv4 to IPv6 backends during reconcile. This requires a dual-stack cluster and dual-stack Service configuration. If enabled, every selected backend node must have the required IPv6 address or reconciliation will fail. This can also be configured per-service using the `service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends` annotation. | +| `--enable-ipv6-for-nodebalancer-backends` | Boolean | `false` | Use node public IPv6 addresses for NodeBalancer service backends. VPC IPv6 backend addresses are not supported. Can also be configured per-service using the `service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends` annotation. | | `--node-cidr-mask-size-ipv4` | Int | `24` | ipv4 cidr mask size for pod cidrs allocated to nodes | | `--node-cidr-mask-size-ipv6` | Int | `64` | ipv6 cidr mask size for pod cidrs allocated to nodes | | `--nodebalancer-prefix` | String | `ccm` | Name prefix for NoadBalancers. | diff --git a/docs/configuration/loadbalancer.md b/docs/configuration/loadbalancer.md index d6888ed5..21c93801 100644 --- a/docs/configuration/loadbalancer.md +++ b/docs/configuration/loadbalancer.md @@ -71,13 +71,16 @@ metadata: ``` When IPv6 backends are enabled: +- NodeBalancer backend targets use the node public IPv6 annotation `node.k8s.linode.com/public-ipv6` +- IPv6 NodeBalancer backends are currently supported only through node public IPv6 addresses, not VPC IPv6 backend addresses - both VPC-backed and non-VPC-backed NodeBalancer services are affected - when VPC-backed NodeBalancers are enabled, CCM preserves the NodeBalancer VPC configuration instead of dropping it - enabling the global `--enable-ipv6-for-nodebalancer-backends` flag can migrate existing eligible NodeBalancer services from IPv4 to IPv6 backends during reconcile - to keep an existing Service on IPv4 while the global flag is enabled, set `service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends: "false"` on that Service -- every selected backend node must have an IPv6 address in the currently selected backend path +- every selected backend node must have a public IPv6 address available +- if your cluster uses VPC backends, the nodes still need public IPv6 endpoints so CCM can program IPv6 NodeBalancer backends - the workload cluster and Service must be configured for dual-stack networking -- reconciliation fails and CCM logs an error if a selected backend node does not have the required IPv6 address +- reconciliation fails and CCM logs an error if a selected backend node does not have the required public IPv6 address Recommended Service configuration for IPv6 backends: @@ -101,7 +104,7 @@ spec: app: my-app ``` -If your cluster does not provide IPv6-capable NodePort routing, the NodeBalancer may still be created with IPv6 backend addresses, but the backends will not become healthy. +If your cluster does not provide IPv6-capable NodePort routing, the NodeBalancer may still be created with IPv6 backend addresses, but the backends will not become healthy. Likewise, if your cluster is using VPC backends but the nodes do not also have public IPv6 endpoints, IPv6 backend reconciliation will fail because CCM does not currently program VPC IPv6 backend addresses. ### Basic Configuration From be8cb0b9878ef9f6f47c30ca4012828942c0d0ae Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Wed, 18 Mar 2026 10:28:03 -0700 Subject: [PATCH 25/31] Refactor NodeBalancer subnet handling to support IPv6 backends and update related tests --- cloud/linode/loadbalancers.go | 78 +++++++++++++++--------------- cloud/linode/loadbalancers_test.go | 34 +++++++++++-- 2 files changed, 68 insertions(+), 44 deletions(-) diff --git a/cloud/linode/loadbalancers.go b/cloud/linode/loadbalancers.go index bda9f3e3..563b9e50 100644 --- a/cloud/linode/loadbalancers.go +++ b/cloud/linode/loadbalancers.go @@ -484,28 +484,13 @@ func (l *loadbalancers) updateNodeBalancer( klog.Infof("No preexisting nodebalancer for port %v found.", port.Port) } + useIPv6Backends := resolveIPv6NodeBalancerBackendState(service) // Add all of the Nodes to the config - subnetID := 0 - if options.Options.NodeBalancerBackendIPv4SubnetID != 0 { - subnetID = options.Options.NodeBalancerBackendIPv4SubnetID - } - backendIPv4Range, ok := service.GetAnnotations()[annotations.NodeBalancerBackendIPv4Range] - if ok { - if err = validateNodeBalancerBackendIPv4Range(backendIPv4Range); err != nil { - return err - } - } - if len(options.Options.VPCNames) > 0 && !options.Options.DisableNodeBalancerVPCBackends { - var id int - id, err = l.getSubnetIDForSVC(ctx, service) - if err != nil { - sentry.CaptureError(ctx, err) - return fmt.Errorf("Error getting subnet ID for service %s: %w", service.Name, err) - } - subnetID = id + subnetID, err := l.getBackendSubnetID(ctx, service, useIPv6Backends) + if err != nil { + sentry.CaptureError(ctx, err) + return fmt.Errorf("Error getting subnet ID for service %s: %w", service.Name, err) } - - useIPv6Backends := resolveIPv6NodeBalancerBackendState(service) newNBNodes, err := l.buildNodeBalancerConfigNodes(service, nodes, port.NodePort, subnetID, useIPv6Backends, newNBCfg.Protocol, oldNBNodeIDs) if err != nil { sentry.CaptureError(ctx, err) @@ -937,6 +922,7 @@ func (l *loadbalancers) getSubnetIDByVPCAndSubnetNames(ctx context.Context, vpcN func (l *loadbalancers) createNodeBalancer(ctx context.Context, clusterName string, service *v1.Service, configs []*linodego.NodeBalancerConfigCreateOptions) (lb *linodego.NodeBalancer, err error) { connThrottle := getConnectionThrottle(service) + useIPv6Backends := resolveIPv6NodeBalancerBackendState(service) label := l.GetLoadBalancerName(ctx, clusterName, service) tags := l.GetLoadBalancerTags(ctx, clusterName, service) @@ -950,7 +936,7 @@ func (l *loadbalancers) createNodeBalancer(ctx context.Context, clusterName stri Type: nbType, } - if len(options.Options.VPCNames) > 0 && !options.Options.DisableNodeBalancerVPCBackends { + if !useIPv6Backends && len(options.Options.VPCNames) > 0 && !options.Options.DisableNodeBalancerVPCBackends { createOpts.VPCs, err = l.getVPCCreateOptions(ctx, service) if err != nil { return nil, err @@ -1151,23 +1137,9 @@ func (l *loadbalancers) buildLoadBalancerRequest(ctx context.Context, clusterNam configs := make([]*linodego.NodeBalancerConfigCreateOptions, 0, len(ports)) useIPv6Backends := resolveIPv6NodeBalancerBackendState(service) - subnetID := 0 - if options.Options.NodeBalancerBackendIPv4SubnetID != 0 { - subnetID = options.Options.NodeBalancerBackendIPv4SubnetID - } - // Check for the NodeBalancerBackendIPv4Range annotation - backendIPv4Range, ok := service.GetAnnotations()[annotations.NodeBalancerBackendIPv4Range] - if ok { - if err := validateNodeBalancerBackendIPv4Range(backendIPv4Range); err != nil { - return nil, err - } - } - if len(options.Options.VPCNames) > 0 && !options.Options.DisableNodeBalancerVPCBackends { - id, err := l.getSubnetIDForSVC(ctx, service) - if err != nil { - return nil, err - } - subnetID = id + subnetID, err := l.getBackendSubnetID(ctx, service, useIPv6Backends) + if err != nil { + return nil, err } for _, port := range ports { @@ -1221,12 +1193,40 @@ func (l *loadbalancers) buildNodeBalancerNodeConfigRebuildOptions(service *v1.Se if protocol != linodego.ProtocolUDP { nodeOptions.Mode = "accept" } - if subnetID != 0 { + if !useIPv6Backends && subnetID != 0 { nodeOptions.SubnetID = subnetID } return nodeOptions, nil } +func (l *loadbalancers) getBackendSubnetID(ctx context.Context, service *v1.Service, useIPv6Backends bool) (int, error) { + if useIPv6Backends { + return 0, nil + } + + subnetID := 0 + if options.Options.NodeBalancerBackendIPv4SubnetID != 0 { + subnetID = options.Options.NodeBalancerBackendIPv4SubnetID + } + + backendIPv4Range, ok := service.GetAnnotations()[annotations.NodeBalancerBackendIPv4Range] + if ok { + if err := validateNodeBalancerBackendIPv4Range(backendIPv4Range); err != nil { + return 0, err + } + } + + if len(options.Options.VPCNames) > 0 && !options.Options.DisableNodeBalancerVPCBackends { + id, err := l.getSubnetIDForSVC(ctx, service) + if err != nil { + return 0, err + } + subnetID = id + } + + return subnetID, nil +} + func resolveIPv6NodeBalancerBackendState(service *v1.Service) bool { useIPv6 := getServiceBoolAnnotation(service, annotations.AnnLinodeEnableIPv6Backends) if useIPv6 != nil { diff --git a/cloud/linode/loadbalancers_test.go b/cloud/linode/loadbalancers_test.go index 2ac0ea78..9f01ab97 100644 --- a/cloud/linode/loadbalancers_test.go +++ b/cloud/linode/loadbalancers_test.go @@ -4280,7 +4280,7 @@ func Test_resolveIPv6NodeBalancerBackendState(t *testing.T) { } } -func Test_buildLoadBalancerRequestPreservesVPCConfigForIPv6Backends(t *testing.T) { +func Test_buildLoadBalancerRequestOmitsVPCConfigForIPv6Backends(t *testing.T) { prevVPCNames := options.Options.VPCNames prevSubnetNames := options.Options.SubnetNames prevDisableVPC := options.Options.DisableNodeBalancerVPCBackends @@ -4302,7 +4302,7 @@ func Test_buildLoadBalancerRequestPreservesVPCConfigForIPv6Backends(t *testing.T annotations map[string]string }{ { - name: "service annotation preserves VPC config for IPv6 backends", + name: "service annotation omits VPC config for IPv6 backends", globalFlag: false, annotations: map[string]string{ annotations.AnnLinodeDefaultProtocol: "tcp", @@ -4310,7 +4310,7 @@ func Test_buildLoadBalancerRequestPreservesVPCConfigForIPv6Backends(t *testing.T }, }, { - name: "global flag preserves VPC config for IPv6 backends", + name: "global flag omits VPC config for IPv6 backends", globalFlag: true, annotations: map[string]string{ annotations.AnnLinodeDefaultProtocol: "tcp", @@ -4404,12 +4404,15 @@ func Test_buildLoadBalancerRequestPreservesVPCConfigForIPv6Backends(t *testing.T if err := json.Unmarshal([]byte(req.Body), &createOpts); err != nil { t.Fatalf("unable to unmarshal create request body %#v, error: %#v", req.Body, err) } - if len(createOpts.VPCs) != 1 || createOpts.VPCs[0].SubnetID == 0 { - t.Fatalf("expected nodebalancer create request to preserve VPC config, got %#v", createOpts.VPCs) + if len(createOpts.VPCs) != 0 { + t.Fatalf("expected nodebalancer create request to omit VPC config for IPv6 backends, got %#v", createOpts.VPCs) } if len(createOpts.Configs) != 1 || len(createOpts.Configs[0].Nodes) != 1 { t.Fatalf("expected a single nodebalancer config with one backend node, got %#v", createOpts.Configs) } + if createOpts.Configs[0].Nodes[0].SubnetID != 0 { + t.Fatalf("expected IPv6 backend node to omit subnet ID, got %#v", createOpts.Configs[0].Nodes[0]) + } host, _, hostPortErr := net.SplitHostPort(createOpts.Configs[0].Nodes[0].Address) if hostPortErr != nil { t.Fatal(hostPortErr) @@ -4421,6 +4424,27 @@ func Test_buildLoadBalancerRequestPreservesVPCConfigForIPv6Backends(t *testing.T } } +func Test_buildNodeBalancerNodeConfigRebuildOptionsOmitsSubnetIDForIPv6Backends(t *testing.T) { + lb := &loadbalancers{} + service := &v1.Service{ObjectMeta: metav1.ObjectMeta{Name: "svc-test", Namespace: "default"}} + node := &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node-1", + Annotations: map[string]string{ + annotations.AnnLinodeNodePublicIPv6: "2600:3c06:e727:1::1/128", + }, + }, + } + + opts, err := lb.buildNodeBalancerNodeConfigRebuildOptions(service, node, 30000, 101, true, linodego.ProtocolTCP) + if err != nil { + t.Fatal(err) + } + if opts.SubnetID != 0 { + t.Fatalf("expected IPv6 backend rebuild options to omit subnet ID, got %#v", opts) + } +} + func Test_formatNodeBalancerBackendAddress(t *testing.T) { if got := formatNodeBalancerBackendAddress("192.168.0.10", 30000); got != "192.168.0.10:30000" { t.Fatalf("unexpected IPv4 backend address format: %s", got) From 16c835c7b6e9240a59381a0e3b60ac7488a244b6 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Mon, 23 Mar 2026 16:20:20 -0500 Subject: [PATCH 26/31] Temp fix for the dual stack cluster to enable public ipv6 nodeport datapath --- Makefile | 16 +++++++-- .../lb-with-ipv6-backends/chainsaw-test.yaml | 35 +++++++++++++++---- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index ae7c49b8..fc609299 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,9 @@ CAAPH_VERSION ?= "v0.6.1" # renovate: datasource=github-tags depName=linode/cluster-api-provider-linode CAPL_VERSION ?= "v0.10.1" +# renovate: datasource=github-tags depName=cilium/cilium +CILIUM_VERSION ?= "1.18.7" + # renovate: datasource=github-tags depName=golangci/golangci-lint GOLANGCI_LINT_VERSION ?= "v2.11.3" @@ -165,15 +168,23 @@ run-debug: build mgmt-and-capl-cluster: docker-setup mgmt-cluster capl-cluster .PHONY: capl-cluster -capl-cluster: generate-capl-cluster-manifests create-capl-cluster patch-linode-ccm +capl-cluster: generate-capl-cluster-manifests create-capl-cluster .PHONY: generate-capl-cluster-manifests generate-capl-cluster-manifests: clusterctl # Create the CAPL cluster manifests without any CSI driver stuff - LINODE_FIREWALL_ENABLED=$(LINODE_FIREWALL_ENABLED) LINODE_OS=$(LINODE_OS) VPC_NAME=$(VPC_NAME) $(CLUSTERCTL) generate cluster $(CLUSTER_NAME) \ + LINODE_FIREWALL_ENABLED=$(LINODE_FIREWALL_ENABLED) LINODE_OS=$(LINODE_OS) VPC_NAME=$(VPC_NAME) CILIUM_VERSION=$(CILIUM_VERSION) $(CLUSTERCTL) generate cluster $(CLUSTER_NAME) \ --kubernetes-version $(K8S_VERSION) --infrastructure linode-linode:$(CAPL_VERSION) \ --control-plane-machine-count $(CONTROLPLANE_NODES) --worker-machine-count $(WORKER_NODES) --flavor kubeadm-dual-stack > $(MANIFEST_NAME).yaml yq -i e 'select(.kind == "LinodeVPC").spec.ipv6Range = [{"range": "auto"}] | select(.kind == "LinodeVPC").spec.subnets = [{"ipv4": "10.0.0.0/8", "label": "default", "ipv6Range": [{"range": "auto"}]}, {"ipv4": "172.16.0.0/16", "label": "testing", "ipv6Range": [{"range": "auto"}]}]' $(MANIFEST_NAME).yaml + yq e 'select(.kind == "HelmChartProxy" and .spec.chartName == "cilium").spec.valuesTemplate' $(MANIFEST_NAME).yaml > tmp-cilium.yaml + yq -i e '.devices = ["eth0", "eth1"] | .nodePort.addresses = ["0.0.0.0/0", "::/0"] | .nodePort.directRoutingDevice = "eth0" | .hostFirewall.enabled = false | del(.extraArgs[] | select(. == "--nodeport-addresses=0.0.0.0/0")) | del(.extraArgs[] | select(. == "--nodeport-addresses=0.0.0.0/0,::/0"))' tmp-cilium.yaml + yq -i e 'select(.kind == "HelmChartProxy" and .spec.chartName == "cilium").spec.valuesTemplate = load_str("tmp-cilium.yaml")' $(MANIFEST_NAME).yaml + rm tmp-cilium.yaml + yq e 'select(.kind == "HelmChartProxy" and .spec.chartName == "ccm-linode").spec.valuesTemplate' $(MANIFEST_NAME).yaml > tmp.yaml + IMG_TAG=$${IMG##*:} yq -i e '.image.tag = strenv(IMG_TAG) | .image.pullPolicy = "Always"' tmp.yaml + yq -i e 'select(.kind == "HelmChartProxy" and .spec.chartName == "ccm-linode").spec.valuesTemplate = load_str("tmp.yaml")' $(MANIFEST_NAME).yaml + rm tmp.yaml .PHONY: create-capl-cluster create-capl-cluster: clusterctl @@ -189,6 +200,7 @@ create-capl-cluster: clusterctl .PHONY: patch-linode-ccm patch-linode-ccm: KUBECONFIG=$(KUBECONFIG_PATH) kubectl patch -n kube-system daemonset ccm-linode --type='json' -p="[{'op': 'replace', 'path': '/spec/template/spec/containers/0/image', 'value': '${IMG}'}]" + KUBECONFIG=$(KUBECONFIG_PATH) kubectl patch -n kube-system daemonset ccm-linode --type='json' -p='[{"op": "replace", "path": "/spec/template/spec/containers/0/imagePullPolicy", "value": "Always"}]' KUBECONFIG=$(KUBECONFIG_PATH) kubectl patch -n kube-system daemonset ccm-linode --type='json' -p='[{"op": "add", "path": "/spec/template/spec/containers/0/env/-", "value": {"name": "LINODE_API_VERSION", "value": "v4beta"}}]' KUBECONFIG=$(KUBECONFIG_PATH) kubectl rollout status -n kube-system daemonset/ccm-linode --timeout=600s KUBECONFIG=$(KUBECONFIG_PATH) kubectl -n kube-system get daemonset/ccm-linode -o yaml diff --git a/e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml b/e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml index 95275902..e923db04 100644 --- a/e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml +++ b/e2e/test/lb-with-ipv6-backends/chainsaw-test.yaml @@ -78,22 +78,43 @@ spec: ($error): ~ (contains($stdout, 'NO_BACKEND_ADDRESSES')): false (contains($stdout, 'is NOT IPv6')): false + - name: Wait for loadbalancer to start serving traffic + try: + - script: + timeout: 10m + content: | + bash -ce ' + TARGET_IP=$(kubectl get svc svc-test -n "$NAMESPACE" -o json | jq -r '"'"'.status.loadBalancer.ingress[]? | select(.ip != null and (.ip | contains(":") | not)) | .ip'"'"' | head -n1) + TARGET="http://$TARGET_IP:80" + + for i in {1..24}; do + output=$(curl -s --max-time 8 "$TARGET" | jq -r .podName 2>/dev/null || true) + if [[ "$output" == *"test-"* ]]; then + echo "loadbalancer ready" + exit 0 + fi + sleep 5 + done + + echo "loadbalancer not ready" + exit 1 + ' + check: + ($error == null): true + (contains($stdout, 'loadbalancer ready')): true - name: Fetch loadbalancer ip and check both pods reachable try: - script: + timeout: 10m content: | bash -ce ' - IP=$(kubectl get svc svc-test -n "$NAMESPACE" -o json | jq -r .status.loadBalancer.ingress[0].ip) - if [[ "$IP" == *:* ]]; then - TARGET="[$IP]" - else - TARGET="$IP" - fi + TARGET_IP=$(kubectl get svc svc-test -n "$NAMESPACE" -o json | jq -r '"'"'.status.loadBalancer.ingress[]? | select(.ip != null and (.ip | contains(":") | not)) | .ip'"'"' | head -n1) + TARGET="http://$TARGET_IP:80" podnames=() for i in {1..10}; do if [[ ${#podnames[@]} -lt 2 ]]; then - output=$(curl -s "${TARGET}:80" | jq -e .podName || true) + output=$(curl -s --max-time 8 "$TARGET" | jq -e .podName || true) if [[ "$output" == *"test-"* ]]; then unique=true From 51e6db62131ba2a05433fc2694b5b5573602aa65 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Mon, 23 Mar 2026 16:35:02 -0500 Subject: [PATCH 27/31] Fix CAPL manifest CCM overrides --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index fc609299..3d936846 100644 --- a/Makefile +++ b/Makefile @@ -182,7 +182,7 @@ generate-capl-cluster-manifests: clusterctl yq -i e 'select(.kind == "HelmChartProxy" and .spec.chartName == "cilium").spec.valuesTemplate = load_str("tmp-cilium.yaml")' $(MANIFEST_NAME).yaml rm tmp-cilium.yaml yq e 'select(.kind == "HelmChartProxy" and .spec.chartName == "ccm-linode").spec.valuesTemplate' $(MANIFEST_NAME).yaml > tmp.yaml - IMG_TAG=$${IMG##*:} yq -i e '.image.tag = strenv(IMG_TAG) | .image.pullPolicy = "Always"' tmp.yaml + IMG_TAG=$${IMG##*:} IMG_REPO=$${IMG%:*} yq -i e '.image.repository = strenv(IMG_REPO) | .image.tag = strenv(IMG_TAG) | .image.pullPolicy = "Always" | .env = ((.env // []) | map(select(.name != "LINODE_API_VERSION")) + [{"name": "LINODE_API_VERSION", "value": "v4beta"}])' tmp.yaml yq -i e 'select(.kind == "HelmChartProxy" and .spec.chartName == "ccm-linode").spec.valuesTemplate = load_str("tmp.yaml")' $(MANIFEST_NAME).yaml rm tmp.yaml From 2327abffdb54e01da2be66d8b2e009680cd69a09 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Mon, 23 Mar 2026 16:35:49 -0500 Subject: [PATCH 28/31] Restore EnsureLoadBalancer contract comment --- cloud/linode/loadbalancers.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cloud/linode/loadbalancers.go b/cloud/linode/loadbalancers.go index 563b9e50..2750441b 100644 --- a/cloud/linode/loadbalancers.go +++ b/cloud/linode/loadbalancers.go @@ -264,6 +264,8 @@ func (l *loadbalancers) GetLoadBalancer(ctx context.Context, clusterName string, // EnsureLoadBalancer ensures that the cluster is running a load balancer for // service. +// +// EnsureLoadBalancer will not modify service or nodes. func (l *loadbalancers) EnsureLoadBalancer(ctx context.Context, clusterName string, service *v1.Service, nodes []*v1.Node) (lbStatus *v1.LoadBalancerStatus, err error) { ctx = sentry.SetHubOnContext(ctx) sentry.SetTag(ctx, "cluster_name", clusterName) From 9498c05dbefb4071d2c75fcd8788a6fd09989f5a Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Mon, 23 Mar 2026 17:53:33 -0500 Subject: [PATCH 29/31] Fix CAPL manifest yq patching --- Makefile | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 3d936846..ef69c30f 100644 --- a/Makefile +++ b/Makefile @@ -177,14 +177,8 @@ generate-capl-cluster-manifests: clusterctl --kubernetes-version $(K8S_VERSION) --infrastructure linode-linode:$(CAPL_VERSION) \ --control-plane-machine-count $(CONTROLPLANE_NODES) --worker-machine-count $(WORKER_NODES) --flavor kubeadm-dual-stack > $(MANIFEST_NAME).yaml yq -i e 'select(.kind == "LinodeVPC").spec.ipv6Range = [{"range": "auto"}] | select(.kind == "LinodeVPC").spec.subnets = [{"ipv4": "10.0.0.0/8", "label": "default", "ipv6Range": [{"range": "auto"}]}, {"ipv4": "172.16.0.0/16", "label": "testing", "ipv6Range": [{"range": "auto"}]}]' $(MANIFEST_NAME).yaml - yq e 'select(.kind == "HelmChartProxy" and .spec.chartName == "cilium").spec.valuesTemplate' $(MANIFEST_NAME).yaml > tmp-cilium.yaml - yq -i e '.devices = ["eth0", "eth1"] | .nodePort.addresses = ["0.0.0.0/0", "::/0"] | .nodePort.directRoutingDevice = "eth0" | .hostFirewall.enabled = false | del(.extraArgs[] | select(. == "--nodeport-addresses=0.0.0.0/0")) | del(.extraArgs[] | select(. == "--nodeport-addresses=0.0.0.0/0,::/0"))' tmp-cilium.yaml - yq -i e 'select(.kind == "HelmChartProxy" and .spec.chartName == "cilium").spec.valuesTemplate = load_str("tmp-cilium.yaml")' $(MANIFEST_NAME).yaml - rm tmp-cilium.yaml - yq e 'select(.kind == "HelmChartProxy" and .spec.chartName == "ccm-linode").spec.valuesTemplate' $(MANIFEST_NAME).yaml > tmp.yaml - IMG_TAG=$${IMG##*:} IMG_REPO=$${IMG%:*} yq -i e '.image.repository = strenv(IMG_REPO) | .image.tag = strenv(IMG_TAG) | .image.pullPolicy = "Always" | .env = ((.env // []) | map(select(.name != "LINODE_API_VERSION")) + [{"name": "LINODE_API_VERSION", "value": "v4beta"}])' tmp.yaml - yq -i e 'select(.kind == "HelmChartProxy" and .spec.chartName == "ccm-linode").spec.valuesTemplate = load_str("tmp.yaml")' $(MANIFEST_NAME).yaml - rm tmp.yaml + yq -i e '(select(.kind == "HelmChartProxy" and .spec.chartName == "cilium").spec.valuesTemplate |= sub("hostFirewall:\n enabled: [^\n]+\n"; "hostFirewall:\n enabled: false\n")) | (select(.kind == "HelmChartProxy" and .spec.chartName == "cilium").spec.valuesTemplate |= sub("extraArgs:\n(?:- --nodeport-addresses=0\\.0\\.0\\.0/0(?:,::/0)?\n)+"; "extraArgs: []\n")) | (select(.kind == "HelmChartProxy" and .spec.chartName == "cilium").spec.valuesTemplate |= sub("hubble:\n relay:\n enabled: true\n ui:\n enabled: true"; "hubble:\n relay:\n enabled: true\n ui:\n enabled: true\ndevices:\n - eth0\n - eth1\nnodePort:\n addresses:\n - 0.0.0.0/0\n - ::/0\n directRoutingDevice: eth0"))' $(MANIFEST_NAME).yaml + IMG_TAG=$${IMG##*:} yq -i e '(select(.kind == "HelmChartProxy" and .spec.chartName == "ccm-linode").spec.valuesTemplate |= sub("tag: [^\\n]+"; "tag: " + strenv(IMG_TAG))) | (select(.kind == "HelmChartProxy" and .spec.chartName == "ccm-linode").spec.valuesTemplate |= sub("pullPolicy: [^\\n]+"; "pullPolicy: Always"))' $(MANIFEST_NAME).yaml .PHONY: create-capl-cluster create-capl-cluster: clusterctl From 3a841c12f9731969582e946e1c031ebb03958a34 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Mon, 23 Mar 2026 19:51:59 -0500 Subject: [PATCH 30/31] Use CAPL v0.10.2 manifests --- Makefile | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index ef69c30f..9fc1331a 100644 --- a/Makefile +++ b/Makefile @@ -34,10 +34,7 @@ CAPI_VERSION ?= "v1.12.3" CAAPH_VERSION ?= "v0.6.1" # renovate: datasource=github-tags depName=linode/cluster-api-provider-linode -CAPL_VERSION ?= "v0.10.1" - -# renovate: datasource=github-tags depName=cilium/cilium -CILIUM_VERSION ?= "1.18.7" +CAPL_VERSION ?= "v0.10.2" # renovate: datasource=github-tags depName=golangci/golangci-lint GOLANGCI_LINT_VERSION ?= "v2.11.3" @@ -173,11 +170,10 @@ capl-cluster: generate-capl-cluster-manifests create-capl-cluster .PHONY: generate-capl-cluster-manifests generate-capl-cluster-manifests: clusterctl # Create the CAPL cluster manifests without any CSI driver stuff - LINODE_FIREWALL_ENABLED=$(LINODE_FIREWALL_ENABLED) LINODE_OS=$(LINODE_OS) VPC_NAME=$(VPC_NAME) CILIUM_VERSION=$(CILIUM_VERSION) $(CLUSTERCTL) generate cluster $(CLUSTER_NAME) \ + LINODE_FIREWALL_ENABLED=$(LINODE_FIREWALL_ENABLED) LINODE_OS=$(LINODE_OS) VPC_NAME=$(VPC_NAME) $(CLUSTERCTL) generate cluster $(CLUSTER_NAME) \ --kubernetes-version $(K8S_VERSION) --infrastructure linode-linode:$(CAPL_VERSION) \ --control-plane-machine-count $(CONTROLPLANE_NODES) --worker-machine-count $(WORKER_NODES) --flavor kubeadm-dual-stack > $(MANIFEST_NAME).yaml yq -i e 'select(.kind == "LinodeVPC").spec.ipv6Range = [{"range": "auto"}] | select(.kind == "LinodeVPC").spec.subnets = [{"ipv4": "10.0.0.0/8", "label": "default", "ipv6Range": [{"range": "auto"}]}, {"ipv4": "172.16.0.0/16", "label": "testing", "ipv6Range": [{"range": "auto"}]}]' $(MANIFEST_NAME).yaml - yq -i e '(select(.kind == "HelmChartProxy" and .spec.chartName == "cilium").spec.valuesTemplate |= sub("hostFirewall:\n enabled: [^\n]+\n"; "hostFirewall:\n enabled: false\n")) | (select(.kind == "HelmChartProxy" and .spec.chartName == "cilium").spec.valuesTemplate |= sub("extraArgs:\n(?:- --nodeport-addresses=0\\.0\\.0\\.0/0(?:,::/0)?\n)+"; "extraArgs: []\n")) | (select(.kind == "HelmChartProxy" and .spec.chartName == "cilium").spec.valuesTemplate |= sub("hubble:\n relay:\n enabled: true\n ui:\n enabled: true"; "hubble:\n relay:\n enabled: true\n ui:\n enabled: true\ndevices:\n - eth0\n - eth1\nnodePort:\n addresses:\n - 0.0.0.0/0\n - ::/0\n directRoutingDevice: eth0"))' $(MANIFEST_NAME).yaml IMG_TAG=$${IMG##*:} yq -i e '(select(.kind == "HelmChartProxy" and .spec.chartName == "ccm-linode").spec.valuesTemplate |= sub("tag: [^\\n]+"; "tag: " + strenv(IMG_TAG))) | (select(.kind == "HelmChartProxy" and .spec.chartName == "ccm-linode").spec.valuesTemplate |= sub("pullPolicy: [^\\n]+"; "pullPolicy: Always"))' $(MANIFEST_NAME).yaml .PHONY: create-capl-cluster @@ -256,7 +252,6 @@ e2e-test-subnet: # Create the second cluster MANIFEST_NAME=$(SUBNET_MANIFEST_NAME) CLUSTER_NAME=$(SUBNET_CLUSTER_NAME) KUBECONFIG_PATH=$(SUBNET_KUBECONFIG_PATH) \ make create-capl-cluster - KUBECONFIG_PATH=$(SUBNET_KUBECONFIG_PATH) make patch-linode-ccm # Run chainsaw test LINODE_TOKEN=$(LINODE_TOKEN) \ LINODE_URL=$(LINODE_URL) \ From 5af9bbf6f8611980845b9ac0c61a572202d339b0 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Mon, 23 Mar 2026 22:30:20 -0500 Subject: [PATCH 31/31] test --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 9fc1331a..fa146237 100644 --- a/Makefile +++ b/Makefile @@ -174,7 +174,7 @@ generate-capl-cluster-manifests: clusterctl --kubernetes-version $(K8S_VERSION) --infrastructure linode-linode:$(CAPL_VERSION) \ --control-plane-machine-count $(CONTROLPLANE_NODES) --worker-machine-count $(WORKER_NODES) --flavor kubeadm-dual-stack > $(MANIFEST_NAME).yaml yq -i e 'select(.kind == "LinodeVPC").spec.ipv6Range = [{"range": "auto"}] | select(.kind == "LinodeVPC").spec.subnets = [{"ipv4": "10.0.0.0/8", "label": "default", "ipv6Range": [{"range": "auto"}]}, {"ipv4": "172.16.0.0/16", "label": "testing", "ipv6Range": [{"range": "auto"}]}]' $(MANIFEST_NAME).yaml - IMG_TAG=$${IMG##*:} yq -i e '(select(.kind == "HelmChartProxy" and .spec.chartName == "ccm-linode").spec.valuesTemplate |= sub("tag: [^\\n]+"; "tag: " + strenv(IMG_TAG))) | (select(.kind == "HelmChartProxy" and .spec.chartName == "ccm-linode").spec.valuesTemplate |= sub("pullPolicy: [^\\n]+"; "pullPolicy: Always"))' $(MANIFEST_NAME).yaml + IMG_TAG=$${IMG##*:} CILIUM_HOST_FIREWALL=$$'hostFirewall:\n enabled: false' yq -i e '(select(.kind == "HelmChartProxy" and .spec.chartName == "ccm-linode").spec.valuesTemplate |= sub("tag: [^\\n]+"; "tag: " + strenv(IMG_TAG))) | (select(.kind == "HelmChartProxy" and .spec.chartName == "ccm-linode").spec.valuesTemplate |= sub("pullPolicy: [^\\n]+"; "pullPolicy: Always")) | (select(.kind == "HelmChartProxy" and .spec.chartName == "cilium").spec.valuesTemplate |= sub("hostFirewall:\\n enabled: true"; strenv(CILIUM_HOST_FIREWALL)))' $(MANIFEST_NAME).yaml .PHONY: create-capl-cluster create-capl-cluster: clusterctl