diff --git a/spinifex/daemon/daemon.go b/spinifex/daemon/daemon.go index 369f1e1e8..16e2d0ed4 100644 --- a/spinifex/daemon/daemon.go +++ b/spinifex/daemon/daemon.go @@ -1806,6 +1806,12 @@ func (d *Daemon) startCluster() error { // describe-tags agrees with each resource's own describe from birth and // stops answering for it once it is deleted. d.vpcService.SetCentralTagStore(d.tagsService) + // Name interfaces as their instances are named, so the two agree. + region, internalDomain := d.config.Region, handlers_dns.ResolveInternalDomain(d.config) + d.vpcService.SetPrivateDNSNamer(func(privateIP string) string { + _, private := handlers_dns.EC2DNSNames(region, "", internalDomain, "", privateIP) + return private + }) d.routeTableService, err = initServiceWithRetry("RouteTable service", func() (*handlers_ec2_routetable.RouteTableServiceImpl, error) { return handlers_ec2_routetable.NewRouteTableServiceImplWithNATS(d.ctx, d.config, d.natsConn) diff --git a/spinifex/gateway/ec2.go b/spinifex/gateway/ec2.go index d57334a34..3cb16f668 100644 --- a/spinifex/gateway/ec2.go +++ b/spinifex/gateway/ec2.go @@ -109,8 +109,15 @@ func ec2Handler[In any](handler func(ctx context.Context, input *In, gw *Gateway // ec2ListsAsSet names the list fields AWS omits when empty instead of // rendering them. Each is rendered as its handler set it, so nil omits it. var ec2ListsAsSet = map[reflect.Type][]string{ - reflect.TypeFor[ec2.CreateKeyPairOutput](): {"Tags"}, - reflect.TypeFor[ec2.Image](): {"Tags", "ProductCodes"}, + reflect.TypeFor[ec2.CreateKeyPairOutput](): {"Tags"}, + reflect.TypeFor[ec2.Image](): {"Tags", "ProductCodes"}, + reflect.TypeFor[ec2.CreateSecurityGroupOutput](): {"Tags"}, + reflect.TypeFor[ec2.SecurityGroup](): {"Tags"}, + reflect.TypeFor[ec2.SecurityGroupRule](): {"Tags"}, + reflect.TypeFor[ec2.RevokeSecurityGroupIngressOutput](): {"UnknownIpPermissions"}, + reflect.TypeFor[ec2.RevokeSecurityGroupEgressOutput](): {"UnknownIpPermissions"}, + reflect.TypeFor[ec2.NetworkInterface](): {"Ipv4Prefixes", "Ipv6Prefixes"}, + reflect.TypeFor[ec2.Vpc](): {"Ipv6CidrBlockAssociationSet"}, } // marshalEC2Response renders an EC2 handler's output into the action's XML diff --git a/spinifex/gateway/ec2_emptyresult_test.go b/spinifex/gateway/ec2_emptyresult_test.go index d5ffef11b..02e9a77c0 100644 --- a/spinifex/gateway/ec2_emptyresult_test.go +++ b/spinifex/gateway/ec2_emptyresult_test.go @@ -136,3 +136,48 @@ func TestEC2_ListsAWSOmitsWhenEmpty(t *testing.T) { }) assert.Contains(t, tagged, "kv") } + +// The network lists AWS omits when empty: an untagged security group's and +// rule's tagSet, Revoke*'s unknownIpPermissionSet, an interface's prefix +// lists, and a described VPC's IPv6 association set. +func TestEC2_NetworkListsAWSOmitsWhenEmpty(t *testing.T) { + sg := render[ec2.CreateSecurityGroupInput](t, "CreateSecurityGroup", ec2.CreateSecurityGroupOutput{GroupId: aws.String("sg-1")}) + assert.NotContains(t, sg, "tagSet") + + groups := render[ec2.DescribeSecurityGroupsInput](t, "DescribeSecurityGroups", ec2.DescribeSecurityGroupsOutput{ + SecurityGroups: []*ec2.SecurityGroup{{GroupId: aws.String("sg-1")}}, + }) + assert.NotContains(t, groups, "tagSet") + assert.Contains(t, groups, "") + + auth := render[ec2.AuthorizeSecurityGroupIngressInput](t, "AuthorizeSecurityGroupIngress", ec2.AuthorizeSecurityGroupIngressOutput{ + SecurityGroupRules: []*ec2.SecurityGroupRule{{SecurityGroupRuleId: aws.String("sgr-1")}}, + }) + assert.NotContains(t, auth, "tagSet") + + rules := render[ec2.DescribeSecurityGroupRulesInput](t, "DescribeSecurityGroupRules", ec2.DescribeSecurityGroupRulesOutput{ + SecurityGroupRules: []*ec2.SecurityGroupRule{{SecurityGroupRuleId: aws.String("sgr-1"), Tags: []*ec2.Tag{}}}, + }) + assert.Contains(t, rules, "") + + revoke := render[ec2.RevokeSecurityGroupIngressInput](t, "RevokeSecurityGroupIngress", ec2.RevokeSecurityGroupIngressOutput{Return: aws.Bool(true)}) + assert.NotContains(t, revoke, "unknownIpPermissionSet") + revokeEgress := render[ec2.RevokeSecurityGroupEgressInput](t, "RevokeSecurityGroupEgress", ec2.RevokeSecurityGroupEgressOutput{Return: aws.Bool(true)}) + assert.NotContains(t, revokeEgress, "unknownIpPermissionSet") + + enis := render[ec2.DescribeNetworkInterfacesInput](t, "DescribeNetworkInterfaces", ec2.DescribeNetworkInterfacesOutput{ + NetworkInterfaces: []*ec2.NetworkInterface{{NetworkInterfaceId: aws.String("eni-1")}}, + }) + assert.NotContains(t, enis, "ipv4PrefixSet") + assert.NotContains(t, enis, "ipv6PrefixSet") + assert.Contains(t, enis, "") + + vpcs := render[ec2.DescribeVpcsInput](t, "DescribeVpcs", ec2.DescribeVpcsOutput{Vpcs: []*ec2.Vpc{{VpcId: aws.String("vpc-1")}}}) + assert.NotContains(t, vpcs, "ipv6CidrBlockAssociationSet") + + created := render[ec2.CreateVpcInput](t, "CreateVpc", ec2.CreateVpcOutput{Vpc: &ec2.Vpc{ + VpcId: aws.String("vpc-1"), + Ipv6CidrBlockAssociationSet: []*ec2.VpcIpv6CidrBlockAssociation{}, + }}) + assert.Contains(t, created, "") +} diff --git a/spinifex/handlers/ec2/routetable/service_impl.go b/spinifex/handlers/ec2/routetable/service_impl.go index f3e8ec39b..e52f37367 100644 --- a/spinifex/handlers/ec2/routetable/service_impl.go +++ b/spinifex/handlers/ec2/routetable/service_impl.go @@ -551,7 +551,8 @@ func (s *RouteTableServiceImpl) CreateRouteTable(ctx context.Context, input *ec2 } return &ec2.CreateRouteTableOutput{ - RouteTable: recordToEC2(record), + RouteTable: recordToEC2(record), + ClientToken: input.ClientToken, }, nil } diff --git a/spinifex/handlers/ec2/routetable/service_impl_test.go b/spinifex/handlers/ec2/routetable/service_impl_test.go index 1a33646e0..b6af1c132 100644 --- a/spinifex/handlers/ec2/routetable/service_impl_test.go +++ b/spinifex/handlers/ec2/routetable/service_impl_test.go @@ -103,9 +103,11 @@ func TestCreateRouteTable(t *testing.T) { t.Parallel() svc := setupTestService(t) out, err := svc.CreateRouteTable(t.Context(), &ec2.CreateRouteTableInput{ - VpcId: aws.String("vpc-test1"), + VpcId: aws.String("vpc-test1"), + ClientToken: aws.String("token-1"), }, testAccountID) require.NoError(t, err) + assert.Equal(t, "token-1", aws.StringValue(out.ClientToken), "AWS echoes the request's ClientToken") rtb := out.RouteTable assert.NotEmpty(t, *rtb.RouteTableId) diff --git a/spinifex/handlers/ec2/vpc/default_vpc.go b/spinifex/handlers/ec2/vpc/default_vpc.go index a5eaf060c..95a47b70c 100644 --- a/spinifex/handlers/ec2/vpc/default_vpc.go +++ b/spinifex/handlers/ec2/vpc/default_vpc.go @@ -270,7 +270,6 @@ func (s *VPCServiceImpl) buildDefaultVPC(ctx context.Context, accountID string, AZ: s.localAZ(), EnableDnsSupport: true, // AWS default EnableDnsHostnames: true, // AWS default for default VPC - Tags: map[string]string{"Name": "default"}, CreatedAt: time.Now(), }) if err != nil { diff --git a/spinifex/handlers/ec2/vpc/default_vpc_test.go b/spinifex/handlers/ec2/vpc/default_vpc_test.go index c698f4d0d..b3b4f30d4 100644 --- a/spinifex/handlers/ec2/vpc/default_vpc_test.go +++ b/spinifex/handlers/ec2/vpc/default_vpc_test.go @@ -270,3 +270,17 @@ func TestEnsureDefaultVPC_UsesBootstrapIDs(t *testing.T) { assert.Equal(t, ids.IgwId, info.InternetGatewayId) } } + +// AWS leaves the default VPC untagged. +func TestEnsureDefaultVPC_LeavesVPCUntagged(t *testing.T) { + t.Parallel() + svcs, _, _ := newNodeServices(t, 1) + + info, err := svcs[0].EnsureDefaultVPC("000000000001", handlers_ec2_vpc.BootstrapIDs{}) + require.NoError(t, err) + + out, err := svcs[0].DescribeVpcs(t.Context(), &ec2.DescribeVpcsInput{VpcIds: []*string{aws.String(info.VpcId)}}, "000000000001") + require.NoError(t, err) + require.Len(t, out.Vpcs, 1) + assert.Empty(t, out.Vpcs[0].Tags) +} diff --git a/spinifex/handlers/ec2/vpc/eni.go b/spinifex/handlers/ec2/vpc/eni.go index e45a5a164..768908c31 100644 --- a/spinifex/handlers/ec2/vpc/eni.go +++ b/spinifex/handlers/ec2/vpc/eni.go @@ -216,8 +216,10 @@ func (s *VPCServiceImpl) CreateNetworkInterface(ctx context.Context, input *ec2. return nil, err } + groupNames := make(map[string]string) + s.fillSGNames(ctx, accountID, record.SecurityGroupIds, groupNames) return &ec2.CreateNetworkInterfaceOutput{ - NetworkInterface: s.eniRecordToEC2(&record, accountID), + NetworkInterface: s.eniRecordToEC2(&record, accountID, groupNames), }, nil } @@ -542,6 +544,7 @@ func (s *VPCServiceImpl) DescribeNetworkInterfaces(ctx context.Context, input *e } enis := make([]*ec2.NetworkInterface, 0) + groupNames := make(map[string]string) eniIDs := make(map[string]bool) for _, id := range input.NetworkInterfaceIds { @@ -586,7 +589,8 @@ func (s *VPCServiceImpl) DescribeNetworkInterfaces(ctx context.Context, input *e continue } - enis = append(enis, s.eniRecordToEC2(&record, accountID)) + s.fillSGNames(ctx, accountID, record.SecurityGroupIds, groupNames) + enis = append(enis, s.eniRecordToEC2(&record, accountID, groupNames)) } // If specific ENI IDs were requested but not found, return error @@ -1008,8 +1012,32 @@ func (s *VPCServiceImpl) updateENIPublicIP(ctx context.Context, accountID, eniId return nil } -// eniRecordToEC2 converts an ENI record to an EC2 NetworkInterface. -func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string) *ec2.NetworkInterface { +// fillSGNames records in names the name of each group in ids it does not +// already hold. A group that cannot be read is recorded as "", so it is listed +// without a name and not looked up again. +func (s *VPCServiceImpl) fillSGNames(ctx context.Context, accountID string, ids []string, names map[string]string) { + for _, id := range ids { + if _, ok := names[id]; ok { + continue + } + names[id] = "" + entry, err := s.sgKV.Get(ctx, utils.AccountKey(accountID, id)) + if err != nil { + slog.WarnContext(ctx, "fillSGNames: SG read failed", "groupId", id, "err", err) + continue + } + var rec SecurityGroupRecord + if err := json.Unmarshal(entry.Value(), &rec); err != nil { + slog.WarnContext(ctx, "fillSGNames: SG unmarshal failed", "groupId", id, "err", err) + continue + } + names[id] = rec.GroupName + } +} + +// eniRecordToEC2 converts an ENI record to an EC2 NetworkInterface, naming +// each security group from groupNames. +func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string, groupNames map[string]string) *ec2.NetworkInterface { // ENIs with spinifex:managed-by tag are system-managed (e.g. by ELBv2) requesterManaged := record.Tags["spinifex:managed-by"] != "" @@ -1038,13 +1066,22 @@ func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string) *ec if len(record.SecurityGroupIds) > 0 { groups := make([]*ec2.GroupIdentifier, 0, len(record.SecurityGroupIds)) for _, sgId := range record.SecurityGroupIds { - groups = append(groups, &ec2.GroupIdentifier{ - GroupId: aws.String(sgId), - }) + group := &ec2.GroupIdentifier{GroupId: aws.String(sgId)} + if name := groupNames[sgId]; name != "" { + group.GroupName = aws.String(name) + } + groups = append(groups, group) } eni.Groups = groups } + if s.privateDNSName != nil && record.PrivateIpAddress != "" { + if name := s.privateDNSName(record.PrivateIpAddress); name != "" { + eni.PrivateDnsName = aws.String(name) + eni.PrivateIpAddresses[0].PrivateDnsName = aws.String(name) + } + } + if record.PublicIpAddress != "" { eni.Association = &ec2.NetworkInterfaceAssociation{ PublicIp: aws.String(record.PublicIpAddress), diff --git a/spinifex/handlers/ec2/vpc/eni_test.go b/spinifex/handlers/ec2/vpc/eni_test.go index 752874caf..0d5d7da6d 100644 --- a/spinifex/handlers/ec2/vpc/eni_test.go +++ b/spinifex/handlers/ec2/vpc/eni_test.go @@ -50,6 +50,46 @@ func TestCreateNetworkInterface(t *testing.T) { assert.NotEmpty(t, *eni.MacAddress) } +func TestNetworkInterface_NamesGroupsAndPrivateAddress(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + vpcId := createTestVPC(t, svc, "10.0.0.0/16") + subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24") + sgId := createTestSG(t, svc, vpcId, "web") + + // Without a namer an interface carries no private name. + unnamed := createTestENI(t, svc, subnetId) + desc, err := svc.DescribeNetworkInterfaces(context.Background(), &ec2.DescribeNetworkInterfacesInput{ + NetworkInterfaceIds: []*string{aws.String(unnamed)}, + }, testAccountID) + require.NoError(t, err) + require.Len(t, desc.NetworkInterfaces, 1) + assert.Nil(t, desc.NetworkInterfaces[0].PrivateDnsName) + + svc.SetPrivateDNSNamer(func(ip string) string { return "name-of-" + ip }) + out, err := svc.CreateNetworkInterface(context.Background(), &ec2.CreateNetworkInterfaceInput{ + SubnetId: aws.String(subnetId), + Groups: []*string{aws.String(sgId)}, + }, testAccountID) + require.NoError(t, err) + + desc, err = svc.DescribeNetworkInterfaces(context.Background(), &ec2.DescribeNetworkInterfacesInput{ + NetworkInterfaceIds: []*string{out.NetworkInterface.NetworkInterfaceId}, + }, testAccountID) + require.NoError(t, err) + require.Len(t, desc.NetworkInterfaces, 1) + + for name, eni := range map[string]*ec2.NetworkInterface{"create": out.NetworkInterface, "describe": desc.NetworkInterfaces[0]} { + want := "name-of-" + aws.StringValue(eni.PrivateIpAddress) + assert.Equal(t, want, aws.StringValue(eni.PrivateDnsName), name) + require.Len(t, eni.PrivateIpAddresses, 1, name) + assert.Equal(t, want, aws.StringValue(eni.PrivateIpAddresses[0].PrivateDnsName), name) + require.Len(t, eni.Groups, 1, name) + assert.Equal(t, sgId, aws.StringValue(eni.Groups[0].GroupId), name) + assert.Equal(t, "web", aws.StringValue(eni.Groups[0].GroupName), name) + } +} + func TestCreateNetworkInterface_SequentialIPs(t *testing.T) { t.Parallel() svc := setupTestVPCService(t) diff --git a/spinifex/handlers/ec2/vpc/security_group.go b/spinifex/handlers/ec2/vpc/security_group.go index 5926516f8..389f4d4e8 100644 --- a/spinifex/handlers/ec2/vpc/security_group.go +++ b/spinifex/handlers/ec2/vpc/security_group.go @@ -286,6 +286,7 @@ func (s *VPCServiceImpl) CreateSecurityGroup(ctx context.Context, input *ec2.Cre return &ec2.CreateSecurityGroupOutput{ GroupId: aws.String(groupId), + Tags: utils.MapToEC2Tags(record.Tags), }, nil } @@ -864,6 +865,13 @@ func (s *VPCServiceImpl) DescribeSecurityGroupRules(ctx context.Context, input * } rules, nextToken := paging.EC2Page(rules, func(r *ec2.SecurityGroupRule) string { return *r.SecurityGroupRuleId }, pageReq) + // AWS lists an untagged rule with empty Tags here, but omits them from + // the Authorize* responses that share the rule shape. + for _, r := range rules { + if r.Tags == nil { + r.Tags = []*ec2.Tag{} + } + } slog.InfoContext(ctx, "DescribeSecurityGroupRules completed", "count", len(rules), "accountID", accountID) @@ -982,8 +990,8 @@ func applySGRuleTags(rules []SGRule, specs []*ec2.TagSpecification) []SGRule { } // sgRuleToSecurityGroupRule flattens a stored SGRule into the AWS API shape. -// accountID supplies GroupOwnerId and ReferencedGroupInfo.UserId; VpcId is -// derived from the parent record (same-VPC references are enforced on write). +// accountID supplies GroupOwnerId and ReferencedGroupInfo.UserId. AWS names +// no VpcId for a same-VPC reference, the only kind Spinifex allows. func sgRuleToSecurityGroupRule(record *SecurityGroupRecord, rule SGRule, isEgress bool, accountID string) *ec2.SecurityGroupRule { // An all-protocol rule has no ports to report. AWS answers -1 for both; // reporting the stored zeroes offers a caller two ports it never sent. @@ -1014,7 +1022,6 @@ func sgRuleToSecurityGroupRule(record *SecurityGroupRecord, rule SGRule, isEgres out.ReferencedGroupInfo = &ec2.ReferencedSecurityGroup{ GroupId: aws.String(rule.SourceSG), UserId: aws.String(accountID), - VpcId: aws.String(record.VpcId), } } return out @@ -1841,8 +1848,8 @@ func (s *VPCServiceImpl) sgRecordToEC2(record *SecurityGroupRecord, accountID st Description: aws.String(record.Description), VpcId: aws.String(record.VpcId), OwnerId: aws.String(accountID), - IpPermissions: sgRulesToIpPermissions(record.IngressRules), - IpPermissionsEgress: sgRulesToIpPermissions(record.EgressRules), + IpPermissions: sgRulesToIpPermissions(record.IngressRules, accountID), + IpPermissionsEgress: sgRulesToIpPermissions(record.EgressRules, accountID), } sg.Tags = utils.MapToEC2Tags(record.Tags) @@ -1954,7 +1961,8 @@ func ipPermissionsToSGRules(perms []*ec2.IpPermission, mode sgParseMode) ([]SGRu } // sgRulesToIpPermissions converts SGRule slice to AWS IpPermission slice. -func sgRulesToIpPermissions(rules []SGRule) []*ec2.IpPermission { +// accountID owns every referenced group, since references stay in one VPC. +func sgRulesToIpPermissions(rules []SGRule, accountID string) []*ec2.IpPermission { // Group rules by protocol+port range type permKey struct { IpProtocol string @@ -1992,7 +2000,7 @@ func sgRulesToIpPermissions(rules []SGRule) []*ec2.IpPermission { perm.Ipv6Ranges = append(perm.Ipv6Ranges, ipRange) } if rule.SourceSG != "" { - pair := &ec2.UserIdGroupPair{GroupId: aws.String(rule.SourceSG)} + pair := &ec2.UserIdGroupPair{GroupId: aws.String(rule.SourceSG), UserId: aws.String(accountID)} if rule.Description != "" { pair.Description = aws.String(rule.Description) } diff --git a/spinifex/handlers/ec2/vpc/security_group_test.go b/spinifex/handlers/ec2/vpc/security_group_test.go index 8e05ad60b..7f6b81bd6 100644 --- a/spinifex/handlers/ec2/vpc/security_group_test.go +++ b/spinifex/handlers/ec2/vpc/security_group_test.go @@ -44,6 +44,7 @@ func TestCreateSecurityGroup_Success(t *testing.T) { }, testAccountID) require.NoError(t, err) assert.NotEmpty(t, *out.GroupId) + assert.Nil(t, out.Tags, "AWS omits Tags from an untagged CreateSecurityGroup") } func TestCreateSecurityGroup_MissingGroupName(t *testing.T) { @@ -930,7 +931,7 @@ func TestSGRulesToIpPermissions_IPv6RoundTrip(t *testing.T) { perms := sgRulesToIpPermissions([]SGRule{ {IpProtocol: "-1", CidrIp: "0.0.0.0/0"}, {IpProtocol: "-1", CidrIpv6: "::/0"}, - }) + }, testAccountID) require.Len(t, perms, 1) require.Len(t, perms[0].IpRanges, 1) require.Len(t, perms[0].Ipv6Ranges, 1) @@ -945,7 +946,7 @@ func TestSGRulesToIpPermissions_Conversion(t *testing.T) { {IpProtocol: "-1", SourceSG: "sg-other"}, } - perms := sgRulesToIpPermissions(rules) + perms := sgRulesToIpPermissions(rules, testAccountID) require.Len(t, perms, 2) // Order is non-deterministic (map-based), find each by protocol @@ -968,6 +969,7 @@ func TestSGRulesToIpPermissions_Conversion(t *testing.T) { assert.Nil(t, allPerm.ToPort) assert.Len(t, allPerm.UserIdGroupPairs, 1) assert.Equal(t, "sg-other", *allPerm.UserIdGroupPairs[0].GroupId) + assert.Equal(t, testAccountID, aws.StringValue(allPerm.UserIdGroupPairs[0].UserId)) } func TestSGRuleKey(t *testing.T) { @@ -1456,6 +1458,9 @@ func TestDescribeSecurityGroups_FilterByTag(t *testing.T) { }, }, testAccountID) require.NoError(t, err) + require.Len(t, out.Tags, 1, "CreateSecurityGroup echoes the requested tags") + assert.Equal(t, "Env", aws.StringValue(out.Tags[0].Key)) + assert.Equal(t, "prod", aws.StringValue(out.Tags[0].Value)) createTestSG(t, svc, vpcID, "untagged-sg") @@ -2309,7 +2314,7 @@ func TestDescribeSecurityGroupRules_ReferencedGroupInfo(t *testing.T) { dstSG := createTestSG(t, svc, vpcID, "dest") proto := "tcp" - _, err := svc.AuthorizeSecurityGroupIngress(context.Background(), &ec2.AuthorizeSecurityGroupIngressInput{ + authOut, err := svc.AuthorizeSecurityGroupIngress(context.Background(), &ec2.AuthorizeSecurityGroupIngressInput{ GroupId: aws.String(dstSG), IpPermissions: []*ec2.IpPermission{{ IpProtocol: &proto, @@ -2319,6 +2324,8 @@ func TestDescribeSecurityGroupRules_ReferencedGroupInfo(t *testing.T) { }}, }, testAccountID) require.NoError(t, err) + require.Len(t, authOut.SecurityGroupRules, 1) + assert.Nil(t, authOut.SecurityGroupRules[0].Tags, "Authorize* omits Tags on an untagged rule") out, err := svc.DescribeSecurityGroupRules(context.Background(), &ec2.DescribeSecurityGroupRulesInput{ Filters: []*ec2.Filter{{Name: aws.String("group-id"), Values: []*string{aws.String(dstSG)}}}, @@ -2330,11 +2337,12 @@ func TestDescribeSecurityGroupRules_ReferencedGroupInfo(t *testing.T) { if r.ReferencedGroupInfo != nil { require.NotNil(t, r.ReferencedGroupInfo.GroupId) require.NotNil(t, r.ReferencedGroupInfo.UserId) - require.NotNil(t, r.ReferencedGroupInfo.VpcId) assert.Equal(t, srcSG, *r.ReferencedGroupInfo.GroupId) assert.Equal(t, testAccountID, *r.ReferencedGroupInfo.UserId) - assert.Equal(t, vpcID, *r.ReferencedGroupInfo.VpcId) + assert.Nil(t, r.ReferencedGroupInfo.VpcId, "AWS names no VpcId for a same-VPC reference") assert.Nil(t, r.CidrIpv4) + assert.NotNil(t, r.Tags, "DescribeSecurityGroupRules lists an untagged rule with empty Tags") + assert.Empty(t, r.Tags) found = true } } diff --git a/spinifex/handlers/ec2/vpc/service_impl.go b/spinifex/handlers/ec2/vpc/service_impl.go index 0d9f82dd9..0225c6000 100644 --- a/spinifex/handlers/ec2/vpc/service_impl.go +++ b/spinifex/handlers/ec2/vpc/service_impl.go @@ -98,6 +98,10 @@ type VPCServiceImpl struct { // A nil value leaves both unchanged. centralTags CentralTagStore + // Optional: injected after construction to name interfaces' private + // addresses. Nil leaves them unnamed. + privateDNSName func(privateIP string) string + // disableDefaultPublicIP seeds default subnets with MapPublicIpOnLaunch=false // (non-pool external modes have no public IPs to assign). Zero value keeps // the AWS-faithful default of true. @@ -117,6 +121,13 @@ func (s *VPCServiceImpl) SetExternalIPAM(ipam *ExternalIPAM, eipKV jetstream.Key s.eipKV = eipKV } +// SetPrivateDNSNamer injects how a private address is named, so an interface +// reports the same PrivateDnsName as the instance using it. Nil, or a namer +// that answers "", leaves interfaces unnamed. +func (s *VPCServiceImpl) SetPrivateDNSNamer(namer func(privateIP string) string) { + s.privateDNSName = namer +} + // SetCentralTagStore injects the central tag store so create paths project // their record tags into it and delete paths clear them. Nil leaves both // unchanged. @@ -328,9 +339,11 @@ func (s *VPCServiceImpl) CreateVpc(ctx context.Context, input *ec2.CreateVpcInpu return nil, errors.New(awserrors.ErrorServerInternal) } - return &ec2.CreateVpcOutput{ - Vpc: s.vpcRecordToEC2(&record, accountID), - }, nil + // AWS renders the empty IPv6 association list on CreateVpc only; + // DescribeVpcs omits it. + vpc := s.vpcRecordToEC2(&record, accountID) + vpc.Ipv6CidrBlockAssociationSet = []*ec2.VpcIpv6CidrBlockAssociation{} + return &ec2.CreateVpcOutput{Vpc: vpc}, nil } // requireVPCExists returns InvalidVpcID.NotFound if the VPC doesn't exist for