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
2 changes: 1 addition & 1 deletion pkg/common/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ type auditParentHandle interface {
}

type auditParentDir struct {
path string
handle auditParentHandle
path string
}

func (f *auditOSFile) syncParents() error {
Expand Down
14 changes: 7 additions & 7 deletions pkg/common/reservation_name.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ const awsReservationNameMaxLen = 60
// deterministic assertions. Production callers leave randSource nil (the
// builder then uses crypto/rand) and pass time.Now().
type ReservationNameFields struct {
// Now is the timestamp baseline. Must be non-zero for production calls.
// Tests inject a fixed value for determinism.
Now time.Time

// Service is the short identifier for the AWS service, e.g. "rds",
// "cache", "memdb", "opensearch", "redshift". Set per-call by the
// service client; the builder treats it as opaque and sanitizes it.
Expand All @@ -35,9 +39,6 @@ type ReservationNameFields struct {
// "m5.large"). Dots are converted to hyphens by SanitizeReservationID.
ResourceType string

// Count is the reservation quantity. Always rendered as "{N}x".
Count int

// Term is the commitment term, normalized to "1yr" / "3yr" by upstream
// recommendation parsers. The builder collapses it to "1yr"/"3yr" when
// possible, and sanitizes otherwise.
Expand All @@ -49,14 +50,13 @@ type ReservationNameFields struct {
// segment stays under 8 chars.
Payment string

// Now is the timestamp baseline. Must be non-zero for production calls.
// Tests inject a fixed value for determinism.
Now time.Time

// randSource is an optional 4-byte source for the random suffix.
// When nil (production), the builder reads from crypto/rand. Tests set
// it via WithRandSource to make output deterministic.
randSource []byte

// Count is the reservation quantity. Always rendered as "{N}x".
Count int
}

// WithRandSource returns a copy of f with the given bytes used as the
Expand Down
217 changes: 126 additions & 91 deletions pkg/common/types.go

Large diffs are not rendered by default.

54 changes: 30 additions & 24 deletions pkg/exchange/auto.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,30 +18,32 @@ import (
//
//nolint:revive // stutters, but cloud-commitments-platform references this type through pkg's pinned go.mod pseudo-version (not workspace-resolved under GOWORK=off); renaming here without a coordinated go.mod bump would break that build mode
type ExchangeRecord struct {
CreatedAt time.Time
UpdatedAt time.Time

// 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

CompletedAt *time.Time
ExpiresAt *time.Time
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
SourceRIIDs []string
SourceCount int
TargetCount int
}

// RIExchangeStore is the subset of store operations needed by RunAutoExchange.
Expand Down Expand Up @@ -106,12 +108,6 @@ 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
Expand All @@ -123,15 +119,23 @@ type RunAutoExchangeParams struct {
// cancels only standalone (ladder_run_id IS NULL) pendings.
LadderRunID *string

AccountID string
Region string
DashboardURL string
RIs []RIInfo
Utilization []UtilizationInfo

// OfferedInstanceTypes is the region's offered EC2 instance types. Empty keeps
// the legacy size ladder; otherwise reshape targets are limited to it.
OfferedInstanceTypes []string

Config RIExchangeConfig

// 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

// OfferedInstanceTypes is the region's offered EC2 instance types. Empty keeps
// the legacy size ladder; otherwise reshape targets are limited to it.
OfferedInstanceTypes []string
}

// RIMetadataInfo holds the offering metadata for a specific RI.
Expand Down Expand Up @@ -162,16 +166,18 @@ type ExchangeOutcome struct {
SourceInstanceType string
TargetInstanceType string
TargetOfferingID string
TargetCount int32
PaymentDue string
ExchangeID string
UtilizationPct float64
Error string
UtilizationPct float64
TargetCount int32

// 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
Expand Down
50 changes: 29 additions & 21 deletions pkg/exchange/exchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,11 @@ import (
//
//nolint:revive // stutters, but cloud-commitments-platform references this type through pkg's pinned go.mod pseudo-version (not workspace-resolved under GOWORK=off); renaming here without a coordinated go.mod bump would break that build mode
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

PaymentDueUSD *big.Rat `json:"-"` // internal use only, not serializable
ValidationFailureReason string
CurrencyCode string
PaymentDueRaw string // as returned by AWS (string)
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)
Expand All @@ -42,6 +39,8 @@ type ExchangeQuoteSummary struct {
TargetHourlyPriceRaw string
TargetRemainingUpfrontRaw string
TargetRemainingTotalRaw string

IsValidExchange bool
}

// ParseDecimalRat parses AWS decimal strings like "123.45" or "-0.018000" into big.Rat.
Expand Down Expand Up @@ -73,7 +72,14 @@ type TargetConfig struct {
type ExchangeQuoteRequest struct {
Region string
ExpectedAccount string // optional safety check
ReservedIDs []string

// 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

ReservedIDs []string

// Targets is the preferred path for multi-target exchanges. When
// non-empty, TargetOfferingID / TargetCount are ignored.
Expand All @@ -83,8 +89,7 @@ type ExchangeQuoteRequest struct {
// 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
TargetCount int32

// DryRun here uses the AWS API DryRun parameter (permission check).
// The quote call itself never performs an exchange.
Expand All @@ -95,24 +100,27 @@ type ExchangeQuoteRequest struct {
//
//nolint:revive // stutters, but ci_cd_sanity_tests references this type through pkg's pinned go.mod pseudo-version (not workspace-resolved under GOWORK=off); renaming here without a coordinated go.mod bump would break that build mode
type ExchangeExecuteRequest struct {
// 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

Region string
ExpectedAccount string // optional safety check
ReservedIDs []string

// Legacy single-target alias. Prefer Targets for new code.
TargetOfferingID string

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
TargetCount int32
}

// targetConfigs returns the ec2types slice to pass to the EC2 API.
Expand Down
63 changes: 37 additions & 26 deletions pkg/exchange/reshape.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,18 @@ import (
// RIInfo describes a Reserved Instance for reshape analysis.
// Callers should pre-filter to convertible RIs only.
type RIInfo struct {
ID string
InstanceType string
InstanceCount int32
OfferingClass string // must be "convertible" — standard RIs cannot be exchanged
ID string
InstanceType string
OfferingClass string // must be "convertible" — standard RIs cannot be exchanged

// 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

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
Expand All @@ -26,11 +33,7 @@ type RIInfo struct {
// 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
Expand All @@ -41,6 +44,8 @@ type RIInfo struct {
// the term filter is skipped for that rec to preserve today's
// behavior for older callers that don't populate it.
TermSeconds int64

InstanceCount int32
}

// UtilizationInfo provides utilization data for a specific RI.
Expand All @@ -59,21 +64,33 @@ type UtilizationInfo struct {
// pkg/exchange stays AWS-free and providers/aws/services/ec2 imports
// the type rather than defining its own.
type OfferingOption struct {
InstanceType string `json:"instance_type"`
OfferingID string `json:"offering_id"`
// 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"`

InstanceType string `json:"instance_type"`
OfferingID string `json:"offering_id"`

// 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"`

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.
Expand All @@ -83,13 +100,7 @@ type OfferingOption struct {
// 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
Expand Down Expand Up @@ -126,14 +137,14 @@ type PurchaseRecLookup func(ctx context.Context, region, currencyCode string) ([
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"`
Reason string `json:"reason"`
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"`
SourceCount int32 `json:"source_count"`
TargetCount int32 `json:"target_count"`
}

// passesDollarUnitsCheck approximates AWS's exchange-validity rule
Expand Down
Loading
Loading