carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Cost Explorer: the real-AWS half of the billPass seam (economicspass.go).
2// A deployment with no billing IAM grant, or one whose AWS config simply
3// doesn't resolve (no box role, no ~/.aws/credentials — plenty of dev/test
4// environments), must not have that surface as a recurring error: the spec's
5// posture is "a deployment that never pulled shows manual entry, not an
6// error" (economicsview.go's loadRecentBills carries the same posture for a
7// missing bill object). realCECosts is where that posture is enforced —
8// but ONLY for construction-class failures: the AWS config never resolving
9// at all, no usable credential in the chain (checked once, explicitly,
10// bounded to 5s — see realCECosts), or a GetCostAndUsage failure shaped
11// like AccessDenied/an invalid credential (isAuthShapedError). Those latch
12// s.ceDisabled after one log line (s.ceWarnOnce) and stay off for the rest
13// of the process — there is no "billing IAM might appear later" retry
14// worth making. Everything else — throttling, a 5xx, a transient network
15// blip — is NOT auth-shaped: it logs and the next hourly tick tries again,
16// because CE itself throttles under sustained load and a single blip must
17// not silently kill bill pulls until redeploy.
18package console
19
20import (
21 "context"
22 "errors"
23 "fmt"
24 "log"
25 "strconv"
26 "strings"
27 "time"
28
29 "github.com/aws/aws-sdk-go-v2/aws"
30 "github.com/aws/aws-sdk-go-v2/service/costexplorer"
31 cetypes "github.com/aws/aws-sdk-go-v2/service/costexplorer/types"
32 "github.com/aws/smithy-go"
33 "github.com/carlosframework/platform/internal/rootaws"
34)
35
36// resolveCECosts is billPass's entry point into the seam: the test/fake
37// s.ceCosts function when a test set one, otherwise the real, lazily-built,
38// process-cached Cost Explorer client. Guarded by sync.Once/plain bools
39// rather than a mutex because billPass only ever runs from
40// StartEconomicsPass's single-goroutine hourly ticker (economicspass.go) —
41// there is no concurrent caller for these fields to race against.
42func (s *Server) resolveCECosts(ctx context.Context, start, end time.Time) (map[string]float64, error) {
43 if s.ceCosts != nil {
44 return s.ceCosts(ctx, start, end)
45 }
46 return s.realCECosts(ctx, start, end)
47}
48
49// realCECosts builds the Cost Explorer client once (s.ceClientOnce) and
50// reuses it for the life of the process. See the package doc comment above
51// for which failures latch s.ceDisabled (auth-shaped, permanent) versus
52// which just fail this one tick (everything else, retried next hour).
53func (s *Server) realCECosts(ctx context.Context, start, end time.Time) (map[string]float64, error) {
54 if s.ceDisabled {
55 return nil, fmt.Errorf("console: cost explorer disabled for this process")
56 }
57 s.ceClientOnce.Do(func() {
58 cfg, err := rootaws.LoadDefaultConfig(ctx)
59 if err != nil {
60 // The AWS config itself failed to resolve — opaque and
61 // permanent for this process: there is no "retry the SDK's
62 // environment/instance-metadata lookup next tick" path, so
63 // this always latches.
64 s.disableCE(fmt.Errorf("console: cost explorer: load aws config: %w", err))
65 return
66 }
67 // Force credential resolution HERE, once, under a short bound —
68 // rather than letting it happen lazily inside the first
69 // GetCostAndUsage call below. LoadDefaultConfig never itself
70 // contacts the network; a missing credential chain (no IMDS role,
71 // no ~/.aws/credentials — exactly the "opaque credential-chain
72 // construction failure" this seam exists to survive) only
73 // surfaces once something tries to actually sign a request, as a
74 // generic wrapped error with no smithy.APIError to classify —
75 // left unhandled here, EVERY hourly tick would pay the SDK's own
76 // multi-attempt IMDS retry/timeout before failing, forever. Doing
77 // it once, bounded, at construction time keeps that cost to a
78 // single ~5s probe for the life of the process instead.
79 credCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
80 defer cancel()
81 if _, err := cfg.Credentials.Retrieve(credCtx); err != nil {
82 s.disableCE(fmt.Errorf("console: cost explorer: resolve aws credentials: %w", err))
83 return
84 }
85 s.ceClient = costexplorer.NewFromConfig(cfg)
86 })
87 if s.ceDisabled {
88 return nil, fmt.Errorf("console: cost explorer disabled for this process")
89 }
90
91 out, err := s.ceClient.GetCostAndUsage(ctx, &costexplorer.GetCostAndUsageInput{
92 Granularity: cetypes.GranularityMonthly,
93 Metrics: []string{"UnblendedCost"},
94 GroupBy: []cetypes.GroupDefinition{
95 {Type: cetypes.GroupDefinitionTypeDimension, Key: aws.String("SERVICE")},
96 },
97 TimePeriod: &cetypes.DateInterval{
98 Start: aws.String(start.Format("2006-01-02")),
99 End: aws.String(end.Format("2006-01-02")),
100 },
101 })
102 if err != nil {
103 if isAuthShapedError(err) {
104 s.disableCE(fmt.Errorf("console: cost explorer: get cost and usage: %w", err))
105 } else {
106 log.Printf("console: cost explorer: get cost and usage: %v (not auth-shaped; will retry next tick)", err)
107 }
108 return nil, err
109 }
110
111 costs := map[string]float64{}
112 for _, res := range out.ResultsByTime {
113 for _, g := range res.Groups {
114 if len(g.Keys) == 0 {
115 continue
116 }
117 mv, ok := g.Metrics["UnblendedCost"]
118 if !ok || mv.Amount == nil {
119 continue
120 }
121 amt, perr := strconv.ParseFloat(*mv.Amount, 64)
122 if perr != nil {
123 log.Printf("console: cost explorer: %s: unparseable amount %q: %v", g.Keys[0], *mv.Amount, perr)
124 continue
125 }
126 costs[g.Keys[0]] += amt
127 }
128 }
129 return costs, nil
130}
131
132// isAuthShapedError reports whether err is the "billing IAM isn't set up
133// here" shape — the only GetCostAndUsage failure realCECosts treats as
134// permanent for the process. AWS surfaces a missing/wrong grant as a smithy
135// API error whose code names the problem; Cost Explorer's own generated
136// exception types (types/errors.go) don't include these because they're
137// account/credential-wide, not CE-specific — they arrive as
138// *smithy.GenericAPIError instead, which is why this matches on
139// ErrorCode() rather than a typed CE exception. Matched by substring
140// (Contains, not exact-equal) because AWS is inconsistent about the
141// "Exception" suffix across services and error-message formats
142// (AccessDenied vs AccessDeniedException being the canonical example).
143func isAuthShapedError(err error) bool {
144 var apiErr smithy.APIError
145 if !errors.As(err, &apiErr) {
146 return false
147 }
148 code := apiErr.ErrorCode()
149 for _, shape := range []string{
150 "AccessDenied",
151 "UnrecognizedClient",
152 "InvalidClientTokenId",
153 "InvalidAccessKeyId",
154 "AuthFailure",
155 "ExpiredToken",
156 } {
157 if strings.Contains(code, shape) {
158 return true
159 }
160 }
161 return false
162}
163
164// disableCE logs err exactly once for the process (s.ceWarnOnce) and
165// latches s.ceDisabled — called only for auth-shaped failures (see
166// isAuthShapedError), so "billing IAM isn't set up here" ends in one log
167// line and silent manual mode, while a throttle or a 5xx keeps retrying.
168func (s *Server) disableCE(err error) {
169 s.ceDisabled = true
170 s.ceWarnOnce.Do(func() {
171 log.Printf("%v — bill pulls disabled for this process; manual bill entry only", err)
172 })
173}
174