Documentation
¶
Overview ¶
Package exchange provides AWS Convertible Reserved Instance exchange operations. It wraps the EC2 GetReservedInstancesExchangeQuote and AcceptReservedInstancesExchangeQuote APIs with input validation and spend-cap guardrails.
Index ¶
- Variables
- func NormalizationFactorForSize(size string) float64
- func ParseDecimalRat(s string) (*big.Rat, error)
- type AutoExchangeResult
- type Client
- type EC2ExchangeAPI
- type ExchangeClientInterface
- type ExchangeExecuteRequest
- type ExchangeOutcome
- type ExchangeQuoteRequest
- type ExchangeQuoteSummary
- type ExchangeRecord
- type LookupOfferingFunc
- type LookupOfferingParams
- type Mode
- type OfferingOption
- type PurchaseRecLookup
- type RIExchangeConfig
- type RIExchangeStore
- type RIInfo
- type RIMetadataInfo
- type ReshapeRecommendation
- type RunAutoExchangeParams
- type SkippedRecommendation
- type TargetConfig
- type UtilizationInfo
Constants ¶
This section is empty.
Variables ¶
var ErrQuoteNotUSD = errors.New("exchange quote is not denominated in USD")
ErrQuoteNotUSD is returned when a quote's CurrencyCode is not USD or is absent, since every exchange spend cap is denominated in USD.
Functions ¶
func NormalizationFactorForSize ¶
NormalizationFactorForSize returns the normalization factor for a given instance size. Returns 0 if the size is unknown.
Types ¶
type AutoExchangeResult ¶
type AutoExchangeResult struct {
Mode string
Completed []ExchangeOutcome
Pending []ExchangeOutcome
Failed []ExchangeOutcome
Skipped []SkippedRecommendation
}
AutoExchangeResult contains the outcome of an auto exchange run.
func RunAutoExchange ¶
func RunAutoExchange(ctx context.Context, params RunAutoExchangeParams) (*AutoExchangeResult, error)
RunAutoExchange orchestrates automated RI exchanges.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client wraps an EC2ExchangeAPI for dependency-injected exchange operations. Use NewExchangeClient to construct one.
ec2 and identity are set together at construction and never mutated afterward (no setter exists): both must resolve to the same AWS account, and a setter that let a caller swap one independently of the other would let the account guard pass while the exchange itself ran in a different account (issue #81 follow-up).
func NewExchangeClient ¶
NewExchangeClient creates a Client from an AWS config. ec2 and identity are derived from the same config, so ExpectedAccount is honored on this Client's GetQuote/Execute methods just as it is on the package-level GetExchangeQuote/ExecuteExchange functions, and the two clients can never point at different accounts.
func NewExchangeClientFromAPI ¶
func NewExchangeClientFromAPI(api EC2ExchangeAPI) *Client
NewExchangeClientFromAPI creates a Client from an existing EC2ExchangeAPI implementation (useful for testing). It has no identity resolver: a request that sets ExpectedAccount on a Client built this way fails loud (see assertAccount) rather than silently skipping the check.
func (*Client) Execute ¶
func (c *Client) Execute(ctx context.Context, req ExchangeExecuteRequest) (string, *ExchangeQuoteSummary, error)
Execute performs a convertible RI exchange with a spend-cap guardrail using the injected EC2 client.
func (*Client) GetQuote ¶
func (c *Client) GetQuote(ctx context.Context, req ExchangeQuoteRequest) (*ExchangeQuoteSummary, error)
GetQuote retrieves an exchange quote using the injected EC2 client.
type EC2ExchangeAPI ¶
type EC2ExchangeAPI interface {
GetReservedInstancesExchangeQuote(ctx context.Context, params *ec2.GetReservedInstancesExchangeQuoteInput, optFns ...func(*ec2.Options)) (*ec2.GetReservedInstancesExchangeQuoteOutput, error)
AcceptReservedInstancesExchangeQuote(ctx context.Context, params *ec2.AcceptReservedInstancesExchangeQuoteInput, optFns ...func(*ec2.Options)) (*ec2.AcceptReservedInstancesExchangeQuoteOutput, error)
}
EC2ExchangeAPI defines the EC2 API methods used by exchange operations. Satisfied by *ec2.Client; accept this interface to enable testing without real AWS credentials.
type ExchangeClientInterface ¶
type ExchangeClientInterface interface {
GetQuote(ctx context.Context, req ExchangeQuoteRequest) (*ExchangeQuoteSummary, error)
Execute(ctx context.Context, req ExchangeExecuteRequest) (string, *ExchangeQuoteSummary, error)
}
ExchangeClientInterface abstracts the Client for testability.
type ExchangeExecuteRequest ¶
type ExchangeExecuteRequest struct {
Region string
ExpectedAccount string // optional safety check
ReservedIDs []string
// Targets is the preferred path for multi-target exchanges. When
// non-empty, TargetOfferingID / TargetCount are ignored.
Targets []TargetConfig
// Legacy single-target alias. Prefer Targets for new code.
TargetOfferingID string
TargetCount int32
// Guardrail: require PaymentDue <= MaxPaymentDueUSD to execute.
// AWS returns a single aggregated PaymentDue across all targets,
// so for multi-target requests this guardrail naturally becomes a
// total cap rather than a per-target cap.
// If nil, execution is refused.
MaxPaymentDueUSD *big.Rat
}
ExchangeExecuteRequest holds parameters for executing an exchange.
type ExchangeOutcome ¶
type ExchangeOutcome struct {
RecordID string
ApprovalToken string
SourceRIID string
SourceInstanceType string
TargetInstanceType string
TargetOfferingID string
TargetCount int32
PaymentDue string
ExchangeID string
UtilizationPct float64
Error string
// OutcomeUnknown is true when the accept response was lost, so the exchange
// may have executed. It must be reconciled before any retry. The outcome
// stays in AutoExchangeResult.Failed (readers of Failed still see it) and
// halts the run (no further exchanges are attempted).
OutcomeUnknown bool
// Simulated is true when RunAutoExchangeParams.DryRun was set. The outcome
// was analyzed and quoted but no record was saved and no exchange was executed.
Simulated bool
}
ExchangeOutcome captures the result of a single exchange attempt.
type ExchangeQuoteRequest ¶
type ExchangeQuoteRequest struct {
Region string
ExpectedAccount string // optional safety check
ReservedIDs []string
// Targets is the preferred path for multi-target exchanges. When
// non-empty, TargetOfferingID / TargetCount are ignored.
Targets []TargetConfig
// TargetOfferingID + TargetCount are the legacy single-target
// fields, retained so pre-existing callers (HTTP handlers, sanity
// tests, serialized PurchasePlans) don't need a flag-day change.
// New code should populate Targets instead.
TargetOfferingID string
TargetCount int32
// DryRun here uses the AWS API DryRun parameter (permission check).
// The quote call itself never performs an exchange.
DryRun bool
}
ExchangeQuoteRequest holds parameters for requesting an exchange quote.
type ExchangeQuoteSummary ¶
type ExchangeQuoteSummary struct {
IsValidExchange bool
ValidationFailureReason string
CurrencyCode string
PaymentDueRaw string // as returned by AWS (string)
PaymentDueUSD *big.Rat `json:"-"` // internal use only, not serializable
PaymentDueUSDStr string `json:"payment_due_usd,omitempty"` // parsed decimal for JSON consumers
OutputReservedInstancesExp string // formatted date string (YYYY-MM-DD), empty if not set
// Rollups (strings in AWS response)
SourceHourlyPriceRaw string
SourceRemainingUpfrontRaw string
SourceRemainingTotalRaw string
TargetHourlyPriceRaw string
TargetRemainingUpfrontRaw string
TargetRemainingTotalRaw string
}
ExchangeQuoteSummary is a small, stable summary we can log/guard on.
func ExecuteExchange ¶
func ExecuteExchange(ctx context.Context, req ExchangeExecuteRequest) (exchangeID string, quote *ExchangeQuoteSummary, err error)
ExecuteExchange performs a convertible RI exchange with a spend-cap guardrail. This is a convenience wrapper that creates its own AWS client from default config.
func GetExchangeQuote ¶
func GetExchangeQuote(ctx context.Context, req ExchangeQuoteRequest) (*ExchangeQuoteSummary, error)
GetExchangeQuote retrieves an exchange quote from the EC2 API.
type ExchangeRecord ¶
type ExchangeRecord struct {
ID string
AccountID string
ExchangeID string
Region string
SourceRIIDs []string
SourceInstanceType string
SourceCount int
TargetOfferingID string
TargetInstanceType string
TargetCount int
PaymentDue string
Status string
ApprovalToken string
Error string
Mode string
// LadderRunID links this exchange record to the ladder run that created it.
// Nil for standalone ri_exchange_reshape task records.
// The database column ri_exchange_history.ladder_run_id was added in
// migration 000080 and is the authoritative source for origin scoping.
LadderRunID *string
CreatedAt time.Time
UpdatedAt time.Time
CompletedAt *time.Time
ExpiresAt *time.Time
}
ExchangeRecord is a lightweight record type for the auto exchange logic.
It mirrors config.RIExchangeRecord but lives in pkg/exchange to avoid cross-module imports (pkg/ is a separate Go module from internal/).
type LookupOfferingFunc ¶
type LookupOfferingFunc func(ctx context.Context, p LookupOfferingParams) (string, error)
LookupOfferingFunc looks up a target offering ID for a given instance type and RI metadata.
type LookupOfferingParams ¶
type LookupOfferingParams struct {
InstanceType string
ProductDescription string
Tenancy string
Scope string
AvailabilityZone string
Duration int64
}
LookupOfferingParams carries the target instance type and the source RI metadata a lookup needs. AvailabilityZone is the source RI's AZ and is empty for regional RIs.
type Mode ¶
type Mode string
Mode constrains the originating code path of an exchange record so `saveFailedRecord` (and any future caller) can't silently leak a typo into `ExchangeRecord.Mode`. The storage field stays `string` for serialization stability — this is a call-site discipline, not a schema change.
type OfferingOption ¶
type OfferingOption struct {
InstanceType string `json:"instance_type"`
OfferingID string `json:"offering_id"`
EffectiveMonthlyCost float64 `json:"effective_monthly_cost"`
// NormalizationFactor is the AWS normalization factor for the
// offering's instance size — needed by the local
// passesDollarUnitsCheck pre-filter that gates which cross-family
// alternatives surface in the UI. Zero on missing means the
// alternative cannot be safely compared and the pre-filter
// excludes it.
NormalizationFactor float64 `json:"normalization_factor,omitempty"`
// CurrencyCode is the ISO-4217 currency the EffectiveMonthlyCost is
// denominated in (typically "USD"). The pre-filter requires source
// and target currencies to match; empty on either side falls back to
// "skip the currency guard" so today's USD-only fixtures stay green.
CurrencyCode string `json:"currency_code,omitempty"`
// TermSeconds is the offering's commitment term in seconds
// (31_536_000 for 1y, 94_608_000 for 3y) — same unit as
// RIInfo.TermSeconds and the AWS SDK's ReservedInstance.Duration.
// AnalyzeReshapingWithRecs rejects term-mismatched alternatives
// before building AlternativeTargets so a 3y RI never surfaces as
// an alternative to a 1y source commitment (and vice versa). Zero
// on either side falls back to "skip the term guard" for callers
// or fixtures that don't populate it.
TermSeconds int64 `json:"term_seconds,omitempty"`
// SavingsAbs is the absolute monthly savings (in CurrencyCode) that
// AWS Cost Explorer reported for this recommendation. Used by
// compositeScore to estimate savings % and derive a confidence
// signal. Zero means "not supplied" — the score component is omitted
// rather than coerced to 0 (which would falsely rank this offering
// last on the savings axis).
SavingsAbs *float64 `json:"savings_abs,omitempty"`
// RecommendationCount is the number of instances AWS Cost Explorer
// included in the recommendation. Used together with SavingsAbs to
// derive a confidence signal (large fleet + large savings = high
// confidence). Zero means "not supplied"; the confidence signal is
// skipped rather than treated as a 0-instance fleet.
RecommendationCount int `json:"recommendation_count,omitempty"`
}
OfferingOption is a single Convertible RI offering exposed to the reshape layer: the AWS offering ID, the instance type it provisions, and the effective monthly cost (amortized fixed price + recurring hourly charges × 730 hours). Used both as the return shape of PurchaseRecLookup (what the recommendations-store closure returns) and as the element type of ReshapeRecommendation.AlternativeTargets (what reshape emits to the HTTP handler). Kept in pkg/exchange so pkg/exchange stays AWS-free and providers/aws/services/ec2 imports the type rather than defining its own.
type PurchaseRecLookup ¶
type PurchaseRecLookup func(ctx context.Context, region, currencyCode string) ([]OfferingOption, error)
PurchaseRecLookup is the signature of the closure that resolves a region + currency to the cached AWS Cost Explorer purchase recommendations available there. Used by AnalyzeReshapingWithRecs to generate cross-family alternatives without per-recommendation AWS API calls — the recommendations table is already populated by the scheduler tick. Implementations read from store.ListStoredRecommendations and map each RecommendationRecord to an OfferingOption (effective monthly cost amortized from the upfront and monthly fields).
region is the AWS region the reshape page is viewing; currencyCode is the source RIs' currency, propagated onto each returned OfferingOption.CurrencyCode so the dollar-units pre-filter can refuse cross-currency comparisons cleanly.
type RIExchangeConfig ¶
type RIExchangeConfig struct {
Mode string
UtilizationThreshold float64
MaxPaymentPerExchangeUSD float64
MaxPaymentDailyUSD float64
LookbackDays int
}
RIExchangeConfig holds the runtime configuration for auto exchange.
type RIExchangeStore ¶
type RIExchangeStore interface {
SaveRIExchangeRecord(ctx context.Context, record *ExchangeRecord) error
// CancelAllPendingExchanges cancels every pending record regardless of origin.
// Kept for interface compatibility; RunAutoExchange now calls
// CancelPendingExchangesByOrigin instead to avoid cross-origin contamination.
CancelAllPendingExchanges(ctx context.Context) (int64, error)
// CancelPendingExchangesByOrigin cancels only pending records whose origin
// matches:
// - common.ExchangeOriginStandalone: cancels WHERE ladder_run_id IS NULL
// - common.ExchangeOriginLadder: cancels WHERE ladder_run_id IS NOT NULL
// This prevents the standalone task from wiping out ladder-linked pending
// reshapes and vice versa. Implementations must validate the origin and
// fail loud on an unknown value (money path).
CancelPendingExchangesByOrigin(ctx context.Context, origin common.ExchangeOrigin) (int64, error)
GetStaleProcessingExchanges(ctx context.Context, olderThan time.Duration) ([]ExchangeRecord, error)
GetRIExchangeDailySpend(ctx context.Context, date time.Time) (string, error)
CompleteRIExchange(ctx context.Context, id string, exchangeID string) error
// ReserveRIExchange atomically checks daily spend and saves a processing
// record with the returned execution ceiling. It sets record.ID.
ReserveRIExchange(ctx context.Context, record *ExchangeRecord, dailyCapUSD, perExchangeCapUSD string) (string, error)
CompleteRIExchangeWithPayment(ctx context.Context, id, exchangeID, acceptedPaymentDue string) error
FailRIExchange(ctx context.Context, id string, errorMsg string) error
}
RIExchangeStore is the subset of store operations needed by RunAutoExchange.
type RIInfo ¶
type RIInfo struct {
ID string
InstanceType string
InstanceCount int32
OfferingClass string // must be "convertible" — standard RIs cannot be exchanged
NormalizationFactor float64 // AWS normalization factor for the instance size
// MonthlyCost is the effective per-instance per-month price (amortized
// upfront + recurring charges, AWS-canonical 730 hours per month).
// Used by the local passesDollarUnitsCheck pre-filter that gates
// cross-family alternatives. Zero when the caller didn't compute it
// (e.g. older callers that only need primary-target analysis); the
// pre-filter treats zero as "skip the check" so existing behavior
// is preserved.
MonthlyCost float64
// CurrencyCode is the ISO-4217 currency the prices are denominated
// in (typically "USD"). Used by the cross-family check to refuse
// comparisons across currencies. Empty matches the same "skip"
// semantics as MonthlyCost == 0.
CurrencyCode string
// TermSeconds is the RI commitment term in seconds (31_536_000 for
// 1y, 94_608_000 for 3y) — the same unit the AWS SDK uses on the
// ReservedInstance.Duration field and the unit ec2.ConvertibleRI.Duration
// already carries. Used by the cross-family alternatives pass to
// reject term-mismatched offerings (e.g. surfacing a 3y RI as an
// alternative to a 1y commitment is wrong because the customer's
// existing exchange anchor is the 1y term). Zero means "unknown" —
// the term filter is skipped for that rec to preserve today's
// behavior for older callers that don't populate it.
TermSeconds int64
}
RIInfo describes a Reserved Instance for reshape analysis. Callers should pre-filter to convertible RIs only.
type RIMetadataInfo ¶
type RIMetadataInfo struct {
ProductDescription string
InstanceTenancy string
Scope string
AvailabilityZone string
Duration int64
}
RIMetadataInfo holds the offering metadata for a specific RI.
type ReshapeRecommendation ¶
type ReshapeRecommendation struct {
SourceRIID string `json:"source_ri_id"`
SourceInstanceType string `json:"source_instance_type"`
SourceCount int32 `json:"source_count"`
TargetInstanceType string `json:"target_instance_type"`
TargetCount int32 `json:"target_count"`
AlternativeTargets []OfferingOption `json:"alternative_targets,omitempty"`
UtilizationPercent float64 `json:"utilization_percent"`
NormalizedUsed float64 `json:"normalized_used"`
NormalizedPurchased float64 `json:"normalized_purchased"`
Reason string `json:"reason"`
}
ReshapeRecommendation describes a suggested exchange for an underutilized RI.
AlternativeTargets lists cross-family options enriched with real offering IDs and monthly cost from cached AWS Cost Explorer purchase recommendations (see AnalyzeReshapingWithRecs). This is advisory data for the UI to surface alongside the primary target; the auto-exchange pipeline still acts on TargetInstanceType only so existing automated behavior is unchanged. Empty when the base AnalyzeReshaping is used directly (auto.go) or when no cached recommendations exist for the region.
func AnalyzeReshaping ¶
func AnalyzeReshaping(ris []RIInfo, utilization []UtilizationInfo, threshold float64) []ReshapeRecommendation
AnalyzeReshaping identifies underutilized convertible RIs and suggests optimal exchange targets using AWS normalization factors.
threshold is a percentage (0–100) below which an RI is considered underutilized. For example, threshold=95 means RIs with <95% utilization get recommendations.
Recommendations are emitted with empty AlternativeTargets — alternatives are populated only by AnalyzeReshapingWithRecs, which pairs each rec against the cached AWS Cost Explorer recommendations.
func AnalyzeReshapingWithRecs ¶
func AnalyzeReshapingWithRecs( ctx context.Context, ris []RIInfo, utilization []UtilizationInfo, threshold float64, region, currencyCode string, lookup PurchaseRecLookup, ) []ReshapeRecommendation
AnalyzeReshapingWithRecs is AnalyzeReshaping plus cross-family alternatives sourced from the cached AWS Cost Explorer purchase recommendations table. It calls the base analyzer, then makes a SINGLE region-scoped lookup and pairs each rec against the returned offerings — no per-recommendation AWS API call, no hand-curated peer-family allowlist.
Filtering rules per rec:
- The source family (parsed from rec.SourceInstanceType) is excluded so the alternatives slice carries only cross-family options.
- The primary target (rec.TargetInstanceType) is also excluded — it is already surfaced as the primary suggestion.
- passesDollarUnitsCheck gates each surviving offering against the source RI's NF / MonthlyCost / CurrencyCode so the UI doesn't show options that would be rejected at AWS exchange time. When the source RI lacks pricing (MonthlyCost == 0) the gate is skipped for that rec to preserve today's behavior for callers that don't supply pricing.
Missing cache, lookup error, or empty-region response: rec ships with empty AlternativeTargets — the dashboard's primary reshape suggestion stays intact and the UX matches "AWS hasn't recommended anything for this region yet". auto.go keeps calling AnalyzeReshaping (no alternatives needed); only the HTTP reshape handler calls this enriched version.
type RunAutoExchangeParams ¶
type RunAutoExchangeParams struct {
Store RIExchangeStore
ExchangeClient ExchangeClientInterface
LookupOffering LookupOfferingFunc
RIs []RIInfo
Utilization []UtilizationInfo
Config RIExchangeConfig
AccountID string
Region string
DashboardURL string
// RIMetadata maps RI ID to its metadata (product description, tenancy, scope, duration).
RIMetadata map[string]RIMetadataInfo
// LadderRunID links all exchange records created by this run to the ladder
// run that originated them. Nil for the standalone ri_exchange_reshape task.
// When set, RunAutoExchange calls CancelPendingExchangesByOrigin with
// ladderScoped=true (cancels only ladder-linked pendings). When nil, it
// cancels only standalone (ladder_run_id IS NULL) pendings.
LadderRunID *string
// DryRun skips all state mutations (cancellations, record saves, exchange
// executions). Outcomes are returned with Simulated=true. No store write
// is performed; mock.AssertExpectations will catch any accidental mutation
// call in tests (fail-loud gate).
DryRun bool
}
RunAutoExchangeParams holds all dependencies for RunAutoExchange.
type SkippedRecommendation ¶
SkippedRecommendation captures a recommendation that was not processed.
type TargetConfig ¶
TargetConfig is a single target offering in an exchange: a Convertible RI offering to buy and how many of it. AWS accepts multiple targets per exchange (AcceptReservedInstancesExchangeQuote is all-or-nothing across the whole TargetConfigurations slice), which lets callers redistribute RI value across several shapes in one atomic operation.
type UtilizationInfo ¶
UtilizationInfo provides utilization data for a specific RI.