carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package awsinv
2
3import (
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.
22const 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.
34var ErrNoGrant = errors.New("awsinv: every read was denied — this deployment has no inventory grant")
35
36// Provider is the AWS implementation of infraview.Provider.
37type 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.
51func 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.
91func 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.
112func (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.
198func 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
209func 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