diff --git a/docs/guide/service/annotations.md b/docs/guide/service/annotations.md index 11dd6289cd..86fa436820 100644 --- a/docs/guide/service/annotations.md +++ b/docs/guide/service/annotations.md @@ -174,6 +174,21 @@ on the load balancer. service.beta.kubernetes.io/aws-load-balancer-eip-allocations: eipalloc-xyz, eipalloc-zzz ``` +- `service.beta.kubernetes.io/aws-load-balancer-eip-tags` specifies a set of AWS tags to dynamically select unassociated Elastic IPs for an internet-facing NLB. + + !!!note + - This configuration is optional, and you can use it to assign static IP addresses to your NLB by tag selection. + - You must specify tags in the format `key1=value1,key2=value2`. + - The controller will select unassociated EIPs matching all tags and assign them to the NLB subnets. + - NLB must be internet-facing. + - You must tag your EIPs in AWS before using this annotation. + - The number of matching, unassociated EIPs must be at least the number of subnets. + + !!!example + ``` + service.beta.kubernetes.io/aws-load-balancer-eip-tags: k8s-cluster=my-prod-cluster,purpose=ingress-nlb + ``` + - `service.beta.kubernetes.io/aws-load-balancer-private-ipv4-addresses` specifies a list of private IPv4 addresses for an internal NLB. diff --git a/pkg/annotations/constants.go b/pkg/annotations/constants.go index 0c9483086c..597c43c5fc 100644 --- a/pkg/annotations/constants.go +++ b/pkg/annotations/constants.go @@ -105,6 +105,7 @@ const ( SvcLBSuffixTargetGroupAttributes = "aws-load-balancer-target-group-attributes" SvcLBSuffixSubnets = "aws-load-balancer-subnets" SvcLBSuffixEIPAllocations = "aws-load-balancer-eip-allocations" + SvcLBSuffixEIPTags = "aws-load-balancer-eip-tags" SvcLBSuffixPrivateIpv4Addresses = "aws-load-balancer-private-ipv4-addresses" SvcLBSuffixIpv6Addresses = "aws-load-balancer-ipv6-addresses" SvcLBSuffixALPNPolicy = "aws-load-balancer-alpn-policy" diff --git a/pkg/aws/services/ec2.go b/pkg/aws/services/ec2.go index 7084bc1459..0dd1211b72 100644 --- a/pkg/aws/services/ec2.go +++ b/pkg/aws/services/ec2.go @@ -37,6 +37,9 @@ type EC2 interface { DescribeAvailabilityZonesWithContext(ctx context.Context, input *ec2.DescribeAvailabilityZonesInput) (*ec2.DescribeAvailabilityZonesOutput, error) DescribeVpcsWithContext(ctx context.Context, input *ec2.DescribeVpcsInput) (*ec2.DescribeVpcsOutput, error) DescribeInstancesWithContext(ctx context.Context, input *ec2.DescribeInstancesInput) (*ec2.DescribeInstancesOutput, error) + + // DescribeAddressesAsList wraps the DescribeAddresses API, aggregates results into a list. + DescribeAddressesAsList(ctx context.Context, input *ec2.DescribeAddressesInput) ([]types.Address, error) } // NewEC2 constructs new EC2 implementation. @@ -50,6 +53,18 @@ type ec2Client struct { awsClientsProvider provider.AWSClientsProvider } +func (c *ec2Client) DescribeAddressesAsList(ctx context.Context, input *ec2.DescribeAddressesInput) ([]types.Address, error) { + client, err := c.awsClientsProvider.GetEC2Client(ctx, "DescribeAddresses") + if err != nil { + return nil, err + } + output, err := client.DescribeAddresses(ctx, input) + if err != nil { + return nil, err + } + return output.Addresses, nil +} + func (c *ec2Client) DescribeInstancesWithContext(ctx context.Context, input *ec2.DescribeInstancesInput) (*ec2.DescribeInstancesOutput, error) { client, err := c.awsClientsProvider.GetEC2Client(ctx, "DescribeInstances") if err != nil { diff --git a/pkg/service/model_build_load_balancer.go b/pkg/service/model_build_load_balancer.go index 52a7172f02..f7f52ecb9c 100644 --- a/pkg/service/model_build_load_balancer.go +++ b/pkg/service/model_build_load_balancer.go @@ -10,6 +10,8 @@ import ( "sort" "strconv" + "github.com/aws/aws-sdk-go-v2/service/ec2" + awssdk "github.com/aws/aws-sdk-go-v2/aws" ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/pkg/errors" @@ -335,6 +337,8 @@ func (t *defaultModelBuildTask) buildLoadBalancerTags(ctx context.Context) (map[ func (t *defaultModelBuildTask) buildLoadBalancerSubnetMappings(_ context.Context, ipAddressType elbv2model.IPAddressType, scheme elbv2model.LoadBalancerScheme, ec2Subnets []ec2types.Subnet, enablePrefixForIpv6SourceNat elbv2model.EnablePrefixForIpv6SourceNat) ([]elbv2model.SubnetMapping, error) { var eipAllocation []string eipConfigured := t.annotationParser.ParseStringSliceAnnotation(annotations.SvcLBSuffixEIPAllocations, &eipAllocation, t.service.Annotations) + var eipTags string + eipTagsConfigured := t.annotationParser.ParseStringAnnotation(annotations.SvcLBSuffixEIPTags, &eipTags, t.service.Annotations) if eipConfigured { if scheme != elbv2model.LoadBalancerSchemeInternetFacing { return nil, errors.Errorf("EIP allocations can only be set for internet facing load balancers") @@ -342,6 +346,51 @@ func (t *defaultModelBuildTask) buildLoadBalancerSubnetMappings(_ context.Contex if len(eipAllocation) != len(ec2Subnets) { return nil, errors.Errorf("count of EIP allocations (%d) and subnets (%d) must match", len(eipAllocation), len(ec2Subnets)) } + } else if eipTagsConfigured { + if scheme != elbv2model.LoadBalancerSchemeInternetFacing { + return nil, errors.Errorf("EIP tags can only be set for internet facing load balancers") + } + // Parse tags string into map + tagMap := make(map[string]string) + for _, kv := range regexp.MustCompile(`,`).Split(eipTags, -1) { + parts := regexp.MustCompile(`=`).Split(kv, 2) + if len(parts) == 2 { + tagMap[parts[0]] = parts[1] + } + } + // Build EC2 filter for DescribeAddresses + var filters []ec2types.Filter + for k, v := range tagMap { + filters = append(filters, ec2types.Filter{ + Name: awssdk.String(fmt.Sprintf("tag:%s", k)), + Values: []string{v}, + }) + } + filters = append(filters, ec2types.Filter{ + Name: awssdk.String("domain"), + Values: []string{"vpc"}, + }) + addresses, err := t.ec2Client.DescribeAddressesAsList(context.Background(), &ec2.DescribeAddressesInput{ + Filters: filters, + }) + if err != nil { + return nil, errors.Wrap(err, "failed to describe EIPs by tags") + } + // Filter unassociated EIPs client-side + var unassociatedAddresses []ec2types.Address + for _, addr := range addresses { + if addr.AssociationId == nil || awssdk.ToString(addr.AssociationId) == "" { + unassociatedAddresses = append(unassociatedAddresses, addr) + } + } + if len(unassociatedAddresses) < len(ec2Subnets) { + return nil, errors.Errorf("not enough unassociated EIPs with specified tags; needed %d, found %d", len(ec2Subnets), len(unassociatedAddresses)) + } + eipAllocation = make([]string, len(ec2Subnets)) + for i := range ec2Subnets { + eipAllocation[i] = awssdk.ToString(unassociatedAddresses[i].AllocationId) + } + eipConfigured = true } var ipv4Addresses []netip.Addr