carlos / platform Public

awsinv: S3/Route53/IAM/CloudWatch/ACM mappings, tagging catch-all, assembly

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 8fd2609edf5371c1fa30a247cce11024b6732b62 parent 6fbb04f
4 files changed, +821 −0
  • internal/awsinv/awsinv.go +184 −0
  • internal/awsinv/awsinv_test.go +104 −0
  • internal/awsinv/rest.go +229 −0
  • internal/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: &notAfter,
+ },
+ }}}
+ 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)
+ }
+ }
+}