Skip to content

Commit 0bf0d35

Browse files
committed
[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
1 parent 8ac8b75 commit 0bf0d35

File tree

5 files changed

+465
-29
lines changed

5 files changed

+465
-29
lines changed

cloud/annotations/annotations.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,10 @@ const (
5353
NodeBalancerBackendVPCName = "service.beta.kubernetes.io/linode-loadbalancer-backend-vpc-name"
5454
NodeBalancerBackendSubnetName = "service.beta.kubernetes.io/linode-loadbalancer-backend-subnet-name"
5555
NodeBalancerBackendSubnetID = "service.beta.kubernetes.io/linode-loadbalancer-backend-subnet-id"
56+
57+
NodeBalancerFrontendIPv4Range = "service.beta.kubernetes.io/linode-loadbalancer-frontend-ipv4-range"
58+
NodeBalancerFrontendIPv6Range = "service.beta.kubernetes.io/linode-loadbalancer-frontend-ipv6-range"
59+
NodeBalancerFrontendVPCName = "service.beta.kubernetes.io/linode-loadbalancer-frontend-vpc-name"
60+
NodeBalancerFrontendSubnetName = "service.beta.kubernetes.io/linode-loadbalancer-frontend-subnet-name"
61+
NodeBalancerFrontendSubnetID = "service.beta.kubernetes.io/linode-loadbalancer-frontend-subnet-id"
5662
)

cloud/linode/loadbalancers.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,130 @@ func (l *loadbalancers) getVPCCreateOptions(ctx context.Context, service *v1.Ser
848848
return vpcCreateOpts, nil
849849
}
850850

851+
// getFrontendVPCCreateOptions returns the VPC options for the NodeBalancer frontend VPC creation.
852+
// Order of precedence:
853+
// 1. Frontend IPv4/IPv6 Range Annotations - Explicit CIDR ranges
854+
// 2. Frontend VPC/Subnet Name Annotations - Resolve by name
855+
// 3. Frontend Subnet ID Annotation - Direct subnet ID
856+
func (l *loadbalancers) getFrontendVPCCreateOptions(ctx context.Context, service *v1.Service) ([]linodego.NodeBalancerVPCOptions, error) {
857+
frontendIPv4Range, hasIPv4Range := service.GetAnnotations()[annotations.NodeBalancerFrontendIPv4Range]
858+
frontendIPv6Range, hasIPv6Range := service.GetAnnotations()[annotations.NodeBalancerFrontendIPv6Range]
859+
_, hasVPCName := service.GetAnnotations()[annotations.NodeBalancerFrontendVPCName]
860+
_, hasSubnetName := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetName]
861+
_, hasSubnetID := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetID]
862+
863+
// If no frontend VPC annotations are present, return empty slice
864+
if !hasIPv4Range && !hasIPv6Range && !hasVPCName && !hasSubnetName && !hasSubnetID {
865+
return nil, nil
866+
}
867+
868+
var subnetID int
869+
var err error
870+
871+
// Precedence 1: IPv4/IPv6 Range Annotations - Explicit CIDR ranges
872+
if hasIPv4Range || hasIPv6Range {
873+
if err := validateNodeBalancerFrontendIPv4Range(frontendIPv4Range); err != nil {
874+
return nil, err
875+
}
876+
if err := validateNodeBalancerFrontendIPv6Range(frontendIPv6Range); err != nil {
877+
return nil, err
878+
}
879+
if frontendSubnetID, ok := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetID]; ok {
880+
subnetID, err = strconv.Atoi(frontendSubnetID)
881+
if err != nil {
882+
return nil, fmt.Errorf("invalid frontend subnet ID: %w", err)
883+
}
884+
} else {
885+
subnetID, err = l.getFrontendSubnetIDForSVC(ctx, service)
886+
if err != nil {
887+
return nil, err
888+
}
889+
}
890+
891+
vpcCreateOpts := []linodego.NodeBalancerVPCOptions{
892+
{
893+
SubnetID: subnetID,
894+
IPv4Range: frontendIPv4Range,
895+
IPv6Range: frontendIPv6Range,
896+
},
897+
}
898+
return vpcCreateOpts, nil
899+
}
900+
901+
// Precedence 2: VPC/Subnet Name Annotations - Resolve by name
902+
if hasVPCName || hasSubnetName {
903+
subnetID, err = l.getFrontendSubnetIDForSVC(ctx, service)
904+
if err != nil {
905+
return nil, err
906+
}
907+
908+
vpcCreateOpts := []linodego.NodeBalancerVPCOptions{
909+
{
910+
SubnetID: subnetID,
911+
},
912+
}
913+
return vpcCreateOpts, nil
914+
}
915+
916+
// Precedence 3: Subnet ID Annotation - Direct subnet ID
917+
if hasSubnetID {
918+
frontendSubnetID := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetID]
919+
subnetID, err = strconv.Atoi(frontendSubnetID)
920+
if err != nil {
921+
return nil, fmt.Errorf("invalid frontend subnet ID: %w", err)
922+
}
923+
924+
vpcCreateOpts := []linodego.NodeBalancerVPCOptions{
925+
{
926+
SubnetID: subnetID,
927+
},
928+
}
929+
return vpcCreateOpts, nil
930+
}
931+
932+
return nil, nil
933+
}
934+
935+
// getFrontendSubnetIDForSVC returns the subnet ID for the frontend VPC configuration.
936+
// Following precedence rules are applied:
937+
// 1. If the service has an annotation for FrontendSubnetID, use that.
938+
// 2. If the service has annotations specifying FrontendVPCName or FrontendSubnetName, use them.
939+
// 3. Return error if no VPC configuration is found.
940+
func (l *loadbalancers) getFrontendSubnetIDForSVC(ctx context.Context, service *v1.Service) (int, error) {
941+
// Check if the service has an annotation for FrontendSubnetID
942+
if specifiedSubnetID, ok := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetID]; ok {
943+
subnetID, err := strconv.Atoi(specifiedSubnetID)
944+
if err != nil {
945+
return 0, fmt.Errorf("invalid frontend subnet ID: %w", err)
946+
}
947+
return subnetID, nil
948+
}
949+
950+
specifiedVPCName, vpcOk := service.GetAnnotations()[annotations.NodeBalancerFrontendVPCName]
951+
specifiedSubnetName, subnetOk := service.GetAnnotations()[annotations.NodeBalancerFrontendSubnetName]
952+
953+
// If no VPCName or SubnetName is specified, return error
954+
if !vpcOk && !subnetOk {
955+
return 0, fmt.Errorf("frontend VPC configuration requires either vpc-name, subnet-name, or subnet-id annotations")
956+
}
957+
958+
// Require both VPC name and subnet name when using name-based resolution
959+
if !vpcOk {
960+
return 0, fmt.Errorf("frontend VPC configuration with subnet-name requires vpc-name annotation")
961+
}
962+
if !subnetOk {
963+
return 0, fmt.Errorf("frontend VPC configuration with vpc-name requires subnet-name annotation")
964+
}
965+
966+
vpcID, err := services.GetVPCID(ctx, l.client, specifiedVPCName)
967+
if err != nil {
968+
return 0, fmt.Errorf("failed to get VPC ID for frontend VPC '%s': %w", specifiedVPCName, err)
969+
}
970+
971+
// Use the VPC ID and Subnet Name to get the subnet ID
972+
return services.GetSubnetID(ctx, l.client, vpcID, specifiedSubnetName)
973+
}
974+
851975
func (l *loadbalancers) createNodeBalancer(ctx context.Context, clusterName string, service *v1.Service, configs []*linodego.NodeBalancerConfigCreateOptions) (lb *linodego.NodeBalancer, err error) {
852976
connThrottle := getConnectionThrottle(service)
853977

@@ -870,6 +994,13 @@ func (l *loadbalancers) createNodeBalancer(ctx context.Context, clusterName stri
870994
}
871995
}
872996

997+
// Add frontend VPC configuration
998+
if frontendVPCs, err := l.getFrontendVPCCreateOptions(ctx, service); err != nil {
999+
return nil, err
1000+
} else if len(frontendVPCs) > 0 {
1001+
createOpts.FrontendVPCs = frontendVPCs
1002+
}
1003+
8731004
// Check for static IPv4 address annotation
8741005
if ipv4, ok := service.GetAnnotations()[annotations.AnnLinodeLoadBalancerReservedIPv4]; ok {
8751006
createOpts.IPv4 = &ipv4
@@ -1336,6 +1467,12 @@ func makeLoadBalancerStatus(service *v1.Service, nb *linodego.NodeBalancer) *v1.
13361467
}
13371468
}
13381469

1470+
// Debug info log: Is a frontend VPC NodeBalancer?
1471+
isFrontendVPC := nb.FrontendAddressType != nil && *nb.FrontendAddressType == "vpc"
1472+
if isFrontendVPC {
1473+
klog.V(4).Infof("NodeBalancer (%d) is using frontend VPC address type", nb.ID)
1474+
}
1475+
13391476
// Check for per-service IPv6 annotation first, then fall back to global setting
13401477
useIPv6 := getServiceBoolAnnotation(service, annotations.AnnLinodeEnableIPv6Ingress) || options.Options.EnableIPv6ForLoadBalancers
13411478

@@ -1403,6 +1540,32 @@ func validateNodeBalancerBackendIPv4Range(backendIPv4Range string) error {
14031540
return nil
14041541
}
14051542

1543+
// validateNodeBalancerFrontendIPv4Range validates the frontend IPv4 range annotation.
1544+
// Performs basic CIDR format validation.
1545+
func validateNodeBalancerFrontendIPv4Range(frontendIPv4Range string) error {
1546+
if frontendIPv4Range == "" {
1547+
return nil
1548+
}
1549+
_, _, err := net.ParseCIDR(frontendIPv4Range)
1550+
if err != nil {
1551+
return fmt.Errorf("invalid frontend IPv4 range '%s': %w", frontendIPv4Range, err)
1552+
}
1553+
return nil
1554+
}
1555+
1556+
// validateNodeBalancerFrontendIPv6Range validates the frontend IPv6 range annotation.
1557+
// Performs basic CIDR format validation.
1558+
func validateNodeBalancerFrontendIPv6Range(frontendIPv6Range string) error {
1559+
if frontendIPv6Range == "" {
1560+
return nil
1561+
}
1562+
_, _, err := net.ParseCIDR(frontendIPv6Range)
1563+
if err != nil {
1564+
return fmt.Errorf("invalid frontend IPv6 range '%s': %w", frontendIPv6Range, err)
1565+
}
1566+
return nil
1567+
}
1568+
14061569
// isCIDRWithinCIDR returns true if the inner CIDR is within the outer CIDR.
14071570
func isCIDRWithinCIDR(outer, inner string) (bool, error) {
14081571
_, ipNet1, err := net.ParseCIDR(outer)

0 commit comments

Comments
 (0)