diff --git a/spinifex/daemon/daemon.go b/spinifex/daemon/daemon.go index 0332a7539..90e8bbfda 100644 --- a/spinifex/daemon/daemon.go +++ b/spinifex/daemon/daemon.go @@ -492,6 +492,7 @@ func (d *Daemon) subscribeAll() error { natsSub{"ec2.AssociateAddress", d.handleEC2AssociateAddress, "spinifex-workers"}, natsSub{"ec2.DisassociateAddress", d.handleEC2DisassociateAddress, "spinifex-workers"}, natsSub{"ec2.DescribeAddresses", d.handleEC2DescribeAddresses, "spinifex-workers"}, + natsSub{"ec2.DescribeAddressesAttribute", d.handleEC2DescribeAddressesAttribute, "spinifex-workers"}, ) } diff --git a/spinifex/daemon/daemon_handlers_eip.go b/spinifex/daemon/daemon_handlers_eip.go index 27fa78732..262b72bf1 100644 --- a/spinifex/daemon/daemon_handlers_eip.go +++ b/spinifex/daemon/daemon_handlers_eip.go @@ -21,3 +21,7 @@ func (d *Daemon) handleEC2DisassociateAddress(msg *nats.Msg) { func (d *Daemon) handleEC2DescribeAddresses(msg *nats.Msg) { handleNATSRequest(msg, d.eipService.DescribeAddresses) } + +func (d *Daemon) handleEC2DescribeAddressesAttribute(msg *nats.Msg) { + handleNATSRequest(msg, d.eipService.DescribeAddressesAttribute) +} diff --git a/spinifex/gateway/ec2.go b/spinifex/gateway/ec2.go index 1eca2de2a..918a3c928 100644 --- a/spinifex/gateway/ec2.go +++ b/spinifex/gateway/ec2.go @@ -318,6 +318,9 @@ var ec2Actions = map[string]EC2Handler{ "DescribeAddresses": ec2Handler(func(input *ec2.DescribeAddressesInput, gw *GatewayConfig, accountID string) (any, error) { return gateway_ec2_eip.DescribeAddresses(input, gw.NATSConn, accountID) }), + "DescribeAddressesAttribute": ec2Handler(func(input *ec2.DescribeAddressesAttributeInput, gw *GatewayConfig, accountID string) (any, error) { + return gateway_ec2_eip.DescribeAddressesAttribute(input, gw.NATSConn, accountID) + }), "CreateNatGateway": ec2Handler(func(input *ec2.CreateNatGatewayInput, gw *GatewayConfig, accountID string) (any, error) { return gateway_ec2_natgw.CreateNatGateway(input, gw.NATSConn, accountID) }), diff --git a/spinifex/gateway/ec2/eip/DescribeAddressesAttribute.go b/spinifex/gateway/ec2/eip/DescribeAddressesAttribute.go new file mode 100644 index 000000000..c6e54afd4 --- /dev/null +++ b/spinifex/gateway/ec2/eip/DescribeAddressesAttribute.go @@ -0,0 +1,27 @@ +package gateway_ec2_eip + +import ( + "errors" + + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/spinifex/awserrors" + handlers_ec2_eip "github.com/mulgadc/spinifex/spinifex/handlers/ec2/eip" + "github.com/nats-io/nats.go" +) + +// DescribeAddressesAttribute handles the EC2 DescribeAddressesAttribute API call. +func DescribeAddressesAttribute(input *ec2.DescribeAddressesAttributeInput, natsConn *nats.Conn, accountID string) (ec2.DescribeAddressesAttributeOutput, error) { + var output ec2.DescribeAddressesAttributeOutput + + if input == nil { + return output, errors.New(awserrors.ErrorInvalidParameterValue) + } + + svc := handlers_ec2_eip.NewNATSEIPService(natsConn) + result, err := svc.DescribeAddressesAttribute(input, accountID) + if err != nil { + return output, err + } + + return *result, nil +} diff --git a/spinifex/gateway/ec2/eip/eip_test.go b/spinifex/gateway/ec2/eip/eip_test.go index c3f26fab1..70062507f 100644 --- a/spinifex/gateway/ec2/eip/eip_test.go +++ b/spinifex/gateway/ec2/eip/eip_test.go @@ -57,6 +57,18 @@ func TestDescribeAddresses_NilNATS(t *testing.T) { assert.Error(t, err) } +// DescribeAddressesAttribute tests + +func TestDescribeAddressesAttribute_NilInput(t *testing.T) { + _, err := DescribeAddressesAttribute(nil, nil, testAccountID) + assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue) +} + +func TestDescribeAddressesAttribute_NilNATS(t *testing.T) { + _, err := DescribeAddressesAttribute(&ec2.DescribeAddressesAttributeInput{}, nil, testAccountID) + assert.Error(t, err) +} + // DisassociateAddress tests func TestDisassociateAddress_NilInput(t *testing.T) { diff --git a/spinifex/gateway/gateway_test.go b/spinifex/gateway/gateway_test.go index b50d9afbf..65bacef5f 100644 --- a/spinifex/gateway/gateway_test.go +++ b/spinifex/gateway/gateway_test.go @@ -1029,7 +1029,7 @@ func TestEC2ActionMapCompleteness(t *testing.T) { "AuthorizeSecurityGroupIngress", "AuthorizeSecurityGroupEgress", "RevokeSecurityGroupIngress", "RevokeSecurityGroupEgress", "DescribeInstanceCreditSpecifications", - "AllocateAddress", "ReleaseAddress", "AssociateAddress", "DisassociateAddress", "DescribeAddresses", + "AllocateAddress", "ReleaseAddress", "AssociateAddress", "DisassociateAddress", "DescribeAddresses", "DescribeAddressesAttribute", "CreateRouteTable", "DeleteRouteTable", "DescribeRouteTables", "CreateRoute", "DeleteRoute", "ReplaceRoute", "AssociateRouteTable", "DisassociateRouteTable", "ReplaceRouteTableAssociation", diff --git a/spinifex/handlers/ec2/eip/service.go b/spinifex/handlers/ec2/eip/service.go index 109b60912..ebb586ffe 100644 --- a/spinifex/handlers/ec2/eip/service.go +++ b/spinifex/handlers/ec2/eip/service.go @@ -9,4 +9,5 @@ type EIPService interface { AssociateAddress(input *ec2.AssociateAddressInput, accountID string) (*ec2.AssociateAddressOutput, error) DisassociateAddress(input *ec2.DisassociateAddressInput, accountID string) (*ec2.DisassociateAddressOutput, error) DescribeAddresses(input *ec2.DescribeAddressesInput, accountID string) (*ec2.DescribeAddressesOutput, error) + DescribeAddressesAttribute(input *ec2.DescribeAddressesAttributeInput, accountID string) (*ec2.DescribeAddressesAttributeOutput, error) } diff --git a/spinifex/handlers/ec2/eip/service_impl.go b/spinifex/handlers/ec2/eip/service_impl.go index dcdff586b..05862b121 100644 --- a/spinifex/handlers/ec2/eip/service_impl.go +++ b/spinifex/handlers/ec2/eip/service_impl.go @@ -368,6 +368,72 @@ func (s *EIPServiceImpl) DescribeAddresses(input *ec2.DescribeAddressesInput, ac }, nil } +// DescribeAddressesAttribute returns per-EIP attributes (e.g. domain-name). +// Spinifex doesn't support reverse-DNS PTR records, so PtrRecord is left nil. +// Unlike DescribeAddresses, this returns an empty list (not an error) when +// requested AllocationIds are not found. This matches real AWS behavior. +func (s *EIPServiceImpl) DescribeAddressesAttribute(input *ec2.DescribeAddressesAttributeInput, accountID string) (*ec2.DescribeAddressesAttributeOutput, error) { + var addresses []*ec2.AddressAttribute + + if len(input.AllocationIds) > 0 { + // Direct lookups — O(n) on requested IDs rather than scanning all EIPs. + for _, id := range input.AllocationIds { + if id == nil { + continue + } + key := utils.AccountKey(accountID, *id) + entry, err := s.eipKV.Get(key) + if err != nil { + continue // not found, skip + } + var record EIPRecord + if err := json.Unmarshal(entry.Value(), &record); err != nil { + slog.Warn("Failed to unmarshal EIP record", "key", key, "error", err) + continue + } + addresses = append(addresses, &ec2.AddressAttribute{ + AllocationId: aws.String(record.AllocationId), + PublicIp: aws.String(record.PublicIp), + }) + } + } else { + // No filter — scan all EIPs for this account. + prefix := accountID + "." + keys, err := s.eipKV.Keys() + if err != nil && !errors.Is(err, nats.ErrNoKeysFound) { + return nil, errors.New(awserrors.ErrorServerInternal) + } + for _, k := range keys { + if k == utils.VersionKey { + continue + } + if !strings.HasPrefix(k, prefix) { + continue + } + entry, err := s.eipKV.Get(k) + if err != nil { + slog.Warn("Failed to get EIP record", "key", k, "error", err) + continue + } + var record EIPRecord + if err := json.Unmarshal(entry.Value(), &record); err != nil { + slog.Warn("Failed to unmarshal EIP record", "key", k, "error", err) + continue + } + addresses = append(addresses, &ec2.AddressAttribute{ + AllocationId: aws.String(record.AllocationId), + PublicIp: aws.String(record.PublicIp), + }) + } + } + + slog.Info("DescribeAddressesAttribute completed", "count", len(addresses), "accountID", accountID) + + return &ec2.DescribeAddressesAttributeOutput{ + Addresses: addresses, + }, nil +} + // lookupENI retrieves an ENI record by its ID using the VPC service. func (s *EIPServiceImpl) lookupENI(accountID, eniID string) (*handlers_ec2_vpc.ENIRecord, error) { output, err := s.vpcService.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{ diff --git a/spinifex/handlers/ec2/eip/service_impl_test.go b/spinifex/handlers/ec2/eip/service_impl_test.go index 8671b91a4..69012f2e0 100644 --- a/spinifex/handlers/ec2/eip/service_impl_test.go +++ b/spinifex/handlers/ec2/eip/service_impl_test.go @@ -293,6 +293,46 @@ func TestEIP_FindByAssociationID_NotFound(t *testing.T) { assert.Contains(t, err.Error(), "InvalidAssociationID.NotFound") } +func TestEIP_DescribeAddressesAttribute(t *testing.T) { + svc, _ := setupTestEIP(t) + + // Allocate multiple EIPs + out1, err := svc.AllocateAddress(&ec2.AllocateAddressInput{}, testAccountID) + require.NoError(t, err) + out2, err := svc.AllocateAddress(&ec2.AllocateAddressInput{}, testAccountID) + require.NoError(t, err) + + // Describe all — should return both + desc, err := svc.DescribeAddressesAttribute(&ec2.DescribeAddressesAttributeInput{}, testAccountID) + require.NoError(t, err) + assert.Len(t, desc.Addresses, 2) + + // Each entry should have AllocationId and PublicIp populated + for _, addr := range desc.Addresses { + assert.NotNil(t, addr.AllocationId) + assert.NotNil(t, addr.PublicIp) + assert.Nil(t, addr.PtrRecord) // no reverse-DNS support + } + + // Filter by specific allocation ID + desc2, err := svc.DescribeAddressesAttribute(&ec2.DescribeAddressesAttributeInput{ + AllocationIds: []*string{out1.AllocationId}, + }, testAccountID) + require.NoError(t, err) + assert.Len(t, desc2.Addresses, 1) + assert.Equal(t, *out1.AllocationId, *desc2.Addresses[0].AllocationId) + assert.Equal(t, *out1.PublicIp, *desc2.Addresses[0].PublicIp) + + // Filter by unknown allocation ID — returns empty, not error + desc3, err := svc.DescribeAddressesAttribute(&ec2.DescribeAddressesAttributeInput{ + AllocationIds: []*string{aws.String("eipalloc-nonexistent")}, + }, testAccountID) + require.NoError(t, err) + assert.Empty(t, desc3.Addresses) + + _ = out2 +} + func TestEIP_DescribeAddresses(t *testing.T) { svc, _ := setupTestEIP(t) diff --git a/spinifex/handlers/ec2/eip/service_nats.go b/spinifex/handlers/ec2/eip/service_nats.go index 4ca3d41cd..017c3035b 100644 --- a/spinifex/handlers/ec2/eip/service_nats.go +++ b/spinifex/handlers/ec2/eip/service_nats.go @@ -37,3 +37,7 @@ func (s *NATSEIPService) DisassociateAddress(input *ec2.DisassociateAddressInput func (s *NATSEIPService) DescribeAddresses(input *ec2.DescribeAddressesInput, accountID string) (*ec2.DescribeAddressesOutput, error) { return utils.NATSRequest[ec2.DescribeAddressesOutput](s.natsConn, "ec2.DescribeAddresses", input, 30*time.Second, accountID) } + +func (s *NATSEIPService) DescribeAddressesAttribute(input *ec2.DescribeAddressesAttributeInput, accountID string) (*ec2.DescribeAddressesAttributeOutput, error) { + return utils.NATSRequest[ec2.DescribeAddressesAttributeOutput](s.natsConn, "ec2.DescribeAddressesAttribute", input, 30*time.Second, accountID) +}