awsinv: fix catch-all double-counting, nil-output panics, Route53 pagination
Three review findings, all ruled to fix: - Inventory ran the per-region catch-all before the global S3/Route53/IAM mappings, so a bucket, hosted zone, or IAM role surfaced through a region's tagging call was emitted twice: once typed, once as KindOther. Global mappings now run first. Route53 also needed seeding the bare trailing id component into seen, since a zone's ID (/hostedzone/Z1) doesn't match the catch-all's parsed ARN id (Z1). - None of the six new rest.go mappings guarded a nil-but-error-free output, unlike ec2.go's else-if-o-!=-nil pattern. The fakes this task itself added return exactly that shape for an unset field, so a future test written the obvious way would panic. Mirrored ec2.go's guard everywhere, including the per-bucket loc/versioning/PAB reads and catchAll's page. - Route53 record pagination resumed on StartRecordName alone; the API needs StartRecordType (and StartRecordIdentifier) to resume at the exact record, so a page boundary within one name double-counted records, or never advanced when a single name filled a whole page. Now carries the full cursor triple and bails if the next cursor is nil or unchanged. Regression tests: extended TestCatchAllDedupesAgainstTypedResources to wire S3/Route53/IAM (the prior version only covered EC2, which is why the double-count slipped through), and added TestRoute53PaginationStopsWhenCursorDoesNotAdvance.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 files changed,
+192
−32
internal/awsinv/awsinv.go+37 −19internal/awsinv/awsinv_test.go+44 −1internal/awsinv/rest.go+50 −12internal/awsinv/rest_test.go+61 −0
diff --git a/internal/awsinv/awsinv.go b/internal/awsinv/awsinv.go| index 1cbe61ae3..1eaa3c2ed 100644 |
| --- a/internal/awsinv/awsinv.go |
| +++ b/internal/awsinv/awsinv.go |
| @@ -80,8 +80,17 @@ func newWithFactory(f Factory, home string, regions []string, account string) *P |
| return &Provider{factory: f, home: home, regions: regions, account: account, now: time.Now} |
| } |
| -// Inventory walks every region's typed mappings, then the catch-all, then |
| -// the global services once from the home region. |
| +// Inventory walks the global services first, then every region's typed |
| +// mappings and catch-all. |
| +// |
| +// The global-first ordering matters: the tagging catch-all "only ever |
| +// ADDS" (never duplicates a typed resource), and it runs once per region — |
| +// including the bucket's own region for an S3 bucket, and whichever region |
| +// happens to be home for a hosted zone or an IAM role, since those are |
| +// global services surfaced through a regional tagging call. If the catch- |
| +// all for that region ran before the global mappings had populated seen, |
| +// the resource would be double-counted: once under its typed kind, once |
| +// again as KindOther. Running S3/Route53/IAM first closes that gap. |
| // |
| // The error contract: an error means "this deployment cannot inventory |
| // itself at all". Anything less — a throttled call, one dead region, a |
| @@ -99,6 +108,15 @@ func (p *Provider) Inventory(ctx context.Context) (infraview.Inventory, error) { |
| add := func(rs []infraview.Resource) { |
| for _, r := range rs { |
| seen[r.ID] = true |
| + // The catch-all derives its dedup key from an ARN's trailing |
| + // path component (parseARN), while a typed resource's ID is not |
| + // always that bare component — a Route53 zone's ID is |
| + // "/hostedzone/Z1", not "Z1". Seeding the bare trailing |
| + // component too is what lets the catch-all's parsed id match a |
| + // typed ID that carries a path prefix. |
| + if i := strings.LastIndex(r.ID, "/"); i >= 0 { |
| + seen[r.ID[i+1:]] = true |
| + } |
| inv.Resources = append(inv.Resources, r) |
| } |
| } |
| @@ -107,6 +125,23 @@ func (p *Provider) Inventory(ctx context.Context) (infraview.Inventory, error) { |
| // factory legitimately wires up only the service under test — a nil |
| // client here means "not exercised", not "denied", so it is skipped |
| // rather than dereferenced. |
| + global := p.factory(p.home) |
| + if global.S3 != nil { |
| + rs, partial := s3Resources(ctx, global.S3) |
| + add(rs) |
| + note(partial) |
| + } |
| + if global.Route53 != nil { |
| + rs, partial := route53Resources(ctx, global.Route53) |
| + add(rs) |
| + note(partial) |
| + } |
| + if global.IAM != nil { |
| + rs, partial := iamResources(ctx, global.IAM) |
| + add(rs) |
| + note(partial) |
| + } |
| + |
| for _, region := range p.regions { |
| c := p.factory(region) |
| if c.EC2 != nil { |
| @@ -131,23 +166,6 @@ func (p *Provider) Inventory(ctx context.Context) (infraview.Inventory, error) { |
| } |
| } |
| - global := p.factory(p.home) |
| - if global.S3 != nil { |
| - rs, partial := s3Resources(ctx, global.S3) |
| - add(rs) |
| - note(partial) |
| - } |
| - if global.Route53 != nil { |
| - rs, partial := route53Resources(ctx, global.Route53) |
| - add(rs) |
| - note(partial) |
| - } |
| - if global.IAM != nil { |
| - rs, partial := iamResources(ctx, global.IAM) |
| - add(rs) |
| - note(partial) |
| - } |
| - |
| for _, line := range inv.Partial { |
| if looksDenied(line) { |
| authDenied = true |
diff --git a/internal/awsinv/awsinv_test.go b/internal/awsinv/awsinv_test.go| index 2dcb263ae..a6bcc8fca 100644 |
| --- a/internal/awsinv/awsinv_test.go |
| +++ b/internal/awsinv/awsinv_test.go |
| @@ -8,8 +8,14 @@ import ( |
| "github.com/aws/aws-sdk-go-v2/aws" |
| "github.com/aws/aws-sdk-go-v2/service/ec2" |
| ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" |
| + "github.com/aws/aws-sdk-go-v2/service/iam" |
| + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" |
| "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" |
| rgtypes "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi/types" |
| + "github.com/aws/aws-sdk-go-v2/service/route53" |
| + r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" |
| + "github.com/aws/aws-sdk-go-v2/service/s3" |
| + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" |
| "github.com/carlosframework/platform/internal/infraview" |
| ) |
| @@ -24,7 +30,13 @@ func (f *fakeTagging) GetResources(context.Context, *resourcegroupstaggingapi.Ge |
| // A resource the typed mappings already produced must NOT appear twice |
| // because the catch-all also saw it; anything the catch-all alone knows |
| -// about must appear as "other" rather than vanishing. |
| +// about must appear as "other" rather than vanishing. This covers all |
| +// three global services (S3, Route53, IAM) as well as EC2, because the |
| +// tagging catch-all runs once per region and a global service's resources |
| +// surface through whichever region happens to be home for it — a bucket |
| +// in its own region, a hosted zone or an IAM role through us-east-1 — so a |
| +// test that wires only EC2 would never exercise the global/regional |
| +// ordering the catch-all's dedup depends on. |
| func TestCatchAllDedupesAgainstTypedResources(t *testing.T) { |
| factory := func(region string) Clients { |
| return Clients{ |
| @@ -33,10 +45,29 @@ func TestCatchAllDedupesAgainstTypedResources(t *testing.T) { |
| InstanceId: aws.String("i-123"), |
| }}}}, |
| }}, |
| + S3: &fakeS3{ |
| + buckets: &s3.ListBucketsOutput{Buckets: []s3types.Bucket{{Name: aws.String("carlos-data-ie")}}}, |
| + }, |
| + Route53: &fakeRoute53{ |
| + zones: &route53.ListHostedZonesOutput{HostedZones: []r53types.HostedZone{ |
| + {Id: aws.String("/hostedzone/Z1"), Name: aws.String("carlosframework.com.")}, |
| + }}, |
| + recs: &route53.ListResourceRecordSetsOutput{}, |
| + }, |
| + IAM: &fakeIAM{ |
| + roles: &iam.ListRolesOutput{Roles: []iamtypes.Role{ |
| + {RoleName: aws.String("region-host"), Arn: aws.String("arn:aws:iam::1234:role/region-host")}, |
| + }}, |
| + users: &iam.ListUsersOutput{}, |
| + attached: &iam.ListAttachedRolePoliciesOutput{}, |
| + }, |
| Tagging: &fakeTagging{out: &resourcegroupstaggingapi.GetResourcesOutput{ |
| ResourceTagMappingList: []rgtypes.ResourceTagMapping{ |
| {ResourceARN: aws.String("arn:aws:ec2:eu-west-1:1234:instance/i-123")}, |
| {ResourceARN: aws.String("arn:aws:sqs:eu-west-1:1234:some-queue")}, |
| + {ResourceARN: aws.String("arn:aws:s3:::carlos-data-ie")}, |
| + {ResourceARN: aws.String("arn:aws:route53:::hostedzone/Z1")}, |
| + {ResourceARN: aws.String("arn:aws:iam::1234:role/region-host")}, |
| }, |
| }}, |
| } |
| @@ -51,6 +82,18 @@ func TestCatchAllDedupesAgainstTypedResources(t *testing.T) { |
| if len(machines) != 1 { |
| t.Fatalf("machines = %d, want 1 (the catch-all must not duplicate a typed resource)", len(machines)) |
| } |
| + buckets := inv.ByKind(infraview.KindBucket) |
| + if len(buckets) != 1 { |
| + t.Fatalf("buckets = %d, want 1 (the catch-all must not duplicate the S3 mapping)", len(buckets)) |
| + } |
| + zones := inv.ByKind(infraview.KindDNSZone) |
| + if len(zones) != 1 { |
| + t.Fatalf("zones = %d, want 1 (the catch-all must not duplicate the Route53 mapping)", len(zones)) |
| + } |
| + identities := inv.ByKind(infraview.KindIdentity) |
| + if len(identities) != 1 { |
| + t.Fatalf("identities = %d, want 1 (the catch-all must not duplicate the IAM mapping)", len(identities)) |
| + } |
| others := inv.ByKind(infraview.KindOther) |
| if len(others) != 1 || others[0].NativeType != "sqs" { |
| t.Fatalf("others = %+v, want one sqs resource", others) |
diff --git a/internal/awsinv/rest.go b/internal/awsinv/rest.go| index 70ccef1fe..2db750dbd 100644 |
| --- a/internal/awsinv/rest.go |
| +++ b/internal/awsinv/rest.go |
| @@ -12,6 +12,7 @@ import ( |
| "github.com/aws/aws-sdk-go-v2/service/iam" |
| "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" |
| "github.com/aws/aws-sdk-go-v2/service/route53" |
| + r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" |
| "github.com/aws/aws-sdk-go-v2/service/s3" |
| "github.com/carlosframework/platform/internal/infraview" |
| ) |
| @@ -27,26 +28,29 @@ func s3Resources(ctx context.Context, api S3API) (out []infraview.Resource, part |
| if err != nil { |
| return nil, []string{fmt.Sprintf("buckets: %v", err)} |
| } |
| + if o == nil { |
| + return nil, partial |
| + } |
| for _, b := range o.Buckets { |
| name := aws.ToString(b.Name) |
| r := res(infraview.KindBucket, "s3:bucket", name, name, "") |
| - if loc, err := api.GetBucketLocation(ctx, &s3.GetBucketLocationInput{Bucket: b.Name}); err == nil { |
| + // Each of the four posture reads below is best-effort per bucket: a |
| + // bucket whose versioning cannot be read still appears, with the |
| + // attribute absent rather than a false "off". "off" is a claim. |
| + if loc, err := api.GetBucketLocation(ctx, &s3.GetBucketLocationInput{Bucket: b.Name}); err == nil && loc != nil { |
| region := string(loc.LocationConstraint) |
| if region == "" { |
| region = "us-east-1" // the API's documented empty-means-us-east-1 |
| } |
| r.Region = region |
| } |
| - // Each of the three posture reads is best-effort per bucket: a |
| - // bucket whose versioning cannot be read still appears, with the |
| - // attribute absent rather than a false "off". "off" is a claim. |
| - if v, err := api.GetBucketVersioning(ctx, &s3.GetBucketVersioningInput{Bucket: b.Name}); err == nil { |
| + if v, err := api.GetBucketVersioning(ctx, &s3.GetBucketVersioningInput{Bucket: b.Name}); err == nil && v != nil { |
| set(&r, "versioning", strings.ToLower(string(v.Status))) |
| } |
| if _, err := api.GetBucketEncryption(ctx, &s3.GetBucketEncryptionInput{Bucket: b.Name}); err == nil { |
| set(&r, "encryption", "on") |
| } |
| - if p, err := api.GetPublicAccessBlock(ctx, &s3.GetPublicAccessBlockInput{Bucket: b.Name}); err == nil && p.PublicAccessBlockConfiguration != nil { |
| + if p, err := api.GetPublicAccessBlock(ctx, &s3.GetPublicAccessBlockInput{Bucket: b.Name}); err == nil && p != nil && p.PublicAccessBlockConfiguration != nil { |
| c := p.PublicAccessBlockConfiguration |
| if aws.ToBool(c.BlockPublicAcls) && aws.ToBool(c.BlockPublicPolicy) { |
| set(&r, "public_access_block", "on") |
| @@ -64,6 +68,9 @@ func route53Resources(ctx context.Context, api Route53API) (out []infraview.Reso |
| if err != nil { |
| return nil, []string{fmt.Sprintf("hosted zones: %v", err)} |
| } |
| + if zones == nil { |
| + return nil, partial |
| + } |
| for _, z := range zones.HostedZones { |
| id := aws.ToString(z.Id) |
| name := strings.TrimSuffix(aws.ToString(z.Name), ".") |
| @@ -72,15 +79,25 @@ func route53Resources(ctx context.Context, api Route53API) (out []infraview.Reso |
| set(&zr, "private", "yes") |
| } |
| count := 0 |
| - var next *string |
| + // Route53 needs the full StartRecordName+StartRecordType+ |
| + // StartRecordIdentifier triple to resume at the exact record; a |
| + // name-only cursor restarts each page at the FIRST record carrying |
| + // that name, which both double-counts records at the page boundary |
| + // and, when a single name owns a whole page, never advances at all. |
| + var nextName, nextIdentifier *string |
| + var nextType r53types.RRType |
| for { |
| recs, err := api.ListResourceRecordSets(ctx, &route53.ListResourceRecordSetsInput{ |
| - HostedZoneId: z.Id, StartRecordName: next, |
| + HostedZoneId: z.Id, StartRecordName: nextName, |
| + StartRecordType: nextType, StartRecordIdentifier: nextIdentifier, |
| }) |
| if err != nil { |
| partial = append(partial, fmt.Sprintf("records in %s: %v", name, err)) |
| break |
| } |
| + if recs == nil { |
| + break |
| + } |
| for _, rec := range recs.ResourceRecordSets { |
| count++ |
| rtype := string(rec.Type) |
| @@ -106,7 +123,19 @@ func route53Resources(ctx context.Context, api Route53API) (out []infraview.Reso |
| if !recs.IsTruncated { |
| break |
| } |
| - next = recs.NextRecordName |
| + // A nil next-name, or one that (together with the type) is |
| + // identical to what we just asked for, means the cursor did not |
| + // advance. Trusting IsTruncated forever here would spin an |
| + // unbounded loop against a live API on an hourly pass, so stop |
| + // and name the shortfall rather than loop or restart the zone. |
| + if recs.NextRecordName == nil || |
| + (aws.ToString(recs.NextRecordName) == aws.ToString(nextName) && recs.NextRecordType == nextType) { |
| + partial = append(partial, fmt.Sprintf("records in %s: pagination cursor did not advance, stopped short", name)) |
| + break |
| + } |
| + nextName = recs.NextRecordName |
| + nextType = recs.NextRecordType |
| + nextIdentifier = recs.NextRecordIdentifier |
| } |
| set(&zr, "record_count", strconv.Itoa(count)) |
| out = append(out, zr) |
| @@ -120,12 +149,12 @@ func route53Resources(ctx context.Context, api Route53API) (out []infraview.Reso |
| func iamResources(ctx context.Context, api IAMAPI) (out []infraview.Resource, partial []string) { |
| if o, err := api.ListRoles(ctx, &iam.ListRolesInput{}); err != nil { |
| partial = append(partial, fmt.Sprintf("IAM roles: %v", err)) |
| - } else { |
| + } else if o != nil { |
| for _, role := range o.Roles { |
| name := aws.ToString(role.RoleName) |
| r := res(infraview.KindIdentity, "iam:role", aws.ToString(role.Arn), name, "") |
| set(&r, "kind", "role") |
| - if pol, err := api.ListAttachedRolePolicies(ctx, &iam.ListAttachedRolePoliciesInput{RoleName: role.RoleName}); err == nil { |
| + if pol, err := api.ListAttachedRolePolicies(ctx, &iam.ListAttachedRolePoliciesInput{RoleName: role.RoleName}); err == nil && pol != nil { |
| set(&r, "attached_policies", strconv.Itoa(len(pol.AttachedPolicies))) |
| } |
| out = append(out, r) |
| @@ -133,7 +162,7 @@ func iamResources(ctx context.Context, api IAMAPI) (out []infraview.Resource, pa |
| } |
| if o, err := api.ListUsers(ctx, &iam.ListUsersInput{}); err != nil { |
| partial = append(partial, fmt.Sprintf("IAM users: %v", err)) |
| - } else { |
| + } else if o != nil { |
| for _, u := range o.Users { |
| r := res(infraview.KindIdentity, "iam:user", aws.ToString(u.Arn), aws.ToString(u.UserName), "") |
| set(&r, "kind", "user") |
| @@ -148,6 +177,9 @@ func alarmResources(ctx context.Context, api CloudWatchAPI, region string) (out |
| if err != nil { |
| return nil, []string{fmt.Sprintf("alarms in %s: %v", region, err)} |
| } |
| + if o == nil { |
| + return nil, partial |
| + } |
| for _, a := range o.MetricAlarms { |
| r := res(infraview.KindAlarm, "cloudwatch:alarm", aws.ToString(a.AlarmArn), aws.ToString(a.AlarmName), region) |
| set(&r, "state", string(a.StateValue)) |
| @@ -167,6 +199,9 @@ func certResources(ctx context.Context, api ACMAPI, region string) (out []infrav |
| if err != nil { |
| return nil, []string{fmt.Sprintf("certificates in %s: %v", region, err)} |
| } |
| + if o == nil { |
| + return nil, partial |
| + } |
| for _, c := range o.CertificateSummaryList { |
| r := res(infraview.KindCertificate, "acm:certificate", aws.ToString(c.CertificateArn), aws.ToString(c.DomainName), region) |
| set(&r, "domain", aws.ToString(c.DomainName)) |
| @@ -191,6 +226,9 @@ func catchAll(ctx context.Context, api TaggingAPI, region string, seen map[strin |
| if err != nil { |
| return out, append(partial, fmt.Sprintf("resource inventory in %s: %v", region, err)) |
| } |
| + if o == nil { |
| + return out, partial |
| + } |
| for _, m := range o.ResourceTagMappingList { |
| arn := aws.ToString(m.ResourceARN) |
| service, id := parseARN(arn) |
diff --git a/internal/awsinv/rest_test.go b/internal/awsinv/rest_test.go| index c257af99f..37445fb8d 100644 |
| --- a/internal/awsinv/rest_test.go |
| +++ b/internal/awsinv/rest_test.go |
| @@ -158,6 +158,67 @@ func TestRoute53RecordsMaterialiseOnlyJoinable(t *testing.T) { |
| } |
| } |
| +// fakeRoute53Stuck always claims IsTruncated with the same next cursor, no |
| +// matter what StartRecordName/Type it was called with — simulating a |
| +// pagination cursor that never advances (a single record name owning a |
| +// whole page is the real-world case). route53Resources must stop rather |
| +// than spin forever against a live API on an hourly pass. |
| +type fakeRoute53Stuck struct { |
| + zones *route53.ListHostedZonesOutput |
| + calls int |
| +} |
| + |
| +func (f *fakeRoute53Stuck) ListHostedZones(context.Context, *route53.ListHostedZonesInput, ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) { |
| + return f.zones, nil |
| +} |
| + |
| +func (f *fakeRoute53Stuck) ListResourceRecordSets(context.Context, *route53.ListResourceRecordSetsInput, ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) { |
| + f.calls++ |
| + return &route53.ListResourceRecordSetsOutput{ |
| + ResourceRecordSets: []r53types.ResourceRecordSet{ |
| + { |
| + Name: aws.String("stuck.carlosframework.com."), Type: r53types.RRTypeA, |
| + ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("52.0.0.9")}}, |
| + }, |
| + }, |
| + IsTruncated: true, |
| + NextRecordName: aws.String("stuck.carlosframework.com."), |
| + NextRecordType: r53types.RRTypeA, |
| + }, nil |
| +} |
| + |
| +func TestRoute53PaginationStopsWhenCursorDoesNotAdvance(t *testing.T) { |
| + f := &fakeRoute53Stuck{ |
| + zones: &route53.ListHostedZonesOutput{HostedZones: []r53types.HostedZone{ |
| + {Id: aws.String("/hostedzone/Z2"), Name: aws.String("carlosframework.com.")}, |
| + }}, |
| + } |
| + |
| + done := make(chan struct{}) |
| + var rs []infraview.Resource |
| + var partial []string |
| + go func() { |
| + rs, partial = route53Resources(context.Background(), f) |
| + close(done) |
| + }() |
| + select { |
| + case <-done: |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("route53Resources did not return — a non-advancing cursor spun forever") |
| + } |
| + |
| + if f.calls > 3 { |
| + t.Fatalf("ListResourceRecordSets called %d times for a cursor that never advances, want it to stop quickly", f.calls) |
| + } |
| + if len(partial) == 0 { |
| + t.Fatal("a stalled cursor must be named in partial, not silently truncate the zone") |
| + } |
| + zone := find(rs, infraview.KindDNSZone, "/hostedzone/Z2") |
| + if zone == nil { |
| + t.Fatal("the zone itself must still be returned even though pagination stalled") |
| + } |
| +} |
| + |
| type fakeIAM struct { |
| roles *iam.ListRolesOutput |
| users *iam.ListUsersOutput |