diff --git a/pkg/ladder/capability.go b/pkg/ladder/capability.go index 9206ac8..769a513 100644 --- a/pkg/ladder/capability.go +++ b/pkg/ladder/capability.go @@ -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 diff --git a/providers/aws/go.mod b/providers/aws/go.mod index 5ff1a58..f0a8a74 100644 --- a/providers/aws/go.mod +++ b/providers/aws/go.mod @@ -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 diff --git a/providers/aws/go.sum b/providers/aws/go.sum index 0598ad2..779dd73 100644 --- a/providers/aws/go.sum +++ b/providers/aws/go.sum @@ -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= diff --git a/providers/aws/ladder/commitment_rates.go b/providers/aws/ladder/commitment_rates.go new file mode 100644 index 0000000..f7908d1 --- /dev/null +++ b/providers/aws/ladder/commitment_rates.go @@ -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 +} diff --git a/providers/aws/ladder/commitment_rates_sdk_test.go b/providers/aws/ladder/commitment_rates_sdk_test.go new file mode 100644 index 0000000..405dbd8 --- /dev/null +++ b/providers/aws/ladder/commitment_rates_sdk_test.go @@ -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) + }) + } +} diff --git a/providers/aws/ladder/commitment_rates_test.go b/providers/aws/ladder/commitment_rates_test.go new file mode 100644 index 0000000..261477b --- /dev/null +++ b/providers/aws/ladder/commitment_rates_test.go @@ -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) + } + }) + } +} diff --git a/providers/aws/ladder/factory.go b/providers/aws/ladder/factory.go index e132409..d05d66b 100644 --- a/providers/aws/ladder/factory.go +++ b/providers/aws/ladder/factory.go @@ -60,6 +60,13 @@ func NewFromAWSConfig(ctx context.Context, region, accountID string) (pkgladder. if err != nil { return nil, fmt.Errorf("awsladder.NewFromAWSConfig: load AWS config: %w", err) } + return NewFromConfig(awsCfg, Config{Region: region, AccountID: accountID}) +} + +// NewFromConfig accepts resolved credentials and transport configuration for +// callers that already own the account's AWS configuration. +func NewFromConfig(awsCfg aws.Config, cfg Config) (*AWSLadder, error) { + awsCfg.Region = cfg.Region // recommendations.Client satisfies riCoverageSource and utilizationSource // directly, and is the underlying client for the on-demand series and SP @@ -74,14 +81,10 @@ func NewFromAWSConfig(ctx context.Context, region, accountID string) (pkgladder. // need the offering and purchase methods the full API exposes). spSDKClient := sdksp.NewFromConfig(awsCfg) - cfg := Config{ - Region: region, - AccountID: accountID, - } l, err := New( cfg, ec2Client, // riLister - &spListerAdapter{api: spSDKClient, region: region}, // spLister (region-scoped) + &spListerAdapter{api: spSDKClient, region: cfg.Region}, // spLister (region-scoped) recoClient, // riCoverageSource &onDemandSeriesAdapter{client: recoClient}, // onDemandSeriesSource recoClient, // utilizationSource @@ -89,8 +92,9 @@ func NewFromAWSConfig(ctx context.Context, region, accountID string) (pkgladder. &spUtilizationAdapter{client: recoClient}, // spUtilizationSource ) if err != nil { - return nil, fmt.Errorf("awsladder.NewFromAWSConfig: %w", err) + return nil, fmt.Errorf("awsladder.NewFromConfig: %w", err) } + l.spRates = recoClient return l, nil } diff --git a/providers/aws/ladder/ladder.go b/providers/aws/ladder/ladder.go index d906587..df8fa5b 100644 --- a/providers/aws/ladder/ladder.go +++ b/providers/aws/ladder/ladder.go @@ -93,6 +93,7 @@ type AWSLadder struct { riPurchase riPurchaser // write side; nil until WithWriteSide is called spPurchase spPurchaser // write side; nil until WithWriteSide is called exchange exchangeRunner // write side; nil until WithWriteSide is called + spRates spCommitmentRateSource cfg Config }