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
22 changes: 15 additions & 7 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,15 +246,20 @@ type PurchasePlan struct {
Unassigned bool `json:"unassigned,omitempty" dynamodbav:"unassigned,omitempty"`
}

type RampAllocationMode string

const RampAllocationEqualCount RampAllocationMode = "equal_count"

// RampSchedule defines how purchases are spread over time.
type RampSchedule struct {
Type string `json:"type" dynamodbav:"type"` // immediate, weekly, monthly, custom
PercentPerStep float64 `json:"percent_per_step" dynamodbav:"percent_per_step"`
StepIntervalDays int `json:"step_interval_days" dynamodbav:"step_interval_days"`
CurrentStep int `json:"current_step" dynamodbav:"current_step"`
TotalSteps int `json:"total_steps" dynamodbav:"total_steps"`
StartDate time.Time `json:"start_date" dynamodbav:"start_date"`
DatesManaged bool `json:"dates_managed,omitempty" dynamodbav:"dates_managed,omitempty"`
Type string `json:"type" dynamodbav:"type"` // immediate, weekly, monthly, custom
PercentPerStep float64 `json:"percent_per_step" dynamodbav:"percent_per_step"`
StepIntervalDays int `json:"step_interval_days" dynamodbav:"step_interval_days"`
CurrentStep int `json:"current_step" dynamodbav:"current_step"`
TotalSteps int `json:"total_steps" dynamodbav:"total_steps"`
StartDate time.Time `json:"start_date" dynamodbav:"start_date"`
DatesManaged bool `json:"dates_managed,omitempty" dynamodbav:"dates_managed,omitempty"`
AllocationMode RampAllocationMode `json:"allocation_mode,omitempty" dynamodbav:"allocation_mode,omitempty"`
}

// PresetRampSchedules provides common ramp-up configurations.
Expand Down Expand Up @@ -285,6 +290,9 @@ func (r *RampSchedule) CumulativePercent(step int) float64 {
if r.Type == "immediate" {
return 100
}
if r.AllocationMode == RampAllocationEqualCount && r.TotalSteps > 0 {
return 100 * float64(min(step, r.TotalSteps)) / float64(r.TotalSteps)
}
return math.Min(100, float64(step)*r.PercentPerStep)
}

Expand Down
17 changes: 17 additions & 0 deletions internal/config/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,9 @@ func (p *PurchasePlan) CheckNoMultiStepSavingsPlans() error {

// Validate validates the RampSchedule.
func (r *RampSchedule) Validate() error {
if err := r.ValidateAllocationMode(); err != nil {
return err
}
if r.Type != "" && !isValidRampScheduleType(r.Type) {
return fmt.Errorf("invalid ramp schedule type: %s (valid: %s)", r.Type, strings.Join(ValidRampScheduleTypes, ", "))
}
Expand All @@ -645,6 +648,20 @@ func (r *RampSchedule) Validate() error {
return nil
}

func (r *RampSchedule) ValidateAllocationMode() error {
switch r.AllocationMode {
case "":
return nil
case RampAllocationEqualCount:
if r.TotalSteps < 1 || r.TotalSteps > MaxTotalSteps || (r.Type == "immediate" && r.TotalSteps != 1) {
return fmt.Errorf("invalid equal-count total steps: %d", r.TotalSteps)
}
return nil
default:
return fmt.Errorf("unsupported ramp allocation mode: %q", r.AllocationMode)
}
}

// validatePercentPerStep checks PercentPerStep bounds based on ramp type.
// Step-based types require > 0; "immediate" ignores it; unset type allows 0.
func (r *RampSchedule) validatePercentPerStep() error {
Expand Down
65 changes: 65 additions & 0 deletions internal/planstep/allocation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package planstep

import (
"fmt"
"math"
"math/big"
"strconv"

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

func (r *ramp) validateCounts(counts ...int) error {
if r.mode == config.RampAllocationEqualCount {
for _, count := range counts {
if count < 0 {
return fmt.Errorf("negative allocation count: %d", count)
}
}
}
return nil
}

func decimalRatio(v float64) (*big.Rat, error) {
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 {
return nil, fmt.Errorf("invalid allocation quantity: %v", v)
}
r, ok := new(big.Rat).SetString(strconv.FormatFloat(v, 'g', -1, 64))
if !ok {
return nil, fmt.Errorf("invalid allocation decimal: %v", v)
}
return r, nil
}

func (r *ramp) equalCountShare(coverage float64, step int) (*big.Rat, error) {
share, err := decimalRatio(coverage)
if err != nil {
return nil, err
}
return share.Mul(share, big.NewRat(int64(min(step, r.count)), int64(100*r.count))), nil
}

func floorAllocation(v *big.Rat) (int, error) {
n := new(big.Int).Quo(v.Num(), v.Denom())
if !n.IsInt64() || n.Sign() < 0 || n.Cmp(big.NewInt(math.MaxInt)) > 0 {
return 0, fmt.Errorf("allocation exceeds whole-unit range")
}
return int(n.Int64()), nil
}

func exactSPHourly(hourly float64, share *big.Rat) (float64, error) {
h, err := decimalRatio(hourly)
if err != nil {
return 0, err
}
h.Mul(h, share).Mul(h, big.NewRat(100, 1))
cents := new(big.Int).Quo(h.Num(), h.Denom())
sized, _ := new(big.Rat).SetFrac(cents, big.NewInt(100)).Float64()
for _, text := range []string{strconv.FormatFloat(sized, 'g', -1, 64), fmt.Sprintf("%.2f", sized)} {
decimal, ok := new(big.Rat).SetString(text)
if !ok || decimal.Mul(decimal, big.NewRat(100, 1)).Cmp(new(big.Rat).SetInt(cents)) != 0 {
return 0, fmt.Errorf("SP allocation cannot represent whole cents")
}
}
return sized, nil
}
121 changes: 121 additions & 0 deletions internal/planstep/allocation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package planstep

import (
"encoding/json"
"fmt"
"testing"

"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// Fixture evidence: actual Resolve with in-memory recommendation reads.
func equalCountRamp(t *testing.T, p *config.PurchasePlan, n int, percent float64) {
t.Helper()
require.NoError(t, json.Unmarshal(fmt.Appendf(nil, `{"type":"custom","allocation_mode":"equal_count","total_steps":%d,"percent_per_step":%v}`, n, percent), &p.RampSchedule))
}

func TestEqualCountResolve(t *testing.T) {
p := cumPlan(100, 100.0/3, "custom", 3)
equalCountRamp(t, p, 3, 100.0/3)
assert.Equal(t, []int{32, 32, 33}, runRamp(t, p, 97, 3, suppressed(97)))
equalCountRamp(t, p, 97, 100.0/97)
got := runRamp(t, p, 97, 97, suppressed(97))
for step, n := range got {
assert.Equal(t, 1, n, "step %d", step+1)
}
equalCountRamp(t, p, 3, 33)
p.Services["azure:compute"] = azurePlan(50.5, 33).Services["azure:compute"]
assert.Equal(t, []int{16, 16, 16}, runRamp(t, p, 97, 3, suppressed(97)))
assert.Equal(t, []int{33, 33, 33}, runRamp(t, cumPlan(100, 33, "custom", 3), 100, 3, suppressed(100)))
assert.InDelta(t, 100.0/3, p.RampSchedule.CumulativePercent(1), 1e-12)
row := azureRec(97, 1000)
key := cumKey(t, row)
shown := row
shown.Count = 2
res, err := Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{shown}}, fakeStore{rows: []config.RecommendationRecord{row}}, p, accts, 2, config.PlanRampBaseline{key: {Count: 97}}, map[string]config.PlanRampQuantity{key: {Count: 17}})
require.NoError(t, err)
assert.Equal(t, 2, res.Recs[0].Count, "own ledger gap clamps to shown")
_, err = Resolve(t.Context(), fakeSource{}, fakeStore{}, p, accts, 2, config.PlanRampBaseline{key: {Count: 97}}, map[string]config.PlanRampQuantity{key: {Count: 32}})
require.ErrorIs(t, err, ErrTargetMet)
}

func TestEqualCountSavingsPlan(t *testing.T) {
for _, tc := range []struct {
count int
hourly, want string
percent float64
}{{1, "1.234", "1.23", 33}, {1, "1.234", "1.23", 0}, {3, "1.234", "0.41", 33}, {97, "0.97", "0.01", 33}, {1, "1e14", "100000000000000", 33}, {1, "1e20", "100000000000000000000", 33}} {
t.Run(fmt.Sprint(tc.count), func(t *testing.T) {
p := spPlan(100, 33)
equalCountRamp(t, p, tc.count, tc.percent)
if tc.count == 1 {
p.RampSchedule.Type = "immediate"
}
row := spRec(`{"hourly_commitment":` + tc.hourly + `,"extra":9007199254740993}`)
got, err := resolveStep1(t, fakeSource{shown: []config.RecommendationRecord{row}}, fakeStore{rows: []config.RecommendationRecord{row}}, p, accts)
require.NoError(t, err)
var details map[string]json.RawMessage
require.NoError(t, json.Unmarshal(got[0].Details, &details))
assert.Equal(t, tc.want, string(details["hourly_commitment"]))
assert.Equal(t, "9007199254740993", string(details["extra"]))
if tc.count > 1 {
p.Enabled, p.AutoPurchase = true, true
require.ErrorIs(t, Precheck(p), config.ErrMultiStepSavingsPlans)
}
})
}
}

func TestEqualCountImmediateOmittedPercent(t *testing.T) {
p := azurePlan(100, 0)
equalCountRamp(t, p, 1, 0)
p.RampSchedule.Type = "immediate"
require.NoError(t, p.RampSchedule.Validate())
row := azureRec(97, 1000)
got, err := resolveStep1(t, fakeSource{shown: []config.RecommendationRecord{row}}, fakeStore{rows: []config.RecommendationRecord{row}}, p, accts)
require.NoError(t, err)
assert.Equal(t, 97, got[0].Count)
require.NoError(t, json.Unmarshal([]byte(`{"allocation_mode":""}`), &p.RampSchedule))
_, err = resolveStep1(t, nil, nil, p, accts)
require.ErrorIs(t, err, ErrUnsupportedPlan)
}

func TestEqualCountRejectsBeforeReads(t *testing.T) {
for _, raw := range []string{`{"allocation_mode":"future"}`, `{"allocation_mode":"equal_count","total_steps":0}`, `{"allocation_mode":"equal_count","total_steps":101}`, `{"allocation_mode":"equal_count","total_steps":2,"type":"immediate"}`} {
p := azurePlan(100, 33)
require.NoError(t, json.Unmarshal([]byte(raw), &p.RampSchedule))
require.Error(t, p.RampSchedule.Validate())
_, err := resolveStep1(t, nil, nil, p, accts)
require.ErrorIs(t, err, ErrUnsupportedPlan)
}
}

func TestEqualCountInvalidQuantities(t *testing.T) {
p := cumPlan(100, 33, "custom", 3)
equalCountRamp(t, p, 3, 33)
row := azureRec(97, 1000)
key := cumKey(t, row)
for _, counts := range [][3]int{{-1, 97, 0}, {97, -1, 0}, {97, 97, -1}} {
shown := row
shown.Count = counts[0]
_, err := Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{shown}}, fakeStore{rows: []config.RecommendationRecord{row}}, p, accts, 2, config.PlanRampBaseline{key: {Count: counts[1]}}, map[string]config.PlanRampQuantity{key: {Count: counts[2]}})
require.Error(t, err)
assert.NotErrorIs(t, err, ErrNoMatch)
}
for _, tc := range []struct {
hourly string
coverage float64
want string
}{
{"90071992547409", 99, "cannot represent whole cents"},
{"100000000000000.1", 100, "cannot represent whole cents"},
} {
p = spPlan(tc.coverage, 33)
equalCountRamp(t, p, 1, 33)
row = spRec(`{"hourly_commitment":` + tc.hourly + `}`)
_, err := resolveStep1(t, fakeSource{shown: []config.RecommendationRecord{row}}, fakeStore{rows: []config.RecommendationRecord{row}}, p, accts)
require.ErrorContains(t, err, tc.want)
}
}
5 changes: 4 additions & 1 deletion internal/planstep/precheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ var ErrUnsupportedPlan = errors.New("plan settings are not supported by recommen
// 100 (an immediate ramp ignores that field when validated but Resolve sizes
// with it).
func Precheck(plan *config.PurchasePlan) error {
if err := plan.RampSchedule.ValidateAllocationMode(); err != nil {
return fmt.Errorf("%w: plan %s: %w", ErrUnsupportedPlan, plan.ID, err)
}
keys := make([]string, 0, len(plan.Services))
for k := range plan.Services {
keys = append(keys, k)
Expand All @@ -33,7 +36,7 @@ func Precheck(plan *config.PurchasePlan) error {
return fmt.Errorf("%w: plan %s service %q sets %s", ErrUnsupportedPlan, plan.ID, k, field)
}
}
if plan.RampSchedule.Type == "immediate" && plan.RampSchedule.PercentPerStep != 100 {
if plan.RampSchedule.AllocationMode == "" && plan.RampSchedule.Type == "immediate" && plan.RampSchedule.PercentPerStep != 100 {
return fmt.Errorf("%w: plan %s has an immediate ramp with percent_per_step %v (must be 100)",
ErrUnsupportedPlan, plan.ID, plan.RampSchedule.PercentPerStep)
}
Expand Down
Loading
Loading