package awsinv import ( "context" "errors" "fmt" "strings" "time" "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" "github.com/carlosframework/platform/internal/rootaws" ) // 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 // ErrNoGrant is Inventory's sentinel for "every read was denied and // nothing was discovered" — this deployment has no inventory IAM grant. // A sentinel, not a wrapped original error, because by the time Inventory // decides this the individual mapping failures have already been turned // into Partial STRINGS (looksDenied scans those, not error values) — no // error object survives assembly to wrap, so a sentinel is the only way to // keep this one rule in one place. IsAuthShaped matches it via errors.Is, // same as it matches a denied smithy code, so the console's latch fires on // both the construction-time denial (a bad DescribeRegions call) and this // assembly-time one. var ErrNoGrant = errors.New("awsinv: every read was denied — this deployment has no inventory grant") // 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 := rootaws.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 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 // 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...) } // seen is keyed on the literal Resource.ID only. A global bare-trailing- // component seeding was tried here and removed: catchAll's parsed id is // always an ARN's bare tail, so seeding every typed resource's tail too // meant an IAM role or user named "worker" would seed "worker" into // seen and silently swallow an unrelated SQS queue, Lambda function, or // SNS topic sharing that same tail — a common naming collision. Any // mapping whose typed ID needs to equal the catch-all's parsed id (the // Route53 zone case that motivated the tail-seeding in the first place) // fixes it at the source instead: route53Resources now uses the bare // hosted-zone id as the Resource's ID, so it matches parseARN's output // directly with no cross-service seeding rule. 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. 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 { 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) } } for _, line := range inv.Partial { if looksDenied(line) { authDenied = true break } } if len(inv.Resources) == 0 && authDenied { return inv, ErrNoGrant } 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 { if errors.Is(err, ErrNoGrant) { return true } 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 }