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
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,10 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/reservations/armreservations v1.1.0
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261105183508-790f369f4822
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261105183508-790f369f4822
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261105183508-790f369f4822
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261105183508-790f369f4822
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261106191535-c83aef8b18a9
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261106191535-c83aef8b18a9
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261106191535-c83aef8b18a9
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261106191535-c83aef8b18a9
github.com/aws/aws-lambda-go v1.55.1
github.com/aws/aws-sdk-go-v2/service/kms v1.62.1
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.4
Expand Down
16 changes: 8 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,14 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261105183508-790f369f4822 h1:BMVY/iAVbsrxYMLBh8VYCb4qywH+ROp+6lFa9y1NBBs=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261105183508-790f369f4822/go.mod h1:l9V+ecJCfAigYr6XiK9kpIUemF9bnXOKHJN1s4TPsM0=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261105183508-790f369f4822 h1:uBRU062BP6FWe+72aexmy+Md+UDUZ0vodQDW1clE58k=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261105183508-790f369f4822/go.mod h1:tsjVv5Rj/QGuyVQ6w+sZ0w8fn/imBTNL5l6okmEBJn8=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261105183508-790f369f4822 h1:Lqf1GTF3g1gUPeCsDpW1cAZ0e1NyHQAw4N1sdb/qCUM=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261105183508-790f369f4822/go.mod h1:X+73EdStvYV6pqEVZZFyMfLG1frORlO9B1IsuK0LfOs=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261105183508-790f369f4822 h1:eKmwzGVdQZ9aowC5ab7OoHyPc7gdKzoNeXdzJjPPUA4=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261105183508-790f369f4822/go.mod h1:kfbQ5CsDr1958iSuZq6edcuB0es62XRCXGn/hFFYBW0=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261106191535-c83aef8b18a9 h1:omDAY4B4wshoedBYE368TW05d0PR2HTnebPgu8WqIFw=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261106191535-c83aef8b18a9/go.mod h1:l9V+ecJCfAigYr6XiK9kpIUemF9bnXOKHJN1s4TPsM0=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261106191535-c83aef8b18a9 h1:z4LNOkM5qTNq7dbzfdh0VdsSW4BTxRuVaXjOZfRADe0=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261106191535-c83aef8b18a9/go.mod h1:uVh1sdSIwKbHCWZ/TA66m2cR9Odb/36sxDHNKLyuMKY=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261106191535-c83aef8b18a9 h1:OISjPdyg7xcy8kCJelHJle4YnTMQycAWzglVi6L15uw=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261106191535-c83aef8b18a9/go.mod h1:X+73EdStvYV6pqEVZZFyMfLG1frORlO9B1IsuK0LfOs=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261106191535-c83aef8b18a9 h1:7Vz6QVVlwe0GG7TT2+4tQp9HBBCZ0YkfEDwFZOF+Ir8=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261106191535-c83aef8b18a9/go.mod h1:kfbQ5CsDr1958iSuZq6edcuB0es62XRCXGn/hFFYBW0=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=
Expand Down
2 changes: 1 addition & 1 deletion internal/config/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -888,7 +888,7 @@ func (c *LadderConfigDB) validateLadderBaselineBounds() error {
// baseline can use reliably. A window of N days returns at most N daily points
// and Cost Explorer lags 24-48h, so AWS needs the 7-point floor
// (providers/aws ladder/baseline.go:17 minBaselineSeriesDays, pinned
// 9962786e0695, unexported) plus its 3-day missing-day tolerance
// c83aef8b18a9, unexported) plus its 3-day missing-day tolerance
// (maxMissingDays): 10. Recheck when the aws provider module is bumped.
// A provider without an entry has no minimum beyond lookback_days > 0.
var ladderMinLookbackDays = map[string]int{"aws": 10}
Expand Down
7 changes: 6 additions & 1 deletion internal/server/handler_ladder.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,11 @@ func (app *Application) executeLadderRun(
}
scope := engineCfg.Scope

layers := capability.SupportedLayers()
if refusal := ladderComputeCapRefusal(dbCfg, layers); refusal != nil {
return refusal
}

// Collect read-side data. All three calls are independent; errors are
// fail-loud (not silently defaulted to empty).
baseline, err := capability.GetUsageBaseline(ctx, scope, engineCfg.LookbackDays, engineCfg.BaselinePercentile)
Expand Down Expand Up @@ -371,7 +376,7 @@ func (app *Application) executeLadderRun(
// network I/O.
in := &ladderRunPlanner{
dbCfg: dbCfg, engineCfg: engineCfg, baseline: baseline, layerStates: layerStates, rates: rates,
layers: capability.SupportedLayers(), term: term, paymentOpt: paymentOpt, now: now, missing: missing,
layers: layers, term: term, paymentOpt: paymentOpt, now: now, missing: missing,
}
savedRun, err := app.Config.PlanAndSaveLadderRun(ctx, dbCfg.ID, now, in.build)
if err != nil {
Expand Down
2 changes: 2 additions & 0 deletions internal/server/handler_ladder_rates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ var ratesTestNow = time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
func runWithRates(t *testing.T, store *ladderTestStore, cap pkgladder.LadderCapability) error {
t.Helper()
dbCfg := validTestDBConfig("cfg-rates")
// Capped so the fail-closed Compute SP refusal does not fire for fakes that list it.
dbCfg.MaxHourlyCommitPerRun = f64(100)
app := &Application{Config: store}
return app.executeLadderRun(testutil.TestContext(t), &dbCfg, cap, "123456789012", pkgladder.Term1Year, pkgladder.PaymentNoUpfront, ratesTestNow)
}
Expand Down
23 changes: 23 additions & 0 deletions internal/server/ladder_compute_cap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package server

import (
"errors"

pkgladder "github.com/LeanerCloud/cloud-commitments-go/pkg/ladder"

"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
)

// ladderComputeCapRefusal fails closed for an uncapped Compute SP ladder: its
// rate is an account-wide blend, and only max_hourly_commit_per_run bounds that bias.
func ladderComputeCapRefusal(dbCfg *config.LadderConfigDB, layers []pkgladder.LayerSpec) error {
if dbCfg.MaxHourlyCommitPerRun != nil {
return nil
}
for _, l := range layers {
if l.Type == pkgladder.LayerComputeSP {
return errors.New("max_hourly_commit_per_run is required for a ladder config that sizes the Compute Savings Plan layer: its rate is an account-wide blend, so an uncapped run is refused")
}
}
return nil
}
133 changes: 133 additions & 0 deletions internal/server/ladder_compute_cap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package server

import (
"context"
"testing"
"time"

pkgladder "github.com/LeanerCloud/cloud-commitments-go/pkg/ladder"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
"github.com/LeanerCloud/cloud-commitments-platform/internal/testutil"
)

func TestLadderComputeCapRefusal(t *testing.T) {
computeSP := []pkgladder.LayerSpec{{Type: pkgladder.LayerComputeSP, Roles: []pkgladder.LayerRole{pkgladder.RoleFlex}}}
noCompute := []pkgladder.LayerSpec{{Type: pkgladder.LayerEC2InstanceSP, Roles: []pkgladder.LayerRole{pkgladder.RoleBase}}}
for _, tc := range []struct {
name string
layers []pkgladder.LayerSpec
cap *float64
wantErr bool
}{
{"compute sp without a cap", computeSP, nil, true},
{"compute sp among others without a cap", awsLadderLayers, nil, true},
{"compute sp with a cap", computeSP, f64(100), false},
{"no compute sp without a cap", noCompute, nil, false},
{"empty layers without a cap", nil, nil, false},
} {
t.Run(tc.name, func(t *testing.T) {
dbCfg := validTestDBConfig("cfg-cap")
dbCfg.MaxHourlyCommitPerRun = tc.cap
err := ladderComputeCapRefusal(&dbCfg, tc.layers)
if tc.wantErr {
require.ErrorContains(t, err, "max_hourly_commit_per_run")
return
}
require.NoError(t, err)
})
}
}

// providerReadCounter counts the read-side provider calls of one run.
type providerReadCounter struct {
*fakeLadderCapability
baselines, layerStates, rates int
}

func (c *providerReadCounter) GetUsageBaseline(ctx context.Context, s pkgladder.Scope, days int, pct float64) (pkgladder.UsageBaseline, error) {
c.baselines++
return c.fakeLadderCapability.GetUsageBaseline(ctx, s, days, pct)
}

func (c *providerReadCounter) GetLayerStates(ctx context.Context, s pkgladder.Scope) (map[pkgladder.LayerType]pkgladder.LayerState, error) {
c.layerStates++
return c.fakeLadderCapability.GetLayerStates(ctx, s)
}

func (c *providerReadCounter) GetCommitmentRates(ctx context.Context, s pkgladder.Scope, term pkgladder.Term, p pkgladder.PaymentOption) (map[pkgladder.LayerType]float64, error) {
c.rates++
return c.fakeLadderCapability.GetCommitmentRates(ctx, s, term, p)
}

// persistCountingStore counts PlanAndSaveLadderRun calls.
type persistCountingStore struct {
*ladderTestStore
persists int
}

func (s *persistCountingStore) PlanAndSaveLadderRun(ctx context.Context, id string, now time.Time, build config.LadderPlanBuilder) (*config.LadderRunDB, error) {
s.persists++
return s.ladderTestStore.PlanAndSaveLadderRun(ctx, id, now, build)
}

func runComputeCapConfig(t *testing.T, cap *float64) (*LadderRunResult, *persistCountingStore, *providerReadCounter) {
t.Helper()
states := map[pkgladder.LayerType]pkgladder.LayerState{}
rates := map[pkgladder.LayerType]float64{}
for _, spec := range awsPlanningLayers {
states[spec.Type] = emptyLayerState(spec.Type)
rates[spec.Type] = 0.5
}
reads := &providerReadCounter{fakeLadderCapability: &fakeLadderCapability{
t: t, baseline: testBaselineLowWater(12.5), layerStates: states, rates: rates, supportedLayers: awsPlanningLayers,
}}
store := &persistCountingStore{ladderTestStore: &ladderTestStore{cloudAcct: validTestCloudAccount(computeCapAccount)}}
app := &Application{
Config: store,
LadderCapabilityFactory: func(_ context.Context, _, _ string) (pkgladder.LadderCapability, error) {
return reads, nil
},
}
dbCfg := validTestDBConfig("cfg-compute-cap")
dbCfg.MaxHourlyCommitPerRun = cap
result := app.runLadderConfigs(testutil.TestContext(t), []config.LadderConfigDB{dbCfg}, computeCapAccount, "us-east-1",
pkgladder.Term1Year, pkgladder.PaymentNoUpfront, ratesTestNow, false)
return result, store, reads
}

// computeCapAccount is the lambda and cloud account of the cap tests.
const computeCapAccount = "210987654321"

// awsPlanningLayers mirrors the AWS capability's SupportedLayers, one base layer
// only, so a capped run can allocate.
var awsPlanningLayers = []pkgladder.LayerSpec{
{Type: pkgladder.LayerEC2InstanceSP, Roles: []pkgladder.LayerRole{pkgladder.RoleBase}},
{Type: pkgladder.LayerComputeSP, Roles: []pkgladder.LayerRole{pkgladder.RoleFlex}},
{Type: pkgladder.LayerConvertibleRI, Roles: []pkgladder.LayerRole{pkgladder.RoleBuffer}},
}

func TestRunLadderConfigs_ComputeSPWithoutCapIsErroredBeforeAnyRead(t *testing.T) {
result, store, reads := runComputeCapConfig(t, nil)

assert.Equal(t, LadderRunResult{Errored: 1}, *result)
assert.Zero(t, store.persists, "PlanAndSaveLadderRun must not be called")
assert.Nil(t, store.savedRun)
assert.Empty(t, store.savedRuns)
assert.Zero(t, reads.baselines, "no baseline read")
assert.Zero(t, reads.layerStates, "no layer-state read")
assert.Zero(t, reads.rates, "no rate read")
}

func TestRunLadderConfigs_ComputeSPWithCapStillPlans(t *testing.T) {
result, store, reads := runComputeCapConfig(t, f64(100))

assert.Equal(t, LadderRunResult{Planned: 1}, *result)
assert.Equal(t, 1, store.persists)
require.NotNil(t, store.savedRun)
assert.Equal(t, 1, reads.baselines)
assert.Equal(t, 1, reads.layerStates)
assert.Equal(t, 1, reads.rates)
}
Loading