diff --git a/go.mod b/go.mod index e4e30722..8c5ef42e 100644 --- a/go.mod +++ b/go.mod @@ -84,10 +84,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.4.0 github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 - github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261012205319-ad67030387e4 - github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261012205319-ad67030387e4 - github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261012205319-ad67030387e4 - github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261012205319-ad67030387e4 + github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261015143912-740dfbd72456 + github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261015143912-740dfbd72456 + github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261015143912-740dfbd72456 + github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261015143912-740dfbd72456 github.com/aws/aws-lambda-go v1.47.0 github.com/aws/aws-sdk-go-v2/service/kms v1.50.4 github.com/aws/aws-sdk-go-v2/service/lambda v1.89.0 diff --git a/go.sum b/go.sum index f7c47e3d..9001e046 100644 --- a/go.sum +++ b/go.sum @@ -92,14 +92,14 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= -github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261012205319-ad67030387e4 h1:cXoxVl0G696HrsXGc0t2jKH5iHUQe+i9pL/q4dnFPSc= -github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261012205319-ad67030387e4/go.mod h1:8RTmzPazA8IeVZfn7wNaU7ILx0VK5eHZQozO21Mvtq8= -github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261012205319-ad67030387e4 h1:PCPhtB3yJsLdg6zitDgNxgntHGq3unUn0NoyV1u2PwY= -github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261012205319-ad67030387e4/go.mod h1:fiu6WQmUGRdViiOf45QV235Cowq8j0yn8ahGWCGEFn8= -github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261012205319-ad67030387e4 h1:dbkTxvqS1xWTvCSKulDWL0UIuQSZ1JTBRw14OW/U+wU= -github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261012205319-ad67030387e4/go.mod h1:8vqO2ZlDzjX/Z4bESZVp/IenhTifkySeF0VJwQKihLA= -github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261012205319-ad67030387e4 h1:T3woCHq7rBuExSd0+BI1BYfF0Pd5AMgpngnc8Bqepd0= -github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261012205319-ad67030387e4/go.mod h1:J4H0b34EwQQ4p9cM3amsIU9tFmbzecS8E00ybjVp56g= +github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261015143912-740dfbd72456 h1:ZPVVIwrZ28I+f+Fe6DUK6g/nVGIJj06nNhI32M7rt9I= +github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261015143912-740dfbd72456/go.mod h1:8RTmzPazA8IeVZfn7wNaU7ILx0VK5eHZQozO21Mvtq8= +github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261015143912-740dfbd72456 h1:3lNYgah7tF+bnOXv619jgiSguUhpiI2I2sFmLrj7Ba8= +github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261015143912-740dfbd72456/go.mod h1:uOUuTVRjTm0Fhu/Vra2M2NES0w4pvZUSfZLsGyCd18s= +github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261015143912-740dfbd72456 h1:agVjEMOqGsehx98Lb4IyIPlCzf6VIBNHjGekUfCxkpU= +github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261015143912-740dfbd72456/go.mod h1:8vqO2ZlDzjX/Z4bESZVp/IenhTifkySeF0VJwQKihLA= +github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261015143912-740dfbd72456 h1:+pjt8F5bHFHz9OFYEOXUESdIE1KVSfdQH3URqsjaIfw= +github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261015143912-740dfbd72456/go.mod h1:KV+Ov04+ZMBGv1wA0s9lEHLinlg1p74XrNafXYLwKmE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/aws/aws-lambda-go v1.47.0 h1:0H8s0vumYx/YKs4sE7YM0ktwL2eWse+kfopsRI1sXVI= diff --git a/internal/config/store_postgres_ladder_basis_integration_test.go b/internal/config/store_postgres_ladder_basis_integration_test.go index ec8a9f47..3b1b7a06 100644 --- a/internal/config/store_postgres_ladder_basis_integration_test.go +++ b/internal/config/store_postgres_ladder_basis_integration_test.go @@ -125,3 +125,21 @@ func TestLadderNetting_NullBasisStillFailsLoudBeforeBuilder(t *testing.T) { require.Error(t, err) require.False(t, built) } + +func TestLadderAmend_CommitmentRowKeepsNativeAmountAfterReload(t *testing.T) { + f := newCeilingFixture(t) + _, ids := f.plan(fptr(10), fptr(0), fptr(100), 5) + f.setBasis(ids[0], "commitment", "1/2") + require.NoError(t, f.amend(ids[0], 4)) + var amount float64 + var basis, rate string + require.NoError(t, f.store.db.QueryRow(f.ctx, "SELECT amount_usd_hr,amount_basis,commitment_rate FROM ladder_tranches WHERE id=$1", ids[0]).Scan(&amount, &basis, &rate)) + require.Equal(t, 4.0, amount, "the amend stores the native commitment amount, not an on-demand conversion") + require.Equal(t, "commitment", basis) + require.Equal(t, "1/2", rate) + rows, err := inFlightLadderRows(f.ctx, f.store.db, true, f.cfgID, time.Now()) + require.NoError(t, err) + require.Len(t, rows, 1) + require.Equal(t, 4.0, rows[0].CommitmentUSDPerHour) + require.Equal(t, 8.0, mustOnDemand(t, rows)) +} diff --git a/internal/scheduler/recommendation_completeness_integration_test.go b/internal/scheduler/recommendation_completeness_integration_test.go index 1902887c..fd698d70 100644 --- a/internal/scheduler/recommendation_completeness_integration_test.go +++ b/internal/scheduler/recommendation_completeness_integration_test.go @@ -68,7 +68,7 @@ func (f *completenessHTTP) Do(req *http.Request) (*http.Response, error) { case "AWSInsightsIndexService.GetReservationPurchaseRecommendation": allowed := map[string]bool{ "Amazon Elastic Compute Cloud - Compute": true, "Amazon Relational Database Service": true, - "Amazon ElastiCache": true, "Amazon OpenSearch Service": true, "Amazon Redshift": true, + "Amazon ElastiCache": true, "Amazon OpenSearch Service": true, "Amazon Redshift": true, "Amazon MemoryDB Service": true, } if !allowed[input.Service] { f.unexpected.Add(1) @@ -393,7 +393,8 @@ func assertSavingsPlansPersistence(t *testing.T, rows []config.RecommendationRec require.NotNil(t, row.SavingsPercentage) require.Equal(t, 25.0, *row.SavingsPercentage) require.NotNil(t, row.MonthlyCost) - monthly := 1460.0 + // Fixture: $2/hr commitment, $3 upfront. Recurring = (2 x 8760 x years - 3) / (12 x years). + monthly := (2.0*8760*float64(row.Term) - 3) / (12 * float64(row.Term)) if row.Payment == "all-upfront" { monthly = 0 } diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index 65fe3d40..f5c90798 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -1927,7 +1927,7 @@ func TestScheduler_CollectAzureRecommendations_AllAccountsFailLoud(t *testing.T) require.Error(t, err, "collectAzureRecommendations must fail loud when the only account has unusable credentials (COR-03)") assert.Contains(t, err.Error(), "Azure: all 1 accounts failed", "the per-account fan-out must surface the all-accounts-failed shape so the scheduler keeps stale rows and records last_collection_error") - assert.Contains(t, err.Error(), "all 4 Azure recommendation services failed") + assert.Contains(t, err.Error(), "all 5 Azure recommendation services failed") assert.Contains(t, err.Error(), fixtureMarker) assert.Positive(t, atomic.LoadInt32(&calls), "the managed-identity fixture must receive token requests") assert.Nil(t, recs) diff --git a/internal/server/handler_ladder.go b/internal/server/handler_ladder.go index 6a0dbc40..16338133 100644 --- a/internal/server/handler_ladder.go +++ b/internal/server/handler_ladder.go @@ -278,10 +278,10 @@ func (app *Application) processOneLadderConfig( // have not yet been bought. Tranches completed after `now` are counted too (see // below): a safe under-buy, and a deliberate deviation from go's InFlight doc. // -// Units: tranche amounts are stored exactly as Allocate emits them, which is -// ON-DEMAND-EQUIVALENT $/hr until Allocate converts to commitment dollars -// (go#349). Do not convert here. The store nets each stored row to -// on-demand-equivalent dollars by its own basis and rate. +// Units: Allocate sizes the gap in on-demand-equivalent dollars and emits +// tranches in commitment dollars with the rate they were sized with. Rows are +// stored as basis=commitment plus that rate; the store nets each row back to +// on-demand-equivalent dollars by its own rate. // // Persistence is APPEND-ONLY (see persistLadderRun): every run inserts its new // scheduled tranches and NEVER supersedes existing ones. Netting already @@ -317,6 +317,18 @@ func (app *Application) executeLadderRun( return fmt.Errorf("GetLayerStates: %w", err) } + // Rates are provider data, fetched outside the transaction. No capability or + // a fetch error fails the run: the allocator never gets a guessed rate. An + // empty map is valid and yields a Hold-only run. + rater, ok := capability.(pkgladder.CommitmentRateCapability) + if !ok { + return fmt.Errorf("capability %T does not provide commitment rates", capability) + } + rates, err := rater.GetCommitmentRates(ctx, scope, term, paymentOpt) + if err != nil { + return fmt.Errorf("GetCommitmentRates: %w", err) + } + // L5 netting runs inside the persist transaction (PlanAndSaveLadderRun): // the store reads the config's in-flight commitment (scheduled tranches // still within config.LadderFireGrace plus fired, unresolved ones) under @@ -327,7 +339,7 @@ func (app *Application) executeLadderRun( // provider reads above stay outside the transaction: no lock is held across // network I/O. in := &ladderRunPlanner{ - dbCfg: dbCfg, engineCfg: engineCfg, baseline: baseline, layerStates: layerStates, + dbCfg: dbCfg, engineCfg: engineCfg, baseline: baseline, layerStates: layerStates, rates: rates, layers: capability.SupportedLayers(), term: term, paymentOpt: paymentOpt, now: now, } savedRun, err := app.Config.PlanAndSaveLadderRun(ctx, dbCfg.ID, now, in.plan) @@ -347,6 +359,7 @@ type ladderRunPlanner struct { engineCfg pkgladder.LadderConfig baseline pkgladder.UsageBaseline layerStates map[pkgladder.LayerType]pkgladder.LayerState + rates map[pkgladder.LayerType]float64 layers []pkgladder.LayerSpec term pkgladder.Term paymentOpt pkgladder.PaymentOption @@ -356,18 +369,20 @@ type ladderRunPlanner struct { } func (p *ladderRunPlanner) plan(rows []config.LadderInFlightRow) (*config.LadderRunDB, []config.LadderTrancheDB, error) { - inFlight, err := config.LadderInFlightOnDemand(rows) - if err != nil { - return nil, nil, fmt.Errorf("net in-flight tranches: %w", err) + // Allocate rejects a row on a layer outside Layers, so it cannot be silently un-netted. + inFlight := make([]pkgladder.InFlightTranche, len(rows)) + for i, r := range rows { + inFlight[i] = pkgladder.InFlightTranche{Layer: r.Layer, CommitmentUSDPerHour: r.CommitmentUSDPerHour, Rate: r.Rate} } allocResult, err := pkgladder.Allocate(&pkgladder.AllocationInput{ - Now: p.now, - Baseline: p.baseline, - LayerStates: p.layerStates, - Layers: p.layers, - Config: p.engineCfg, - DataSources: []string{dataSourceAWSCostExplorer}, - InFlightUSDPerHour: &inFlight, + Now: p.now, + Baseline: p.baseline, + LayerStates: p.layerStates, + Layers: p.layers, + Config: p.engineCfg, + DataSources: []string{dataSourceAWSCostExplorer}, + InFlight: inFlight, + CommitmentRates: p.rates, }) if err != nil { return nil, nil, fmt.Errorf("allocate: %w", err) @@ -539,8 +554,8 @@ func ladderConfigToEngine(dbCfg *config.LadderConfigDB, accountID string) (pkgla // allocation rather than recomputed independently: // - target = baseline low-water * targetCoveragePct/100 (the coverage goal), // - existing = sum of per-layer ExistingUSDPerHour, -// - gap = sum of the planned allocation gaps (what is actually being placed -// this run), NOT target - existing. +// - gap = sum of the planned allocations' on-demand-equivalent gaps (the unit +// of target and existing), NOT target - existing. // // When the baseline low-water is nil the run is a Hold-only no-op (Allocate // returns no allocations), so target and gap stay nil (never 0-coerced); @@ -600,9 +615,10 @@ func assembleLadderPlan( target = new(big.Rat).Mul(low, pctFrac) } } - // gap is what is actually being placed this run (Σ allocation gaps), - // consistent with total_hourly_commit, not target - existing. - gap = sumAllocationGaps(allocResult.Allocations) + // gap is what is being placed this run in on-demand-equivalent dollars, + // so it compares with target and existing; total_hourly_commit is the + // same placement in commitment dollars. + gap = sumAllocationOnDemandGaps(allocResult.Allocations) } return &pkgladder.LadderPlan{ @@ -638,26 +654,24 @@ func ratToFloat64Ptr(r *big.Rat) *float64 { return &f } -// sumAllocationGaps sums the GapUSDPerHour across all allocations as an exact -// *big.Rat. It returns a zero (non-nil) rat for an empty allocation set: a +// sumAllocationOnDemandGaps sums the on-demand-equivalent gap across all +// allocations as an exact *big.Rat, the unit of target and existing. It returns a zero (non-nil) rat for an empty allocation set: a // Hold-only run plans exactly $0 of new commitment, which is a meaningful // value here (what is being placed), not "not computed". -func sumAllocationGaps(allocs []pkgladder.Allocation) *big.Rat { +func sumAllocationOnDemandGaps(allocs []pkgladder.Allocation) *big.Rat { sum := new(big.Rat) for _, a := range allocs { - if a.GapUSDPerHour != nil { - sum.Add(sum, a.GapUSDPerHour) + if a.OnDemandEquivalentUSDPerHour != nil { + sum.Add(sum, a.OnDemandEquivalentUSDPerHour) } } return sum } // sumTrancheRowAmounts sums the amounts of the tranche rows a run persists. -// This is the non-nullable total_hourly_commit stored in the run row; it is 0 -// for a no-op (Hold-only) plan, which is the correct semantic (zero new -// commitment, not "not computed"). Units are whatever Allocate emits: -// on-demand-equivalent $/hr until the commitment-dollar conversion lands -// (go#349); this code stores amounts unchanged. +// This is the non-nullable total_hourly_commit stored in the run row, in +// commitment dollars; it is 0 for a no-op (Hold-only) plan, which is the +// correct semantic (zero new commitment, not "not computed"). func sumTrancheRowAmounts(rows []config.LadderTrancheDB) float64 { var sum float64 for i := range rows { @@ -707,18 +721,23 @@ func buildTrancheDBRows(tranches []pkgladder.Tranche, runID string, configID *st } amountUSDHr, _ := r.Float64() runIDCopy := runID - basis := config.LadderAmountOnDemand // The pinned pre-go349 SDK emits OD amounts. + if tr.CommitmentRate == "" { + return nil, fmt.Errorf("tranche %s: empty commitment rate", tr.ID) + } + basis := config.LadderAmountCommitment + rate := tr.CommitmentRate rows = append(rows, config.LadderTrancheDB{ - ID: tr.ID, - ConfigID: configID, - RunID: &runIDCopy, - LayerType: tr.Layer, - Term: tr.Term, - PaymentOption: tr.PaymentOption, - Status: pkgladder.TrancheStatusScheduled, - AmountUSDHr: amountUSDHr, - AmountBasis: &basis, - ScheduledDate: tr.FireAfter, + ID: tr.ID, + ConfigID: configID, + RunID: &runIDCopy, + LayerType: tr.Layer, + Term: tr.Term, + PaymentOption: tr.PaymentOption, + Status: pkgladder.TrancheStatusScheduled, + AmountUSDHr: amountUSDHr, + AmountBasis: &basis, + CommitmentRate: &rate, + ScheduledDate: tr.FireAfter, }) } return rows, nil diff --git a/internal/server/handler_ladder_lock_integration_test.go b/internal/server/handler_ladder_lock_integration_test.go index 2478fe12..3c20245f 100644 --- a/internal/server/handler_ladder_lock_integration_test.go +++ b/internal/server/handler_ladder_lock_integration_test.go @@ -122,7 +122,7 @@ func TestLadderLock_MixedBasisReachesActualPlanner(t *testing.T) { require.Equal(t, 5.0, total) var basis string require.NoError(t, e.conn.QueryRow(ctx, "SELECT amount_basis FROM ladder_tranches WHERE run_id=$1 LIMIT 1", id).Scan(&basis)) - require.Equal(t, string(od), basis) + require.Equal(t, string(native), basis) } func TestLadderLock_UnknownBasisPersistsNoRun(t *testing.T) { diff --git a/internal/server/handler_ladder_rates_test.go b/internal/server/handler_ladder_rates_test.go new file mode 100644 index 00000000..f084ab02 --- /dev/null +++ b/internal/server/handler_ladder_rates_test.go @@ -0,0 +1,99 @@ +package server + +import ( + "encoding/json" + "errors" + "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" +) + +var ratesTestNow = time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + +// runWithRates plans one run (low-water 12.5 -> target 10, one base allocation +// of 10 on-demand-equivalent) and returns the store and the error. +func runWithRates(t *testing.T, store *ladderTestStore, cap pkgladder.LadderCapability) error { + t.Helper() + dbCfg := validTestDBConfig("cfg-rates") + app := &Application{Config: store} + return app.executeLadderRun(testutil.TestContext(t), &dbCfg, cap, "123456789012", pkgladder.Term1Year, pkgladder.PaymentNoUpfront, ratesTestNow) +} + +func TestExecuteLadderRun_RateHalfPersistsCommitmentDollars(t *testing.T) { + store := &ladderTestStore{} + cap := &fakeLadderCapability{t: t, baseline: testBaselineLowWater(12.5), + rates: map[pkgladder.LayerType]float64{pkgladder.LayerConvertibleRI: 0.5}} + require.NoError(t, runWithRates(t, store, cap)) + + require.Len(t, store.savedTranches, 1) + tr := store.savedTranches[0] + assert.InDelta(t, 5.0, tr.AmountUSDHr, 1e-9, "10 on-demand at rate 1/2 is 5 commitment") + require.NotNil(t, tr.AmountBasis) + assert.Equal(t, config.LadderAmountCommitment, *tr.AmountBasis) + require.NotNil(t, tr.CommitmentRate) + assert.Equal(t, "1/2", *tr.CommitmentRate) + + run := store.savedRun + assert.InDelta(t, 5.0, run.TotalHourlyCommit, 1e-9, "total_hourly_commit stays commitment dollars") + require.NotNil(t, run.GapUSDHr) + assert.InDelta(t, 10.0, *run.GapUSDHr, 1e-9, "gap_usd_hr is on-demand-equivalent, comparable with target") +} + +func TestExecuteLadderRun_PlanJSONGapIsOnDemand(t *testing.T) { + store := &ladderTestStore{} + cap := &fakeLadderCapability{t: t, baseline: testBaselineLowWater(12.5), + rates: map[pkgladder.LayerType]float64{pkgladder.LayerConvertibleRI: 0.5}} + require.NoError(t, runWithRates(t, store, cap)) + var dto ladderPlanJSONDTO + require.NoError(t, json.Unmarshal(store.savedRun.Plan, &dto)) + require.NotNil(t, dto.GapUSDHr) + assert.InDelta(t, 10.0, *dto.GapUSDHr, 1e-9) +} + +func TestExecuteLadderRun_NoRateCapabilityIsErrored(t *testing.T) { + store := &ladderTestStore{} + cap := &fakeLadderCapability{t: t, baseline: testBaseline()} + // Embedding the interface hides GetCommitmentRates. + err := runWithRates(t, store, struct{ pkgladder.LadderCapability }{cap}) + require.ErrorContains(t, err, "commitment rates") + assert.Nil(t, store.savedRun) +} + +func TestExecuteLadderRun_RateFetchErrorIsErrored(t *testing.T) { + store := &ladderTestStore{} + cap := &fakeLadderCapability{t: t, baseline: testBaseline(), ratesErr: errors.New("ce throttled")} + err := runWithRates(t, store, cap) + require.ErrorContains(t, err, "ce throttled") + assert.Nil(t, store.savedRun) +} + +func TestExecuteLadderRun_EmptyRateMapIsHoldOnlyAndPersists(t *testing.T) { + store := &ladderTestStore{} + cap := &fakeLadderCapability{t: t, baseline: testBaselineLowWater(12.5), rates: map[pkgladder.LayerType]float64{}} + require.NoError(t, runWithRates(t, store, cap)) + require.NotNil(t, store.savedRun) + assert.Empty(t, store.savedTranches) + assert.Zero(t, store.savedRun.TotalHourlyCommit) +} + +func TestExecuteLadderRun_InFlightOnUnsupportedLayerFailsLoud(t *testing.T) { + store := &ladderTestStore{inFlightRows: []config.LadderInFlightRow{{Layer: pkgladder.LayerComputeSP, CommitmentUSDPerHour: 1, Rate: 1}}} + cap := &fakeLadderCapability{t: t, baseline: testBaseline()} + err := runWithRates(t, store, cap) + require.ErrorContains(t, err, "not in the supported layers") + assert.Nil(t, store.savedRun) +} + +func TestBuildTrancheDBRows_EmptyCommitmentRateErrors(t *testing.T) { + cfgID := "cfg" + _, err := buildTrancheDBRows([]pkgladder.Tranche{{ + ID: "tr-norate", Layer: pkgladder.LayerEC2InstanceSP, AmountUSDPerHour: "5", FireAfter: ratesTestNow, + }}, "run", &cfgID) + require.ErrorContains(t, err, "tr-norate") +} diff --git a/internal/server/handler_ladder_test.go b/internal/server/handler_ladder_test.go index edbd7ca1..7566f406 100644 --- a/internal/server/handler_ladder_test.go +++ b/internal/server/handler_ladder_test.go @@ -31,9 +31,21 @@ type fakeLadderCapability struct { layerStates map[pkgladder.LayerType]pkgladder.LayerState layerStatesErr error supportedLayers []pkgladder.LayerSpec + rates map[pkgladder.LayerType]float64 // nil means 1.0 for both default layers + ratesErr error t *testing.T } +func (f *fakeLadderCapability) GetCommitmentRates(_ context.Context, _ pkgladder.Scope, _ pkgladder.Term, _ pkgladder.PaymentOption) (map[pkgladder.LayerType]float64, error) { + if f.ratesErr != nil { + return nil, f.ratesErr + } + if f.rates != nil { + return f.rates, nil + } + return map[pkgladder.LayerType]float64{pkgladder.LayerConvertibleRI: 1, pkgladder.LayerEC2InstanceSP: 1}, nil +} + func (f *fakeLadderCapability) Provider() pkgcommon.ProviderType { return pkgcommon.ProviderAWS } @@ -126,6 +138,7 @@ type ladderTestStore struct { // L5: configurable in-flight hourly commitment handed to the plan builder // by PlanAndSaveLadderRun. Defaults to zero. inFlightUSDHr *float64 + inFlightRows []config.LadderInFlightRow // overrides inFlightUSDHr inFlightErr error // Capture fields: non-nil if the call was made. savedRuns accumulates every @@ -194,9 +207,9 @@ func (s *ladderTestStore) PlanAndSaveLadderRun(_ context.Context, _ string, _ ti if s.inFlightErr != nil { return nil, s.inFlightErr } - var rows []config.LadderInFlightRow + rows := s.inFlightRows if s.inFlightUSDHr != nil { - rows = []config.LadderInFlightRow{{Layer: pkgladder.LayerComputeSP, CommitmentUSDPerHour: *s.inFlightUSDHr, Rate: 1}} + rows = []config.LadderInFlightRow{{Layer: pkgladder.LayerConvertibleRI, CommitmentUSDPerHour: *s.inFlightUSDHr, Rate: 1}} } run, tranches, err := build(rows) if err != nil { @@ -607,8 +620,8 @@ func TestExecuteLadderRun_ImmediateStepIsTrancheAndPlanShowsOnlyIt(t *testing.T) require.NoError(t, err) var persisted map[string]any require.NoError(t, json.Unmarshal(encoded, &persisted)) - assert.Equal(t, "on_demand_equivalent", persisted["amount_basis"]) - assert.NotContains(t, persisted, "commitment_rate") + assert.Equal(t, "commitment", persisted["amount_basis"]) + assert.Equal(t, "1", persisted["commitment_rate"]) } assert.True(t, store.savedTranches[0].ScheduledDate.Equal(now), "immediate tranche scheduled_date must be the run start") assert.InDelta(t, 2.5, store.savedTranches[0].AmountUSDHr, 1e-9) @@ -1085,6 +1098,7 @@ func TestBuildTrancheDBRows_MalformedAmount_Errors(t *testing.T) { PaymentOption: pkgladder.PaymentNoUpfront, Status: pkgladder.TrancheStatusScheduled, AmountUSDPerHour: "3/2", + CommitmentRate: "1", FireAfter: time.Now(), }, { @@ -1117,10 +1131,10 @@ func TestRatToFloat64Ptr_PositiveValue(t *testing.T) { // Build a *big.Rat from an Allocation's GapUSDPerHour (same boundary path // used in the handler). ratToFloat64Ptr lives in this package so no import // of math/big is needed in the test. - zeroInflight := 0.0 allocResult, err := pkgladder.Allocate(&pkgladder.AllocationInput{ - Now: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), - InFlightUSDPerHour: &zeroInflight, + Now: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), + InFlight: []pkgladder.InFlightTranche{}, + CommitmentRates: map[pkgladder.LayerType]float64{pkgladder.LayerConvertibleRI: 1, pkgladder.LayerEC2InstanceSP: 1}, Baseline: pkgladder.UsageBaseline{ LowWaterUSDPerHour: nonZeroFloat64(5.0), StableUSDPerHour: nonZeroFloat64(4.5), @@ -1219,14 +1233,14 @@ func (s *ladderStatefulStore) scheduledTrancheIDs(configID string) map[string]bo // any existing tranche's status. This mirrors the production store, whose // supersede/cancel path was removed. func (s *ladderStatefulStore) PlanAndSaveLadderRun(_ context.Context, configID string, asOf time.Time, build config.LadderPlanBuilder) (*config.LadderRunDB, error) { - inFlight := 0.0 + var rows []config.LadderInFlightRow for i := range s.tranches { tr := &s.tranches[i] if tr.ConfigID != nil && *tr.ConfigID == configID && tr.Status == pkgladder.TrancheStatusScheduled && tr.ScheduledDate.After(asOf) { - inFlight += tr.AmountUSDHr + rows = append(rows, config.LadderInFlightRow{Layer: tr.LayerType, CommitmentUSDPerHour: tr.AmountUSDHr, Rate: 1}) } } - run, tranches, err := build([]config.LadderInFlightRow{{Layer: pkgladder.LayerComputeSP, CommitmentUSDPerHour: inFlight, Rate: 1}}) + run, tranches, err := build(rows) if err != nil { return nil, err }