| 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. |
| 18 | package console |
| 19 | |
| 20 | import ( |
| 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. |
| 42 | func (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). |
| 53 | func (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). |
| 143 | func 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. |
| 168 | func (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 | |