From ce995f226a9aeca03f4e0308060caf30b7a9b5a3 Mon Sep 17 00:00:00 2001 From: Tomy Guichard Date: Mon, 20 Jul 2026 15:29:09 +0200 Subject: [PATCH] feat: add service.beta.kubernetes.io/scw-loadbalancer-flexible-ip-types annotation --- docs/loadbalancer-annotations.md | 10 +++ scaleway/loadbalancers.go | 14 +++- scaleway/loadbalancers_annotations.go | 34 +++++++++ scaleway/loadbalancers_test.go | 101 ++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 2 deletions(-) diff --git a/docs/loadbalancer-annotations.md b/docs/loadbalancer-annotations.md index 6052c0b..80d177d 100644 --- a/docs/loadbalancer-annotations.md +++ b/docs/loadbalancer-annotations.md @@ -229,6 +229,16 @@ The possible formats are: - ``: will attach a single IP to the LB. - `,`: will attach the two IPs to the LB. +### `service.beta.kubernetes.io/scw-loadbalancer-flexible-ip-types` + +This is the annotation to choose which flexible IP(s) to book when the LB is created. +It is a comma delimited list of IP types. The default value is `ipv4` and the possible +values are `ipv4` or `ipv4,ipv6`. +A flexible IPv4 is always required, an IPv6-only LB is not supported. +This annotation is only taken into account at LB creation, and has no effect on an +already existing LB. It is ignored if `service.beta.kubernetes.io/scw-loadbalancer-ip-ids` +is set, since in that case the provided IP(s) are used instead of booking new ones. + ### `service.beta.kubernetes.io/scw-loadbalancer-private-ip-ids` This is the annotation to statically set the private IPs of the loadbalancer. diff --git a/scaleway/loadbalancers.go b/scaleway/loadbalancers.go index 261cfbc..8facc12 100644 --- a/scaleway/loadbalancers.go +++ b/scaleway/loadbalancers.go @@ -480,11 +480,20 @@ func (l *loadbalancers) createLoadBalancer(ctx context.Context, service *v1.Serv // Attach specific IP if set var ipIDs []string + var assignFlexibleIP, assignFlexibleIPv6 bool if !lbPrivate { ipIDs, err = l.getLoadBalancerStaticIPIDs(ctx, service) if err != nil { return nil, err } + + // We must only assign flexible IP(s) if no IP ID is provided. + if len(ipIDs) == 0 { + assignFlexibleIP, assignFlexibleIPv6, err = getFlexibleIPTypes(service) + if err != nil { + return nil, fmt.Errorf("invalid value for annotation %s: %w", serviceAnnotationLoadBalancerFlexibleIPTypes, err) + } + } } lbName := l.GetLoadBalancerName(ctx, "", service) @@ -510,9 +519,10 @@ func (l *loadbalancers) createLoadBalancer(ctx context.Context, service *v1.Serv Tags: tags, IPIDs: ipIDs, Type: lbType, - // We must only assign a flexible IP if LB is public AND no IP ID is provided. + // We must only assign flexible IP(s) if LB is public AND no IP ID is provided. // If IP IDs are provided, there must be at least one IPv4. - AssignFlexibleIP: scw.BoolPtr(!lbPrivate && len(ipIDs) == 0), + AssignFlexibleIP: new(assignFlexibleIP), + AssignFlexibleIPv6: new(assignFlexibleIPv6), SslCompatibilityLevel: sslCompatibilityLevel, } lb, err := l.api.CreateLB(&request, scw.WithContext(ctx)) diff --git a/scaleway/loadbalancers_annotations.go b/scaleway/loadbalancers_annotations.go index 37e38d8..f60bf2b 100644 --- a/scaleway/loadbalancers_annotations.go +++ b/scaleway/loadbalancers_annotations.go @@ -1,6 +1,7 @@ package scaleway import ( + "errors" "fmt" "net/url" "slices" @@ -253,6 +254,13 @@ const ( // - ",": will attach the two Private Networks to the LB. serviceAnnotationPrivateNetworkIDs = "service.beta.kubernetes.io/scw-loadbalancer-pn-ids" + // serviceAnnotationLoadBalancerFlexibleIPTypes is the annotation to choose which flexible IP(s) to book for the LB + // when it is created and no static IP is provided via service.beta.kubernetes.io/scw-loadbalancer-ip-ids. + // The default value is "ipv4" and the possible values are "ipv4" or "ipv4,ipv6". + // A flexible IPv4 is always required, an IPv6-only LB is not supported. + // This annotation is only used at LB creation and has no effect on an already existing LB. + serviceAnnotationLoadBalancerFlexibleIPTypes = "service.beta.kubernetes.io/scw-loadbalancer-flexible-ip-types" + // serviceAnnotationLoadBalancerHealthCheckFromService is the annotation to use healthCheckNodePort from the service // The possible values are "false", "true" or "*" for all ports or a comma delimited list of the service port // (for instance "80,443"). When enabled for a port, the health check will use the service's healthCheckNodePort @@ -329,6 +337,32 @@ func getIPIDs(service *v1.Service) []string { return strings.Split(ipIDs, ",") } +// getFlexibleIPTypes returns whether a flexible IPv4 and/or a flexible IPv6 should be +// booked for the LB, based on the serviceAnnotationLoadBalancerFlexibleIPTypes annotation. +func getFlexibleIPTypes(service *v1.Service) (assignIPv4 bool, assignIPv6 bool, err error) { + flexibleIPTypes := service.Annotations[serviceAnnotationLoadBalancerFlexibleIPTypes] + if flexibleIPTypes == "" { + return true, false, nil + } + + for ipType := range strings.SplitSeq(flexibleIPTypes, ",") { + switch strings.ToLower(strings.TrimSpace(ipType)) { + case "ipv4": + assignIPv4 = true + case "ipv6": + assignIPv6 = true + default: + return false, false, fmt.Errorf("unsupported value %q", strings.ToLower(strings.TrimSpace(ipType))) + } + } + + if !assignIPv4 { + return false, false, errors.New("an IPv4 is always required") + } + + return assignIPv4, assignIPv6, nil +} + func getPrivateIPIDs(service *v1.Service) sets.Set[string] { privateIPIDs := service.Annotations[serviceAnnotationLoadBalancerPrivateIPIDs] if privateIPIDs == "" { diff --git a/scaleway/loadbalancers_test.go b/scaleway/loadbalancers_test.go index 7fe4c31..d858f5d 100644 --- a/scaleway/loadbalancers_test.go +++ b/scaleway/loadbalancers_test.go @@ -2185,6 +2185,107 @@ func TestGetSSLCompatibilityLevel(t *testing.T) { } } +func TestGetFlexibleIPTypes(t *testing.T) { + matrix := []struct { + name string + service *v1.Service + wantIPv4 bool + wantIPv6 bool + wantErr bool + }{ + { + "with no annotation", + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{}, + }, + }, + true, + false, + false, + }, + { + "with ipv4", + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "service.beta.kubernetes.io/scw-loadbalancer-flexible-ip-types": "ipv4", + }, + }, + }, + true, + false, + false, + }, + { + "with ipv6 only (unsupported)", + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "service.beta.kubernetes.io/scw-loadbalancer-flexible-ip-types": "ipv6", + }, + }, + }, + false, + false, + true, + }, + { + "with ipv4,ipv6", + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "service.beta.kubernetes.io/scw-loadbalancer-flexible-ip-types": "ipv4,ipv6", + }, + }, + }, + true, + true, + false, + }, + { + "with spaces and uppercase values", + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "service.beta.kubernetes.io/scw-loadbalancer-flexible-ip-types": "IPV4, IPV6", + }, + }, + }, + true, + true, + false, + }, + { + "with invalid value", + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "service.beta.kubernetes.io/scw-loadbalancer-flexible-ip-types": "invalid", + }, + }, + }, + false, + false, + true, + }, + } + + for _, tt := range matrix { + t.Run(tt.name, func(t *testing.T) { + gotIPv4, gotIPv6, err := getFlexibleIPTypes(tt.service) + if tt.wantErr != (err != nil) { + t.Errorf("got error: %s, expected: %v", err, tt.wantErr) + return + } + + if gotIPv4 != tt.wantIPv4 || gotIPv6 != tt.wantIPv6 { + t.Errorf("want: (%v, %v), got: (%v, %v)", tt.wantIPv4, tt.wantIPv6, gotIPv4, gotIPv6) + } + }) + } +} + func Test_ipMode(t *testing.T) { type args struct { service *v1.Service