diff --git a/providers/aws/recommendations/client.go b/providers/aws/recommendations/client.go index 01257deb5..95102165f 100644 --- a/providers/aws/recommendations/client.go +++ b/providers/aws/recommendations/client.go @@ -28,6 +28,8 @@ type CostExplorerAPI interface { GetSavingsPlansPurchaseRecommendation(ctx context.Context, params *costexplorer.GetSavingsPlansPurchaseRecommendationInput, optFns ...func(*costexplorer.Options)) (*costexplorer.GetSavingsPlansPurchaseRecommendationOutput, error) GetReservationUtilization(ctx context.Context, params *costexplorer.GetReservationUtilizationInput, optFns ...func(*costexplorer.Options)) (*costexplorer.GetReservationUtilizationOutput, error) GetReservationCoverage(ctx context.Context, params *costexplorer.GetReservationCoverageInput, optFns ...func(*costexplorer.Options)) (*costexplorer.GetReservationCoverageOutput, error) + GetSavingsPlansCoverage(ctx context.Context, params *costexplorer.GetSavingsPlansCoverageInput, optFns ...func(*costexplorer.Options)) (*costexplorer.GetSavingsPlansCoverageOutput, error) + GetSavingsPlansUtilization(ctx context.Context, params *costexplorer.GetSavingsPlansUtilizationInput, optFns ...func(*costexplorer.Options)) (*costexplorer.GetSavingsPlansUtilizationOutput, error) } // Client wraps the AWS Cost Explorer client for RI recommendations diff --git a/providers/aws/recommendations/client_test.go b/providers/aws/recommendations/client_test.go index 63c45779c..2d83b44ab 100644 --- a/providers/aws/recommendations/client_test.go +++ b/providers/aws/recommendations/client_test.go @@ -52,6 +52,14 @@ func (m *mockCostExplorerAPI) GetReservationCoverage(ctx context.Context, params return &costexplorer.GetReservationCoverageOutput{}, nil } +func (m *mockCostExplorerAPI) GetSavingsPlansCoverage(ctx context.Context, params *costexplorer.GetSavingsPlansCoverageInput, optFns ...func(*costexplorer.Options)) (*costexplorer.GetSavingsPlansCoverageOutput, error) { + return &costexplorer.GetSavingsPlansCoverageOutput{}, nil +} + +func (m *mockCostExplorerAPI) GetSavingsPlansUtilization(ctx context.Context, params *costexplorer.GetSavingsPlansUtilizationInput, optFns ...func(*costexplorer.Options)) (*costexplorer.GetSavingsPlansUtilizationOutput, error) { + return &costexplorer.GetSavingsPlansUtilizationOutput{}, nil +} + func TestNewClient(t *testing.T) { cfg := aws.Config{ Region: "us-west-2", @@ -681,6 +689,18 @@ func (m *multiPageRIMock) GetReservationCoverage( return &costexplorer.GetReservationCoverageOutput{}, nil } +func (m *multiPageRIMock) GetSavingsPlansCoverage( + _ context.Context, _ *costexplorer.GetSavingsPlansCoverageInput, _ ...func(*costexplorer.Options), +) (*costexplorer.GetSavingsPlansCoverageOutput, error) { + return &costexplorer.GetSavingsPlansCoverageOutput{}, nil +} + +func (m *multiPageRIMock) GetSavingsPlansUtilization( + _ context.Context, _ *costexplorer.GetSavingsPlansUtilizationInput, _ ...func(*costexplorer.Options), +) (*costexplorer.GetSavingsPlansUtilizationOutput, error) { + return &costexplorer.GetSavingsPlansUtilizationOutput{}, nil +} + // riDetail returns a minimal ReservationPurchaseRecommendation with n EC2 details. func riDetail(n int) types.ReservationPurchaseRecommendation { details := make([]types.ReservationPurchaseRecommendationDetail, n) @@ -801,6 +821,18 @@ func (m *alwaysNextPageRIMock) GetReservationCoverage( return &costexplorer.GetReservationCoverageOutput{}, nil } +func (m *alwaysNextPageRIMock) GetSavingsPlansCoverage( + _ context.Context, _ *costexplorer.GetSavingsPlansCoverageInput, _ ...func(*costexplorer.Options), +) (*costexplorer.GetSavingsPlansCoverageOutput, error) { + return &costexplorer.GetSavingsPlansCoverageOutput{}, nil +} + +func (m *alwaysNextPageRIMock) GetSavingsPlansUtilization( + _ context.Context, _ *costexplorer.GetSavingsPlansUtilizationInput, _ ...func(*costexplorer.Options), +) (*costexplorer.GetSavingsPlansUtilizationOutput, error) { + return &costexplorer.GetSavingsPlansUtilizationOutput{}, nil +} + // TestGetRecommendations_RI_PaginationCapError asserts that exceeding // maxRecommendationPages returns a diagnostic error (issue #692). func TestGetRecommendations_RI_PaginationCapError(t *testing.T) { diff --git a/providers/aws/recommendations/parser_sp_additional_test.go b/providers/aws/recommendations/parser_sp_additional_test.go index cbe28a128..25252d13f 100644 --- a/providers/aws/recommendations/parser_sp_additional_test.go +++ b/providers/aws/recommendations/parser_sp_additional_test.go @@ -245,6 +245,14 @@ func (m *mockCostExplorerForSP) GetReservationCoverage(ctx context.Context, para return &costexplorer.GetReservationCoverageOutput{}, nil } +func (m *mockCostExplorerForSP) GetSavingsPlansCoverage(_ context.Context, _ *costexplorer.GetSavingsPlansCoverageInput, _ ...func(*costexplorer.Options)) (*costexplorer.GetSavingsPlansCoverageOutput, error) { + return &costexplorer.GetSavingsPlansCoverageOutput{}, nil +} + +func (m *mockCostExplorerForSP) GetSavingsPlansUtilization(_ context.Context, _ *costexplorer.GetSavingsPlansUtilizationInput, _ ...func(*costexplorer.Options)) (*costexplorer.GetSavingsPlansUtilizationOutput, error) { + return &costexplorer.GetSavingsPlansUtilizationOutput{}, nil +} + func TestGetSavingsPlansRecommendations_WithFilters(t *testing.T) { mockAPI := &mockCostExplorerForSP{ responses: map[types.SupportedSavingsPlansType]*costexplorer.GetSavingsPlansPurchaseRecommendationOutput{ @@ -434,6 +442,18 @@ func (m *multiPageSPMock) GetReservationCoverage( return &costexplorer.GetReservationCoverageOutput{}, nil } +func (m *multiPageSPMock) GetSavingsPlansCoverage( + _ context.Context, _ *costexplorer.GetSavingsPlansCoverageInput, _ ...func(*costexplorer.Options), +) (*costexplorer.GetSavingsPlansCoverageOutput, error) { + return &costexplorer.GetSavingsPlansCoverageOutput{}, nil +} + +func (m *multiPageSPMock) GetSavingsPlansUtilization( + _ context.Context, _ *costexplorer.GetSavingsPlansUtilizationInput, _ ...func(*costexplorer.Options), +) (*costexplorer.GetSavingsPlansUtilizationOutput, error) { + return &costexplorer.GetSavingsPlansUtilizationOutput{}, nil +} + // alwaysNextPageSPMock returns pages each carrying a non-nil non-empty NextPageToken. type alwaysNextPageSPMock struct { calls int @@ -466,6 +486,18 @@ func (m *alwaysNextPageSPMock) GetReservationCoverage( return &costexplorer.GetReservationCoverageOutput{}, nil } +func (m *alwaysNextPageSPMock) GetSavingsPlansCoverage( + _ context.Context, _ *costexplorer.GetSavingsPlansCoverageInput, _ ...func(*costexplorer.Options), +) (*costexplorer.GetSavingsPlansCoverageOutput, error) { + return &costexplorer.GetSavingsPlansCoverageOutput{}, nil +} + +func (m *alwaysNextPageSPMock) GetSavingsPlansUtilization( + _ context.Context, _ *costexplorer.GetSavingsPlansUtilizationInput, _ ...func(*costexplorer.Options), +) (*costexplorer.GetSavingsPlansUtilizationOutput, error) { + return &costexplorer.GetSavingsPlansUtilizationOutput{}, nil +} + // TestGetSavingsPlansRecommendations_Paginates asserts multi-page accumulation (issue #692). func TestGetSavingsPlansRecommendations_Paginates(t *testing.T) { mock := &multiPageSPMock{ diff --git a/providers/aws/recommendations/sp_coverage.go b/providers/aws/recommendations/sp_coverage.go new file mode 100644 index 000000000..808c25662 --- /dev/null +++ b/providers/aws/recommendations/sp_coverage.go @@ -0,0 +1,523 @@ +package recommendations + +import ( + "context" + "fmt" + "math" + "strconv" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/costexplorer" + "github.com/aws/aws-sdk-go-v2/service/costexplorer/types" +) + +// SPCoverageSummary is the Savings Plans coverage summary (optionally scoped +// to one region) over a lookback window. +// +// Unlike PoolCoverageMap (keyed by region:instance_type), SP coverage carries +// no pool key. Savings Plans commitment is dollar-denominated and applies +// cross-service (and, for Compute SPs, cross-region), so there is no +// meaningful per-instance-type breakdown at the coverage level. Any region +// scoping happens via the CE Filter on the request instead, so the summary +// stays flat. Coverage is also NOT plan-type-scoped - see GetSPCoverageSummary +// for why (CE filter contract). +// +// All money/percent fields are pointers so "CE returned no data" (nil) is +// distinguishable from "CE returned 0%" (pointer to 0.0). Days holds the +// number of daily data points that had a non-nil Coverage block in the CE +// response; zero means CE returned no coverage data at all for the window +// (e.g., no Savings Plans have ever been purchased in the account). +type SPCoverageSummary struct { + // CoveragePct is covered / (covered + on-demand) * 100 over the window + // (0-100): the share of SP-ELIGIBLE spend actually covered by Savings + // Plans. CE's OnDemandCost is the UNCOVERED remainder, not the eligible + // total, so the denominator must be the sum of both fields - dividing + // by OnDemandCost alone overstates coverage and exceeds 100% as soon as + // real coverage passes 50%. Nil ONLY when the eligible total is zero + // (no SP-eligible activity in the window) or Days==0; a fully covered + // window (OnDemandCost==0, covered>0) yields &100.0, not nil. + CoveragePct *float64 + // CoveredUSDPerHour is the average SP-covered spend per hour over the + // window (total SpendCoveredBySavingsPlans / windowHours). Nil when + // Days==0. + CoveredUSDPerHour *float64 + // OnDemandUSDPerHour is the average spend billed at on-demand rates per + // hour, i.e. the SP-eligible spend NOT covered by any Savings Plan. + // This is the uncovered portion (CE's OnDemandCost), not the eligible + // total - see EligibleUSDPerHour for the total. Nil when Days==0. + OnDemandUSDPerHour *float64 + // EligibleUSDPerHour is the average total SP-eligible spend per hour: + // CoveredUSDPerHour + OnDemandUSDPerHour. Provided so consumers (the + // ladder sizing math) do not have to re-derive the coverage + // denominator. Nil when Days==0. + EligibleUSDPerHour *float64 + // Days is the count of daily CE data points that had a non-nil Coverage + // block in the response. Zero means CE returned no data for the window. + Days int +} + +// SPUtilizationSummary is the Savings Plans utilization summary for one plan +// type (and optionally one region) over a lookback window. All money/percent +// fields are pointers so "CE returned no data" (nil) is distinguishable from +// "CE returned 0%". +// +// UsedCommitmentUSDPerHour and TotalCommitmentUSDPerHour are derived by +// dividing the CE-reported period totals by windowHours so callers receive a +// consistent $/hr denomination, matching PoolCoverage.AvgInstancesPerHour. +type SPUtilizationSummary struct { + // UtilizationPct is the percentage of the SP commitment actually consumed + // over the window (0-100). Nil when CE returned no utilization data. + UtilizationPct *float64 + // UsedCommitmentUSDPerHour is the average used SP commitment in $/hr. + // Nil when CE returned no data. + UsedCommitmentUSDPerHour *float64 + // TotalCommitmentUSDPerHour is the average total SP commitment in $/hr. + // Nil when CE returned no data. + TotalCommitmentUSDPerHour *float64 +} + +// spCoverageMetricSpendCovered is the value for the REQUIRED Metrics +// parameter of GetSavingsPlansCoverage. Per the Cost Explorer API reference, +// "SpendCoveredBySavingsPlans" is the SOLE valid value; omitting Metrics +// makes real calls fail or return incomplete data. There is no typed SDK +// constant for it - the types.Metric enum covers only the GetCostAndUsage +// cost metrics (BLENDED_COST etc.) - and the SDK's client-side validator +// checks only TimePeriod, which is why mock-backed tests could not catch +// the omission. +const spCoverageMetricSpendCovered = "SpendCoveredBySavingsPlans" + +// maxSPCoveragePages caps the GetSavingsPlansCoverage NextToken loop, +// mirroring the maxRecommendationPages guard from issue #692 (client.go). +// Named separately because the workloads differ: 20 pages of daily coverage +// data is far beyond anything a real 13-month-max CE window can produce, so +// exceeding the cap indicates a token loop or API misbehavior and returns a +// diagnostic error instead of spinning (and billing $0.01/call) forever. +const maxSPCoveragePages = 20 + +// spPlanTypeDimensionValues translates the SupportedSavingsPlansType +// PARAMETER enum to the SAVINGS_PLANS_TYPE DIMENSION vocabulary. +// +// TWO-VOCABULARY TRAP (feedback_sdk_enum_string_literals class): the +// parameter enum used by GetSavingsPlansPurchaseRecommendation +// ("COMPUTE_SP", "EC2_INSTANCE_SP", ...) is NOT the vocabulary the +// SAVINGS_PLANS_TYPE filter dimension matches on. The dimension uses the +// CamelCase values below (the same vocabulary as the CUR savings_plan_type +// column). Sending the parameter-enum spelling makes CE silently match +// NOTHING - no error, just empty results - which is why no mock-backed test +// can prove this mapping. The SDK exposes no typed constants for the +// dimension vocabulary, hence this explicit map with fail-loud lookup. +var spPlanTypeDimensionValues = map[types.SupportedSavingsPlansType]string{ + types.SupportedSavingsPlansTypeComputeSp: "ComputeSavingsPlans", + types.SupportedSavingsPlansTypeEc2InstanceSp: "EC2InstanceSavingsPlans", + types.SupportedSavingsPlansTypeSagemakerSp: "SageMakerSavingsPlans", + types.SupportedSavingsPlansTypeDatabaseSp: "DatabaseSavingsPlans", +} + +// spPlanTypeDimensionValue returns the SAVINGS_PLANS_TYPE dimension value +// for a plan-type parameter enum, failing loud on an unmapped value (e.g. a +// plan type added to the SDK enum after this map was written) rather than +// sending a spelling CE would silently match nothing on. +func spPlanTypeDimensionValue(planType types.SupportedSavingsPlansType) (string, error) { + v, ok := spPlanTypeDimensionValues[planType] + if !ok { + return "", fmt.Errorf( + "sp: no SAVINGS_PLANS_TYPE dimension value mapped for plan type %q; add it to spPlanTypeDimensionValues", + planType) + } + return v, nil +} + +// validateSPPlanType rejects empty or unknown Savings Plans type values at +// the API boundary (fail loud on unknown enum input rather than silently +// sending it to CE). The valid set comes from the SDK enum itself so a new +// SDK plan type is accepted automatically after an SDK upgrade. +func validateSPPlanType(planType types.SupportedSavingsPlansType) error { + for _, v := range planType.Values() { + if planType == v { + return nil + } + } + return fmt.Errorf("sp: unknown Savings Plans type %q (valid values: %v)", + planType, planType.Values()) +} + +// spUtilizationFilter builds the CE Filter expression for +// GetSavingsPlansUtilization: a SAVINGS_PLANS_TYPE dimension carrying the +// mapped dimension-vocabulary value (see spPlanTypeDimensionValues - NOT the +// parameter-enum spelling), optionally ANDed with a REGION dimension when +// region is non-empty. Mirrors the And composition idiom of +// serviceRegionFilter in coverage.go. An empty region means "all regions" +// (no region dimension at all - relevant for Compute SPs, whose commitment +// floats across regions). Errors when the plan type has no mapped dimension +// value. +// +// DO NOT reuse this for GetSavingsPlansCoverage and do not re-symmetrize the +// two filter paths: the coverage API's Filter contract does NOT support the +// SAVINGS_PLANS_TYPE dimension (it supports only LINKED_ACCOUNT, REGION, +// SERVICE, and INSTANCE_FAMILY - see the GetSavingsPlansCoverageInput.Filter +// SDK doc), so sending SAVINGS_PLANS_TYPE to coverage fails with a +// ValidationException on every real call. Coverage uses +// spCoverageRegionFilter instead; the two helpers are deliberately separate. +func spUtilizationFilter(planType types.SupportedSavingsPlansType, region string) (*types.Expression, error) { + dimensionValue, err := spPlanTypeDimensionValue(planType) + if err != nil { + return nil, err + } + planTypeExpr := types.Expression{ + Dimensions: &types.DimensionValues{ + Key: types.DimensionSavingsPlansType, + Values: []string{dimensionValue}, + }, + } + if region == "" { + return &planTypeExpr, nil + } + return &types.Expression{ + And: []types.Expression{ + planTypeExpr, + {Dimensions: &types.DimensionValues{Key: types.DimensionRegion, Values: []string{region}}}, + }, + }, nil +} + +// spCoverageRegionFilter builds the CE Filter expression for +// GetSavingsPlansCoverage: a bare REGION dimension when region is non-empty, +// or nil (no Filter field at all) when region is empty. +// +// DO NOT add a SAVINGS_PLANS_TYPE dimension here to mirror +// spUtilizationFilter: GetSavingsPlansCoverage's Filter supports only +// LINKED_ACCOUNT, REGION, SERVICE, and INSTANCE_FAMILY (per the +// GetSavingsPlansCoverageInput.Filter SDK doc); SAVINGS_PLANS_TYPE is a +// utilization-only dimension and CE rejects it here with a +// ValidationException. That asymmetry is why this helper is deliberately +// separate from spUtilizationFilter rather than a shared parameterized +// builder. +func spCoverageRegionFilter(region string) *types.Expression { + if region == "" { + return nil + } + return &types.Expression{ + Dimensions: &types.DimensionValues{Key: types.DimensionRegion, Values: []string{region}}, + } +} + +// spCoverageAccumulator aggregates SP coverage data across paginated CE +// responses so the caller holds a single summary rather than a per-page slice. +type spCoverageAccumulator struct { + totalCovered float64 + totalOnDemand float64 + days int +} + +// add incorporates one SavingsPlansCoverage item into the accumulator. +// Items with a nil Coverage block are silently skipped; CE may omit the block +// for daily periods with no eligible SP activity, and skipping them keeps Days +// as a count of periods with actual data rather than a raw time-bucket count. +func (a *spCoverageAccumulator) add(cov types.SavingsPlansCoverage) error { + if cov.Coverage == nil { + return nil + } + if cov.Coverage.SpendCoveredBySavingsPlans != nil { + v, err := parseSPFloat(aws.ToString(cov.Coverage.SpendCoveredBySavingsPlans), "SpendCoveredBySavingsPlans") + if err != nil { + return err + } + a.totalCovered += v + } + if cov.Coverage.OnDemandCost != nil { + v, err := parseSPFloat(aws.ToString(cov.Coverage.OnDemandCost), "OnDemandCost") + if err != nil { + return err + } + a.totalOnDemand += v + } + a.days++ + return nil +} + +// summarize converts the accumulated totals into an SPCoverageSummary. +// windowHours must equal lookbackDays*24 and be positive; zero or negative +// yields an empty summary (same as days==0). +func (a *spCoverageAccumulator) summarize(windowHours float64) SPCoverageSummary { + if a.days == 0 || windowHours <= 0 { + return SPCoverageSummary{Days: a.days} + } + covered := a.totalCovered / windowHours + onDemand := a.totalOnDemand / windowHours + eligible := covered + onDemand + summary := SPCoverageSummary{ + CoveredUSDPerHour: &covered, + OnDemandUSDPerHour: &onDemand, + EligibleUSDPerHour: &eligible, + Days: a.days, + } + // CE's OnDemandCost is the UNCOVERED spend, so the eligible total is + // covered + onDemand and coverage = covered / eligible. Dividing by + // OnDemandCost alone would overstate coverage (exceeding 100% past the + // 50% mark) and would misreport a fully covered window (onDemand==0, + // covered>0) as "no eligible activity". Pct is nil ONLY when the + // eligible total is zero. + if totalEligible := a.totalCovered + a.totalOnDemand; totalEligible > 0 { + pct := (a.totalCovered / totalEligible) * 100 + summary.CoveragePct = &pct + } + return summary +} + +// GetSPCoverageSummary fetches Savings Plans coverage over the last +// lookbackDays days and returns a summary the ladder engine can consume. +// +// Unlike GetSPUtilization, coverage is NOT plan-type-scoped, for two reasons: +// +// 1. CE filter contract: GetSavingsPlansCoverage's Filter supports only the +// LINKED_ACCOUNT, REGION, SERVICE, and INSTANCE_FAMILY dimensions (see +// the GetSavingsPlansCoverageInput.Filter SDK doc). SAVINGS_PLANS_TYPE +// is a utilization-only dimension; passing it here fails with a +// ValidationException on every real call. +// 2. Semantics: coverage measures the shared pool of SP-eligible on-demand +// spend that is covered by ANY Savings Plan, so attributing coverage to +// a single plan type is not meaningful - a dollar of eligible spend +// covered by a Compute SP is indistinguishable, coverage-wise, from one +// covered by an EC2 Instance SP. +// +// region == "" means all regions (no Filter at all); a non-empty region adds +// a REGION dimension filter (a supported coverage dimension). +// +// Coverage carries no pool key (unlike RI coverage's PoolCoverageMap) because +// SP commitment is dollar-denominated and applies cross-service; the return +// type is therefore a flat SPCoverageSummary rather than a keyed map. +// +// lookbackDays must be positive. Zero or negative is an error - the caller +// is responsible for supplying the window (per the no-hardcoded-window rule). +// +// An empty CE response (Days==0) is not an error: it means CE has no coverage +// data for the requested scope and window. Note that a nonexistent or +// misspelled region does NOT error either - CE simply matches nothing and +// the result is Days==0 with all pointer fields nil. Callers must read +// Days==0 as "no data for this scope", not "no SPs in the account", and +// check Days before dereferencing the pointer fields. +// +// Concurrency: Client is NOT safe for concurrent SP calls - the shared +// rateLimiter's Reset() is unsynchronized (see +// feedback_rate_limiter_per_call), so concurrent callers race on the retry +// counter. Call from a single goroutine per Client; the ladder consumer +// (PR 5) is sequential by design. +func (c *Client) GetSPCoverageSummary(ctx context.Context, region string, lookbackDays int) (SPCoverageSummary, error) { + if lookbackDays <= 0 { + return SPCoverageSummary{}, fmt.Errorf("sp coverage: lookbackDays must be positive, got %d", lookbackDays) + } + end := time.Now().UTC() + start := end.AddDate(0, 0, -lookbackDays) + input := &costexplorer.GetSavingsPlansCoverageInput{ + TimePeriod: &types.DateInterval{ + Start: aws.String(start.Format("2006-01-02")), + End: aws.String(end.Format("2006-01-02")), + }, + Granularity: types.GranularityDaily, + // Metrics is REQUIRED by the API (not enforced by the SDK's + // client-side validator) and "SpendCoveredBySavingsPlans" is its + // sole valid value. GetSavingsPlansUtilization has no Metrics + // parameter, so there is no counterpart on that path. + Metrics: []string{spCoverageMetricSpendCovered}, + // Region-only filter (nil when region == ""). Deliberately NOT the + // utilization filter shape: coverage's Filter contract rejects the + // SAVINGS_PLANS_TYPE dimension - do not re-symmetrize the two paths. + // See spCoverageRegionFilter / spUtilizationFilter. + Filter: spCoverageRegionFilter(region), + } + + var acc spCoverageAccumulator + var token *string + for pageIdx := 0; ; pageIdx++ { + if err := ctx.Err(); err != nil { + return SPCoverageSummary{}, fmt.Errorf("sp coverage: pagination canceled: %w", err) + } + if pageIdx >= maxSPCoveragePages { + return SPCoverageSummary{}, fmt.Errorf( + "sp coverage: pagination cap reached after %d pages (mirrors the issue #692 guard)", + maxSPCoveragePages) + } + input.NextToken = token + result, err := c.fetchSPCoveragePage(ctx, input) + if err != nil { + return SPCoverageSummary{}, err + } + for _, cov := range result.SavingsPlansCoverages { + if addErr := acc.add(cov); addErr != nil { + return SPCoverageSummary{}, fmt.Errorf("sp coverage: %w", addErr) + } + } + if result.NextToken == nil || *result.NextToken == "" { + break + } + token = result.NextToken + } + + windowHours := float64(lookbackDays * 24) + return acc.summarize(windowHours), nil +} + +// fetchSPCoveragePage calls GetSavingsPlansCoverage with rate-limit retry. +// Mirrors fetchCoveragePage in coverage.go so both paths back off consistently. +func (c *Client) fetchSPCoveragePage(ctx context.Context, input *costexplorer.GetSavingsPlansCoverageInput) (*costexplorer.GetSavingsPlansCoverageOutput, error) { + c.rateLimiter.Reset() + for { + if waitErr := c.rateLimiter.Wait(ctx); waitErr != nil { + return nil, fmt.Errorf("rate limiter wait failed: %w", waitErr) + } + result, err := c.costExplorerClient.GetSavingsPlansCoverage(ctx, input) + if !c.rateLimiter.ShouldRetry(err) { + if err != nil { + return nil, fmt.Errorf("failed to get SP coverage: %w", err) + } + return result, nil + } + } +} + +// GetSPUtilization fetches Savings Plans utilization for one plan type over +// the last lookbackDays days. +// +// planType scopes the CE call to a single SAVINGS_PLANS_TYPE dimension value +// (typed SDK enum, validated at the boundary; empty or unknown values are an +// explicit error). Utilization is per-commitment: the ladder holds two +// distinct SP layers, and a blended account-wide utilization number would +// mask under-utilization in one layer being offset by over-utilization in +// the other. region == "" means all regions (no REGION filter); a non-empty +// region is ANDed with the plan-type filter. Unlike coverage, utilization's +// Filter contract DOES support SAVINGS_PLANS_TYPE - see spUtilizationFilter. +// +// GetSavingsPlansUtilization is a single-page API (the response has no +// NextToken); the Total field already aggregates the full window, so no +// pagination loop is needed. +// +// lookbackDays must be positive. +// +// Returned money fields (UsedCommitmentUSDPerHour, TotalCommitmentUSDPerHour) +// are CE's period totals divided by windowHours so callers receive consistent +// $/hr values. Nil fields mean CE returned no utilization data. As with +// coverage, a nonexistent or misspelled region (or a plan type with no +// commitments) does NOT error - CE matches nothing and every pointer field +// comes back nil. Callers must read all-nil as "no data for this scope", +// not "no SPs in the account". +// +// MANUAL VERIFICATION REQUIRED (mock-unprovable): the plan-type filter +// relies on the parameter-enum -> dimension-vocabulary mapping in +// spPlanTypeDimensionValues ("COMPUTE_SP" -> "ComputeSavingsPlans", ...). +// A wrong dimension value does not error - CE silently matches nothing - +// so no unit test can prove the mapping. Before trusting this path in +// production, verify once against the real API: call GetDimensionValues +// with Context=SAVINGS_PLANS and Dimension=SAVINGS_PLANS_TYPE (or run one +// real GetSavingsPlansUtilization per plan type) and confirm the returned +// dimension values match the map. +// +// Concurrency: Client is NOT safe for concurrent SP calls - the shared +// rateLimiter's Reset() is unsynchronized (see +// feedback_rate_limiter_per_call), so concurrent callers race on the retry +// counter. Call from a single goroutine per Client; the ladder consumer +// (PR 5) is sequential by design. +func (c *Client) GetSPUtilization(ctx context.Context, planType types.SupportedSavingsPlansType, region string, lookbackDays int) (SPUtilizationSummary, error) { + if err := validateSPPlanType(planType); err != nil { + return SPUtilizationSummary{}, fmt.Errorf("sp utilization: %w", err) + } + if lookbackDays <= 0 { + return SPUtilizationSummary{}, fmt.Errorf("sp utilization: lookbackDays must be positive, got %d", lookbackDays) + } + // Plan-type filter, optionally ANDed with region. This shape is valid + // ONLY for the utilization API - coverage rejects SAVINGS_PLANS_TYPE. + // See spUtilizationFilter / spCoverageRegionFilter. + filter, err := spUtilizationFilter(planType, region) + if err != nil { + return SPUtilizationSummary{}, fmt.Errorf("sp utilization: %w", err) + } + end := time.Now().UTC() + start := end.AddDate(0, 0, -lookbackDays) + input := &costexplorer.GetSavingsPlansUtilizationInput{ + TimePeriod: &types.DateInterval{ + Start: aws.String(start.Format("2006-01-02")), + End: aws.String(end.Format("2006-01-02")), + }, + Granularity: types.GranularityDaily, + Filter: filter, + } + result, err := c.fetchSPUtilizationPage(ctx, input) + if err != nil { + return SPUtilizationSummary{}, err + } + windowHours := float64(lookbackDays * 24) + return buildSPUtilizationSummary(result, windowHours) +} + +// fetchSPUtilizationPage calls GetSavingsPlansUtilization with rate-limit retry. +// Mirrors fetchUtilizationPage in utilization.go so both paths back off consistently. +func (c *Client) fetchSPUtilizationPage(ctx context.Context, input *costexplorer.GetSavingsPlansUtilizationInput) (*costexplorer.GetSavingsPlansUtilizationOutput, error) { + c.rateLimiter.Reset() + for { + if waitErr := c.rateLimiter.Wait(ctx); waitErr != nil { + return nil, fmt.Errorf("rate limiter wait failed: %w", waitErr) + } + result, err := c.costExplorerClient.GetSavingsPlansUtilization(ctx, input) + if !c.rateLimiter.ShouldRetry(err) { + if err != nil { + return nil, fmt.Errorf("failed to get SP utilization: %w", err) + } + return result, nil + } + } +} + +// buildSPUtilizationSummary converts a CE GetSavingsPlansUtilization response +// into an SPUtilizationSummary. windowHours converts CE's period-total dollar +// amounts into $/hr rates consistent with PoolCoverage.AvgInstancesPerHour. +func buildSPUtilizationSummary(result *costexplorer.GetSavingsPlansUtilizationOutput, windowHours float64) (SPUtilizationSummary, error) { + if result.Total == nil || result.Total.Utilization == nil { + return SPUtilizationSummary{}, nil + } + u := result.Total.Utilization + var summary SPUtilizationSummary + if u.UtilizationPercentage != nil { + v, err := parseSPFloat(aws.ToString(u.UtilizationPercentage), "UtilizationPercentage") + if err != nil { + return SPUtilizationSummary{}, err + } + summary.UtilizationPct = &v + } + if windowHours > 0 { + if u.UsedCommitment != nil { + v, err := parseSPFloat(aws.ToString(u.UsedCommitment), "UsedCommitment") + if err != nil { + return SPUtilizationSummary{}, err + } + rate := v / windowHours + summary.UsedCommitmentUSDPerHour = &rate + } + if u.TotalCommitment != nil { + v, err := parseSPFloat(aws.ToString(u.TotalCommitment), "TotalCommitment") + if err != nil { + return SPUtilizationSummary{}, err + } + rate := v / windowHours + summary.TotalCommitmentUSDPerHour = &rate + } + } + return summary, nil +} + +// parseSPFloat parses a Cost Explorer string value into a float64, returning +// an explicit error on unparseable values, NaN/Inf, or negative values +// (all of which are invalid for financial metrics). Unlike the package-level +// parseFloat helper in utilization.go, this never silently falls back to zero. +func parseSPFloat(s, field string) (float64, error) { + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, fmt.Errorf("sp: field %q: cannot parse %q as float: %w", field, s, err) + } + if math.IsNaN(v) || math.IsInf(v, 0) { + return 0, fmt.Errorf("sp: field %q: non-finite value %q", field, s) + } + if v < 0 { + return 0, fmt.Errorf("sp: field %q: negative value %g is invalid for a financial metric", field, v) + } + return v, nil +} diff --git a/providers/aws/recommendations/sp_coverage_test.go b/providers/aws/recommendations/sp_coverage_test.go new file mode 100644 index 000000000..b1fc16531 --- /dev/null +++ b/providers/aws/recommendations/sp_coverage_test.go @@ -0,0 +1,665 @@ +package recommendations + +import ( + "context" + "errors" + "strconv" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/costexplorer" + "github.com/aws/aws-sdk-go-v2/service/costexplorer/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// floatPtr is a test helper that returns a pointer to f. +func floatPtr(f float64) *float64 { return &f } + +// allSPPlanTypes enumerates the SDK's Savings Plans type enum members so the +// filter-construction tests cover every plan type the ladder can reference. +var allSPPlanTypes = []types.SupportedSavingsPlansType{ + types.SupportedSavingsPlansTypeComputeSp, + types.SupportedSavingsPlansTypeEc2InstanceSp, + types.SupportedSavingsPlansTypeSagemakerSp, + types.SupportedSavingsPlansTypeDatabaseSp, +} + +// wantSPDimensionValues re-states the SAVINGS_PLANS_TYPE dimension +// vocabulary independently of the production spPlanTypeDimensionValues map, +// so a typo introduced in the production map fails these tests instead of +// being read back and trusted. The CamelCase vocabulary (matching the CUR +// savings_plan_type column) is deliberately NOT the parameter-enum spelling +// ("COMPUTE_SP", ...), which CE would silently match nothing on. +var wantSPDimensionValues = map[types.SupportedSavingsPlansType]string{ + types.SupportedSavingsPlansTypeComputeSp: "ComputeSavingsPlans", + types.SupportedSavingsPlansTypeEc2InstanceSp: "EC2InstanceSavingsPlans", + types.SupportedSavingsPlansTypeSagemakerSp: "SageMakerSavingsPlans", + types.SupportedSavingsPlansTypeDatabaseSp: "DatabaseSavingsPlans", +} + +// mockSPCE extends mockCostExplorerAPI with configurable SP coverage and +// utilization responses for hermetic testing without hitting AWS. Incoming +// request inputs are captured so tests can assert Filter construction. +type mockSPCE struct { + coverageErr error + utilErr error + utilOutput *costexplorer.GetSavingsPlansUtilizationOutput + mockCostExplorerAPI + // coveragePages is consumed in order on each GetSavingsPlansCoverage call. + // When exhausted, subsequent calls return an empty output. + coveragePages []*costexplorer.GetSavingsPlansCoverageOutput + // coverageInputs records each GetSavingsPlansCoverage request. + coverageInputs []*costexplorer.GetSavingsPlansCoverageInput + // utilInputs records each GetSavingsPlansUtilization request. + utilInputs []*costexplorer.GetSavingsPlansUtilizationInput + coverageIdx int + // coverageAlwaysToken makes every GetSavingsPlansCoverage response carry + // a non-empty NextToken, for exercising the pagination cap. + coverageAlwaysToken bool +} + +func (m *mockSPCE) GetSavingsPlansCoverage(_ context.Context, params *costexplorer.GetSavingsPlansCoverageInput, _ ...func(*costexplorer.Options)) (*costexplorer.GetSavingsPlansCoverageOutput, error) { + m.coverageInputs = append(m.coverageInputs, params) + if m.coverageErr != nil { + return nil, m.coverageErr + } + if m.coverageAlwaysToken { + return &costexplorer.GetSavingsPlansCoverageOutput{NextToken: aws.String("again")}, nil + } + if m.coverageIdx >= len(m.coveragePages) { + return &costexplorer.GetSavingsPlansCoverageOutput{}, nil + } + out := m.coveragePages[m.coverageIdx] + m.coverageIdx++ + return out, nil +} + +func (m *mockSPCE) GetSavingsPlansUtilization(_ context.Context, params *costexplorer.GetSavingsPlansUtilizationInput, _ ...func(*costexplorer.Options)) (*costexplorer.GetSavingsPlansUtilizationOutput, error) { + m.utilInputs = append(m.utilInputs, params) + if m.utilErr != nil { + return nil, m.utilErr + } + if m.utilOutput == nil { + return &costexplorer.GetSavingsPlansUtilizationOutput{}, nil + } + return m.utilOutput, nil +} + +// oneSPCovPage is a test helper that builds a GetSavingsPlansCoverageOutput +// with a single SavingsPlansCoverage entry from the provided string fields. +// nil token means no more pages (last page). +func oneSPCovPage(covered, onDemand, pct string, nextToken *string) *costexplorer.GetSavingsPlansCoverageOutput { + return &costexplorer.GetSavingsPlansCoverageOutput{ + SavingsPlansCoverages: []types.SavingsPlansCoverage{ + { + Coverage: &types.SavingsPlansCoverageData{ + SpendCoveredBySavingsPlans: aws.String(covered), + OnDemandCost: aws.String(onDemand), + CoveragePercentage: aws.String(pct), + }, + }, + }, + NextToken: nextToken, + } +} + +// TestGetSPCoverageSummary covers single-page happy path, multi-page +// pagination, empty response, nil Coverage block, zero-coverage vs missing +// data, and unparseable/negative number error paths. +func TestGetSPCoverageSummary(t *testing.T) { + // lookbackDays=30 => windowHours=720. + const lookback = 30 + const windowHours = float64(lookback * 24) + + tests := []struct { + apiErr error + // expected summary fields; nil means the field must be nil. + wantPct *float64 + wantCovered *float64 + wantOnDemand *float64 + wantEligible *float64 + name string + pages []*costexplorer.GetSavingsPlansCoverageOutput + wantDays int + wantErr bool + }{ + { + name: "single_page_happy_path", + // $360 covered, $540 billed at on-demand rates (UNCOVERED) over + // 30 days. Eligible total = 360+540 = 900. + // covered/hr = 360/720 = 0.5; onDemand/hr = 540/720 = 0.75; + // eligible/hr = 900/720 = 1.25. + // pct = 360/900 * 100 = 40.00 (matches CE's own CoveragePercentage). + pages: []*costexplorer.GetSavingsPlansCoverageOutput{oneSPCovPage("360", "540", "40.00", nil)}, + wantPct: floatPtr(360.0 / 900.0 * 100), + wantCovered: floatPtr(360.0 / windowHours), + wantOnDemand: floatPtr(540.0 / windowHours), + wantEligible: floatPtr(900.0 / windowHours), + wantDays: 1, + }, + { + name: "multi_page_pagination", + // Page 1: $720 covered, $1440 uncovered; NextToken present. + // Page 2: $360 covered, $720 uncovered; no NextToken. + // Totals: covered=1080, onDemand=2160, eligible=3240, + // pct = 1080/3240 = 33.33%. + pages: []*costexplorer.GetSavingsPlansCoverageOutput{ + oneSPCovPage("720", "1440", "33.33", aws.String("page2")), + oneSPCovPage("360", "720", "33.33", nil), + }, + wantPct: floatPtr(1080.0 / 3240.0 * 100), + wantCovered: floatPtr(1080.0 / windowHours), + wantOnDemand: floatPtr(2160.0 / windowHours), + wantEligible: floatPtr(3240.0 / windowHours), + wantDays: 2, + }, + { + name: "empty_response", + pages: []*costexplorer.GetSavingsPlansCoverageOutput{ + {SavingsPlansCoverages: nil}, + }, + // Days=0 => all fields nil (CE has no data for the window). + wantDays: 0, + }, + { + name: "nil_coverage_block_skipped", + // CE can omit the Coverage block for periods with no eligible activity. + // Such entries must not increment Days or contribute to totals. + pages: []*costexplorer.GetSavingsPlansCoverageOutput{ + { + SavingsPlansCoverages: []types.SavingsPlansCoverage{ + {Coverage: nil}, + }, + }, + }, + wantDays: 0, + }, + { + name: "zero_coverage_with_on_demand_spend", + // Coverage block present; on-demand spend > 0 but nothing covered + // by SPs. CoveragePct must be &0.0 (explicit zero), not nil. + // This is "zero coverage" (distinct from "no data" where Days==0). + pages: []*costexplorer.GetSavingsPlansCoverageOutput{ + { + SavingsPlansCoverages: []types.SavingsPlansCoverage{ + { + Coverage: &types.SavingsPlansCoverageData{ + SpendCoveredBySavingsPlans: aws.String("0"), + OnDemandCost: aws.String("720"), + CoveragePercentage: aws.String("0.0"), + }, + }, + }, + }, + }, + wantPct: floatPtr(0.0), + wantCovered: floatPtr(0.0 / windowHours), + wantOnDemand: floatPtr(720.0 / windowHours), + wantEligible: floatPtr(720.0 / windowHours), + wantDays: 1, + }, + { + name: "full_coverage_yields_pct_100", + // Everything covered: OnDemandCost (the UNCOVERED portion) is 0 + // while covered spend is positive. Eligible = covered, so pct + // must be &100.0 - NOT nil. The old covered/onDemand arithmetic + // misreported exactly this case as "no eligible activity". + pages: []*costexplorer.GetSavingsPlansCoverageOutput{ + { + SavingsPlansCoverages: []types.SavingsPlansCoverage{ + { + Coverage: &types.SavingsPlansCoverageData{ + SpendCoveredBySavingsPlans: aws.String("720"), + OnDemandCost: aws.String("0"), + CoveragePercentage: aws.String("100.00"), + }, + }, + }, + }, + }, + wantPct: floatPtr(100.0), + wantCovered: floatPtr(720.0 / windowHours), + wantOnDemand: floatPtr(0.0), + wantEligible: floatPtr(720.0 / windowHours), + wantDays: 1, + }, + { + name: "zero_eligible_leaves_pct_nil", + // Coverage block present but both covered and uncovered spend + // are zero: no SP-eligible activity at all, so there is no + // denominator and CoveragePct must be nil. + pages: []*costexplorer.GetSavingsPlansCoverageOutput{ + { + SavingsPlansCoverages: []types.SavingsPlansCoverage{ + { + Coverage: &types.SavingsPlansCoverageData{ + SpendCoveredBySavingsPlans: aws.String("0"), + OnDemandCost: aws.String("0"), + }, + }, + }, + }, + }, + wantPct: nil, + wantCovered: floatPtr(0.0), + wantOnDemand: floatPtr(0.0), + wantEligible: floatPtr(0.0), + wantDays: 1, + }, + { + name: "unparseable_covered_field", + pages: []*costexplorer.GetSavingsPlansCoverageOutput{oneSPCovPage("not-a-number", "540", "0", nil)}, + wantErr: true, + }, + { + name: "unparseable_on_demand_field", + pages: []*costexplorer.GetSavingsPlansCoverageOutput{oneSPCovPage("360", "NaN", "0", nil)}, + wantErr: true, + }, + { + name: "negative_covered_value", + pages: []*costexplorer.GetSavingsPlansCoverageOutput{oneSPCovPage("-1.0", "540", "0", nil)}, + wantErr: true, + }, + { + name: "negative_on_demand_value", + pages: []*costexplorer.GetSavingsPlansCoverageOutput{oneSPCovPage("360", "-5", "0", nil)}, + wantErr: true, + }, + { + name: "api_error_propagated", + apiErr: errors.New("CE throttled"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &mockSPCE{ + coveragePages: tt.pages, + coverageErr: tt.apiErr, + } + client := NewClientWithAPI(mock, "us-east-1") + got, err := client.GetSPCoverageSummary(context.Background(), "", lookback) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantDays, got.Days) + if tt.wantPct == nil { + assert.Nil(t, got.CoveragePct, "CoveragePct must be nil only when the eligible total is zero or Days==0") + } else { + require.NotNil(t, got.CoveragePct) + assert.InDelta(t, *tt.wantPct, *got.CoveragePct, 0.001) + } + if tt.wantCovered == nil { + assert.Nil(t, got.CoveredUSDPerHour) + } else { + require.NotNil(t, got.CoveredUSDPerHour) + assert.InDelta(t, *tt.wantCovered, *got.CoveredUSDPerHour, 0.0001) + } + if tt.wantOnDemand == nil { + assert.Nil(t, got.OnDemandUSDPerHour) + } else { + require.NotNil(t, got.OnDemandUSDPerHour) + assert.InDelta(t, *tt.wantOnDemand, *got.OnDemandUSDPerHour, 0.0001) + } + if tt.wantEligible == nil { + assert.Nil(t, got.EligibleUSDPerHour) + } else { + require.NotNil(t, got.EligibleUSDPerHour) + assert.InDelta(t, *tt.wantEligible, *got.EligibleUSDPerHour, 0.0001) + } + }) + } +} + +// TestGetSPCoverageSummary_NoRegionMeansNilFilter asserts that with an empty +// region the coverage request carries NO Filter at all. Coverage's Filter +// contract supports only LINKED_ACCOUNT, REGION, SERVICE, and INSTANCE_FAMILY +// (SAVINGS_PLANS_TYPE is utilization-only and would draw a +// ValidationException), so with no region there is nothing to filter on. +func TestGetSPCoverageSummary_NoRegionMeansNilFilter(t *testing.T) { + mock := &mockSPCE{} + client := NewClientWithAPI(mock, "us-east-1") + _, err := client.GetSPCoverageSummary(context.Background(), "", 30) + require.NoError(t, err) + require.Len(t, mock.coverageInputs, 1, "exactly one CE call expected") + in := mock.coverageInputs[0] + assert.Nil(t, in.Filter, "empty region must send no Filter at all") + assert.Equal(t, []string{spCoverageMetricSpendCovered}, in.Metrics, + "Metrics is required by the API; its sole valid value must always be sent") + // Request-shape assertions: daily granularity and a date-only window + // with start strictly before end (ISO date strings compare correctly + // as strings). + assert.Equal(t, types.GranularityDaily, in.Granularity) + require.NotNil(t, in.TimePeriod) + start, end := aws.ToString(in.TimePeriod.Start), aws.ToString(in.TimePeriod.End) + assert.Regexp(t, `^\d{4}-\d{2}-\d{2}$`, start, "TimePeriod.Start must be date-only") + assert.Regexp(t, `^\d{4}-\d{2}-\d{2}$`, end, "TimePeriod.End must be date-only") + assert.Less(t, start, end, "TimePeriod.Start must precede End") +} + +// TestGetSPCoverageSummary_PctMatchesCEReportedPct cross-checks our +// covered/(covered+onDemand) arithmetic against the CoveragePercentage that +// CE itself reports for the same figures. Guards the denominator choice: +// with covered=360 and OnDemandCost=540 (the UNCOVERED spend), CE reports +// 40%, and covered/onDemand (the old bug) would yield 66.67%. +func TestGetSPCoverageSummary_PctMatchesCEReportedPct(t *testing.T) { + const ceReportedPct = "40.00" + mock := &mockSPCE{ + coveragePages: []*costexplorer.GetSavingsPlansCoverageOutput{ + oneSPCovPage("360", "540", ceReportedPct, nil), + }, + } + client := NewClientWithAPI(mock, "us-east-1") + got, err := client.GetSPCoverageSummary(context.Background(), "", 30) + require.NoError(t, err) + require.NotNil(t, got.CoveragePct) + wantPct, parseErr := strconv.ParseFloat(ceReportedPct, 64) + require.NoError(t, parseErr) + assert.InDelta(t, wantPct, *got.CoveragePct, 0.01, + "computed covered/(covered+onDemand) must agree with CE's own CoveragePercentage") +} + +// TestGetSPCoverageSummary_PaginationCap asserts the NextToken loop stops +// with a diagnostic error at maxSPCoveragePages instead of spinning forever +// on an API that keeps returning tokens (mirrors the issue #692 guard). +func TestGetSPCoverageSummary_PaginationCap(t *testing.T) { + mock := &mockSPCE{coverageAlwaysToken: true} + client := NewClientWithAPI(mock, "us-east-1") + _, err := client.GetSPCoverageSummary(context.Background(), "", 30) + require.Error(t, err, "endless-token pagination must fail loud, not spin") + assert.Contains(t, err.Error(), "pagination cap") + assert.Len(t, mock.coverageInputs, maxSPCoveragePages, "loop must stop exactly at the cap") +} + +// TestGetSPCoverageSummary_RegionOnlyFilter asserts that a non-empty region +// produces a bare REGION dimension filter - no And wrapper, and critically +// NO SAVINGS_PLANS_TYPE dimension, which the coverage API's Filter contract +// does not support and would reject with a ValidationException. +func TestGetSPCoverageSummary_RegionOnlyFilter(t *testing.T) { + mock := &mockSPCE{} + client := NewClientWithAPI(mock, "us-east-1") + _, err := client.GetSPCoverageSummary(context.Background(), "eu-west-2", 30) + require.NoError(t, err) + require.Len(t, mock.coverageInputs, 1) + filter := mock.coverageInputs[0].Filter + require.NotNil(t, filter, "non-empty region must set a Filter") + assert.Nil(t, filter.And, "region-only filter must be a bare dimension, not an And composition") + require.NotNil(t, filter.Dimensions) + assert.Equal(t, types.DimensionRegion, filter.Dimensions.Key, + "coverage filter must be REGION-only; SAVINGS_PLANS_TYPE is unsupported by the coverage API") + assert.Equal(t, []string{"eu-west-2"}, filter.Dimensions.Values) + assert.Equal(t, []string{spCoverageMetricSpendCovered}, mock.coverageInputs[0].Metrics, + "Metrics is required by the API; its sole valid value must always be sent") +} + +// TestGetSPCoverageSummary_InvalidLookback verifies that lookbackDays <= 0 is +// an explicit error (fail loud; the caller supplies the default window). +func TestGetSPCoverageSummary_InvalidLookback(t *testing.T) { + client := NewClientWithAPI(&mockSPCE{}, "us-east-1") + for _, days := range []int{0, -1, -30} { + _, err := client.GetSPCoverageSummary(context.Background(), "", days) + require.Errorf(t, err, "expected error for lookbackDays=%d", days) + } +} + +// TestGetSPCoverageSummary_CtxCancelTerminal verifies that a canceled context +// surfaces as an error rather than a silently partial result +// (feedback_ctx_cancel_terminal). The context is canceled before the call, so +// the ctx.Err() guard fires on the FIRST loop iteration, before any CE call. +func TestGetSPCoverageSummary_CtxCancelTerminal(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // pre-cancel: ctx.Err() is non-nil on the first loop check + + // Provide a NextToken so the loop would attempt further pages if it + // ignored cancellation. + token := "page2" + mock := &mockSPCE{ + coveragePages: []*costexplorer.GetSavingsPlansCoverageOutput{ + { + SavingsPlansCoverages: []types.SavingsPlansCoverage{ + { + Coverage: &types.SavingsPlansCoverageData{ + SpendCoveredBySavingsPlans: aws.String("100"), + OnDemandCost: aws.String("200"), + }, + }, + }, + NextToken: &token, + }, + }, + } + client := NewClientWithAPI(mock, "us-east-1") + _, err := client.GetSPCoverageSummary(ctx, "", 30) + require.Error(t, err, "canceled context must surface an error, not return partial data") + assert.Empty(t, mock.coverageInputs, "guard fires before the first CE call on a pre-canceled ctx") +} + +// TestGetSPUtilization covers the happy path, nil Total/Utilization, unparseable +// fields, negative values, API errors, and invalid lookback. +func TestGetSPUtilization(t *testing.T) { + // lookbackDays=30 => windowHours=720. + const lookback = 30 + const windowHours = float64(lookback * 24) + + // CE reports total amounts for the full window; we divide by windowHours. + // $720 total commitment / 720h = $1.00/hr; $576 used / 720h = $0.80/hr. + validTotal := &costexplorer.GetSavingsPlansUtilizationOutput{ + Total: &types.SavingsPlansUtilizationAggregates{ + Utilization: &types.SavingsPlansUtilization{ + UtilizationPercentage: aws.String("80.0"), + UsedCommitment: aws.String("576"), + TotalCommitment: aws.String("720"), + }, + }, + } + + tests := []struct { + utilErr error + utilOutput *costexplorer.GetSavingsPlansUtilizationOutput + wantUtilPct *float64 + wantUsed *float64 + wantTotal *float64 + name string + wantErr bool + }{ + { + name: "happy_path", + utilOutput: validTotal, + wantUtilPct: floatPtr(80.0), + wantUsed: floatPtr(576.0 / windowHours), + wantTotal: floatPtr(720.0 / windowHours), + }, + { + name: "nil_total_returns_empty_summary", + utilOutput: &costexplorer.GetSavingsPlansUtilizationOutput{Total: nil}, + }, + { + name: "nil_utilization_returns_empty_summary", + utilOutput: &costexplorer.GetSavingsPlansUtilizationOutput{ + Total: &types.SavingsPlansUtilizationAggregates{Utilization: nil}, + }, + }, + { + name: "unparseable_utilization_pct", + utilOutput: &costexplorer.GetSavingsPlansUtilizationOutput{ + Total: &types.SavingsPlansUtilizationAggregates{ + Utilization: &types.SavingsPlansUtilization{ + UtilizationPercentage: aws.String("not-a-number"), + }, + }, + }, + wantErr: true, + }, + { + name: "negative_used_commitment", + utilOutput: &costexplorer.GetSavingsPlansUtilizationOutput{ + Total: &types.SavingsPlansUtilizationAggregates{ + Utilization: &types.SavingsPlansUtilization{ + UtilizationPercentage: aws.String("80.0"), + UsedCommitment: aws.String("-576"), + }, + }, + }, + wantErr: true, + }, + { + name: "api_error_propagated", + utilErr: errors.New("CE throttled"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &mockSPCE{ + utilOutput: tt.utilOutput, + utilErr: tt.utilErr, + } + client := NewClientWithAPI(mock, "us-east-1") + got, err := client.GetSPUtilization(context.Background(), types.SupportedSavingsPlansTypeComputeSp, "", lookback) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + if tt.wantUtilPct == nil { + assert.Nil(t, got.UtilizationPct) + } else { + require.NotNil(t, got.UtilizationPct) + assert.InDelta(t, *tt.wantUtilPct, *got.UtilizationPct, 0.001) + } + if tt.wantUsed == nil { + assert.Nil(t, got.UsedCommitmentUSDPerHour) + } else { + require.NotNil(t, got.UsedCommitmentUSDPerHour) + assert.InDelta(t, *tt.wantUsed, *got.UsedCommitmentUSDPerHour, 0.0001) + } + if tt.wantTotal == nil { + assert.Nil(t, got.TotalCommitmentUSDPerHour) + } else { + require.NotNil(t, got.TotalCommitmentUSDPerHour) + assert.InDelta(t, *tt.wantTotal, *got.TotalCommitmentUSDPerHour, 0.0001) + } + }) + } +} + +// TestGetSPUtilization_FilterPerPlanType asserts the CE Filter is a bare +// SAVINGS_PLANS_TYPE dimension carrying the CamelCase DIMENSION-vocabulary +// value (e.g. "ComputeSavingsPlans"), NOT the parameter-enum spelling +// ("COMPUTE_SP") which CE silently matches nothing on, for every plan type +// the SDK defines. +func TestGetSPUtilization_FilterPerPlanType(t *testing.T) { + for _, planType := range allSPPlanTypes { + t.Run(string(planType), func(t *testing.T) { + mock := &mockSPCE{} + client := NewClientWithAPI(mock, "us-east-1") + _, err := client.GetSPUtilization(context.Background(), planType, "", 30) + require.NoError(t, err) + require.Len(t, mock.utilInputs, 1, "exactly one CE call expected") + filter := mock.utilInputs[0].Filter + require.NotNil(t, filter, "Filter must be set") + assert.Nil(t, filter.And, "no region => bare plan-type dimension, no And wrapper") + require.NotNil(t, filter.Dimensions) + assert.Equal(t, types.DimensionSavingsPlansType, filter.Dimensions.Key) + assert.Equal(t, []string{wantSPDimensionValues[planType]}, filter.Dimensions.Values, + "dimension value must be the CamelCase vocabulary, not the parameter enum") + assert.NotEqual(t, []string{string(planType)}, filter.Dimensions.Values, + "parameter-enum spelling must never reach the dimension filter") + }) + } +} + +// TestGetSPUtilization_RegionFilterANDed asserts that a non-empty region +// produces an And expression combining the SAVINGS_PLANS_TYPE and REGION +// dimensions. +func TestGetSPUtilization_RegionFilterANDed(t *testing.T) { + mock := &mockSPCE{} + client := NewClientWithAPI(mock, "us-east-1") + _, err := client.GetSPUtilization(context.Background(), types.SupportedSavingsPlansTypeComputeSp, "us-west-2", 30) + require.NoError(t, err) + require.Len(t, mock.utilInputs, 1) + in := mock.utilInputs[0] + filter := in.Filter + require.NotNil(t, filter) + assert.Nil(t, filter.Dimensions, "region set => And composition, not a bare dimension") + require.Len(t, filter.And, 2, "And must combine plan-type and region dimensions") + require.NotNil(t, filter.And[0].Dimensions) + assert.Equal(t, types.DimensionSavingsPlansType, filter.And[0].Dimensions.Key) + assert.Equal(t, []string{"ComputeSavingsPlans"}, filter.And[0].Dimensions.Values, + "dimension value must be the CamelCase vocabulary, not the COMPUTE_SP parameter enum") + require.NotNil(t, filter.And[1].Dimensions) + assert.Equal(t, types.DimensionRegion, filter.And[1].Dimensions.Key) + assert.Equal(t, []string{"us-west-2"}, filter.And[1].Dimensions.Values) + // Request-shape assertions: daily granularity and a date-only window + // with start strictly before end. + assert.Equal(t, types.GranularityDaily, in.Granularity) + require.NotNil(t, in.TimePeriod) + start, end := aws.ToString(in.TimePeriod.Start), aws.ToString(in.TimePeriod.End) + assert.Regexp(t, `^\d{4}-\d{2}-\d{2}$`, start, "TimePeriod.Start must be date-only") + assert.Regexp(t, `^\d{4}-\d{2}-\d{2}$`, end, "TimePeriod.End must be date-only") + assert.Less(t, start, end, "TimePeriod.Start must precede End") +} + +// TestGetSPUtilization_InvalidPlanType verifies that empty or unknown +// plan-type values are rejected at the boundary with an explicit error and +// no CE call is made. +func TestGetSPUtilization_InvalidPlanType(t *testing.T) { + for _, planType := range []types.SupportedSavingsPlansType{"", "NOT_A_PLAN_TYPE"} { + t.Run(string(planType), func(t *testing.T) { + mock := &mockSPCE{} + client := NewClientWithAPI(mock, "us-east-1") + _, err := client.GetSPUtilization(context.Background(), planType, "", 30) + require.Errorf(t, err, "expected error for planType %q", planType) + assert.Empty(t, mock.utilInputs, "invalid plan type must not reach CE") + }) + } +} + +// TestGetSPUtilization_InvalidLookback verifies that lookbackDays <= 0 is an +// explicit error (fail loud; the caller supplies the default window). +func TestGetSPUtilization_InvalidLookback(t *testing.T) { + client := NewClientWithAPI(&mockSPCE{}, "us-east-1") + for _, days := range []int{0, -1} { + _, err := client.GetSPUtilization(context.Background(), types.SupportedSavingsPlansTypeComputeSp, "", days) + require.Errorf(t, err, "expected error for lookbackDays=%d", days) + } +} + +// TestParseSPFloat covers the strict float parsing contract: valid values pass, +// unparseable strings error, NaN/Inf error, and negative values error. +func TestParseSPFloat(t *testing.T) { + tests := []struct { + name string + input string + want float64 + wantErr bool + }{ + {name: "zero", input: "0", want: 0}, + {name: "positive_decimal", input: "66.667", want: 66.667}, + {name: "large_value", input: "1000000.50", want: 1000000.50}, + {name: "empty_string", input: "", wantErr: true}, + {name: "not_a_number", input: "foo", wantErr: true}, + {name: "nan_string", input: "NaN", wantErr: true}, + {name: "inf_string", input: "Inf", wantErr: true}, + {name: "negative_inf_string", input: "-Inf", wantErr: true}, + {name: "negative_value", input: "-1.0", wantErr: true}, + {name: "negative_zero_is_fine", input: "-0", want: 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseSPFloat(tt.input, "testField") + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.InDelta(t, tt.want, got, 0.0001) + }) + } +}