awsinv: S3/Route53/IAM/CloudWatch/ACM mappings, tagging catch-all, assembly
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 files changed,
+821
−0
internal/awsinv/awsinv.go+184 −0internal/awsinv/awsinv_test.go+104 −0internal/awsinv/rest.go+229 −0internal/awsinv/rest_test.go+304 −0
diff --git a/internal/awsinv/awsinv.go b/internal/awsinv/awsinv.go| new file mode 100644 |
| index 000000000..1cbe61ae3 |
| --- /dev/null |
| +++ b/internal/awsinv/awsinv.go |
| @@ -0,0 +1,184 @@ |
| +package awsinv |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "strings" |
| + "time" |
| + |
| + awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| + "github.com/aws/aws-sdk-go-v2/service/ec2" |
| + "github.com/aws/aws-sdk-go-v2/service/sts" |
| + "github.com/aws/smithy-go" |
| + "github.com/carlosframework/platform/internal/infraview" |
| +) |
| + |
| +// credentialProbe is how long New waits for the credential chain to answer |
| +// before deciding this deployment has none. Five seconds, matching |
| +// cepull.go's own bounded probe: on a box with an instance role it is |
| +// instant, and on a laptop with no credential at all the chain can |
| +// otherwise hang on IMDS. |
| +const credentialProbe = 5 * time.Second |
| + |
| +// Provider is the AWS implementation of infraview.Provider. |
| +type Provider struct { |
| + factory Factory |
| + home string // the region whose clients answer the global services |
| + regions []string // every region to inventory |
| + account string |
| + now func() time.Time // test seam |
| +} |
| + |
| +// New resolves the credential chain once, discovers the account's |
| +// opted-in regions, and returns a Provider. Every failure here is |
| +// construction-class — no config, no credential, no reachable EC2 — and the |
| +// caller (the console pass) treats an auth-shaped one as "this deployment |
| +// has no inventory grant", latching the pass off rather than retrying |
| +// hourly forever. |
| +func New(ctx context.Context) (*Provider, error) { |
| + cfg, err := awsconfig.LoadDefaultConfig(ctx) |
| + if err != nil { |
| + return nil, fmt.Errorf("awsinv: aws config: %w", err) |
| + } |
| + probeCtx, cancel := context.WithTimeout(ctx, credentialProbe) |
| + defer cancel() |
| + if _, err := cfg.Credentials.Retrieve(probeCtx); err != nil { |
| + return nil, fmt.Errorf("awsinv: no usable credential: %w", err) |
| + } |
| + factory := factoryFor(cfg) |
| + home := cfg.Region |
| + if home == "" { |
| + home = "us-east-1" |
| + } |
| + |
| + regions := []string{home} |
| + if o, err := factory(home).EC2.DescribeRegions(ctx, &ec2.DescribeRegionsInput{}); err != nil { |
| + if IsAuthShaped(err) { |
| + return nil, fmt.Errorf("awsinv: describe regions: %w", err) |
| + } |
| + // Not auth-shaped: inventory the home region alone rather than |
| + // nothing. The missing regions surface in Partial at assembly. |
| + } else { |
| + regions = regions[:0] |
| + for _, r := range o.Regions { |
| + if r.RegionName != nil { |
| + regions = append(regions, *r.RegionName) |
| + } |
| + } |
| + } |
| + |
| + account := "" |
| + if id, err := sts.NewFromConfig(cfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}); err == nil && id.Account != nil { |
| + account = *id.Account |
| + } |
| + return &Provider{factory: factory, home: home, regions: regions, account: account, now: time.Now}, nil |
| +} |
| + |
| +// newWithFactory is New's test twin: no credential chain, no network. |
| +func newWithFactory(f Factory, home string, regions []string, account string) *Provider { |
| + 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. |
| +// |
| +// The error contract: an error means "this deployment cannot inventory |
| +// itself at all". Anything less — a throttled call, one dead region, a |
| +// service the role cannot read — lands in Partial and the inventory is |
| +// still returned, because a page showing eleven of twelve resource classes |
| +// is worth far more than an error. |
| +func (p *Provider) Inventory(ctx context.Context) (infraview.Inventory, error) { |
| + inv := infraview.Inventory{Provider: "aws", Account: p.account, TakenAt: p.now().UTC()} |
| + seen := map[string]bool{} |
| + authDenied := false |
| + |
| + note := func(lines []string) { |
| + inv.Partial = append(inv.Partial, lines...) |
| + } |
| + add := func(rs []infraview.Resource) { |
| + for _, r := range rs { |
| + seen[r.ID] = true |
| + inv.Resources = append(inv.Resources, r) |
| + } |
| + } |
| + |
| + // Every field on Clients is non-nil once factoryFor built it, but a test |
| + // 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. |
| + for _, region := range p.regions { |
| + c := p.factory(region) |
| + if c.EC2 != nil { |
| + rs, partial := ec2Resources(ctx, c.EC2, region) |
| + add(rs) |
| + note(partial) |
| + } |
| + if c.CloudWatch != nil { |
| + rs, partial := alarmResources(ctx, c.CloudWatch, region) |
| + add(rs) |
| + note(partial) |
| + } |
| + if c.ACM != nil { |
| + rs, partial := certResources(ctx, c.ACM, region) |
| + add(rs) |
| + note(partial) |
| + } |
| + if c.Tagging != nil { |
| + rs, partial := catchAll(ctx, c.Tagging, region, seen) |
| + add(rs) |
| + note(partial) |
| + } |
| + } |
| + |
| + 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 |
| + break |
| + } |
| + } |
| + if len(inv.Resources) == 0 && authDenied { |
| + return inv, errors.New("awsinv: every read was denied — this deployment has no inventory grant") |
| + } |
| + return inv, nil |
| +} |
| + |
| +// IsAuthShaped is cepull.go's isAuthShapedError, exported here so the |
| +// console classifies THIS package's construction errors by the same rule it |
| +// classifies Cost Explorer's. One rule, one list of codes. |
| +func IsAuthShaped(err error) bool { |
| + var apiErr smithy.APIError |
| + if !errors.As(err, &apiErr) { |
| + return false |
| + } |
| + return looksDenied(apiErr.ErrorCode()) |
| +} |
| + |
| +func looksDenied(s string) bool { |
| + for _, shape := range []string{ |
| + "AccessDenied", "UnauthorizedOperation", "UnrecognizedClient", |
| + "InvalidClientTokenId", "InvalidAccessKeyId", "AuthFailure", "ExpiredToken", |
| + } { |
| + if strings.Contains(s, shape) { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
diff --git a/internal/awsinv/awsinv_test.go b/internal/awsinv/awsinv_test.go| new file mode 100644 |
| index 000000000..2dcb263ae |
| --- /dev/null |
| +++ b/internal/awsinv/awsinv_test.go |
| @@ -0,0 +1,104 @@ |
| +package awsinv |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "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/resourcegroupstaggingapi" |
| + rgtypes "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi/types" |
| + "github.com/carlosframework/platform/internal/infraview" |
| +) |
| + |
| +type fakeTagging struct { |
| + out *resourcegroupstaggingapi.GetResourcesOutput |
| + err error |
| +} |
| + |
| +func (f *fakeTagging) GetResources(context.Context, *resourcegroupstaggingapi.GetResourcesInput, ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.GetResourcesOutput, error) { |
| + return f.out, f.err |
| +} |
| + |
| +// 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. |
| +func TestCatchAllDedupesAgainstTypedResources(t *testing.T) { |
| + factory := func(region string) Clients { |
| + return Clients{ |
| + EC2: &fakeEC2{instances: &ec2.DescribeInstancesOutput{ |
| + Reservations: []ec2types.Reservation{{Instances: []ec2types.Instance{{ |
| + InstanceId: aws.String("i-123"), |
| + }}}}, |
| + }}, |
| + 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")}, |
| + }, |
| + }}, |
| + } |
| + } |
| + p := newWithFactory(factory, "eu-west-1", []string{"eu-west-1"}, "1234") |
| + |
| + inv, err := p.Inventory(context.Background()) |
| + if err != nil { |
| + t.Fatalf("Inventory: %v", err) |
| + } |
| + machines := inv.ByKind(infraview.KindMachine) |
| + if len(machines) != 1 { |
| + t.Fatalf("machines = %d, want 1 (the catch-all must not duplicate a typed resource)", len(machines)) |
| + } |
| + others := inv.ByKind(infraview.KindOther) |
| + if len(others) != 1 || others[0].NativeType != "sqs" { |
| + t.Fatalf("others = %+v, want one sqs resource", others) |
| + } |
| + if inv.Provider != "aws" || inv.Account != "1234" || inv.TakenAt.IsZero() { |
| + t.Fatalf("header not stamped: %+v", inv) |
| + } |
| +} |
| + |
| +// Auth-shaped failure with nothing discovered is the one case that must |
| +// reach the caller as an error — it is what latches the console's pass off |
| +// so an ungranted deployment says "not granted" instead of erroring hourly. |
| +func TestAuthShapedWithNoResourcesIsAnError(t *testing.T) { |
| + authErr := &fakeAPIErr{code: "AccessDenied"} |
| + factory := func(region string) Clients { |
| + return Clients{ |
| + EC2: &fakeEC2Failing{err: authErr}, |
| + Tagging: &fakeTagging{err: authErr}, |
| + } |
| + } |
| + p := newWithFactory(factory, "eu-west-1", []string{"eu-west-1"}, "") |
| + if _, err := p.Inventory(context.Background()); err == nil { |
| + t.Fatal("want an error when every read is denied and nothing was discovered") |
| + } |
| +} |
| + |
| +// The same denial, but something WAS discovered: the inventory is returned |
| +// with the failures named in Partial, never thrown away. |
| +func TestPartialSurvivesWhenSomethingWasDiscovered(t *testing.T) { |
| + factory := func(region string) Clients { |
| + return Clients{ |
| + EC2: &fakeEC2{ |
| + instances: &ec2.DescribeInstancesOutput{ |
| + Reservations: []ec2types.Reservation{{Instances: []ec2types.Instance{{ |
| + InstanceId: aws.String("i-123"), |
| + }}}}, |
| + }, |
| + errVols: errors.New("AccessDenied: not authorized"), |
| + }, |
| + Tagging: &fakeTagging{err: errors.New("AccessDenied: not authorized")}, |
| + } |
| + } |
| + p := newWithFactory(factory, "eu-west-1", []string{"eu-west-1"}, "") |
| + inv, err := p.Inventory(context.Background()) |
| + if err != nil { |
| + t.Fatalf("Inventory: %v", err) |
| + } |
| + if len(inv.Partial) == 0 { |
| + t.Fatal("failures must be named in Partial") |
| + } |
| +} |
diff --git a/internal/awsinv/rest.go b/internal/awsinv/rest.go| new file mode 100644 |
| index 000000000..70ccef1fe |
| --- /dev/null |
| +++ b/internal/awsinv/rest.go |
| @@ -0,0 +1,229 @@ |
| +package awsinv |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "strconv" |
| + "strings" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + "github.com/aws/aws-sdk-go-v2/service/acm" |
| + "github.com/aws/aws-sdk-go-v2/service/cloudwatch" |
| + "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" |
| + "github.com/aws/aws-sdk-go-v2/service/s3" |
| + "github.com/carlosframework/platform/internal/infraview" |
| +) |
| + |
| +// joinedRecordTypes are the record types that can participate in the |
| +// DNS -> IP -> machine join, and so the only ones materialised as their own |
| +// Resource. Everything else is counted into the zone's record_count: a zone |
| +// can hold thousands of records and this object is read on every page load. |
| +var joinedRecordTypes = map[string]bool{"A": true, "AAAA": true} |
| + |
| +func s3Resources(ctx context.Context, api S3API) (out []infraview.Resource, partial []string) { |
| + o, err := api.ListBuckets(ctx, &s3.ListBucketsInput{}) |
| + if err != nil { |
| + return nil, []string{fmt.Sprintf("buckets: %v", err)} |
| + } |
| + 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 { |
| + 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 { |
| + 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 { |
| + c := p.PublicAccessBlockConfiguration |
| + if aws.ToBool(c.BlockPublicAcls) && aws.ToBool(c.BlockPublicPolicy) { |
| + set(&r, "public_access_block", "on") |
| + } else { |
| + set(&r, "public_access_block", "partial") |
| + } |
| + } |
| + out = append(out, r) |
| + } |
| + return out, partial |
| +} |
| + |
| +func route53Resources(ctx context.Context, api Route53API) (out []infraview.Resource, partial []string) { |
| + zones, err := api.ListHostedZones(ctx, &route53.ListHostedZonesInput{}) |
| + if err != nil { |
| + return nil, []string{fmt.Sprintf("hosted zones: %v", err)} |
| + } |
| + for _, z := range zones.HostedZones { |
| + id := aws.ToString(z.Id) |
| + name := strings.TrimSuffix(aws.ToString(z.Name), ".") |
| + zr := res(infraview.KindDNSZone, "route53:hosted-zone", id, name, "") |
| + if z.Config != nil && z.Config.PrivateZone { |
| + set(&zr, "private", "yes") |
| + } |
| + count := 0 |
| + var next *string |
| + for { |
| + recs, err := api.ListResourceRecordSets(ctx, &route53.ListResourceRecordSetsInput{ |
| + HostedZoneId: z.Id, StartRecordName: next, |
| + }) |
| + if err != nil { |
| + partial = append(partial, fmt.Sprintf("records in %s: %v", name, err)) |
| + break |
| + } |
| + for _, rec := range recs.ResourceRecordSets { |
| + count++ |
| + rtype := string(rec.Type) |
| + if !joinedRecordTypes[rtype] { |
| + continue |
| + } |
| + rname := strings.TrimSuffix(aws.ToString(rec.Name), ".") |
| + for _, v := range rec.ResourceRecords { |
| + rr := res(infraview.KindDNSRecord, "route53:record", rname+"/"+rtype+"/"+aws.ToString(v.Value), rname, "") |
| + set(&rr, "zone", name) |
| + set(&rr, "type", rtype) |
| + set(&rr, "value", aws.ToString(v.Value)) |
| + out = append(out, rr) |
| + } |
| + if rec.AliasTarget != nil { |
| + rr := res(infraview.KindDNSRecord, "route53:alias", rname+"/ALIAS", rname, "") |
| + set(&rr, "zone", name) |
| + set(&rr, "type", "ALIAS") |
| + set(&rr, "value", strings.TrimSuffix(aws.ToString(rec.AliasTarget.DNSName), ".")) |
| + out = append(out, rr) |
| + } |
| + } |
| + if !recs.IsTruncated { |
| + break |
| + } |
| + next = recs.NextRecordName |
| + } |
| + set(&zr, "record_count", strconv.Itoa(count)) |
| + out = append(out, zr) |
| + } |
| + return out, partial |
| +} |
| + |
| +// iamResources reports identities as NAMES and a count of attached |
| +// policies. Policy documents are deliberately never read: this page is a |
| +// map, not a credential viewer (spec §4). |
| +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 { |
| + 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 { |
| + set(&r, "attached_policies", strconv.Itoa(len(pol.AttachedPolicies))) |
| + } |
| + out = append(out, r) |
| + } |
| + } |
| + if o, err := api.ListUsers(ctx, &iam.ListUsersInput{}); err != nil { |
| + partial = append(partial, fmt.Sprintf("IAM users: %v", err)) |
| + } else { |
| + for _, u := range o.Users { |
| + r := res(infraview.KindIdentity, "iam:user", aws.ToString(u.Arn), aws.ToString(u.UserName), "") |
| + set(&r, "kind", "user") |
| + out = append(out, r) |
| + } |
| + } |
| + return out, partial |
| +} |
| + |
| +func alarmResources(ctx context.Context, api CloudWatchAPI, region string) (out []infraview.Resource, partial []string) { |
| + o, err := api.DescribeAlarms(ctx, &cloudwatch.DescribeAlarmsInput{}) |
| + if err != nil { |
| + return nil, []string{fmt.Sprintf("alarms in %s: %v", region, err)} |
| + } |
| + 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)) |
| + set(&r, "metric", aws.ToString(a.MetricName)) |
| + for _, d := range a.Dimensions { |
| + if aws.ToString(d.Name) == "InstanceId" { |
| + set(&r, "target", aws.ToString(d.Value)) |
| + } |
| + } |
| + out = append(out, r) |
| + } |
| + return out, partial |
| +} |
| + |
| +func certResources(ctx context.Context, api ACMAPI, region string) (out []infraview.Resource, partial []string) { |
| + o, err := api.ListCertificates(ctx, &acm.ListCertificatesInput{}) |
| + if err != nil { |
| + return nil, []string{fmt.Sprintf("certificates in %s: %v", region, err)} |
| + } |
| + 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)) |
| + set(&r, "status", string(c.Status)) |
| + if c.NotAfter != nil { |
| + set(&r, "expires", c.NotAfter.UTC().Format("2006-01-02")) |
| + } |
| + out = append(out, r) |
| + } |
| + return out, partial |
| +} |
| + |
| +// catchAll is the one genuinely AWS-shaped trick in this package: one call |
| +// that returns every TAGGABLE resource in the region, so a resource type |
| +// nobody wrote a mapping for is still visible. seen holds the ids the typed |
| +// mappings already produced — an ARN whose id is in it is skipped, so the |
| +// catch-all only ever ADDS. |
| +func catchAll(ctx context.Context, api TaggingAPI, region string, seen map[string]bool) (out []infraview.Resource, partial []string) { |
| + var token *string |
| + for { |
| + o, err := api.GetResources(ctx, &resourcegroupstaggingapi.GetResourcesInput{PaginationToken: token}) |
| + if err != nil { |
| + return out, append(partial, fmt.Sprintf("resource inventory in %s: %v", region, err)) |
| + } |
| + for _, m := range o.ResourceTagMappingList { |
| + arn := aws.ToString(m.ResourceARN) |
| + service, id := parseARN(arn) |
| + if id == "" || seen[id] || seen[arn] { |
| + continue |
| + } |
| + r := res(infraview.KindOther, service, arn, id, region) |
| + for _, t := range m.Tags { |
| + if r.Tags == nil { |
| + r.Tags = map[string]string{} |
| + } |
| + r.Tags[aws.ToString(t.Key)] = aws.ToString(t.Value) |
| + } |
| + out = append(out, r) |
| + } |
| + if o.PaginationToken == nil || *o.PaginationToken == "" { |
| + return out, partial |
| + } |
| + token = o.PaginationToken |
| + } |
| +} |
| + |
| +// parseARN pulls the service and the trailing resource id out of an ARN: |
| +// arn:aws:ec2:eu-west-1:1234:instance/i-123 -> ("ec2", "i-123"). |
| +func parseARN(arn string) (service, id string) { |
| + parts := strings.SplitN(arn, ":", 6) |
| + if len(parts) < 6 { |
| + return "", "" |
| + } |
| + service = parts[2] |
| + tail := parts[5] |
| + if i := strings.LastIndexAny(tail, "/:"); i >= 0 { |
| + tail = tail[i+1:] |
| + } |
| + return service, tail |
| +} |
diff --git a/internal/awsinv/rest_test.go b/internal/awsinv/rest_test.go| new file mode 100644 |
| index 000000000..c257af99f |
| --- /dev/null |
| +++ b/internal/awsinv/rest_test.go |
| @@ -0,0 +1,304 @@ |
| +package awsinv |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + "github.com/aws/aws-sdk-go-v2/service/acm" |
| + acmtypes "github.com/aws/aws-sdk-go-v2/service/acm/types" |
| + "github.com/aws/aws-sdk-go-v2/service/cloudwatch" |
| + cwtypes "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" |
| + "github.com/aws/aws-sdk-go-v2/service/ec2" |
| + "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/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/aws/smithy-go" |
| + "github.com/carlosframework/platform/internal/infraview" |
| +) |
| + |
| +type fakeAPIErr struct{ code string } |
| + |
| +func (e *fakeAPIErr) Error() string { return e.code } |
| +func (e *fakeAPIErr) ErrorCode() string { return e.code } |
| +func (e *fakeAPIErr) ErrorMessage() string { return e.code } |
| +func (e *fakeAPIErr) ErrorFault() smithy.ErrorFault { return smithy.FaultClient } |
| + |
| +// fakeEC2Failing fails every call with the same error. |
| +type fakeEC2Failing struct{ err error } |
| + |
| +func (f *fakeEC2Failing) DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) { |
| + return nil, f.err |
| +} |
| +func (f *fakeEC2Failing) DescribeVolumes(context.Context, *ec2.DescribeVolumesInput, ...func(*ec2.Options)) (*ec2.DescribeVolumesOutput, error) { |
| + return nil, f.err |
| +} |
| +func (f *fakeEC2Failing) DescribeAddresses(context.Context, *ec2.DescribeAddressesInput, ...func(*ec2.Options)) (*ec2.DescribeAddressesOutput, error) { |
| + return nil, f.err |
| +} |
| +func (f *fakeEC2Failing) DescribeSecurityGroups(context.Context, *ec2.DescribeSecurityGroupsInput, ...func(*ec2.Options)) (*ec2.DescribeSecurityGroupsOutput, error) { |
| + return nil, f.err |
| +} |
| +func (f *fakeEC2Failing) DescribeVpcs(context.Context, *ec2.DescribeVpcsInput, ...func(*ec2.Options)) (*ec2.DescribeVpcsOutput, error) { |
| + return nil, f.err |
| +} |
| +func (f *fakeEC2Failing) DescribeSubnets(context.Context, *ec2.DescribeSubnetsInput, ...func(*ec2.Options)) (*ec2.DescribeSubnetsOutput, error) { |
| + return nil, f.err |
| +} |
| +func (f *fakeEC2Failing) DescribeRegions(context.Context, *ec2.DescribeRegionsInput, ...func(*ec2.Options)) (*ec2.DescribeRegionsOutput, error) { |
| + return nil, f.err |
| +} |
| + |
| +type fakeS3 struct { |
| + buckets *s3.ListBucketsOutput |
| + vers *s3.GetBucketVersioningOutput |
| +} |
| + |
| +func (f *fakeS3) ListBuckets(context.Context, *s3.ListBucketsInput, ...func(*s3.Options)) (*s3.ListBucketsOutput, error) { |
| + return f.buckets, nil |
| +} |
| +func (f *fakeS3) GetBucketLocation(context.Context, *s3.GetBucketLocationInput, ...func(*s3.Options)) (*s3.GetBucketLocationOutput, error) { |
| + return &s3.GetBucketLocationOutput{LocationConstraint: s3types.BucketLocationConstraintEuWest1}, nil |
| +} |
| +func (f *fakeS3) GetBucketVersioning(context.Context, *s3.GetBucketVersioningInput, ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) { |
| + return f.vers, nil |
| +} |
| +func (f *fakeS3) GetBucketEncryption(context.Context, *s3.GetBucketEncryptionInput, ...func(*s3.Options)) (*s3.GetBucketEncryptionOutput, error) { |
| + return nil, errors.New("no encryption configured") |
| +} |
| +func (f *fakeS3) GetPublicAccessBlock(context.Context, *s3.GetPublicAccessBlockInput, ...func(*s3.Options)) (*s3.GetPublicAccessBlockOutput, error) { |
| + return nil, errors.New("no public access block") |
| +} |
| + |
| +// A bucket whose posture reads fail still appears — with the attribute |
| +// ABSENT, never a false "off". "Off" is a claim this read did not earn. |
| +func TestS3BucketMapping(t *testing.T) { |
| + f := &fakeS3{ |
| + buckets: &s3.ListBucketsOutput{Buckets: []s3types.Bucket{{Name: aws.String("carlos-data-ie")}}}, |
| + vers: &s3.GetBucketVersioningOutput{Status: s3types.BucketVersioningStatusEnabled}, |
| + } |
| + rs, partial := s3Resources(context.Background(), f) |
| + if len(partial) != 0 || len(rs) != 1 { |
| + t.Fatalf("rs=%+v partial=%v", rs, partial) |
| + } |
| + b := rs[0] |
| + if b.Kind != infraview.KindBucket || b.Name != "carlos-data-ie" || b.Region != "eu-west-1" { |
| + t.Errorf("bucket = %+v", b) |
| + } |
| + if b.Attr("versioning") != "enabled" { |
| + t.Errorf("versioning = %q, want enabled", b.Attr("versioning")) |
| + } |
| + if _, ok := b.Attrs["encryption"]; ok { |
| + t.Errorf("a failed encryption read must leave the attribute absent, not claim a value") |
| + } |
| +} |
| + |
| +type fakeRoute53 struct { |
| + zones *route53.ListHostedZonesOutput |
| + recs *route53.ListResourceRecordSetsOutput |
| +} |
| + |
| +func (f *fakeRoute53) ListHostedZones(context.Context, *route53.ListHostedZonesInput, ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) { |
| + return f.zones, nil |
| +} |
| +func (f *fakeRoute53) ListResourceRecordSets(context.Context, *route53.ListResourceRecordSetsInput, ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) { |
| + return f.recs, nil |
| +} |
| + |
| +// Only record types that can participate in the DNS -> IP -> machine join |
| +// are materialised as their own Resource; everything else just bumps the |
| +// zone's record_count, because a zone can hold thousands of records. |
| +func TestRoute53RecordsMaterialiseOnlyJoinable(t *testing.T) { |
| + f := &fakeRoute53{ |
| + zones: &route53.ListHostedZonesOutput{HostedZones: []r53types.HostedZone{ |
| + {Id: aws.String("/hostedzone/Z1"), Name: aws.String("carlosframework.com.")}, |
| + }}, |
| + recs: &route53.ListResourceRecordSetsOutput{ResourceRecordSets: []r53types.ResourceRecordSet{ |
| + { |
| + Name: aws.String("carlosframework.com."), Type: r53types.RRTypeA, |
| + ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("52.0.0.1")}}, |
| + }, |
| + { |
| + Name: aws.String("carlosframework.com."), Type: r53types.RRTypeTxt, |
| + ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("\"v=spf1 -all\"")}}, |
| + }, |
| + }}, |
| + } |
| + rs, partial := route53Resources(context.Background(), f) |
| + if len(partial) != 0 { |
| + t.Fatalf("partial = %v, want none", partial) |
| + } |
| + dnsRecords := 0 |
| + var zone *infraview.Resource |
| + for i := range rs { |
| + switch rs[i].Kind { |
| + case infraview.KindDNSRecord: |
| + dnsRecords++ |
| + if rs[i].Attr("type") != "A" { |
| + t.Errorf("only the A record should have been materialised, got type %q", rs[i].Attr("type")) |
| + } |
| + case infraview.KindDNSZone: |
| + zone = &rs[i] |
| + } |
| + } |
| + if dnsRecords != 1 { |
| + t.Fatalf("dns_record resources = %d, want exactly 1 (the A record, not the TXT)", dnsRecords) |
| + } |
| + if zone == nil { |
| + t.Fatal("no dns_zone resource produced") |
| + } |
| + if zone.Attr("record_count") != "2" { |
| + t.Errorf("record_count = %q, want 2 (both records counted)", zone.Attr("record_count")) |
| + } |
| +} |
| + |
| +type fakeIAM struct { |
| + roles *iam.ListRolesOutput |
| + users *iam.ListUsersOutput |
| + attached *iam.ListAttachedRolePoliciesOutput |
| +} |
| + |
| +func (f *fakeIAM) ListRoles(context.Context, *iam.ListRolesInput, ...func(*iam.Options)) (*iam.ListRolesOutput, error) { |
| + return f.roles, nil |
| +} |
| +func (f *fakeIAM) ListUsers(context.Context, *iam.ListUsersInput, ...func(*iam.Options)) (*iam.ListUsersOutput, error) { |
| + return f.users, nil |
| +} |
| +func (f *fakeIAM) ListAttachedRolePolicies(context.Context, *iam.ListAttachedRolePoliciesInput, ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error) { |
| + return f.attached, nil |
| +} |
| + |
| +// Identities carry a name, a kind, and a COUNT of attached policies — |
| +// nothing more. No code path here ever reads a policy document, so no |
| +// attribute value can ever contain one. |
| +func TestIAMNeverCarriesPolicyDocuments(t *testing.T) { |
| + f := &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{Users: []iamtypes.User{ |
| + {UserName: aws.String("deploy-bot"), Arn: aws.String("arn:aws:iam::1234:user/deploy-bot")}, |
| + }}, |
| + attached: &iam.ListAttachedRolePoliciesOutput{AttachedPolicies: []iamtypes.AttachedPolicy{ |
| + {PolicyName: aws.String("AdministratorAccess")}, |
| + {PolicyName: aws.String("ReadOnlyAccess")}, |
| + }}, |
| + } |
| + rs, partial := iamResources(context.Background(), f) |
| + if len(partial) != 0 || len(rs) != 2 { |
| + t.Fatalf("rs=%+v partial=%v", rs, partial) |
| + } |
| + role := find(rs, infraview.KindIdentity, "arn:aws:iam::1234:role/region-host") |
| + if role == nil { |
| + t.Fatalf("no role in %+v", rs) |
| + } |
| + if role.Attr("attached_policies") != "2" { |
| + t.Errorf("attached_policies = %q, want 2", role.Attr("attached_policies")) |
| + } |
| + for _, r := range rs { |
| + for k, v := range r.Attrs { |
| + if k == "policy" || k == "policy_document" { |
| + t.Errorf("resource %+v carries a policy document field %q", r, k) |
| + } |
| + if strings.Contains(v, "Statement") { |
| + t.Errorf("attribute %s = %q looks like a policy document", k, v) |
| + } |
| + } |
| + } |
| +} |
| + |
| +type fakeCloudWatch struct { |
| + alarms *cloudwatch.DescribeAlarmsOutput |
| +} |
| + |
| +func (f *fakeCloudWatch) DescribeAlarms(context.Context, *cloudwatch.DescribeAlarmsInput, ...func(*cloudwatch.Options)) (*cloudwatch.DescribeAlarmsOutput, error) { |
| + return f.alarms, nil |
| +} |
| + |
| +// An alarm on the InstanceId dimension names its target machine, which is |
| +// what lets the page draw the alarm next to the instance it watches. |
| +func TestAlarmTargetsInstanceDimension(t *testing.T) { |
| + f := &fakeCloudWatch{alarms: &cloudwatch.DescribeAlarmsOutput{MetricAlarms: []cwtypes.MetricAlarm{ |
| + { |
| + AlarmArn: aws.String("arn:aws:cloudwatch:eu-west-1:1234:alarm:cpu-high"), AlarmName: aws.String("cpu-high"), |
| + StateValue: cwtypes.StateValueAlarm, MetricName: aws.String("CPUUtilization"), |
| + Dimensions: []cwtypes.Dimension{{Name: aws.String("InstanceId"), Value: aws.String("i-123")}}, |
| + }, |
| + }}} |
| + rs, partial := alarmResources(context.Background(), f, "eu-west-1") |
| + if len(partial) != 0 || len(rs) != 1 { |
| + t.Fatalf("rs=%+v partial=%v", rs, partial) |
| + } |
| + a := rs[0] |
| + if a.Kind != infraview.KindAlarm || a.Name != "cpu-high" || a.Region != "eu-west-1" { |
| + t.Errorf("alarm = %+v", a) |
| + } |
| + if a.Attr("state") != "ALARM" { |
| + t.Errorf("state = %q, want ALARM", a.Attr("state")) |
| + } |
| + if a.Attr("target") != "i-123" { |
| + t.Errorf("target = %q, want i-123", a.Attr("target")) |
| + } |
| +} |
| + |
| +type fakeACM struct { |
| + certs *acm.ListCertificatesOutput |
| +} |
| + |
| +func (f *fakeACM) ListCertificates(context.Context, *acm.ListCertificatesInput, ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) { |
| + return f.certs, nil |
| +} |
| + |
| +func TestCertMapping(t *testing.T) { |
| + notAfter, err := time.Parse("2006-01-02", "2027-01-15") |
| + if err != nil { |
| + t.Fatalf("parse notAfter: %v", err) |
| + } |
| + f := &fakeACM{certs: &acm.ListCertificatesOutput{CertificateSummaryList: []acmtypes.CertificateSummary{ |
| + { |
| + CertificateArn: aws.String("arn:aws:acm:eu-west-1:1234:certificate/abc"), |
| + DomainName: aws.String("carloku.com"), |
| + Status: acmtypes.CertificateStatusIssued, |
| + NotAfter: ¬After, |
| + }, |
| + }}} |
| + rs, partial := certResources(context.Background(), f, "eu-west-1") |
| + if len(partial) != 0 || len(rs) != 1 { |
| + t.Fatalf("rs=%+v partial=%v", rs, partial) |
| + } |
| + c := rs[0] |
| + if c.Kind != infraview.KindCertificate || c.Name != "carloku.com" || c.Region != "eu-west-1" { |
| + t.Errorf("cert = %+v", c) |
| + } |
| + if c.Attr("status") != "ISSUED" { |
| + t.Errorf("status = %q, want ISSUED", c.Attr("status")) |
| + } |
| + if c.Attr("expires") != "2027-01-15" { |
| + t.Errorf("expires = %q, want 2027-01-15", c.Attr("expires")) |
| + } |
| +} |
| + |
| +func TestParseARN(t *testing.T) { |
| + cases := []struct { |
| + arn, service, id string |
| + }{ |
| + {"arn:aws:ec2:eu-west-1:1234:instance/i-123", "ec2", "i-123"}, |
| + {"arn:aws:s3:::carlos-data-ie", "s3", "carlos-data-ie"}, |
| + {"arn:aws:sqs:eu-west-1:1234:some-queue", "sqs", "some-queue"}, |
| + {"arn:aws:iam::1234:role/region-host", "iam", "region-host"}, |
| + {"not-an-arn", "", ""}, |
| + {"", "", ""}, |
| + } |
| + for _, c := range cases { |
| + service, id := parseARN(c.arn) |
| + if service != c.service || id != c.id { |
| + t.Errorf("parseARN(%q) = (%q, %q), want (%q, %q)", c.arn, service, id, c.service, c.id) |
| + } |
| + } |
| +} |