diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aae237..7dca0ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added +- **`recommendations.Client.GetSPCommitmentRate`:** commitment dollars per + on-demand dollar (r) of the used part of a new Savings Plan, derived from CE + purchase recommendations as `(utilization/100) * EstimatedSPCost / + (EstimatedSPCost + EstimatedSavingsAmount)` (ratio of sums over matching + details). Strict raw-detail parsing; an absent field or no matching detail is + `ErrNoSPCommitmentRate`, never a 1:1 fallback. Nothing consumes it yet + (go#349, groundwork for converting on-demand ladder sizing to commitment + dollars). + - **`recommendations.ApplyFamilyNUSizingRDSWithExpiryOptions`:** the rebuy-window RDS family sizing, plus credit for replacements already bought. Coverage is a lookback average, so RIs bought inside `CoverageLookbackDays` diff --git a/providers/aws/recommendations/sp_commitment_rate.go b/providers/aws/recommendations/sp_commitment_rate.go new file mode 100644 index 0000000..ba9784d --- /dev/null +++ b/providers/aws/recommendations/sp_commitment_rate.go @@ -0,0 +1,188 @@ +package recommendations + +import ( + "context" + "errors" + "fmt" + "math" + "strconv" + + "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" +) + +// ErrNoSPCommitmentRate means Cost Explorer produced no usable Savings Plan +// recommendation for the requested scope (no matching detail, a field the +// formula needs is absent, or no covered on-demand spend). Callers must treat +// the layer as having no rate (explainable Hold); they must never substitute a +// 1:1 rate. +var ErrNoSPCommitmentRate = errors.New("sp commitment rate: no usable recommendation detail") + +// SPCommitmentRateParams selects the recommendation details a rate is derived +// from. All fields are required. +type SPCommitmentRateParams struct { + // PlanType is COMPUTE_SP or EC2_INSTANCE_SP. + PlanType types.SupportedSavingsPlansType + // AccountID restricts details to this (linked) account. + AccountID string + // Region restricts details to this region. CE reports it on + // SavingsPlansDetails only for EC2 Instance SP; Compute SP details carry no + // region, so for COMPUTE_SP the result is the account-wide blend across + // EC2, Lambda and Fargate (an upper bound on the EC2 rate, see go#349 D6). + Region string + // Term is 1yr or 3yr; PaymentOption and LookbackPeriod use the same + // vocabulary as common.RecommendationParams. + Term string + PaymentOption string + LookbackPeriod string +} + +// GetSPCommitmentRate returns r: commitment dollars per on-demand dollar for +// the USED part of a new Savings Plan, derived from CE purchase +// recommendations. +// +// EstimatedSavingsPercentage is measured against ALL applicable on-demand +// spend, not just the covered part, so it cannot be used. Per detail: +// +// covered_od = EstimatedSPCost + EstimatedSavingsAmount +// r = (EstimatedAverageUtilization/100) * EstimatedSPCost / covered_od +// +// summed over the matching details (ratio of sums; every detail shares the +// lookback so the hours cancel). The raw detail is parsed strictly: an absent +// field is ErrNoSPCommitmentRate (never degraded to 0, which would give +// r == utilization ~ 1:1), an unparseable or non-finite one is an error, and +// a non-positive r (zero utilization) is an error; r <= 1 holds by construction +// (non-negative savings, utilization <= 100). +// +// MANUAL VERIFICATION REQUIRED before relying on it with real money: the +// semantics of the CE cost fields are verified only against the SDK docs and +// a recorded fixture, not a live response. +func (c *Client) GetSPCommitmentRate(ctx context.Context, p SPCommitmentRateParams) (float64, error) { + input, err := spRateInput(p) + if err != nil { + return 0, err + } + spCost, covered, matched, err := c.sumSPRateDetails(ctx, input, &p) + if err != nil { + return 0, err + } + if matched == 0 || covered <= 0 { + return 0, fmt.Errorf("%w: plan=%s region=%q term=%s payment=%s (%d matching details)", + ErrNoSPCommitmentRate, p.PlanType, p.Region, p.Term, p.PaymentOption, matched) + } + r := spCost / covered + if math.IsNaN(r) || r <= 0 { + return 0, fmt.Errorf("sp commitment rate: r=%g is not positive (used spend %g, covered on-demand %g)", r, spCost, covered) + } + return r, nil +} + +// sumSPRateDetails pages through the recommendation and sums the used SP spend +// and covered on-demand spend of every matching detail. +func (c *Client) sumSPRateDetails(ctx context.Context, input *costexplorer.GetSavingsPlansPurchaseRecommendationInput, p *SPCommitmentRateParams) (spCost, covered float64, matched int, err error) { + for page := 0; ; page++ { + if page >= maxRecommendationPages { + return 0, 0, 0, fmt.Errorf("sp commitment rate: pagination cap reached after %d pages", maxRecommendationPages) + } + out, fetchErr := c.fetchSPPageWithRetry(ctx, input) + if fetchErr != nil { + return 0, 0, 0, fmt.Errorf("sp commitment rate: %w", fetchErr) + } + if out == nil { + break + } + if rec := out.SavingsPlansPurchaseRecommendation; rec != nil { + for i := range rec.SavingsPlansPurchaseRecommendationDetails { + d := &rec.SavingsPlansPurchaseRecommendationDetails[i] + if !spRateDetailMatches(d, p) { + continue + } + used, cov, err := spRateTerms(d) + if err != nil { + return 0, 0, 0, err + } + spCost += used + covered += cov + matched++ + } + } + input.NextPageToken = out.NextPageToken + if aws.ToString(input.NextPageToken) == "" { + break + } + } + return spCost, covered, matched, nil +} + +func spRateInput(p SPCommitmentRateParams) (*costexplorer.GetSavingsPlansPurchaseRecommendationInput, error) { + if p.PlanType != types.SupportedSavingsPlansTypeComputeSp && p.PlanType != types.SupportedSavingsPlansTypeEc2InstanceSp { + return nil, fmt.Errorf("sp commitment rate: unsupported plan type %q", p.PlanType) + } + if p.AccountID == "" || p.Region == "" { + return nil, errors.New("sp commitment rate: AccountID and Region are required") + } + payment, err := convertSavingsPlansPaymentOption(p.PaymentOption) + if err != nil { + return nil, fmt.Errorf("sp commitment rate: payment option: %w", err) + } + term, err := convertSavingsPlansTermInYears(p.Term) + if err != nil { + return nil, fmt.Errorf("sp commitment rate: term: %w", err) + } + lookback, err := convertSavingsPlansLookbackPeriod(p.LookbackPeriod) + if err != nil { + return nil, fmt.Errorf("sp commitment rate: lookback: %w", err) + } + return &costexplorer.GetSavingsPlansPurchaseRecommendationInput{ + SavingsPlansType: p.PlanType, PaymentOption: payment, TermInYears: term, + LookbackPeriodInDays: lookback, AccountScope: types.AccountScopeLinked, + }, nil +} + +// spRateDetailMatches filters details to the requested account and, for EC2 +// Instance SP, region. +func spRateDetailMatches(d *types.SavingsPlansPurchaseRecommendationDetail, p *SPCommitmentRateParams) bool { + if aws.ToString(d.AccountId) != p.AccountID { + return false + } + if p.PlanType != types.SupportedSavingsPlansTypeEc2InstanceSp { + return true + } + return d.SavingsPlansDetails != nil && + normalizeRegionName(aws.ToString(d.SavingsPlansDetails.Region)) == p.Region +} + +// spRateTerms returns the used SP spend and the covered on-demand spend of one +// detail, parsed strictly. +func spRateTerms(d *types.SavingsPlansPurchaseRecommendationDetail) (usedSPCost, coveredOD float64, err error) { + spCost, err := spRateField("EstimatedSPCost", d.EstimatedSPCost) + if err != nil { + return 0, 0, err + } + savings, err := spRateField("EstimatedSavingsAmount", d.EstimatedSavingsAmount) + if err != nil { + return 0, 0, err + } + utilPct, err := spRateField("EstimatedAverageUtilization", d.EstimatedAverageUtilization) + if err != nil { + return 0, 0, err + } + if utilPct > 100 { + return 0, 0, fmt.Errorf("sp commitment rate: EstimatedAverageUtilization %g exceeds 100", utilPct) + } + return utilPct / 100 * spCost, spCost + savings, nil +} + +// spRateField parses a required non-negative finite CE string field. Absent is +// ErrNoSPCommitmentRate; present-but-invalid is a plain error. +func spRateField(name string, v *string) (float64, error) { + if v == nil { + return 0, fmt.Errorf("%w: %s absent", ErrNoSPCommitmentRate, name) + } + f, err := strconv.ParseFloat(*v, 64) + if err != nil || math.IsNaN(f) || math.IsInf(f, 0) || f < 0 { + return 0, fmt.Errorf("sp commitment rate: %s %q is not a non-negative finite number", name, *v) + } + return f, nil +} diff --git a/providers/aws/recommendations/sp_commitment_rate_test.go b/providers/aws/recommendations/sp_commitment_rate_test.go new file mode 100644 index 0000000..93ccfe0 --- /dev/null +++ b/providers/aws/recommendations/sp_commitment_rate_test.go @@ -0,0 +1,165 @@ +package recommendations + +import ( + "context" + "errors" + "math" + "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" +) + +const lookback30dHours = 720.0 + +// rateDetail builds a recorded-shape CE detail. All amounts are lookback +// totals as CE returns them. +func rateDetail(acct, region, spCost, savings, util string) types.SavingsPlansPurchaseRecommendationDetail { + d := types.SavingsPlansPurchaseRecommendationDetail{ + AccountId: aws.String(acct), + EstimatedSPCost: aws.String(spCost), + EstimatedSavingsAmount: aws.String(savings), + EstimatedAverageUtilization: aws.String(util), + // Decoy: measured against ALL on-demand spend; must not drive r. + EstimatedSavingsPercentage: aws.String("18"), + } + if region != "" { + d.SavingsPlansDetails = &types.SavingsPlansDetails{Region: aws.String(region)} + } + return d +} + +func rateClient(details ...types.SavingsPlansPurchaseRecommendationDetail) *Client { + return NewClientWithAPI(&mockCostExplorerAPI{ + spRecommendations: &costexplorer.GetSavingsPlansPurchaseRecommendationOutput{ + SavingsPlansPurchaseRecommendation: &types.SavingsPlansPurchaseRecommendation{ + SavingsPlansPurchaseRecommendationDetails: details, + }, + }, + }, "us-east-1") +} + +func rateParams(pt types.SupportedSavingsPlansType) SPCommitmentRateParams { + return SPCommitmentRateParams{PlanType: pt, AccountID: "acct-a", Region: "us-east-1", + Term: "1yr", PaymentOption: "no-upfront", LookbackPeriod: "30d"} +} + +func TestGetSPCommitmentRate_FixtureCrossCheck(t *testing.T) { + // OD 1.00/hr over 720h = 720; CE says 300 stays on-demand with the SP, so + // covered_od = 420 = SPCost 294 + savings 126: r = 0.7 at full utilization. + // The 18% savings-vs-all-OD decoy would give 0.82 (the rejected formula). + currentOD := 1.0 * lookback30dHours + remainingOD := 300.0 + d := rateDetail("acct-a", "US East (N. Virginia)", "294", "126", "100") + d.CurrentAverageHourlyOnDemandSpend = aws.String("1.0") + d.EstimatedOnDemandCost = aws.String(strconv.FormatFloat(remainingOD, 'f', -1, 64)) + if got := currentOD - remainingOD; got != 420 { + t.Fatalf("fixture inconsistent: covered_od cross-check %g != 420", got) + } + r, err := rateClient(d).GetSPCommitmentRate(context.Background(), rateParams(types.SupportedSavingsPlansTypeEc2InstanceSp)) + if err != nil { + t.Fatal(err) + } + if math.Abs(r-0.7) > 1e-12 { + t.Fatalf("r = %g, want 0.7", r) + } +} + +func TestGetSPCommitmentRate_UtilizationScalesUsedSpend(t *testing.T) { + d := rateDetail("acct-a", "US East (N. Virginia)", "294", "126", "80") + r, err := rateClient(d).GetSPCommitmentRate(context.Background(), rateParams(types.SupportedSavingsPlansTypeEc2InstanceSp)) + if err != nil || math.Abs(r-0.56) > 1e-12 { + t.Fatalf("r = %g, err %v, want 0.56", r, err) + } +} + +func TestGetSPCommitmentRate_RatioOfSumsNotMeanOfRatios(t *testing.T) { + // r1 = 0.5 (covered 20), r2 = 0.8 (covered 100): mean 0.65, ratio of sums + // (10+80)/120 = 0.75. + a := rateDetail("acct-a", "US East (N. Virginia)", "10", "10", "100") + b := rateDetail("acct-a", "US East (N. Virginia)", "80", "20", "100") + r, err := rateClient(a, b).GetSPCommitmentRate(context.Background(), rateParams(types.SupportedSavingsPlansTypeEc2InstanceSp)) + if err != nil || math.Abs(r-0.75) > 1e-12 { + t.Fatalf("r = %g, err %v, want 0.75", r, err) + } +} + +func TestGetSPCommitmentRate_FiltersAccountAndRegion(t *testing.T) { + wrongAcct := rateDetail("acct-b", "US East (N. Virginia)", "10", "90", "100") + wrongRegion := rateDetail("acct-a", "EU (Ireland)", "10", "90", "100") + right := rateDetail("acct-a", "US East (N. Virginia)", "70", "30", "100") + r, err := rateClient(wrongAcct, wrongRegion, right).GetSPCommitmentRate(context.Background(), rateParams(types.SupportedSavingsPlansTypeEc2InstanceSp)) + if err != nil || math.Abs(r-0.7) > 1e-12 { + t.Fatalf("r = %g, err %v, want 0.7", r, err) + } +} + +func TestGetSPCommitmentRate_ComputeSPIgnoresRegion(t *testing.T) { + d := rateDetail("acct-a", "", "60", "40", "100") + r, err := rateClient(d).GetSPCommitmentRate(context.Background(), rateParams(types.SupportedSavingsPlansTypeComputeSp)) + if err != nil || math.Abs(r-0.6) > 1e-12 { + t.Fatalf("r = %g, err %v, want 0.6", r, err) + } +} + +func TestGetSPCommitmentRate_TypedAbsence(t *testing.T) { + missing := func(mut func(*types.SavingsPlansPurchaseRecommendationDetail)) types.SavingsPlansPurchaseRecommendationDetail { + d := rateDetail("acct-a", "US East (N. Virginia)", "70", "30", "100") + mut(&d) + return d + } + cases := map[string][]types.SavingsPlansPurchaseRecommendationDetail{ + "no details": nil, + "no matching detail": {rateDetail("acct-b", "US East (N. Virginia)", "70", "30", "100")}, + "missing SP cost": {missing(func(d *types.SavingsPlansPurchaseRecommendationDetail) { d.EstimatedSPCost = nil })}, + "missing savings": {missing(func(d *types.SavingsPlansPurchaseRecommendationDetail) { d.EstimatedSavingsAmount = nil })}, + "missing util": {missing(func(d *types.SavingsPlansPurchaseRecommendationDetail) { d.EstimatedAverageUtilization = nil })}, + "zero covered": {rateDetail("acct-a", "US East (N. Virginia)", "0", "0", "100")}, + } + for name, details := range cases { + t.Run(name, func(t *testing.T) { + r, err := rateClient(details...).GetSPCommitmentRate(context.Background(), rateParams(types.SupportedSavingsPlansTypeEc2InstanceSp)) + if !errors.Is(err, ErrNoSPCommitmentRate) || r != 0 { + t.Fatalf("r = %g, err %v, want ErrNoSPCommitmentRate", r, err) + } + }) + } +} + +func TestGetSPCommitmentRate_InvalidFieldsAreErrors(t *testing.T) { + for name, d := range map[string]types.SavingsPlansPurchaseRecommendationDetail{ + "unparseable": rateDetail("acct-a", "US East (N. Virginia)", "abc", "30", "100"), + "NaN": rateDetail("acct-a", "US East (N. Virginia)", "NaN", "30", "100"), + "negative": rateDetail("acct-a", "US East (N. Virginia)", "70", "-0.5", "100"), + "util > 100": rateDetail("acct-a", "US East (N. Virginia)", "70", "30", "101"), + } { + t.Run(name, func(t *testing.T) { + _, err := rateClient(d).GetSPCommitmentRate(context.Background(), rateParams(types.SupportedSavingsPlansTypeEc2InstanceSp)) + if err == nil || errors.Is(err, ErrNoSPCommitmentRate) { + t.Fatalf("want a non-absence error, got %v", err) + } + }) + } +} + +func TestGetSPCommitmentRate_BadParams(t *testing.T) { + c := rateClient() + for name, mut := range map[string]func(*SPCommitmentRateParams){ + "plan type": func(p *SPCommitmentRateParams) { p.PlanType = "SAGEMAKER_SP" }, + "account": func(p *SPCommitmentRateParams) { p.AccountID = "" }, + "region": func(p *SPCommitmentRateParams) { p.Region = "" }, + "term": func(p *SPCommitmentRateParams) { p.Term = "2yr" }, + "payment": func(p *SPCommitmentRateParams) { p.PaymentOption = "x" }, + "lookback": func(p *SPCommitmentRateParams) { p.LookbackPeriod = "9d" }, + } { + t.Run(name, func(t *testing.T) { + p := rateParams(types.SupportedSavingsPlansTypeEc2InstanceSp) + mut(&p) + if _, err := c.GetSPCommitmentRate(context.Background(), p); err == nil { + t.Fatal("want error") + } + }) + } +}