Skip to content
Merged
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
6 changes: 6 additions & 0 deletions pkg/ladder/capability.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import (
"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
)

// CommitmentRateCapability optionally supplies new-purchase commitment dollars
// per covered on-demand dollar. Missing layers in the partial map are intentional.
type CommitmentRateCapability interface {
GetCommitmentRates(context.Context, Scope, Term, PaymentOption) (map[LayerType]float64, error)
}

// LadderCapability is implemented by each cloud provider to give the ladder
// engine a uniform interface for querying commitment state and executing
// purchases. Implementations live in providers/ and are injected into the
Expand Down
2 changes: 1 addition & 1 deletion providers/aws/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/LeanerCloud/cloud-commitments-go/providers/aws
go 1.26.9

require (
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261013182117-cdcdc7fbcc32
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261015071854-5b254484d5e4
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.29.12
github.com/aws/aws-sdk-go-v2/credentials v1.17.65
Expand Down
4 changes: 2 additions & 2 deletions providers/aws/go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261013182117-cdcdc7fbcc32 h1:IxqJVVAA26JAS9BtKvNFjn2zPN/LuzPFP5y4O417JS8=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261013182117-cdcdc7fbcc32/go.mod h1:8RTmzPazA8IeVZfn7wNaU7ILx0VK5eHZQozO21Mvtq8=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261015071854-5b254484d5e4 h1:qI0IihoIaQ0MFgj9hXF0aGyokniwwYS938PrNmMVbGs=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261015071854-5b254484d5e4/go.mod h1:8RTmzPazA8IeVZfn7wNaU7ILx0VK5eHZQozO21Mvtq8=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/config v1.29.12 h1:Y/2a+jLPrPbHpFkpAAYkVEtJmxORlXoo5k2g1fa2sUo=
Expand Down
46 changes: 46 additions & 0 deletions providers/aws/ladder/commitment_rates.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package ladder

import (
"context"
"errors"
"fmt"

"github.com/aws/aws-sdk-go-v2/service/costexplorer/types"

"github.com/LeanerCloud/cloud-commitments-go/pkg/ladder"
"github.com/LeanerCloud/cloud-commitments-go/providers/aws/recommendations"
)

type spCommitmentRateSource interface {
GetSPCommitmentRate(context.Context, recommendations.SPCommitmentRateParams) (float64, error)
}

// GetCommitmentRates leaves Compute absent pending the caller's sizing policy;
// regional EC2 Instance recommendations are not a Compute pricing proxy.
func (a *AWSLadder) GetCommitmentRates(ctx context.Context, scope ladder.Scope, term ladder.Term, payment ladder.PaymentOption) (map[ladder.LayerType]float64, error) {
if err := a.validateScope(scope); err != nil {
return nil, err
}
if err := term.Validate(); err != nil {
return nil, fmt.Errorf("GetCommitmentRates: %w", err)
}
if err := payment.Validate(); err != nil {
return nil, fmt.Errorf("GetCommitmentRates: %w", err)
}
if a.spRates == nil {
return nil, errors.New("GetCommitmentRates: Savings Plan rate source not wired")
}
rate, err := a.spRates.GetSPCommitmentRate(ctx, recommendations.SPCommitmentRateParams{
PlanType: types.SupportedSavingsPlansTypeEc2InstanceSp,
AccountID: a.cfg.AccountID, Region: a.cfg.Region,
Term: string(term), PaymentOption: string(payment),
LookbackPeriod: fmt.Sprintf("%dd", a.cfg.lookbackDays()),
})
if errors.Is(err, recommendations.ErrNoSPCommitmentRate) {
return map[ladder.LayerType]float64{}, nil
}
if err != nil {
return nil, fmt.Errorf("GetCommitmentRates: %w", err)
}
return map[ladder.LayerType]float64{ladder.LayerEC2InstanceSP: rate}, nil
}
188 changes: 188 additions & 0 deletions providers/aws/ladder/commitment_rates_sdk_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package ladder_test

import (
"context"
"encoding/json"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"sync/atomic"
"testing"
"time"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
"github.com/LeanerCloud/cloud-commitments-go/pkg/ladder"
awsladder "github.com/LeanerCloud/cloud-commitments-go/providers/aws/ladder"
)

type ceLoopback struct {
endpoint *url.URL
transport http.RoundTripper
}

func (c ceLoopback) RoundTrip(req *http.Request) (*http.Response, error) {
if req.URL.Scheme != "https" || req.URL.Host != "ce.us-east-1.amazonaws.com" {
return nil, fmt.Errorf("fixture rejects host %s", req.URL.Host)
}
local := req.Clone(req.Context())
local.URL.Scheme, local.URL.Host = c.endpoint.Scheme, c.endpoint.Host
local.Host = c.endpoint.Host
return c.transport.RoundTrip(local)
}

// These CE-shaped responses are constructed fixtures, not recorded billing data.
func rateDetail(account, region, family, cost, savings string) string {
return fmt.Sprintf(`{"AccountId":%q,"SavingsPlansDetails":{"Region":%q,"InstanceFamily":%q},"EstimatedSPCost":%q,"EstimatedSavingsAmount":%q,"EstimatedAverageUtilization":"100"}`,
account, region, family, cost, savings)
}

func ratePage(details, token string) string {
return fmt.Sprintf(`{"NextPageToken":%q,"SavingsPlansPurchaseRecommendation":{"SavingsPlansPurchaseRecommendationDetails":[%s]}}`, token, details)
}

func sdkRateLadder(t *testing.T, pages []string) *awsladder.AWSLadder {
t.Helper()
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page := int(calls.Add(1)) - 1
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "AWSInsightsIndexService.GetSavingsPlansPurchaseRecommendation", r.Header.Get("X-Amz-Target"))
var input map[string]any
if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&input)) {
w.WriteHeader(http.StatusBadRequest)
return
}
want := map[string]any{"SavingsPlansType": "EC2_INSTANCE_SP", "AccountScope": "LINKED",
"TermInYears": "THREE_YEARS", "PaymentOption": "PARTIAL_UPFRONT", "LookbackPeriodInDays": "SEVEN_DAYS"}
if page > 0 {
want["NextPageToken"] = "second"
}
assert.Equal(t, want, input)
if page >= len(pages) {
t.Error("unexpected continuation request")
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/x-amz-json-1.1")
_, err := w.Write([]byte(pages[page]))
assert.NoError(t, err)
}))
t.Cleanup(func() {
srv.Close()
assert.Equal(t, int32(len(pages)), calls.Load())
})
endpoint, err := url.Parse(srv.URL)
require.NoError(t, err)
awsCfg := aws.Config{Region: "us-west-2", RetryMaxAttempts: 1,
Credentials: credentials.NewStaticCredentialsProvider("synthetic", "synthetic", ""),
HTTPClient: &http.Client{Transport: ceLoopback{endpoint: endpoint, transport: srv.Client().Transport}},
}
a, err := awsladder.NewFromConfig(awsCfg, awsladder.Config{Region: "eu-west-1", AccountID: "fixture-account", LookbackDays: 7})
require.NoError(t, err)
assert.Equal(t, "us-west-2", awsCfg.Region)
return a
}

func TestCommitmentRates_PublicSDKConsumer(t *testing.T) {
a := sdkRateLadder(t, []string{
ratePage(rateDetail("fixture-account", "eu-west-1", "m5", "10", "10")+","+
rateDetail("other-fixture-account", "eu-west-1", "m5", "999", "1")+","+
rateDetail("fixture-account", "us-east-1", "m5", "999", "1"), "second"),
ratePage(rateDetail("fixture-account", "eu-west-1", "c5", "60", "20"), ""),
})
scope := ladder.Scope{Provider: common.ProviderAWS, AccountID: "fixture-account"}
rates, err := a.GetCommitmentRates(context.Background(), scope, ladder.Term3Year, ladder.PaymentPartialUpfront)
require.NoError(t, err)
require.Equal(t, map[ladder.LayerType]float64{ladder.LayerEC2InstanceSP: 0.7}, rates)
in := consumerInput(a, scope, rates)
result, err := ladder.Allocate(in)
require.NoError(t, err)
require.Len(t, result.Allocations, 1)
allocation := result.Allocations[0]
assert.Equal(t, ladder.LayerEC2InstanceSP, allocation.Layer)
assert.Equal(t, 0, allocation.OnDemandEquivalentUSDPerHour.Cmp(big.NewRat(10, 1)))
assert.InDelta(t, 7, ratFloat(allocation.GapUSDPerHour), 1e-12)
assertMissingRateHold(t, result, ladder.LayerComputeSP)
tranches, err := ladder.BuildTranches(&ladder.TrancheInput{
Config: &in.Config, RunID: "synthetic-run", Term: ladder.Term3Year,
PaymentOption: ladder.PaymentPartialUpfront, NewID: func() string { return "synthetic-tranche" },
Now: in.Now, Allocations: result.Allocations,
})
require.NoError(t, err)
require.Len(t, tranches.Tranches, 1)
tr := tranches.Tranches[0]
assert.Equal(t, allocation.GapUSDPerHour.RatString(), tr.AmountUSDPerHour)
assert.Equal(t, allocation.Rate.RatString(), tr.CommitmentRate)
storedAmount, ok := new(big.Rat).SetString(tr.AmountUSDPerHour)
require.True(t, ok)
storedRate, ok := new(big.Rat).SetString(tr.CommitmentRate)
require.True(t, ok)
in.InFlight = []ladder.InFlightTranche{{Layer: tr.Layer, CommitmentUSDPerHour: ratFloat(storedAmount), Rate: ratFloat(storedRate)}}
replay, err := ladder.Allocate(in)
require.NoError(t, err)
assert.True(t, replay.IsNoOp())
assertMissingRateHold(t, replay, ladder.LayerComputeSP)
}

func consumerInput(a *awsladder.AWSLadder, scope ladder.Scope, rates map[ladder.LayerType]float64) *ladder.AllocationInput {
zero, low, stable := 0.0, 20.0, 10.0
states := make(map[ladder.LayerType]ladder.LayerState)
for _, spec := range a.SupportedLayers() {
states[spec.Type] = ladder.LayerState{Layer: spec.Type, ExistingUSDPerHour: &zero, ExpiringUSDPerHour: &zero}
}
return &ladder.AllocationInput{
Config: ladder.LadderConfig{Scope: scope, Mode: ladder.ModeEmailApproval, Cadence: ladder.CadenceWeekly,
TargetCoveragePct: 100, BaselinePercentile: 5, LookbackDays: 7, MaxActionsPerRun: 10,
BufferUtilizationThresholdPct: ladder.DefaultBufferUtilizationThresholdPct,
Ramp: ladder.RampSchedule{Steps: []ladder.RampStep{{AfterDays: 0, Fraction: 1}}}},
Layers: a.SupportedLayers(), LayerStates: states, CommitmentRates: rates, InFlight: []ladder.InFlightTranche{},
Baseline: ladder.UsageBaseline{LowWaterUSDPerHour: &low, StableUSDPerHour: &stable},
Now: time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC), DataSources: []string{"constructed-ce-loopback"},
}
}

func ratFloat(r *big.Rat) float64 { f, _ := r.Float64(); return f }

func assertMissingRateHold(t *testing.T, result *ladder.AllocateResult, layer ladder.LayerType) {
t.Helper()
for _, hold := range result.Holds {
if hold.Layer == layer && hold.Action == ladder.ActionHold {
assert.Contains(t, hold.Rationale, "no commitment rate")
return
}
}
t.Errorf("missing rate Hold for %s: %+v", layer, result)
}

func TestCommitmentRates_SDKMissingAndMalformed(t *testing.T) {
for _, cost := range []string{"", "bad"} {
t.Run("cost="+cost, func(t *testing.T) {
detail := rateDetail("fixture-account", "eu-west-1", "m5", cost, "3")
if cost == "" {
detail = `{"AccountId":"fixture-account","SavingsPlansDetails":{"Region":"eu-west-1"}}`
}
a := sdkRateLadder(t, []string{ratePage(detail, "")})
scope := ladder.Scope{Provider: common.ProviderAWS, AccountID: "fixture-account"}
rates, err := a.GetCommitmentRates(context.Background(), scope, ladder.Term3Year, ladder.PaymentPartialUpfront)
if cost != "" {
require.ErrorContains(t, err, "EstimatedSPCost")
assert.Nil(t, rates)
return
}
require.NoError(t, err)
require.NotNil(t, rates)
assert.Empty(t, rates)
result, err := ladder.Allocate(consumerInput(a, scope, rates))
require.NoError(t, err)
assert.True(t, result.IsNoOp())
assertMissingRateHold(t, result, ladder.LayerEC2InstanceSP)
})
}
}
119 changes: 119 additions & 0 deletions providers/aws/ladder/commitment_rates_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package ladder

import (
"context"
"errors"
"fmt"
"testing"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/costexplorer/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
"github.com/LeanerCloud/cloud-commitments-go/pkg/ladder"
"github.com/LeanerCloud/cloud-commitments-go/providers/aws/recommendations"
)

type fakeSPRateSource struct {
err error
params []recommendations.SPCommitmentRateParams
}

func TestNewFromConfig_ValidatesAndScopesClients(t *testing.T) {
t.Parallel()
for _, cfg := range []Config{{AccountID: testScope().AccountID}, {Region: "eu-west-1"}} {
a, err := NewFromConfig(aws.Config{}, cfg)
require.Error(t, err)
assert.Nil(t, a)
}
awsCfg := aws.Config{Region: "us-west-2"}
a, err := NewFromConfig(awsCfg, Config{Region: "eu-west-1", AccountID: testScope().AccountID})
require.NoError(t, err)
assert.Equal(t, "us-west-2", awsCfg.Region)
assert.Equal(t, "eu-west-1", a.cfg.Region)
assert.Equal(t, "eu-west-1", a.sps.(*spListerAdapter).region)
require.NotNil(t, a.spRates)
}

func (f *fakeSPRateSource) GetSPCommitmentRate(_ context.Context, p recommendations.SPCommitmentRateParams) (float64, error) {
f.params = append(f.params, p)
return 0.7, f.err
}

func TestGetCommitmentRates_ValidatesBeforeSource(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
scope ladder.Scope
term ladder.Term
payment ladder.PaymentOption
}{
{"provider", ladder.Scope{Provider: common.ProviderAzure, AccountID: testScope().AccountID}, ladder.Term1Year, ladder.PaymentNoUpfront},
{"account", ladder.Scope{Provider: common.ProviderAWS, AccountID: "wrong"}, ladder.Term1Year, ladder.PaymentNoUpfront},
{"term", testScope(), ladder.Term("bad"), ladder.PaymentNoUpfront},
{"payment", testScope(), ladder.Term1Year, ladder.PaymentOption("bad")},
} {
t.Run(tc.name, func(t *testing.T) {
f := &fakeSPRateSource{}
a := &AWSLadder{cfg: Config{Region: "us-east-1", AccountID: testScope().AccountID}, spRates: f}
rates, err := a.GetCommitmentRates(context.Background(), tc.scope, tc.term, tc.payment)
require.Error(t, err)
assert.Nil(t, rates)
assert.Empty(t, f.params)
})
}
}

func TestGetCommitmentRates_ForwardsSelection(t *testing.T) {
t.Parallel()
for _, days := range []int{0, 7, 60} {
t.Run(fmt.Sprint(days), func(t *testing.T) {
f := &fakeSPRateSource{}
a := &AWSLadder{cfg: Config{Region: "eu-west-1", AccountID: testScope().AccountID, LookbackDays: days}, spRates: f}
rates, err := a.GetCommitmentRates(context.Background(), testScope(), ladder.Term3Year, ladder.PaymentPartialUpfront)
require.NoError(t, err)
assert.Equal(t, map[ladder.LayerType]float64{ladder.LayerEC2InstanceSP: 0.7}, rates)
if days == 0 {
days = DefaultLookbackDays
}
assert.Equal(t, []recommendations.SPCommitmentRateParams{{
PlanType: types.SupportedSavingsPlansTypeEc2InstanceSp,
AccountID: testScope().AccountID, Region: "eu-west-1",
Term: "3yr", PaymentOption: "partial-upfront", LookbackPeriod: fmt.Sprintf("%dd", days),
}}, f.params)
})
}
}

func TestGetCommitmentRates_AbsenceAndErrors(t *testing.T) {
t.Parallel()
hard := errors.New("source failed")
for _, tc := range []struct {
name string
source spCommitmentRateSource
want error
}{
{"unwired", nil, nil},
{"absent", &fakeSPRateSource{err: fmt.Errorf("wrapped: %w", recommendations.ErrNoSPCommitmentRate)}, nil},
{"hard", &fakeSPRateSource{err: hard}, hard},
} {
t.Run(tc.name, func(t *testing.T) {
a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, &fakeCoverageSource{}, &fakeUtilizationSource{})
a.spRates = tc.source
rates, err := a.GetCommitmentRates(context.Background(), testScope(), ladder.Term1Year, ladder.PaymentNoUpfront)
if tc.source == nil {
require.ErrorContains(t, err, "not wired")
assert.Nil(t, rates)
} else if tc.want != nil {
require.ErrorIs(t, err, tc.want)
assert.Nil(t, rates)
} else {
require.NoError(t, err)
require.NotNil(t, rates)
assert.Empty(t, rates)
}
})
}
}
Loading
Loading