From 07b0ae8737f2d760aff0a349d61c7c746a382a55 Mon Sep 17 00:00:00 2001 From: Bart Riepe Date: Mon, 27 Oct 2025 11:47:26 +0900 Subject: [PATCH 1/3] fix(elbv2): prevent duplicate listener creation on same port/protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AWS CreateListener API sometimes silently returns an existing listener instead of raising a DuplicateListener error when attempting to create a listener with the same port and protocol combination on a load balancer. This causes Terraform/Pulumi to incorrectly treat the operation as successful, leading to state inconsistencies where multiple listener resources point to the same ARN. This change adds defensive validation before listener creation by: - Querying existing listeners on the load balancer - Checking for port/protocol conflicts - Returning an explicit error if a duplicate is detected - Properly handling protocol defaulting for ALBs This follows the same defensive pattern used in load_balancer.go and target_group.go for preventing duplicate resources. Fixes #35121 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- internal/service/elbv2/listener.go | 38 ++++++++++++ internal/service/elbv2/listener_test.go | 80 +++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/internal/service/elbv2/listener.go b/internal/service/elbv2/listener.go index d8e20f9233a3..c3e72ba34444 100644 --- a/internal/service/elbv2/listener.go +++ b/internal/service/elbv2/listener.go @@ -570,6 +570,44 @@ func resourceListenerCreate(ctx context.Context, d *schema.ResourceData, meta an conn := meta.(*conns.AWSClient).ELBV2Client(ctx) lbARN := d.Get("load_balancer_arn").(string) + + // Check for duplicate listener on same load balancer with same port/protocol + if v, ok := d.GetOk(names.AttrPort); ok { + port := int32(v.(int)) + + // Determine protocol (using same logic as creation) + var protocol awstypes.ProtocolEnum + if v, ok := d.GetOk(names.AttrProtocol); ok { + protocol = awstypes.ProtocolEnum(v.(string)) + } else if strings.Contains(lbARN, "loadbalancer/app/") { + // Default to HTTP for Application Load Balancers if no protocol specified + if _, ok := d.GetOk(names.AttrCertificateARN); ok { + protocol = awstypes.ProtocolEnumHttps + } else { + protocol = awstypes.ProtocolEnumHttp + } + } + + // Only check for duplicates if we have a protocol to check + if protocol != "" { + // Query existing listeners on this load balancer + describeInput := &elasticloadbalancingv2.DescribeListenersInput{ + LoadBalancerArn: aws.String(lbARN), + } + existingListener, err := findListener(ctx, conn, describeInput, func(listener *awstypes.Listener) bool { + return aws.ToInt32(listener.Port) == port && listener.Protocol == protocol + }) + + if err != nil && !tfresource.NotFound(err) { + return sdkdiag.AppendErrorf(diags, "reading ELBv2 Listeners for Load Balancer (%s): %s", lbARN, err) + } + + if existingListener != nil { + return sdkdiag.AppendErrorf(diags, "ELBv2 Listener on port %d with protocol %s already exists on Load Balancer (%s)", port, protocol, lbARN) + } + } + } + input := &elasticloadbalancingv2.CreateListenerInput{ LoadBalancerArn: aws.String(lbARN), Tags: getTagsIn(ctx), diff --git a/internal/service/elbv2/listener_test.go b/internal/service/elbv2/listener_test.go index 475c5c2c1150..46575430a0c9 100644 --- a/internal/service/elbv2/listener_test.go +++ b/internal/service/elbv2/listener_test.go @@ -289,6 +289,24 @@ func TestAccELBV2Listener_disappears(t *testing.T) { }) } +func TestAccELBV2Listener_duplicate(t *testing.T) { + ctx := acctest.Context(t) + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.ELBV2ServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckListenerDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccListenerConfig_duplicate(rName), + ExpectError: regexache.MustCompile(`already exists`), + }, + }, + }) +} + func TestAccELBV2Listener_Forward_update(t *testing.T) { ctx := acctest.Context(t) var conf awstypes.Listener @@ -4835,3 +4853,65 @@ resource "aws_lb_target_group" "test" { } `, rName)) } + +func testAccListenerConfig_duplicate(rName string) string { + return acctest.ConfigCompose(testAccListenerConfig_base(rName), fmt.Sprintf(` +resource "aws_lb_listener" "test" { + load_balancer_arn = aws_lb.test.id + protocol = "HTTP" + port = "80" + + default_action { + target_group_arn = aws_lb_target_group.test.arn + type = "forward" + } +} + +resource "aws_lb_listener" "duplicate" { + load_balancer_arn = aws_lb.test.id + protocol = "HTTP" + port = "80" + + default_action { + target_group_arn = aws_lb_target_group.test.arn + type = "forward" + } +} + +resource "aws_lb" "test" { + name = %[1]q + internal = true + security_groups = [aws_security_group.test.id] + subnets = aws_subnet.test[*].id + + idle_timeout = 30 + enable_deletion_protection = false + + tags = { + Name = %[1]q + } +} + +resource "aws_lb_target_group" "test" { + name = %[1]q + port = 8080 + protocol = "HTTP" + vpc_id = aws_vpc.test.id + + health_check { + path = "/health" + interval = 60 + port = 8081 + protocol = "HTTP" + timeout = 3 + healthy_threshold = 3 + unhealthy_threshold = 3 + matcher = "200-299" + } + + tags = { + Name = %[1]q + } +} +`, rName)) +} From f36a809e7edbb600ba7d7186f34faed57d32372b Mon Sep 17 00:00:00 2001 From: Bart Riepe Date: Mon, 27 Oct 2025 12:04:21 +0900 Subject: [PATCH 2/3] chore: add changelog entry for issue #35121 --- .changelog/35121.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changelog/35121.txt diff --git a/.changelog/35121.txt b/.changelog/35121.txt new file mode 100644 index 000000000000..2bdb24cc8efd --- /dev/null +++ b/.changelog/35121.txt @@ -0,0 +1,3 @@ +```release-note:bug +resource/aws_lb_listener: Prevent duplicate listener creation by checking for existing listeners with same port and protocol before creation +``` From 9e1525b0eee0bf2e5f7d9e1a91064849c9152cb6 Mon Sep 17 00:00:00 2001 From: Bart Riepe Date: Mon, 27 Oct 2025 12:19:47 +0900 Subject: [PATCH 3/3] test(elbv2): add comprehensive duplicate listener test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 4 high-priority test cases to ensure duplicate listener detection works correctly across different scenarios: 1. TestAccELBV2Listener_duplicate_ALB_protocolDefaulting_HTTP - Tests duplicate detection when ALB protocol defaults to HTTP (no explicit protocol, no certificate) 2. TestAccELBV2Listener_duplicate_ALB_protocolDefaulting_HTTPS - Tests duplicate detection when ALB protocol defaults to HTTPS (no explicit protocol, with certificate) 3. TestAccELBV2Listener_duplicate_NLB_TCP - Tests duplicate detection for Network Load Balancers with TCP protocol 4. TestAccELBV2Listener_duplicate_NLB_TLS - Tests duplicate detection for NLB with TLS protocol and certificate These tests validate that the defensive duplicate check correctly handles: - Protocol defaulting logic for ALBs - Different load balancer types (ALB and NLB) - Listeners with and without certificates 🤖 Generated with Claude Code Co-Authored-By: Claude --- internal/service/elbv2/listener_test.go | 328 ++++++++++++++++++++++++ 1 file changed, 328 insertions(+) diff --git a/internal/service/elbv2/listener_test.go b/internal/service/elbv2/listener_test.go index 46575430a0c9..512c76f06c8c 100644 --- a/internal/service/elbv2/listener_test.go +++ b/internal/service/elbv2/listener_test.go @@ -307,6 +307,82 @@ func TestAccELBV2Listener_duplicate(t *testing.T) { }) } +func TestAccELBV2Listener_duplicate_ALB_protocolDefaulting_HTTP(t *testing.T) { + ctx := acctest.Context(t) + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.ELBV2ServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckListenerDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccListenerConfig_duplicate_ALB_protocolDefaulting_HTTP(rName), + ExpectError: regexache.MustCompile(`already exists`), + }, + }, + }) +} + +func TestAccELBV2Listener_duplicate_ALB_protocolDefaulting_HTTPS(t *testing.T) { + ctx := acctest.Context(t) + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + key := acctest.TLSRSAPrivateKeyPEM(t, 2048) + certificate := acctest.TLSRSAX509SelfSignedCertificatePEM(t, key, "example.com") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.ELBV2ServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckListenerDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccListenerConfig_duplicate_ALB_protocolDefaulting_HTTPS(rName, key, certificate), + ExpectError: regexache.MustCompile(`already exists`), + }, + }, + }) +} + +func TestAccELBV2Listener_duplicate_NLB_TCP(t *testing.T) { + ctx := acctest.Context(t) + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.ELBV2ServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckListenerDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccListenerConfig_duplicate_NLB_TCP(rName), + ExpectError: regexache.MustCompile(`already exists`), + }, + }, + }) +} + +func TestAccELBV2Listener_duplicate_NLB_TLS(t *testing.T) { + ctx := acctest.Context(t) + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + key := acctest.TLSRSAPrivateKeyPEM(t, 2048) + certificate := acctest.TLSRSAX509SelfSignedCertificatePEM(t, key, "example.com") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.ELBV2ServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckListenerDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccListenerConfig_duplicate_NLB_TLS(rName, key, certificate), + ExpectError: regexache.MustCompile(`already exists`), + }, + }, + }) +} + func TestAccELBV2Listener_Forward_update(t *testing.T) { ctx := acctest.Context(t) var conf awstypes.Listener @@ -4915,3 +4991,255 @@ resource "aws_lb_target_group" "test" { } `, rName)) } + +func testAccListenerConfig_duplicate_ALB_protocolDefaulting_HTTP(rName string) string { + return acctest.ConfigCompose(testAccListenerConfig_base(rName), fmt.Sprintf(` +resource "aws_lb_listener" "test" { + load_balancer_arn = aws_lb.test.id + port = "80" + + default_action { + target_group_arn = aws_lb_target_group.test.arn + type = "forward" + } +} + +resource "aws_lb_listener" "duplicate" { + load_balancer_arn = aws_lb.test.id + port = "80" + + default_action { + target_group_arn = aws_lb_target_group.test.arn + type = "forward" + } +} + +resource "aws_lb" "test" { + name = %[1]q + internal = true + security_groups = [aws_security_group.test.id] + subnets = aws_subnet.test[*].id + + idle_timeout = 30 + enable_deletion_protection = false + + tags = { + Name = %[1]q + } +} + +resource "aws_lb_target_group" "test" { + name = %[1]q + port = 8080 + protocol = "HTTP" + vpc_id = aws_vpc.test.id + + health_check { + path = "/health" + interval = 60 + port = 8081 + protocol = "HTTP" + timeout = 3 + healthy_threshold = 3 + unhealthy_threshold = 3 + matcher = "200-299" + } + + tags = { + Name = %[1]q + } +} +`, rName)) +} + +func testAccListenerConfig_duplicate_ALB_protocolDefaulting_HTTPS(rName, key, certificate string) string { + return acctest.ConfigCompose(testAccListenerConfig_base(rName), fmt.Sprintf(` +resource "aws_iam_server_certificate" "test" { + name = %[1]q + certificate_body = "%[2]s" + private_key = "%[3]s" +} + +resource "aws_lb_listener" "test" { + load_balancer_arn = aws_lb.test.id + port = "443" + certificate_arn = aws_iam_server_certificate.test.arn + + default_action { + target_group_arn = aws_lb_target_group.test.arn + type = "forward" + } +} + +resource "aws_lb_listener" "duplicate" { + load_balancer_arn = aws_lb.test.id + port = "443" + certificate_arn = aws_iam_server_certificate.test.arn + + default_action { + target_group_arn = aws_lb_target_group.test.arn + type = "forward" + } +} + +resource "aws_lb" "test" { + name = %[1]q + internal = true + security_groups = [aws_security_group.test.id] + subnets = aws_subnet.test[*].id + + idle_timeout = 30 + enable_deletion_protection = false + + tags = { + Name = %[1]q + } +} + +resource "aws_lb_target_group" "test" { + name = %[1]q + port = 8080 + protocol = "HTTP" + vpc_id = aws_vpc.test.id + + health_check { + path = "/health" + interval = 60 + port = 8081 + protocol = "HTTP" + timeout = 3 + healthy_threshold = 3 + unhealthy_threshold = 3 + matcher = "200-299" + } + + tags = { + Name = %[1]q + } +} +`, rName, acctest.TLSPEMEscapeNewlines(certificate), acctest.TLSPEMEscapeNewlines(key))) +} + +func testAccListenerConfig_duplicate_NLB_TCP(rName string) string { + return acctest.ConfigCompose(testAccListenerConfig_base(rName), fmt.Sprintf(` +resource "aws_lb_listener" "test" { + load_balancer_arn = aws_lb.test.id + protocol = "TCP" + port = "80" + + default_action { + target_group_arn = aws_lb_target_group.test.arn + type = "forward" + } +} + +resource "aws_lb_listener" "duplicate" { + load_balancer_arn = aws_lb.test.id + protocol = "TCP" + port = "80" + + default_action { + target_group_arn = aws_lb_target_group.test.arn + type = "forward" + } +} + +resource "aws_lb" "test" { + name = %[1]q + internal = true + load_balancer_type = "network" + subnets = aws_subnet.test[*].id + + enable_deletion_protection = false + + tags = { + Name = %[1]q + } +} + +resource "aws_lb_target_group" "test" { + name = %[1]q + port = 8080 + protocol = "TCP" + vpc_id = aws_vpc.test.id + + health_check { + protocol = "TCP" + interval = 30 + healthy_threshold = 3 + unhealthy_threshold = 3 + } + + tags = { + Name = %[1]q + } +} +`, rName)) +} + +func testAccListenerConfig_duplicate_NLB_TLS(rName, key, certificate string) string { + return acctest.ConfigCompose(testAccListenerConfig_base(rName), fmt.Sprintf(` +resource "aws_iam_server_certificate" "test" { + name = %[1]q + certificate_body = "%[2]s" + private_key = "%[3]s" +} + +resource "aws_lb_listener" "test" { + load_balancer_arn = aws_lb.test.id + protocol = "TLS" + port = "443" + certificate_arn = aws_iam_server_certificate.test.arn + ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" + + default_action { + target_group_arn = aws_lb_target_group.test.arn + type = "forward" + } +} + +resource "aws_lb_listener" "duplicate" { + load_balancer_arn = aws_lb.test.id + protocol = "TLS" + port = "443" + certificate_arn = aws_iam_server_certificate.test.arn + ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" + + default_action { + target_group_arn = aws_lb_target_group.test.arn + type = "forward" + } +} + +resource "aws_lb" "test" { + name = %[1]q + internal = true + load_balancer_type = "network" + subnets = aws_subnet.test[*].id + + enable_deletion_protection = false + + tags = { + Name = %[1]q + } +} + +resource "aws_lb_target_group" "test" { + name = %[1]q + port = 8080 + protocol = "TCP" + vpc_id = aws_vpc.test.id + + health_check { + protocol = "TCP" + interval = 30 + healthy_threshold = 3 + unhealthy_threshold = 3 + } + + tags = { + Name = %[1]q + } +} +`, rName, acctest.TLSPEMEscapeNewlines(certificate), acctest.TLSPEMEscapeNewlines(key))) +}