| 1 | package awsinv |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "strings" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/aws/aws-sdk-go-v2/service/ec2" |
| 11 | "github.com/aws/aws-sdk-go-v2/service/sts" |
| 12 | "github.com/aws/smithy-go" |
| 13 | "github.com/carlosframework/platform/internal/infraview" |
| 14 | "github.com/carlosframework/platform/internal/rootaws" |
| 15 | ) |
| 16 | |
| 17 | // credentialProbe is how long New waits for the credential chain to answer |
| 18 | // before deciding this deployment has none. Five seconds, matching |
| 19 | // cepull.go's own bounded probe: on a box with an instance role it is |
| 20 | // instant, and on a laptop with no credential at all the chain can |
| 21 | // otherwise hang on IMDS. |
| 22 | const credentialProbe = 5 * time.Second |
| 23 | |
| 24 | // ErrNoGrant is Inventory's sentinel for "every read was denied and |
| 25 | // nothing was discovered" — this deployment has no inventory IAM grant. |
| 26 | // A sentinel, not a wrapped original error, because by the time Inventory |
| 27 | // decides this the individual mapping failures have already been turned |
| 28 | // into Partial STRINGS (looksDenied scans those, not error values) — no |
| 29 | // error object survives assembly to wrap, so a sentinel is the only way to |
| 30 | // keep this one rule in one place. IsAuthShaped matches it via errors.Is, |
| 31 | // same as it matches a denied smithy code, so the console's latch fires on |
| 32 | // both the construction-time denial (a bad DescribeRegions call) and this |
| 33 | // assembly-time one. |
| 34 | var ErrNoGrant = errors.New("awsinv: every read was denied — this deployment has no inventory grant") |
| 35 | |
| 36 | // Provider is the AWS implementation of infraview.Provider. |
| 37 | type Provider struct { |
| 38 | factory Factory |
| 39 | home string // the region whose clients answer the global services |
| 40 | regions []string // every region to inventory |
| 41 | account string |
| 42 | now func() time.Time // test seam |
| 43 | } |
| 44 | |
| 45 | // New resolves the credential chain once, discovers the account's |
| 46 | // opted-in regions, and returns a Provider. Every failure here is |
| 47 | // construction-class — no config, no credential, no reachable EC2 — and the |
| 48 | // caller (the console pass) treats an auth-shaped one as "this deployment |
| 49 | // has no inventory grant", latching the pass off rather than retrying |
| 50 | // hourly forever. |
| 51 | func New(ctx context.Context) (*Provider, error) { |
| 52 | cfg, err := rootaws.LoadDefaultConfig(ctx) |
| 53 | if err != nil { |
| 54 | return nil, fmt.Errorf("awsinv: aws config: %w", err) |
| 55 | } |
| 56 | probeCtx, cancel := context.WithTimeout(ctx, credentialProbe) |
| 57 | defer cancel() |
| 58 | if _, err := cfg.Credentials.Retrieve(probeCtx); err != nil { |
| 59 | return nil, fmt.Errorf("awsinv: no usable credential: %w", err) |
| 60 | } |
| 61 | factory := factoryFor(cfg) |
| 62 | home := cfg.Region |
| 63 | if home == "" { |
| 64 | home = "us-east-1" |
| 65 | } |
| 66 | |
| 67 | regions := []string{home} |
| 68 | if o, err := factory(home).EC2.DescribeRegions(ctx, &ec2.DescribeRegionsInput{}); err != nil { |
| 69 | if IsAuthShaped(err) { |
| 70 | return nil, fmt.Errorf("awsinv: describe regions: %w", err) |
| 71 | } |
| 72 | // Not auth-shaped: inventory the home region alone rather than |
| 73 | // nothing. The missing regions surface in Partial at assembly. |
| 74 | } else { |
| 75 | regions = regions[:0] |
| 76 | for _, r := range o.Regions { |
| 77 | if r.RegionName != nil { |
| 78 | regions = append(regions, *r.RegionName) |
| 79 | } |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | account := "" |
| 84 | if id, err := sts.NewFromConfig(cfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}); err == nil && id.Account != nil { |
| 85 | account = *id.Account |
| 86 | } |
| 87 | return &Provider{factory: factory, home: home, regions: regions, account: account, now: time.Now}, nil |
| 88 | } |
| 89 | |
| 90 | // newWithFactory is New's test twin: no credential chain, no network. |
| 91 | func newWithFactory(f Factory, home string, regions []string, account string) *Provider { |
| 92 | return &Provider{factory: f, home: home, regions: regions, account: account, now: time.Now} |
| 93 | } |
| 94 | |
| 95 | // Inventory walks the global services first, then every region's typed |
| 96 | // mappings and catch-all. |
| 97 | // |
| 98 | // The global-first ordering matters: the tagging catch-all "only ever |
| 99 | // ADDS" (never duplicates a typed resource), and it runs once per region — |
| 100 | // including the bucket's own region for an S3 bucket, and whichever region |
| 101 | // happens to be home for a hosted zone or an IAM role, since those are |
| 102 | // global services surfaced through a regional tagging call. If the catch- |
| 103 | // all for that region ran before the global mappings had populated seen, |
| 104 | // the resource would be double-counted: once under its typed kind, once |
| 105 | // again as KindOther. Running S3/Route53/IAM first closes that gap. |
| 106 | // |
| 107 | // The error contract: an error means "this deployment cannot inventory |
| 108 | // itself at all". Anything less — a throttled call, one dead region, a |
| 109 | // service the role cannot read — lands in Partial and the inventory is |
| 110 | // still returned, because a page showing eleven of twelve resource classes |
| 111 | // is worth far more than an error. |
| 112 | func (p *Provider) Inventory(ctx context.Context) (infraview.Inventory, error) { |
| 113 | inv := infraview.Inventory{Provider: "aws", Account: p.account, TakenAt: p.now().UTC()} |
| 114 | seen := map[string]bool{} |
| 115 | authDenied := false |
| 116 | |
| 117 | note := func(lines []string) { |
| 118 | inv.Partial = append(inv.Partial, lines...) |
| 119 | } |
| 120 | // seen is keyed on the literal Resource.ID only. A global bare-trailing- |
| 121 | // component seeding was tried here and removed: catchAll's parsed id is |
| 122 | // always an ARN's bare tail, so seeding every typed resource's tail too |
| 123 | // meant an IAM role or user named "worker" would seed "worker" into |
| 124 | // seen and silently swallow an unrelated SQS queue, Lambda function, or |
| 125 | // SNS topic sharing that same tail — a common naming collision. Any |
| 126 | // mapping whose typed ID needs to equal the catch-all's parsed id (the |
| 127 | // Route53 zone case that motivated the tail-seeding in the first place) |
| 128 | // fixes it at the source instead: route53Resources now uses the bare |
| 129 | // hosted-zone id as the Resource's ID, so it matches parseARN's output |
| 130 | // directly with no cross-service seeding rule. |
| 131 | add := func(rs []infraview.Resource) { |
| 132 | for _, r := range rs { |
| 133 | seen[r.ID] = true |
| 134 | inv.Resources = append(inv.Resources, r) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // Every field on Clients is non-nil once factoryFor built it, but a test |
| 139 | // factory legitimately wires up only the service under test — a nil |
| 140 | // client here means "not exercised", not "denied", so it is skipped |
| 141 | // rather than dereferenced. |
| 142 | global := p.factory(p.home) |
| 143 | if global.S3 != nil { |
| 144 | rs, partial := s3Resources(ctx, global.S3) |
| 145 | add(rs) |
| 146 | note(partial) |
| 147 | } |
| 148 | if global.Route53 != nil { |
| 149 | rs, partial := route53Resources(ctx, global.Route53) |
| 150 | add(rs) |
| 151 | note(partial) |
| 152 | } |
| 153 | if global.IAM != nil { |
| 154 | rs, partial := iamResources(ctx, global.IAM) |
| 155 | add(rs) |
| 156 | note(partial) |
| 157 | } |
| 158 | |
| 159 | for _, region := range p.regions { |
| 160 | c := p.factory(region) |
| 161 | if c.EC2 != nil { |
| 162 | rs, partial := ec2Resources(ctx, c.EC2, region) |
| 163 | add(rs) |
| 164 | note(partial) |
| 165 | } |
| 166 | if c.CloudWatch != nil { |
| 167 | rs, partial := alarmResources(ctx, c.CloudWatch, region) |
| 168 | add(rs) |
| 169 | note(partial) |
| 170 | } |
| 171 | if c.ACM != nil { |
| 172 | rs, partial := certResources(ctx, c.ACM, region) |
| 173 | add(rs) |
| 174 | note(partial) |
| 175 | } |
| 176 | if c.Tagging != nil { |
| 177 | rs, partial := catchAll(ctx, c.Tagging, region, seen) |
| 178 | add(rs) |
| 179 | note(partial) |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | for _, line := range inv.Partial { |
| 184 | if looksDenied(line) { |
| 185 | authDenied = true |
| 186 | break |
| 187 | } |
| 188 | } |
| 189 | if len(inv.Resources) == 0 && authDenied { |
| 190 | return inv, ErrNoGrant |
| 191 | } |
| 192 | return inv, nil |
| 193 | } |
| 194 | |
| 195 | // IsAuthShaped is cepull.go's isAuthShapedError, exported here so the |
| 196 | // console classifies THIS package's construction errors by the same rule it |
| 197 | // classifies Cost Explorer's. One rule, one list of codes. |
| 198 | func IsAuthShaped(err error) bool { |
| 199 | if errors.Is(err, ErrNoGrant) { |
| 200 | return true |
| 201 | } |
| 202 | var apiErr smithy.APIError |
| 203 | if !errors.As(err, &apiErr) { |
| 204 | return false |
| 205 | } |
| 206 | return looksDenied(apiErr.ErrorCode()) |
| 207 | } |
| 208 | |
| 209 | func looksDenied(s string) bool { |
| 210 | for _, shape := range []string{ |
| 211 | "AccessDenied", "UnauthorizedOperation", "UnrecognizedClient", |
| 212 | "InvalidClientTokenId", "InvalidAccessKeyId", "AuthFailure", "ExpiredToken", |
| 213 | } { |
| 214 | if strings.Contains(s, shape) { |
| 215 | return true |
| 216 | } |
| 217 | } |
| 218 | return false |
| 219 | } |
| 220 | |