diff --git a/providers/aws/ladder/baseline.go b/providers/aws/ladder/baseline.go new file mode 100644 index 000000000..1a6fa461c --- /dev/null +++ b/providers/aws/ladder/baseline.go @@ -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 +} diff --git a/providers/aws/ladder/commitments.go b/providers/aws/ladder/commitments.go new file mode 100644 index 000000000..95c19d8c8 --- /dev/null +++ b/providers/aws/ladder/commitments.go @@ -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, + } +} diff --git a/providers/aws/ladder/interfaces.go b/providers/aws/ladder/interfaces.go new file mode 100644 index 000000000..71bb536f0 --- /dev/null +++ b/providers/aws/ladder/interfaces.go @@ -0,0 +1,133 @@ +// Package ladder implements the ladder.LadderCapability READ side for AWS. +// Write-side methods (PurchaseLayer, ReshapeBuffer) return explicit +// not-implemented errors until the write-side PR lands. +package ladder + +import ( + "context" + "time" + + cetypes "github.com/aws/aws-sdk-go-v2/service/costexplorer/types" + + "github.com/LeanerCloud/CUDly/providers/aws/recommendations" + ec2svc "github.com/LeanerCloud/CUDly/providers/aws/services/ec2" +) + +// riLister is the narrow interface for listing active convertible RIs. +// The concrete implementation is ec2svc.Client.ListConvertibleReservedInstances. +type riLister interface { + ListConvertibleReservedInstances(ctx context.Context) ([]ec2svc.ConvertibleRI, error) +} + +// ActiveSP is a minimal view of an active Savings Plan needed by AWSLadder. +// Only EC2Instance and Compute plan types are relevant to the three ladder layers. +// +// Fields are ordered to minimize the GC pointer-scan range (fieldalignment). +type ActiveSP struct { + // PlanID is the AWS Savings Plan ID. + PlanID string + // PlanType is "EC2Instance" or "Compute" (matches the SavingsPlan.SavingsPlanType + // display form from the DescribeSavingsPlans response). + PlanType string + // State mirrors SavingsPlan.State as a string ("active", "pending-return", "queued"). + State string + // StartDate is the SP activation time. + StartDate time.Time + // EndDate is the SP expiry time. + EndDate time.Time + // Region is the AWS region; empty for Compute SPs (which are global). + Region string + // HourlyCommitmentUSD is the committed spend in USD per hour (SavingsPlan.Commitment + // parsed as float64). This is what the engine counts as ExistingUSDPerHour for SP layers. + // Placed last to keep all pointer-containing fields contiguous for the GC scanner. + HourlyCommitmentUSD float64 +} + +// spLister is the narrow interface for listing active Savings Plans relevant +// to the three ladder layers. The real implementation (wired when both PRs +// land) calls DescribeSavingsPlans filtering to Active state and maps the +// Commitment field to HourlyCommitmentUSD. Tests pass a hermetic fake. +type spLister interface { + ListActiveSPs(ctx context.Context) ([]ActiveSP, error) +} + +// coverageSource is the narrow interface for RI coverage data and the +// on-demand daily spend series used by GetUsageBaseline. +// +// GetRICoverageMap returns the per-pool org-wide RI coverage map (keyed by +// "region:instance_type" for EC2) for the given lookback window and regions. +// +// GetOnDemandSeries returns a slice of len(lookbackDays) daily on-demand- +// equivalent USD/hour values for the given region, ordered oldest-to-newest. +// Each element is the average on-demand spend in USD per hour for that +// calendar day. The real implementation sources this from CE GetCostAndUsage +// with Granularity=Daily filtered to on-demand usage types; wiring happens +// when the cost-and-usage collector PR lands. Tests pass a hermetic fake. +type coverageSource interface { + GetRICoverageMap(ctx context.Context, lookbackDays int, regions []string) (recommendations.PoolCoverageMap, error) + GetOnDemandSeries(ctx context.Context, region string, lookbackDays int) ([]float64, error) +} + +// utilizationSource is the narrow interface for RI utilization data. +// The real implementation is RecommendationsClientAdapter.GetRIUtilization. +type utilizationSource interface { + GetRIUtilization(ctx context.Context, lookbackDays int) ([]recommendations.RIUtilization, error) +} + +// SPCoverageSummary carries the Savings Plans coverage result from the CE API. +// The CE GetSavingsPlansCoverage API does not support filtering by plan type, +// so one summary covers all SP layers. When the parallel SP coverage PR +// (PR 4) lands, reconcile this type with the one it defines. +type SPCoverageSummary struct { + // CoveragePct is the percentage (0-100) of eligible compute spend covered + // by Savings Plans. Nil means the CE API returned no data for the scope. + CoveragePct *float64 +} + +// SPUtilizationSummary carries the Savings Plans utilization result from the +// CE API. Per-plan-type utilization is available via GetSavingsPlansUtilization, +// so each SP layer gets its own summary. When PR 4 lands, reconcile this type. +type SPUtilizationSummary struct { + // UtilizationPct is the percentage (0-100) of the committed spend that was + // actually used. Nil means the CE API returned no data for the scope. + UtilizationPct *float64 +} + +// spCoverageSource is the narrow interface for Savings Plans coverage data. +// It is wired when the SP coverage PR (parallel to this one, PR 4) lands. +// Pass nil to skip SP coverage measurement; CoveragePct will be nil for SP layers. +// +// CE API note: GetSavingsPlansCoverage does NOT support plan-type filtering. +// The returned coverage applies to ALL Savings Plan types in the region, so +// AWSLadder sets the same CoveragePct on both EC2Instance and Compute SP layers. +// +// Adapter requirement: Go interface satisfaction needs identical return types, +// and PR 4's concrete implementation returns its own richer +// recommendations.SPCoverageSummary (more fields, e.g. Days). The wiring PR +// must therefore provide a thin adapter mapping +// recommendations.SPCoverageSummary{CoveragePct, ..., Days} -> this package's +// SPCoverageSummary, preserving nil CoveragePct as no-data (PR 4 returns nil +// when Days==0). +type spCoverageSource interface { + GetSPCoverageSummary(ctx context.Context, region string, lookbackDays int) (SPCoverageSummary, error) +} + +// spUtilizationSource is the narrow interface for Savings Plans utilization data. +// It is wired when the SP utilization PR (parallel to this one, PR 4) lands. +// Pass nil to skip SP utilization measurement; UtilizationPct will be nil for SP layers. +// +// planType uses the CE SDK enum (cetypes.SupportedSavingsPlansTypeEc2InstanceSp, +// cetypes.SupportedSavingsPlansTypeComputeSp, etc.). +// region "" = all regions; pass "" for Compute SPs (global) and the configured +// region for EC2 Instance SPs. +// +// Adapter requirement: Go interface satisfaction needs identical return types, +// and PR 4's concrete implementation returns its own richer +// recommendations.SPUtilizationSummary (more fields, e.g. Days). The wiring PR +// must therefore provide a thin adapter mapping +// recommendations.SPUtilizationSummary{UtilizationPct, ..., Days} -> this +// package's SPUtilizationSummary, preserving nil UtilizationPct as no-data +// (PR 4 returns nil when Days==0). +type spUtilizationSource interface { + GetSPUtilization(ctx context.Context, planType cetypes.SupportedSavingsPlansType, region string, lookbackDays int) (SPUtilizationSummary, error) +} diff --git a/providers/aws/ladder/ladder.go b/providers/aws/ladder/ladder.go new file mode 100644 index 000000000..a1274c220 --- /dev/null +++ b/providers/aws/ladder/ladder.go @@ -0,0 +1,160 @@ +package ladder + +import ( + "context" + "errors" + "fmt" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/ladder" +) + +// DefaultHorizonDays is the number of days ahead used to classify a +// commitment as "expiring soon" in GetLayerStates.ExpiringUSDPerHour. +// Callers that need a different window pass it via Config.HorizonDays. +const DefaultHorizonDays = 30 + +// DefaultLookbackDays is the number of days used for coverage and +// utilization queries when Config.LookbackDays is zero. +const DefaultLookbackDays = 30 + +// errWriteNotWired is the sentinel returned by PurchaseLayer and ReshapeBuffer +// until the write-side PR (PR 6) lands. It is distinct from +// common.ErrCommitmentPurchaseNotSupported, which signals that this provider +// can NEVER purchase a given layer type programmatically. Here the capability +// WILL be supported once wired; the error is a clear placeholder, not a +// permanent constraint. +var errWriteNotWired = errors.New("write side not yet wired (PR 6): call sites must not invoke PurchaseLayer or ReshapeBuffer until the write PR is merged") + +// Config holds construction-time parameters for AWSLadder. +type Config struct { + // Region is the AWS region this ladder instance operates on. + Region string + // AccountID is the AWS account ID this ladder instance is scoped to. + AccountID string + // HorizonDays is the look-ahead window (in days) used to classify a + // commitment as expiring soon in ExpiringUSDPerHour. When zero, + // DefaultHorizonDays is applied. + HorizonDays int + // LookbackDays is the history window (in days) for coverage and + // utilization queries. When zero, DefaultLookbackDays is applied. + LookbackDays int +} + +// horizonDays returns the effective horizon, applying the default when unset. +func (c Config) horizonDays() int { + if c.HorizonDays > 0 { + return c.HorizonDays + } + return DefaultHorizonDays +} + +// lookbackDays returns the effective lookback, applying the default when unset. +func (c Config) lookbackDays() int { + if c.LookbackDays > 0 { + return c.LookbackDays + } + return DefaultLookbackDays +} + +// AWSLadder implements ladder.LadderCapability for AWS. It provides the READ +// side (ListCommitments, GetLayerStates, GetUsageBaseline); the write side +// (PurchaseLayer, ReshapeBuffer) is wired in PR 6 and returns an explicit +// not-implemented error until then. +// +// All four data-source dependencies are injected via narrow interfaces so that +// unit tests are hermetic (no real AWS calls needed). The caller wires the +// concrete adapters (ec2svc.Client, savingsplans.Client, etc.) at startup. +// +// SP coverage and utilization (spCoverageSource, spUtilizationSource) may be +// nil; when nil, CoveragePct and UtilizationPct for SP layers are nil, which +// the engine treats as "not yet measured." They are wired when the parallel +// SP coverage PR lands. +// +// Fields are ordered to minimize the GC pointer-scan range (fieldalignment): +// interface fields (all-pointer) come before Config (which has trailing int fields). +type AWSLadder struct { + ris riLister + sps spLister + coverage coverageSource + utilization utilizationSource + spCoverage spCoverageSource // nil until parallel SP coverage PR (PR 4) lands + spUtil spUtilizationSource // nil until parallel SP utilization PR (PR 4) lands + cfg Config +} + +// New constructs an AWSLadder. All four required interfaces must be non-nil; +// spCoverage and spUtil may be nil (wired later). +func New( + cfg Config, + ris riLister, + sps spLister, + cov coverageSource, + util utilizationSource, + spCov spCoverageSource, + spUtil spUtilizationSource, +) (*AWSLadder, error) { + if cfg.Region == "" { + return nil, fmt.Errorf("AWSLadder: Config.Region must not be empty") + } + if cfg.AccountID == "" { + return nil, fmt.Errorf("AWSLadder: Config.AccountID must not be empty") + } + if ris == nil { + return nil, fmt.Errorf("AWSLadder: riLister must not be nil") + } + if sps == nil { + return nil, fmt.Errorf("AWSLadder: spLister must not be nil") + } + if cov == nil { + return nil, fmt.Errorf("AWSLadder: coverageSource must not be nil") + } + if util == nil { + return nil, fmt.Errorf("AWSLadder: utilizationSource must not be nil") + } + return &AWSLadder{ + cfg: cfg, + ris: ris, + sps: sps, + coverage: cov, + utilization: util, + spCoverage: spCov, + spUtil: spUtil, + }, nil +} + +// Provider returns common.ProviderAWS to identify this implementation. +func (a *AWSLadder) Provider() common.ProviderType { + return common.ProviderAWS +} + +// SupportedLayers returns the three AWS ladder layers: +// - LayerEC2InstanceSP carries RoleBase (EC2-family-locked SPs for the stable base). +// - LayerComputeSP carries RoleFlex (compute-wide SPs for flexible coverage). +// - LayerConvertibleRI carries RoleBuffer (exchangeable RIs for the reshapeable buffer). +// +// Role-cardinality contract: exactly one RoleFlex (ComputeSP), one RoleBase +// (EC2InstanceSP), one RoleBuffer (ConvertibleRI). No multi-role merges on AWS. +func (a *AWSLadder) SupportedLayers() []ladder.LayerSpec { + return []ladder.LayerSpec{ + {Type: ladder.LayerEC2InstanceSP, Roles: []ladder.LayerRole{ladder.RoleBase}}, + {Type: ladder.LayerComputeSP, Roles: []ladder.LayerRole{ladder.RoleFlex}}, + {Type: ladder.LayerConvertibleRI, Roles: []ladder.LayerRole{ladder.RoleBuffer}}, + } +} + +// PurchaseLayer is not yet wired. It returns an explicit placeholder error +// that is NOT common.ErrCommitmentPurchaseNotSupported (which would signal +// permanent inability to purchase). This error signals that the write-side +// wiring is missing; callers must not invoke this method until PR 6 is merged. +// +//nolint:gocritic // hugeParam: Recommendation is large but the LadderCapability interface contract requires value, not pointer +func (a *AWSLadder) PurchaseLayer(_ context.Context, _ ladder.LayerType, _ common.Recommendation, _ common.PurchaseOptions) (common.PurchaseResult, error) { + return common.PurchaseResult{}, fmt.Errorf("PurchaseLayer: %w", errWriteNotWired) +} + +// ReshapeBuffer is not yet wired. It returns the same placeholder error as +// PurchaseLayer; see that method's comment for the rationale. +func (a *AWSLadder) ReshapeBuffer(_ context.Context, _ ladder.Scope, _ ladder.BufferReshapeConfig) (ladder.ReshapeSummary, error) { + return ladder.ReshapeSummary{}, fmt.Errorf("ReshapeBuffer: %w", errWriteNotWired) +} diff --git a/providers/aws/ladder/ladder_test.go b/providers/aws/ladder/ladder_test.go new file mode 100644 index 000000000..adbeb0ee9 --- /dev/null +++ b/providers/aws/ladder/ladder_test.go @@ -0,0 +1,814 @@ +package ladder + +import ( + "context" + "errors" + "math" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cetypes "github.com/aws/aws-sdk-go-v2/service/costexplorer/types" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/ladder" + "github.com/LeanerCloud/CUDly/providers/aws/recommendations" + ec2svc "github.com/LeanerCloud/CUDly/providers/aws/services/ec2" +) + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +// fakeRILister: err field before ris to minimize GC pointer-scan range (fieldalignment). +type fakeRILister struct { + err error + ris []ec2svc.ConvertibleRI +} + +func (f *fakeRILister) ListConvertibleReservedInstances(_ context.Context) ([]ec2svc.ConvertibleRI, error) { + return f.ris, f.err +} + +// fakeSPLister: err field before sps for fieldalignment. +type fakeSPLister struct { + err error + sps []ActiveSP +} + +func (f *fakeSPLister) ListActiveSPs(_ context.Context) ([]ActiveSP, error) { + return f.sps, f.err +} + +// fakeCoverageSource: error fields before slice for fieldalignment +// (all-pointer types before slice whose trailing len/cap are non-pointer). +type fakeCoverageSource struct { + coverageErr error + onDemandErr error + coverageMap recommendations.PoolCoverageMap + onDemandSeries []float64 +} + +func (f *fakeCoverageSource) GetRICoverageMap(_ context.Context, _ int, _ []string) (recommendations.PoolCoverageMap, error) { + return f.coverageMap, f.coverageErr +} + +func (f *fakeCoverageSource) GetOnDemandSeries(_ context.Context, _ string, _ int) ([]float64, error) { + return f.onDemandSeries, f.onDemandErr +} + +// fakeUtilizationSource: err field before utils for fieldalignment. +type fakeUtilizationSource struct { + err error + utils []recommendations.RIUtilization +} + +func (f *fakeUtilizationSource) GetRIUtilization(_ context.Context, _ int) ([]recommendations.RIUtilization, error) { + return f.utils, f.err +} + +// fakeSPCoverageSource is a hermetic fake for the spCoverageSource interface. +type fakeSPCoverageSource struct { + err error + summary SPCoverageSummary +} + +func (f *fakeSPCoverageSource) GetSPCoverageSummary(_ context.Context, _ string, _ int) (SPCoverageSummary, error) { + return f.summary, f.err +} + +// fakeSPUtilizationSource is a hermetic fake for the spUtilizationSource interface. +type fakeSPUtilizationSource struct { + err error + summary SPUtilizationSummary + gotType cetypes.SupportedSavingsPlansType +} + +func (f *fakeSPUtilizationSource) GetSPUtilization(_ context.Context, planType cetypes.SupportedSavingsPlansType, _ string, _ int) (SPUtilizationSummary, error) { + f.gotType = planType + return f.summary, f.err +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +func newTestLadder( + t *testing.T, + ris riLister, + sps spLister, + cov coverageSource, + util utilizationSource, +) *AWSLadder { + t.Helper() + a, err := New( + Config{Region: "us-east-1", AccountID: "123456789012", HorizonDays: 30, LookbackDays: 30}, + ris, sps, cov, util, + nil, nil, + ) + require.NoError(t, err) + return a +} + +func testScope() ladder.Scope { + return ladder.Scope{Provider: common.ProviderAWS, AccountID: "123456789012"} +} + +// makeRI constructs a ConvertibleRI for test use. fixedPrice is hardcoded to 0 +// (no-upfront) and duration to one year (31536000 s); tests that need other +// payment options build ec2svc.ConvertibleRI directly. +func makeRI(id, instanceType string, count int32, recurringHourly float64, end time.Time) ec2svc.ConvertibleRI { + return ec2svc.ConvertibleRI{ + ReservedInstanceID: id, + InstanceType: instanceType, + InstanceCount: count, + FixedPrice: 0, + RecurringHourlyAmount: recurringHourly, + Duration: 31536000, // 1 year in seconds + State: "active", + End: end, + } +} + +func makeSP(id, planType string, hourly float64, end time.Time) ActiveSP { + return ActiveSP{ + PlanID: id, + PlanType: planType, + HourlyCommitmentUSD: hourly, + State: "active", + EndDate: end, + } +} + +// --------------------------------------------------------------------------- +// New() constructor +// --------------------------------------------------------------------------- + +func TestNew_RequiredFieldValidation(t *testing.T) { + ri := &fakeRILister{} + sp := &fakeSPLister{} + cov := &fakeCoverageSource{} + util := &fakeUtilizationSource{} + + // cfg is last in the anonymous struct to minimize GC scan range (fieldalignment): + // interface fields (all-pointer) before Config (which has trailing int fields). + tests := []struct { + name string + ri riLister + sp spLister + cov coverageSource + util utilizationSource + wantErr string + cfg Config + }{ + {"empty region", ri, sp, cov, util, "Region must not be empty", Config{AccountID: "1"}}, + {"empty account", ri, sp, cov, util, "AccountID must not be empty", Config{Region: "us-east-1"}}, + {"nil riLister", nil, sp, cov, util, "riLister must not be nil", Config{Region: "us-east-1", AccountID: "1"}}, + {"nil spLister", ri, nil, cov, util, "spLister must not be nil", Config{Region: "us-east-1", AccountID: "1"}}, + {"nil coverageSource", ri, sp, nil, util, "coverageSource must not be nil", Config{Region: "us-east-1", AccountID: "1"}}, + {"nil utilizationSource", ri, sp, cov, nil, "utilizationSource must not be nil", Config{Region: "us-east-1", AccountID: "1"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := New(tt.cfg, tt.ri, tt.sp, tt.cov, tt.util, nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +// --------------------------------------------------------------------------- +// Provider / SupportedLayers +// --------------------------------------------------------------------------- + +func TestProvider(t *testing.T) { + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, &fakeCoverageSource{}, &fakeUtilizationSource{}) + assert.Equal(t, common.ProviderAWS, a.Provider()) +} + +func TestSupportedLayers_RoleCardinality(t *testing.T) { + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, &fakeCoverageSource{}, &fakeUtilizationSource{}) + layers := a.SupportedLayers() + require.Len(t, layers, 3) + + roleSeen := make(map[ladder.LayerRole]int) + for _, l := range layers { + for _, r := range l.Roles { + roleSeen[r]++ + } + } + assert.Equal(t, 1, roleSeen[ladder.RoleFlex], "exactly one RoleFlex") + assert.Equal(t, 1, roleSeen[ladder.RoleBase], "exactly one RoleBase") + assert.Equal(t, 1, roleSeen[ladder.RoleBuffer], "exactly one RoleBuffer") + + // Verify the layer-to-role assignment. + layerRole := make(map[ladder.LayerType]ladder.LayerRole) + for _, l := range layers { + layerRole[l.Type] = l.Roles[0] + } + assert.Equal(t, ladder.RoleBase, layerRole[ladder.LayerEC2InstanceSP]) + assert.Equal(t, ladder.RoleFlex, layerRole[ladder.LayerComputeSP]) + assert.Equal(t, ladder.RoleBuffer, layerRole[ladder.LayerConvertibleRI]) +} + +// --------------------------------------------------------------------------- +// PurchaseLayer / ReshapeBuffer stub errors +// --------------------------------------------------------------------------- + +func TestPurchaseLayer_ReturnsNotWiredError(t *testing.T) { + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, &fakeCoverageSource{}, &fakeUtilizationSource{}) + _, err := a.PurchaseLayer(context.Background(), ladder.LayerConvertibleRI, common.Recommendation{}, common.PurchaseOptions{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "write side not yet wired") + assert.False(t, errors.Is(err, common.ErrCommitmentPurchaseNotSupported), + "must NOT wrap ErrCommitmentPurchaseNotSupported -- that sentinel means permanent inability, not missing wiring") +} + +func TestReshapeBuffer_ReturnsNotWiredError(t *testing.T) { + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, &fakeCoverageSource{}, &fakeUtilizationSource{}) + _, err := a.ReshapeBuffer(context.Background(), testScope(), ladder.BufferReshapeConfig{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "write side not yet wired") +} + +// --------------------------------------------------------------------------- +// ListCommitments +// --------------------------------------------------------------------------- + +func TestListCommitments_MergesRIsAndSPs(t *testing.T) { + now := time.Now() + end1yr := now.Add(365 * 24 * time.Hour) + + ris := []ec2svc.ConvertibleRI{ + makeRI("ri-1", "m5.xlarge", 2, 0.50, end1yr), + } + sps := []ActiveSP{ + makeSP("sp-ec2-1", "EC2Instance", 1.00, end1yr), + makeSP("sp-compute-1", "Compute", 2.00, end1yr), + makeSP("sp-sagemaker-1", "SageMaker", 0.50, end1yr), // filtered out + } + + a := newTestLadder(t, &fakeRILister{ris: ris}, &fakeSPLister{sps: sps}, &fakeCoverageSource{}, &fakeUtilizationSource{}) + commitments, err := a.ListCommitments(context.Background(), testScope()) + require.NoError(t, err) + + // 1 RI + 2 SP (EC2Instance + Compute); SageMaker filtered out. + require.Len(t, commitments, 3) + + var riFound, ec2SPFound, computeSPFound bool + for _, c := range commitments { + switch { + case c.CommitmentType == common.CommitmentReservedInstance: + riFound = true + assert.Equal(t, "ri-1", c.CommitmentID) + assert.Equal(t, 2, c.Count) + // Per-instance pricing: no-upfront 0.50/hr recurring x 2 instances + // = 1.00 reservation-total (DescribeReservedInstances fields are + // per-instance). + assert.InDelta(t, 1.00, c.Cost, 1e-9) + case c.CommitmentID == "sp-ec2-1": + ec2SPFound = true + assert.Equal(t, common.ServiceSavingsPlansEC2Instance, c.Service) + case c.CommitmentID == "sp-compute-1": + computeSPFound = true + assert.Equal(t, common.ServiceSavingsPlansCompute, c.Service) + } + } + assert.True(t, riFound) + assert.True(t, ec2SPFound) + assert.True(t, computeSPFound) +} + +func TestListCommitments_EmptySourcesReturnEmptySlice(t *testing.T) { + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, &fakeCoverageSource{}, &fakeUtilizationSource{}) + commitments, err := a.ListCommitments(context.Background(), testScope()) + require.NoError(t, err) + assert.Empty(t, commitments) +} + +func TestListCommitments_WrongScope_ReturnsError(t *testing.T) { + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, &fakeCoverageSource{}, &fakeUtilizationSource{}) + wrongScope := ladder.Scope{Provider: common.ProviderAWS, AccountID: "999"} + _, err := a.ListCommitments(context.Background(), wrongScope) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match configured account") +} + +func TestListCommitments_RIError_Propagates(t *testing.T) { + ri := &fakeRILister{err: errors.New("AWS API error")} + a := newTestLadder(t, ri, &fakeSPLister{}, &fakeCoverageSource{}, &fakeUtilizationSource{}) + _, err := a.ListCommitments(context.Background(), testScope()) + require.Error(t, err) + assert.Contains(t, err.Error(), "RI listing failed") +} + +// --------------------------------------------------------------------------- +// riHourlyCost +// --------------------------------------------------------------------------- + +func TestRIHourlyCost_PaymentOptions(t *testing.T) { + oneYearSeconds := int64(31536000) + tests := []struct { + name string + fixedPrice float64 + usagePrice float64 + recurringHourly float64 + duration int64 + instanceCount int32 + wantHourly float64 + }{ + { + name: "no-upfront: only recurring", + fixedPrice: 0, + recurringHourly: 0.30, + duration: oneYearSeconds, + instanceCount: 1, + wantHourly: 0.30, + }, + { + name: "all-upfront: only amortized", + fixedPrice: 8760 * 0.20, // $0.20/hr amortized over 1yr + duration: oneYearSeconds, + instanceCount: 1, + wantHourly: 0.20, + }, + { + name: "partial-upfront: both", + fixedPrice: 8760 * 0.10, // $0.10/hr upfront portion + recurringHourly: 0.15, + duration: oneYearSeconds, + instanceCount: 1, + wantHourly: 0.25, + }, + { + name: "legacy usage price included", + usagePrice: 0.05, + recurringHourly: 0.10, + duration: oneYearSeconds, + instanceCount: 1, + wantHourly: 0.15, + }, + { + name: "per-instance semantics: count multiplies the rate", + fixedPrice: 8760 * 0.10, // $0.10/hr upfront portion per instance + usagePrice: 0.02, + recurringHourly: 0.08, + duration: oneYearSeconds, + instanceCount: 4, + wantHourly: 0.80, // (0.10 + 0.02 + 0.08) * 4 + }, + { + name: "zero duration: no panic", + fixedPrice: 1000, + recurringHourly: 0.10, + duration: 0, + instanceCount: 1, + wantHourly: 0.10, // upfront amortized skipped when duration==0 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ri := ec2svc.ConvertibleRI{ + FixedPrice: tt.fixedPrice, + UsagePrice: tt.usagePrice, + RecurringHourlyAmount: tt.recurringHourly, + Duration: tt.duration, + InstanceCount: tt.instanceCount, + } + got := riHourlyCost(&ri) + assert.InDelta(t, tt.wantHourly, got, 1e-6) + }) + } +} + +// --------------------------------------------------------------------------- +// GetLayerStates - explicit zero contract +// --------------------------------------------------------------------------- + +func TestGetLayerStates_EmptyLayer_ExplicitZeros(t *testing.T) { + // Empty RI and SP lists. The contract requires ExistingUSDPerHour and + // ExpiringUSDPerHour to be explicit zero pointers (not nil), and + // UtilizationPct to be nil (genuinely unmeasured on an empty layer). + a := newTestLadder(t, + &fakeRILister{}, + &fakeSPLister{}, + &fakeCoverageSource{}, + &fakeUtilizationSource{}, + ) + states, err := a.GetLayerStates(context.Background(), testScope()) + require.NoError(t, err) + + for _, layerType := range []ladder.LayerType{ + ladder.LayerConvertibleRI, + ladder.LayerEC2InstanceSP, + ladder.LayerComputeSP, + } { + s, ok := states[layerType] + require.True(t, ok, "layer %s must be present", layerType) + + require.NotNil(t, s.ExistingUSDPerHour, "ExistingUSDPerHour must be non-nil (explicit zero) for empty layer %s", layerType) + assert.Equal(t, 0.0, *s.ExistingUSDPerHour, "ExistingUSDPerHour must be 0 for empty layer %s", layerType) + + require.NotNil(t, s.ExpiringUSDPerHour, "ExpiringUSDPerHour must be non-nil (explicit zero) for empty layer %s", layerType) + assert.Equal(t, 0.0, *s.ExpiringUSDPerHour, "ExpiringUSDPerHour must be 0 for empty layer %s", layerType) + + // UtilizationPct must be nil for an empty layer (genuinely unmeasured). + assert.Nil(t, s.UtilizationPct, "UtilizationPct must be nil (not measured) for empty layer %s", layerType) + } +} + +// --------------------------------------------------------------------------- +// GetLayerStates - expiry horizon boundary +// --------------------------------------------------------------------------- + +func TestGetLayerStates_ExpiryHorizonBoundary(t *testing.T) { + now := time.Now() + horizonDays := 30 + + // RI that expires exactly at the horizon boundary (on or before horizon). + atHorizon := now.Add(time.Duration(horizonDays) * 24 * time.Hour) + justAfter := atHorizon.Add(time.Second) + + riAtHorizon := makeRI("ri-at", "m5.large", 1, 1.00, atHorizon) + riJustAfter := makeRI("ri-after", "m5.large", 1, 1.00, justAfter) + + a, err := New( + Config{Region: "us-east-1", AccountID: "123456789012", HorizonDays: horizonDays, LookbackDays: 30}, + &fakeRILister{ris: []ec2svc.ConvertibleRI{riAtHorizon, riJustAfter}}, + &fakeSPLister{}, + &fakeCoverageSource{}, + &fakeUtilizationSource{}, + nil, nil, + ) + require.NoError(t, err) + + states, err := a.GetLayerStates(context.Background(), testScope()) + require.NoError(t, err) + + ri := states[ladder.LayerConvertibleRI] + require.NotNil(t, ri.ExpiringUSDPerHour) + // Only riAtHorizon is within the horizon; riJustAfter is not. + assert.InDelta(t, 1.00, *ri.ExpiringUSDPerHour, 1e-6, + "only the RI expiring at-or-before the horizon should be counted") +} + +// --------------------------------------------------------------------------- +// GetLayerStates - per-instance RI pricing regression +// --------------------------------------------------------------------------- + +func TestGetLayerStates_RILayer_CountGreaterThanOne_MultipliesCost(t *testing.T) { + // Regression: DescribeReservedInstances pricing fields are per-instance. + // A count=3 RI at 0.40/hr recurring must contribute 1.20/hr to + // ExistingUSDPerHour — understating this by factor InstanceCount would + // make the engine overbuy new commitments. + end1yr := time.Now().Add(365 * 24 * time.Hour) + ris := []ec2svc.ConvertibleRI{ + makeRI("ri-multi", "m5.large", 3, 0.40, end1yr), + } + + a := newTestLadder(t, + &fakeRILister{ris: ris}, + &fakeSPLister{}, + &fakeCoverageSource{}, + &fakeUtilizationSource{}, + ) + states, err := a.GetLayerStates(context.Background(), testScope()) + require.NoError(t, err) + + ri := states[ladder.LayerConvertibleRI] + require.NotNil(t, ri.ExistingUSDPerHour) + assert.InDelta(t, 1.20, *ri.ExistingUSDPerHour, 1e-9, + "reservation-total = per-instance 0.40/hr x 3 instances") + require.NotNil(t, ri.ExpiringUSDPerHour) + assert.InDelta(t, 0.0, *ri.ExpiringUSDPerHour, 1e-9, + "1-year-out expiry is beyond the 30-day horizon") +} + +// --------------------------------------------------------------------------- +// GetLayerStates - coverage and utilization +// --------------------------------------------------------------------------- + +func TestGetLayerStates_RILayer_CoverageAndUtilization(t *testing.T) { + ris := []ec2svc.ConvertibleRI{ + makeRI("ri-1", "m5.large", 1, 0.50, time.Now().Add(365*24*time.Hour)), + } + coverageMap := recommendations.PoolCoverageMap{ + "us-east-1:m5.large": {Pct: 80.0, AvgInstancesPerHour: 10.0}, + "us-east-1:m5.xlarge": {Pct: 60.0, AvgInstancesPerHour: 5.0}, + } + utils := []recommendations.RIUtilization{ + {PurchasedHours: 100, TotalActualHours: 90}, + } + + a := newTestLadder(t, + &fakeRILister{ris: ris}, + &fakeSPLister{}, + &fakeCoverageSource{coverageMap: coverageMap}, + &fakeUtilizationSource{utils: utils}, + ) + states, err := a.GetLayerStates(context.Background(), testScope()) + require.NoError(t, err) + + ri := states[ladder.LayerConvertibleRI] + require.NotNil(t, ri.CoveragePct, "CoveragePct should be non-nil when coverage map is populated") + // Weighted average: (80*10 + 60*5) / (10+5) = (800+300)/15 = 1100/15 ~= 73.33 + assert.InDelta(t, 73.33, *ri.CoveragePct, 0.1) + + require.NotNil(t, ri.UtilizationPct, "UtilizationPct should be non-nil when utilization data is present") + assert.InDelta(t, 90.0, *ri.UtilizationPct, 1e-6) +} + +func TestGetLayerStates_CoverageError_DegradesToNil(t *testing.T) { + ris := []ec2svc.ConvertibleRI{ + makeRI("ri-1", "m5.large", 1, 0.50, time.Now().Add(365*24*time.Hour)), + } + a := newTestLadder(t, + &fakeRILister{ris: ris}, + &fakeSPLister{}, + &fakeCoverageSource{coverageErr: errors.New("CE API error")}, + &fakeUtilizationSource{}, + ) + states, err := a.GetLayerStates(context.Background(), testScope()) + require.NoError(t, err, "coverage error must not fail GetLayerStates") + assert.Nil(t, states[ladder.LayerConvertibleRI].CoveragePct, "CoveragePct must be nil on coverage source error") +} + +func TestGetLayerStates_SPLayers_NilSPInterfaces_GiveNilCovUtil(t *testing.T) { + sps := []ActiveSP{makeSP("sp-1", "Compute", 2.0, time.Now().Add(365*24*time.Hour))} + a := newTestLadder(t, + &fakeRILister{}, + &fakeSPLister{sps: sps}, + &fakeCoverageSource{}, + &fakeUtilizationSource{}, + ) + // spCoverageSource and spUtilizationSource are nil (not wired yet). + states, err := a.GetLayerStates(context.Background(), testScope()) + require.NoError(t, err) + + sp := states[ladder.LayerComputeSP] + require.NotNil(t, sp.ExistingUSDPerHour) + assert.InDelta(t, 2.0, *sp.ExistingUSDPerHour, 1e-9) + assert.Nil(t, sp.CoveragePct, "CoveragePct must be nil when spCoverageSource is not wired") + assert.Nil(t, sp.UtilizationPct, "UtilizationPct must be nil when spUtilizationSource is not wired") +} + +func TestGetLayerStates_SPLayers_SharedCovPct_BothLayersGetSameValue(t *testing.T) { + // When spCoverageSource is wired, both SP layers must share the same CoveragePct + // (CE API limitation: GetSavingsPlansCoverage does not support plan-type filtering). + covPct := 75.0 + spCov := &fakeSPCoverageSource{summary: SPCoverageSummary{CoveragePct: &covPct}} + + a, err := New( + Config{Region: "us-east-1", AccountID: "123456789012", HorizonDays: 30, LookbackDays: 30}, + &fakeRILister{}, &fakeSPLister{}, &fakeCoverageSource{}, &fakeUtilizationSource{}, + spCov, nil, + ) + require.NoError(t, err) + + states, err := a.GetLayerStates(context.Background(), testScope()) + require.NoError(t, err) + + ec2SP := states[ladder.LayerEC2InstanceSP] + computeSP := states[ladder.LayerComputeSP] + + require.NotNil(t, ec2SP.CoveragePct) + require.NotNil(t, computeSP.CoveragePct) + assert.InDelta(t, 75.0, *ec2SP.CoveragePct, 1e-9, "EC2Instance SP layer coverage") + assert.InDelta(t, 75.0, *computeSP.CoveragePct, 1e-9, "Compute SP layer coverage must equal EC2Instance SP (CE API limitation)") +} + +func TestGetLayerStates_SPUtilization_CorrectCEEnum(t *testing.T) { + // Verify that spLayerState passes the correct CE SDK enum to GetSPUtilization. + utilPct := 85.0 + spUtil := &fakeSPUtilizationSource{summary: SPUtilizationSummary{UtilizationPct: &utilPct}} + + a, err := New( + Config{Region: "us-east-1", AccountID: "123456789012", HorizonDays: 30, LookbackDays: 30}, + &fakeRILister{}, &fakeSPLister{}, &fakeCoverageSource{}, &fakeUtilizationSource{}, + nil, spUtil, + ) + require.NoError(t, err) + + states, err := a.GetLayerStates(context.Background(), testScope()) + require.NoError(t, err) + + // Both layers should get the same utilization (fake returns same for any planType). + ec2SP := states[ladder.LayerEC2InstanceSP] + computeSP := states[ladder.LayerComputeSP] + + require.NotNil(t, ec2SP.UtilizationPct) + require.NotNil(t, computeSP.UtilizationPct) + assert.InDelta(t, 85.0, *ec2SP.UtilizationPct, 1e-9) + assert.InDelta(t, 85.0, *computeSP.UtilizationPct, 1e-9) +} + +// --------------------------------------------------------------------------- +// toSPUtilPlanType +// --------------------------------------------------------------------------- + +func TestToSPUtilPlanType_MapsCorrectly(t *testing.T) { + got, err := toSPUtilPlanType("EC2Instance") + require.NoError(t, err) + assert.Equal(t, cetypes.SupportedSavingsPlansTypeEc2InstanceSp, got) + + got, err = toSPUtilPlanType("Compute") + require.NoError(t, err) + assert.Equal(t, cetypes.SupportedSavingsPlansTypeComputeSp, got) + + _, err = toSPUtilPlanType("SageMaker") + require.Error(t, err, "unknown plan type must return an error") +} + +// --------------------------------------------------------------------------- +// computeEC2CoveragePct +// --------------------------------------------------------------------------- + +func TestComputeEC2CoveragePct_WeightedAverage(t *testing.T) { + m := recommendations.PoolCoverageMap{ + "us-east-1:m5.large": {Pct: 80.0, AvgInstancesPerHour: 10.0}, + "us-east-1:m5.xlarge": {Pct: 60.0, AvgInstancesPerHour: 5.0}, + "us-west-2:m5.large": {Pct: 50.0, AvgInstancesPerHour: 8.0}, // different region, excluded + } + result := computeEC2CoveragePct(m, "us-east-1") + require.NotNil(t, result) + assert.InDelta(t, 73.33, *result, 0.1) +} + +func TestComputeEC2CoveragePct_NoMatchingPools_ReturnsNil(t *testing.T) { + m := recommendations.PoolCoverageMap{ + "us-west-2:m5.large": {Pct: 80.0}, + } + result := computeEC2CoveragePct(m, "us-east-1") + assert.Nil(t, result) +} + +func TestComputeEC2CoveragePct_AllZeroWeight_UnweightedAverage(t *testing.T) { + m := recommendations.PoolCoverageMap{ + "us-east-1:m5.large": {Pct: 80.0, AvgInstancesPerHour: 0}, + "us-east-1:m5.xlarge": {Pct: 60.0, AvgInstancesPerHour: 0}, + } + result := computeEC2CoveragePct(m, "us-east-1") + require.NotNil(t, result) + assert.InDelta(t, 70.0, *result, 1e-6, "unweighted average of 80 and 60") +} + +// --------------------------------------------------------------------------- +// nearestRankPercentile +// --------------------------------------------------------------------------- + +func TestNearestRankPercentile(t *testing.T) { + tests := []struct { + name string + data []float64 + p float64 + want float64 + wantErr bool + }{ + {"p5 of 20 elements", makeRange(20), 5.0, 1.0, false}, + {"p50 of 10 elements", makeRange(10), 50.0, 5.0, false}, + {"p100 returns max", []float64{3, 1, 4, 1, 5, 9}, 100.0, 9.0, false}, + {"p5 of 1 element", []float64{7.0}, 5.0, 7.0, false}, + {"all equal values", []float64{5, 5, 5, 5, 5}, 25.0, 5.0, false}, + {"empty data", []float64{}, 50.0, 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := nearestRankPercentile(tt.data, tt.p) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.InDelta(t, tt.want, got, 1e-9) + }) + } +} + +// --------------------------------------------------------------------------- +// GetUsageBaseline +// --------------------------------------------------------------------------- + +func TestGetUsageBaseline_SingleDaySeries_ReturnsThatValue(t *testing.T) { + // A 7-day series with all same value; p5 should return 3.0. + series := []float64{3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0} + cov := &fakeCoverageSource{onDemandSeries: series} + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, cov, &fakeUtilizationSource{}) + + bl, err := a.GetUsageBaseline(context.Background(), testScope(), 7, 5.0) + require.NoError(t, err) + require.NotNil(t, bl.LowWaterUSDPerHour) + assert.InDelta(t, 3.0, *bl.LowWaterUSDPerHour, 1e-9) + // StableUSDPerHour must be nil: no stable-usage estimator exists yet, and + // the pkg/ladder contract defines Stable as post-buffer-fraction (aliasing + // it to LowWater would make the engine over-commit the base layer). nil + // triggers the engine's documented "route all core gap to flex" degradation. + assert.Nil(t, bl.StableUSDPerHour) + assert.Equal(t, 7, bl.LookbackDays) + assert.InDelta(t, 5.0, bl.Percentile, 1e-9) +} + +func TestGetUsageBaseline_P5OfVariedSeries(t *testing.T) { + // 20-element series [1..20]; p5 nearest-rank: ceil(5/100*20)=ceil(1)=1 -> sorted[0]=1. + cov := &fakeCoverageSource{onDemandSeries: makeRange(20)} + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, cov, &fakeUtilizationSource{}) + + bl, err := a.GetUsageBaseline(context.Background(), testScope(), 20, 5.0) + require.NoError(t, err) + require.NotNil(t, bl.LowWaterUSDPerHour) + assert.InDelta(t, 1.0, *bl.LowWaterUSDPerHour, 1e-9) +} + +func TestGetUsageBaseline_EmptySeries_ReturnsError(t *testing.T) { + cov := &fakeCoverageSource{onDemandSeries: []float64{}} + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, cov, &fakeUtilizationSource{}) + _, err := a.GetUsageBaseline(context.Background(), testScope(), 7, 5.0) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty") +} + +func TestGetUsageBaseline_SeriesTooShort_ReturnsError(t *testing.T) { + cov := &fakeCoverageSource{onDemandSeries: []float64{1.0, 2.0, 3.0}} // < 7 days + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, cov, &fakeUtilizationSource{}) + _, err := a.GetUsageBaseline(context.Background(), testScope(), 7, 5.0) + require.Error(t, err) + assert.Contains(t, err.Error(), "below minimum") +} + +func TestGetUsageBaseline_NaNElement_ReturnsErrorNamingIndex(t *testing.T) { + series := makeRange(10) + series[4] = math.NaN() + cov := &fakeCoverageSource{onDemandSeries: series} + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, cov, &fakeUtilizationSource{}) + _, err := a.GetUsageBaseline(context.Background(), testScope(), 10, 5.0) + require.Error(t, err, "a NaN element must be rejected at the boundary") + assert.Contains(t, err.Error(), "index 4") + assert.Contains(t, err.Error(), "not finite") +} + +func TestGetUsageBaseline_InfElement_ReturnsErrorNamingIndex(t *testing.T) { + series := makeRange(10) + series[7] = math.Inf(1) + cov := &fakeCoverageSource{onDemandSeries: series} + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, cov, &fakeUtilizationSource{}) + _, err := a.GetUsageBaseline(context.Background(), testScope(), 10, 5.0) + require.Error(t, err, "an Inf element must be rejected at the boundary") + assert.Contains(t, err.Error(), "index 7") + assert.Contains(t, err.Error(), "not finite") +} + +func TestGetUsageBaseline_NegativeElement_ReturnsErrorNamingIndex(t *testing.T) { + series := makeRange(10) + series[2] = -0.5 + cov := &fakeCoverageSource{onDemandSeries: series} + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, cov, &fakeUtilizationSource{}) + _, err := a.GetUsageBaseline(context.Background(), testScope(), 10, 5.0) + require.Error(t, err, "a negative cost element must be rejected at the boundary") + assert.Contains(t, err.Error(), "index 2") + assert.Contains(t, err.Error(), "negative") +} + +func TestGetUsageBaseline_OnDemandSourceError_Propagates(t *testing.T) { + cov := &fakeCoverageSource{onDemandErr: errors.New("CE error")} + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, cov, &fakeUtilizationSource{}) + _, err := a.GetUsageBaseline(context.Background(), testScope(), 30, 5.0) + require.Error(t, err) + assert.Contains(t, err.Error(), "on-demand series fetch failed") +} + +func TestGetUsageBaseline_InvalidPercentile_ReturnsError(t *testing.T) { + series := makeRange(30) + cov := &fakeCoverageSource{onDemandSeries: series} + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, cov, &fakeUtilizationSource{}) + + for _, p := range []float64{0.0, -1.0, 101.0} { + _, err := a.GetUsageBaseline(context.Background(), testScope(), 30, p) + require.Error(t, err, "percentile %g should fail", p) + assert.Contains(t, err.Error(), "percentile") + } +} + +func TestGetUsageBaseline_WrongScope_ReturnsError(t *testing.T) { + cov := &fakeCoverageSource{onDemandSeries: makeRange(30)} + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, cov, &fakeUtilizationSource{}) + badScope := ladder.Scope{Provider: common.ProviderAWS, AccountID: "wrong"} + _, err := a.GetUsageBaseline(context.Background(), badScope, 30, 5.0) + require.Error(t, err) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// makeRange returns a float64 slice [1.0, 2.0, ..., float64(n)]. +func makeRange(n int) []float64 { + out := make([]float64, n) + for i := range out { + out[i] = float64(i + 1) + } + return out +} diff --git a/providers/aws/ladder/layer_states.go b/providers/aws/ladder/layer_states.go new file mode 100644 index 000000000..a59e01806 --- /dev/null +++ b/providers/aws/ladder/layer_states.go @@ -0,0 +1,342 @@ +package ladder + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + cetypes "github.com/aws/aws-sdk-go-v2/service/costexplorer/types" + + "github.com/LeanerCloud/CUDly/pkg/ladder" + "github.com/LeanerCloud/CUDly/providers/aws/recommendations" + ec2svc "github.com/LeanerCloud/CUDly/providers/aws/services/ec2" +) + +// ptr wraps a float64 as a non-nil pointer. Used to convert derived metrics +// into the pointer form required by LayerState. +func ptr(v float64) *float64 { return &v } + +// GetLayerStates returns a point-in-time snapshot for each of the three +// supported AWS ladder layers. The snapshot rules are: +// +// - ExistingUSDPerHour: 0 (explicit zero pointer) when the layer has no +// active commitments; non-nil with the summed hourly amortized cost otherwise. +// - ExpiringUSDPerHour: 0 (explicit zero pointer) when nothing expires within +// Config.HorizonDays; the expiring share otherwise. +// - CoveragePct: nil when not measured (SP layers before the parallel SP +// coverage PR lands); non-nil from CE coverage data for the RI layer. +// - UtilizationPct: nil on an empty layer or when data is unavailable; non-nil +// from CE utilization data for the RI layer. +// +// CE API note: GetSavingsPlansCoverage does not support plan-type filtering, +// so both EC2Instance and Compute SP layers share the same CoveragePct value. +// This is documented on each SP LayerState via the layer type field. +// +// The scope must match Config.AccountID and common.ProviderAWS. +func (a *AWSLadder) GetLayerStates(ctx context.Context, scope ladder.Scope) (map[ladder.LayerType]ladder.LayerState, error) { + if err := a.validateScope(scope); err != nil { + return nil, err + } + + ris, err := a.ris.ListConvertibleReservedInstances(ctx) + if err != nil { + return nil, fmt.Errorf("GetLayerStates: RI listing failed: %w", err) + } + + sps, err := a.sps.ListActiveSPs(ctx) + if err != nil { + return nil, fmt.Errorf("GetLayerStates: SP listing failed: %w", err) + } + + coverageMap, covErr := a.coverage.GetRICoverageMap(ctx, a.cfg.lookbackDays(), []string{a.cfg.Region}) + // covErr is checked per-layer below; a coverage failure does not fail the + // whole snapshot — it degrades CoveragePct to nil. + + utils, utilErr := a.utilization.GetRIUtilization(ctx, a.cfg.lookbackDays()) + // utilErr is handled the same way: degrade UtilizationPct to nil. + + now := time.Now() + horizon := now.Add(time.Duration(a.cfg.horizonDays()) * 24 * time.Hour) + + // SP coverage is fetched once; the CE GetSavingsPlansCoverage API does not + // support filtering by plan type, so both SP layers receive the same value. + spCovPct := a.fetchSPCoveragePct(ctx) + + states := make(map[ladder.LayerType]ladder.LayerState, 3) + states[ladder.LayerConvertibleRI] = a.riLayerState(ris, horizon, coverageMap, covErr, utils, utilErr) + states[ladder.LayerEC2InstanceSP] = a.spLayerState(ctx, ladder.LayerEC2InstanceSP, "EC2Instance", sps, horizon, spCovPct) + states[ladder.LayerComputeSP] = a.spLayerState(ctx, ladder.LayerComputeSP, "Compute", sps, horizon, spCovPct) + return states, nil +} + +// riLayerState builds the LayerState for the ConvertibleRI (buffer) layer. +func (a *AWSLadder) riLayerState( + ris []ec2svc.ConvertibleRI, + horizon time.Time, + coverageMap recommendations.PoolCoverageMap, + covErr error, + utils []recommendations.RIUtilization, + utilErr error, +) ladder.LayerState { + existing := sumRIHourlyCost(ris) + expiring := sumExpiringRIHourlyCost(ris, horizon) + + state := ladder.LayerState{ + Layer: ladder.LayerConvertibleRI, + ExistingUSDPerHour: ptr(existing), + ExpiringUSDPerHour: ptr(expiring), + } + + switch { + case covErr != nil: + // CoveragePct stays nil (unmeasured). Log so a persistently failing CE + // call is visible — silent degradation would quietly disable reshape + // triggering downstream. + log.Printf("WARNING: AWSLadder GetLayerStates: RI coverage degraded to nil (layer=%s, source=GetRICoverageMap, region=%s): %v", + ladder.LayerConvertibleRI, a.cfg.Region, covErr) + case len(coverageMap) > 0: + state.CoveragePct = computeEC2CoveragePct(coverageMap, a.cfg.Region) + } + + if utilErr != nil { + // UtilizationPct stays nil (unmeasured); same observability rationale. + log.Printf("WARNING: AWSLadder GetLayerStates: RI utilization degraded to nil (layer=%s, source=GetRIUtilization, region=%s): %v", + ladder.LayerConvertibleRI, a.cfg.Region, utilErr) + } else { + state.UtilizationPct = computeRIUtilizationPct(utils) + } + + return state +} + +// spLayerState builds the LayerState for an EC2Instance or Compute SP layer. +// +// sharedCovPct is the SP coverage percentage fetched once for both SP layers; +// the CE GetSavingsPlansCoverage API does not support plan-type filtering so +// both layers receive the same value (nil when the source is not yet wired). +// +// UtilizationPct is fetched per-layer via spUtilizationSource, which uses +// GetSavingsPlansUtilization and does support plan-type filtering. +func (a *AWSLadder) spLayerState( + ctx context.Context, + layerType ladder.LayerType, + planType string, + sps []ActiveSP, + horizon time.Time, + sharedCovPct *float64, +) ladder.LayerState { + existing := sumSPHourlyCost(sps, planType) + expiring := sumExpiringSPHourlyCost(sps, planType, horizon) + + state := ladder.LayerState{ + Layer: layerType, + ExistingUSDPerHour: ptr(existing), + ExpiringUSDPerHour: ptr(expiring), + CoveragePct: sharedCovPct, + UtilizationPct: a.fetchSPUtilizationPct(ctx, planType), + } + return state +} + +// fetchSPCoveragePct calls the injected spCoverageSource when wired; returns +// nil (unmeasured) when the interface is nil (PR 4 not yet landed). +// No planType is passed: the CE GetSavingsPlansCoverage API does not support +// filtering by plan type; the result applies to all SP types in the region. +func (a *AWSLadder) fetchSPCoveragePct(ctx context.Context) *float64 { + if a.spCoverage == nil { + return nil + } + summary, err := a.spCoverage.GetSPCoverageSummary(ctx, a.cfg.Region, a.cfg.lookbackDays()) + if err != nil { + // Degrade gracefully (caller treats nil as unmeasured) but log: a + // persistently failing CE call must not silently disable SP coverage. + log.Printf("WARNING: AWSLadder GetLayerStates: SP coverage degraded to nil (layers=%s+%s, source=GetSPCoverageSummary, region=%s): %v", + ladder.LayerEC2InstanceSP, ladder.LayerComputeSP, a.cfg.Region, err) + return nil + } + return summary.CoveragePct +} + +// fetchSPUtilizationPct calls the injected spUtilizationSource when wired; +// returns nil when the interface is nil (PR 4 not yet landed). +// +// Compute SPs are global; their utilization is queried with region="" (all +// regions). EC2 Instance SPs are region-specific; the configured region is used. +func (a *AWSLadder) fetchSPUtilizationPct(ctx context.Context, planType string) *float64 { + if a.spUtil == nil { + return nil + } + cePlanType, err := toSPUtilPlanType(planType) + if err != nil { + // Defensive: unknown plan type -> unmeasured; log for observability. + log.Printf("WARNING: AWSLadder GetLayerStates: SP utilization degraded to nil (planType=%s, source=toSPUtilPlanType): %v", + planType, err) + return nil + } + // Compute SPs are global; EC2 Instance SPs are region-scoped. + region := a.cfg.Region + if planType == "Compute" { + region = "" // "" = all regions in the CE GetSavingsPlansUtilization API + } + summary, err := a.spUtil.GetSPUtilization(ctx, cePlanType, region, a.cfg.lookbackDays()) + if err != nil { + // Degrade gracefully but log: silent CE failures must stay visible. + log.Printf("WARNING: AWSLadder GetLayerStates: SP utilization degraded to nil (planType=%s, source=GetSPUtilization, region=%q): %v", + planType, region, err) + return nil + } + return summary.UtilizationPct +} + +// toSPUtilPlanType maps the DescribeSavingsPlans planType string (from ActiveSP) +// to the CE SDK enum required by GetSavingsPlansUtilization. +func toSPUtilPlanType(planType string) (cetypes.SupportedSavingsPlansType, error) { + switch planType { + case "EC2Instance": + return cetypes.SupportedSavingsPlansTypeEc2InstanceSp, nil + case "Compute": + return cetypes.SupportedSavingsPlansTypeComputeSp, nil + default: + return "", fmt.Errorf("toSPUtilPlanType: unrecognized SP plan type %q", planType) + } +} + +// sumRIHourlyCost returns the total hourly amortized cost across all RIs. +// Index-based range with pointer avoids copying the large ConvertibleRI struct. +func sumRIHourlyCost(ris []ec2svc.ConvertibleRI) float64 { + var total float64 + for i := range ris { + total += riHourlyCost(&ris[i]) + } + return total +} + +// riHourlyCost computes the reservation-total hourly amortized cost for an RI. +// ri is taken by pointer to avoid copying the large ConvertibleRI struct (hugeParam). +// +// DescribeReservedInstances pricing fields are PER-INSTANCE (same semantics as +// the repo's canonical monthlyCostFromConvertibleRI helper in +// internal/api/handler_ri_exchange.go), so the per-instance hourly rate is +// multiplied by InstanceCount: +// +// hourly = (RecurringHourlyAmount + UsagePrice + FixedPrice/(Duration/3600)) * InstanceCount +// +// RecurringHourlyAmount covers the recurring charge (non-zero for no-upfront +// and partial-upfront); UsagePrice is the legacy per-hour usage fee; +// FixedPrice / (Duration / 3600) amortizes the upfront payment over the term +// (Duration is in seconds). Upfront amortization is skipped when Duration is +// zero (defensive; avoids divide-by-zero). +func riHourlyCost(ri *ec2svc.ConvertibleRI) float64 { + var upfrontAmortized float64 + if ri.Duration > 0 { + upfrontAmortized = ri.FixedPrice / (float64(ri.Duration) / 3600.0) + } + perInstance := ri.RecurringHourlyAmount + ri.UsagePrice + upfrontAmortized + return perInstance * float64(ri.InstanceCount) +} + +// sumExpiringRIHourlyCost sums the hourly costs of RIs whose EndDate is +// non-zero and falls on or before horizon. A zero EndDate is treated as +// "no expiry known" and excluded, to avoid misclassifying perpetual-term RIs. +// Index-based range with pointer avoids copying the large ConvertibleRI struct. +func sumExpiringRIHourlyCost(ris []ec2svc.ConvertibleRI, horizon time.Time) float64 { + var total float64 + for i := range ris { + if !ris[i].End.IsZero() && !ris[i].End.After(horizon) { + total += riHourlyCost(&ris[i]) + } + } + return total +} + +// sumSPHourlyCost sums the hourly commitment amounts for SPs of the given +// plan type. +func sumSPHourlyCost(sps []ActiveSP, planType string) float64 { + var total float64 + for _, sp := range sps { + if sp.PlanType == planType { + total += sp.HourlyCommitmentUSD + } + } + return total +} + +// sumExpiringSPHourlyCost sums the hourly commitments of SPs of the given +// plan type that expire on or before horizon. A zero EndDate is excluded. +func sumExpiringSPHourlyCost(sps []ActiveSP, planType string, horizon time.Time) float64 { + var total float64 + for _, sp := range sps { + if sp.PlanType != planType { + continue + } + if !sp.EndDate.IsZero() && !sp.EndDate.After(horizon) { + total += sp.HourlyCommitmentUSD + } + } + return total +} + +// computeEC2CoveragePct derives an aggregate EC2 RI coverage percentage from +// the PoolCoverageMap for the given region. Only EC2 pools (keys matching +// "region:*" with a non-zero AvgInstancesPerHour or Pct) are considered. +// +// Weighting: when any pool has a non-zero AvgInstancesPerHour, coverage is +// a weighted average (weight = AvgInstancesPerHour). When all pools have +// AvgInstancesPerHour == 0 (CE returned coverage % but no running hours -- +// unusual), a simple (unweighted) average is used. Returns nil when no EC2 +// pools for the region are found in the map. +func computeEC2CoveragePct(coverageMap recommendations.PoolCoverageMap, region string) *float64 { + prefix := strings.ToLower(region) + ":" + var weightedSum, totalWeight float64 + var simpleSum float64 + count := 0 + + for key, cov := range coverageMap { + if !strings.HasPrefix(key, prefix) { + continue + } + // Exclude RDS keys (contain extra ":" segments for engine:deployment). + // EC2 pool keys are exactly "region:instance_type" (one colon). + if strings.Count(key, ":") != 1 { + continue + } + count++ + simpleSum += cov.Pct + if cov.AvgInstancesPerHour > 0 { + weightedSum += cov.Pct * cov.AvgInstancesPerHour + totalWeight += cov.AvgInstancesPerHour + } + } + + if count == 0 { + return nil + } + + var result float64 + if totalWeight > 0 { + result = weightedSum / totalWeight + } else { + result = simpleSum / float64(count) + } + return ptr(result) +} + +// computeRIUtilizationPct aggregates per-RI utilization data from the CE +// GetReservationUtilization response into a single percentage. +// +// Method: sum(TotalActualHours) / sum(PurchasedHours) * 100. Returns nil when +// the slice is empty or the sum of PurchasedHours is zero (layer is empty or +// CE returned no hours -- genuinely unmeasured per the LayerState contract). +func computeRIUtilizationPct(utils []recommendations.RIUtilization) *float64 { + var purchased, actual float64 + for _, u := range utils { + purchased += u.PurchasedHours + actual += u.TotalActualHours + } + if purchased == 0 { + return nil + } + return ptr((actual / purchased) * 100.0) +}