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
12 changes: 6 additions & 6 deletions providers/aws/ladder/baseline.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ import (
const minBaselineSeriesDays = 7

// GetUsageBaseline computes a statistical low-water-mark from a daily
// on-demand-equivalent USD/hour series returned by the injected coverageSource.
// on-demand-equivalent USD/hour series returned by the injected onDemandSeriesSource.
//
// 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
// The series is sourced from onDemandSeriesSource.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.
Expand Down Expand Up @@ -55,16 +55,16 @@ func (a *AWSLadder) GetUsageBaseline(ctx context.Context, scope ladder.Scope, lo
return ladder.UsageBaseline{}, err
}

series, err := a.coverage.GetOnDemandSeries(ctx, a.cfg.Region, lookbackDays)
series, err := a.onDemand.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)
return ladder.UsageBaseline{}, fmt.Errorf("GetUsageBaseline: on-demand series is empty for region %s (series 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",
"GetUsageBaseline: series length %d is below minimum %d days; extend the lookback window or check the on-demand series source",
len(series), minBaselineSeriesDays,
)
}
Expand Down Expand Up @@ -92,7 +92,7 @@ func (a *AWSLadder) GetUsageBaseline(ctx context.Context, scope ladder.Scope, lo
}

// validateSeries rejects series containing non-finite (NaN/Inf) or negative
// elements at the trust boundary: the series is injected via coverageSource,
// elements at the trust boundary: the series is injected via onDemandSeriesSource,
// 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.
Expand Down
4 changes: 2 additions & 2 deletions providers/aws/ladder/commitments.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ func (a *AWSLadder) listSPCommitments(ctx context.Context) ([]common.Commitment,

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

// spToCommitment converts an ActiveSP to a common.Commitment.
Expand All @@ -123,7 +123,7 @@ func isLadderSPType(planType string) bool {
// (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" {
if sp.PlanType == spPlanTypeCompute {
service = common.ServiceSavingsPlansCompute
}

Expand Down
91 changes: 75 additions & 16 deletions providers/aws/ladder/interfaces.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,34 @@
// 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 implements ladder.LadderCapability for AWS: the read side
// (commitment listing, layer states, usage baseline) and the write side
// (layer purchases, buffer reshaping). Write-side methods require the write
// dependencies to be wired via AWSLadder.WithWriteSide; until then they
// return an explicit not-wired error.
package ladder

import (
"context"
"time"

cetypes "github.com/aws/aws-sdk-go-v2/service/costexplorer/types"
sptypes "github.com/aws/aws-sdk-go-v2/service/savingsplans/types"

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

// Savings Plan plan-type identifiers, derived from the AWS SDK enum so this
// package can never drift from the vocabulary the savingsplans service client
// uses (its PlanTypeForServiceType / ServiceTypeForPlanType mappings are built
// on sptypes.SavingsPlanType). The string form is needed because ActiveSP.
// PlanType and common.SavingsPlanDetails.PlanType are plain strings; the
// constant conversion keeps these compile-time constants, not vars.
const (
spPlanTypeEC2Instance = string(sptypes.SavingsPlanTypeEc2Instance)
spPlanTypeCompute = string(sptypes.SavingsPlanTypeCompute)
)

// riLister is the narrow interface for listing active convertible RIs.
// The concrete implementation is ec2svc.Client.ListConvertibleReservedInstances.
type riLister interface {
Expand Down Expand Up @@ -51,20 +67,25 @@ 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 {
// riCoverageSource is the narrow interface for RI coverage data, consumed by
// GetLayerStates. GetRICoverageMap returns the per-pool org-wide RI coverage
// map (keyed by "region:instance_type" for EC2) for the given lookback window
// and regions. Kept single-method (interface segregation) so implementations
// that only provide coverage need not stub the on-demand series and vice versa;
// one concrete adapter may still implement both.
type riCoverageSource interface {
GetRICoverageMap(ctx context.Context, lookbackDays int, regions []string) (recommendations.PoolCoverageMap, error)
}

// onDemandSeriesSource is the narrow interface for the daily on-demand spend
// series consumed by GetUsageBaseline. 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 onDemandSeriesSource interface {
GetOnDemandSeries(ctx context.Context, region string, lookbackDays int) ([]float64, error)
}

Expand Down Expand Up @@ -131,3 +152,41 @@ type spCoverageSource interface {
type spUtilizationSource interface {
GetSPUtilization(ctx context.Context, planType cetypes.SupportedSavingsPlansType, region string, lookbackDays int) (SPUtilizationSummary, error)
}

// riPurchaser is the narrow interface for purchasing EC2 convertible Reserved
// Instances. The concrete implementation is ec2svc.Client.PurchaseCommitment,
// which resolves the offering from the recommendation, enforces the
// idempotency-tag dedupe guard (issue #636: a lookup for an RI already tagged
// with opts.IdempotencyToken short-circuits a re-driven purchase), and tags
// the fresh RI post-purchase.
type riPurchaser interface {
PurchaseCommitment(ctx context.Context, rec common.Recommendation, opts common.PurchaseOptions) (common.PurchaseResult, error)
}

// spPurchaser is the narrow interface for purchasing Savings Plans. The
// concrete implementation is savingsplans.Client.PurchaseCommitment, which
// resolves the offering (plan type + term + payment option) and calls
// CreateSavingsPlan with opts.IdempotencyToken as the native ClientToken
// (server-side idempotency: a repeated call returns the original plan).
//
// A single spPurchaser serves both SP layers: AWSLadder validates that the
// recommendation's SavingsPlanDetails.PlanType matches the dispatched layer
// (EC2Instance for LayerEC2InstanceSP, Compute for LayerComputeSP) before
// calling, and a plan-type-scoped savingsplans.Client re-validates against
// its own scope (resolveSPPlanType), so a mismatched purchase cannot slip
// through either boundary.
type spPurchaser interface {
PurchaseCommitment(ctx context.Context, rec common.Recommendation, opts common.PurchaseOptions) (common.PurchaseResult, error)
}

// exchangeRunner is the narrow interface for running the automated RI
// exchange flow. The concrete implementation wraps exchange.RunAutoExchange
// and owns everything ReshapeBuffer must not know about: the exchange store,
// the ExchangeClient, the offering lookup, and the RI/utilization inventory
// conversion (the same wiring internal/server.executeRIExchangeReshape does).
// AWSLadder only supplies the run configuration; injecting the full
// exchange.RunAutoExchangeParams surface here would drag store and exchange
// client dependencies into this package for no benefit.
type exchangeRunner interface {
RunAutoExchange(ctx context.Context, cfg exchange.RIExchangeConfig) (*exchange.AutoExchangeResult, error)
}
88 changes: 55 additions & 33 deletions providers/aws/ladder/ladder.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package ladder

import (
"context"
"errors"
"fmt"

Expand All @@ -19,12 +18,12 @@ const DefaultHorizonDays = 30
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")
// when the write-side dependencies have not been wired via WithWriteSide.
// It is distinct from common.ErrCommitmentPurchaseNotSupported, which signals
// that this provider can NEVER purchase a given layer type programmatically.
// Here the capability exists; the instance is just missing its write wiring —
// a configuration error at the call site, not a permanent constraint.
var errWriteNotWired = errors.New("write side not wired: wire riPurchaser, spPurchaser, and exchangeRunner via WithWriteSide before calling PurchaseLayer or ReshapeBuffer")

// Config holds construction-time parameters for AWSLadder.
type Config struct {
Expand Down Expand Up @@ -57,15 +56,19 @@ func (c Config) lookbackDays() int {
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.
// AWSLadder implements ladder.LadderCapability for AWS: the read side
// (ListCommitments, GetLayerStates, GetUsageBaseline) and the write side
// (PurchaseLayer, ReshapeBuffer).
//
// 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
// All five read 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.
//
// The write-side dependencies (riPurchase, spPurchase, exchange) are wired via
// WithWriteSide; until then PurchaseLayer and ReshapeBuffer fail loud with
// errWriteNotWired. This keeps read-only wiring (dashboards, analysis) free of
// purchase/exchange infrastructure.
//
// 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
Expand All @@ -76,20 +79,27 @@ func (c Config) lookbackDays() int {
type AWSLadder struct {
ris riLister
sps spLister
coverage coverageSource
riCoverage riCoverageSource
onDemand onDemandSeriesSource
utilization utilizationSource
spCoverage spCoverageSource // nil until parallel SP coverage PR (PR 4) lands
spUtil spUtilizationSource // nil until parallel SP utilization PR (PR 4) lands
riPurchase riPurchaser // write side; nil until WithWriteSide is called
spPurchase spPurchaser // write side; nil until WithWriteSide is called
exchange exchangeRunner // write side; nil until WithWriteSide is called
cfg Config
}

// New constructs an AWSLadder. All four required interfaces must be non-nil;
// spCoverage and spUtil may be nil (wired later).
// New constructs an AWSLadder. The five required read-side interfaces must be
// non-nil; spCov and spUtil may be nil (wired later). riCov and odSeries are
// separate single-method interfaces (interface segregation); one concrete
// adapter may satisfy both and be passed for each.
func New(
cfg Config,
ris riLister,
sps spLister,
cov coverageSource,
riCov riCoverageSource,
odSeries onDemandSeriesSource,
util utilizationSource,
spCov spCoverageSource,
spUtil spUtilizationSource,
Expand All @@ -106,8 +116,11 @@ func New(
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 riCov == nil {
return nil, fmt.Errorf("AWSLadder: riCoverageSource must not be nil")
}
if odSeries == nil {
return nil, fmt.Errorf("AWSLadder: onDemandSeriesSource must not be nil")
}
if util == nil {
return nil, fmt.Errorf("AWSLadder: utilizationSource must not be nil")
Expand All @@ -116,7 +129,8 @@ func New(
cfg: cfg,
ris: ris,
sps: sps,
coverage: cov,
riCoverage: riCov,
onDemand: odSeries,
utilization: util,
spCoverage: spCov,
spUtil: spUtil,
Expand All @@ -143,18 +157,26 @@ func (a *AWSLadder) SupportedLayers() []ladder.LayerSpec {
}
}

// 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.
// WithWriteSide wires the write-side dependencies and returns the same
// instance for chaining. All three must be non-nil: a partially wired write
// side would let one write method work while its sibling fails at call time,
// which is harder to diagnose than failing here at construction.
//
//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)
// riP purchases EC2 convertible RIs (LayerConvertibleRI); spP purchases
// Savings Plans (LayerEC2InstanceSP and LayerComputeSP); ex runs the
// automated RI exchange flow backing ReshapeBuffer.
func (a *AWSLadder) WithWriteSide(riP riPurchaser, spP spPurchaser, ex exchangeRunner) (*AWSLadder, error) {
if riP == nil {
return nil, fmt.Errorf("AWSLadder.WithWriteSide: riPurchaser must not be nil")
}
if spP == nil {
return nil, fmt.Errorf("AWSLadder.WithWriteSide: spPurchaser must not be nil")
}
if ex == nil {
return nil, fmt.Errorf("AWSLadder.WithWriteSide: exchangeRunner must not be nil")
}
a.riPurchase = riP
a.spPurchase = spP
a.exchange = ex
return a, nil
}
Loading