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", + } +}