diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md
index 2eb0389b4..f3850d005 100644
--- a/docs/COMMANDS.md
+++ b/docs/COMMANDS.md
@@ -589,6 +589,12 @@ All ELBv2 resources are stored in the `spinifex-elbv2` JetStream KV bucket. Key
| `delete-listener` | `--listener-arn` | `--dry-run` | Listener must exist | Gateway validates ListenerArn (required) → NATS `elbv2.DeleteListener` → daemon verifies listener exists and belongs to account → deletes from KV → fetches parent LB → calls `pushConfig(lb)` to regenerate HAProxy config without this listener → returns success | 1. Delete listener
2. Not found (ListenerNotFound) | **DONE** |
| `describe-listeners` | `--load-balancer-arn`, `--listener-arns` | `--page-size`, `--marker`, `--dry-run` | None | NATS `elbv2.DescribeListeners` → daemon lists listeners → if LoadBalancerArn specified, filters to that LB → applies ARN filter → account isolation → returns Listener list | 1. Filter by LB ARN
2. Account isolation | **DONE** |
+#### ELBv2 - Tags
+
+| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
+|---------|-------------------|---------------|---------------|-------------|------------|--------|
+| `describe-tags` | `--resource-arns` (loadbalancer, targetgroup, listener) | — | Resource(s) must exist | Gateway validates non-empty ResourceArns → NATS `elbv2.DescribeTags` → daemon parses each ARN's resource segment to dispatch by type → looks up record (LB/TG/listener) → enforces account isolation (cross-account → not-found) → returns TagDescriptions in input order with sorted-by-key Tag list. Listener records don't store tags yet, so they always return an empty Tags slice (matches AWS behaviour for untagged resources). Required by Terraform AWS provider post-create refresh on `aws_lb`, `aws_lb_target_group`, and `aws_lb_listener`. | 1. LB tags round-trip
2. TG tags round-trip
3. Listener returns empty Tags (no error)
4. Multiple ARNs in one call
5. Unknown LB/TG/listener (per-type not-found)
6. Cross-account ARN (not-found, no leak)
7. Invalid/non-ELBv2 ARN (InvalidParameterValue)
8. Empty/nil ResourceArns (MissingParameter)
9. Untagged LB returns empty Tags | **DONE** |
+
#### ELBv2 - Not Implemented
| Feature | Description | Priority | Status |
diff --git a/spinifex/daemon/daemon.go b/spinifex/daemon/daemon.go
index 51158c0ac..c3c1c021b 100644
--- a/spinifex/daemon/daemon.go
+++ b/spinifex/daemon/daemon.go
@@ -491,8 +491,13 @@ func (d *Daemon) subscribeAll() error {
{"elbv2.CreateListener", d.handleELBv2CreateListener, "spinifex-workers"},
{"elbv2.DeleteListener", d.handleELBv2DeleteListener, "spinifex-workers"},
{"elbv2.DescribeListeners", d.handleELBv2DescribeListeners, "spinifex-workers"},
+ {"elbv2.DescribeTags", d.handleELBv2DescribeTags, "spinifex-workers"},
{"elbv2.LBAgentHeartbeat", d.handleELBv2LBAgentHeartbeat, "spinifex-workers"},
{"elbv2.GetLBConfig", d.handleELBv2GetLBConfig, "spinifex-workers"},
+ {"elbv2.ModifyTargetGroupAttributes", d.handleELBv2ModifyTargetGroupAttributes, "spinifex-workers"},
+ {"elbv2.DescribeTargetGroupAttributes", d.handleELBv2DescribeTargetGroupAttributes, "spinifex-workers"},
+ {"elbv2.ModifyLoadBalancerAttributes", d.handleELBv2ModifyLoadBalancerAttributes, "spinifex-workers"},
+ {"elbv2.DescribeLoadBalancerAttributes", d.handleELBv2DescribeLoadBalancerAttributes, "spinifex-workers"},
{fmt.Sprintf("spinifex.admin.%s.health", d.node), d.handleHealthCheck, ""},
{"spinifex.nodes.discover", d.handleNodeDiscover, ""},
{"spinifex.node.status", d.handleNodeStatus, ""},
diff --git a/spinifex/daemon/daemon_handlers_elbv2.go b/spinifex/daemon/daemon_handlers_elbv2.go
index 6ca13b5fc..991b10a87 100644
--- a/spinifex/daemon/daemon_handlers_elbv2.go
+++ b/spinifex/daemon/daemon_handlers_elbv2.go
@@ -50,6 +50,10 @@ func (d *Daemon) handleELBv2DescribeListeners(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DescribeListeners)
}
+func (d *Daemon) handleELBv2DescribeTags(msg *nats.Msg) {
+ handleNATSRequest(msg, d.elbv2Service.DescribeTags)
+}
+
func (d *Daemon) handleELBv2LBAgentHeartbeat(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.LBAgentHeartbeat)
}
@@ -57,3 +61,19 @@ func (d *Daemon) handleELBv2LBAgentHeartbeat(msg *nats.Msg) {
func (d *Daemon) handleELBv2GetLBConfig(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.GetLBConfig)
}
+
+func (d *Daemon) handleELBv2ModifyTargetGroupAttributes(msg *nats.Msg) {
+ handleNATSRequest(msg, d.elbv2Service.ModifyTargetGroupAttributes)
+}
+
+func (d *Daemon) handleELBv2DescribeTargetGroupAttributes(msg *nats.Msg) {
+ handleNATSRequest(msg, d.elbv2Service.DescribeTargetGroupAttributes)
+}
+
+func (d *Daemon) handleELBv2ModifyLoadBalancerAttributes(msg *nats.Msg) {
+ handleNATSRequest(msg, d.elbv2Service.ModifyLoadBalancerAttributes)
+}
+
+func (d *Daemon) handleELBv2DescribeLoadBalancerAttributes(msg *nats.Msg) {
+ handleNATSRequest(msg, d.elbv2Service.DescribeLoadBalancerAttributes)
+}
diff --git a/spinifex/gateway/elbv2.go b/spinifex/gateway/elbv2.go
index d9de58b5b..091a08e2e 100644
--- a/spinifex/gateway/elbv2.go
+++ b/spinifex/gateway/elbv2.go
@@ -76,12 +76,27 @@ var elbv2Actions = map[string]ELBv2Handler{
"DescribeListeners": elbv2Handler(func(input *elbv2.DescribeListenersInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_elbv2.DescribeListeners(input, gw.NATSConn, accountID)
}),
+ "DescribeTags": elbv2Handler(func(input *elbv2.DescribeTagsInput, gw *GatewayConfig, accountID string) (any, error) {
+ return gateway_elbv2.DescribeTags(input, gw.NATSConn, accountID)
+ }),
"LBAgentHeartbeat": elbv2Handler(func(input *handlers_elbv2.LBAgentHeartbeatInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_elbv2.LBAgentHeartbeat(input, gw.NATSConn, accountID)
}),
"GetLBConfig": elbv2Handler(func(input *handlers_elbv2.GetLBConfigInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_elbv2.GetLBConfig(input, gw.NATSConn, accountID)
}),
+ "ModifyTargetGroupAttributes": elbv2Handler(func(input *elbv2.ModifyTargetGroupAttributesInput, gw *GatewayConfig, accountID string) (any, error) {
+ return gateway_elbv2.ModifyTargetGroupAttributes(input, gw.NATSConn, accountID)
+ }),
+ "DescribeTargetGroupAttributes": elbv2Handler(func(input *elbv2.DescribeTargetGroupAttributesInput, gw *GatewayConfig, accountID string) (any, error) {
+ return gateway_elbv2.DescribeTargetGroupAttributes(input, gw.NATSConn, accountID)
+ }),
+ "ModifyLoadBalancerAttributes": elbv2Handler(func(input *elbv2.ModifyLoadBalancerAttributesInput, gw *GatewayConfig, accountID string) (any, error) {
+ return gateway_elbv2.ModifyLoadBalancerAttributes(input, gw.NATSConn, accountID)
+ }),
+ "DescribeLoadBalancerAttributes": elbv2Handler(func(input *elbv2.DescribeLoadBalancerAttributesInput, gw *GatewayConfig, accountID string) (any, error) {
+ return gateway_elbv2.DescribeLoadBalancerAttributes(input, gw.NATSConn, accountID)
+ }),
}
func (gw *GatewayConfig) ELBv2_Request(w http.ResponseWriter, r *http.Request) error {
diff --git a/spinifex/gateway/elbv2/DescribeLoadBalancerAttributes.go b/spinifex/gateway/elbv2/DescribeLoadBalancerAttributes.go
new file mode 100644
index 000000000..61add9f37
--- /dev/null
+++ b/spinifex/gateway/elbv2/DescribeLoadBalancerAttributes.go
@@ -0,0 +1,30 @@
+package gateway_elbv2
+
+import (
+ "errors"
+
+ "github.com/aws/aws-sdk-go/service/elbv2"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
+ "github.com/nats-io/nats.go"
+)
+
+// DescribeLoadBalancerAttributes handles the ELBv2 DescribeLoadBalancerAttributes API call.
+func DescribeLoadBalancerAttributes(input *elbv2.DescribeLoadBalancerAttributesInput, natsConn *nats.Conn, accountID string) (elbv2.DescribeLoadBalancerAttributesOutput, error) {
+ var output elbv2.DescribeLoadBalancerAttributesOutput
+
+ if input == nil {
+ return output, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ if input.LoadBalancerArn == nil || *input.LoadBalancerArn == "" {
+ return output, errors.New(awserrors.ErrorMissingParameter)
+ }
+
+ svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
+ result, err := svc.DescribeLoadBalancerAttributes(input, accountID)
+ if err != nil {
+ return output, err
+ }
+
+ return *result, nil
+}
diff --git a/spinifex/gateway/elbv2/DescribeTags.go b/spinifex/gateway/elbv2/DescribeTags.go
new file mode 100644
index 000000000..693c23d18
--- /dev/null
+++ b/spinifex/gateway/elbv2/DescribeTags.go
@@ -0,0 +1,31 @@
+package gateway_elbv2
+
+import (
+ "errors"
+
+ "github.com/aws/aws-sdk-go/service/elbv2"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
+ "github.com/nats-io/nats.go"
+)
+
+// DescribeTags handles the ELBv2 DescribeTags API call. Returns the tags for
+// each ELBv2 resource (load balancer, target group, listener) named in the
+// input ResourceArns. Untagged resources still produce a TagDescription with
+// an empty Tags slice — this matches AWS behaviour and is what the Terraform
+// AWS provider expects during post-create refresh.
+func DescribeTags(input *elbv2.DescribeTagsInput, natsConn *nats.Conn, accountID string) (elbv2.DescribeTagsOutput, error) {
+ var output elbv2.DescribeTagsOutput
+
+ if input == nil || len(input.ResourceArns) == 0 {
+ return output, errors.New(awserrors.ErrorMissingParameter)
+ }
+
+ svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
+ result, err := svc.DescribeTags(input, accountID)
+ if err != nil {
+ return output, err
+ }
+
+ return *result, nil
+}
diff --git a/spinifex/gateway/elbv2/DescribeTargetGroupAttributes.go b/spinifex/gateway/elbv2/DescribeTargetGroupAttributes.go
new file mode 100644
index 000000000..541e95f8d
--- /dev/null
+++ b/spinifex/gateway/elbv2/DescribeTargetGroupAttributes.go
@@ -0,0 +1,30 @@
+package gateway_elbv2
+
+import (
+ "errors"
+
+ "github.com/aws/aws-sdk-go/service/elbv2"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
+ "github.com/nats-io/nats.go"
+)
+
+// DescribeTargetGroupAttributes handles the ELBv2 DescribeTargetGroupAttributes API call.
+func DescribeTargetGroupAttributes(input *elbv2.DescribeTargetGroupAttributesInput, natsConn *nats.Conn, accountID string) (elbv2.DescribeTargetGroupAttributesOutput, error) {
+ var output elbv2.DescribeTargetGroupAttributesOutput
+
+ if input == nil {
+ return output, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ if input.TargetGroupArn == nil || *input.TargetGroupArn == "" {
+ return output, errors.New(awserrors.ErrorMissingParameter)
+ }
+
+ svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
+ result, err := svc.DescribeTargetGroupAttributes(input, accountID)
+ if err != nil {
+ return output, err
+ }
+
+ return *result, nil
+}
diff --git a/spinifex/gateway/elbv2/ModifyLoadBalancerAttributes.go b/spinifex/gateway/elbv2/ModifyLoadBalancerAttributes.go
new file mode 100644
index 000000000..6a806de5a
--- /dev/null
+++ b/spinifex/gateway/elbv2/ModifyLoadBalancerAttributes.go
@@ -0,0 +1,33 @@
+package gateway_elbv2
+
+import (
+ "errors"
+
+ "github.com/aws/aws-sdk-go/service/elbv2"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
+ "github.com/nats-io/nats.go"
+)
+
+// ModifyLoadBalancerAttributes handles the ELBv2 ModifyLoadBalancerAttributes API call.
+func ModifyLoadBalancerAttributes(input *elbv2.ModifyLoadBalancerAttributesInput, natsConn *nats.Conn, accountID string) (elbv2.ModifyLoadBalancerAttributesOutput, error) {
+ var output elbv2.ModifyLoadBalancerAttributesOutput
+
+ if input == nil {
+ return output, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ if input.LoadBalancerArn == nil || *input.LoadBalancerArn == "" {
+ return output, errors.New(awserrors.ErrorMissingParameter)
+ }
+ if len(input.Attributes) == 0 {
+ return output, errors.New(awserrors.ErrorMissingParameter)
+ }
+
+ svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
+ result, err := svc.ModifyLoadBalancerAttributes(input, accountID)
+ if err != nil {
+ return output, err
+ }
+
+ return *result, nil
+}
diff --git a/spinifex/gateway/elbv2/ModifyTargetGroupAttributes.go b/spinifex/gateway/elbv2/ModifyTargetGroupAttributes.go
new file mode 100644
index 000000000..1e0845d0f
--- /dev/null
+++ b/spinifex/gateway/elbv2/ModifyTargetGroupAttributes.go
@@ -0,0 +1,33 @@
+package gateway_elbv2
+
+import (
+ "errors"
+
+ "github.com/aws/aws-sdk-go/service/elbv2"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
+ "github.com/nats-io/nats.go"
+)
+
+// ModifyTargetGroupAttributes handles the ELBv2 ModifyTargetGroupAttributes API call.
+func ModifyTargetGroupAttributes(input *elbv2.ModifyTargetGroupAttributesInput, natsConn *nats.Conn, accountID string) (elbv2.ModifyTargetGroupAttributesOutput, error) {
+ var output elbv2.ModifyTargetGroupAttributesOutput
+
+ if input == nil {
+ return output, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ if input.TargetGroupArn == nil || *input.TargetGroupArn == "" {
+ return output, errors.New(awserrors.ErrorMissingParameter)
+ }
+ if len(input.Attributes) == 0 {
+ return output, errors.New(awserrors.ErrorMissingParameter)
+ }
+
+ svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
+ result, err := svc.ModifyTargetGroupAttributes(input, accountID)
+ if err != nil {
+ return output, err
+ }
+
+ return *result, nil
+}
diff --git a/spinifex/gateway/elbv2/gateway_elbv2_test.go b/spinifex/gateway/elbv2/gateway_elbv2_test.go
index 99a70920b..796802c77 100644
--- a/spinifex/gateway/elbv2/gateway_elbv2_test.go
+++ b/spinifex/gateway/elbv2/gateway_elbv2_test.go
@@ -144,3 +144,85 @@ func TestDescribeListeners_NilInput(t *testing.T) {
_, err := DescribeListeners(nil, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
}
+
+func TestModifyTargetGroupAttributes_NilInput(t *testing.T) {
+ _, err := ModifyTargetGroupAttributes(nil, nil, "123456789012")
+ assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
+}
+
+func TestModifyTargetGroupAttributes_MissingArn(t *testing.T) {
+ _, err := ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{}, nil, "123456789012")
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+}
+
+func TestModifyTargetGroupAttributes_EmptyArn(t *testing.T) {
+ _, err := ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
+ TargetGroupArn: aws.String(""),
+ }, nil, "123456789012")
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+}
+
+func TestModifyTargetGroupAttributes_MissingAttributes(t *testing.T) {
+ _, err := ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
+ TargetGroupArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/my-tg/abc"),
+ }, nil, "123456789012")
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+}
+
+func TestDescribeTargetGroupAttributes_NilInput(t *testing.T) {
+ _, err := DescribeTargetGroupAttributes(nil, nil, "123456789012")
+ assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
+}
+
+func TestDescribeTargetGroupAttributes_MissingArn(t *testing.T) {
+ _, err := DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{}, nil, "123456789012")
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+}
+
+func TestDescribeTargetGroupAttributes_EmptyArn(t *testing.T) {
+ _, err := DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{
+ TargetGroupArn: aws.String(""),
+ }, nil, "123456789012")
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+}
+
+func TestModifyLoadBalancerAttributes_NilInput(t *testing.T) {
+ _, err := ModifyLoadBalancerAttributes(nil, nil, "123456789012")
+ assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
+}
+
+func TestModifyLoadBalancerAttributes_MissingArn(t *testing.T) {
+ _, err := ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{}, nil, "123456789012")
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+}
+
+func TestModifyLoadBalancerAttributes_EmptyArn(t *testing.T) {
+ _, err := ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
+ LoadBalancerArn: aws.String(""),
+ }, nil, "123456789012")
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+}
+
+func TestModifyLoadBalancerAttributes_MissingAttributes(t *testing.T) {
+ _, err := ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
+ LoadBalancerArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/abc"),
+ }, nil, "123456789012")
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+}
+
+func TestDescribeLoadBalancerAttributes_NilInput(t *testing.T) {
+ _, err := DescribeLoadBalancerAttributes(nil, nil, "123456789012")
+ assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
+}
+
+func TestDescribeLoadBalancerAttributes_MissingArn(t *testing.T) {
+ _, err := DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{}, nil, "123456789012")
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+}
+
+func TestDescribeLoadBalancerAttributes_EmptyArn(t *testing.T) {
+ _, err := DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{
+ LoadBalancerArn: aws.String(""),
+ }, nil, "123456789012")
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+}
diff --git a/spinifex/gateway/elbv2_test.go b/spinifex/gateway/elbv2_test.go
index a4e2476bf..b9b1fbca7 100644
--- a/spinifex/gateway/elbv2_test.go
+++ b/spinifex/gateway/elbv2_test.go
@@ -20,8 +20,13 @@ func TestELBv2ActionsMap_AllActionsRegistered(t *testing.T) {
"CreateListener",
"DeleteListener",
"DescribeListeners",
+ "DescribeTags",
"LBAgentHeartbeat",
"GetLBConfig",
+ "ModifyTargetGroupAttributes",
+ "DescribeTargetGroupAttributes",
+ "ModifyLoadBalancerAttributes",
+ "DescribeLoadBalancerAttributes",
}
for _, action := range expectedActions {
diff --git a/spinifex/handlers/elbv2/service.go b/spinifex/handlers/elbv2/service.go
index d3a730e53..477c61c56 100644
--- a/spinifex/handlers/elbv2/service.go
+++ b/spinifex/handlers/elbv2/service.go
@@ -7,10 +7,14 @@ type ELBv2Service interface {
CreateLoadBalancer(input *elbv2.CreateLoadBalancerInput, accountID string) (*elbv2.CreateLoadBalancerOutput, error)
DeleteLoadBalancer(input *elbv2.DeleteLoadBalancerInput, accountID string) (*elbv2.DeleteLoadBalancerOutput, error)
DescribeLoadBalancers(input *elbv2.DescribeLoadBalancersInput, accountID string) (*elbv2.DescribeLoadBalancersOutput, error)
+ ModifyLoadBalancerAttributes(input *elbv2.ModifyLoadBalancerAttributesInput, accountID string) (*elbv2.ModifyLoadBalancerAttributesOutput, error)
+ DescribeLoadBalancerAttributes(input *elbv2.DescribeLoadBalancerAttributesInput, accountID string) (*elbv2.DescribeLoadBalancerAttributesOutput, error)
CreateTargetGroup(input *elbv2.CreateTargetGroupInput, accountID string) (*elbv2.CreateTargetGroupOutput, error)
DeleteTargetGroup(input *elbv2.DeleteTargetGroupInput, accountID string) (*elbv2.DeleteTargetGroupOutput, error)
DescribeTargetGroups(input *elbv2.DescribeTargetGroupsInput, accountID string) (*elbv2.DescribeTargetGroupsOutput, error)
+ ModifyTargetGroupAttributes(input *elbv2.ModifyTargetGroupAttributesInput, accountID string) (*elbv2.ModifyTargetGroupAttributesOutput, error)
+ DescribeTargetGroupAttributes(input *elbv2.DescribeTargetGroupAttributesInput, accountID string) (*elbv2.DescribeTargetGroupAttributesOutput, error)
RegisterTargets(input *elbv2.RegisterTargetsInput, accountID string) (*elbv2.RegisterTargetsOutput, error)
DeregisterTargets(input *elbv2.DeregisterTargetsInput, accountID string) (*elbv2.DeregisterTargetsOutput, error)
@@ -20,6 +24,8 @@ type ELBv2Service interface {
DeleteListener(input *elbv2.DeleteListenerInput, accountID string) (*elbv2.DeleteListenerOutput, error)
DescribeListeners(input *elbv2.DescribeListenersInput, accountID string) (*elbv2.DescribeListenersOutput, error)
+ DescribeTags(input *elbv2.DescribeTagsInput, accountID string) (*elbv2.DescribeTagsOutput, error)
+
LBAgentHeartbeat(input *LBAgentHeartbeatInput, accountID string) (*LBAgentHeartbeatOutput, error)
GetLBConfig(input *GetLBConfigInput, accountID string) (*GetLBConfigOutput, error)
}
diff --git a/spinifex/handlers/elbv2/service_impl.go b/spinifex/handlers/elbv2/service_impl.go
index 15d83eb23..a3bee3cbc 100644
--- a/spinifex/handlers/elbv2/service_impl.go
+++ b/spinifex/handlers/elbv2/service_impl.go
@@ -6,6 +6,9 @@ import (
"errors"
"fmt"
"log/slog"
+ "maps"
+ "slices"
+ "strings"
"sync"
"time"
@@ -425,6 +428,37 @@ func buildListenerArn(region, accountID, lbName, lbID, listenerID, lbType string
return fmt.Sprintf("arn:aws:elasticloadbalancing:%s:%s:listener/%s/%s/%s/%s", region, accountID, lbArnPathSegment(lbType), lbName, lbID, listenerID)
}
+// ELBv2 resource types as they appear in the resource segment of an ARN.
+const (
+ elbv2ResourceLoadBalancer = "loadbalancer"
+ elbv2ResourceTargetGroup = "targetgroup"
+ elbv2ResourceListener = "listener"
+)
+
+// elbv2ResourceTypeFromArn extracts the resource type from an ELBv2 ARN.
+// ELBv2 ARNs have the form
+// "arn:aws:elasticloadbalancing:{region}:{account}:{type}/...". Returns one
+// of "loadbalancer", "targetgroup", "listener" — anything else yields
+// ErrorInvalidParameterValue so callers can surface a proper API error.
+func elbv2ResourceTypeFromArn(arn string) (string, error) {
+ parts := strings.SplitN(arn, ":", 6)
+ if len(parts) < 6 || parts[0] != "arn" || parts[2] != "elasticloadbalancing" {
+ return "", errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ resourceSegment := parts[5]
+ slash := strings.Index(resourceSegment, "/")
+ if slash <= 0 {
+ return "", errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ resourceType := resourceSegment[:slash]
+ switch resourceType {
+ case elbv2ResourceLoadBalancer, elbv2ResourceTargetGroup, elbv2ResourceListener:
+ return resourceType, nil
+ default:
+ return "", errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+}
+
// isCompatibleProtocol checks whether a listener protocol is compatible with a
// target group protocol. ALB: HTTP/HTTPS listeners can forward to HTTP or HTTPS
// TGs. NLB: TCP→TCP, UDP→UDP, TLS→TCP or TLS, TCP_UDP→TCP_UDP.
@@ -632,28 +666,30 @@ func (s *ELBv2ServiceImpl) CreateLoadBalancer(input *elbv2.CreateLoadBalancerInp
state = StateFailed
}
+ // Attributes are intentionally left nil — DescribeLoadBalancerAttributes
+ // derives per-type defaults from lb.Type via DefaultLoadBalancerAttributes.
+ // Callers that want a non-default value must call ModifyLoadBalancerAttributes.
record := &LoadBalancerRecord{
- LoadBalancerArn: lbArn,
- LoadBalancerID: lbID,
- DNSName: dnsName,
- Name: name,
- Scheme: scheme,
- Type: lbType,
- CrossZoneEnabled: lbType == LoadBalancerTypeApplication,
- State: state,
- VpcId: vpcID,
- SecurityGroups: securityGroups,
- Subnets: subnets,
- AvailZones: availZones,
- ENIs: eniIDs,
- InstanceID: albInstanceID,
- VPCIP: albVPCIP,
- HostPorts: hostPorts,
- IPAddressType: IPAddressTypeIPv4,
- NodeID: s.nodeID,
- Tags: tags,
- AccountID: accountID,
- CreatedAt: time.Now().UTC(),
+ LoadBalancerArn: lbArn,
+ LoadBalancerID: lbID,
+ DNSName: dnsName,
+ Name: name,
+ Scheme: scheme,
+ Type: lbType,
+ State: state,
+ VpcId: vpcID,
+ SecurityGroups: securityGroups,
+ Subnets: subnets,
+ AvailZones: availZones,
+ ENIs: eniIDs,
+ InstanceID: albInstanceID,
+ VPCIP: albVPCIP,
+ HostPorts: hostPorts,
+ IPAddressType: IPAddressTypeIPv4,
+ NodeID: s.nodeID,
+ Tags: tags,
+ AccountID: accountID,
+ CreatedAt: time.Now().UTC(),
}
if err := s.store.PutLoadBalancer(record); err != nil {
@@ -1335,8 +1371,314 @@ func (s *ELBv2ServiceImpl) DescribeListeners(input *elbv2.DescribeListenersInput
}, nil
}
+// --- Tag operations ---
+
+// DescribeTags returns tags for one or more ELBv2 resources (load balancers,
+// target groups, listeners). Tag data is read from the existing record stores
+// — Spinifex doesn't have a separate tag KV. Listeners currently never store
+// tags, so they always return an empty Tags slice (matches AWS behaviour for
+// untagged resources). Cross-account or unknown ARNs return the per-resource
+// not-found error so existence isn't leaked across accounts.
+func (s *ELBv2ServiceImpl) DescribeTags(input *elbv2.DescribeTagsInput, accountID string) (*elbv2.DescribeTagsOutput, error) {
+ if input == nil || len(input.ResourceArns) == 0 {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+
+ descriptions := make([]*elbv2.TagDescription, 0, len(input.ResourceArns))
+ for _, arnPtr := range input.ResourceArns {
+ if arnPtr == nil || *arnPtr == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+ arn := *arnPtr
+
+ resourceType, err := elbv2ResourceTypeFromArn(arn)
+ if err != nil {
+ return nil, err
+ }
+
+ var (
+ tags map[string]string
+ ownerAccount string
+ notFoundError string
+ found bool
+ )
+ switch resourceType {
+ case elbv2ResourceLoadBalancer:
+ notFoundError = awserrors.ErrorELBv2LoadBalancerNotFound
+ lb, lbErr := s.store.GetLoadBalancerByArn(arn)
+ if lbErr != nil {
+ slog.Error("DescribeTags: failed to get LB", "arn", arn, "err", lbErr)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ if lb != nil {
+ found = true
+ tags = lb.Tags
+ ownerAccount = lb.AccountID
+ }
+ case elbv2ResourceTargetGroup:
+ notFoundError = awserrors.ErrorELBv2TargetGroupNotFound
+ tg, tgErr := s.store.GetTargetGroupByArn(arn)
+ if tgErr != nil {
+ slog.Error("DescribeTags: failed to get target group", "arn", arn, "err", tgErr)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ if tg != nil {
+ found = true
+ tags = tg.Tags
+ ownerAccount = tg.AccountID
+ }
+ case elbv2ResourceListener:
+ notFoundError = awserrors.ErrorELBv2ListenerNotFound
+ l, lErr := s.store.GetListenerByArn(arn)
+ if lErr != nil {
+ slog.Error("DescribeTags: failed to get listener", "arn", arn, "err", lErr)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ if l != nil {
+ found = true
+ ownerAccount = l.AccountID
+ // Listeners don't store tags yet — leave map nil.
+ }
+ }
+
+ if !found || ownerAccount != accountID {
+ return nil, errors.New(notFoundError)
+ }
+
+ descriptions = append(descriptions, &elbv2.TagDescription{
+ ResourceArn: aws.String(arn),
+ Tags: tagsMapToSDK(tags),
+ })
+ }
+
+ return &elbv2.DescribeTagsOutput{
+ TagDescriptions: descriptions,
+ }, nil
+}
+
+// tagsMapToSDK converts a tag map into the SDK Tag slice with deterministic
+// (sorted-by-key) ordering. Returns nil for empty/nil input so the response
+// matches AWS for untagged resources.
+func tagsMapToSDK(tags map[string]string) []*elbv2.Tag {
+ if len(tags) == 0 {
+ return nil
+ }
+ keys := make([]string, 0, len(tags))
+ for k := range tags {
+ keys = append(keys, k)
+ }
+ slices.Sort(keys)
+ out := make([]*elbv2.Tag, 0, len(keys))
+ for _, k := range keys {
+ out = append(out, &elbv2.Tag{Key: aws.String(k), Value: aws.String(tags[k])})
+ }
+ return out
+}
+
// --- SDK type conversion helpers ---
+func (s *ELBv2ServiceImpl) ModifyTargetGroupAttributes(input *elbv2.ModifyTargetGroupAttributesInput, accountID string) (*elbv2.ModifyTargetGroupAttributesOutput, error) {
+ if input.TargetGroupArn == nil || *input.TargetGroupArn == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+
+ tg, err := s.store.GetTargetGroupByArn(*input.TargetGroupArn)
+ if err != nil {
+ slog.Error("ModifyTargetGroupAttributes: failed to get TG", "arn", *input.TargetGroupArn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ if tg == nil || tg.AccountID != accountID {
+ return nil, errors.New(awserrors.ErrorELBv2TargetGroupNotFound)
+ }
+
+ knownTGAttrs := DefaultTargetGroupAttributes()
+ var submitted []*elbv2.TargetGroupAttribute
+ dirty := false
+ for _, attr := range input.Attributes {
+ if attr == nil {
+ slog.Warn("ModifyTargetGroupAttributes: skipping nil attribute element", "arn", *input.TargetGroupArn)
+ continue
+ }
+ if attr.Key == nil || attr.Value == nil {
+ slog.Warn("ModifyTargetGroupAttributes: skipping attribute with nil Key or Value", "arn", *input.TargetGroupArn)
+ continue
+ }
+ if _, ok := knownTGAttrs[*attr.Key]; !ok {
+ slog.Warn("ModifyTargetGroupAttributes: rejecting unknown attribute key", "arn", *input.TargetGroupArn, "key", *attr.Key)
+ return nil, errors.New(awserrors.ErrorValidationError)
+ }
+ submitted = append(submitted, &elbv2.TargetGroupAttribute{
+ Key: attr.Key,
+ Value: attr.Value,
+ })
+ existing, exists := tg.Attributes[*attr.Key]
+ if exists && existing == *attr.Value {
+ continue // value already matches stored — no mutation needed
+ }
+ if tg.Attributes == nil {
+ tg.Attributes = make(map[string]string)
+ }
+ tg.Attributes[*attr.Key] = *attr.Value
+ dirty = true
+ }
+
+ // If the caller sent attributes but every single one was rejected by the
+ // nil guard above, surface that as an error instead of silently returning
+ // a successful empty response — otherwise the caller thinks the write
+ // landed when nothing was actually applied.
+ if len(input.Attributes) > 0 && len(submitted) == 0 {
+ slog.Warn("ModifyTargetGroupAttributes: all submitted attributes were invalid", "arn", *input.TargetGroupArn, "submitted_count", len(input.Attributes))
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+
+ // Skip the NATS/KV write when nothing changed. Terraform re-applies the same
+ // attribute set on every drift check, so this kills ~all Modify traffic
+ // during steady state and narrows the read-modify-write race window.
+ if dirty {
+ if err := s.store.PutTargetGroup(tg); err != nil {
+ slog.Error("ModifyTargetGroupAttributes: failed to persist TG", "arn", *input.TargetGroupArn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ }
+
+ return &elbv2.ModifyTargetGroupAttributesOutput{
+ Attributes: submitted,
+ }, nil
+}
+
+func (s *ELBv2ServiceImpl) DescribeTargetGroupAttributes(input *elbv2.DescribeTargetGroupAttributesInput, accountID string) (*elbv2.DescribeTargetGroupAttributesOutput, error) {
+ if input.TargetGroupArn == nil || *input.TargetGroupArn == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+
+ tg, err := s.store.GetTargetGroupByArn(*input.TargetGroupArn)
+ if err != nil {
+ slog.Error("DescribeTargetGroupAttributes: failed to get TG", "arn", *input.TargetGroupArn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ if tg == nil || tg.AccountID != accountID {
+ return nil, errors.New(awserrors.ErrorELBv2TargetGroupNotFound)
+ }
+
+ merged := DefaultTargetGroupAttributes()
+ maps.Copy(merged, tg.Attributes)
+
+ // Sort keys for deterministic output — Terraform diffs and snapshot tests
+ // depend on stable attribute ordering.
+ keys := slices.Sorted(maps.Keys(merged))
+ attrs := make([]*elbv2.TargetGroupAttribute, 0, len(keys))
+ for _, k := range keys {
+ attrs = append(attrs, &elbv2.TargetGroupAttribute{
+ Key: aws.String(k),
+ Value: aws.String(merged[k]),
+ })
+ }
+
+ return &elbv2.DescribeTargetGroupAttributesOutput{
+ Attributes: attrs,
+ }, nil
+}
+
+func (s *ELBv2ServiceImpl) ModifyLoadBalancerAttributes(input *elbv2.ModifyLoadBalancerAttributesInput, accountID string) (*elbv2.ModifyLoadBalancerAttributesOutput, error) {
+ if input.LoadBalancerArn == nil || *input.LoadBalancerArn == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+
+ lb, err := s.store.GetLoadBalancerByArn(*input.LoadBalancerArn)
+ if err != nil {
+ slog.Error("ModifyLoadBalancerAttributes: failed to get LB", "arn", *input.LoadBalancerArn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ if lb == nil || lb.AccountID != accountID {
+ return nil, errors.New(awserrors.ErrorELBv2LoadBalancerNotFound)
+ }
+
+ knownLBAttrs := DefaultLoadBalancerAttributes(lb.Type)
+ var submitted []*elbv2.LoadBalancerAttribute
+ dirty := false
+ for _, attr := range input.Attributes {
+ if attr == nil {
+ slog.Warn("ModifyLoadBalancerAttributes: skipping nil attribute element", "arn", *input.LoadBalancerArn)
+ continue
+ }
+ if attr.Key == nil || attr.Value == nil {
+ slog.Warn("ModifyLoadBalancerAttributes: skipping attribute with nil Key or Value", "arn", *input.LoadBalancerArn)
+ continue
+ }
+ if _, ok := knownLBAttrs[*attr.Key]; !ok {
+ slog.Warn("ModifyLoadBalancerAttributes: rejecting unknown attribute key", "arn", *input.LoadBalancerArn, "key", *attr.Key, "lbType", lb.Type)
+ return nil, errors.New(awserrors.ErrorValidationError)
+ }
+ submitted = append(submitted, &elbv2.LoadBalancerAttribute{
+ Key: attr.Key,
+ Value: attr.Value,
+ })
+ existing, exists := lb.Attributes[*attr.Key]
+ if exists && existing == *attr.Value {
+ continue
+ }
+ if lb.Attributes == nil {
+ lb.Attributes = make(map[string]string)
+ }
+ lb.Attributes[*attr.Key] = *attr.Value
+ dirty = true
+ }
+
+ // If the caller sent attributes but every single one was rejected by the
+ // nil guard above, surface that as an error instead of silently returning
+ // a successful empty response.
+ if len(input.Attributes) > 0 && len(submitted) == 0 {
+ slog.Warn("ModifyLoadBalancerAttributes: all submitted attributes were invalid", "arn", *input.LoadBalancerArn, "submitted_count", len(input.Attributes))
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+
+ // Skip the NATS/KV write when nothing changed. See
+ // ModifyTargetGroupAttributes for the Terraform-drift-check motivation.
+ if dirty {
+ if err := s.store.PutLoadBalancer(lb); err != nil {
+ slog.Error("ModifyLoadBalancerAttributes: failed to persist LB", "arn", *input.LoadBalancerArn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ }
+
+ return &elbv2.ModifyLoadBalancerAttributesOutput{
+ Attributes: submitted,
+ }, nil
+}
+
+func (s *ELBv2ServiceImpl) DescribeLoadBalancerAttributes(input *elbv2.DescribeLoadBalancerAttributesInput, accountID string) (*elbv2.DescribeLoadBalancerAttributesOutput, error) {
+ if input.LoadBalancerArn == nil || *input.LoadBalancerArn == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+
+ lb, err := s.store.GetLoadBalancerByArn(*input.LoadBalancerArn)
+ if err != nil {
+ slog.Error("DescribeLoadBalancerAttributes: failed to get LB", "arn", *input.LoadBalancerArn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ if lb == nil || lb.AccountID != accountID {
+ return nil, errors.New(awserrors.ErrorELBv2LoadBalancerNotFound)
+ }
+
+ merged := DefaultLoadBalancerAttributes(lb.Type)
+ maps.Copy(merged, lb.Attributes)
+
+ // Sort keys for deterministic output — Terraform diffs and snapshot tests
+ // depend on stable attribute ordering.
+ keys := slices.Sorted(maps.Keys(merged))
+ attrs := make([]*elbv2.LoadBalancerAttribute, 0, len(keys))
+ for _, k := range keys {
+ attrs = append(attrs, &elbv2.LoadBalancerAttribute{
+ Key: aws.String(k),
+ Value: aws.String(merged[k]),
+ })
+ }
+
+ return &elbv2.DescribeLoadBalancerAttributesOutput{
+ Attributes: attrs,
+ }, nil
+}
+
func (s *ELBv2ServiceImpl) lbRecordToSDK(r *LoadBalancerRecord) *elbv2.LoadBalancer {
lb := &elbv2.LoadBalancer{
LoadBalancerArn: aws.String(r.LoadBalancerArn),
diff --git a/spinifex/handlers/elbv2/service_impl_test.go b/spinifex/handlers/elbv2/service_impl_test.go
index a25c229e3..c955b6af1 100644
--- a/spinifex/handlers/elbv2/service_impl_test.go
+++ b/spinifex/handlers/elbv2/service_impl_test.go
@@ -1,12 +1,14 @@
package handlers_elbv2
import (
+ "sort"
"sync"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/elbv2"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/testutil"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/stretchr/testify/assert"
@@ -139,30 +141,47 @@ func TestCreateLoadBalancer_NetworkType_RejectsSecurityGroups(t *testing.T) {
assert.Contains(t, err.Error(), "InvalidConfigurationRequest")
}
-func TestCreateLoadBalancer_NetworkType_CrossZoneDisabled(t *testing.T) {
+func TestCreateLoadBalancer_CrossZoneAttributes(t *testing.T) {
svc := setupTestService(t)
- out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ // NLB: no stored attributes, Describe should return default cross-zone=false.
+ nlbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("nlb-cz"),
Type: aws.String("network"),
}, testAccountID)
-
require.NoError(t, err)
- // Verify the stored record has CrossZoneEnabled=false
- lb, err := svc.store.GetLoadBalancerByName("nlb-cz", testAccountID)
+ nlbRec, err := svc.store.GetLoadBalancerByName("nlb-cz", testAccountID)
require.NoError(t, err)
- assert.False(t, lb.CrossZoneEnabled)
+ assert.Nil(t, nlbRec.Attributes, "NLB should store nil attributes — defaults come from the handler")
- // ALB should default to CrossZoneEnabled=true
- _, err = svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ nlbDesc, err := svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{
+ LoadBalancerArn: nlbOut.LoadBalancers[0].LoadBalancerArn,
+ }, testAccountID)
+ require.NoError(t, err)
+ nlbAttrs := make(map[string]string)
+ for _, a := range nlbDesc.Attributes {
+ nlbAttrs[*a.Key] = *a.Value
+ }
+ assert.Equal(t, "false", nlbAttrs["load_balancing.cross_zone.enabled"])
+
+ // ALB: no stored attributes either, Describe should return default cross-zone=true.
+ albOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("alb-cz"),
}, testAccountID)
require.NoError(t, err)
albRec, err := svc.store.GetLoadBalancerByName("alb-cz", testAccountID)
require.NoError(t, err)
- assert.True(t, albRec.CrossZoneEnabled)
+ assert.Nil(t, albRec.Attributes, "ALB should no longer seed attributes at create time")
- _ = out // suppress unused warning
+ albDesc, err := svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{
+ LoadBalancerArn: albOut.LoadBalancers[0].LoadBalancerArn,
+ }, testAccountID)
+ require.NoError(t, err)
+ albAttrs := make(map[string]string)
+ for _, a := range albDesc.Attributes {
+ albAttrs[*a.Key] = *a.Value
+ }
+ assert.Equal(t, "true", albAttrs["load_balancing.cross_zone.enabled"])
}
func TestCreateLoadBalancer_InvalidType(t *testing.T) {
@@ -1668,3 +1687,911 @@ func TestUpdateStoredConfig_MissingTargetGroup(t *testing.T) {
require.NoError(t, err)
assert.NotEmpty(t, stored.ConfigHash)
}
+
+// --- Attribute tests ---
+
+func TestDescribeTargetGroupAttributes_Defaults(t *testing.T) {
+ svc := setupTestService(t)
+ tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-attr-defaults")}, testAccountID)
+ require.NoError(t, err)
+
+ out, err := svc.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{
+ TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
+ }, testAccountID)
+ require.NoError(t, err)
+
+ defaults := DefaultTargetGroupAttributes()
+ assert.Len(t, out.Attributes, len(defaults))
+ attrMap := make(map[string]string)
+ for _, a := range out.Attributes {
+ attrMap[*a.Key] = *a.Value
+ }
+ for k, v := range defaults {
+ assert.Equal(t, v, attrMap[k], "default mismatch for key %s", k)
+ }
+}
+
+func TestDescribeLoadBalancerAttributes_ALBDefaults(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("alb-attr-defaults")}, testAccountID)
+ require.NoError(t, err)
+
+ out, err := svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{
+ LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
+ }, testAccountID)
+ require.NoError(t, err)
+
+ defaults := DefaultLoadBalancerAttributes(LoadBalancerTypeApplication)
+ assert.Len(t, out.Attributes, len(defaults))
+ attrMap := make(map[string]string)
+ for _, a := range out.Attributes {
+ attrMap[*a.Key] = *a.Value
+ }
+ // ALB default cross-zone is true — comes from the per-type default, not seeding.
+ assert.Equal(t, "true", attrMap["load_balancing.cross_zone.enabled"])
+}
+
+func TestDescribeLoadBalancerAttributes_NLBDefaults(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ Name: aws.String("nlb-attr-defaults"),
+ Type: aws.String("network"),
+ }, testAccountID)
+ require.NoError(t, err)
+
+ out, err := svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{
+ LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
+ }, testAccountID)
+ require.NoError(t, err)
+
+ attrMap := make(map[string]string)
+ for _, a := range out.Attributes {
+ attrMap[*a.Key] = *a.Value
+ }
+ // NLB should fall through to default false
+ assert.Equal(t, "false", attrMap["load_balancing.cross_zone.enabled"])
+}
+
+func TestModifyDescribeTargetGroupAttributes_RoundTrip(t *testing.T) {
+ svc := setupTestService(t)
+ tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-attr-rt")}, testAccountID)
+ require.NoError(t, err)
+ arn := tgOut.TargetGroups[0].TargetGroupArn
+
+ modOut, err := svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
+ TargetGroupArn: arn,
+ Attributes: []*elbv2.TargetGroupAttribute{
+ {Key: aws.String("deregistration_delay.timeout_seconds"), Value: aws.String("60")},
+ {Key: aws.String("stickiness.enabled"), Value: aws.String("true")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+ // Assert the exact echoed key/value pairs, not just the length — a regression
+ // that pointed at the wrong source or dropped Value would pass a length check.
+ require.Len(t, modOut.Attributes, 2)
+ modMap := make(map[string]string, len(modOut.Attributes))
+ for _, a := range modOut.Attributes {
+ require.NotNil(t, a.Key)
+ require.NotNil(t, a.Value)
+ modMap[*a.Key] = *a.Value
+ }
+ assert.Equal(t, "60", modMap["deregistration_delay.timeout_seconds"])
+ assert.Equal(t, "true", modMap["stickiness.enabled"])
+
+ descOut, err := svc.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{
+ TargetGroupArn: arn,
+ }, testAccountID)
+ require.NoError(t, err)
+ attrMap := make(map[string]string)
+ for _, a := range descOut.Attributes {
+ attrMap[*a.Key] = *a.Value
+ }
+ assert.Equal(t, "60", attrMap["deregistration_delay.timeout_seconds"])
+ assert.Equal(t, "true", attrMap["stickiness.enabled"])
+ // Unmodified defaults should still be present
+ assert.Equal(t, "lb_cookie", attrMap["stickiness.type"])
+}
+
+func TestModifyDescribeLoadBalancerAttributes_RoundTrip(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-attr-rt")}, testAccountID)
+ require.NoError(t, err)
+ arn := lbOut.LoadBalancers[0].LoadBalancerArn
+
+ modOut, err := svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
+ LoadBalancerArn: arn,
+ Attributes: []*elbv2.LoadBalancerAttribute{
+ {Key: aws.String("idle_timeout.timeout_seconds"), Value: aws.String("120")},
+ {Key: aws.String("deletion_protection.enabled"), Value: aws.String("true")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+ // Assert the exact echoed key/value pairs, not just the length.
+ require.Len(t, modOut.Attributes, 2)
+ modMap := make(map[string]string, len(modOut.Attributes))
+ for _, a := range modOut.Attributes {
+ require.NotNil(t, a.Key)
+ require.NotNil(t, a.Value)
+ modMap[*a.Key] = *a.Value
+ }
+ assert.Equal(t, "120", modMap["idle_timeout.timeout_seconds"])
+ assert.Equal(t, "true", modMap["deletion_protection.enabled"])
+
+ descOut, err := svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{
+ LoadBalancerArn: arn,
+ }, testAccountID)
+ require.NoError(t, err)
+ attrMap := make(map[string]string)
+ for _, a := range descOut.Attributes {
+ attrMap[*a.Key] = *a.Value
+ }
+ assert.Equal(t, "120", attrMap["idle_timeout.timeout_seconds"])
+ assert.Equal(t, "true", attrMap["deletion_protection.enabled"])
+ // Unmodified defaults should still be present
+ assert.Equal(t, "true", attrMap["routing.http2.enabled"])
+}
+
+func TestModifyTargetGroupAttributes_NotFound(t *testing.T) {
+ svc := setupTestService(t)
+ _, err := svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
+ TargetGroupArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/missing/tg-missing"),
+ Attributes: []*elbv2.TargetGroupAttribute{
+ {Key: aws.String("stickiness.enabled"), Value: aws.String("true")},
+ },
+ }, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorELBv2TargetGroupNotFound)
+}
+
+func TestDescribeTargetGroupAttributes_NotFound(t *testing.T) {
+ svc := setupTestService(t)
+ _, err := svc.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{
+ TargetGroupArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/missing/tg-missing"),
+ }, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorELBv2TargetGroupNotFound)
+}
+
+func TestModifyLoadBalancerAttributes_NotFound(t *testing.T) {
+ svc := setupTestService(t)
+ _, err := svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
+ LoadBalancerArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/missing/lb-missing"),
+ Attributes: []*elbv2.LoadBalancerAttribute{
+ {Key: aws.String("idle_timeout.timeout_seconds"), Value: aws.String("30")},
+ },
+ }, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorELBv2LoadBalancerNotFound)
+}
+
+func TestDescribeLoadBalancerAttributes_NotFound(t *testing.T) {
+ svc := setupTestService(t)
+ _, err := svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{
+ LoadBalancerArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/missing/lb-missing"),
+ }, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorELBv2LoadBalancerNotFound)
+}
+
+func TestModifyTargetGroupAttributes_MissingArn(t *testing.T) {
+ svc := setupTestService(t)
+ _, err := svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
+ Attributes: []*elbv2.TargetGroupAttribute{
+ {Key: aws.String("stickiness.enabled"), Value: aws.String("true")},
+ },
+ }, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+}
+
+func TestDescribeTargetGroupAttributes_MissingArn(t *testing.T) {
+ svc := setupTestService(t)
+ _, err := svc.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{}, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+}
+
+func TestModifyLoadBalancerAttributes_MissingArn(t *testing.T) {
+ svc := setupTestService(t)
+ _, err := svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
+ Attributes: []*elbv2.LoadBalancerAttribute{
+ {Key: aws.String("idle_timeout.timeout_seconds"), Value: aws.String("30")},
+ },
+ }, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+}
+
+func TestDescribeLoadBalancerAttributes_MissingArn(t *testing.T) {
+ svc := setupTestService(t)
+ _, err := svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{}, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+}
+
+func TestModifyTargetGroupAttributes_WrongAccount(t *testing.T) {
+ svc := setupTestService(t)
+ tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-attr-wrong-acct")}, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
+ TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
+ Attributes: []*elbv2.TargetGroupAttribute{
+ {Key: aws.String("stickiness.enabled"), Value: aws.String("true")},
+ },
+ }, "999999999999")
+ assert.EqualError(t, err, awserrors.ErrorELBv2TargetGroupNotFound)
+}
+
+func TestDescribeTargetGroupAttributes_WrongAccount(t *testing.T) {
+ svc := setupTestService(t)
+ tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-desc-attr-wrong-acct")}, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{
+ TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
+ }, "999999999999")
+ assert.EqualError(t, err, awserrors.ErrorELBv2TargetGroupNotFound)
+}
+
+func TestModifyLoadBalancerAttributes_WrongAccount(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-attr-wrong-acct")}, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
+ LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
+ Attributes: []*elbv2.LoadBalancerAttribute{
+ {Key: aws.String("idle_timeout.timeout_seconds"), Value: aws.String("30")},
+ },
+ }, "999999999999")
+ assert.EqualError(t, err, awserrors.ErrorELBv2LoadBalancerNotFound)
+}
+
+func TestDescribeLoadBalancerAttributes_WrongAccount(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-desc-attr-wrong-acct")}, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{
+ LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
+ }, "999999999999")
+ assert.EqualError(t, err, awserrors.ErrorELBv2LoadBalancerNotFound)
+}
+
+// TestModifyTargetGroupAttributes_SkipsInvalidEntries verifies that nil slice
+// elements, nil Keys, and nil Values are skipped (with a warning) rather than
+// panicking or being silently dropped. Valid attributes in the same call must
+// still be applied.
+func TestModifyTargetGroupAttributes_SkipsInvalidEntries(t *testing.T) {
+ svc := setupTestService(t)
+ tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-attr-skip")}, testAccountID)
+ require.NoError(t, err)
+ arn := tgOut.TargetGroups[0].TargetGroupArn
+
+ modOut, err := svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
+ TargetGroupArn: arn,
+ Attributes: []*elbv2.TargetGroupAttribute{
+ nil, // nil element must not panic
+ {Key: nil, Value: aws.String("v")},
+ {Key: aws.String("k"), Value: nil},
+ {Key: aws.String("stickiness.enabled"), Value: aws.String("true")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+ // Only the one valid attribute should be returned.
+ require.Len(t, modOut.Attributes, 1)
+ assert.Equal(t, "stickiness.enabled", *modOut.Attributes[0].Key)
+ assert.Equal(t, "true", *modOut.Attributes[0].Value)
+
+ // The valid attribute should have been persisted.
+ descOut, err := svc.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{
+ TargetGroupArn: arn,
+ }, testAccountID)
+ require.NoError(t, err)
+ attrMap := make(map[string]string)
+ for _, a := range descOut.Attributes {
+ attrMap[*a.Key] = *a.Value
+ }
+ assert.Equal(t, "true", attrMap["stickiness.enabled"])
+}
+
+// TestModifyLoadBalancerAttributes_SkipsInvalidEntries mirrors the TG case for
+// the LB handler: nil elements and nil Key/Value fields must be skipped, not
+// panic or swallow valid attributes in the same request.
+func TestModifyLoadBalancerAttributes_SkipsInvalidEntries(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-attr-skip")}, testAccountID)
+ require.NoError(t, err)
+ arn := lbOut.LoadBalancers[0].LoadBalancerArn
+
+ modOut, err := svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
+ LoadBalancerArn: arn,
+ Attributes: []*elbv2.LoadBalancerAttribute{
+ nil, // nil element must not panic
+ {Key: nil, Value: aws.String("v")},
+ {Key: aws.String("k"), Value: nil},
+ {Key: aws.String("idle_timeout.timeout_seconds"), Value: aws.String("75")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, modOut.Attributes, 1)
+ assert.Equal(t, "idle_timeout.timeout_seconds", *modOut.Attributes[0].Key)
+ assert.Equal(t, "75", *modOut.Attributes[0].Value)
+
+ descOut, err := svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{
+ LoadBalancerArn: arn,
+ }, testAccountID)
+ require.NoError(t, err)
+ attrMap := make(map[string]string)
+ for _, a := range descOut.Attributes {
+ attrMap[*a.Key] = *a.Value
+ }
+ assert.Equal(t, "75", attrMap["idle_timeout.timeout_seconds"])
+}
+
+// TestModifyTargetGroupAttributes_AllInvalidReturnsError guards against the
+// silent-success case where every submitted attribute trips the nil guard and
+// the handler returned 200 OK with an empty response body — the caller would
+// think the write landed when nothing was actually applied.
+func TestModifyTargetGroupAttributes_AllInvalidReturnsError(t *testing.T) {
+ svc := setupTestService(t)
+ tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-all-invalid")}, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
+ TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
+ Attributes: []*elbv2.TargetGroupAttribute{
+ nil,
+ {Key: nil, Value: aws.String("v")},
+ {Key: aws.String("k"), Value: nil},
+ },
+ }, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
+}
+
+// TestModifyLoadBalancerAttributes_AllInvalidReturnsError mirrors the TG case.
+func TestModifyLoadBalancerAttributes_AllInvalidReturnsError(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-all-invalid")}, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
+ LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
+ Attributes: []*elbv2.LoadBalancerAttribute{
+ nil,
+ {Key: nil, Value: aws.String("v")},
+ {Key: aws.String("k"), Value: nil},
+ },
+ }, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
+}
+
+// TestModifyTargetGroupAttributes_SequentialMerge verifies that successive
+// Modify calls accumulate keys instead of replacing the entire attribute map.
+// A future refactor that did `tg.Attributes = newMap` would pass every other
+// test (single-call round-trip covers the happy path) but silently wipe
+// previous attributes on every subsequent Modify — the most likely real-world
+// ALB/TG bug.
+func TestModifyTargetGroupAttributes_SequentialMerge(t *testing.T) {
+ svc := setupTestService(t)
+ tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-seq-merge")}, testAccountID)
+ require.NoError(t, err)
+ arn := tgOut.TargetGroups[0].TargetGroupArn
+
+ // Call 1: set deregistration_delay.timeout_seconds.
+ _, err = svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
+ TargetGroupArn: arn,
+ Attributes: []*elbv2.TargetGroupAttribute{
+ {Key: aws.String("deregistration_delay.timeout_seconds"), Value: aws.String("45")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ // Call 2: set a different key; must not wipe the first one.
+ _, err = svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
+ TargetGroupArn: arn,
+ Attributes: []*elbv2.TargetGroupAttribute{
+ {Key: aws.String("stickiness.enabled"), Value: aws.String("true")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ descOut, err := svc.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{
+ TargetGroupArn: arn,
+ }, testAccountID)
+ require.NoError(t, err)
+ attrMap := make(map[string]string)
+ for _, a := range descOut.Attributes {
+ attrMap[*a.Key] = *a.Value
+ }
+ assert.Equal(t, "45", attrMap["deregistration_delay.timeout_seconds"], "first-call key must survive second Modify")
+ assert.Equal(t, "true", attrMap["stickiness.enabled"], "second-call key must be present")
+
+ // Call 3: overwrite the same key with a new value.
+ _, err = svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
+ TargetGroupArn: arn,
+ Attributes: []*elbv2.TargetGroupAttribute{
+ {Key: aws.String("stickiness.enabled"), Value: aws.String("false")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ descOut, err = svc.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{
+ TargetGroupArn: arn,
+ }, testAccountID)
+ require.NoError(t, err)
+ attrMap = make(map[string]string)
+ for _, a := range descOut.Attributes {
+ attrMap[*a.Key] = *a.Value
+ }
+ assert.Equal(t, "false", attrMap["stickiness.enabled"], "same-key overwrite must replace the value")
+ assert.Equal(t, "45", attrMap["deregistration_delay.timeout_seconds"], "unrelated key must still survive")
+}
+
+// TestModifyLoadBalancerAttributes_SequentialMerge mirrors the TG case.
+func TestModifyLoadBalancerAttributes_SequentialMerge(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-seq-merge")}, testAccountID)
+ require.NoError(t, err)
+ arn := lbOut.LoadBalancers[0].LoadBalancerArn
+
+ _, err = svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
+ LoadBalancerArn: arn,
+ Attributes: []*elbv2.LoadBalancerAttribute{
+ {Key: aws.String("idle_timeout.timeout_seconds"), Value: aws.String("120")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
+ LoadBalancerArn: arn,
+ Attributes: []*elbv2.LoadBalancerAttribute{
+ {Key: aws.String("deletion_protection.enabled"), Value: aws.String("true")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ descOut, err := svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{
+ LoadBalancerArn: arn,
+ }, testAccountID)
+ require.NoError(t, err)
+ attrMap := make(map[string]string)
+ for _, a := range descOut.Attributes {
+ attrMap[*a.Key] = *a.Value
+ }
+ assert.Equal(t, "120", attrMap["idle_timeout.timeout_seconds"], "first-call key must survive second Modify")
+ assert.Equal(t, "true", attrMap["deletion_protection.enabled"], "second-call key must be present")
+
+ // Same-key overwrite.
+ _, err = svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
+ LoadBalancerArn: arn,
+ Attributes: []*elbv2.LoadBalancerAttribute{
+ {Key: aws.String("idle_timeout.timeout_seconds"), Value: aws.String("90")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ descOut, err = svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{
+ LoadBalancerArn: arn,
+ }, testAccountID)
+ require.NoError(t, err)
+ attrMap = make(map[string]string)
+ for _, a := range descOut.Attributes {
+ attrMap[*a.Key] = *a.Value
+ }
+ assert.Equal(t, "90", attrMap["idle_timeout.timeout_seconds"], "same-key overwrite must replace the value")
+ assert.Equal(t, "true", attrMap["deletion_protection.enabled"], "unrelated key must still survive")
+}
+
+// TestModifyTargetGroupAttributes_NoopSkipsPersist verifies that a
+// re-submission of the same attribute values does not bump the underlying KV
+// revision — Terraform's drift check hits Modify on every apply, so the
+// steady-state path must be a no-op at the storage layer.
+func TestModifyTargetGroupAttributes_NoopSkipsPersist(t *testing.T) {
+ svc := setupTestService(t)
+ tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-noop")}, testAccountID)
+ require.NoError(t, err)
+ tgArn := tgOut.TargetGroups[0].TargetGroupArn
+
+ // First modify — must hit the store.
+ _, err = svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
+ TargetGroupArn: tgArn,
+ Attributes: []*elbv2.TargetGroupAttribute{
+ {Key: aws.String("stickiness.enabled"), Value: aws.String("true")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ // Capture KV revision after the first write.
+ tg, err := svc.store.GetTargetGroupByArn(*tgArn)
+ require.NoError(t, err)
+ entry, err := svc.store.kv.Get(KeyPrefixTG + tg.TargetGroupID)
+ require.NoError(t, err)
+ revBefore := entry.Revision()
+
+ // Second modify with identical values — must skip the Put.
+ modOut, err := svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
+ TargetGroupArn: tgArn,
+ Attributes: []*elbv2.TargetGroupAttribute{
+ {Key: aws.String("stickiness.enabled"), Value: aws.String("true")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, modOut.Attributes, 1)
+
+ entry, err = svc.store.kv.Get(KeyPrefixTG + tg.TargetGroupID)
+ require.NoError(t, err)
+ assert.Equal(t, revBefore, entry.Revision(), "identical modify must not increment KV revision")
+
+ // Empty attribute list is also a no-op.
+ _, err = svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
+ TargetGroupArn: tgArn,
+ }, testAccountID)
+ require.NoError(t, err)
+ entry, err = svc.store.kv.Get(KeyPrefixTG + tg.TargetGroupID)
+ require.NoError(t, err)
+ assert.Equal(t, revBefore, entry.Revision(), "empty modify must not increment KV revision")
+}
+
+// TestModifyLoadBalancerAttributes_NoopSkipsPersist mirrors the TG case.
+func TestModifyLoadBalancerAttributes_NoopSkipsPersist(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-noop")}, testAccountID)
+ require.NoError(t, err)
+ lbArn := lbOut.LoadBalancers[0].LoadBalancerArn
+
+ _, err = svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
+ LoadBalancerArn: lbArn,
+ Attributes: []*elbv2.LoadBalancerAttribute{
+ {Key: aws.String("idle_timeout.timeout_seconds"), Value: aws.String("75")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ lb, err := svc.store.GetLoadBalancerByArn(*lbArn)
+ require.NoError(t, err)
+ entry, err := svc.store.kv.Get(KeyPrefixLB + lb.LoadBalancerID)
+ require.NoError(t, err)
+ revBefore := entry.Revision()
+
+ _, err = svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
+ LoadBalancerArn: lbArn,
+ Attributes: []*elbv2.LoadBalancerAttribute{
+ {Key: aws.String("idle_timeout.timeout_seconds"), Value: aws.String("75")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ entry, err = svc.store.kv.Get(KeyPrefixLB + lb.LoadBalancerID)
+ require.NoError(t, err)
+ assert.Equal(t, revBefore, entry.Revision(), "identical modify must not increment KV revision")
+}
+
+// TestModifyTargetGroupAttributes_RejectsUnknownKey guards against silently
+// persisting typo'd or cross-product attribute keys. AWS rejects unknown keys
+// with ValidationError; we must match that so Terraform surfaces the typo at
+// plan time instead of letting it drift into KV forever.
+func TestModifyTargetGroupAttributes_RejectsUnknownKey(t *testing.T) {
+ svc := setupTestService(t)
+ tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-unknown-key")}, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
+ TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
+ Attributes: []*elbv2.TargetGroupAttribute{
+ {Key: aws.String("stickness.enabled"), Value: aws.String("true")}, // typo of "stickiness"
+ },
+ }, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorValidationError)
+
+ // The rejected key must not have been persisted.
+ descOut, err := svc.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{
+ TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
+ }, testAccountID)
+ require.NoError(t, err)
+ for _, a := range descOut.Attributes {
+ assert.NotEqual(t, "stickness.enabled", *a.Key, "unknown key must not appear in Describe")
+ }
+}
+
+// TestModifyLoadBalancerAttributes_RejectsUnknownKey mirrors the TG case.
+func TestModifyLoadBalancerAttributes_RejectsUnknownKey(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-unknown-key")}, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
+ LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
+ Attributes: []*elbv2.LoadBalancerAttribute{
+ // Valid TG attribute key sent to LB handler — common cross-product mistake.
+ {Key: aws.String("stickiness.enabled"), Value: aws.String("true")},
+ },
+ }, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorValidationError)
+}
+
+// TestDescribeTargetGroupAttributes_SortedOrder verifies that attributes are
+// returned in a stable, sorted-by-key order. Go map iteration is randomised,
+// so without explicit sorting Terraform would see spurious plan diffs between
+// back-to-back describe calls.
+func TestDescribeTargetGroupAttributes_SortedOrder(t *testing.T) {
+ svc := setupTestService(t)
+ tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-attr-sorted")}, testAccountID)
+ require.NoError(t, err)
+ arn := tgOut.TargetGroups[0].TargetGroupArn
+
+ // Modify a few attributes to ensure the merged map contains both defaults
+ // and overrides.
+ _, err = svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
+ TargetGroupArn: arn,
+ Attributes: []*elbv2.TargetGroupAttribute{
+ {Key: aws.String("stickiness.enabled"), Value: aws.String("true")},
+ {Key: aws.String("deregistration_delay.timeout_seconds"), Value: aws.String("45")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ // Call Describe multiple times; each result must be identical and sorted.
+ var firstKeys []string
+ for i := range 5 {
+ descOut, err := svc.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{
+ TargetGroupArn: arn,
+ }, testAccountID)
+ require.NoError(t, err)
+
+ keys := make([]string, len(descOut.Attributes))
+ for j, a := range descOut.Attributes {
+ keys[j] = *a.Key
+ }
+ assert.True(t, sort.StringsAreSorted(keys), "attributes must be sorted by key, got %v", keys)
+ if i == 0 {
+ firstKeys = keys
+ } else {
+ assert.Equal(t, firstKeys, keys, "attribute order must be stable across calls")
+ }
+ }
+}
+
+// TestDescribeLoadBalancerAttributes_SortedOrder mirrors the TG test: LB
+// describe responses must be sorted and stable across repeated calls.
+func TestDescribeLoadBalancerAttributes_SortedOrder(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-attr-sorted")}, testAccountID)
+ require.NoError(t, err)
+ arn := lbOut.LoadBalancers[0].LoadBalancerArn
+
+ _, err = svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
+ LoadBalancerArn: arn,
+ Attributes: []*elbv2.LoadBalancerAttribute{
+ {Key: aws.String("idle_timeout.timeout_seconds"), Value: aws.String("90")},
+ {Key: aws.String("deletion_protection.enabled"), Value: aws.String("true")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ var firstKeys []string
+ for i := range 5 {
+ descOut, err := svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{
+ LoadBalancerArn: arn,
+ }, testAccountID)
+ require.NoError(t, err)
+
+ keys := make([]string, len(descOut.Attributes))
+ for j, a := range descOut.Attributes {
+ keys[j] = *a.Key
+ }
+ assert.True(t, sort.StringsAreSorted(keys), "attributes must be sorted by key, got %v", keys)
+ if i == 0 {
+ firstKeys = keys
+ } else {
+ assert.Equal(t, firstKeys, keys, "attribute order must be stable across calls")
+ }
+ }
+}
+
+// --- DescribeTags tests ---
+
+func TestDescribeTags_LoadBalancer(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ Name: aws.String("tags-lb"),
+ Tags: []*elbv2.Tag{
+ {Key: aws.String("Env"), Value: aws.String("prod")},
+ {Key: aws.String("App"), Value: aws.String("nginx")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ out, err := svc.DescribeTags(&elbv2.DescribeTagsInput{
+ ResourceArns: []*string{lbOut.LoadBalancers[0].LoadBalancerArn},
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, out.TagDescriptions, 1)
+
+ td := out.TagDescriptions[0]
+ assert.Equal(t, *lbOut.LoadBalancers[0].LoadBalancerArn, *td.ResourceArn)
+ require.Len(t, td.Tags, 2)
+ // Sorted by key: App, Env
+ assert.Equal(t, "App", *td.Tags[0].Key)
+ assert.Equal(t, "nginx", *td.Tags[0].Value)
+ assert.Equal(t, "Env", *td.Tags[1].Key)
+ assert.Equal(t, "prod", *td.Tags[1].Value)
+}
+
+func TestDescribeTags_TargetGroup(t *testing.T) {
+ svc := setupTestService(t)
+ tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
+ Name: aws.String("tags-tg"),
+ Tags: []*elbv2.Tag{
+ {Key: aws.String("Owner"), Value: aws.String("team-a")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ out, err := svc.DescribeTags(&elbv2.DescribeTagsInput{
+ ResourceArns: []*string{tgOut.TargetGroups[0].TargetGroupArn},
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, out.TagDescriptions, 1)
+ require.Len(t, out.TagDescriptions[0].Tags, 1)
+ assert.Equal(t, "Owner", *out.TagDescriptions[0].Tags[0].Key)
+ assert.Equal(t, "team-a", *out.TagDescriptions[0].Tags[0].Value)
+}
+
+func TestDescribeTags_Listener_NoTags(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("tags-lst-lb")}, testAccountID)
+ tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tags-lst-tg")}, testAccountID)
+ lstOut, err := svc.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
+ Protocol: aws.String("HTTP"),
+ Port: aws.Int64(80),
+ DefaultActions: []*elbv2.Action{
+ {Type: aws.String("forward"), TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ // Listeners don't store tags yet — must still return a TagDescription
+ // (with an empty Tags slice), not an error. This is the case the
+ // Terraform AWS provider hits during post-create refresh.
+ out, err := svc.DescribeTags(&elbv2.DescribeTagsInput{
+ ResourceArns: []*string{lstOut.Listeners[0].ListenerArn},
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, out.TagDescriptions, 1)
+ assert.Equal(t, *lstOut.Listeners[0].ListenerArn, *out.TagDescriptions[0].ResourceArn)
+ assert.Empty(t, out.TagDescriptions[0].Tags)
+}
+
+func TestDescribeTags_MultipleArns(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ Name: aws.String("multi-lb"),
+ Tags: []*elbv2.Tag{{Key: aws.String("Name"), Value: aws.String("lb")}},
+ }, testAccountID)
+ tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
+ Name: aws.String("multi-tg"),
+ Tags: []*elbv2.Tag{{Key: aws.String("Name"), Value: aws.String("tg")}},
+ }, testAccountID)
+ lstOut, _ := svc.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
+ Protocol: aws.String("HTTP"),
+ Port: aws.Int64(80),
+ DefaultActions: []*elbv2.Action{
+ {Type: aws.String("forward"), TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn},
+ },
+ }, testAccountID)
+
+ out, err := svc.DescribeTags(&elbv2.DescribeTagsInput{
+ ResourceArns: []*string{
+ lbOut.LoadBalancers[0].LoadBalancerArn,
+ tgOut.TargetGroups[0].TargetGroupArn,
+ lstOut.Listeners[0].ListenerArn,
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, out.TagDescriptions, 3)
+
+ // Order matches input order
+ assert.Equal(t, *lbOut.LoadBalancers[0].LoadBalancerArn, *out.TagDescriptions[0].ResourceArn)
+ assert.Equal(t, *tgOut.TargetGroups[0].TargetGroupArn, *out.TagDescriptions[1].ResourceArn)
+ assert.Equal(t, *lstOut.Listeners[0].ListenerArn, *out.TagDescriptions[2].ResourceArn)
+
+ assert.Equal(t, "lb", *out.TagDescriptions[0].Tags[0].Value)
+ assert.Equal(t, "tg", *out.TagDescriptions[1].Tags[0].Value)
+ assert.Empty(t, out.TagDescriptions[2].Tags)
+}
+
+func TestDescribeTags_LoadBalancerNotFound(t *testing.T) {
+ svc := setupTestService(t)
+ _, err := svc.DescribeTags(&elbv2.DescribeTagsInput{
+ ResourceArns: []*string{
+ aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/missing/lb-deadbeef"),
+ },
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorELBv2LoadBalancerNotFound)
+}
+
+func TestDescribeTags_TargetGroupNotFound(t *testing.T) {
+ svc := setupTestService(t)
+ _, err := svc.DescribeTags(&elbv2.DescribeTagsInput{
+ ResourceArns: []*string{
+ aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/missing/tg-deadbeef"),
+ },
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorELBv2TargetGroupNotFound)
+}
+
+func TestDescribeTags_ListenerNotFound(t *testing.T) {
+ svc := setupTestService(t)
+ _, err := svc.DescribeTags(&elbv2.DescribeTagsInput{
+ ResourceArns: []*string{
+ aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/missing/lb-x/lst-deadbeef"),
+ },
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorELBv2ListenerNotFound)
+}
+
+func TestDescribeTags_CrossAccount(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ Name: aws.String("xa-lb"),
+ }, testAccountID)
+ require.NoError(t, err)
+
+ // Same ARN, different account => not-found (no existence leak)
+ _, err = svc.DescribeTags(&elbv2.DescribeTagsInput{
+ ResourceArns: []*string{lbOut.LoadBalancers[0].LoadBalancerArn},
+ }, "999999999999")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorELBv2LoadBalancerNotFound)
+}
+
+func TestDescribeTags_InvalidArn(t *testing.T) {
+ svc := setupTestService(t)
+ cases := []string{
+ "not-an-arn",
+ "arn:aws:s3:::my-bucket", // wrong service
+ "arn:aws:elasticloadbalancing:us-east-1:123:capacityreservation/abc", // unknown ELBv2 resource type
+ "arn:aws:elasticloadbalancing:us-east-1:123:loadbalancer", // missing slash
+ }
+ for _, arn := range cases {
+ _, err := svc.DescribeTags(&elbv2.DescribeTagsInput{
+ ResourceArns: []*string{aws.String(arn)},
+ }, testAccountID)
+ require.Error(t, err, "arn=%q should be rejected", arn)
+ assert.Contains(t, err.Error(), awserrors.ErrorInvalidParameterValue, "arn=%q", arn)
+ }
+}
+
+func TestDescribeTags_MissingParameter(t *testing.T) {
+ svc := setupTestService(t)
+
+ // Empty ResourceArns
+ _, err := svc.DescribeTags(&elbv2.DescribeTagsInput{}, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorMissingParameter)
+
+ // Nil entry inside a non-empty list
+ _, err = svc.DescribeTags(&elbv2.DescribeTagsInput{
+ ResourceArns: []*string{nil},
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorMissingParameter)
+}
+
+func TestDescribeTags_UntaggedLoadBalancer(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ Name: aws.String("untagged-lb"),
+ }, testAccountID)
+ require.NoError(t, err)
+
+ // Bare LB with no Tags input must still return a TagDescription with
+ // the ARN and an empty/nil Tags slice — exercises the exact path the
+ // failing nginx-alb terraform demo hits.
+ out, err := svc.DescribeTags(&elbv2.DescribeTagsInput{
+ ResourceArns: []*string{lbOut.LoadBalancers[0].LoadBalancerArn},
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, out.TagDescriptions, 1)
+ assert.Equal(t, *lbOut.LoadBalancers[0].LoadBalancerArn, *out.TagDescriptions[0].ResourceArn)
+ assert.Empty(t, out.TagDescriptions[0].Tags)
+}
diff --git a/spinifex/handlers/elbv2/service_nats.go b/spinifex/handlers/elbv2/service_nats.go
index af933a467..020488698 100644
--- a/spinifex/handlers/elbv2/service_nats.go
+++ b/spinifex/handlers/elbv2/service_nats.go
@@ -68,6 +68,26 @@ func (s *NATSELBv2Service) DescribeListeners(input *elbv2.DescribeListenersInput
return utils.NATSRequest[elbv2.DescribeListenersOutput](s.natsConn, "elbv2.DescribeListeners", input, defaultTimeout, accountID)
}
+func (s *NATSELBv2Service) DescribeTags(input *elbv2.DescribeTagsInput, accountID string) (*elbv2.DescribeTagsOutput, error) {
+ return utils.NATSRequest[elbv2.DescribeTagsOutput](s.natsConn, "elbv2.DescribeTags", input, defaultTimeout, accountID)
+}
+
+func (s *NATSELBv2Service) ModifyTargetGroupAttributes(input *elbv2.ModifyTargetGroupAttributesInput, accountID string) (*elbv2.ModifyTargetGroupAttributesOutput, error) {
+ return utils.NATSRequest[elbv2.ModifyTargetGroupAttributesOutput](s.natsConn, "elbv2.ModifyTargetGroupAttributes", input, defaultTimeout, accountID)
+}
+
+func (s *NATSELBv2Service) DescribeTargetGroupAttributes(input *elbv2.DescribeTargetGroupAttributesInput, accountID string) (*elbv2.DescribeTargetGroupAttributesOutput, error) {
+ return utils.NATSRequest[elbv2.DescribeTargetGroupAttributesOutput](s.natsConn, "elbv2.DescribeTargetGroupAttributes", input, defaultTimeout, accountID)
+}
+
+func (s *NATSELBv2Service) ModifyLoadBalancerAttributes(input *elbv2.ModifyLoadBalancerAttributesInput, accountID string) (*elbv2.ModifyLoadBalancerAttributesOutput, error) {
+ return utils.NATSRequest[elbv2.ModifyLoadBalancerAttributesOutput](s.natsConn, "elbv2.ModifyLoadBalancerAttributes", input, defaultTimeout, accountID)
+}
+
+func (s *NATSELBv2Service) DescribeLoadBalancerAttributes(input *elbv2.DescribeLoadBalancerAttributesInput, accountID string) (*elbv2.DescribeLoadBalancerAttributesOutput, error) {
+ return utils.NATSRequest[elbv2.DescribeLoadBalancerAttributesOutput](s.natsConn, "elbv2.DescribeLoadBalancerAttributes", input, defaultTimeout, accountID)
+}
+
func (s *NATSELBv2Service) LBAgentHeartbeat(input *LBAgentHeartbeatInput, accountID string) (*LBAgentHeartbeatOutput, error) {
return utils.NATSRequest[LBAgentHeartbeatOutput](s.natsConn, "elbv2.LBAgentHeartbeat", input, defaultTimeout, accountID)
}
diff --git a/spinifex/handlers/elbv2/store.go b/spinifex/handlers/elbv2/store.go
index c52d2fb29..e4f312330 100644
--- a/spinifex/handlers/elbv2/store.go
+++ b/spinifex/handlers/elbv2/store.go
@@ -89,18 +89,29 @@ func (s *Store) ListLoadBalancers() ([]*LoadBalancerRecord, error) {
return listByPrefix[LoadBalancerRecord](s.kv, KeyPrefixLB)
}
-// GetLoadBalancerByArn finds a load balancer by its ARN.
+// GetLoadBalancerByArn finds a load balancer by its ARN via a direct KV lookup
+// on the short ID embedded in the ARN's final path segment. Falls back to a
+// linear scan only if the ARN can't be parsed. Terraform hits Describe*Attributes
+// on every plan/refresh so this must be O(1), not O(n).
func (s *Store) GetLoadBalancerByArn(arn string) (*LoadBalancerRecord, error) {
- lbs, err := s.ListLoadBalancers()
+ // ELBv2 LB ARN: arn:aws:elasticloadbalancing:{region}:{account}:loadbalancer/{app,net}/{name}/{lbID}
+ idx := strings.LastIndex(arn, "/")
+ if idx < 0 || idx == len(arn)-1 {
+ return nil, nil
+ }
+ lbID := arn[idx+1:]
+ lb, err := s.GetLoadBalancer(lbID)
if err != nil {
return nil, err
}
- for _, lb := range lbs {
- if lb.LoadBalancerArn == arn {
- return lb, nil
- }
+ // Defence-in-depth: ensure the record actually belongs to this ARN.
+ // Short IDs are random hex, so collisions are effectively impossible, but
+ // a mismatch here would indicate KV corruption and must not be silently
+ // served as a successful lookup.
+ if lb == nil || lb.LoadBalancerArn != arn {
+ return nil, nil
}
- return nil, nil
+ return lb, nil
}
// GetLoadBalancerByName finds a load balancer by name within an account.
@@ -159,18 +170,25 @@ func (s *Store) ListTargetGroups() ([]*TargetGroupRecord, error) {
return listByPrefix[TargetGroupRecord](s.kv, KeyPrefixTG)
}
-// GetTargetGroupByArn finds a target group by its ARN.
+// GetTargetGroupByArn finds a target group by its ARN via a direct KV lookup
+// on the short ID embedded in the ARN's final path segment. See
+// GetLoadBalancerByArn for the motivation — Terraform's per-plan
+// DescribeTargetGroupAttributes storm must be O(1).
func (s *Store) GetTargetGroupByArn(arn string) (*TargetGroupRecord, error) {
- tgs, err := s.ListTargetGroups()
+ // ELBv2 TG ARN: arn:aws:elasticloadbalancing:{region}:{account}:targetgroup/{name}/{tgID}
+ idx := strings.LastIndex(arn, "/")
+ if idx < 0 || idx == len(arn)-1 {
+ return nil, nil
+ }
+ tgID := arn[idx+1:]
+ tg, err := s.GetTargetGroup(tgID)
if err != nil {
return nil, err
}
- for _, tg := range tgs {
- if tg.TargetGroupArn == arn {
- return tg, nil
- }
+ if tg == nil || tg.TargetGroupArn != arn {
+ return nil, nil
}
- return nil, nil
+ return tg, nil
}
// TargetGroupsForLB returns only the target groups attached to a load balancer
diff --git a/spinifex/handlers/elbv2/types.go b/spinifex/handlers/elbv2/types.go
index 069bcffd8..bf16bea9c 100644
--- a/spinifex/handlers/elbv2/types.go
+++ b/spinifex/handlers/elbv2/types.go
@@ -61,30 +61,30 @@ const (
// LoadBalancerRecord represents a stored load balancer (ALB or NLB).
type LoadBalancerRecord struct {
- LoadBalancerArn string `json:"load_balancer_arn"`
- LoadBalancerID string `json:"load_balancer_id"` // Short ID (hex suffix)
- DNSName string `json:"dns_name"`
- Name string `json:"name"`
- Scheme string `json:"scheme"` // "internet-facing" or "internal"
- Type string `json:"type"` // Always "application"
- State string `json:"state"` // "provisioning", "active", "failed"
- VpcId string `json:"vpc_id"`
- SecurityGroups []string `json:"security_groups"`
- Subnets []string `json:"subnets"`
- AvailZones []AvailZoneInfo `json:"availability_zones"`
- ENIs []string `json:"enis,omitempty"` // ENI IDs created for this ALB (internal)
- InstanceID string `json:"instance_id,omitempty"` // ALB VM instance ID (system-managed)
- VPCIP string `json:"vpc_ip,omitempty"` // VPC private IP of the ALB VM
- ConfigText string `json:"config_text,omitempty"` // Pre-computed HAProxy config
- ConfigHash string `json:"config_hash,omitempty"` // SHA256 of ConfigText
- LastHeartbeat time.Time `json:"last_heartbeat"` // Last agent heartbeat timestamp
- HostPorts map[int]int `json:"host_ports,omitempty"` // Dev mode: guest port → host port forwarding
- NodeID string `json:"node_id"` // Daemon node running this ALB
- IPAddressType string `json:"ip_address_type"` // "ipv4"
- CrossZoneEnabled bool `json:"cross_zone_enabled"` // Default false for NLB, true for ALB
- Tags map[string]string `json:"tags,omitempty"`
- AccountID string `json:"account_id"`
- CreatedAt time.Time `json:"created_at"`
+ LoadBalancerArn string `json:"load_balancer_arn"`
+ LoadBalancerID string `json:"load_balancer_id"` // Short ID (hex suffix)
+ DNSName string `json:"dns_name"`
+ Name string `json:"name"`
+ Scheme string `json:"scheme"` // "internet-facing" or "internal"
+ Type string `json:"type"` // Always "application"
+ State string `json:"state"` // "provisioning", "active", "failed"
+ VpcId string `json:"vpc_id"`
+ SecurityGroups []string `json:"security_groups"`
+ Subnets []string `json:"subnets"`
+ AvailZones []AvailZoneInfo `json:"availability_zones"`
+ ENIs []string `json:"enis,omitempty"` // ENI IDs created for this ALB (internal)
+ InstanceID string `json:"instance_id,omitempty"` // ALB VM instance ID (system-managed)
+ VPCIP string `json:"vpc_ip,omitempty"` // VPC private IP of the ALB VM
+ ConfigText string `json:"config_text,omitempty"` // Pre-computed HAProxy config
+ ConfigHash string `json:"config_hash,omitempty"` // SHA256 of ConfigText
+ LastHeartbeat time.Time `json:"last_heartbeat"` // Last agent heartbeat timestamp
+ HostPorts map[int]int `json:"host_ports,omitempty"` // Dev mode: guest port → host port forwarding
+ NodeID string `json:"node_id"` // Daemon node running this ALB
+ IPAddressType string `json:"ip_address_type"` // "ipv4"
+ Attributes map[string]string `json:"attributes,omitempty"`
+ Tags map[string]string `json:"tags,omitempty"`
+ AccountID string `json:"account_id"`
+ CreatedAt time.Time `json:"created_at"`
}
// AvailZoneInfo tracks subnet-to-AZ mapping for a load balancer.
@@ -105,6 +105,7 @@ type TargetGroupRecord struct {
TargetType string `json:"target_type"` // "instance" for v1
HealthCheck HealthCheckConfig `json:"health_check"`
Targets []Target `json:"targets"`
+ Attributes map[string]string `json:"attributes,omitempty"`
Tags map[string]string `json:"tags,omitempty"`
AccountID string `json:"account_id"`
CreatedAt time.Time `json:"created_at"`
@@ -175,3 +176,40 @@ type ListenerAction struct {
Type string `json:"type"` // "forward"
TargetGroupArn string `json:"target_group_arn"`
}
+
+// DefaultLoadBalancerAttributes returns the default attribute set for a load
+// balancer of the given type. ALBs default load_balancing.cross_zone.enabled to
+// "true"; NLBs (and any unknown type) default it to "false". This is the single
+// source of truth — CreateLoadBalancer no longer seeds attributes, and
+// DescribeLoadBalancerAttributes derives the per-type defaults from lb.Type on
+// read.
+func DefaultLoadBalancerAttributes(lbType string) map[string]string {
+ crossZone := "false"
+ if lbType == LoadBalancerTypeApplication {
+ crossZone = "true"
+ }
+ return map[string]string{
+ "deletion_protection.enabled": "false",
+ "load_balancing.cross_zone.enabled": crossZone,
+ "access_logs.s3.enabled": "false",
+ "access_logs.s3.bucket": "",
+ "access_logs.s3.prefix": "",
+ "idle_timeout.timeout_seconds": "60",
+ "routing.http.drop_invalid_header_fields.enabled": "false",
+ "routing.http2.enabled": "true",
+ "client_keep_alive.seconds": "3600",
+ }
+}
+
+// DefaultTargetGroupAttributes returns the default attribute set for target groups.
+func DefaultTargetGroupAttributes() map[string]string {
+ return map[string]string{
+ "deregistration_delay.timeout_seconds": "300",
+ "stickiness.enabled": "false",
+ "stickiness.type": "lb_cookie",
+ "stickiness.lb_cookie.duration_seconds": "86400",
+ "load_balancing.cross_zone.enabled": "use_load_balancer_configuration",
+ "load_balancing.algorithm.type": "round_robin",
+ "slow_start.duration_seconds": "0",
+ }
+}