diff --git a/pkg/common/audit.go b/pkg/common/audit.go index 2c8fee35..10dba4d4 100644 --- a/pkg/common/audit.go +++ b/pkg/common/audit.go @@ -102,8 +102,8 @@ type auditParentHandle interface { } type auditParentDir struct { - path string handle auditParentHandle + path string } func (f *auditOSFile) syncParents() error { diff --git a/pkg/common/reservation_name.go b/pkg/common/reservation_name.go index ac6aa543..97bbd5e2 100644 --- a/pkg/common/reservation_name.go +++ b/pkg/common/reservation_name.go @@ -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. @@ -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. @@ -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 diff --git a/pkg/common/types.go b/pkg/common/types.go index 489d6fc4..4106fb35 100644 --- a/pkg/common/types.go +++ b/pkg/common/types.go @@ -199,6 +199,24 @@ func (c CommitmentType) String() string { // Recommendation represents a commitment purchase recommendation across any cloud provider. type Recommendation struct { + // Metadata + Timestamp time.Time `json:"timestamp,omitempty" csv:"Timestamp"` + + // Service-specific details (polymorphic) + Details ServiceDetails `json:"details,omitempty" csv:"-"` + + // RecurringMonthlyCost is the recurring monthly charge for this commitment + // (i.e. the part the user pays every month after any upfront payment). + // nil means the provider API did not return a monthly breakdown — the + // frontend renders nil as "—" rather than "$0" to avoid misleading users. + // Populated by cloud parsers when the API exposes it; left nil otherwise. + RecurringMonthlyCost *float64 `json:"recurring_monthly_cost,omitempty" csv:"RecurringMonthlyCost"` + + // ExistingCoveragePercentExact preserves expiry arithmetic until in-process sizing. + // Treat it as immutable; clear it when replacing coverage, even with the same percentage. + // Serialization drops this state and its exact-arithmetic guarantee. + ExistingCoveragePercentExact *big.Rat `json:"-" csv:"-"` + // Provider identification Provider ProviderType `json:"provider" csv:"Provider"` Account string `json:"account" csv:"Account"` @@ -210,13 +228,34 @@ type Recommendation struct { // Resource details ResourceType string `json:"resource_type" csv:"ResourceType"` // Instance type, node type, VM size, etc. - Count int `json:"count" csv:"Count"` // Commitment details CommitmentType CommitmentType `json:"commitment_type" csv:"CommitmentType"` // RI, SP, CUD, etc. Term string `json:"term" csv:"Term"` // 1yr, 3yr PaymentOption string `json:"payment_option" csv:"PaymentOption"` // all-upfront, partial, no-upfront, monthly + // CommitmentCurrency covers CommitmentCost and RecurringMonthlyCost, not savings or baseline. + // Empty means unknown; presence requires source-proven amounts and currency for every required payment leg. + CommitmentCurrency string `json:"commitment_currency,omitempty" csv:"CommitmentCurrency"` + + // Metadata + SourceRecommendation string `json:"source_recommendation,omitempty" csv:"SourceRecommendation"` + + // RawRecommendation holds the original cloud API response bytes for audit/debugging. + // omitempty ensures nil is absent from JSON (not written as null). + RawRecommendation json.RawMessage `json:"raw_recommendation,omitempty" csv:"-"` + + // UsageHistory is an ordered slice of daily coverage/utilization + // percentages (0-100) for the last N days of the lookback window + // (oldest-to-newest). Populated by cloud collectors that can source the + // signal from the provider API; nil when not yet wired or when the + // provider returned no data (frontend renders "—" in that case). + // Not written to CSV (no column heading — enrichment-only field). + UsageHistory []float64 `json:"usage_history,omitempty" csv:"-"` + + // Resource details + Count int `json:"count" csv:"Count"` + // Cost information. OnDemandCost, EstimatedSavings and SavingsPercentage are // USD: providers skip a recommendation whose source reports an explicit // non-USD currency; an AWS Cost Explorer detail with no CurrencyCode is @@ -225,22 +264,6 @@ type Recommendation struct { CommitmentCost float64 `json:"commitment_cost" csv:"CommitmentCost"` EstimatedSavings float64 `json:"estimated_savings" csv:"EstimatedSavings"` SavingsPercentage float64 `json:"savings_percentage" csv:"SavingsPercentage"` - // RecurringMonthlyCost is the recurring monthly charge for this commitment - // (i.e. the part the user pays every month after any upfront payment). - // nil means the provider API did not return a monthly breakdown — the - // frontend renders nil as "—" rather than "$0" to avoid misleading users. - // Populated by cloud parsers when the API exposes it; left nil otherwise. - RecurringMonthlyCost *float64 `json:"recurring_monthly_cost,omitempty" csv:"RecurringMonthlyCost"` - // CommitmentCurrency covers CommitmentCost and RecurringMonthlyCost, not savings or baseline. - // Empty means unknown; presence requires source-proven amounts and currency for every required payment leg. - CommitmentCurrency string `json:"commitment_currency,omitempty" csv:"CommitmentCurrency"` - - // Service-specific details (polymorphic) - Details ServiceDetails `json:"details,omitempty" csv:"-"` - - // Metadata - SourceRecommendation string `json:"source_recommendation,omitempty" csv:"SourceRecommendation"` - Timestamp time.Time `json:"timestamp,omitempty" csv:"Timestamp"` // Break-even in months (populated by cloud parsers where available; used by scorer filter) BreakEvenMonths float64 `json:"break_even_months,omitempty" csv:"BreakEvenMonths"` @@ -264,10 +287,10 @@ type Recommendation struct { RecommendedUtilization float64 `json:"recommended_utilization,omitempty" csv:"RecommendedUtilization"` RecommendedCount int `json:"recommended_count,omitempty" csv:"RecommendedCount"` ExistingCoveragePct float64 `json:"existing_coverage_pct,omitempty" csv:"ExistingCoveragePct"` - // ExistingCoveragePercentExact preserves expiry arithmetic until in-process sizing. - // Treat it as immutable; clear it when replacing coverage, even with the same percentage. - // Serialization drops this state and its exact-arithmetic guarantee. - ExistingCoveragePercentExact *big.Rat `json:"-" csv:"-"` + + ProjectedUtilization float64 `json:"projected_utilization,omitempty" csv:"ProjectedUtilization"` + ProjectedCoverage float64 `json:"projected_coverage,omitempty" csv:"ProjectedCoverage"` + // ExistingCoverageKnown distinguishes "CE returned a value for this // pool" (Known=true, Pct possibly 0.0 meaning the pool has running // instances but no RI coverage yet) from "CE has no data for this @@ -277,21 +300,7 @@ type Recommendation struct { // CSV writers use this to render "n/a" for unknown vs "0.0" for // genuine zero-coverage pools. RI --target-coverage sizing drops recs // with Known=false and positive average demand (target-coverage-unknown). - ExistingCoverageKnown bool `json:"existing_coverage_known,omitempty" csv:"-"` - ProjectedUtilization float64 `json:"projected_utilization,omitempty" csv:"ProjectedUtilization"` - ProjectedCoverage float64 `json:"projected_coverage,omitempty" csv:"ProjectedCoverage"` - - // RawRecommendation holds the original cloud API response bytes for audit/debugging. - // omitempty ensures nil is absent from JSON (not written as null). - RawRecommendation json.RawMessage `json:"raw_recommendation,omitempty" csv:"-"` - - // UsageHistory is an ordered slice of daily coverage/utilization - // percentages (0-100) for the last N days of the lookback window - // (oldest-to-newest). Populated by cloud collectors that can source the - // signal from the provider API; nil when not yet wired or when the - // provider returned no data (frontend renders "—" in that case). - // Not written to CSV (no column heading — enrichment-only field). - UsageHistory []float64 `json:"usage_history,omitempty" csv:"-"` + ExistingCoverageKnown bool `json:"existing_coverage_known,omitempty" csv:"-"` } // ServiceDetails is an interface for service-specific details. @@ -355,15 +364,18 @@ func ScaleRecommendationCosts(rec Recommendation, ratio float64) Recommendation // PurchaseResult represents the outcome of a commitment purchase. type PurchaseResult struct { - Recommendation Recommendation `json:"recommendation"` - Success bool `json:"success"` - CommitmentID string `json:"commitment_id,omitempty"` - Error error `json:"error,omitempty"` + Timestamp time.Time `json:"timestamp"` + Error error `json:"error,omitempty"` + // Cost is the total upfront charge for the whole purchase (unit price x count). // Nil means unknown; a non-nil zero means no upfront charge. - Cost *float64 `json:"cost"` - DryRun bool `json:"dry_run"` - Timestamp time.Time `json:"timestamp"` + Cost *float64 `json:"cost"` + + CommitmentID string `json:"commitment_id,omitempty"` + Recommendation Recommendation `json:"recommendation"` + Success bool `json:"success"` + DryRun bool `json:"dry_run"` + // ExistingCommitment is true when the provider adopted a commitment that // already existed for the same idempotency token (a re-drive of an earlier // attempt) instead of creating a new one. Success stays true and @@ -399,12 +411,21 @@ const IdempotencyTagKey = "cudly-idempotency-token" // creates (as a tag, label, or — where the cloud API permits nothing else — // encoded in the commitment description). type PurchaseOptions struct { + // RequestedAt is the first-dispatch time of this purchase lineage, stable + // across re-drives of the same IdempotencyToken. Providers use it to bound + // idempotency lookups for services that have no client token (Redshift + // reserved nodes). A zero value means unknown; a provider that requires it + // must refuse the purchase rather than guess. + RequestedAt time.Time + Source string + // ReservationID, when set, is used as the provider-side commitment // identifier (e.g. RDS ReservedDBInstanceId) so purchased commitments // carry a descriptive, account/engine/region-aware name instead of a // generic auto-generated one. Providers sanitize it to their ID rules. ReservationID string + // IdempotencyToken, when non-empty, is a deterministic token (see // DeriveIdempotencyToken) that makes commitment creation idempotent across // re-drives of the same execution (issue #636). Savings Plans pass it as @@ -414,24 +435,20 @@ type PurchaseOptions struct { // purchase path, which has no owning execution, leaves it empty and keeps // its prior non-idempotent behavior). IdempotencyToken string + // ExecutionID, when non-empty, is the purchase_executions row UUID that // owns this purchase attempt. Carried so the purchase-execution flow can // emit log lines tagged with the execution ID for correlation with the // CloudWatch / DB execution row (issue #667). The CLI purchase path has // no owning execution and leaves it empty. ExecutionID string + // OfferingClass is the EC2 Reserved Instance offering class for this // purchase: "convertible" (exchangeable) or "standard" (locked, ~5% // cheaper). Empty means the caller has not set one; the EC2 client // defaults to "convertible" to preserve pre-694 behavior. // Only meaningful for EC2 RI purchases; ignored by other providers. OfferingClass string - // RequestedAt is the first-dispatch time of this purchase lineage, stable - // across re-drives of the same IdempotencyToken. Providers use it to bound - // idempotency lookups for services that have no client token (Redshift - // reserved nodes). A zero value means unknown; a provider that requires it - // must refuse the purchase rather than guess. - RequestedAt time.Time } // NormalizeSource lowercases s and returns it when it matches an allowed @@ -487,6 +504,8 @@ const ( // adjustments would silently miss because Recommendation lookup keys // are deployment-aware while commitment keys defaulted to empty. type Commitment struct { + StartDate time.Time `json:"start_date"` + EndDate time.Time `json:"end_date"` Provider ProviderType `json:"provider"` Account string `json:"account"` CommitmentID string `json:"commitment_id"` @@ -496,16 +515,7 @@ type Commitment struct { ResourceType string `json:"resource_type"` Engine string `json:"engine,omitempty"` // Database engine for RDS/ElastiCache (e.g., "mysql", "aurora-postgresql") Deployment string `json:"deployment,omitempty"` // RDS Multi-AZ vs Single-AZ; empty for non-RDS - Count int `json:"count"` - StartDate time.Time `json:"start_date"` - EndDate time.Time `json:"end_date"` State CommitmentState `json:"state"` - Cost float64 `json:"cost"` - - // StartDateIsDay marks StartDate as a UTC calendar date. The zero value - // means StartDate is a precise timestamp; true means the real instant - // lies in [StartDate, StartDate+24h). - StartDateIsDay bool `json:"start_date_is_day,omitempty"` // EC2 reserved-instance dimensions. Empty means unknown or a wildcard. // Filled by the EC2 commitment listing from the reserved instance's @@ -514,6 +524,14 @@ type Commitment struct { Tenancy string `json:"tenancy,omitempty"` Scope string `json:"scope,omitempty"` AvailabilityZone string `json:"availability_zone,omitempty"` + + Count int `json:"count"` + Cost float64 `json:"cost"` + + // StartDateIsDay marks StartDate as a UTC calendar date. The zero value + // means StartDate is a precise timestamp; true means the real instant + // lies in [StartDate, StartDate+24h). + StartDateIsDay bool `json:"start_date_is_day,omitempty"` } // OfferingDetails represents cloud provider offering details. @@ -530,11 +548,11 @@ type OfferingDetails struct { ResourceType string `json:"resource_type"` Term string `json:"term"` PaymentOption string `json:"payment_option"` + Currency string `json:"currency"` UpfrontCost float64 `json:"upfront_cost"` RecurringCost float64 `json:"recurring_cost"` TotalCost float64 `json:"total_cost"` EffectiveHourlyRate float64 `json:"effective_hourly_rate"` - Currency string `json:"currency"` } // RecommendationParams represents parameters for fetching recommendations. @@ -544,20 +562,25 @@ type RecommendationParams struct { LookbackPeriod string // 7d, 30d, 60d Term string // 1yr, 3yr PaymentOption string - AccountFilter []string - IncludeRegions []string - ExcludeRegions []string - // Savings Plans specific filters - IncludeSPTypes []string // Compute, EC2Instance, SageMaker, Database - ExcludeSPTypes []string + // SPAccountScope selects the Cost Explorer account scope for Savings Plans // recommendations: "linked" or "payer" (case-insensitive). Empty means // linked. Payer scope only applies to a management account. SPAccountScope string + // RIAccountScope selects the Cost Explorer account scope for Reserved // Instance recommendations, with the same values as SPAccountScope: // "linked" or "payer" (case-insensitive). Empty means linked. RIAccountScope string + + AccountFilter []string + IncludeRegions []string + ExcludeRegions []string + + // Savings Plans specific filters + IncludeSPTypes []string // Compute, EC2Instance, SageMaker, Database + ExcludeSPTypes []string + // ExperimentalGCPPlanTerm (GCP Compute Engine only, ignored elsewhere) // takes the CUD term from the recommendation's root Commitment plan // instead of Term. EXPERIMENTAL: the payload shape is unverified against a @@ -596,9 +619,9 @@ type ComputeDetails struct { Tenancy string `json:"tenancy"` // default, dedicated, host Scope string `json:"scope"` // regional, zonal AvailabilityZone string `json:"availability_zone,omitempty"` // required for zonal EC2 scope, empty for regional - VCPU int `json:"vcpu,omitempty"` // 0 = unknown - MemoryGB float64 `json:"memory_gb,omitempty"` // 0 = unknown GCPCommitmentType string `json:"gcp_commitment_type,omitempty"` + VCPU int `json:"vcpu,omitempty"` // 0 = unknown + MemoryGB float64 `json:"memory_gb,omitempty"` // 0 = unknown } func (d ComputeDetails) GetServiceType() ServiceType { @@ -655,8 +678,8 @@ func (d CacheDetails) GetDetailDescription() string { // SearchDetails represents search-specific details (OpenSearch, Azure Search). type SearchDetails struct { InstanceType string `json:"instance_type"` - MasterNodeCount int `json:"master_node_count,omitempty"` MasterNodeType string `json:"master_node_type,omitempty"` + MasterNodeCount int `json:"master_node_count,omitempty"` } func (d SearchDetails) GetServiceType() ServiceType { @@ -670,8 +693,8 @@ func (d SearchDetails) GetDetailDescription() string { // DataWarehouseDetails represents data warehouse-specific details (Redshift, Synapse, BigQuery). type DataWarehouseDetails struct { NodeType string `json:"node_type"` - NumberOfNodes int `json:"number_of_nodes"` ClusterType string `json:"cluster_type,omitempty"` // single-node, multi-node + NumberOfNodes int `json:"number_of_nodes"` } func (d DataWarehouseDetails) GetServiceType() ServiceType { @@ -709,13 +732,15 @@ func (d NoSQLDetails) GetDetailDescription() string { // SavingsPlanDetails represents AWS Savings Plans specific details. type SavingsPlanDetails struct { - PlanType string `json:"plan_type"` // Compute, EC2Instance, SageMaker - HourlyCommitment float64 `json:"hourly_commitment"` + PlanType string `json:"plan_type"` // Compute, EC2Instance, SageMaker + // Currency is the ISO 4217 code HourlyCommitment is denominated in. Azure // requires it to match the billing account currency and fails closed when // it is empty or malformed; empty is never treated as USD. Currency string `json:"currency,omitempty"` + Coverage string `json:"coverage,omitempty"` + // InstanceFamily is the EC2 instance family recommended by Cost Explorer // (e.g. "m5"). Only populated for EC2Instance Savings Plans; empty for // Compute, SageMaker and Database plans which are family-agnostic. Used at @@ -723,17 +748,21 @@ type SavingsPlanDetails struct { // so the offering resolves to the correct family rather than the // lexicographically-smallest across all families in the region. InstanceFamily string `json:"instance_family,omitempty"` + // Region is the AWS region recommended by Cost Explorer for this EC2Instance // Savings Plan (e.g. "us-east-1"). Only populated for EC2Instance plans; // Compute, SageMaker and Database plans are global and carry no region. // Used at purchase time as the region filter for DescribeSavingsPlansOfferings // instead of the client's configured region, which may differ. Region string `json:"region,omitempty"` + // OfferingID is the exact Savings Plans offering ID returned by Cost Explorer // in SavingsPlansDetails.OfferingId. When non-empty the purchase path uses it // directly and skips DescribeSavingsPlansOfferings entirely, which is the // safest possible resolution. Only populated when CE provides it. OfferingID string `json:"offering_id,omitempty"` + + HourlyCommitment float64 `json:"hourly_commitment"` } func (d SavingsPlanDetails) GetServiceType() ServiceType { @@ -766,22 +795,32 @@ func (d SavingsPlanDetails) GetDetailDescription() string { // UtilizationPercent <- derived: (TotalActualHours / PurchasedHours) * 100 // SKUName <- Properties.SKUName (Azure-only; empty string for AWS) type RIUtilization struct { - ReservedInstanceID string `json:"reserved_instance_id"` - UtilizationPercent float64 `json:"utilization_percent"` - PurchasedHours float64 `json:"purchased_hours"` - TotalActualHours float64 `json:"total_actual_hours"` - UnusedHours float64 `json:"unused_hours"` + ReservedInstanceID string `json:"reserved_instance_id"` + // SKUName is the Azure reservation SKU (e.g. "Standard_D2s_v3"). It is // empty for AWS RIs because Cost Explorer does not return the instance // type on the utilization response. Consumers that need it for Azure // can populate downstream display from this field; AWS consumers can // join on ReservedInstanceID via the EC2 DescribeReservedInstances API. SKUName string `json:"sku_name,omitempty"` + + UtilizationPercent float64 `json:"utilization_percent"` + PurchasedHours float64 `json:"purchased_hours"` + TotalActualHours float64 `json:"total_actual_hours"` + UnusedHours float64 `json:"unused_hours"` } // AuditRecord is one line in the JSON-lines audit log. // Status values: "success", "error", "skipped" (dry-run), "skipped_covered" (idempotency). type AuditRecord struct { + Timestamp time.Time `json:"timestamp"` + + // RecurringMonthlyCost is the recurring charge in USD per month (the part + // paid every month after any upfront payment), copied from + // Recommendation.RecurringMonthlyCost. nil (omitted) means the provider did + // not report it, which is not the same as a known 0 (all-upfront). + RecurringMonthlyCost *float64 `json:"recurring_monthly_cost,omitempty"` + RunID string `json:"run_id"` Provider ProviderType `json:"provider"` AccountID string `json:"account_id"` @@ -791,23 +830,19 @@ type AuditRecord struct { Service string `json:"service"` ResourceType string `json:"resource_type"` CommitmentType CommitmentType `json:"commitment_type"` - Term int `json:"term_months"` - Count int `json:"count"` - EstimatedCost float64 `json:"estimated_cost"` // Upfront payment (Recommendation.CommitmentCost); 0 for no-upfront commitments. - // RecurringMonthlyCost is the recurring charge in USD per month (the part - // paid every month after any upfront payment), copied from - // Recommendation.RecurringMonthlyCost. nil (omitted) means the provider did - // not report it, which is not the same as a known 0 (all-upfront). - RecurringMonthlyCost *float64 `json:"recurring_monthly_cost,omitempty"` - EstimatedSavings float64 `json:"estimated_savings"` - CommitmentID string `json:"commitment_id"` - Status string `json:"status"` - ErrorMessage string `json:"error_message"` - Timestamp time.Time `json:"timestamp"` - DryRun bool `json:"dry_run"` - RawRecommendation json.RawMessage `json:"raw_recommendation,omitempty"` + CommitmentID string `json:"commitment_id"` + Status string `json:"status"` + ErrorMessage string `json:"error_message"` + // Source identifies the CUDly surface (cudly-cli / cudly-web) that // triggered the purchase. Mirrors the value stamped on the commitment // itself via purchase-automation tag/label. Source string `json:"source,omitempty"` + + RawRecommendation json.RawMessage `json:"raw_recommendation,omitempty"` + Term int `json:"term_months"` + Count int `json:"count"` + EstimatedCost float64 `json:"estimated_cost"` // Upfront payment (Recommendation.CommitmentCost); 0 for no-upfront commitments. + EstimatedSavings float64 `json:"estimated_savings"` + DryRun bool `json:"dry_run"` } diff --git a/pkg/exchange/auto.go b/pkg/exchange/auto.go index 11fb4d70..09e6eb2a 100644 --- a/pkg/exchange/auto.go +++ b/pkg/exchange/auto.go @@ -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. @@ -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 @@ -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. @@ -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 diff --git a/pkg/exchange/exchange.go b/pkg/exchange/exchange.go index 3c94b3ab..85cd96ab 100644 --- a/pkg/exchange/exchange.go +++ b/pkg/exchange/exchange.go @@ -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) @@ -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. @@ -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. @@ -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. @@ -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. diff --git a/pkg/exchange/reshape.go b/pkg/exchange/reshape.go index e741d9cc..8efc0b21 100644 --- a/pkg/exchange/reshape.go +++ b/pkg/exchange/reshape.go @@ -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 @@ -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 @@ -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. @@ -59,9 +64,25 @@ 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 @@ -69,11 +90,7 @@ type OfferingOption struct { // 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. @@ -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 @@ -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 diff --git a/pkg/insurance/client.go b/pkg/insurance/client.go index 82cf7615..e11fa659 100644 --- a/pkg/insurance/client.go +++ b/pkg/insurance/client.go @@ -42,11 +42,12 @@ type Config struct { // header and a truncated message from the documented error shapes are kept; // the raw body is never echoed. type HTTPError struct { + Message string StatusCode int + // RetryAfter is the parsed Retry-After header, zero when absent or not a // number of seconds. The client never retries on its own. RetryAfter time.Duration - Message string } func (e *HTTPError) Error() string { @@ -63,13 +64,16 @@ func (e *HTTPError) Error() string { // field fmt or reflection can read, so no fmt verb prints it wherever the // Client (or a pointer to it) is stored. type Client struct { - baseURL string - orgID string - hc *http.Client + hc *http.Client + // setAuth adds the credential to one outgoing request. setAuth func(*http.Request) + // redact masks the credential in a vendor-supplied message. redact func(string) string + + baseURL string + orgID string } var _ QuoteClient = (*Client)(nil) @@ -275,8 +279,8 @@ func sanitizeVendorText(msg string, redact func(string) string) string { // the original in the Unwrap chain, so errors.Is/As (errMissing, read-size // and transport errors) still work. type sanitizedError struct { - msg string err error + msg string } func (e *sanitizedError) Error() string { return e.msg } diff --git a/pkg/insurance/line_items.go b/pkg/insurance/line_items.go index 333d65b4..a60c1aa8 100644 --- a/pkg/insurance/line_items.go +++ b/pkg/insurance/line_items.go @@ -16,30 +16,26 @@ import ( // ID, PlanID and OfferID keep the vendor's case; compare UUIDs case-insensitively // (strings.EqualFold) when joining to comparison line_item_id values. type LineItem struct { - ID, PlanID, OfferID string + Offer LineItemOffer + ContractSpec LineItemContractSpec AccountID *string - SelectedQuantity int64 SelectedCommitment *big.Rat - IsSelected bool + ID, PlanID, OfferID string ContractTerm string PaymentOption PaymentOption - ContractSpec LineItemContractSpec - Offer LineItemOffer + SelectedQuantity int64 + IsSelected bool } // LineItemContractSpec preserves the vendor's explicit terms for consistency checks. type LineItemContractSpec struct { - CommitmentType string `json:"commitment_type"` Term *string `json:"term"` PaymentOption *PaymentOption `json:"payment_option"` + CommitmentType string `json:"commitment_type"` } // LineItemOffer keeps provider and catalog offering identifiers distinct. type LineItemOffer struct { - ID string - ProviderOfferingID string - Type string - Provider common.ProviderType OfferingID *string Region *string AZ *string @@ -53,11 +49,15 @@ type LineItemOffer struct { DurationSeconds *big.Rat OfferingClass *string PaymentOption *PaymentOption + ID string + ProviderOfferingID string + Type string + Provider common.ProviderType } type jsonField struct { - name string out any + name string nullable bool } @@ -118,11 +118,11 @@ func decodeLineItem(m map[string]json.RawMessage, planID string) (LineItem, erro var payment string var offer, contract map[string]json.RawMessage err := readFields(m, - jsonField{"id", &li.ID, false}, jsonField{"plan_id", &li.PlanID, false}, jsonField{"offer_id", &li.OfferID, false}, - jsonField{"account_id", &li.AccountID, true}, jsonField{"selected_quantity", &li.SelectedQuantity, false}, - jsonField{"selected_commitment", &amount, false}, jsonField{"is_selected", &li.IsSelected, false}, - jsonField{"contract_term", &li.ContractTerm, false}, jsonField{"payment_option", &payment, false}, - jsonField{"offer", &offer, false}, jsonField{"contract_spec", &contract, false}) + jsonField{out: &li.ID, name: "id", nullable: false}, jsonField{out: &li.PlanID, name: "plan_id", nullable: false}, jsonField{out: &li.OfferID, name: "offer_id", nullable: false}, + jsonField{out: &li.AccountID, name: "account_id", nullable: true}, jsonField{out: &li.SelectedQuantity, name: "selected_quantity", nullable: false}, + jsonField{out: &amount, name: "selected_commitment", nullable: false}, jsonField{out: &li.IsSelected, name: "is_selected", nullable: false}, + jsonField{out: &li.ContractTerm, name: "contract_term", nullable: false}, jsonField{out: &payment, name: "payment_option", nullable: false}, + jsonField{out: &offer, name: "offer", nullable: false}, jsonField{out: &contract, name: "contract_spec", nullable: false}) if err != nil { return li, err } @@ -160,13 +160,13 @@ func decodeLineItemTerms(li *LineItem, payment string, contract map[string]json. } func decodeContractSpec(contract map[string]json.RawMessage, spec *LineItemContractSpec) error { - if err := readFields(contract, jsonField{"commitment_type", &spec.CommitmentType, false}); err != nil { + if err := readFields(contract, jsonField{out: &spec.CommitmentType, name: "commitment_type", nullable: false}); err != nil { return fmt.Errorf("contract_spec: %w", err) } if spec.CommitmentType == "" { return fmt.Errorf("contract_spec.commitment_type: %w", errMissing) } - for _, f := range []jsonField{{"term", &spec.Term, true}, {"payment_option", &spec.PaymentOption, true}} { + for _, f := range []jsonField{{out: &spec.Term, name: "term", nullable: true}, {out: &spec.PaymentOption, name: "payment_option", nullable: true}} { if _, ok := contract[f.name]; ok { if err := readFields(contract, f); err != nil { return fmt.Errorf("contract_spec: %w", err) @@ -188,13 +188,13 @@ func decodeLineItemOffer(m map[string]json.RawMessage, offerID string) (LineItem var o LineItemOffer var duration wireNum var provider string - err := readFields(m, jsonField{"id", &o.ID, false}, jsonField{"provider", &provider, false}, - jsonField{"type", &o.Type, false}, - jsonField{"provider_offering_id", &o.ProviderOfferingID, false}, jsonField{"duration_seconds", &duration, false}, - jsonField{"offering_id", &o.OfferingID, true}, jsonField{"region", &o.Region, true}, jsonField{"az", &o.AZ, true}, - jsonField{"instance_type", &o.InstanceType, true}, jsonField{"instance_family", &o.InstanceFamily, true}, - jsonField{"product_description", &o.ProductDescription, true}, jsonField{"tenancy", &o.Tenancy, true}, - jsonField{"plan_type", &o.PlanType, true}, jsonField{"is_multi_az", &o.IsMultiAZ, true}, jsonField{"is_flexible", &o.IsFlexible, true}) + err := readFields(m, jsonField{out: &o.ID, name: "id", nullable: false}, jsonField{out: &provider, name: "provider", nullable: false}, + jsonField{out: &o.Type, name: "type", nullable: false}, + jsonField{out: &o.ProviderOfferingID, name: "provider_offering_id", nullable: false}, jsonField{out: &duration, name: "duration_seconds", nullable: false}, + jsonField{out: &o.OfferingID, name: "offering_id", nullable: true}, jsonField{out: &o.Region, name: "region", nullable: true}, jsonField{out: &o.AZ, name: "az", nullable: true}, + jsonField{out: &o.InstanceType, name: "instance_type", nullable: true}, jsonField{out: &o.InstanceFamily, name: "instance_family", nullable: true}, + jsonField{out: &o.ProductDescription, name: "product_description", nullable: true}, jsonField{out: &o.Tenancy, name: "tenancy", nullable: true}, + jsonField{out: &o.PlanType, name: "plan_type", nullable: true}, jsonField{out: &o.IsMultiAZ, name: "is_multi_az", nullable: true}, jsonField{out: &o.IsFlexible, name: "is_flexible", nullable: true}) if err != nil { return o, fmt.Errorf("offer: %w", err) } @@ -222,7 +222,7 @@ func decodeLineItemOffer(m map[string]json.RawMessage, offerID string) (LineItem func decodeOfferEnums(m map[string]json.RawMessage, o *LineItemOffer) error { var payment *string - for _, f := range []jsonField{{"offering_class", &o.OfferingClass, true}, {"payment_option", &payment, true}} { + for _, f := range []jsonField{{out: &o.OfferingClass, name: "offering_class", nullable: true}, {out: &payment, name: "payment_option", nullable: true}} { if _, ok := m[f.name]; ok { if err := readFields(m, f); err != nil { return fmt.Errorf("offer: %w", err) diff --git a/pkg/insurance/line_items_client.go b/pkg/insurance/line_items_client.go index 4925dfbf..eb9c0af7 100644 --- a/pkg/insurance/line_items_client.go +++ b/pkg/insurance/line_items_client.go @@ -13,8 +13,8 @@ import ( const lineItemsPageSize int64 = 100 type lineItemsPagination struct { - Total, TotalPages, Page, FirstPage, LastPage int64 NextPage, PreviousPage *int64 + Total, TotalPages, Page, FirstPage, LastPage int64 } func decodePagination(header string, requested int64) (lineItemsPagination, error) { @@ -23,12 +23,12 @@ func decodePagination(header string, requested int64) (lineItemsPagination, erro if err := decodeOne(strings.NewReader(header), &m); err != nil { return p, errors.New("X-Pagination: invalid JSON metadata") } - err := readFields(m, jsonField{"total", &p.Total, false}, jsonField{"total_pages", &p.TotalPages, false}, - jsonField{"page", &p.Page, false}, jsonField{"first_page", &p.FirstPage, false}, jsonField{"last_page", &p.LastPage, false}) + err := readFields(m, jsonField{out: &p.Total, name: "total", nullable: false}, jsonField{out: &p.TotalPages, name: "total_pages", nullable: false}, + jsonField{out: &p.Page, name: "page", nullable: false}, jsonField{out: &p.FirstPage, name: "first_page", nullable: false}, jsonField{out: &p.LastPage, name: "last_page", nullable: false}) if err != nil { return p, fmt.Errorf("X-Pagination: %w", err) } - for _, f := range []jsonField{{"next_page", &p.NextPage, false}, {"previous_page", &p.PreviousPage, false}} { + for _, f := range []jsonField{{out: &p.NextPage, name: "next_page", nullable: false}, {out: &p.PreviousPage, name: "previous_page", nullable: false}} { if _, ok := m[f.name]; ok { if err = readFields(m, f); err != nil { return p, fmt.Errorf("X-Pagination: %w", err) diff --git a/pkg/insurance/quote.go b/pkg/insurance/quote.go index 4a5f32bd..0a85d902 100644 --- a/pkg/insurance/quote.go +++ b/pkg/insurance/quote.go @@ -173,11 +173,11 @@ type wireHypotheticalDelta struct { // wireHypothetical mirrors HypotheticalTotal. type wireHypothetical struct { ContractTerm *string `json:"contract_term"` - PaymentOption string `json:"payment_option"` Monthly *wireFinancials `json:"commitment_financials_monthly_rate"` - UpfrontCost wireNum `json:"commitment_upfront_cost"` Delta *wireHypotheticalDelta `json:"delta_vs_current"` LineItems *[]wireHypotheticalLineItem `json:"line_items"` + PaymentOption string `json:"payment_option"` + UpfrontCost wireNum `json:"commitment_upfront_cost"` } func (w wireHypothetical) toHypothetical() (Hypothetical, error) { @@ -196,9 +196,9 @@ func (w wireHypothetical) toHypothetical() (Hypothetical, error) { return h, fmt.Errorf("delta_vs_current: %w", errMissing) } if err := fillRats( - numField{"delta_vs_current.monthly_net_savings", w.Delta.MonthlyNetSavings, &h.DeltaMonthlyNetSavings, true}, - numField{"delta_vs_current.monthly_commitment_cost", w.Delta.MonthlyCommitmentCost, &h.DeltaMonthlyCommitmentCost, true}, - numField{"delta_vs_current.upfront_cost", w.Delta.UpfrontCost, &h.DeltaUpfrontCost, true}, + numField{out: &h.DeltaMonthlyNetSavings, name: "delta_vs_current.monthly_net_savings", in: w.Delta.MonthlyNetSavings, required: true}, + numField{out: &h.DeltaMonthlyCommitmentCost, name: "delta_vs_current.monthly_commitment_cost", in: w.Delta.MonthlyCommitmentCost, required: true}, + numField{out: &h.DeltaUpfrontCost, name: "delta_vs_current.upfront_cost", in: w.Delta.UpfrontCost, required: true}, ); err != nil { return h, err } @@ -218,23 +218,23 @@ func (w wireHypothetical) toHypothetical() (Hypothetical, error) { // wireOfferEntry mirrors the consumed subset of OfferComparisonEntry. type wireOfferEntry struct { - IsCurrent *bool `json:"is_current"` - OfferID string `json:"offer_id"` - SelectedAmount wireNum `json:"selected_amount"` - Offer *struct { + IsCurrent *bool `json:"is_current"` + Offer *struct { Provider *string `json:"provider"` Region *string `json:"region"` GuaranteedDisplayName *string `json:"guaranteed_display_name"` } `json:"offer"` LeaseMenuItemID *string `json:"lease_menu_item_id"` - CommitmentType string `json:"commitment_type"` ContractTerm *string `json:"contract_term"` PaymentOption *string `json:"payment_option"` + Monthly *wireFinancials `json:"commitment_financials_monthly_rate"` + Delta *wireOfferDelta `json:"delta_vs_current"` + OfferID string `json:"offer_id"` + SelectedAmount wireNum `json:"selected_amount"` + CommitmentType string `json:"commitment_type"` DiscountRate wireNum `json:"discount_rate"` BreakevenDays wireNum `json:"breakeven_days"` UpfrontCost wireNum `json:"commitment_upfront_cost"` - Monthly *wireFinancials `json:"commitment_financials_monthly_rate"` - Delta *wireOfferDelta `json:"delta_vs_current"` } // wireOfferDelta mirrors OfferComparisonDelta. @@ -294,13 +294,13 @@ func (w *wireOfferEntry) toOfferEntry() (OfferEntry, error) { return e, err } if err := fillRats( - numField{"discount_rate", w.DiscountRate, &e.DiscountRate, true}, - numField{"breakeven_days", w.BreakevenDays, &e.BreakevenDays, false}, - numField{"commitment_upfront_cost", w.UpfrontCost, &e.UpfrontCost, true}, - numField{"delta_vs_current.monthly_net_savings", w.Delta.MonthlyNetSavings, &e.Delta.MonthlyNetSavings, true}, - numField{"delta_vs_current.upfront_cost", w.Delta.UpfrontCost, &e.Delta.UpfrontCost, true}, - numField{"delta_vs_current.discount_rate", w.Delta.DiscountRate, &e.Delta.DiscountRate, true}, - numField{"delta_vs_current.breakeven_days", w.Delta.BreakevenDays, &e.Delta.BreakevenDays, false}, + numField{out: &e.DiscountRate, name: "discount_rate", in: w.DiscountRate, required: true}, + numField{out: &e.BreakevenDays, name: "breakeven_days", in: w.BreakevenDays, required: false}, + numField{out: &e.UpfrontCost, name: "commitment_upfront_cost", in: w.UpfrontCost, required: true}, + numField{out: &e.Delta.MonthlyNetSavings, name: "delta_vs_current.monthly_net_savings", in: w.Delta.MonthlyNetSavings, required: true}, + numField{out: &e.Delta.UpfrontCost, name: "delta_vs_current.upfront_cost", in: w.Delta.UpfrontCost, required: true}, + numField{out: &e.Delta.DiscountRate, name: "delta_vs_current.discount_rate", in: w.Delta.DiscountRate, required: true}, + numField{out: &e.Delta.BreakevenDays, name: "delta_vs_current.breakeven_days", in: w.Delta.BreakevenDays, required: false}, ); err != nil { return e, err } @@ -309,9 +309,9 @@ func (w *wireOfferEntry) toOfferEntry() (OfferEntry, error) { // wireRow mirrors LineItemOfferComparisonRow. type wireRow struct { - LineItemID string `json:"line_item_id"` Current *wireOfferEntry `json:"current"` Candidates *[]wireOfferEntry `json:"candidates"` + LineItemID string `json:"line_item_id"` } func (w wireRow) toRow() (ComparisonRow, error) { @@ -418,14 +418,14 @@ func DecodePlan(r io.Reader) (*Plan, error) { } p := &Plan{ID: wire.ID, Status: status, IsCalculating: *wire.IsCalculating} for _, f := range []struct { + out **big.Rat name string in wireNum - out **big.Rat }{ - {"fee_hourly", wire.FeeHourly, &p.FeeHourly}, - {"savings_hourly", wire.SavingsHourly, &p.SavingsHourly}, - {"commitment_coverage", wire.CommitmentCoverage, &p.CommitmentCoverage}, - {"monthly_savings", wire.MonthlySavings, &p.MonthlySavings}, + {&p.FeeHourly, "fee_hourly", wire.FeeHourly}, + {&p.SavingsHourly, "savings_hourly", wire.SavingsHourly}, + {&p.CommitmentCoverage, "commitment_coverage", wire.CommitmentCoverage}, + {&p.MonthlySavings, "monthly_savings", wire.MonthlySavings}, } { if *f.out, err = requiredRat(f.in); err != nil { return nil, fmt.Errorf("%s: %w", f.name, err) @@ -546,9 +546,9 @@ func nullableTerm(s *string) (*string, error) { // numField is one JSON number to convert into *big.Rat. A required field that // is absent or null is an error; an optional one becomes nil (unknown). type numField struct { + out **big.Rat name string in wireNum - out **big.Rat required bool } diff --git a/pkg/insurance/types.go b/pkg/insurance/types.go index 69ccf995..15a44d46 100644 --- a/pkg/insurance/types.go +++ b/pkg/insurance/types.go @@ -137,38 +137,52 @@ type OfferDelta struct { // OfferEntry mirrors the consumed subset of OfferComparisonEntry: one offer // (current or candidate) for a line item. type OfferEntry struct { - // IsCurrent is true when this entry matches the line item's current - // offer and lease. - IsCurrent bool - OfferID string + // Monthly holds the 730-hour monthly rate financials. + Monthly Financials + + Delta OfferDelta + // SelectedAmount is a count for RI/unit CUD, or $/hour for spend commitments. SelectedAmount *big.Rat - // CommitmentType is the vendor's offer type string. The docs give both - // "aws/AmazonEC2"-style and "ri"/"savings_plan"/"cud"-style examples; - // treat it as opaque display text. - CommitmentType string - Provider common.ProviderType + // ContractTerm is the effective lock-in term (lease lock-in when a lease // is attached). Nullable in the schema. ContractTerm *string + // PaymentOption is nullable in the schema. PaymentOption *PaymentOption + // LeaseMenuItemID is the attached Archera lease, nil for none. LeaseMenuItemID *string + // GuaranteedDisplayName is the offer name when purchased as an Archera // Guaranteed Commitment; nil when the vendor sends none. GuaranteedDisplayName *string + // Region is nil when the offer does not report one. Region *string + // DiscountRate is the discount versus on-demand on a 0-1 basis. DiscountRate *big.Rat + // BreakevenDays is nil when breakeven is undefined. BreakevenDays *big.Rat - // Monthly holds the 730-hour monthly rate financials. - Monthly Financials + // UpfrontCost is one-time dollars at signing, NOT a rate. UpfrontCost *big.Rat - Delta OfferDelta + + OfferID string + + // CommitmentType is the vendor's offer type string. The docs give both + // "aws/AmazonEC2"-style and "ri"/"savings_plan"/"cud"-style examples; + // treat it as opaque display text. + CommitmentType string + + Provider common.ProviderType + + // IsCurrent is true when this entry matches the line item's current + // offer and lease. + IsCurrent bool } // LeaseBacked reports whether an Archera lease is attached to this offer. The @@ -189,23 +203,29 @@ type ComparisonRow struct { // Vendor totals pass through verbatim with the premium already inside them. // It is a plan hypothetical, not a bindable quote. type Comparison struct { + // Current is the plan-wide rollup for the line items currently in scope. + Current Totals + + // FetchedAt records when the response was retrieved; there is no + // documented TTL, so staleness policy belongs to the consumer. + FetchedAt time.Time + + // Currency is always nil with the current API schemas: no currency field + // is documented. Consumers must render it as unknown. + Currency *string + OrgID string + // PlanID is an Archera plan identifier, not a local recommendation ID. PlanID string - // Current is the plan-wide rollup for the line items currently in scope. - Current Totals + // Hypotheticals holds one rollup per requested (or candidate-derived) // (contract term, payment option) combination. Hypotheticals []Hypothetical + // Rows holds per-line current and candidate offers, in vendor order // (by line item ID). Rows []ComparisonRow - // Currency is always nil with the current API schemas: no currency field - // is documented. Consumers must render it as unknown. - Currency *string - // FetchedAt records when the response was retrieved; there is no - // documented TTL, so staleness policy belongs to the consumer. - FetchedAt time.Time } // PlanStatus mirrors the documented CommitmentPlan.status enum. @@ -227,21 +247,26 @@ const ( // render them verbatim with their vendor field names and never convert them // (no hourly-to-monthly scaling, no percent conversion of CommitmentCoverage). type Plan struct { - ID string - Status PlanStatus - // IsCalculating is true while Archera recomputes the plan; consumers - // show a non-final state instead of treating the figures as current. - IsCalculating bool // FeeHourly is the vendor's fee_hourly, unit basis undocumented. FeeHourly *big.Rat + // SavingsHourly is the vendor's savings_hourly, unit basis undocumented. SavingsHourly *big.Rat + // CommitmentCoverage is the vendor's commitment_coverage; its scale // (0-1 or percent) is undocumented. It is native commitment coverage, // never insured coverage. CommitmentCoverage *big.Rat + // MonthlySavings is the vendor's monthly_savings, basis undocumented. MonthlySavings *big.Rat + + ID string + Status PlanStatus + + // IsCalculating is true while Archera recomputes the plan; consumers + // show a non-final state instead of treating the figures as current. + IsCalculating bool } // ComparisonRequest selects the line items, target terms, and payment options diff --git a/pkg/ladder/allocate.go b/pkg/ladder/allocate.go index da2436fa..e3ec56a6 100644 --- a/pkg/ladder/allocate.go +++ b/pkg/ladder/allocate.go @@ -63,7 +63,7 @@ func minAllocatableGap() *big.Rat { // logs identify the source of each decision. // // Now must be set by the caller; Allocate never calls time.Now internally. -type AllocationInput struct { +type AllocationInput struct { //nolint:govet // fieldalignment: file owned by open PR #441 Now time.Time LayerStates map[LayerType]LayerState Layers []LayerSpec diff --git a/pkg/ladder/capability.go b/pkg/ladder/capability.go index 01c0489a..e4a4b769 100644 --- a/pkg/ladder/capability.go +++ b/pkg/ladder/capability.go @@ -85,7 +85,7 @@ type LadderCapability interface { // pkg/exchange float64 anyway). Convert via ratFromFloat where exact math // is needed; NaN/Inf/non-positive values are rejected wherever these caps // are validated. -type BufferReshapeConfig struct { +type BufferReshapeConfig struct { //nolint:govet // fieldalignment: file owned by open PR #441 // MaxPaymentPerExchangeUSD caps the payment for a single exchange. nil // means no per-exchange cap is applied. MaxPaymentPerExchangeUSD *float64 diff --git a/pkg/logging/logger.go b/pkg/logging/logger.go index 3c272e89..75f41bc8 100644 --- a/pkg/logging/logger.go +++ b/pkg/logging/logger.go @@ -35,10 +35,10 @@ const ( // performed by every Debug/Info/Warn/Error call are race-free under the // concurrent fan-out, which logs from many goroutines. type Logger struct { - level atomic.Int32 logger *log.Logger - prefix string metadata map[string]interface{} + prefix string + level atomic.Int32 } // getLevel returns the logger's current level, read atomically. diff --git a/pkg/provider/interface.go b/pkg/provider/interface.go index 2a1fe25a..e6ae35f3 100644 --- a/pkg/provider/interface.go +++ b/pkg/provider/interface.go @@ -73,25 +73,6 @@ type Credentials interface { // Config represents configuration for a provider. type Config struct { - Name string - - // Deprecated: Profile is overloaded with provider-specific semantics - // (AWS: named profile, Azure: subscription ID, GCP: project ID). Prefer - // the typed AWSProfile/AzureSubscriptionID/GCPProjectID fields below; - // when both are set, the typed field wins. - Profile string - - // Typed per-provider identity fields. When set, these take precedence - // over Profile. Each provider only reads its own field and ignores the - // others, so a single Config can be reused across providers. - AWSProfile string // AWS named profile from ~/.aws/credentials or ~/.aws/config - AzureSubscriptionID string // Azure subscription ID (UUID) - GCPProjectID string // GCP project ID (e.g. "my-project") - - Region string - CredentialPath string - Endpoint string // For custom endpoints - // Optional pre-resolved credentials. The AWS slot is typed because this // module already depends on the AWS SDK core (see go.mod and the exchange // package); the Azure and GCP slots are typed as `any` to keep this module @@ -110,6 +91,25 @@ type Config struct { // going through the registry. Use this to inject a pre-built, pre-authenticated // provider when the typed credential slots above aren't expressive enough. ProviderOverride Provider + + Name string + + // Deprecated: Profile is overloaded with provider-specific semantics + // (AWS: named profile, Azure: subscription ID, GCP: project ID). Prefer + // the typed AWSProfile/AzureSubscriptionID/GCPProjectID fields below; + // when both are set, the typed field wins. + Profile string + + // Typed per-provider identity fields. When set, these take precedence + // over Profile. Each provider only reads its own field and ignores the + // others, so a single Config can be reused across providers. + AWSProfile string // AWS named profile from ~/.aws/credentials or ~/.aws/config + AzureSubscriptionID string // Azure subscription ID (UUID) + GCPProjectID string // GCP project ID (e.g. "my-project") + + Region string + CredentialPath string + Endpoint string // For custom endpoints } // ProviderConfig is the former name of Config, kept so external consumers keep diff --git a/pkg/reccontract/capabilities.go b/pkg/reccontract/capabilities.go index d8828dc2..18d115cc 100644 --- a/pkg/reccontract/capabilities.go +++ b/pkg/reccontract/capabilities.go @@ -10,13 +10,13 @@ import "github.com/LeanerCloud/cloud-commitments-go/pkg/common" // Capabilities declares the request values a provider can honor. type Capabilities struct { - Provider common.ProviderType - Terms []string // "1yr","3yr" - PaymentOptions []string // canonical payment tokens - Lookbacks []string // "7d","30d","60d"; empty when the provider has no lookback input - DefaultLookback string // filled into an empty request lookback; empty when the provider has none - AccountScopes []string // "linked","payer" - OfferingClasses []string // "standard","convertible" - SavingsPlanTypes bool // IncludeSPTypes/ExcludeSPTypes are meaningful Unavailable map[common.ServiceType]string // service -> reason it cannot be recommended via API + Provider common.ProviderType + DefaultLookback string // filled into an empty request lookback; empty when the provider has none + Terms []string // "1yr","3yr" + PaymentOptions []string // canonical payment tokens + Lookbacks []string // "7d","30d","60d"; empty when the provider has no lookback input + AccountScopes []string // "linked","payer" + OfferingClasses []string // "standard","convertible" + SavingsPlanTypes bool // IncludeSPTypes/ExcludeSPTypes are meaningful } diff --git a/pkg/recfilter/dedupe.go b/pkg/recfilter/dedupe.go index 07900f9f..5f1514c9 100644 --- a/pkg/recfilter/dedupe.go +++ b/pkg/recfilter/dedupe.go @@ -18,7 +18,7 @@ type recentCommitmentFilter interface { } // DuplicateChecker checks for existing commitments to avoid duplicates. -type DuplicateChecker struct { +type DuplicateChecker struct { //nolint:govet // fieldalignment: file owned by open PR #491 LookbackHours int // How many hours to look back for recent purchases // Logf receives the per-commitment decision trail. Nil is silent. diff --git a/pkg/recfilter/dedupe_ec2.go b/pkg/recfilter/dedupe_ec2.go index d2d9b884..6577338c 100644 --- a/pkg/recfilter/dedupe_ec2.go +++ b/pkg/recfilter/dedupe_ec2.go @@ -18,8 +18,8 @@ const ( // scopeState is the normalized scope of an EC2 commitment or recommendation. type scopeState struct { - kind scopeKind az string + kind scopeKind } func newScopeState(rawScope, az string) scopeState { diff --git a/pkg/retry/exponential.go b/pkg/retry/exponential.go index e46020c9..71cdc75d 100644 --- a/pkg/retry/exponential.go +++ b/pkg/retry/exponential.go @@ -25,14 +25,20 @@ import ( // Config bundles the exponential-backoff knobs. type Config struct { + // OnAttempt, if non-nil, is invoked once per attempt — see OnAttemptFn. + OnAttempt OnAttemptFn + // MaxAttempts is the total number of attempts (NOT retries). 1 = no // retry, 5 = first attempt + 4 retries. MaxAttempts int + // BaseDelay is the delay before the SECOND attempt; subsequent // delays double until MaxDelay caps them. BaseDelay time.Duration + // MaxDelay caps the per-iteration backoff, including any jitter. MaxDelay time.Duration + // PerAttemptTimeout, if > 0, wraps each op invocation in a // context.WithTimeout derived from the outer ctx, so a single hung // attempt fails fast and the retry budget continues, while outer @@ -40,14 +46,13 @@ type Config struct { // context passed to op; op must honor that ctx for an attempt to fail // fast. Zero disables. PerAttemptTimeout time.Duration + // Jitter, if true, adds ±25% noise to each backoff (the result is still // capped at MaxDelay) via math/rand/v2 // (lock-free per-goroutine source, no global RNG mutex contention // under concurrent retries). math/rand/v2 is sufficient for backoff // jitter; cryptographic randomness is not needed. Jitter bool - // OnAttempt, if non-nil, is invoked once per attempt — see OnAttemptFn. - OnAttempt OnAttemptFn } // OnAttemptFn is called BEFORE each attempt (after any backoff diff --git a/pkg/scorer/scorer.go b/pkg/scorer/scorer.go index 9937880e..df1ee4f2 100644 --- a/pkg/scorer/scorer.go +++ b/pkg/scorer/scorer.go @@ -14,11 +14,11 @@ import ( // Config controls which recommendations are allowed through the scorer. // Zero values mean "no filter" for numeric thresholds, except that MinSavingsPctSet makes a zero MinSavingsPct a real floor; empty slice means "all services". type Config struct { + EnabledServices []string // Empty = all services. E.g. ["ec2", "rds"]. MinSavingsPct float64 // Minimum savings percentage. 0 with MinSavingsPctSet false = no filter. - MinSavingsPctSet bool // True makes MinSavingsPct a real floor even at 0 (drops recs with negative savings). MaxBreakEvenMonths int // Maximum break-even months. 0 = no filter. No-upfront recs pass; recs whose break-even cannot be computed are filtered (an upfront payment option with no upfront price as FilterReasonPriceUnknown). MinCount int // Minimum count per recommendation. 0 = no filter. Does not apply to Savings Plans (dollar-denominated; their Count is always 1). - EnabledServices []string // Empty = all services. E.g. ["ec2", "rds"]. + MinSavingsPctSet bool // True makes MinSavingsPct a real floor even at 0 (drops recs with negative savings). } // Validate rejects a MinSavingsPct that is NaN, infinite or negative. Callers @@ -40,8 +40,8 @@ const FilterReasonPriceUnknown = "price-unknown" // FilteredRecommendation holds a recommendation that did not pass a filter, with the reason. type FilteredRecommendation struct { - Recommendation common.Recommendation FilterReason string + Recommendation common.Recommendation } // ScoredResult holds recommendations that passed the scorer (Passed) and those that did not (Filtered).