Skip to content

Commit c3eeaf0

Browse files
authored
Merge pull request #1338 from LeanerCloud/feat/ladder-types
feat(ladder): core types and capability interfaces for commitment laddering
2 parents a2d8e11 + 904f3d1 commit c3eeaf0

7 files changed

Lines changed: 2586 additions & 0 deletions

File tree

pkg/ladder/capability.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package ladder
2+
3+
import (
4+
"context"
5+
6+
"github.com/LeanerCloud/CUDly/pkg/common"
7+
)
8+
9+
// LadderCapability is implemented by each cloud provider to give the ladder
10+
// engine a uniform interface for querying commitment state and executing
11+
// purchases. Implementations live in providers/ and are injected into the
12+
// engine at startup.
13+
//
14+
//nolint:revive // Ladder* prefix is the spec-mandated public name (issue #1334); matches pkg/exchange's Exchange* convention.
15+
type LadderCapability interface {
16+
// Provider returns the cloud provider identifier (common.ProviderAWS,
17+
// common.ProviderAzure, or common.ProviderGCP).
18+
Provider() common.ProviderType
19+
20+
// SupportedLayers returns the commitment layers this provider can fulfill.
21+
// Each LayerSpec declares both the layer type and the roles it covers.
22+
//
23+
// Role-cardinality contract (enforced by the engine): the returned set
24+
// must contain exactly one layer carrying RoleFlex, at most one carrying
25+
// RoleBase, and at most one carrying RoleBuffer. The only permitted
26+
// multi-role merge is base+buffer on a single layer (e.g. an Azure
27+
// reservation serving both roles).
28+
SupportedLayers() []LayerSpec
29+
30+
// ListCommitments returns all active commitments for the given scope.
31+
ListCommitments(ctx context.Context, scope Scope) ([]common.Commitment, error)
32+
33+
// GetLayerStates returns a point-in-time snapshot for each supported layer.
34+
// Layers with no active commitments carry explicit zeros in
35+
// ExistingUSDPerHour/ExpiringUSDPerHour; nil is reserved for genuinely
36+
// unmeasured metrics (e.g. UtilizationPct on an empty layer) and is
37+
// treated as missing data by the engine.
38+
GetLayerStates(ctx context.Context, scope Scope) (map[LayerType]LayerState, error)
39+
40+
// GetUsageBaseline computes a statistical baseline from historical
41+
// on-demand usage over the given lookback window and percentile.
42+
GetUsageBaseline(ctx context.Context, scope Scope, lookbackDays int, percentile float64) (UsageBaseline, error)
43+
44+
// PurchaseLayer buys commitments for the given layer using the provided
45+
// recommendation. Implementations return an error wrapping
46+
// common.ErrCommitmentPurchaseNotSupported when the layer cannot be
47+
// purchased programmatically; callers detect it with
48+
// errors.Is(err, common.ErrCommitmentPurchaseNotSupported).
49+
//
50+
// Precision note: the engine plans amounts as exact *big.Rat values
51+
// (PlannedAction.AmountUSDPerHour), but this boundary converts them to
52+
// the float64 cost fields of common.Recommendation. This is a documented
53+
// precision seam; exact-decimal plumbing through the purchase path is
54+
// addressed in a later PR.
55+
PurchaseLayer(ctx context.Context, layer LayerType, rec common.Recommendation, opts common.PurchaseOptions) (common.PurchaseResult, error)
56+
57+
// ReshapeBuffer exchanges or modifies buffer-layer commitments to improve
58+
// utilization when it falls below the configured threshold.
59+
ReshapeBuffer(ctx context.Context, scope Scope, cfg BufferReshapeConfig) (ReshapeSummary, error)
60+
}
61+
62+
// BufferReshapeConfig parameterizes a buffer reshape operation.
63+
//
64+
// The money caps are config-boundary floats, consistent with
65+
// LadderConfig.MaxHourlyCommitPerRun (implementations convert to
66+
// pkg/exchange float64 anyway). Convert via ratFromFloat where exact math
67+
// is needed; NaN/Inf/non-positive values are rejected wherever these caps
68+
// are validated.
69+
type BufferReshapeConfig struct {
70+
// MaxPaymentPerExchangeUSD caps the payment for a single exchange. nil
71+
// means no per-exchange cap is applied.
72+
MaxPaymentPerExchangeUSD *float64
73+
// MaxPaymentDailyUSD caps the total exchange payments for the current day.
74+
// nil means no daily cap is applied.
75+
MaxPaymentDailyUSD *float64
76+
// UtilizationThresholdPct triggers a reshape when commitment utilization
77+
// drops below this percentage.
78+
UtilizationThresholdPct float64
79+
// LookbackDays is the window used to measure utilization when deciding
80+
// whether to trigger a reshape.
81+
LookbackDays int
82+
// DryRun, when true, simulates the reshape without executing any exchanges.
83+
DryRun bool
84+
}
85+
86+
// ReshapeSummary reports the outcome of a buffer reshape operation.
87+
type ReshapeSummary struct {
88+
// Details holds per-commitment outcome descriptions for logging and audit.
89+
Details []string
90+
// Analyzed is the total number of commitments inspected.
91+
Analyzed int
92+
// Reshaped is the number of commitments exchanged or modified.
93+
Reshaped int
94+
// Skipped is the number of commitments that did not meet the utilization
95+
// threshold or were blocked by a spend cap.
96+
Skipped int
97+
}

pkg/ladder/plan.go

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
package ladder
2+
3+
import (
4+
"fmt"
5+
"math/big"
6+
"strings"
7+
"time"
8+
"unicode"
9+
)
10+
11+
// PlannedAction is a single commitment action proposed by the ladder engine.
12+
//
13+
// Every action must carry a non-empty Rationale for audit and approval-email
14+
// readability -- automated money-path decisions must be explained so the
15+
// human approver (or the audit log) can understand why each action was chosen.
16+
type PlannedAction struct {
17+
Action ActionType
18+
Layer LayerType
19+
// AmountUSDPerHour is the hourly commitment delta. It must be non-nil and
20+
// positive for ActionPurchase, and must be nil for ActionHold and
21+
// ActionReshape (whose financial impact is implicit in the underlying
22+
// exchange operation).
23+
AmountUSDPerHour *big.Rat
24+
// Term is the commitment term. Must be a valid Term for purchase
25+
// actions; empty for hold and reshape.
26+
Term Term
27+
// PaymentOption is the payment structure. Must be a valid PaymentOption
28+
// for purchase actions; empty for hold and reshape.
29+
PaymentOption PaymentOption
30+
// Rationale is a human-readable explanation of why this action was chosen.
31+
// Must be non-empty for all action types.
32+
Rationale string
33+
// DataSources lists the data feeds (Cost Explorer, CloudWatch, etc.) used
34+
// to derive this action. Aids auditability.
35+
DataSources []string
36+
}
37+
38+
// Validate checks that the action is self-consistent:
39+
// - action type and layer type must be recognized
40+
// - rationale must be non-empty
41+
// - purchase actions require a positive AmountUSDPerHour and valid Term
42+
// and PaymentOption enum values (money-shaping fields must be present)
43+
// - hold and reshape actions require a nil AmountUSDPerHour and empty
44+
// Term and PaymentOption
45+
func (a *PlannedAction) Validate() error {
46+
if err := a.Action.Validate(); err != nil {
47+
return fmt.Errorf("action: %w", err)
48+
}
49+
if err := a.Layer.Validate(); err != nil {
50+
return fmt.Errorf("layer: %w", err)
51+
}
52+
if a.Rationale == "" {
53+
return fmt.Errorf("rationale is required (money-path auditability)")
54+
}
55+
switch a.Action {
56+
case ActionPurchase:
57+
return a.validatePurchaseFields()
58+
case ActionHold, ActionReshape:
59+
if a.AmountUSDPerHour != nil {
60+
return fmt.Errorf("%s action requires nil AmountUSDPerHour, got %s",
61+
a.Action, a.AmountUSDPerHour.RatString())
62+
}
63+
if a.Term != "" || a.PaymentOption != "" {
64+
return fmt.Errorf("%s action requires empty Term and PaymentOption", a.Action)
65+
}
66+
}
67+
return nil
68+
}
69+
70+
// validatePurchaseFields checks the money-shaping fields required for a
71+
// purchase action. Split out of Validate to keep each function's cyclomatic
72+
// complexity within the repo limit.
73+
func (a *PlannedAction) validatePurchaseFields() error {
74+
if a.AmountUSDPerHour == nil {
75+
return fmt.Errorf("purchase action requires a non-nil AmountUSDPerHour")
76+
}
77+
if a.AmountUSDPerHour.Sign() <= 0 {
78+
return fmt.Errorf("purchase action requires a positive AmountUSDPerHour, got %s",
79+
a.AmountUSDPerHour.RatString())
80+
}
81+
// Term and PaymentOption shape the money committed; a purchase with
82+
// either missing or unrecognized would silently default at the provider
83+
// boundary. Validate against the typed enums.
84+
if err := a.Term.Validate(); err != nil {
85+
return fmt.Errorf("purchase action term: %w", err)
86+
}
87+
if err := a.PaymentOption.Validate(); err != nil {
88+
return fmt.Errorf("purchase action payment option: %w", err)
89+
}
90+
return nil
91+
}
92+
93+
// LadderPlan is a complete, validated commitment plan for one scope and run.
94+
// It captures the baseline, the monetary gap, and the ordered list of actions
95+
// the engine proposes to close that gap.
96+
//
97+
//nolint:revive // Ladder* prefix is the spec-mandated public name (issue #1334); matches pkg/exchange's Exchange* convention.
98+
type LadderPlan struct {
99+
Scope Scope
100+
GeneratedAt time.Time
101+
// TargetUSDPerHour is the hourly commitment target derived from
102+
// Baseline * TargetCoveragePct. nil means it could not be computed.
103+
TargetUSDPerHour *big.Rat
104+
// ExistingUSDPerHour is the sum of hourly amortized costs across all
105+
// active commitment layers. nil means it could not be measured.
106+
ExistingUSDPerHour *big.Rat
107+
// GapUSDPerHour is TargetUSDPerHour - ExistingUSDPerHour. nil when either
108+
// input is nil.
109+
GapUSDPerHour *big.Rat
110+
Actions []PlannedAction
111+
Baseline UsageBaseline
112+
}
113+
114+
// Validate checks that the plan is self-consistent: a valid scope, a set
115+
// generation timestamp, and all PlannedActions valid.
116+
func (p *LadderPlan) Validate() error {
117+
if err := p.Scope.Validate(); err != nil {
118+
return fmt.Errorf("scope: %w", err)
119+
}
120+
// A zero GeneratedAt would corrupt approval-expiry and audit ordering
121+
// downstream; fail loud like Tranche.FireAfter.
122+
if p.GeneratedAt.IsZero() {
123+
return fmt.Errorf("generated_at must be set")
124+
}
125+
for i, a := range p.Actions {
126+
if err := a.Validate(); err != nil {
127+
return fmt.Errorf("action[%d]: %w", i, err)
128+
}
129+
}
130+
return nil
131+
}
132+
133+
// sanitizeLine strips control characters (including \n and \r) from a value
134+
// interpolated into a single Explain line. Without this, a crafted field
135+
// (e.g. a Rationale containing "\n 2. fake action") could spoof extra lines
136+
// in the approval email body.
137+
func sanitizeLine(s string) string {
138+
return strings.Map(func(r rune) rune {
139+
if unicode.IsControl(r) {
140+
return -1
141+
}
142+
return r
143+
}, s)
144+
}
145+
146+
// formatUSDPerHour renders a *big.Rat as a fixed 2-decimal USD/hr string.
147+
// nil is rendered as "unknown" -- never as "$0.00" -- to unambiguously
148+
// distinguish absent data from a genuine zero commitment (project rule:
149+
// absent numbers are nil, not zero).
150+
//
151+
// FloatString(2) formats the exact rational with half-away-from-zero
152+
// rounding, avoiding the float64 conversion step (e.g. 199/200 renders as
153+
// $1.00/hr, not $0.99/hr).
154+
func formatUSDPerHour(v *big.Rat) string {
155+
if v == nil {
156+
return "unknown"
157+
}
158+
return fmt.Sprintf("$%s/hr", v.FloatString(2))
159+
}
160+
161+
// Explain returns a deterministic, human-readable multi-line summary of the
162+
// plan. The output is suitable for use as an approval email body and is
163+
// designed to be read by a non-technical budget owner.
164+
//
165+
// Layout:
166+
// 1. Scope header and generation timestamp
167+
// 2. Baseline parameters (lookback window, percentile)
168+
// 3. Target / existing / gap hourly rates
169+
// 4. Numbered action list (or "none")
170+
// 5. "Data sources:" line aggregating the actions' DataSources (omitted
171+
// when no action carries any)
172+
//
173+
// Every interpolated free-form field is passed through sanitizeLine so a
174+
// crafted value cannot spoof additional lines in the email body.
175+
func (p *LadderPlan) Explain() string {
176+
var b strings.Builder
177+
178+
fmt.Fprintf(&b, "Scope: provider=%s account=%s\n", p.Scope.Provider, sanitizeLine(p.Scope.AccountID))
179+
fmt.Fprintf(&b, "Generated: %s\n", p.GeneratedAt.UTC().Format(time.RFC3339))
180+
fmt.Fprintf(&b, "Baseline: lookback=%dd percentile=%g\n",
181+
p.Baseline.LookbackDays, p.Baseline.Percentile)
182+
fmt.Fprintf(&b, "Target: %s\n", formatUSDPerHour(p.TargetUSDPerHour))
183+
fmt.Fprintf(&b, "Existing: %s\n", formatUSDPerHour(p.ExistingUSDPerHour))
184+
fmt.Fprintf(&b, "Gap: %s\n", formatUSDPerHour(p.GapUSDPerHour))
185+
186+
if len(p.Actions) == 0 {
187+
fmt.Fprintf(&b, "Actions: none\n")
188+
return b.String()
189+
}
190+
191+
fmt.Fprintf(&b, "Actions (%d):\n", len(p.Actions))
192+
for i, a := range p.Actions {
193+
switch a.Action {
194+
case ActionPurchase:
195+
fmt.Fprintf(&b, " %d. %s %s %s term=%s payment=%s -- %s\n",
196+
i+1, a.Action, a.Layer, formatUSDPerHour(a.AmountUSDPerHour),
197+
sanitizeLine(string(a.Term)), sanitizeLine(string(a.PaymentOption)),
198+
sanitizeLine(a.Rationale))
199+
default:
200+
fmt.Fprintf(&b, " %d. %s %s -- %s\n",
201+
i+1, a.Action, a.Layer, sanitizeLine(a.Rationale))
202+
}
203+
}
204+
if sources := collectDataSources(p.Actions); len(sources) > 0 {
205+
fmt.Fprintf(&b, "Data sources: %s\n", strings.Join(sources, ", "))
206+
}
207+
return b.String()
208+
}
209+
210+
// collectDataSources aggregates the DataSources of all actions, sanitized
211+
// and deduplicated, preserving first-seen stored order so the output stays
212+
// deterministic for a given plan.
213+
func collectDataSources(actions []PlannedAction) []string {
214+
var sources []string
215+
seen := make(map[string]bool)
216+
for _, a := range actions {
217+
for _, s := range a.DataSources {
218+
s = sanitizeLine(s)
219+
if seen[s] {
220+
continue
221+
}
222+
seen[s] = true
223+
sources = append(sources, s)
224+
}
225+
}
226+
return sources
227+
}

0 commit comments

Comments
 (0)