diff --git a/internal/api/handler_purchases_plan_step_approve_test.go b/internal/api/handler_purchases_plan_step_approve_test.go index ae3178e4..418a4ec0 100644 --- a/internal/api/handler_purchases_plan_step_approve_test.go +++ b/internal/api/handler_purchases_plan_step_approve_test.go @@ -57,7 +57,8 @@ func planStepExecution(t *testing.T) (*config.PurchaseExecution, float64) { Services: map[string]config.ServiceConfig{"azure:compute": {Provider: "azure", Service: "compute", Enabled: true, Term: 1, Payment: "no-upfront", Coverage: 80}}, RampSchedule: config.RampSchedule{PercentPerStep: 25}, } - recs, err := planstep.Resolve(context.Background(), stepSource{[]config.RecommendationRecord{stored}}, stepStore{[]config.RecommendationRecord{stored}}, plan, []string{stepAcct}) + res, err := planstep.Resolve(context.Background(), stepSource{[]config.RecommendationRecord{stored}}, stepStore{[]config.RecommendationRecord{stored}}, plan, []string{stepAcct}, 1, nil, nil) + recs := res.Recs require.NoError(t, err) require.Len(t, recs, 1) total := recs[0].TotalCommitment() diff --git a/internal/planstep/precheck_test.go b/internal/planstep/precheck_test.go index 00fdf03e..935a60b9 100644 --- a/internal/planstep/precheck_test.go +++ b/internal/planstep/precheck_test.go @@ -74,13 +74,13 @@ func TestResolveRefusesUnsupportedPlanBeforeReading(t *testing.T) { svc.MinCount = 3 plan.Services["azure:compute"] = svc // A nil Source/Store would panic if Resolve read anything. - _, err := Resolve(t.Context(), nil, nil, plan, accts) + _, err := resolveStep1(t, nil, nil, plan, accts) require.ErrorIs(t, err, ErrUnsupportedPlan) } func TestResolveRefusesStoredSPWithCountNotOne(t *testing.T) { stored := spRec(`{"hourly_commitment":1.234}`) stored.Count = 3 - _, err := Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{stored}}, fakeStore{rows: []config.RecommendationRecord{stored}}, spPlan(80, 25), accts) + _, err := resolveStep1(t, fakeSource{shown: []config.RecommendationRecord{stored}}, fakeStore{rows: []config.RecommendationRecord{stored}}, spPlan(80, 25), accts) require.ErrorIs(t, err, ErrUnsupportedPlan) } diff --git a/internal/planstep/resolve.go b/internal/planstep/resolve.go index 08401775..4724c79d 100644 --- a/internal/planstep/resolve.go +++ b/internal/planstep/resolve.go @@ -10,6 +10,8 @@ import ( "fmt" "math" "sort" + "strconv" + "strings" "github.com/LeanerCloud/cloud-commitments-go/pkg/common" "github.com/LeanerCloud/cloud-commitments-platform/internal/config" @@ -20,6 +22,11 @@ import ( // retry on a later tick and fail only after the grace window. var ErrNoMatch = errors.New("no current recommendation matches the plan") +// ErrTargetMet means every matched recommendation already has this plan's +// cumulative target committed, so the step has nothing left to buy. It is +// distinct from ErrNoMatch (nothing matched, retry later). +var ErrTargetMet = errors.New("the plan's cumulative target is already met") + // spHourlyStep is the precision AWS accepts for a Savings Plans hourly // commitment (the purchase client formats it with %.2f). Sizing rounds DOWN // to whole cents (floor(hourly*product/100)/100) and drops anything below one step. @@ -38,48 +45,150 @@ type Store interface { GetGlobalConfig(ctx context.Context) (*config.GlobalConfig, error) } -// Resolve returns the recommendations the plan's next step would buy: for each +// Result is what Resolve decided for one step. +type Result struct { + // Recs are the recommendations the step would buy. + Recs []config.RecommendationRecord + // Baseline is set only when step is 1: every matched count-based identity + // mapped to its base (the existing baseline entry, else step-1 shown), + // including recs that sized to 0. The caller freezes it; nil at step > 1. + Baseline config.PlanRampBaseline + // Skipped lists identities with no baseline at step > 1 (new recs, newly + // added accounts). They are never frozen at first sight: under-buy. + Skipped []string +} + +// ramp carries the cumulative-sizing inputs and accumulates its outputs. +type ramp struct { + step int + cum float64 + baseline config.PlanRampBaseline + committed map[string]config.PlanRampQuantity + out Result +} + +// Resolve returns the recommendations the plan's step would buy: for each // enabled plan service, the recs Opportunities shows (strict read, enabled // providers) restricted to the plan's term, payment and target accounts, sized -// to Coverage * PercentPerStep and priced from the unsuppressed stored row. +// to the CUMULATIVE target and priced from the unsuppressed stored row. For a +// count rec: target = floor(base * Coverage * CumulativePercent(step) / 10000) +// and buy = clamp(target - committed, 0, shown). base is the frozen baseline +// entry, or at step 1 the shown count (post-suppression, never above demand); +// at step > 1 an identity without a baseline is skipped. baseline and +// committed are caller-supplied (this function does no extra store reads). // accountIDs are the plan's target accounts; recs without an account are // excluded. Every failure is returned; there is no unsized or over-show -// fallback. An empty result is ErrNoMatch. -func Resolve(ctx context.Context, src Source, store Store, plan *config.PurchasePlan, accountIDs []string) ([]config.RecommendationRecord, error) { +// fallback. An empty result is ErrTargetMet only when a non-empty baseline +// exists and every baselined identity has its target committed (decided +// without `shown`); anything else, including a hidden identity with a gap or an +// empty step-1 read (no baseline returned), is ErrNoMatch. +// Savings Plans keep their per-step sizing (platform#631 Q7). +func Resolve(ctx context.Context, src Source, store Store, plan *config.PurchasePlan, accountIDs []string, + step int, baseline config.PlanRampBaseline, committed map[string]config.PlanRampQuantity) (Result, error) { if err := Precheck(plan); err != nil { - return nil, err + return Result{}, err + } + if step < 1 { + return Result{}, fmt.Errorf("plan %s: step %d must be at least 1", plan.ID, step) } if len(accountIDs) == 0 { - return nil, fmt.Errorf("plan %s has no target accounts", plan.ID) + return Result{}, fmt.Errorf("plan %s has no target accounts", plan.ID) } global, err := store.GetGlobalConfig(ctx) if err != nil { - return nil, fmt.Errorf("read global config: %w", err) + return Result{}, fmt.Errorf("read global config: %w", err) } allowed := make(map[string]struct{}, len(accountIDs)) for _, id := range accountIDs { allowed[id] = struct{}{} } - var out []config.RecommendationRecord + r := &ramp{step: step, cum: plan.RampSchedule.CumulativePercent(step), baseline: baseline, committed: committed} + if step == 1 { + r.out.Baseline = config.PlanRampBaseline{} + } + if err := r.resolveServices(ctx, src, store, plan, accountIDs, allowed, global.EnabledProviders); err != nil { + return Result{}, err + } + return r.out, r.emptyErr(plan) +} + +// emptyErr classifies an empty result; nil when there is something to buy. +func (r *ramp) emptyErr(plan *config.PurchasePlan) error { + if len(r.out.Recs) > 0 { + return nil + } + if len(r.out.Baseline) == 0 { + r.out.Baseline = nil // an empty baseline is never frozen + } + if r.targetMet(plan) { + return ErrTargetMet + } + return ErrNoMatch +} + +// resolveServices sizes every enabled plan service into r.out.Recs. +func (r *ramp) resolveServices(ctx context.Context, src Source, store Store, plan *config.PurchasePlan, + accountIDs []string, allowed map[string]struct{}, enabledProviders []string) error { seen := map[string]struct{}{} for _, k := range sortedKeys(plan.Services) { svc := plan.Services[k] if !svc.Enabled { continue } - recs, err := resolveService(ctx, src, store, &svc, plan.RampSchedule.PercentPerStep, accountIDs, allowed, global.EnabledProviders) + recs, err := resolveService(ctx, src, store, &svc, plan.RampSchedule.PercentPerStep, r, accountIDs, allowed, enabledProviders) if err == nil { err = claimIdentities(seen, recs) } if err != nil { - return nil, fmt.Errorf("plan service %q: %w", k, err) + return fmt.Errorf("plan service %q: %w", k, err) } - out = append(out, recs...) + r.out.Recs = append(r.out.Recs, recs...) } - if len(out) == 0 { - return nil, ErrNoMatch + return nil +} + +// targetMet reports whether a non-empty frozen baseline exists and every +// baselined identity of an enabled plan service has its cumulative target +// committed. It reads no `shown`: an identity that is merely hidden (still has +// a gap) is not met, so it ends as ErrNoMatch, never a silent no-op advance. +func (r *ramp) targetMet(plan *config.PurchasePlan) bool { + if len(r.baseline) == 0 { + return false + } + judged := 0 + for key, base := range r.baseline { + svc, ok := planServiceOf(plan, key) + if !ok { + continue + } + judged++ + if r.target(base.Count, svc.Coverage) > r.committed[key].Count { + return false + } } - return out, nil + return judged > 0 +} + +// planServiceOf finds the enabled plan service an identity key belongs to +// (provider, service and term are fields 0, 2 and 6 of the key). +func planServiceOf(plan *config.PurchasePlan, key string) (config.ServiceConfig, bool) { + f := strings.Split(key, "\x1f") + if len(f) < 7 { + return config.ServiceConfig{}, false + } + for _, k := range sortedKeys(plan.Services) { + svc := plan.Services[k] + if svc.Enabled && strings.EqualFold(svc.Provider, f[0]) && svc.Service == f[2] && strconv.Itoa(svc.Term) == f[6] { + return svc, true + } + } + return config.ServiceConfig{}, false +} + +// target is floor(base * coverage * cum / 10000); coverage*cum is multiplied +// before the base so 100*10 over 10000 stays exact. +func (r *ramp) target(base int, coverage float64) int { + return int(math.Floor(float64(base) * (coverage * r.cum) / 10000)) } func sortedKeys(m map[string]config.ServiceConfig) []string { @@ -107,7 +216,7 @@ func claimIdentities(seen map[string]struct{}, recs []config.RecommendationRecor return nil } -func resolveService(ctx context.Context, src Source, store Store, svc *config.ServiceConfig, percentPerStep float64, +func resolveService(ctx context.Context, src Source, store Store, svc *config.ServiceConfig, percentPerStep float64, r *ramp, accountIDs []string, allowed map[string]struct{}, enabledProviders []string) ([]config.RecommendationRecord, error) { wantPayment, err := validateInputs(svc, percentPerStep) if err != nil { @@ -133,7 +242,7 @@ func resolveService(ctx context.Context, src Source, store Store, svc *config.Se if !match { continue } - sized, keep, err := sizeFromStored(&rec, stored, svc.Coverage*percentPerStep) + sized, keep, err := r.sizeFromStored(&rec, stored, svc.Coverage, percentPerStep) if err != nil { return nil, fmt.Errorf("recommendation %s: %w", rec.ID, err) } @@ -196,7 +305,7 @@ func storedByIdentity(ctx context.Context, store Store, filter config.Recommenda // sizeFromStored joins shown to its unsuppressed stored row on the full // identity key, then sizes and prices it. -func sizeFromStored(shown *config.RecommendationRecord, stored map[string]*config.RecommendationRecord, product float64) (config.RecommendationRecord, bool, error) { +func (r *ramp) sizeFromStored(shown *config.RecommendationRecord, stored map[string]*config.RecommendationRecord, coverage, percentPerStep float64) (config.RecommendationRecord, bool, error) { key, err := config.RecIdentityKey(shown) if err != nil { return config.RecommendationRecord{}, false, err @@ -205,14 +314,43 @@ func sizeFromStored(shown *config.RecommendationRecord, stored map[string]*confi if base == nil { return config.RecommendationRecord{}, false, errors.New("no stored row to price from (cache changed mid-read)") } - return size(shown, base, product) + if common.IsSavingsPlan(common.ServiceType(base.Service)) { + return size(shown, base, coverage*percentPerStep, 0) + } + n, ok := r.countToBuy(key, shown.Count, coverage) + if !ok { + return config.RecommendationRecord{}, false, nil + } + return size(shown, base, 0, n) +} + +// countToBuy applies the cumulative formula to one count identity and records +// the baseline entry (step 1, shown > 0). ok=false means skip: no baseline at +// step > 1. +func (r *ramp) countToBuy(key string, shown int, coverage float64) (int, bool) { + base, frozen := r.baseline[key] + switch { + case !frozen && r.step == 1: + base = config.PlanRampQuantity{Count: shown} + case !frozen: + r.out.Skipped = append(r.out.Skipped, key) + return 0, false + } + if r.step == 1 && base.Count > 0 { + r.out.Baseline[key] = base + } + gap := r.target(base.Count, coverage) - r.committed[key].Count + if gap <= 0 { + return 0, true + } + return min(gap, shown), true } -// size returns shown sized to product/10000 of itself (product = -// coverage*percentPerStep, multiplied before dividing so 100*10 over 10000 -// stays exact) and priced from the unsuppressed stored row. keep=false means -// the rec sized to nothing. -func size(shown, stored *config.RecommendationRecord, product float64) (config.RecommendationRecord, bool, error) { +// size returns shown sized to n units (count recs) or to product/10000 of its +// hourly commitment (Savings Plans; product = coverage*percentPerStep, +// multiplied before dividing) and priced from the unsuppressed stored row. +// keep=false means the rec sized to nothing. +func size(shown, stored *config.RecommendationRecord, product float64, n int) (config.RecommendationRecord, bool, error) { if stored.Count <= 0 { return config.RecommendationRecord{}, false, fmt.Errorf("stored count %d cannot price a share", stored.Count) } @@ -228,7 +366,7 @@ func size(shown, stored *config.RecommendationRecord, product float64) (config.R if common.IsSavingsPlan(common.ServiceType(stored.Service)) { ratio, err = sizeSP(&out, stored, product) } else { - ratio = sizeCount(&out, stored, shown.Count, product) + ratio = sizeCount(&out, stored, n) } if err != nil || ratio == 0 { return out, false, err @@ -244,10 +382,9 @@ func size(shown, stored *config.RecommendationRecord, product float64) (config.R return out, true, nil } -// sizeCount sets out.Count to floor(shown*product/10000) and returns the -// discrete ratio to stored.Count; 0 means it sized to nothing. -func sizeCount(out, stored *config.RecommendationRecord, shown int, product float64) float64 { - n := int(math.Floor(float64(shown) * product / 10000)) +// sizeCount sets out.Count to n and returns the ratio to stored.Count; 0 +// means it sized to nothing. +func sizeCount(out, stored *config.RecommendationRecord, n int) float64 { if n <= 0 { return 0 } diff --git a/internal/planstep/resolve_cumulative_test.go b/internal/planstep/resolve_cumulative_test.go new file mode 100644 index 00000000..7c8c6d8a --- /dev/null +++ b/internal/planstep/resolve_cumulative_test.go @@ -0,0 +1,197 @@ +package planstep + +import ( + "errors" + "testing" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Fixture/mock evidence: in-memory fakes, no database or cloud. + +func cumKey(t *testing.T, r config.RecommendationRecord) string { + t.Helper() + k, err := config.RecIdentityKey(&r) + require.NoError(t, err) + return k +} + +func cumPlan(coverage, pps float64, typ string, steps int) *config.PurchasePlan { + p := azurePlan(coverage, pps) + p.RampSchedule.Type, p.RampSchedule.TotalSteps = typ, steps + return p +} + +// runRamp drives Resolve step by step like the scheduler will: step 1 freezes +// the baseline, every bought count lands in the ledger. shownAt models what the +// strict read shows before each step given what is committed so far. +func runRamp(t *testing.T, plan *config.PurchasePlan, stored int, steps int, shownAt func(step, committed int) int) []int { + t.Helper() + row := azureRec(stored, 1000) + key := cumKey(t, row) + var baseline config.PlanRampBaseline + committed := map[string]config.PlanRampQuantity{} + var bought []int + for step := 1; step <= steps; step++ { + shown := row + shown.Count = shownAt(step, committed[key].Count) + res, err := Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{shown}}, fakeStore{rows: []config.RecommendationRecord{row}}, plan, accts, step, baseline, committed) + n := 0 + if err == nil { + n = res.Recs[0].Count + } else { + require.True(t, errors.Is(err, ErrTargetMet) || errors.Is(err, ErrNoMatch), "unexpected error: %v", err) + } + if step == 1 { + baseline = res.Baseline + } + committed[key] = config.PlanRampQuantity{Count: committed[key].Count + n} + bought = append(bought, n) + } + return bought +} + +// suppressed models step-1..k buys hiding capacity from the strict read. +func suppressed(demand int) func(int, int) int { + return func(_, committed int) int { return demand - committed } +} + +func TestCumulativeWeekly25Coverage80(t *testing.T) { + // The pre-cumulative formula bought 20,16,12,10 here. + got := runRamp(t, cumPlan(80, 25, "weekly", 4), 100, 5, suppressed(100)) + assert.Equal(t, []int{20, 20, 20, 20, 0}, got) +} + +func TestCumulativeCustom3x40(t *testing.T) { + got := runRamp(t, cumPlan(80, 40, "custom", 3), 100, 4, suppressed(100)) + assert.Equal(t, []int{32, 32, 16, 0}, got) +} + +func TestCumulativeLaggingShownDoesNotOverBuy(t *testing.T) { + // Suppressions expired: the provider still lists the capacity step 1 bought, + // so shown regrows to the full 100. A base of shown+committed would grow the + // target to 120*0.8*50% = 48 and buy 28; the frozen base buys exactly 20. + full := func(int, int) int { return 100 } + got := runRamp(t, cumPlan(80, 25, "weekly", 4), 100, 6, full) + assert.Equal(t, []int{20, 20, 20, 20, 0, 0}, got) + assert.Equal(t, 80, got[0]+got[1]+got[2]+got[3], "total never exceeds frozen base*coverage") +} + +func TestCumulativeManualPurchaseLowersShownClampsToShown(t *testing.T) { + // Someone bought 85 elsewhere before step 2: only 15 remain shown, below the + // 20 the target asks for. + got := runRamp(t, cumPlan(80, 25, "weekly", 4), 100, 2, func(step, committed int) int { + if step == 1 { + return 100 + } + return 15 + }) + assert.Equal(t, []int{20, 15}, got) +} + +func TestCumulativeTargetMet(t *testing.T) { + row := azureRec(100, 1000) + key := cumKey(t, row) + plan := cumPlan(80, 25, "weekly", 4) + baseline := config.PlanRampBaseline{key: {Count: 100}} + for _, committed := range []int{40, 55} { // step 2 target is 40 + res, err := Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{row}}, fakeStore{rows: []config.RecommendationRecord{row}}, plan, accts, 2, baseline, map[string]config.PlanRampQuantity{key: {Count: committed}}) + require.ErrorIs(t, err, ErrTargetMet) + assert.NotErrorIs(t, err, ErrNoMatch) + assert.Empty(t, res.Recs) + } +} + +func TestCumulativeShownZero(t *testing.T) { + // A rec that shows nothing buys nothing and is no-match, not target-met. + row := azureRec(100, 1000) + zero := row + zero.Count = 0 + res, err := Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{zero}}, fakeStore{rows: []config.RecommendationRecord{row}}, cumPlan(80, 25, "weekly", 4), accts, 1, nil, nil) + require.ErrorIs(t, err, ErrNoMatch) + assert.NotErrorIs(t, err, ErrTargetMet) + assert.Nil(t, res.Baseline) +} + +func TestCumulativePercentOver100Clamps(t *testing.T) { + // 60%/step: step 2 is 120% -> 100%, step 3 stays 100% and is met. + got := runRamp(t, cumPlan(100, 60, "custom", 3), 100, 3, suppressed(100)) + assert.Equal(t, []int{60, 40, 0}, got) +} + +func TestCumulativeRoundsDown(t *testing.T) { + // base 7: floor(7*0.5)=3, then target 7 buys the remaining 4. + assert.Equal(t, []int{3, 4}, runRamp(t, cumPlan(100, 50, "custom", 2), 7, 2, suppressed(7))) + // base 7 at 50% coverage, 50%/step: floor(1.75)=1 then floor(3.5)=3 -> buys 2. + assert.Equal(t, []int{1, 2}, runRamp(t, cumPlan(50, 50, "custom", 2), 7, 2, suppressed(7))) +} + +func TestCumulativeBaselineRecordsEveryMatchedIdentity(t *testing.T) { + tiny := azureRec(3, 1000) // floor(3*10*10/10000) = 0: sizes to nothing + res, err := Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{tiny}}, fakeStore{rows: []config.RecommendationRecord{tiny}}, cumPlan(10, 10, "weekly", 10), accts, 1, nil, nil) + require.ErrorIs(t, err, ErrNoMatch) + assert.Equal(t, config.PlanRampBaseline{cumKey(t, tiny): {Count: 3}}, res.Baseline) +} + +func TestCumulativeSkipsIdentityWithoutBaselineAfterStep1(t *testing.T) { + row := azureRec(100, 1000) + res, err := Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{row}}, fakeStore{rows: []config.RecommendationRecord{row}}, cumPlan(80, 25, "weekly", 4), accts, 2, config.PlanRampBaseline{"other": {Count: 9}}, nil) + require.ErrorIs(t, err, ErrNoMatch) + assert.Equal(t, []string{cumKey(t, row)}, res.Skipped) + assert.Nil(t, res.Baseline, "never frozen after step 1") +} + +func TestCumulativeRejectsStepBelowOne(t *testing.T) { + row := azureRec(100, 1000) + _, err := Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{row}}, fakeStore{rows: []config.RecommendationRecord{row}}, cumPlan(80, 25, "weekly", 4), accts, 0, nil, nil) + require.Error(t, err) +} + +func TestCumulativeGapWithShownZeroIsNoMatchNotNoop(t *testing.T) { + // G1: step 2 still has a gap of 20 but the identity is hidden (shown 0). + row := azureRec(100, 1000) + key := cumKey(t, row) + zero := row + zero.Count = 0 + _, err := Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{zero}}, fakeStore{rows: []config.RecommendationRecord{row}}, cumPlan(80, 25, "weekly", 4), accts, 2, + config.PlanRampBaseline{key: {Count: 100}}, map[string]config.PlanRampQuantity{key: {Count: 20}}) + require.ErrorIs(t, err, ErrNoMatch) + assert.NotErrorIs(t, err, ErrTargetMet) +} + +func TestCumulativeTargetMetIgnoresShown(t *testing.T) { + // Fully bought and hidden from the read: still met, decided from the ledger. + row := azureRec(100, 1000) + key := cumKey(t, row) + _, err := Resolve(t.Context(), fakeSource{}, fakeStore{rows: []config.RecommendationRecord{row}}, cumPlan(80, 25, "weekly", 4), accts, 4, + config.PlanRampBaseline{key: {Count: 100}}, map[string]config.PlanRampQuantity{key: {Count: 80}}) + require.ErrorIs(t, err, ErrTargetMet) +} + +func TestCumulativeEmptyStep1IsNoMatchWithoutBaseline(t *testing.T) { + // G2: nothing shown at step 1 freezes nothing and is never a no-op. + row := azureRec(100, 1000) + res, err := Resolve(t.Context(), fakeSource{}, fakeStore{rows: []config.RecommendationRecord{row}}, cumPlan(80, 25, "weekly", 4), accts, 1, nil, nil) + require.ErrorIs(t, err, ErrNoMatch) + assert.Nil(t, res.Baseline) +} + +func TestCumulativeNoBaselineIsNeverTargetMet(t *testing.T) { + row := azureRec(100, 1000) + _, err := Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{row}}, fakeStore{rows: []config.RecommendationRecord{row}}, cumPlan(80, 25, "weekly", 4), accts, 2, nil, nil) + require.ErrorIs(t, err, ErrNoMatch) +} + +func TestCumulativeFlooredToZeroTargetIsLegitimateNoop(t *testing.T) { + // base 1 at cov 80, 25%/step: floor(0.2)=0 target; baseline is kept and the + // step is a no-op only once something is baselined. + row := azureRec(1, 1000) + key := cumKey(t, row) + res, err := Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{row}}, fakeStore{rows: []config.RecommendationRecord{row}}, cumPlan(80, 25, "weekly", 4), accts, 1, nil, nil) + require.ErrorIs(t, err, ErrNoMatch) // first sight: nothing committed yet + assert.Equal(t, config.PlanRampBaseline{key: {Count: 1}}, res.Baseline) + _, err = Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{row}}, fakeStore{rows: []config.RecommendationRecord{row}}, cumPlan(80, 25, "weekly", 4), accts, 2, res.Baseline, nil) + require.ErrorIs(t, err, ErrTargetMet) +} diff --git a/internal/planstep/resolve_test.go b/internal/planstep/resolve_test.go index a1b6f51e..10c88faa 100644 --- a/internal/planstep/resolve_test.go +++ b/internal/planstep/resolve_test.go @@ -40,6 +40,13 @@ func (f fakeStore) GetGlobalConfig(context.Context) (*config.GlobalConfig, error return &f.global, f.globalErr } +// resolveStep1 is the pre-cumulative call shape: step 1, no baseline, no ledger. +func resolveStep1(t *testing.T, src Source, store Store, plan *config.PurchasePlan, accountIDs []string) ([]config.RecommendationRecord, error) { + t.Helper() + res, err := Resolve(t.Context(), src, store, plan, accountIDs, 1, nil, nil) + return res.Recs, err +} + func ptr[T any](v T) *T { return &v } func azureRec(count int, upfront float64) config.RecommendationRecord { @@ -68,7 +75,7 @@ func TestResolveSizesAndPricesFromUnsuppressedRow(t *testing.T) { shown.SuppressedCount = 10 shown.UpfrontCost = 1 // the displayed row's totals must never be the price source // Plan payment "no-upfront" must match the Azure token "monthly". - got, err := Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{shown}}, fakeStore{rows: []config.RecommendationRecord{stored}}, azurePlan(80, 25), accts) + got, err := resolveStep1(t, fakeSource{shown: []config.RecommendationRecord{shown}}, fakeStore{rows: []config.RecommendationRecord{stored}}, azurePlan(80, 25), accts) require.NoError(t, err) require.Len(t, got, 1) n := 2 // floor(10*80*25/10000) @@ -86,12 +93,12 @@ func TestResolveSizesAndPricesFromUnsuppressedRow(t *testing.T) { func TestResolveFloorsProductFirst(t *testing.T) { stored := azureRec(100, 1000) // 0.7*0.1 as a ratio is 0.06999999999999999, which floors 7 to 6. - got, err := Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{stored}}, fakeStore{rows: []config.RecommendationRecord{stored}}, azurePlan(70, 10), accts) + got, err := resolveStep1(t, fakeSource{shown: []config.RecommendationRecord{stored}}, fakeStore{rows: []config.RecommendationRecord{stored}}, azurePlan(70, 10), accts) require.NoError(t, err) assert.Equal(t, 7, got[0].Count) // 0.29 as a ratio is 0.28999999999999998, which floors 29 to 28. - got, err = Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{stored}}, fakeStore{rows: []config.RecommendationRecord{stored}}, azurePlan(100, 29), accts) + got, err = resolveStep1(t, fakeSource{shown: []config.RecommendationRecord{stored}}, fakeStore{rows: []config.RecommendationRecord{stored}}, azurePlan(100, 29), accts) require.NoError(t, err) assert.Equal(t, 29, got[0].Count) } @@ -110,17 +117,17 @@ func TestResolveFilters(t *testing.T) { upfront.Payment = "upfront" mixed := []config.RecommendationRecord{other, nilAcct, threeYear, upfront} // The source ignores the filter, so the explicit re-check is what holds. - _, err := Resolve(t.Context(), fakeSource{shown: mixed}, fakeStore{rows: append(mixed, stored)}, azurePlan(100, 100), accts) + _, err := resolveStep1(t, fakeSource{shown: mixed}, fakeStore{rows: append(mixed, stored)}, azurePlan(100, 100), accts) assert.ErrorIs(t, err, ErrNoMatch) disabled := azurePlan(100, 100) svc := disabled.Services["azure:compute"] svc.Enabled = false disabled.Services["azure:compute"] = svc - _, err = Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{stored}}, fakeStore{rows: []config.RecommendationRecord{stored}}, disabled, accts) + _, err = resolveStep1(t, fakeSource{shown: []config.RecommendationRecord{stored}}, fakeStore{rows: []config.RecommendationRecord{stored}}, disabled, accts) assert.ErrorIs(t, err, ErrNoMatch) - _, err = Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{stored}}, fakeStore{rows: []config.RecommendationRecord{stored}, global: config.GlobalConfig{EnabledProviders: []string{"gcp"}}}, azurePlan(100, 100), accts) + _, err = resolveStep1(t, fakeSource{shown: []config.RecommendationRecord{stored}}, fakeStore{rows: []config.RecommendationRecord{stored}, global: config.GlobalConfig{EnabledProviders: []string{"gcp"}}}, azurePlan(100, 100), accts) assert.ErrorIs(t, err, ErrNoMatch, "plan must apply the same enabled-providers filter as Opportunities") } @@ -161,7 +168,7 @@ func TestResolveFailsClosed(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got, err := Resolve(t.Context(), tc.src, tc.store, tc.plan, tc.accts) + got, err := resolveStep1(t, tc.src, tc.store, tc.plan, tc.accts) require.Error(t, err) assert.NotErrorIs(t, err, ErrNoMatch) assert.Empty(t, got, "nothing may be returned to attach") @@ -180,7 +187,7 @@ func TestResolveJoinsOnFullIdentity(t *testing.T) { } rows := []config.RecommendationRecord{mk("us-east-1a", "Linux/UNIX", 100), mk("us-east-1b", "Linux/UNIX", 200), mk("us-east-1a", "Windows", 300)} plan := &config.PurchasePlan{ID: "p", Services: map[string]config.ServiceConfig{"aws:ec2": {Provider: "aws", Service: "ec2", Enabled: true, Term: 1, Payment: "no-upfront", Coverage: 100}}, RampSchedule: config.RampSchedule{PercentPerStep: 50}} - got, err := Resolve(t.Context(), fakeSource{shown: rows}, fakeStore{rows: rows}, plan, accts) + got, err := resolveStep1(t, fakeSource{shown: rows}, fakeStore{rows: rows}, plan, accts) require.NoError(t, err) require.Len(t, got, 3) for i := range got { @@ -194,7 +201,7 @@ func TestResolveRefusesOverlappingPlanServices(t *testing.T) { stored := azureRec(20, 1000) plan := azurePlan(100, 100) plan.Services["azure:compute-again"] = plan.Services["azure:compute"] - _, err := Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{stored}}, fakeStore{rows: []config.RecommendationRecord{stored}}, plan, accts) + _, err := resolveStep1(t, fakeSource{shown: []config.RecommendationRecord{stored}}, fakeStore{rows: []config.RecommendationRecord{stored}}, plan, accts) require.Error(t, err) assert.NotErrorIs(t, err, ErrNoMatch) } @@ -218,7 +225,7 @@ func TestResolveSavingsPlanRoundsDownAndKeepsRawKeys(t *testing.T) { require.NoError(t, err) stored.Count = 1 shown := stored - got, err := Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{shown}}, fakeStore{rows: []config.RecommendationRecord{stored}}, spPlan(80, 25), accts) + got, err := resolveStep1(t, fakeSource{shown: []config.RecommendationRecord{shown}}, fakeStore{rows: []config.RecommendationRecord{stored}}, spPlan(80, 25), accts) require.NoError(t, err) require.Len(t, got, 1) // 1.234 * 80 * 25 / 10000 = 0.2468, rounded DOWN to cents. @@ -239,12 +246,12 @@ func TestResolveSavingsPlanRoundsDownAndKeepsRawKeys(t *testing.T) { func TestResolveSavingsPlanBelowMinimumAndBadDetails(t *testing.T) { small := spRec(`{"hourly_commitment":0.04}`) - _, err := Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{small}}, fakeStore{rows: []config.RecommendationRecord{small}}, spPlan(80, 25), accts) + _, err := resolveStep1(t, fakeSource{shown: []config.RecommendationRecord{small}}, fakeStore{rows: []config.RecommendationRecord{small}}, spPlan(80, 25), accts) assert.ErrorIs(t, err, ErrNoMatch, "0.008/hr is below one cent: dropped, never rounded up") for name, details := range map[string]string{"missing key": `{"platform":"x"}`, "zero": `{"hourly_commitment":0}`, "not json": `nope`, "empty": ``} { t.Run(name, func(t *testing.T) { bad := spRec(details) - got, err := Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{bad}}, fakeStore{rows: []config.RecommendationRecord{bad}}, spPlan(100, 100), accts) + got, err := resolveStep1(t, fakeSource{shown: []config.RecommendationRecord{bad}}, fakeStore{rows: []config.RecommendationRecord{bad}}, spPlan(100, 100), accts) require.Error(t, err) assert.NotErrorIs(t, err, ErrNoMatch) assert.Empty(t, got) diff --git a/internal/purchase/plan_step_attach.go b/internal/purchase/plan_step_attach.go index 8ebf7123..f6a0a5ba 100644 --- a/internal/purchase/plan_step_attach.go +++ b/internal/purchase/plan_step_attach.go @@ -33,7 +33,10 @@ type planstepResolver struct { } func (r planstepResolver) Resolve(ctx context.Context, plan *config.PurchasePlan, accountIDs []string) ([]config.RecommendationRecord, error) { - return planstep.Resolve(ctx, r.src, r.store, plan, accountIDs) + // Step 1 with no baseline or ledger sizes exactly like the pre-cumulative + // resolver; C3 (platform#631) replaces this with the real step and ledger. + res, err := planstep.Resolve(ctx, r.src, r.store, plan, accountIDs, 1, nil, nil) + return res.Recs, err } // SetRecommendationSource wires the strict recommendation read the plan-step diff --git a/internal/purchase/plan_step_proofs_test.go b/internal/purchase/plan_step_proofs_test.go index 272f8054..0c6c80c8 100644 --- a/internal/purchase/plan_step_proofs_test.go +++ b/internal/purchase/plan_step_proofs_test.go @@ -29,7 +29,8 @@ func azureStepTwo(t *testing.T) (stored config.RecommendationRecord, resolved co shown := stored shown.Count = 10 plan := &config.PurchasePlan{ID: "p", Services: map[string]config.ServiceConfig{"azure:compute": {Provider: "azure", Service: "compute", Enabled: true, Term: 1, Payment: "no-upfront", Coverage: 100}}, RampSchedule: config.RampSchedule{PercentPerStep: 25}} - recs, err := planstep.Resolve(context.Background(), azureStepSource{[]config.RecommendationRecord{shown}}, azureStepStore{[]config.RecommendationRecord{stored}}, plan, []string{acct}) + res, err := planstep.Resolve(context.Background(), azureStepSource{[]config.RecommendationRecord{shown}}, azureStepStore{[]config.RecommendationRecord{stored}}, plan, []string{acct}, 1, nil, nil) + recs := res.Recs require.NoError(t, err) require.Len(t, recs, 1) require.Equal(t, 2, recs[0].Count) diff --git a/internal/purchase/planstep_azure_pricing_test.go b/internal/purchase/planstep_azure_pricing_test.go index c9fc8ca9..e961b217 100644 --- a/internal/purchase/planstep_azure_pricing_test.go +++ b/internal/purchase/planstep_azure_pricing_test.go @@ -40,7 +40,8 @@ func TestResolvedAzureStepPassesStalePricingRefusal(t *testing.T) { shown.Count = 10 // step 1 bought 10; totals still describe 20 plan := &config.PurchasePlan{ID: "p", Services: map[string]config.ServiceConfig{"azure:compute": {Provider: "azure", Service: "compute", Enabled: true, Term: 1, Payment: "no-upfront", Coverage: 100}}, RampSchedule: config.RampSchedule{PercentPerStep: 25}} - recs, err := planstep.Resolve(context.Background(), azureStepSource{[]config.RecommendationRecord{shown}}, azureStepStore{[]config.RecommendationRecord{stored}}, plan, []string{acct}) + res, err := planstep.Resolve(context.Background(), azureStepSource{[]config.RecommendationRecord{shown}}, azureStepStore{[]config.RecommendationRecord{stored}}, plan, []string{acct}, 1, nil, nil) + recs := res.Recs require.NoError(t, err) require.Len(t, recs, 1) require.Equal(t, 2, recs[0].Count)