diff --git a/providers/aws/recommendations/sp_coverage.go b/providers/aws/recommendations/sp_coverage.go index d4162997..ea43dd77 100644 --- a/providers/aws/recommendations/sp_coverage.go +++ b/providers/aws/recommendations/sp_coverage.go @@ -2,6 +2,7 @@ package recommendations import ( "context" + "errors" "fmt" "math" "strconv" @@ -70,6 +71,35 @@ type SPUtilizationSummary struct { // TotalCommitmentUSDPerHour is the average total SP commitment in $/hr. // Nil when CE returned no data. TotalCommitmentUSDPerHour *float64 + // OnDemandCostEquivalentUSDPerHour is the average on-demand price of the + // usage the SP commitment covered, in $/hr (CE Savings.OnDemandCostEquivalent + // divided by windowHours). Nil when CE returned no savings data. + OnDemandCostEquivalentUSDPerHour *float64 +} + +// ErrNoSPUtilization means CE has no usable utilization for the scope (no +// plan, a plan too new for CE to report, or 0% used), so the +// commitment-to-on-demand ratio cannot be derived. Callers must surface an +// explainable no-op rather than fall back to a 1:1 ratio. +var ErrNoSPUtilization = errors.New("sp utilization: no used commitment or on-demand equivalent reported") + +// CommitmentToOnDemandRatio returns k = used commitment / on-demand-equivalent +// cost, computed from the window totals (not an average of daily ratios): +// one commitment dollar covers 1/k on-demand dollars of usage. k must satisfy +// 0 < k <= 1 (a plan cannot cost more than the usage it covers); k > 1 would +// shrink a commitment converted to on-demand terms (E/k) and over-buy, so it +// is an error. Missing or zero used/equivalent returns ErrNoSPUtilization. +func (s SPUtilizationSummary) CommitmentToOnDemandRatio() (float64, error) { + if s.UsedCommitmentUSDPerHour == nil || s.OnDemandCostEquivalentUSDPerHour == nil || + *s.UsedCommitmentUSDPerHour == 0 || *s.OnDemandCostEquivalentUSDPerHour == 0 { + return 0, ErrNoSPUtilization + } + k := *s.UsedCommitmentUSDPerHour / *s.OnDemandCostEquivalentUSDPerHour + if k <= 0 || k > 1 { + return 0, fmt.Errorf("sp utilization: commitment/on-demand ratio %g is outside (0, 1]; used=%g on-demand-equivalent=%g", + k, *s.UsedCommitmentUSDPerHour, *s.OnDemandCostEquivalentUSDPerHour) + } + return k, nil } // spCoverageMetricSpendCovered is the value for the REQUIRED Metrics @@ -475,26 +505,36 @@ func buildSPUtilizationSummary(result *costexplorer.GetSavingsPlansUtilizationOu 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 + var err error + if summary.UsedCommitmentUSDPerHour, err = spRatePerHour(u.UsedCommitment, "UsedCommitment", windowHours); err != nil { + return SPUtilizationSummary{}, err } - if u.TotalCommitment != nil { - v, err := parseSPFloat(aws.ToString(u.TotalCommitment), "TotalCommitment") - if err != nil { + if summary.TotalCommitmentUSDPerHour, err = spRatePerHour(u.TotalCommitment, "TotalCommitment", windowHours); err != nil { + return SPUtilizationSummary{}, err + } + if result.Total.Savings != nil { + if summary.OnDemandCostEquivalentUSDPerHour, err = spRatePerHour(result.Total.Savings.OnDemandCostEquivalent, "OnDemandCostEquivalent", windowHours); err != nil { return SPUtilizationSummary{}, err } - rate := v / windowHours - summary.TotalCommitmentUSDPerHour = &rate } } return summary, nil } +// spRatePerHour converts a CE window-total dollar string into a $/hr pointer; +// nil in, nil out (CE returned no value). +func spRatePerHour(total *string, field string, windowHours float64) (*float64, error) { + if total == nil { + return nil, nil + } + v, err := parseSPFloat(aws.ToString(total), field) + if err != nil { + return nil, err + } + rate := v / windowHours + return &rate, 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 diff --git a/providers/aws/recommendations/sp_coverage_series.go b/providers/aws/recommendations/sp_coverage_series.go new file mode 100644 index 00000000..403fca76 --- /dev/null +++ b/providers/aws/recommendations/sp_coverage_series.go @@ -0,0 +1,160 @@ +package recommendations + +import ( + "context" + "fmt" + "sort" + "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" +) + +// spCoverageServiceEC2 is the SERVICE dimension value used to scope +// GetSavingsPlansCoverage to EC2 compute, matching the EC2-only on-demand +// residual series so covered and uncovered demand describe the same usage. +// +// MANUAL VERIFICATION REQUIRED (mock-unprovable): this string is verified +// offline only for the GetCostAndUsage and RI-coverage filters +// (ec2ComputeService). Whether GetSavingsPlansCoverage accepts the same +// vocabulary has not been confirmed against a live CE response; a wrong value +// makes CE match nothing (complete $0 rows, no error). Keep it a single named +// constant so a correction is one line. +const spCoverageServiceEC2 = "Amazon Elastic Compute Cloud - Compute" + +// spCoverageSeriesFilter builds the GetSavingsPlansCoverage Filter for the +// covered-demand series: SERVICE = EC2 compute AND REGION = region. Both are +// supported coverage dimensions (see spCoverageRegionFilter). region is +// required: the covered series is paired with the region-scoped residual. +func spCoverageSeriesFilter(region string) *types.Expression { + return &types.Expression{ + And: []types.Expression{ + {Dimensions: &types.DimensionValues{ + Key: types.DimensionService, + Values: []string{spCoverageServiceEC2}, + }}, + {Dimensions: &types.DimensionValues{ + Key: types.DimensionRegion, + Values: []string{region}, + }}, + }, + } +} + +// GetSPCoveredSeries returns the daily EC2 usage covered by Savings Plans in +// region over the past lookbackDays days, ordered oldest-to-newest with +// strictly increasing unique UTC days. +// +// Units: CE's SpendCoveredBySavingsPlans is ON-DEMAND-EQUIVALENT dollars (the +// price the covered usage would have cost at on-demand rates), the same unit +// as the uncovered on-demand residual from GetOnDemandSeries. Each day's total +// is divided by 24 (DAILY granularity) to give USD/hr. It is NOT the SP +// commitment consumed; convert commitment to this unit with +// SPUtilizationSummary.CommitmentToOnDemandRatio. +// +// A row whose Coverage block is nil counts as zero covered (precedent: +// spCoverageAccumulator.add; CE omits the block for days without activity). +// An empty response yields an empty series and no error: an account without +// Savings Plans legitimately has no covered usage, and the caller joins this +// with the residual series. A non-nil Coverage block without +// SpendCoveredBySavingsPlans, an undatable row, a duplicate date, or an +// unparseable amount fails loud. +func (c *Client) GetSPCoveredSeries(ctx context.Context, region string, lookbackDays int) ([]DailyCost, error) { + if lookbackDays <= 0 { + return nil, fmt.Errorf("sp covered series: lookbackDays must be > 0, got %d", lookbackDays) + } + if region == "" { + return nil, fmt.Errorf("sp covered series: region must not be empty") + } + end := time.Now().UTC().Truncate(24 * time.Hour) + start := end.AddDate(0, 0, -lookbackDays) + input := &costexplorer.GetSavingsPlansCoverageInput{ + TimePeriod: &types.DateInterval{ + Start: aws.String(start.Format(ceDateLayout)), + End: aws.String(end.Format(ceDateLayout)), + }, + Granularity: types.GranularityDaily, + Metrics: []string{spCoverageMetricSpendCovered}, + Filter: spCoverageSeriesFilter(region), + } + + byDate, err := c.fetchSPCoveredByDate(ctx, input) + if err != nil { + return nil, err + } + return sortedDailyCosts(byDate) +} + +// fetchSPCoveredByDate pages through GetSavingsPlansCoverage and returns +// USD/hr keyed by "YYYY-MM-DD". +func (c *Client) fetchSPCoveredByDate(ctx context.Context, input *costexplorer.GetSavingsPlansCoverageInput) (map[string]float64, error) { + byDate := make(map[string]float64) + var token *string + for pageIdx := 0; ; pageIdx++ { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("sp covered series: pagination canceled: %w", err) + } + if pageIdx >= maxSPCoveragePages { + return nil, fmt.Errorf("sp covered series: pagination cap reached after %d pages", maxSPCoveragePages) + } + input.NextToken = token + result, err := c.fetchSPCoveragePage(ctx, input) + if err != nil { + return nil, err + } + if err := accumulateSPCoveredDays(byDate, result.SavingsPlansCoverages); err != nil { + return nil, fmt.Errorf("sp covered series: %w", err) + } + if result.NextToken == nil || *result.NextToken == "" { + return byDate, nil + } + token = result.NextToken + } +} + +// sortedDailyCosts orders a date-keyed map oldest-to-newest (lexicographic +// order of "YYYY-MM-DD" is chronological; map keys are unique). +func sortedDailyCosts(byDate map[string]float64) ([]DailyCost, error) { + dates := make([]string, 0, len(byDate)) + for d := range byDate { + dates = append(dates, d) + } + sort.Strings(dates) + series := make([]DailyCost, len(dates)) + for i, d := range dates { + day, err := time.Parse(ceDateLayout, d) + if err != nil { + return nil, fmt.Errorf("sp covered series: cannot parse CE period start date %q: %w", d, err) + } + series[i] = DailyCost{Date: day, USDPerHour: byDate[d]} + } + return series, nil +} + +// accumulateSPCoveredDays merges one page of daily coverage rows into byDate +// (USD/hr per "YYYY-MM-DD"). See GetSPCoveredSeries for the fail-loud rules. +func accumulateSPCoveredDays(byDate map[string]float64, rows []types.SavingsPlansCoverage) error { + for i, r := range rows { + if r.TimePeriod == nil || r.TimePeriod.Start == nil { + return fmt.Errorf("CE coverage row %d is missing its period start; an undatable row would hide a gap in the daily series", i) + } + date := aws.ToString(r.TimePeriod.Start) + if _, dup := byDate[date]; dup { + return fmt.Errorf("CE returned more than one coverage row for day %s", date) + } + var usd float64 + if r.Coverage != nil { + if r.Coverage.SpendCoveredBySavingsPlans == nil { + return fmt.Errorf("CE coverage row for day %s has no %s value; CE echoes the requested metric on every row", date, spCoverageMetricSpendCovered) + } + v, err := parseSPFloat(aws.ToString(r.Coverage.SpendCoveredBySavingsPlans), spCoverageMetricSpendCovered) + if err != nil { + return fmt.Errorf("day %s: %w", date, err) + } + usd = v + } + byDate[date] = usd / 24.0 + } + return nil +} diff --git a/providers/aws/recommendations/sp_coverage_series_test.go b/providers/aws/recommendations/sp_coverage_series_test.go new file mode 100644 index 00000000..d4ae8395 --- /dev/null +++ b/providers/aws/recommendations/sp_coverage_series_test.go @@ -0,0 +1,143 @@ +package recommendations + +import ( + "context" + "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" +) + +func covDay(date, spend string) types.SavingsPlansCoverage { + c := types.SavingsPlansCoverage{TimePeriod: &types.DateInterval{Start: aws.String(date), End: aws.String(date)}} + if spend != "" { + c.Coverage = &types.SavingsPlansCoverageData{SpendCoveredBySavingsPlans: aws.String(spend)} + } + return c +} + +func TestGetSPCoveredSeries(t *testing.T) { + page1 := &costexplorer.GetSavingsPlansCoverageOutput{ + SavingsPlansCoverages: []types.SavingsPlansCoverage{ + covDay("2026-01-02", "240"), // 240/24 = 10 OD$/hr + covDay("2026-01-01", "120"), + }, + NextToken: aws.String("p2"), + } + page2 := &costexplorer.GetSavingsPlansCoverageOutput{ + SavingsPlansCoverages: []types.SavingsPlansCoverage{ + covDay("2026-01-03", ""), // nil Coverage block counts as zero + }, + } + mock := &mockSPCE{coveragePages: []*costexplorer.GetSavingsPlansCoverageOutput{page1, page2}} + got, err := NewClientWithAPI(mock, "us-east-1").GetSPCoveredSeries(context.Background(), "us-east-1", 30) + require.NoError(t, err) + require.Len(t, got, 3) + assert.Equal(t, "2026-01-01", got[0].Date.Format(ceDateLayout), "sorted oldest-to-newest") + assert.InDelta(t, 5.0, got[0].USDPerHour, 1e-9) + assert.InDelta(t, 10.0, got[1].USDPerHour, 1e-9) + assert.Zero(t, got[2].USDPerHour) + require.Len(t, mock.coverageInputs, 2) + assert.Equal(t, types.GranularityDaily, mock.coverageInputs[0].Granularity) + assert.Equal(t, []string{spCoverageMetricSpendCovered}, mock.coverageInputs[0].Metrics) +} + +// TestGetSPCoveredSeries_Filter pins the SERVICE and REGION clauses. The +// SERVICE string is unverified against live CE (see spCoverageServiceEC2); +// this test asserts the clause exists and carries the named constant, so +// dropping it (which would pull Fargate/Lambda into EC2 demand and over-buy) +// fails here. +func TestGetSPCoveredSeries_Filter(t *testing.T) { + mock := &mockSPCE{} + _, err := NewClientWithAPI(mock, "us-east-1").GetSPCoveredSeries(context.Background(), "eu-west-2", 30) + require.NoError(t, err) + require.Len(t, mock.coverageInputs, 1) + f := mock.coverageInputs[0].Filter + require.NotNil(t, f) + require.Len(t, f.And, 2) + got := map[types.Dimension][]string{} + for _, e := range f.And { + require.NotNil(t, e.Dimensions) + got[e.Dimensions.Key] = e.Dimensions.Values + } + assert.Equal(t, []string{spCoverageServiceEC2}, got[types.DimensionService]) + assert.Equal(t, []string{"eu-west-2"}, got[types.DimensionRegion]) + assert.NotContains(t, got, types.DimensionSavingsPlansType, "coverage rejects SAVINGS_PLANS_TYPE") +} + +func TestGetSPCoveredSeries_EmptyIsNotAnError(t *testing.T) { + got, err := NewClientWithAPI(&mockSPCE{}, "us-east-1").GetSPCoveredSeries(context.Background(), "us-east-1", 30) + require.NoError(t, err) + assert.Empty(t, got, "an account without SPs has no covered usage") +} + +func TestGetSPCoveredSeries_FailLoud(t *testing.T) { + undated := types.SavingsPlansCoverage{Coverage: &types.SavingsPlansCoverageData{SpendCoveredBySavingsPlans: aws.String("1")}} + noMetric := covDay("2026-01-01", "") + noMetric.Coverage = &types.SavingsPlansCoverageData{} + tests := []struct { + name string + rows []types.SavingsPlansCoverage + }{ + {"undated row", []types.SavingsPlansCoverage{undated}}, + {"missing metric in non-nil block", []types.SavingsPlansCoverage{noMetric}}, + {"unparseable", []types.SavingsPlansCoverage{covDay("2026-01-01", "abc")}}, + {"negative", []types.SavingsPlansCoverage{covDay("2026-01-01", "-5")}}, + {"duplicate day", []types.SavingsPlansCoverage{covDay("2026-01-01", "1"), covDay("2026-01-01", "2")}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mock := &mockSPCE{coveragePages: []*costexplorer.GetSavingsPlansCoverageOutput{{SavingsPlansCoverages: tc.rows}}} + _, err := NewClientWithAPI(mock, "us-east-1").GetSPCoveredSeries(context.Background(), "us-east-1", 30) + require.Error(t, err) + }) + } +} + +func TestGetSPCoveredSeries_InvalidArgs(t *testing.T) { + c := NewClientWithAPI(&mockSPCE{}, "us-east-1") + _, err := c.GetSPCoveredSeries(context.Background(), "", 30) + require.Error(t, err) + _, err = c.GetSPCoveredSeries(context.Background(), "us-east-1", 0) + require.Error(t, err) +} + +func TestSPUtilization_OnDemandEquivalentAndRatio(t *testing.T) { + // Window totals over 30 days (720h): used 3024 => 4.2/hr, on-demand + // equivalent 6000 => 8.3333/hr; k = 0.504. + out := &costexplorer.GetSavingsPlansUtilizationOutput{Total: &types.SavingsPlansUtilizationAggregates{ + Utilization: &types.SavingsPlansUtilization{UsedCommitment: aws.String("3024"), TotalCommitment: aws.String("4320")}, + Savings: &types.SavingsPlansSavings{OnDemandCostEquivalent: aws.String("6000")}, + }} + s, err := NewClientWithAPI(&mockSPCE{utilOutput: out}, "us-east-1"). + GetSPUtilization(context.Background(), types.SupportedSavingsPlansTypeComputeSp, "", 30) + require.NoError(t, err) + require.NotNil(t, s.OnDemandCostEquivalentUSDPerHour) + assert.InDelta(t, 6000.0/720, *s.OnDemandCostEquivalentUSDPerHour, 1e-9) + k, err := s.CommitmentToOnDemandRatio() + require.NoError(t, err) + assert.InDelta(t, 0.504, k, 1e-9) +} + +func TestCommitmentToOnDemandRatio_Errors(t *testing.T) { + f := func(v float64) *float64 { return &v } + _, e0 := SPUtilizationSummary{}.CommitmentToOnDemandRatio() + assert.ErrorIs(t, e0, ErrNoSPUtilization) + for name, s := range map[string]SPUtilizationSummary{ + "zero used": {UsedCommitmentUSDPerHour: f(0), OnDemandCostEquivalentUSDPerHour: f(5)}, + "zero od": {UsedCommitmentUSDPerHour: f(5), OnDemandCostEquivalentUSDPerHour: f(0)}, + "nil od": {UsedCommitmentUSDPerHour: f(5)}, + } { + _, err := s.CommitmentToOnDemandRatio() + assert.ErrorIs(t, err, ErrNoSPUtilization, name) + } + _, err := SPUtilizationSummary{UsedCommitmentUSDPerHour: f(6), OnDemandCostEquivalentUSDPerHour: f(5)}.CommitmentToOnDemandRatio() + require.Error(t, err) + assert.NotErrorIs(t, err, ErrNoSPUtilization, "k > 1 is a data error, not a missing-data Hold") + k, err := SPUtilizationSummary{UsedCommitmentUSDPerHour: f(5), OnDemandCostEquivalentUSDPerHour: f(5)}.CommitmentToOnDemandRatio() + require.NoError(t, err) + assert.InDelta(t, 1.0, k, 1e-12, "k == 1 (no discount) is valid") +}