From 0bf0d35966dcd3102d2a0e8b62f6bae8ea0786bf Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Mon, 8 Dec 2025 14:16:08 -0600 Subject: [PATCH 1/7] [feat] Add frontend VPC support for NodeBalancers Add support for configuring NodeBalancer frontend VPC placement via service annotations. This enables NodeBalancers to be deployed with private frontend addresses within a VPC. New annotations: - linode-loadbalancer-frontend-ipv4-range: Explicit IPv4 CIDR - linode-loadbalancer-frontend-ipv6-range: Explicit IPv6 CIDR - linode-loadbalancer-frontend-vpc-name: VPC name for resolution - linode-loadbalancer-frontend-subnet-name: Subnet name for resolution - linode-loadbalancer-frontend-subnet-id: Direct subnet ID Resolution precedence: 1. IPv4/IPv6 Range annotations (explicit CIDR) 2. VPC/Subnet name annotations (name-based resolution) 3. Subnet ID annotation (direct ID) Key behavioral difference from backend VPC implementation: - Frontend VPC is opt-in: returns nil when no annotations are present, resulting in no frontend VPC configuration - Backend VPC is always configured: falls through precedence levels and always returns VPC options using the service's default subnet ID This design allows frontend VPC to remain an optional feature while backend VPC continues to be mandatory for NodeBalancer operation. Includes: - CIDR validation for IPv4 and IPv6 ranges - Name-to-ID resolution requiring both vpc-name and subnet-name - Unit tests for validation, status generation, and option building - Debug logging for frontend VPC NodeBalancers --- cloud/annotations/annotations.go | 6 + cloud/linode/loadbalancers.go | 163 ++++++++++++++++++ cloud/linode/loadbalancers_test.go | 266 +++++++++++++++++++++++++++++ go.mod | 19 ++- go.sum | 40 ++--- 5 files changed, 465 insertions(+), 29 deletions(-) diff --git a/cloud/annotations/annotations.go b/cloud/annotations/annotations.go index 76e2d83f..197542bb 100644 --- a/cloud/annotations/annotations.go +++ b/cloud/annotations/annotations.go @@ -53,4 +53,10 @@ const ( NodeBalancerBackendVPCName = "service.beta.kubernetes.io/linode-loadbalancer-backend-vpc-name" NodeBalancerBackendSubnetName = "service.beta.kubernetes.io/linode-loadbalancer-backend-subnet-name" NodeBalancerBackendSubnetID = "service.beta.kubernetes.io/linode-loadbalancer-backend-subnet-id" + + NodeBalancerFrontendIPv4Range = "service.beta.kubernetes.io/linode-loadbalancer-frontend-ipv4-range" + NodeBalancerFrontendIPv6Range = "service.beta.kubernetes.io/linode-loadbalancer-frontend-ipv6-range" + NodeBalancerFrontendVPCName = "service.beta.kubernetes.io/linode-loadbalancer-frontend-vpc-name" + NodeBalancerFrontendSubnetName = "service.beta.kubernetes.io/linode-loadbalancer-frontend-subnet-name" + NodeBalancerFrontendSubnetID = "service.beta.kubernetes.io/linode-loadbalancer-frontend-subnet-id" ) diff --git a/cloud/linode/loadbalancers.go b/cloud/linode/loadbalancers.go index 681f4b75..24a8d5e6 100644 --- a/cloud/linode/loadbalancers.go +++ b/cloud/linode/loadbalancers.go @@ -848,6 +848,130 @@ func (l *loadbalancers) getVPCCreateOptions(ctx context.Context, service *v1.Ser return vpcCreateOpts, nil } +// getFrontendVPCCreateOptions returns the VPC options for the NodeBalancer frontend VPC creation. +// Order of precedence: +// 1. Frontend IPv4/IPv6 Range Annotations - Explicit CIDR ranges +// 2. Frontend VPC/Subnet Name Annotations - Resolve by name +// 3. Frontend Subnet ID Annotation - Direct subnet ID +func (l *loadbalancers) getFrontendVPCCreateOptions(ctx context.Context, service *v1.Service) ([]linodego.NodeBalancerVPCOptions, error) { + frontendIPv4Range, hasIPv4Range := service.GetAnnotations()[annotations.NodeBalancerFrontendIPv4Range] + frontendIPv6Range, hasIPv6Range := service.GetAnnotations()[annotations.NodeBalancerFrontendIPv6Range] + _, hasVPCName := service.GetAnnotations()[annotations.NodeBalancerFrontendVPCName] + _, hasSubnetName := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetName] + _, hasSubnetID := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetID] + + // If no frontend VPC annotations are present, return empty slice + if !hasIPv4Range && !hasIPv6Range && !hasVPCName && !hasSubnetName && !hasSubnetID { + return nil, nil + } + + var subnetID int + var err error + + // Precedence 1: IPv4/IPv6 Range Annotations - Explicit CIDR ranges + if hasIPv4Range || hasIPv6Range { + if err := validateNodeBalancerFrontendIPv4Range(frontendIPv4Range); err != nil { + return nil, err + } + if err := validateNodeBalancerFrontendIPv6Range(frontendIPv6Range); err != nil { + return nil, err + } + if frontendSubnetID, ok := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetID]; ok { + subnetID, err = strconv.Atoi(frontendSubnetID) + if err != nil { + return nil, fmt.Errorf("invalid frontend subnet ID: %w", err) + } + } else { + subnetID, err = l.getFrontendSubnetIDForSVC(ctx, service) + if err != nil { + return nil, err + } + } + + vpcCreateOpts := []linodego.NodeBalancerVPCOptions{ + { + SubnetID: subnetID, + IPv4Range: frontendIPv4Range, + IPv6Range: frontendIPv6Range, + }, + } + return vpcCreateOpts, nil + } + + // Precedence 2: VPC/Subnet Name Annotations - Resolve by name + if hasVPCName || hasSubnetName { + subnetID, err = l.getFrontendSubnetIDForSVC(ctx, service) + if err != nil { + return nil, err + } + + vpcCreateOpts := []linodego.NodeBalancerVPCOptions{ + { + SubnetID: subnetID, + }, + } + return vpcCreateOpts, nil + } + + // Precedence 3: Subnet ID Annotation - Direct subnet ID + if hasSubnetID { + frontendSubnetID := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetID] + subnetID, err = strconv.Atoi(frontendSubnetID) + if err != nil { + return nil, fmt.Errorf("invalid frontend subnet ID: %w", err) + } + + vpcCreateOpts := []linodego.NodeBalancerVPCOptions{ + { + SubnetID: subnetID, + }, + } + return vpcCreateOpts, nil + } + + return nil, nil +} + +// getFrontendSubnetIDForSVC returns the subnet ID for the frontend VPC configuration. +// Following precedence rules are applied: +// 1. If the service has an annotation for FrontendSubnetID, use that. +// 2. If the service has annotations specifying FrontendVPCName or FrontendSubnetName, use them. +// 3. Return error if no VPC configuration is found. +func (l *loadbalancers) getFrontendSubnetIDForSVC(ctx context.Context, service *v1.Service) (int, error) { + // Check if the service has an annotation for FrontendSubnetID + if specifiedSubnetID, ok := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetID]; ok { + subnetID, err := strconv.Atoi(specifiedSubnetID) + if err != nil { + return 0, fmt.Errorf("invalid frontend subnet ID: %w", err) + } + return subnetID, nil + } + + specifiedVPCName, vpcOk := service.GetAnnotations()[annotations.NodeBalancerFrontendVPCName] + specifiedSubnetName, subnetOk := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetName] + + // If no VPCName or SubnetName is specified, return error + if !vpcOk && !subnetOk { + return 0, fmt.Errorf("frontend VPC configuration requires either vpc-name, subnet-name, or subnet-id annotations") + } + + // Require both VPC name and subnet name when using name-based resolution + if !vpcOk { + return 0, fmt.Errorf("frontend VPC configuration with subnet-name requires vpc-name annotation") + } + if !subnetOk { + return 0, fmt.Errorf("frontend VPC configuration with vpc-name requires subnet-name annotation") + } + + vpcID, err := services.GetVPCID(ctx, l.client, specifiedVPCName) + if err != nil { + return 0, fmt.Errorf("failed to get VPC ID for frontend VPC '%s': %w", specifiedVPCName, err) + } + + // Use the VPC ID and Subnet Name to get the subnet ID + return services.GetSubnetID(ctx, l.client, vpcID, specifiedSubnetName) +} + func (l *loadbalancers) createNodeBalancer(ctx context.Context, clusterName string, service *v1.Service, configs []*linodego.NodeBalancerConfigCreateOptions) (lb *linodego.NodeBalancer, err error) { connThrottle := getConnectionThrottle(service) @@ -870,6 +994,13 @@ func (l *loadbalancers) createNodeBalancer(ctx context.Context, clusterName stri } } + // Add frontend VPC configuration + if frontendVPCs, err := l.getFrontendVPCCreateOptions(ctx, service); err != nil { + return nil, err + } else if len(frontendVPCs) > 0 { + createOpts.FrontendVPCs = frontendVPCs + } + // Check for static IPv4 address annotation if ipv4, ok := service.GetAnnotations()[annotations.AnnLinodeLoadBalancerReservedIPv4]; ok { createOpts.IPv4 = &ipv4 @@ -1336,6 +1467,12 @@ func makeLoadBalancerStatus(service *v1.Service, nb *linodego.NodeBalancer) *v1. } } + // Debug info log: Is a frontend VPC NodeBalancer? + isFrontendVPC := nb.FrontendAddressType != nil && *nb.FrontendAddressType == "vpc" + if isFrontendVPC { + klog.V(4).Infof("NodeBalancer (%d) is using frontend VPC address type", nb.ID) + } + // Check for per-service IPv6 annotation first, then fall back to global setting useIPv6 := getServiceBoolAnnotation(service, annotations.AnnLinodeEnableIPv6Ingress) || options.Options.EnableIPv6ForLoadBalancers @@ -1403,6 +1540,32 @@ func validateNodeBalancerBackendIPv4Range(backendIPv4Range string) error { return nil } +// validateNodeBalancerFrontendIPv4Range validates the frontend IPv4 range annotation. +// Performs basic CIDR format validation. +func validateNodeBalancerFrontendIPv4Range(frontendIPv4Range string) error { + if frontendIPv4Range == "" { + return nil + } + _, _, err := net.ParseCIDR(frontendIPv4Range) + if err != nil { + return fmt.Errorf("invalid frontend IPv4 range '%s': %w", frontendIPv4Range, err) + } + return nil +} + +// validateNodeBalancerFrontendIPv6Range validates the frontend IPv6 range annotation. +// Performs basic CIDR format validation. +func validateNodeBalancerFrontendIPv6Range(frontendIPv6Range string) error { + if frontendIPv6Range == "" { + return nil + } + _, _, err := net.ParseCIDR(frontendIPv6Range) + if err != nil { + return fmt.Errorf("invalid frontend IPv6 range '%s': %w", frontendIPv6Range, err) + } + return nil +} + // isCIDRWithinCIDR returns true if the inner CIDR is within the outer CIDR. func isCIDRWithinCIDR(outer, inner string) (bool, error) { _, ipNet1, err := net.ParseCIDR(outer) diff --git a/cloud/linode/loadbalancers_test.go b/cloud/linode/loadbalancers_test.go index fdb09b7c..fb60261a 100644 --- a/cloud/linode/loadbalancers_test.go +++ b/cloud/linode/loadbalancers_test.go @@ -18,6 +18,7 @@ import ( "testing" ciliumclient "github.com/cilium/cilium/pkg/k8s/client/clientset/versioned/typed/cilium.io/v2alpha1" + "github.com/golang/mock/gomock" "github.com/linode/linodego" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" @@ -29,6 +30,7 @@ import ( "github.com/linode/linode-cloud-controller-manager/cloud/annotations" "github.com/linode/linode-cloud-controller-manager/cloud/linode/client" + "github.com/linode/linode-cloud-controller-manager/cloud/linode/client/mocks" "github.com/linode/linode-cloud-controller-manager/cloud/linode/options" "github.com/linode/linode-cloud-controller-manager/cloud/linode/services" ) @@ -5430,3 +5432,267 @@ func Test_validateNodeBalancerBackendIPv4Range(t *testing.T) { }) } } + +func Test_validateNodeBalancerFrontendIPv4Range(t *testing.T) { + type args struct { + frontendIPv4Range string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "Valid IPv4 range", + args: args{frontendIPv4Range: "10.100.5.0/24"}, + wantErr: false, + }, + { + name: "Invalid IPv4 range - no CIDR", + args: args{frontendIPv4Range: "10.100.5.0"}, + wantErr: true, + }, + { + name: "Invalid IPv4 range - malformed", + args: args{frontendIPv4Range: "not-an-ip"}, + wantErr: true, + }, + { + name: "Empty range should pass", + args: args{frontendIPv4Range: ""}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateNodeBalancerFrontendIPv4Range(tt.args.frontendIPv4Range); (err != nil) != tt.wantErr { + t.Errorf("validateNodeBalancerFrontendIPv4Range() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func Test_validateNodeBalancerFrontendIPv6Range(t *testing.T) { + type args struct { + frontendIPv6Range string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "Valid IPv6 range", + args: args{frontendIPv6Range: "2001:db80:1005::/48"}, + wantErr: false, + }, + { + name: "Invalid IPv6 range - no CIDR", + args: args{frontendIPv6Range: "2001:db80:1005::"}, + wantErr: true, + }, + { + name: "Invalid IPv6 range - malformed", + args: args{frontendIPv6Range: "not-an-ipv6"}, + wantErr: true, + }, + { + name: "Empty range should pass", + args: args{frontendIPv6Range: ""}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateNodeBalancerFrontendIPv6Range(tt.args.frontendIPv6Range); (err != nil) != tt.wantErr { + t.Errorf("validateNodeBalancerFrontendIPv6Range() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func Test_makeLoadBalancerStatus_FrontendVPC(t *testing.T) { + type args struct { + service *v1.Service + nb *linodego.NodeBalancer + } + tests := []struct { + name string + args args + want *v1.LoadBalancerStatus + }{ + { + name: "Frontend VPC NodeBalancer with IPv4", + args: args{ + service: &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{}, + }, + }, + nb: &linodego.NodeBalancer{ + ID: 123, + Hostname: &[]string{"nb-123.example.com"}[0], + IPv4: &[]string{"10.100.5.10"}[0], + IPv6: nil, + FrontendAddressType: &[]string{"vpc"}[0], + }, + }, + want: &v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{ + { + Hostname: "nb-123.example.com", + IP: "10.100.5.10", + }, + }, + }, + }, + { + name: "Frontend VPC NodeBalancer with IPv4 and IPv6", + args: args{ + service: &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + annotations.AnnLinodeEnableIPv6Ingress: "true", + }, + }, + }, + nb: &linodego.NodeBalancer{ + ID: 123, + Hostname: &[]string{"nb-123.example.com"}[0], + IPv4: &[]string{"10.100.5.10"}[0], + IPv6: &[]string{"2001:db80:1005::10"}[0], + FrontendAddressType: &[]string{"vpc"}[0], + }, + }, + want: &v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{ + { + Hostname: "nb-123.example.com", + IP: "10.100.5.10", + }, + { + Hostname: "nb-123.example.com", + IP: "2001:db80:1005::10", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := makeLoadBalancerStatus(tt.args.service, tt.args.nb) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("makeLoadBalancerStatus() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_getFrontendVPCCreateOptions(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockClient := mocks.NewMockClient(ctrl) + + type args struct { + ctx context.Context + service *v1.Service + } + tests := []struct { + name string + args args + want []linodego.NodeBalancerVPCOptions + wantErr bool + }{ + { + name: "No frontend VPC annotations", + args: args{ + ctx: context.Background(), + service: &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{}, + }, + }, + }, + want: nil, + wantErr: false, + }, + { + name: "Frontend IPv4 range annotation", + args: args{ + ctx: context.Background(), + service: &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + annotations.NodeBalancerFrontendIPv4Range: "10.100.5.0/24", + annotations.NodeBalancerFrontendSubnetID: "123", + }, + }, + }, + }, + want: []linodego.NodeBalancerVPCOptions{ + { + SubnetID: 123, + IPv4Range: "10.100.5.0/24", + }, + }, + wantErr: false, + }, + { + name: "Frontend IPv6 range annotation", + args: args{ + ctx: context.Background(), + service: &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + annotations.NodeBalancerFrontendIPv6Range: "2001:db80:1005::/48", + annotations.NodeBalancerFrontendSubnetID: "123", + }, + }, + }, + }, + want: []linodego.NodeBalancerVPCOptions{ + { + SubnetID: 123, + IPv6Range: "2001:db80:1005::/48", + }, + }, + wantErr: false, + }, + { + name: "Frontend VPC and subnet names", + args: args{ + ctx: context.Background(), + service: &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + annotations.NodeBalancerFrontendVPCName: "my-vpc", + annotations.NodeBalancerFrontendSubnetName: "frontend-subnet", + }, + }, + }, + }, + want: nil, // Will return error due to missing client setup + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := &loadbalancers{ + client: mockClient, + } + got, err := l.getFrontendVPCCreateOptions(tt.args.ctx, tt.args.service) + if (err != nil) != tt.wantErr { + t.Errorf("getFrontendVPCCreateOptions() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Errorf("getFrontendVPCCreateOptions() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/go.mod b/go.mod index 0b4399c7..e27d8d4c 100644 --- a/go.mod +++ b/go.mod @@ -138,16 +138,16 @@ require ( go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/crypto v0.42.0 // indirect - golang.org/x/mod v0.28.0 // indirect - golang.org/x/net v0.44.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/term v0.35.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/term v0.36.0 // indirect + golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.37.0 // indirect + golang.org/x/tools v0.38.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250721164621-a45f3dfb1074 // indirect google.golang.org/grpc v1.74.2 // indirect @@ -171,6 +171,7 @@ require ( ) replace ( + github.com/linode/linodego => github.com/komer3/linodego v0.0.0-20251201202808-852faf57b6e0 k8s.io/cluster-bootstrap => k8s.io/cluster-bootstrap v0.34.1 k8s.io/cri-api => k8s.io/cri-api v0.34.1 k8s.io/cri-client => k8s.io/cri-client v0.34.1 diff --git a/go.sum b/go.sum index 3308b6cc..8e0cda36 100644 --- a/go.sum +++ b/go.sum @@ -186,6 +186,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/komer3/linodego v0.0.0-20251201202808-852faf57b6e0 h1:mlJ8Z51Aa6xG1cLbH7HjacHdb1raPQlLIJTO5s726dE= +github.com/komer3/linodego v0.0.0-20251201202808-852faf57b6e0/go.mod h1:u+mbth1igHGsd8VasP+8LKHrxuCYsVMHbY3fOYRh/FU= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -194,8 +196,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= -github.com/linode/linodego v1.58.0 h1:yf/tDTf5v74qcxqWWvu3bEDOmDT1ulG9NBDdivp0/oM= -github.com/linode/linodego v1.58.0/go.mod h1:ViH3Tun41yQdknbSyrdHz/iFDXsquLu+YwFdFneEZbY= github.com/mackerelio/go-osstat v0.2.6 h1:gs4U8BZeS1tjrL08tt5VUliVvSWP26Ai2Ob8Lr7f2i0= github.com/mackerelio/go-osstat v0.2.6/go.mod h1:lRy8V9ZuHpuRVZh+vyTkODeDPl3/d5MgXHtLSaqG8bA= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= @@ -376,31 +376,31 @@ golang.org/x/crypto v0.0.0-20190422183909-d864b10871cd/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= -golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= -golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -411,15 +411,15 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= -golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -428,8 +428,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= -golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 92f84c29c66224fd386f2feb79dc82263c73980a Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Wed, 10 Dec 2025 16:35:51 -0600 Subject: [PATCH 2/7] Linodego made changes to the API in the PR: https://github.com/linode/linodego/pull/825/files#diff-b7b79f1ac09ee75185e8a967df69488f9fe9597d89aaa80d50296d9c028ee7d9R125 --- cloud/linode/route_controller.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cloud/linode/route_controller.go b/cloud/linode/route_controller.go index 31fc0b9d..bf785849 100644 --- a/cloud/linode/route_controller.go +++ b/cloud/linode/route_controller.go @@ -245,9 +245,8 @@ func (r *routes) DeleteRoute(ctx context.Context, clusterName string, route *clo func (r *routes) handleInterfaces(ctx context.Context, intfRoutes []string, linodeInterfaceRoutes []linodego.VPCInterfaceIPv4RangeCreateOptions, instance *linodego.Instance, intfVPCIP linodego.VPCIP, route *cloudprovider.Route) error { if instance.InterfaceGeneration == linodego.GenerationLinode { interfaceUpdateOptions := linodego.LinodeInterfaceUpdateOptions{ - VPC: &linodego.VPCInterfaceCreateOptions{ - SubnetID: intfVPCIP.SubnetID, - IPv4: &linodego.VPCInterfaceIPv4CreateOptions{Ranges: linodeInterfaceRoutes}, + VPC: &linodego.VPCInterfaceUpdateOptions{ + IPv4: &linodego.VPCInterfaceIPv4CreateOptions{Ranges: &linodeInterfaceRoutes}, }, } resp, err := r.client.UpdateInterface(ctx, instance.ID, intfVPCIP.InterfaceID, interfaceUpdateOptions) From 7ef297bfbfe87d7907c572c5e591ab3c7d7ace21 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Wed, 10 Dec 2025 17:27:51 -0600 Subject: [PATCH 3/7] fix lint issues --- cloud/linode/loadbalancers.go | 4 ++-- cloud/linode/loadbalancers_test.go | 31 +++++++++++++++--------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/cloud/linode/loadbalancers.go b/cloud/linode/loadbalancers.go index 24a8d5e6..798db4d1 100644 --- a/cloud/linode/loadbalancers.go +++ b/cloud/linode/loadbalancers.go @@ -870,10 +870,10 @@ func (l *loadbalancers) getFrontendVPCCreateOptions(ctx context.Context, service // Precedence 1: IPv4/IPv6 Range Annotations - Explicit CIDR ranges if hasIPv4Range || hasIPv6Range { - if err := validateNodeBalancerFrontendIPv4Range(frontendIPv4Range); err != nil { + if err = validateNodeBalancerFrontendIPv4Range(frontendIPv4Range); err != nil { return nil, err } - if err := validateNodeBalancerFrontendIPv6Range(frontendIPv6Range); err != nil { + if err = validateNodeBalancerFrontendIPv6Range(frontendIPv6Range); err != nil { return nil, err } if frontendSubnetID, ok := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetID]; ok { diff --git a/cloud/linode/loadbalancers_test.go b/cloud/linode/loadbalancers_test.go index fb60261a..23a3b0c5 100644 --- a/cloud/linode/loadbalancers_test.go +++ b/cloud/linode/loadbalancers_test.go @@ -5592,25 +5592,19 @@ func Test_makeLoadBalancerStatus_FrontendVPC(t *testing.T) { } func Test_getFrontendVPCCreateOptions(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockClient(ctrl) - type args struct { - ctx context.Context service *v1.Service } tests := []struct { - name string - args args - want []linodego.NodeBalancerVPCOptions - wantErr bool + name string + args args + want []linodego.NodeBalancerVPCOptions + wantErr bool + prepareMock func(*mocks.MockClient) }{ { name: "No frontend VPC annotations", args: args{ - ctx: context.Background(), service: &v1.Service{ ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{}, @@ -5623,7 +5617,6 @@ func Test_getFrontendVPCCreateOptions(t *testing.T) { { name: "Frontend IPv4 range annotation", args: args{ - ctx: context.Background(), service: &v1.Service{ ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{ @@ -5644,7 +5637,6 @@ func Test_getFrontendVPCCreateOptions(t *testing.T) { { name: "Frontend IPv6 range annotation", args: args{ - ctx: context.Background(), service: &v1.Service{ ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{ @@ -5665,7 +5657,6 @@ func Test_getFrontendVPCCreateOptions(t *testing.T) { { name: "Frontend VPC and subnet names", args: args{ - ctx: context.Background(), service: &v1.Service{ ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{ @@ -5677,15 +5668,25 @@ func Test_getFrontendVPCCreateOptions(t *testing.T) { }, want: nil, // Will return error due to missing client setup wantErr: true, + prepareMock: func(m *mocks.MockClient) { + m.EXPECT().ListVPCs(gomock.Any(), gomock.Any()).Return(nil, stderrors.New("mock error")) + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mocks.NewMockClient(ctrl) + if tt.prepareMock != nil { + tt.prepareMock(mockClient) + } + l := &loadbalancers{ client: mockClient, } - got, err := l.getFrontendVPCCreateOptions(tt.args.ctx, tt.args.service) + got, err := l.getFrontendVPCCreateOptions(context.Background(), tt.args.service) if (err != nil) != tt.wantErr { t.Errorf("getFrontendVPCCreateOptions() error = %v, wantErr %v", err, tt.wantErr) return From aae1a151373e2ca7175ada2834471265be2c7133 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Mon, 15 Dec 2025 15:48:52 -0600 Subject: [PATCH 4/7] Refactor frontend VPC annotation precedence and validation logic - Change annotation precedence order: subnet-id first, then vpc-name/subnet-name, then optional IP ranges - Consolidate IPv4/IPv6 validation into single validateNodeBalancerFrontendIPRange function - Simplify getFrontendVPCCreateOptions to reduce code duplication and improve readability - Make IP range annotations optional when subnet is specified - Add error when IP ranges are provided without subnet selector - Update tests to cover more senarios --- cloud/linode/loadbalancers.go | 104 ++++++------------- cloud/linode/loadbalancers_test.go | 158 ++++++++++++++++++++++------- examples/vpc-frontend-example.yaml | 44 ++++++++ 3 files changed, 192 insertions(+), 114 deletions(-) create mode 100644 examples/vpc-frontend-example.yaml diff --git a/cloud/linode/loadbalancers.go b/cloud/linode/loadbalancers.go index 798db4d1..b52b45aa 100644 --- a/cloud/linode/loadbalancers.go +++ b/cloud/linode/loadbalancers.go @@ -850,9 +850,9 @@ func (l *loadbalancers) getVPCCreateOptions(ctx context.Context, service *v1.Ser // getFrontendVPCCreateOptions returns the VPC options for the NodeBalancer frontend VPC creation. // Order of precedence: -// 1. Frontend IPv4/IPv6 Range Annotations - Explicit CIDR ranges +// 1. Frontend Subnet ID Annotation - Direct subnet ID // 2. Frontend VPC/Subnet Name Annotations - Resolve by name -// 3. Frontend Subnet ID Annotation - Direct subnet ID +// 3. Frontend IPv4/IPv6 Range Annotations - Optional CIDR ranges func (l *loadbalancers) getFrontendVPCCreateOptions(ctx context.Context, service *v1.Service) ([]linodego.NodeBalancerVPCOptions, error) { frontendIPv4Range, hasIPv4Range := service.GetAnnotations()[annotations.NodeBalancerFrontendIPv4Range] frontendIPv6Range, hasIPv6Range := service.GetAnnotations()[annotations.NodeBalancerFrontendIPv6Range] @@ -860,76 +860,45 @@ func (l *loadbalancers) getFrontendVPCCreateOptions(ctx context.Context, service _, hasSubnetName := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetName] _, hasSubnetID := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetID] - // If no frontend VPC annotations are present, return empty slice + // If no frontend VPC annotations are present, do not configure a frontend VPC. if !hasIPv4Range && !hasIPv6Range && !hasVPCName && !hasSubnetName && !hasSubnetID { return nil, nil } - var subnetID int - var err error - - // Precedence 1: IPv4/IPv6 Range Annotations - Explicit CIDR ranges - if hasIPv4Range || hasIPv6Range { - if err = validateNodeBalancerFrontendIPv4Range(frontendIPv4Range); err != nil { - return nil, err - } - if err = validateNodeBalancerFrontendIPv6Range(frontendIPv6Range); err != nil { - return nil, err - } - if frontendSubnetID, ok := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetID]; ok { - subnetID, err = strconv.Atoi(frontendSubnetID) - if err != nil { - return nil, fmt.Errorf("invalid frontend subnet ID: %w", err) - } - } else { - subnetID, err = l.getFrontendSubnetIDForSVC(ctx, service) - if err != nil { - return nil, err - } - } - - vpcCreateOpts := []linodego.NodeBalancerVPCOptions{ - { - SubnetID: subnetID, - IPv4Range: frontendIPv4Range, - IPv6Range: frontendIPv6Range, - }, - } - return vpcCreateOpts, nil + if err := validateNodeBalancerFrontendIPRange(frontendIPv4Range, "IPv4"); err != nil { + return nil, err } - - // Precedence 2: VPC/Subnet Name Annotations - Resolve by name - if hasVPCName || hasSubnetName { - subnetID, err = l.getFrontendSubnetIDForSVC(ctx, service) - if err != nil { - return nil, err - } - - vpcCreateOpts := []linodego.NodeBalancerVPCOptions{ - { - SubnetID: subnetID, - }, - } - return vpcCreateOpts, nil + if err := validateNodeBalancerFrontendIPRange(frontendIPv6Range, "IPv6"); err != nil { + return nil, err } - // Precedence 3: Subnet ID Annotation - Direct subnet ID + var subnetID int + var err error + if hasSubnetID { frontendSubnetID := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetID] subnetID, err = strconv.Atoi(frontendSubnetID) if err != nil { return nil, fmt.Errorf("invalid frontend subnet ID: %w", err) } - - vpcCreateOpts := []linodego.NodeBalancerVPCOptions{ - { - SubnetID: subnetID, - }, + } else if hasVPCName || hasSubnetName { + subnetID, err = l.getFrontendSubnetIDForSVC(ctx, service) + if err != nil { + return nil, err } - return vpcCreateOpts, nil + } else { + // Ranges are optional but still require a subnet to target. + return nil, fmt.Errorf("frontend VPC configuration requires either subnet-id or both vpc-name and subnet-name annotations") } - return nil, nil + vpcCreateOpts := []linodego.NodeBalancerVPCOptions{ + { + SubnetID: subnetID, + IPv4Range: frontendIPv4Range, + IPv6Range: frontendIPv6Range, + }, + } + return vpcCreateOpts, nil } // getFrontendSubnetIDForSVC returns the subnet ID for the frontend VPC configuration. @@ -1540,28 +1509,13 @@ func validateNodeBalancerBackendIPv4Range(backendIPv4Range string) error { return nil } -// validateNodeBalancerFrontendIPv4Range validates the frontend IPv4 range annotation. -// Performs basic CIDR format validation. -func validateNodeBalancerFrontendIPv4Range(frontendIPv4Range string) error { - if frontendIPv4Range == "" { - return nil - } - _, _, err := net.ParseCIDR(frontendIPv4Range) - if err != nil { - return fmt.Errorf("invalid frontend IPv4 range '%s': %w", frontendIPv4Range, err) - } - return nil -} - -// validateNodeBalancerFrontendIPv6Range validates the frontend IPv6 range annotation. -// Performs basic CIDR format validation. -func validateNodeBalancerFrontendIPv6Range(frontendIPv6Range string) error { - if frontendIPv6Range == "" { +func validateNodeBalancerFrontendIPRange(frontendIPRange, ipVersion string) error { + if frontendIPRange == "" { return nil } - _, _, err := net.ParseCIDR(frontendIPv6Range) + _, _, err := net.ParseCIDR(frontendIPRange) if err != nil { - return fmt.Errorf("invalid frontend IPv6 range '%s': %w", frontendIPv6Range, err) + return fmt.Errorf("invalid frontend %s range '%s': %w", ipVersion, frontendIPRange, err) } return nil } diff --git a/cloud/linode/loadbalancers_test.go b/cloud/linode/loadbalancers_test.go index 23a3b0c5..0072d0cf 100644 --- a/cloud/linode/loadbalancers_test.go +++ b/cloud/linode/loadbalancers_test.go @@ -5433,9 +5433,10 @@ func Test_validateNodeBalancerBackendIPv4Range(t *testing.T) { } } -func Test_validateNodeBalancerFrontendIPv4Range(t *testing.T) { +func Test_validateNodeBalancerFrontendIPRange(t *testing.T) { type args struct { - frontendIPv4Range string + frontendIPRange string + ipVersion string } tests := []struct { name string @@ -5444,70 +5445,50 @@ func Test_validateNodeBalancerFrontendIPv4Range(t *testing.T) { }{ { name: "Valid IPv4 range", - args: args{frontendIPv4Range: "10.100.5.0/24"}, + args: args{frontendIPRange: "10.100.5.0/24", ipVersion: "IPv4"}, wantErr: false, }, { name: "Invalid IPv4 range - no CIDR", - args: args{frontendIPv4Range: "10.100.5.0"}, + args: args{frontendIPRange: "10.100.5.0", ipVersion: "IPv4"}, wantErr: true, }, { name: "Invalid IPv4 range - malformed", - args: args{frontendIPv4Range: "not-an-ip"}, + args: args{frontendIPRange: "not-an-ip", ipVersion: "IPv4"}, wantErr: true, }, { - name: "Empty range should pass", - args: args{frontendIPv4Range: ""}, + name: "Empty IPv4 range should pass", + args: args{frontendIPRange: "", ipVersion: "IPv4"}, wantErr: false, }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if err := validateNodeBalancerFrontendIPv4Range(tt.args.frontendIPv4Range); (err != nil) != tt.wantErr { - t.Errorf("validateNodeBalancerFrontendIPv4Range() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func Test_validateNodeBalancerFrontendIPv6Range(t *testing.T) { - type args struct { - frontendIPv6Range string - } - tests := []struct { - name string - args args - wantErr bool - }{ { name: "Valid IPv6 range", - args: args{frontendIPv6Range: "2001:db80:1005::/48"}, + args: args{frontendIPRange: "2001:db80:1005::/48", ipVersion: "IPv6"}, wantErr: false, }, { name: "Invalid IPv6 range - no CIDR", - args: args{frontendIPv6Range: "2001:db80:1005::"}, + args: args{frontendIPRange: "2001:db80:1005::", ipVersion: "IPv6"}, wantErr: true, }, { name: "Invalid IPv6 range - malformed", - args: args{frontendIPv6Range: "not-an-ipv6"}, + args: args{frontendIPRange: "not-an-ipv6", ipVersion: "IPv6"}, wantErr: true, }, { - name: "Empty range should pass", - args: args{frontendIPv6Range: ""}, + name: "Empty IPv6 range should pass", + args: args{frontendIPRange: "", ipVersion: "IPv6"}, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := validateNodeBalancerFrontendIPv6Range(tt.args.frontendIPv6Range); (err != nil) != tt.wantErr { - t.Errorf("validateNodeBalancerFrontendIPv6Range() error = %v, wantErr %v", err, tt.wantErr) + if err := validateNodeBalancerFrontendIPRange(tt.args.frontendIPRange, tt.args.ipVersion); (err != nil) != tt.wantErr { + t.Errorf("validateNodeBalancerFrontendIPRange() error = %v, wantErr %v", err, tt.wantErr) } }) } @@ -5615,7 +5596,25 @@ func Test_getFrontendVPCCreateOptions(t *testing.T) { wantErr: false, }, { - name: "Frontend IPv4 range annotation", + name: "Frontend subnet id only", + args: args{ + service: &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + annotations.NodeBalancerFrontendSubnetID: "123", + }, + }, + }, + }, + want: []linodego.NodeBalancerVPCOptions{ + { + SubnetID: 123, + }, + }, + wantErr: false, + }, + { + name: "Frontend IPv4 range + subnet id", args: args{ service: &v1.Service{ ObjectMeta: metav1.ObjectMeta{ @@ -5635,7 +5634,7 @@ func Test_getFrontendVPCCreateOptions(t *testing.T) { wantErr: false, }, { - name: "Frontend IPv6 range annotation", + name: "Frontend IPv6 range + subnet id", args: args{ service: &v1.Service{ ObjectMeta: metav1.ObjectMeta{ @@ -5654,6 +5653,62 @@ func Test_getFrontendVPCCreateOptions(t *testing.T) { }, wantErr: false, }, + { + name: "Frontend ranges without subnet selector should error", + args: args{ + service: &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + annotations.NodeBalancerFrontendIPv4Range: "10.100.5.0/24", + }, + }, + }, + }, + want: nil, + wantErr: true, + }, + { + name: "Frontend vpc-name only should error", + args: args{ + service: &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + annotations.NodeBalancerFrontendVPCName: "my-vpc", + }, + }, + }, + }, + want: nil, + wantErr: true, + }, + { + name: "Frontend subnet-name only should error", + args: args{ + service: &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + annotations.NodeBalancerFrontendSubnetName: "frontend-subnet", + }, + }, + }, + }, + want: nil, + wantErr: true, + }, + { + name: "Frontend invalid subnet-id should error", + args: args{ + service: &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + annotations.NodeBalancerFrontendSubnetID: "abc", + }, + }, + }, + }, + want: nil, + wantErr: true, + }, { name: "Frontend VPC and subnet names", args: args{ @@ -5666,11 +5721,36 @@ func Test_getFrontendVPCCreateOptions(t *testing.T) { }, }, }, - want: nil, // Will return error due to missing client setup - wantErr: true, + want: []linodego.NodeBalancerVPCOptions{ + { + SubnetID: 456, + }, + }, + wantErr: false, prepareMock: func(m *mocks.MockClient) { - m.EXPECT().ListVPCs(gomock.Any(), gomock.Any()).Return(nil, stderrors.New("mock error")) + m.EXPECT().ListVPCs(gomock.Any(), gomock.Any()).Return([]linodego.VPC{{ID: 111, Label: "my-vpc"}}, nil) + m.EXPECT().ListVPCSubnets(gomock.Any(), 111, gomock.Any()).Return([]linodego.VPCSubnet{{ID: 456, Label: "frontend-subnet"}}, nil) + }, + }, + { + name: "Frontend subnet-id should take precedence over names", + args: args{ + service: &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + annotations.NodeBalancerFrontendSubnetID: "123", + annotations.NodeBalancerFrontendVPCName: "my-vpc", + annotations.NodeBalancerFrontendSubnetName: "frontend-subnet", + }, + }, + }, + }, + want: []linodego.NodeBalancerVPCOptions{ + { + SubnetID: 123, + }, }, + wantErr: false, }, } diff --git a/examples/vpc-frontend-example.yaml b/examples/vpc-frontend-example.yaml new file mode 100644 index 00000000..f1032509 --- /dev/null +++ b/examples/vpc-frontend-example.yaml @@ -0,0 +1,44 @@ +apiVersion: v1 +kind: Service +metadata: + name: svc-frontend-vpc-test + annotations: + # Specify the Subnet ID for the NodeBalancer Frontend + service.beta.kubernetes.io/linode-loadbalancer-frontend-subnet-id: "169341" + service.beta.kubernetes.io/linode-loadbalancer-nodebalancer-type: "premium" + # Optional: Specify an IPv4/IPv6 range within the subnet + # service.beta.kubernetes.io/linode-loadbalancer-frontend-ipv4-range: "10.0.0.0/24" + # service.beta.kubernetes.io/linode-loadbalancer-frontend-ipv6-range: "2001:db8::/64" + labels: + app: frontend-vpc-test +spec: + type: LoadBalancer + selector: + app: frontend-vpc-test + ports: + - name: http + protocol: TCP + port: 80 + targetPort: 80 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend-vpc-test + labels: + app: frontend-vpc-test +spec: + replicas: 1 + selector: + matchLabels: + app: frontend-vpc-test + template: + metadata: + labels: + app: frontend-vpc-test + spec: + containers: + - name: nginx + image: nginx:latest + ports: + - containerPort: 80 From 6c9322d961da1a42ae5d11504278e1f5a5165537 Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Mon, 15 Dec 2025 15:56:23 -0600 Subject: [PATCH 5/7] Add documentation for frontend VPC configuration annotations - Document new frontend VPC annotations: frontend-subnet-id, frontend-vpc-name, frontend-subnet-name, frontend-ipv4-range, frontend-ipv6-range - Document backend-subnet-id annotation - Add detailed frontend VPC configuration section explaining annotation precedence and usage - Update backend VPC annotation examples to use correct backend-vpc-name and backend-subnet-name prefixes - Add reference to vpc-frontend-example.yaml in examples --- docs/configuration/annotations.md | 22 +++++++++++++++--- docs/configuration/loadbalancer.md | 37 ++++++++++++++++++++++++++++-- docs/examples/README.md | 3 +++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/docs/configuration/annotations.md b/docs/configuration/annotations.md index a497032b..2d81ad21 100644 --- a/docs/configuration/annotations.md +++ b/docs/configuration/annotations.md @@ -43,6 +43,12 @@ The keys and the values in [annotations must be strings](https://kubernetes.io/d | `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) | +| `backend-subnet-id` | string | | Subnet ID within VPC which is connected to the NodeBalancer backend. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) | +| `frontend-subnet-id` | string | | Subnet ID for the NodeBalancer frontend VPC configuration. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) | +| `frontend-vpc-name` | string | | Frontend VPC name for the NodeBalancer frontend VPC configuration. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) | +| `frontend-subnet-name` | string | | Frontend subnet name for the NodeBalancer frontend VPC configuration. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) | +| `frontend-ipv4-range` | string | | Optional IPv4 CIDR range from the frontend subnet. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) | +| `frontend-ipv6-range` | string | | Optional IPv6 CIDR range from the frontend subnet. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) | | `reserved-ipv4` | string | | An existing Reserved IPv4 address that wil be used to initialize the NodeBalancer instance. See [LoadBalancer Configuration](loadbalancer.md#reserved-ipv4-addresses)) | ### Port Specific Configuration @@ -135,8 +141,18 @@ metadata: metadata: annotations: service.beta.kubernetes.io/linode-loadbalancer-backend-ipv4-range: "10.100.0.0/30" - service.beta.kubernetes.io/linode-loadbalancer-vpc-name: "vpc1" - service.beta.kubernetes.io/linode-loadbalancer-subnet-name: "subnet1" + service.beta.kubernetes.io/linode-loadbalancer-backend-vpc-name: "vpc1" + service.beta.kubernetes.io/linode-loadbalancer-backend-subnet-name: "subnet1" +``` + +Frontend VPC configuration: +```yaml +metadata: + annotations: + service.beta.kubernetes.io/linode-loadbalancer-frontend-subnet-id: "169341" + # Optional: + # service.beta.kubernetes.io/linode-loadbalancer-frontend-ipv4-range: "10.0.0.0/24" + # service.beta.kubernetes.io/linode-loadbalancer-frontend-ipv6-range: "2001:db8::/64" ``` ### Service with IPv6 Address @@ -151,7 +167,7 @@ For more examples and detailed configuration options, see: - [Firewall Configuration](firewall.md) - [Basic Service Examples](../examples/basic.md) - [Advanced Configuration Examples](../examples/advanced.md) -- [Complete Stack Example](../examples/complete-stack.md) +- [Examples](../examples/README.md) See also: - [Environment Variables](environment.md) diff --git a/docs/configuration/loadbalancer.md b/docs/configuration/loadbalancer.md index 0cce5f7a..742d3e37 100644 --- a/docs/configuration/loadbalancer.md +++ b/docs/configuration/loadbalancer.md @@ -194,10 +194,43 @@ By default, CCM uses first VPC and Subnet name configured with it to attach Node metadata: annotations: service.beta.kubernetes.io/linode-loadbalancer-backend-ipv4-range: "10.100.0.4/30" - service.beta.kubernetes.io/linode-loadbalancer-vpc-name: "vpc1" - service.beta.kubernetes.io/linode-loadbalancer-subnet-name: "subnet1" + service.beta.kubernetes.io/linode-loadbalancer-backend-vpc-name: "vpc1" + service.beta.kubernetes.io/linode-loadbalancer-backend-subnet-name: "subnet1" ``` +### Configuring NodeBalancer frontend with VPC + +NodeBalancers can optionally be configured with a VPC-based frontend address. + +Frontend VPC configuration supports the following annotations: + +1. Choose the frontend subnet: + + - `service.beta.kubernetes.io/linode-loadbalancer-frontend-subnet-id` (preferred) + - OR `service.beta.kubernetes.io/linode-loadbalancer-frontend-vpc-name` and `service.beta.kubernetes.io/linode-loadbalancer-frontend-subnet-name` + +2. Optionally constrain the frontend address assignment within the subnet: + + - `service.beta.kubernetes.io/linode-loadbalancer-frontend-ipv4-range` (CIDR) + - `service.beta.kubernetes.io/linode-loadbalancer-frontend-ipv6-range` (CIDR) + +Order of precedence: +- If `frontend-subnet-id` is set, it is used. +- Otherwise, `frontend-vpc-name` + `frontend-subnet-name` are used. +- IPv4/IPv6 range annotations are optional add-ons and require one of the subnet selectors above. + +Example: +```yaml +metadata: + annotations: + service.beta.kubernetes.io/linode-loadbalancer-frontend-subnet-id: "169341" + # Optional: + # service.beta.kubernetes.io/linode-loadbalancer-frontend-ipv4-range: "10.0.0.0/24" + # service.beta.kubernetes.io/linode-loadbalancer-frontend-ipv6-range: "2001:db8::/64" +``` + +For a complete working example, see `examples/vpc-frontend-example.yaml`. + If CCM is started with `--nodebalancer-backend-ipv4-subnet` flag, then it will not allow provisioning of nodebalancer unless subnet specified in service annotation lie within the subnet specified using the flag. This is to prevent accidental overlap between nodebalancer backend ips and pod CIDRs. ## Advanced Configuration diff --git a/docs/examples/README.md b/docs/examples/README.md index 156ebb70..235891da 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -16,6 +16,9 @@ This section provides working examples of common CCM configurations. Each exampl - Shared IP Load-Balancing - Custom Node Selection +3. **Frontend VPC NodeBalancer** + - `examples/vpc-frontend-example.yaml` + Note: To test UDP based NBs, one can use [test-server](https://github.com/rahulait/test-server) repo to run server using UDP protocol and then use the client commands in repo's readme to connect to the server. For testing these examples, see the [test script](https://github.com/linode/linode-cloud-controller-manager/blob/master/examples/test.sh). From e47503c2d201acdcfc5d3a3d9f664128b36a284a Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Mon, 15 Dec 2025 16:23:22 -0600 Subject: [PATCH 6/7] Refactor frontend VPC logic to use switch statement and simplify conditional --- cloud/linode/loadbalancers.go | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/cloud/linode/loadbalancers.go b/cloud/linode/loadbalancers.go index b52b45aa..d8d7ad1c 100644 --- a/cloud/linode/loadbalancers.go +++ b/cloud/linode/loadbalancers.go @@ -858,7 +858,7 @@ func (l *loadbalancers) getFrontendVPCCreateOptions(ctx context.Context, service frontendIPv6Range, hasIPv6Range := service.GetAnnotations()[annotations.NodeBalancerFrontendIPv6Range] _, hasVPCName := service.GetAnnotations()[annotations.NodeBalancerFrontendVPCName] _, hasSubnetName := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetName] - _, hasSubnetID := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetID] + frontendSubnetID, hasSubnetID := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetID] // If no frontend VPC annotations are present, do not configure a frontend VPC. if !hasIPv4Range && !hasIPv6Range && !hasVPCName && !hasSubnetName && !hasSubnetID { @@ -875,18 +875,18 @@ func (l *loadbalancers) getFrontendVPCCreateOptions(ctx context.Context, service var subnetID int var err error - if hasSubnetID { - frontendSubnetID := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetID] + switch { + case hasSubnetID: subnetID, err = strconv.Atoi(frontendSubnetID) if err != nil { return nil, fmt.Errorf("invalid frontend subnet ID: %w", err) } - } else if hasVPCName || hasSubnetName { + case hasVPCName || hasSubnetName: subnetID, err = l.getFrontendSubnetIDForSVC(ctx, service) if err != nil { return nil, err } - } else { + default: // Ranges are optional but still require a subnet to target. return nil, fmt.Errorf("frontend VPC configuration requires either subnet-id or both vpc-name and subnet-name annotations") } @@ -919,17 +919,9 @@ func (l *loadbalancers) getFrontendSubnetIDForSVC(ctx context.Context, service * specifiedVPCName, vpcOk := service.GetAnnotations()[annotations.NodeBalancerFrontendVPCName] specifiedSubnetName, subnetOk := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetName] - // If no VPCName or SubnetName is specified, return error - if !vpcOk && !subnetOk { - return 0, fmt.Errorf("frontend VPC configuration requires either vpc-name, subnet-name, or subnet-id annotations") - } - - // Require both VPC name and subnet name when using name-based resolution - if !vpcOk { - return 0, fmt.Errorf("frontend VPC configuration with subnet-name requires vpc-name annotation") - } - if !subnetOk { - return 0, fmt.Errorf("frontend VPC configuration with vpc-name requires subnet-name annotation") + // If no VPCName or SubnetName is specified and no subnet-id is provided, return error + if !vpcOk || !subnetOk { + return 0, fmt.Errorf("frontend VPC configuration requires either subnet-id annotation or both vpc-name and subnet-name annotations") } vpcID, err := services.GetVPCID(ctx, l.client, specifiedVPCName) @@ -1436,9 +1428,7 @@ func makeLoadBalancerStatus(service *v1.Service, nb *linodego.NodeBalancer) *v1. } } - // Debug info log: Is a frontend VPC NodeBalancer? - isFrontendVPC := nb.FrontendAddressType != nil && *nb.FrontendAddressType == "vpc" - if isFrontendVPC { + if nb.FrontendAddressType != nil && *nb.FrontendAddressType == "vpc" { klog.V(4).Infof("NodeBalancer (%d) is using frontend VPC address type", nb.ID) } From c0cf59d890050b0cc2931ab6915b315b5cf5954a Mon Sep 17 00:00:00 2001 From: Khaja Omer Date: Tue, 16 Dec 2025 11:53:52 -0600 Subject: [PATCH 7/7] Refactor frontend VPC subnet lookup to require both vpc-name and subnet-name annotations. Change annotation check from OR to AND logic for vpc-name and subnet-name --- cloud/linode/loadbalancers.go | 39 ++++++++++------------------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/cloud/linode/loadbalancers.go b/cloud/linode/loadbalancers.go index d8d7ad1c..8482d26d 100644 --- a/cloud/linode/loadbalancers.go +++ b/cloud/linode/loadbalancers.go @@ -856,8 +856,8 @@ func (l *loadbalancers) getVPCCreateOptions(ctx context.Context, service *v1.Ser func (l *loadbalancers) getFrontendVPCCreateOptions(ctx context.Context, service *v1.Service) ([]linodego.NodeBalancerVPCOptions, error) { frontendIPv4Range, hasIPv4Range := service.GetAnnotations()[annotations.NodeBalancerFrontendIPv4Range] frontendIPv6Range, hasIPv6Range := service.GetAnnotations()[annotations.NodeBalancerFrontendIPv6Range] - _, hasVPCName := service.GetAnnotations()[annotations.NodeBalancerFrontendVPCName] - _, hasSubnetName := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetName] + vpcName, hasVPCName := service.GetAnnotations()[annotations.NodeBalancerFrontendVPCName] + subnetName, hasSubnetName := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetName] frontendSubnetID, hasSubnetID := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetID] // If no frontend VPC annotations are present, do not configure a frontend VPC. @@ -881,8 +881,8 @@ func (l *loadbalancers) getFrontendVPCCreateOptions(ctx context.Context, service if err != nil { return nil, fmt.Errorf("invalid frontend subnet ID: %w", err) } - case hasVPCName || hasSubnetName: - subnetID, err = l.getFrontendSubnetIDForSVC(ctx, service) + case hasVPCName && hasSubnetName: + subnetID, err = l.getSubnetIDByVPCAndSubnetNames(ctx, vpcName, subnetName) if err != nil { return nil, err } @@ -901,36 +901,19 @@ func (l *loadbalancers) getFrontendVPCCreateOptions(ctx context.Context, service return vpcCreateOpts, nil } -// getFrontendSubnetIDForSVC returns the subnet ID for the frontend VPC configuration. -// Following precedence rules are applied: -// 1. If the service has an annotation for FrontendSubnetID, use that. -// 2. If the service has annotations specifying FrontendVPCName or FrontendSubnetName, use them. -// 3. Return error if no VPC configuration is found. -func (l *loadbalancers) getFrontendSubnetIDForSVC(ctx context.Context, service *v1.Service) (int, error) { - // Check if the service has an annotation for FrontendSubnetID - if specifiedSubnetID, ok := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetID]; ok { - subnetID, err := strconv.Atoi(specifiedSubnetID) - if err != nil { - return 0, fmt.Errorf("invalid frontend subnet ID: %w", err) - } - return subnetID, nil +// getSubnetIDByVPCAndSubnetNames returns the subnet ID for the given VPC name and subnet name. +func (l *loadbalancers) getSubnetIDByVPCAndSubnetNames(ctx context.Context, vpcName, subnetName string) (int, error) { + if vpcName == "" || subnetName == "" { + return 0, fmt.Errorf("frontend VPC configuration requires either subnet-id annotation or both vpc-name and subnet-name annotations. No vpc-name or subnet-name annotation found") } - specifiedVPCName, vpcOk := service.GetAnnotations()[annotations.NodeBalancerFrontendVPCName] - specifiedSubnetName, subnetOk := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetName] - - // If no VPCName or SubnetName is specified and no subnet-id is provided, return error - if !vpcOk || !subnetOk { - return 0, fmt.Errorf("frontend VPC configuration requires either subnet-id annotation or both vpc-name and subnet-name annotations") - } - - vpcID, err := services.GetVPCID(ctx, l.client, specifiedVPCName) + vpcID, err := services.GetVPCID(ctx, l.client, vpcName) if err != nil { - return 0, fmt.Errorf("failed to get VPC ID for frontend VPC '%s': %w", specifiedVPCName, err) + return 0, fmt.Errorf("failed to get VPC ID for frontend VPC '%s': %w", vpcName, err) } // Use the VPC ID and Subnet Name to get the subnet ID - return services.GetSubnetID(ctx, l.client, vpcID, specifiedSubnetName) + return services.GetSubnetID(ctx, l.client, vpcID, subnetName) } func (l *loadbalancers) createNodeBalancer(ctx context.Context, clusterName string, service *v1.Service, configs []*linodego.NodeBalancerConfigCreateOptions) (lb *linodego.NodeBalancer, err error) {