// Cost Explorer: the real-AWS half of the billPass seam (economicspass.go). // A deployment with no billing IAM grant, or one whose AWS config simply // doesn't resolve (no box role, no ~/.aws/credentials — plenty of dev/test // environments), must not have that surface as a recurring error: the spec's // posture is "a deployment that never pulled shows manual entry, not an // error" (economicsview.go's loadRecentBills carries the same posture for a // missing bill object). realCECosts is where that posture is enforced — // but ONLY for construction-class failures: the AWS config never resolving // at all, no usable credential in the chain (checked once, explicitly, // bounded to 5s — see realCECosts), or a GetCostAndUsage failure shaped // like AccessDenied/an invalid credential (isAuthShapedError). Those latch // s.ceDisabled after one log line (s.ceWarnOnce) and stay off for the rest // of the process — there is no "billing IAM might appear later" retry // worth making. Everything else — throttling, a 5xx, a transient network // blip — is NOT auth-shaped: it logs and the next hourly tick tries again, // because CE itself throttles under sustained load and a single blip must // not silently kill bill pulls until redeploy. package console import ( "context" "errors" "fmt" "log" "strconv" "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/costexplorer" cetypes "github.com/aws/aws-sdk-go-v2/service/costexplorer/types" "github.com/aws/smithy-go" "github.com/carlosframework/platform/internal/rootaws" ) // resolveCECosts is billPass's entry point into the seam: the test/fake // s.ceCosts function when a test set one, otherwise the real, lazily-built, // process-cached Cost Explorer client. Guarded by sync.Once/plain bools // rather than a mutex because billPass only ever runs from // StartEconomicsPass's single-goroutine hourly ticker (economicspass.go) — // there is no concurrent caller for these fields to race against. func (s *Server) resolveCECosts(ctx context.Context, start, end time.Time) (map[string]float64, error) { if s.ceCosts != nil { return s.ceCosts(ctx, start, end) } return s.realCECosts(ctx, start, end) } // realCECosts builds the Cost Explorer client once (s.ceClientOnce) and // reuses it for the life of the process. See the package doc comment above // for which failures latch s.ceDisabled (auth-shaped, permanent) versus // which just fail this one tick (everything else, retried next hour). func (s *Server) realCECosts(ctx context.Context, start, end time.Time) (map[string]float64, error) { if s.ceDisabled { return nil, fmt.Errorf("console: cost explorer disabled for this process") } s.ceClientOnce.Do(func() { cfg, err := rootaws.LoadDefaultConfig(ctx) if err != nil { // The AWS config itself failed to resolve — opaque and // permanent for this process: there is no "retry the SDK's // environment/instance-metadata lookup next tick" path, so // this always latches. s.disableCE(fmt.Errorf("console: cost explorer: load aws config: %w", err)) return } // Force credential resolution HERE, once, under a short bound — // rather than letting it happen lazily inside the first // GetCostAndUsage call below. LoadDefaultConfig never itself // contacts the network; a missing credential chain (no IMDS role, // no ~/.aws/credentials — exactly the "opaque credential-chain // construction failure" this seam exists to survive) only // surfaces once something tries to actually sign a request, as a // generic wrapped error with no smithy.APIError to classify — // left unhandled here, EVERY hourly tick would pay the SDK's own // multi-attempt IMDS retry/timeout before failing, forever. Doing // it once, bounded, at construction time keeps that cost to a // single ~5s probe for the life of the process instead. credCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if _, err := cfg.Credentials.Retrieve(credCtx); err != nil { s.disableCE(fmt.Errorf("console: cost explorer: resolve aws credentials: %w", err)) return } s.ceClient = costexplorer.NewFromConfig(cfg) }) if s.ceDisabled { return nil, fmt.Errorf("console: cost explorer disabled for this process") } out, err := s.ceClient.GetCostAndUsage(ctx, &costexplorer.GetCostAndUsageInput{ Granularity: cetypes.GranularityMonthly, Metrics: []string{"UnblendedCost"}, GroupBy: []cetypes.GroupDefinition{ {Type: cetypes.GroupDefinitionTypeDimension, Key: aws.String("SERVICE")}, }, TimePeriod: &cetypes.DateInterval{ Start: aws.String(start.Format("2006-01-02")), End: aws.String(end.Format("2006-01-02")), }, }) if err != nil { if isAuthShapedError(err) { s.disableCE(fmt.Errorf("console: cost explorer: get cost and usage: %w", err)) } else { log.Printf("console: cost explorer: get cost and usage: %v (not auth-shaped; will retry next tick)", err) } return nil, err } costs := map[string]float64{} for _, res := range out.ResultsByTime { for _, g := range res.Groups { if len(g.Keys) == 0 { continue } mv, ok := g.Metrics["UnblendedCost"] if !ok || mv.Amount == nil { continue } amt, perr := strconv.ParseFloat(*mv.Amount, 64) if perr != nil { log.Printf("console: cost explorer: %s: unparseable amount %q: %v", g.Keys[0], *mv.Amount, perr) continue } costs[g.Keys[0]] += amt } } return costs, nil } // isAuthShapedError reports whether err is the "billing IAM isn't set up // here" shape — the only GetCostAndUsage failure realCECosts treats as // permanent for the process. AWS surfaces a missing/wrong grant as a smithy // API error whose code names the problem; Cost Explorer's own generated // exception types (types/errors.go) don't include these because they're // account/credential-wide, not CE-specific — they arrive as // *smithy.GenericAPIError instead, which is why this matches on // ErrorCode() rather than a typed CE exception. Matched by substring // (Contains, not exact-equal) because AWS is inconsistent about the // "Exception" suffix across services and error-message formats // (AccessDenied vs AccessDeniedException being the canonical example). func isAuthShapedError(err error) bool { var apiErr smithy.APIError if !errors.As(err, &apiErr) { return false } code := apiErr.ErrorCode() for _, shape := range []string{ "AccessDenied", "UnrecognizedClient", "InvalidClientTokenId", "InvalidAccessKeyId", "AuthFailure", "ExpiredToken", } { if strings.Contains(code, shape) { return true } } return false } // disableCE logs err exactly once for the process (s.ceWarnOnce) and // latches s.ceDisabled — called only for auth-shaped failures (see // isAuthShapedError), so "billing IAM isn't set up here" ends in one log // line and silent manual mode, while a throttle or a 5xx keeps retrying. func (s *Server) disableCE(err error) { s.ceDisabled = true s.ceWarnOnce.Do(func() { log.Printf("%v — bill pulls disabled for this process; manual bill entry only", err) }) }