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
158 changes: 158 additions & 0 deletions providers/aws/ladder/baseline.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package ladder

import (
"context"
"fmt"
"math"
"sort"

"github.com/LeanerCloud/CUDly/pkg/ladder"
)

// minBaselineSeriesDays is the shortest daily series AWSLadder will accept for
// baseline computation. Fewer days cannot produce a statistically meaningful
// low-water-mark; GetUsageBaseline returns an error when the series is shorter.
// The caller (engine) should configure LookbackDays >= minBaselineSeriesDays.
const minBaselineSeriesDays = 7

// GetUsageBaseline computes a statistical low-water-mark from a daily
// on-demand-equivalent USD/hour series returned by the injected coverageSource.
//
// Series semantics: each element is the average on-demand-equivalent USD/hour
// for one calendar day over the lookback window, ordered oldest-to-newest.
// The series is sourced from coverageSource.GetOnDemandSeries, which is wired
// in a later PR to call CE GetCostAndUsage (Granularity=Daily, on-demand
// usage-type filter). Until that wiring lands, callers receive a data-source
// error from GetOnDemandSeries.
//
// Limitations:
// - The series covers only the on-demand costs reported by the coverage
// source. Until the CE GetCostAndUsage wiring lands, this will be an error.
// - The series is EC2-scoped (the initial coverage source implementation
// covers EC2 on-demand cost only, not RDS/ElastiCache/etc.).
// - LowWaterUSDPerHour is the nearest-rank percentile of the daily series
// (see nearestRankPercentile).
// - StableUSDPerHour is nil: per the pkg/ladder contract (types.go), Stable
// is the post-buffer-fraction estimate — a producer obligation this
// implementation cannot yet meet (no stable-usage estimator exists for
// AWS). Returning nil triggers the engine's documented degradation
// ("stable usage unknown; routing all core gap to flex"), which is honest
// and conservative. Do NOT alias it to LowWater: the engine consumes
// Stable verbatim as the base-layer cap and would over-commit the base.
//
// Error conditions:
// - Series empty: hard error (no data from the coverage source).
// - Series shorter than minBaselineSeriesDays: hard error (insufficient
// history for a reliable percentile estimate).
// - Series containing a non-finite (NaN/Inf) or negative element: hard error
// naming the offending index (a cost series must be finite and >= 0).
// - percentile not in (0, 100]: hard error (out-of-range).
func (a *AWSLadder) GetUsageBaseline(ctx context.Context, scope ladder.Scope, lookbackDays int, percentile float64) (ladder.UsageBaseline, error) {
if err := a.validateScope(scope); err != nil {
return ladder.UsageBaseline{}, err
}
if err := validateBaselineArgs(lookbackDays, percentile); err != nil {
return ladder.UsageBaseline{}, err
}

series, err := a.coverage.GetOnDemandSeries(ctx, a.cfg.Region, lookbackDays)
if err != nil {
return ladder.UsageBaseline{}, fmt.Errorf("GetUsageBaseline: on-demand series fetch failed: %w", err)
}
if len(series) == 0 {
return ladder.UsageBaseline{}, fmt.Errorf("GetUsageBaseline: on-demand series is empty for region %s (coverage source returned no data)", a.cfg.Region)
}
if len(series) < minBaselineSeriesDays {
return ladder.UsageBaseline{}, fmt.Errorf(
"GetUsageBaseline: series length %d is below minimum %d days; extend the lookback window or check the coverage source",
len(series), minBaselineSeriesDays,
)
}
if vErr := validateSeries(series); vErr != nil {
return ladder.UsageBaseline{}, fmt.Errorf("GetUsageBaseline: %w", vErr)
}

lowWater, err := nearestRankPercentile(series, percentile)
if err != nil {
return ladder.UsageBaseline{}, fmt.Errorf("GetUsageBaseline: percentile computation failed: %w", err)
}

// StableUSDPerHour is intentionally nil: the pkg/ladder contract defines
// Stable as the post-buffer-fraction estimate (a producer obligation), and
// no stable-usage estimator exists for AWS yet. nil triggers the engine's
// documented "stable usage unknown; routing all core gap to flex"
// degradation — honest and conservative until a real estimator lands.
return ladder.UsageBaseline{
LowWaterUSDPerHour: ptr(lowWater),
StableUSDPerHour: nil,
Series: series,
LookbackDays: lookbackDays,
Percentile: percentile,
}, nil
}

// validateSeries rejects series containing non-finite (NaN/Inf) or negative
// elements at the trust boundary: the series is injected via coverageSource,
// and a single bad element would silently corrupt the percentile (NaN makes
// the sort order undefined; a negative cost is impossible for on-demand spend).
// The error names the offending index so the data-source bug is traceable.
func validateSeries(series []float64) error {
for i, v := range series {
if math.IsNaN(v) || math.IsInf(v, 0) {
return fmt.Errorf("series element at index %d is not finite (%g); the on-demand series must contain only finite values", i, v)
}
if v < 0 {
return fmt.Errorf("series element at index %d is negative (%g); on-demand cost values must be >= 0", i, v)
}
}
return nil
}

// validateBaselineArgs checks lookbackDays and percentile for out-of-range
// values. Extracted to keep GetUsageBaseline under the cyclomatic limit.
func validateBaselineArgs(lookbackDays int, percentile float64) error {
if lookbackDays <= 0 {
return fmt.Errorf("GetUsageBaseline: lookbackDays %d must be > 0", lookbackDays)
}
if math.IsNaN(percentile) || !(percentile > 0 && percentile <= 100) {
return fmt.Errorf("GetUsageBaseline: percentile %g must be in (0, 100]", percentile)
}
return nil
}

// nearestRankPercentile returns the p-th percentile of values using the
// nearest-rank method (NIST definition):
//
// rank = ceil(p/100 * N) (1-indexed, clamped to [1, N])
// result = sorted_values[rank-1]
//
// Properties:
// - Exact: no interpolation. The result is always a member of the input set.
// - NaN-hostile: if any value in data is NaN, the sort is undefined and
// the result is meaningless. GetUsageBaseline enforces finiteness via
// validateSeries before calling this function; other callers must do the
// same.
// - Empty slice: returns an error (caller must guard before calling).
// - p==100: returns the maximum element (rank=N).
// - p close to 0: rank rounds up to 1, returning the minimum element.
//
// No external statistics package is imported; this keeps the ladder package
// dependency-free for test purposes.
func nearestRankPercentile(data []float64, p float64) (float64, error) {
if len(data) == 0 {
return 0, fmt.Errorf("nearestRankPercentile: empty data slice")
}
sorted := make([]float64, len(data))
copy(sorted, data)
sort.Float64s(sorted)

n := float64(len(sorted))
rank := int(math.Ceil(p / 100.0 * n))
if rank < 1 {
rank = 1
}
if rank > len(sorted) {
rank = len(sorted)
}
return sorted[rank-1], nil
}
144 changes: 144 additions & 0 deletions providers/aws/ladder/commitments.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package ladder

import (
"context"
"fmt"

"github.com/LeanerCloud/CUDly/pkg/common"
"github.com/LeanerCloud/CUDly/pkg/ladder"
ec2svc "github.com/LeanerCloud/CUDly/providers/aws/services/ec2"
)

// ListCommitments returns all active commitments for the given scope by merging:
// - Active convertible RIs (riLister.ListConvertibleReservedInstances)
// - Active Savings Plans of type EC2Instance and Compute (spLister.ListActiveSPs)
//
// The scope's AccountID must match Config.AccountID; this implementation is
// single-account and returns an error when the scope targets a different account.
func (a *AWSLadder) ListCommitments(ctx context.Context, scope ladder.Scope) ([]common.Commitment, error) {
if err := a.validateScope(scope); err != nil {
return nil, err
}

riCommitments, err := a.listRICommitments(ctx)
if err != nil {
return nil, fmt.Errorf("ListCommitments: RI listing failed: %w", err)
}

spCommitments, err := a.listSPCommitments(ctx)
if err != nil {
return nil, fmt.Errorf("ListCommitments: SP listing failed: %w", err)
}

result := make([]common.Commitment, 0, len(riCommitments)+len(spCommitments))
result = append(result, riCommitments...)
result = append(result, spCommitments...)
return result, nil
}

// validateScope returns an error when scope targets a provider or account that
// does not match this AWSLadder instance. Fails loud rather than silently
// returning data for the wrong account.
func (a *AWSLadder) validateScope(scope ladder.Scope) error {
if scope.Provider != common.ProviderAWS {
return fmt.Errorf("AWSLadder: expected provider %s, got %s", common.ProviderAWS, scope.Provider)
}
if scope.AccountID != a.cfg.AccountID {
return fmt.Errorf("AWSLadder: scope account %s does not match configured account %s",
scope.AccountID, a.cfg.AccountID)
}
return nil
}

// listRICommitments fetches active convertible RIs and maps them to
// common.Commitment values. Only RIs in "active" state are included;
// payment-pending RIs are excluded because their hourly costs are not
// yet finalized.
func (a *AWSLadder) listRICommitments(ctx context.Context) ([]common.Commitment, error) {
ris, err := a.ris.ListConvertibleReservedInstances(ctx)
if err != nil {
return nil, err
}
out := make([]common.Commitment, 0, len(ris))
for i := range ris {
out = append(out, riToCommitment(&ris[i], a.cfg.AccountID, a.cfg.Region))
}
return out, nil
}

// riToCommitment converts a ConvertibleRI to a common.Commitment.
// ri is taken by pointer to avoid copying the large ConvertibleRI struct (hugeParam).
//
// Cost is the RESERVATION-TOTAL hourly amortized cost, computed by riHourlyCost:
// DescribeReservedInstances pricing fields (RecurringHourlyAmount, UsagePrice,
// FixedPrice) are per-instance, so the per-instance hourly rate is multiplied
// by InstanceCount. See riHourlyCost for the formula and semantics (matches the
// repo's canonical monthlyCostFromConvertibleRI in internal/api/handler_ri_exchange.go).
func riToCommitment(ri *ec2svc.ConvertibleRI, accountID, region string) common.Commitment {
totalHourlyCost := riHourlyCost(ri)

return common.Commitment{
Provider: common.ProviderAWS,
Account: accountID,
CommitmentID: ri.ReservedInstanceID,
CommitmentType: common.CommitmentReservedInstance,
Service: common.ServiceEC2,
Region: region,
ResourceType: ri.InstanceType,
Count: int(ri.InstanceCount),
State: ri.State,
StartDate: ri.Start,
EndDate: ri.End,
Cost: totalHourlyCost,
}
}

// listSPCommitments fetches active EC2Instance and Compute Savings Plans and
// maps them to common.Commitment values. Other plan types (SageMaker, Database)
// are filtered out because they do not belong to any of the three ladder layers.
func (a *AWSLadder) listSPCommitments(ctx context.Context) ([]common.Commitment, error) {
sps, err := a.sps.ListActiveSPs(ctx)
if err != nil {
return nil, err
}
out := make([]common.Commitment, 0, len(sps))
for i := range sps {
if !isLadderSPType(sps[i].PlanType) {
continue
}
out = append(out, spToCommitment(&sps[i], a.cfg.AccountID))
}
return out, nil
}

// isLadderSPType returns true for the two plan types that map to ladder layers.
func isLadderSPType(planType string) bool {
return planType == "EC2Instance" || planType == "Compute"
}

// spToCommitment converts an ActiveSP to a common.Commitment.
// sp is taken by pointer to avoid copying the large ActiveSP struct (hugeParam).
// Cost is set to HourlyCommitmentUSD, which is the $/hr committed spend.
// The end date carries a zero value when AWS returns an empty End string
// (e.g. for queued plans); callers treat the zero time as "no expiry signal".
func spToCommitment(sp *ActiveSP, accountID string) common.Commitment {
service := common.ServiceSavingsPlansEC2Instance
if sp.PlanType == "Compute" {
service = common.ServiceSavingsPlansCompute
}

return common.Commitment{
Provider: common.ProviderAWS,
Account: accountID,
CommitmentID: sp.PlanID,
CommitmentType: common.CommitmentSavingsPlan,
Service: service,
Region: sp.Region,
ResourceType: sp.PlanType,
Count: 1, // Savings Plans are single commitment units
State: sp.State,
StartDate: sp.StartDate,
EndDate: sp.EndDate,
Cost: sp.HourlyCommitmentUSD,
}
}
Loading