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