Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
188 changes: 188 additions & 0 deletions providers/aws/recommendations/sp_commitment_rate.go
Original file line number Diff line number Diff line change
@@ -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
}
165 changes: 165 additions & 0 deletions providers/aws/recommendations/sp_commitment_rate_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
}
Loading