// Package ec2 provides AWS EC2 Reserved Instances client package ec2 import ( "context" "fmt" "log" "math" "sort" "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/LeanerCloud/cloud-commitments-go/pkg/common" "github.com/LeanerCloud/cloud-commitments-go/pkg/exchange" "github.com/LeanerCloud/cloud-commitments-go/pkg/retry" "github.com/LeanerCloud/cloud-commitments-go/providers/aws/internal/purchasecfg" ) // API defines the interface for EC2 operations (enables mocking). type API interface { PurchaseReservedInstancesOffering(ctx context.Context, params *ec2.PurchaseReservedInstancesOfferingInput, optFns ...func(*ec2.Options)) (*ec2.PurchaseReservedInstancesOfferingOutput, error) DescribeReservedInstancesOfferings(ctx context.Context, params *ec2.DescribeReservedInstancesOfferingsInput, optFns ...func(*ec2.Options)) (*ec2.DescribeReservedInstancesOfferingsOutput, error) DescribeReservedInstances(ctx context.Context, params *ec2.DescribeReservedInstancesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeReservedInstancesOutput, error) DescribeInstanceTypeOfferings(ctx context.Context, params *ec2.DescribeInstanceTypeOfferingsInput, optFns ...func(*ec2.Options)) (*ec2.DescribeInstanceTypeOfferingsOutput, error) 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) CreateTags(ctx context.Context, params *ec2.CreateTagsInput, optFns ...func(*ec2.Options)) (*ec2.CreateTagsOutput, error) CreateReservedInstancesListing(ctx context.Context, params *ec2.CreateReservedInstancesListingInput, optFns ...func(*ec2.Options)) (*ec2.CreateReservedInstancesListingOutput, error) DescribeReservedInstancesListings(ctx context.Context, params *ec2.DescribeReservedInstancesListingsInput, optFns ...func(*ec2.Options)) (*ec2.DescribeReservedInstancesListingsOutput, error) CancelReservedInstancesListing(ctx context.Context, params *ec2.CancelReservedInstancesListingInput, optFns ...func(*ec2.Options)) (*ec2.CancelReservedInstancesListingOutput, error) } // Client handles AWS EC2 Reserved Instances. type Client struct { client API region string } // NewClient creates a new EC2 client with purchase-path retry/timeout settings. // The tightened config (2 attempts, 15s HTTP timeout) bounds read-only calls to // 30s wall clock. The purchase call itself is sent exactly once (no ClientToken // exists, so a retry could double-buy); see PurchaseCommitment. func NewClient(cfg aws.Config) *Client { pcfg := purchasecfg.NewConfig(cfg) return &Client{ client: ec2.NewFromConfig(pcfg), region: cfg.Region, } } // SetEC2API sets a custom EC2 API client (for testing). func (c *Client) SetEC2API(api API) { c.client = api } // GetServiceType returns the service type. func (c *Client) GetServiceType() common.ServiceType { return common.ServiceCompute } // GetRegion returns the region. func (c *Client) GetRegion() string { return c.region } // GetRecommendations returns empty as EC2 uses centralized Cost Explorer recommendations. func (c *Client) GetRecommendations(_ context.Context, _ *common.RecommendationParams) ([]common.Recommendation, error) { // EC2 recommendations come from Cost Explorer API via RecommendationsClient return []common.Recommendation{}, nil } // GetExistingCommitments retrieves existing EC2 Reserved Instances, including // queued ones (scheduled future purchases) so the duplicate check sees them. func (c *Client) GetExistingCommitments(ctx context.Context) ([]common.Commitment, error) { commitments := make([]common.Commitment, 0) input := &ec2.DescribeReservedInstancesInput{ Filters: []types.Filter{ { Name: aws.String("state"), Values: []string{ string(types.ReservedInstanceStateActive), string(types.ReservedInstanceStatePaymentPending), string(types.ReservedInstanceStateQueued), }, }, }, } response, err := c.client.DescribeReservedInstances(ctx, input) if err != nil { return nil, fmt.Errorf("failed to describe reserved instances: %w", err) } for i := range response.ReservedInstances { ri := &response.ReservedInstances[i] commitment := common.Commitment{ Provider: common.ProviderAWS, CommitmentID: aws.ToString(ri.ReservedInstancesId), CommitmentType: common.CommitmentReservedInstance, Service: common.ServiceEC2, Region: c.region, ResourceType: string(ri.InstanceType), Count: int(aws.ToInt32(ri.InstanceCount)), State: common.CommitmentState(ri.State), StartDate: aws.ToTime(ri.Start), EndDate: aws.ToTime(ri.End), } commitments = append(commitments, commitment) } return commitments, nil } // PurchaseCommitment purchases an EC2 Reserved Instance. func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendation, opts common.PurchaseOptions) (common.PurchaseResult, error) { result := common.PurchaseResult{ Recommendation: rec, DryRun: false, Success: false, Timestamp: time.Now(), } // Idempotency dedupe guard (issue #636). EC2's // PurchaseReservedInstancesOfferingInput has no ClientToken, so the API // cannot dedupe a repeated purchase server-side. Instead, when an // idempotency token is supplied, look for an RI already tagged with it // before buying: if one exists, this is a re-drive of a purchase that // already succeeded, so short-circuit and return the existing RI rather // than buying a second one. if opts.IdempotencyToken != "" { existingID, found, lookupErr := c.findRIByIdempotencyToken(ctx, opts.IdempotencyToken) if lookupErr != nil { // A failed lookup must NOT fall through to a purchase: doing so // would defeat the guard and risk a double-buy on a re-drive. Fail // loudly so the recovery sweep treats it as not-yet-purchased and // retries the whole guarded path, rather than silently buying. result.Error = fmt.Errorf("idempotency lookup failed before EC2 RI purchase (refusing to purchase to avoid a possible double-buy): %w", lookupErr) return result, result.Error } if found { log.Printf("EC2 RI for idempotency token %s already exists (%s); skipping purchase (issue #636 re-drive)", common.MaskToken(opts.IdempotencyToken), existingID) result.Success = true result.ExistingCommitment = true result.CommitmentID = existingID return result, nil } } offering, err := c.findOffering(ctx, rec, opts.ExecutionID, opts.OfferingClass) if err != nil { result.Error = fmt.Errorf("failed to find offering: %w", err) return result, result.Error } // Create the purchase request input := &ec2.PurchaseReservedInstancesOfferingInput{ ReservedInstancesOfferingId: offering.ReservedInstancesOfferingId, InstanceCount: aws.Int32(int32(rec.Count)), // #nosec G115 -- Count from CE recommendation; AWS RI purchase limits keep this far below math.MaxInt32 } // Execute the purchase. A single attempt: the API has no ClientToken, so an // SDK retry after a lost response would buy a second RI (MON-02). response, err := c.client.PurchaseReservedInstancesOffering(ctx, input, func(o *ec2.Options) { o.RetryMaxAttempts = 1 }) if err != nil { result.Error = fmt.Errorf("failed to purchase EC2 RI: %w", purchasecfg.ClassifyPurchaseError(err)) return result, result.Error } // Extract purchase information if response.ReservedInstancesId != nil { result.Success = true result.CommitmentID = aws.ToString(response.ReservedInstancesId) // The purchase response carries no price; the offering's FixedPrice is per // instance, so the total upfront is FixedPrice x the purchased count. if offering.FixedPrice != nil { unitCents := math.Round(float64(*offering.FixedPrice) * 100) total := unitCents * float64(rec.Count) / 100 result.Cost = &total } } else { // A 200 without an ID: the buy most likely happened. result.Error = fmt.Errorf("purchase response was empty: %w", purchasecfg.ErrOutcomeUnknown) return result, result.Error } // PurchaseReservedInstancesOfferingInput has no TagSpecifications — tag the // commitment post-purchase. Failure is logged but does NOT fail the // purchase: the RI is already bought, and failing here would leave the // customer with a paid-for-but-untagged commitment and no way to retry // without double-purchasing. // // The idempotency-token tag (issue #636) rides this same CreateTags call. // It is load-bearing for the dedupe guard above: if this write fails the // guard degrades for that one RI (a re-drive would not find the tag and // could double-buy). That residual window is backstopped by the recovery // sweep's safe-fail + operator-confirm Retry (PR #635), since EC2 offers no // atomic alternative. The cosmetic tags and the idempotency tag share one // call so they cannot drift apart. if err := c.tagReservedInstance(ctx, result.CommitmentID, rec, opts.Source, opts.IdempotencyToken); err != nil { log.Printf("WARNING: failed to tag EC2 RI %s after purchase (commitment is bought; tag missing): %v", result.CommitmentID, err) } return result, nil } // findRIByIdempotencyToken looks for an active or payment-pending Reserved // Instance tagged with the given idempotency token (issue #636). It returns the // RI ID and true when exactly such an RI exists, so a re-driven purchase can // short-circuit instead of buying a second commitment. Retired/canceled RIs are // excluded (they carry the same state filter as GetExistingCommitments) so a // returned or expired commitment does not suppress a legitimate fresh purchase. func (c *Client) findRIByIdempotencyToken(ctx context.Context, token string) (riID string, found bool, err error) { input := &ec2.DescribeReservedInstancesInput{ Filters: []types.Filter{ { Name: aws.String("tag:" + common.IdempotencyTagKey), Values: []string{token}, }, { Name: aws.String("state"), Values: []string{"active", "payment-pending"}, }, }, } response, err := c.client.DescribeReservedInstances(ctx, input) if err != nil { return "", false, fmt.Errorf("failed to describe reserved instances for idempotency check: %w", err) } for i := range response.ReservedInstances { ri := &response.ReservedInstances[i] if ri.ReservedInstancesId != nil { return aws.ToString(ri.ReservedInstancesId), true, nil } } return "", false, nil } // tagReservedInstance applies the standard CUDly tag set (including the // purchase-automation attribution tag when source is non-empty) to an RI // that was just created by PurchaseReservedInstancesOffering. // // Retries up to 4 attempts (1s/2s/4s backoff) on InvalidReservationID.NotFound, // since AWS sometimes needs a couple of seconds before the RI ID is visible // to CreateTags. Non-NotFound errors short-circuit immediately. func (c *Client) tagReservedInstance(ctx context.Context, riID string, rec common.Recommendation, source, idempotencyToken string) error { // EC2 PurchaseReservedInstancesOfferingInput has no customer-supplied name or // ID field. A Name tag (issue #687) is the only way to make the RI // self-describing in the AWS console without cross-referencing CUDly's DB. // BuildReservationName produces the same rich {svc}-{region}-{sku}-{count}x- // {term}-{paymt}-{ts}-{rand} format used by the other AWS service clients. displayName := common.BuildReservationName(common.ReservationNameFields{ Service: "ec2", Region: rec.Region, ResourceType: rec.ResourceType, Count: rec.Count, Term: rec.Term, Payment: rec.PaymentOption, Now: time.Now(), }, "ec2-reserved-") tags := []types.Tag{ {Key: aws.String("Name"), Value: aws.String(displayName)}, {Key: aws.String("Purpose"), Value: aws.String("Reserved Instance Purchase")}, {Key: aws.String("ResourceType"), Value: aws.String(rec.ResourceType)}, {Key: aws.String("Region"), Value: aws.String(rec.Region)}, {Key: aws.String("Count"), Value: aws.String(fmt.Sprintf("%d", rec.Count))}, {Key: aws.String("Term"), Value: aws.String(rec.Term)}, {Key: aws.String("PaymentOption"), Value: aws.String(rec.PaymentOption)}, {Key: aws.String("PurchaseDate"), Value: aws.String(time.Now().Format("2006-01-02"))}, {Key: aws.String("Tool"), Value: aws.String("CUDly")}, } if source != "" { tags = append(tags, types.Tag{ Key: aws.String(common.PurchaseTagKey), Value: aws.String(source), }) } // The idempotency tag is what findRIByIdempotencyToken matches on for the // dedupe guard (issue #636); it must be written for a re-drive to recognize // this RI as already-purchased. if idempotencyToken != "" { tags = append(tags, types.Tag{ Key: aws.String(common.IdempotencyTagKey), Value: aws.String(idempotencyToken), }) } cfg := retry.Config{ MaxAttempts: 4, BaseDelay: time.Second, MaxDelay: 4 * time.Second, } return retry.Do(ctx, cfg, func(perAttemptCtx context.Context, _ int) error { _, err := c.client.CreateTags(perAttemptCtx, &ec2.CreateTagsInput{ Resources: []string{riID}, Tags: tags, }) if err == nil { return nil } // Only retry if the RI isn't yet visible. Anything else is a // permanent failure (permissions, bad tag shape, etc.). if strings.Contains(err.Error(), "InvalidReservationID.NotFound") { return err // retryable } return fmt.Errorf("%w: %w", retry.ErrPermanent, err) }) } // parseEC2Tenancy maps a tenancy value, including the legacy "shared" written // before fix #598, to the RI offering enum. Empty, "host" (no RI product) and // unknown values error instead of defaulting to shared tenancy. func parseEC2Tenancy(t string) (types.Tenancy, error) { switch strings.ToLower(t) { case "shared", string(types.TenancyDefault): return types.TenancyDefault, nil case string(types.TenancyDedicated): return types.TenancyDedicated, nil default: return "", fmt.Errorf("unsupported EC2 RI tenancy %q: must be %q or %q", t, types.TenancyDefault, types.TenancyDedicated) } } // parseEC2Scope maps a scope value, including the legacy lowercase and // hyphenated spellings written before fix #598, to the RI offering enum. // Empty and unknown values error instead of defaulting to regional scope. func parseEC2Scope(s string) (types.Scope, error) { switch strings.ToLower(s) { case "region": return types.ScopeRegional, nil case "availability-zone", "availability zone": return types.ScopeAvailabilityZone, nil default: return "", fmt.Errorf("unsupported EC2 RI scope %q: must be %q or %q", s, types.ScopeRegional, types.ScopeAvailabilityZone) } } // maxOfferingPages is the maximum number of DescribeReservedInstancesOfferings // pages to walk before giving up. At MaxResults=100 per page this caps the // search at 500 offerings. Exceeding the cap returns a diagnostic error instead // of timing out the Lambda budget (issue #688). const maxOfferingPages = 5 // defaultEC2Platform is the EC2 RI product-description value for Linux/UNIX instances. // Used as a fallback in exchange-package helpers (FindConvertibleOffering, // ListTargetOfferings) where callers may legitimately omit the platform; never // used as a silent fallback on the purchase path (see M2/M3 in // 19-hardcoded-fallbacks-aws.md). const defaultEC2Platform = "Linux/UNIX" // convertEC2PaymentOption maps a rec payment-option slug to the AWS // DescribeReservedInstancesOfferings OfferingType enum value. func convertEC2PaymentOption(option string) (types.OfferingTypeValues, error) { switch option { case "all-upfront": return types.OfferingTypeValuesAllUpfront, nil case "partial-upfront": return types.OfferingTypeValuesPartialUpfront, nil case "no-upfront": return types.OfferingTypeValuesNoUpfront, nil default: return "", fmt.Errorf("unsupported EC2 payment option: %s", option) } } // ec2OfferingQuery holds the typed lookup parameters for an EC2 RI offering. type ec2OfferingQuery struct { instanceType types.InstanceType productDesc types.RIProductDescription tenancy types.Tenancy scope types.Scope duration int64 wantOfferingType types.OfferingTypeValues offeringClass types.OfferingClassType } // resolveOfferingClassType maps the GlobalConfig string value to the AWS SDK // type. "convertible" and "" both produce OfferingClassTypeConvertible so the // absence of a stored value preserves the pre-694 default. Any other string // is an explicit error: the caller must not silently fall back, because a DB // typo would otherwise buy the wrong class (feedback_empty_string_vs_error.md). func resolveOfferingClassType(s string) (types.OfferingClassType, error) { switch s { case "", "convertible": return types.OfferingClassTypeConvertible, nil case "standard": return types.OfferingClassTypeStandard, nil default: return "", fmt.Errorf("unknown EC2 RI offering class %q: must be \"convertible\" or \"standard\"", s) } } // buildEC2OfferingQuery resolves the typed lookup parameters from a rec. // Returns an error when Platform, Tenancy or Scope is empty or unrecognized: // on the purchase path the CE parser always populates them from the // recommendation payload, so an empty value signals a malformed rec rather // than a value that should be fabricated (M2/M3 fix, see 19-hardcoded-fallbacks-aws.md). func buildEC2OfferingQuery(rec common.Recommendation, details *common.ComputeDetails, duration int64) (ec2OfferingQuery, error) { if details.Platform == "" { return ec2OfferingQuery{}, fmt.Errorf( "EC2 recommendation for %s is missing Platform: "+ "refusing to fabricate a product-description for the RI offering lookup", rec.ResourceType, ) } tenancy, err := parseEC2Tenancy(details.Tenancy) if err != nil { return ec2OfferingQuery{}, fmt.Errorf("EC2 recommendation for %s: %w", rec.ResourceType, err) } scope, err := parseEC2Scope(details.Scope) if err != nil { return ec2OfferingQuery{}, fmt.Errorf("EC2 recommendation for %s: %w", rec.ResourceType, err) } return ec2OfferingQuery{ instanceType: types.InstanceType(rec.ResourceType), productDesc: types.RIProductDescription(details.Platform), tenancy: tenancy, scope: scope, duration: duration, }, nil } // describeInputFromQuery builds the SDK request struct for one page of the // typed lookup. Typed fields land on AWS's primary indices; only scope has no // typed equivalent and stays in Filters[]. func describeInputFromQuery(q ec2OfferingQuery, nextToken *string) *ec2.DescribeReservedInstancesOfferingsInput { return &ec2.DescribeReservedInstancesOfferingsInput{ InstanceType: q.instanceType, ProductDescription: q.productDesc, InstanceTenancy: q.tenancy, MinDuration: aws.Int64(q.duration), MaxDuration: aws.Int64(q.duration), OfferingClass: q.offeringClass, OfferingType: q.wantOfferingType, IncludeMarketplace: aws.Bool(false), MaxResults: aws.Int32(100), NextToken: nextToken, Filters: []types.Filter{ {Name: aws.String("scope"), Values: []string{string(q.scope)}}, }, } } // buildEC2QueryFromRec extracts ComputeDetails from rec, converts the payment // option, and assembles the ec2OfferingQuery used by findOffering. // // Pulled out of findOffering to keep that function under the cyclomatic limit. func (c *Client) buildEC2QueryFromRec(rec common.Recommendation) (ec2OfferingQuery, error) { details, ok := rec.Details.(*common.ComputeDetails) if !ok || details == nil { return ec2OfferingQuery{}, fmt.Errorf("invalid service details for EC2") } wantOfferingType, err := convertEC2PaymentOption(rec.PaymentOption) if err != nil { return ec2OfferingQuery{}, err } duration, err := c.getDurationValue(rec.Term) if err != nil { return ec2OfferingQuery{}, err } q, err := buildEC2OfferingQuery(rec, details, duration) if err != nil { return ec2OfferingQuery{}, err } q.wantOfferingType = wantOfferingType return q, nil } // findOffering finds the appropriate EC2 Reserved Instance offering. // // The input is built from typed first-class fields on // DescribeReservedInstancesOfferingsInput (InstanceType, ProductDescription, // InstanceTenancy, MinDuration/MaxDuration, OfferingClass, OfferingType) // rather than packing everything into Filters[]. The typed shape was verified // against live AWS to return the exact matching offering immediately; the // Filter[]-heavy shape caused AWS to return empty pages with NextToken on // sparse offering sets, walking until the Lambda budget expired (issue #688). // Only scope has no typed equivalent on the input struct, so it stays in Filters[]. // // execID is the purchase execution UUID for log correlation; pass "" when // calling outside of a purchase flow (ValidateOffering, GetOfferingDetails). // offeringClassStr is the GlobalConfig.OfferingClass value; "" is treated as // "convertible" to preserve pre-694 behavior. Unknown values fail loudly. func (c *Client) findOffering(ctx context.Context, rec common.Recommendation, execID, offeringClassStr string) (*types.ReservedInstancesOffering, error) { q, err := c.buildEC2QueryFromRec(rec) if err != nil { return nil, err } oc, err := resolveOfferingClassType(offeringClassStr) if err != nil { return nil, fmt.Errorf("offering class config error: %w", err) } q.offeringClass = oc tag := execID if tag == "" { tag = "no-exec" } t0 := time.Now() log.Printf("purchase[%s]: EC2 findOffering starting (instance=%s platform=%s tenancy=%s term=%s payment=%s)", tag, rec.ResourceType, q.productDesc, q.tenancy, rec.Term, rec.PaymentOption) var nextToken *string page := 0 for { if err := ctx.Err(); err != nil { return nil, err } page++ if page > maxOfferingPages { return nil, fmt.Errorf("pagination cap reached after %d pages for EC2 %s %s %s (issue #688)", maxOfferingPages, rec.ResourceType, q.productDesc, rec.PaymentOption) } pageStart := time.Now() result, err := c.client.DescribeReservedInstancesOfferings(ctx, describeInputFromQuery(q, nextToken)) if err != nil { log.Printf("purchase[%s]: EC2 findOffering page %d failed after %s (total %s): %v", tag, page, time.Since(pageStart), time.Since(t0), err) return nil, fmt.Errorf("failed to describe offerings: %w", err) } log.Printf("purchase[%s]: EC2 findOffering page %d: %d offerings in %s", tag, page, len(result.ReservedInstancesOfferings), time.Since(pageStart)) if o := scanEC2OfferingPage(result.ReservedInstancesOfferings, q.wantOfferingType); o != nil { log.Printf("purchase[%s]: EC2 findOffering found match on page %d after %s total", tag, page, time.Since(t0)) return o, nil } if isLastEC2Page(result.NextToken) { break } nextToken = result.NextToken } log.Printf("purchase[%s]: EC2 findOffering exhausted %d page(s) in %s -- no match", tag, page, time.Since(t0)) return nil, fmt.Errorf("no offerings found for EC2 %s %s %s after %d page(s) (issue #688)", rec.ResourceType, q.productDesc, rec.PaymentOption, page) } // isLastEC2Page reports whether a NextToken indicates the terminal page. // The AWS SDK may return either nil or a pointer to an empty string for the // last page; both must end pagination so the loop does not issue a redundant // request (and risk a false page-cap error on borderline page counts). func isLastEC2Page(nextToken *string) bool { return nextToken == nil || aws.ToString(nextToken) == "" } // scanEC2OfferingPage returns the first offering whose OfferingType matches // wantType, or nil. With the typed OfferingType field set on the request this should // always be the first offering, but the check is kept as defense in depth. // Mismatched offerings are skipped (logged), not treated as errors -- a // mismatch indicates an API-side anomaly worth observing, not a reason to fail // the rec while a valid offering may still be on a later page. func scanEC2OfferingPage(offerings []types.ReservedInstancesOffering, wantType types.OfferingTypeValues) *types.ReservedInstancesOffering { for i := range offerings { o := &offerings[i] if o.OfferingType != wantType { log.Printf("EC2 findOffering skipping mismatched variant %s (got %q want %q)", aws.ToString(o.ReservedInstancesOfferingId), o.OfferingType, wantType) continue } return o } return nil } // ValidateOffering checks if an offering exists without purchasing. // Uses the convertible class (empty = convertible default) since the // validation path has no GlobalConfig context. func (c *Client) ValidateOffering(ctx context.Context, rec common.Recommendation) error { _, err := c.findOffering(ctx, rec, "", "") return err } // GetOfferingDetails retrieves offering details. // Uses the convertible class (empty = convertible default) since the // details-fetch path has no GlobalConfig context. func (c *Client) GetOfferingDetails(ctx context.Context, rec common.Recommendation) (*common.OfferingDetails, error) { match, err := c.findOffering(ctx, rec, "", "") if err != nil { return nil, err } offeringID := aws.ToString(match.ReservedInstancesOfferingId) input := &ec2.DescribeReservedInstancesOfferingsInput{ ReservedInstancesOfferingIds: []string{offeringID}, } result, err := c.client.DescribeReservedInstancesOfferings(ctx, input) if err != nil { return nil, fmt.Errorf("failed to get offering details: %w", err) } if len(result.ReservedInstancesOfferings) == 0 { return nil, fmt.Errorf("offering not found: %s", offeringID) } offering := result.ReservedInstancesOfferings[0] // Extract fixed price from pricing details var fixedPrice float64 for _, pricing := range offering.PricingDetails { if pricing.Price != nil { fixedPrice = *pricing.Price break } } details := &common.OfferingDetails{ OfferingID: aws.ToString(offering.ReservedInstancesOfferingId), ResourceType: string(offering.InstanceType), Term: rec.Term, PaymentOption: string(offering.OfferingType), UpfrontCost: fixedPrice, RecurringCost: float64(aws.ToFloat32(offering.UsagePrice)), Currency: string(offering.CurrencyCode), } return details, nil } // GetValidResourceTypes returns valid EC2 instance types. func (c *Client) GetValidResourceTypes(ctx context.Context) ([]string, error) { instanceTypesMap := make(map[string]bool) var nextToken *string for { input := &ec2.DescribeInstanceTypeOfferingsInput{ LocationType: types.LocationTypeRegion, NextToken: nextToken, MaxResults: aws.Int32(1000), } result, err := c.client.DescribeInstanceTypeOfferings(ctx, input) if err != nil { return nil, fmt.Errorf("failed to describe EC2 instance type offerings: %w", err) } for _, offering := range result.InstanceTypeOfferings { instanceTypesMap[string(offering.InstanceType)] = true } if result.NextToken == nil || aws.ToString(result.NextToken) == "" { break } nextToken = result.NextToken } instanceTypes := make([]string, 0, len(instanceTypesMap)) for instanceType := range instanceTypesMap { instanceTypes = append(instanceTypes, instanceType) } sort.Strings(instanceTypes) return instanceTypes, nil } // Duration constants for RI term calculations. const ( OneYearSeconds = purchasecfg.OneYearSeconds // 365 days in seconds ThreeYearSeconds = purchasecfg.ThreeYearSeconds // 3 * 365 days in seconds ) // getDurationValue converts a term string to seconds for the EC2 API, // failing loud on any unrecognized or empty input. The parsing logic lives // in purchasecfg; this wrapper only supplies the service name for the error // message. func (c *Client) getDurationValue(term string) (int64, error) { return purchasecfg.DurationSeconds(term, "EC2") } // ConvertibleRI represents an active convertible Reserved Instance. type ConvertibleRI struct { ReservedInstanceID string `json:"reserved_instance_id"` InstanceType string `json:"instance_type"` AvailabilityZone string `json:"availability_zone"` InstanceCount int32 `json:"instance_count"` Start time.Time `json:"start"` End time.Time `json:"end"` OfferingType string `json:"offering_type"` FixedPrice float64 `json:"fixed_price"` UsagePrice float64 `json:"usage_price"` State string `json:"state"` NormalizationFactor float64 `json:"normalization_factor"` ProductDescription string `json:"product_description"` InstanceTenancy string `json:"instance_tenancy"` Scope string `json:"scope"` Duration int64 `json:"duration"` // CurrencyCode is the ISO-4217 currency that FixedPrice / UsagePrice // are denominated in (typically "USD"). Plumbed through to // exchange.RIInfo so the dollar-units pre-filter on cross-family // alternatives can refuse comparisons across currencies. CurrencyCode string `json:"currency_code,omitempty"` // RecurringHourlyAmount is the per-hour recurring charge (sum across // the SDK's RecurringCharges slice) — this is non-zero for // no-upfront and partial-upfront RIs where some of the cost is // billed hourly rather than as the upfront FixedPrice. Used by the // exchange-package dollar-units calculation. RecurringHourlyAmount float64 `json:"recurring_hourly_amount,omitempty"` } // ListConvertibleReservedInstances returns all active convertible RIs in the region. func (c *Client) ListConvertibleReservedInstances(ctx context.Context) ([]ConvertibleRI, error) { input := &ec2.DescribeReservedInstancesInput{ Filters: []types.Filter{ { Name: aws.String("state"), Values: []string{"active"}, }, { Name: aws.String("offering-class"), Values: []string{string(types.OfferingClassTypeConvertible)}, }, }, } resp, err := c.client.DescribeReservedInstances(ctx, input) if err != nil { return nil, fmt.Errorf("failed to describe convertible reserved instances: %w", err) } result := make([]ConvertibleRI, 0, len(resp.ReservedInstances)) for i := range resp.ReservedInstances { ri := &resp.ReservedInstances[i] instanceType := string(ri.InstanceType) normFactor := normalizationFactorForInstanceType(instanceType) var recurringHourly float64 for _, rc := range ri.RecurringCharges { recurringHourly += aws.ToFloat64(rc.Amount) } result = append(result, ConvertibleRI{ ReservedInstanceID: aws.ToString(ri.ReservedInstancesId), InstanceType: instanceType, AvailabilityZone: aws.ToString(ri.AvailabilityZone), InstanceCount: aws.ToInt32(ri.InstanceCount), Start: aws.ToTime(ri.Start), End: aws.ToTime(ri.End), OfferingType: string(ri.OfferingType), FixedPrice: float64(aws.ToFloat32(ri.FixedPrice)), UsagePrice: float64(aws.ToFloat32(ri.UsagePrice)), State: string(ri.State), NormalizationFactor: normFactor, ProductDescription: string(ri.ProductDescription), InstanceTenancy: string(ri.InstanceTenancy), Scope: string(ri.Scope), Duration: aws.ToInt64(ri.Duration), CurrencyCode: string(ri.CurrencyCode), RecurringHourlyAmount: recurringHourly, }) } return result, nil } // FindConvertibleOfferingParams holds the parameters for finding a convertible RI offering. type FindConvertibleOfferingParams struct { InstanceType string ProductDescription string Tenancy string Scope string Duration int64 } // FindConvertibleOffering finds a convertible RI offering ID for the given parameters. func (c *Client) FindConvertibleOffering(ctx context.Context, params FindConvertibleOfferingParams) (string, error) { tenancy, err := parseEC2Tenancy(params.Tenancy) if err != nil { return "", err } scope, err := parseEC2Scope(params.Scope) if err != nil { return "", err } duration := params.Duration if duration == 0 { duration = OneYearSeconds } productDesc := params.ProductDescription if productDesc == "" { productDesc = defaultEC2Platform } filters := []types.Filter{ {Name: aws.String("instance-type"), Values: []string{params.InstanceType}}, {Name: aws.String("product-description"), Values: []string{productDesc}}, {Name: aws.String("scope"), Values: []string{string(scope)}}, {Name: aws.String("duration"), Values: []string{fmt.Sprintf("%d", duration)}}, } input := &ec2.DescribeReservedInstancesOfferingsInput{ Filters: filters, InstanceTenancy: tenancy, OfferingClass: types.OfferingClassTypeConvertible, IncludeMarketplace: aws.Bool(false), MaxResults: aws.Int32(20), } result, err := c.client.DescribeReservedInstancesOfferings(ctx, input) if err != nil { return "", fmt.Errorf("failed to describe convertible offerings: %w", err) } if len(result.ReservedInstancesOfferings) == 0 { return "", fmt.Errorf("no convertible offering found for %s (%s, %s, %s)", params.InstanceType, productDesc, tenancy, scope) } return aws.ToString(result.ReservedInstancesOfferings[0].ReservedInstancesOfferingId), nil } // TargetOffering is one valid target offering for a convertible RI exchange. type TargetOffering struct { OfferingID string `json:"offering_id"` InstanceType string `json:"instance_type"` OfferingType string `json:"offering_type"` ProductDescription string `json:"product_description"` Duration int64 `json:"duration"` FixedPrice float64 `json:"fixed_price"` UsagePrice float64 `json:"usage_price"` CurrencyCode string `json:"currency_code"` Scope string `json:"scope"` NormalizationFactor float64 `json:"normalization_factor"` } // ListTargetOfferingsParams holds the source RI attributes used to narrow the // DescribeReservedInstancesOfferings query to valid exchange targets. type ListTargetOfferingsParams struct { ProductDescription string Tenancy string Scope string Duration int64 OfferingType string } // maxTargetOfferingPages caps the pagination walk for ListTargetOfferings. // At 100 results per page this allows up to 1000 offerings -- more than // enough given the small number of convertible instance types AWS offers. const maxTargetOfferingPages = 10 // normalizeTargetOfferingsParams fills defaults for a zero Duration or // ProductDescription and converts the OfferingType string to the AWS SDK enum. // Extracted from ListTargetOfferings to keep the main function's cyclomatic // complexity under the project threshold of 10. func normalizeTargetOfferingsParams(p ListTargetOfferingsParams) (productDesc string, duration int64, offeringType types.OfferingTypeValues) { duration = p.Duration if duration == 0 { duration = OneYearSeconds } productDesc = p.ProductDescription if productDesc == "" { productDesc = defaultEC2Platform } // OfferingType: typed field when non-empty; empty string means "all // payment options" (the caller didn't specify). Leave unset so AWS // returns all payment variants, giving the user maximum choice. if p.OfferingType != "" { if ot, err := convertEC2PaymentOption(p.OfferingType); err == nil { offeringType = ot } } return } // appendTargetOfferings maps the SDK result page into TargetOffering values // and appends them to out. Extracted from ListTargetOfferings to keep it // under the gocyclo threshold. func appendTargetOfferings(out []TargetOffering, offerings []types.ReservedInstancesOffering, scope string) []TargetOffering { for i := range offerings { o := &offerings[i] id := aws.ToString(o.ReservedInstancesOfferingId) if id == "" { continue } instanceType := string(o.InstanceType) var size string if parts := strings.SplitN(instanceType, ".", 2); len(parts) == 2 { size = parts[1] } out = append(out, TargetOffering{ OfferingID: id, InstanceType: instanceType, OfferingType: string(o.OfferingType), ProductDescription: string(o.ProductDescription), Duration: aws.ToInt64(o.Duration), FixedPrice: float64(aws.ToFloat32(o.FixedPrice)), UsagePrice: float64(aws.ToFloat32(o.UsagePrice)), CurrencyCode: string(o.CurrencyCode), Scope: scope, NormalizationFactor: exchange.NormalizationFactorForSize(size), }) } return out } // ListTargetOfferings returns convertible RI offerings that are valid exchange // targets for a source RI described by params. The query uses the same // typed-field approach as findOffering / describeInputFromQuery (PR #690): // typed primary fields (OfferingClass, OfferingType, Duration, ProductDescription, // InstanceTenancy) instead of Filters[]-heavy style to avoid the empty-page // pagination bug documented in issue #688. Scope has no typed field and stays // in Filters[] as before. // // InstanceType is intentionally left unset so AWS returns all instance types // matching the other constraints -- that is exactly the "valid targets" set for // a convertible RI exchange. func (c *Client) ListTargetOfferings(ctx context.Context, params ListTargetOfferingsParams) ([]TargetOffering, error) { tenancy, err := parseEC2Tenancy(params.Tenancy) if err != nil { return nil, err } scope, err := parseEC2Scope(params.Scope) if err != nil { return nil, err } productDesc, duration, offeringType := normalizeTargetOfferingsParams(params) var out []TargetOffering var nextToken *string for page := 1; page <= maxTargetOfferingPages; page++ { if err := ctx.Err(); err != nil { return nil, err } input := &ec2.DescribeReservedInstancesOfferingsInput{ ProductDescription: types.RIProductDescription(productDesc), InstanceTenancy: tenancy, MinDuration: aws.Int64(duration), MaxDuration: aws.Int64(duration), OfferingClass: types.OfferingClassTypeConvertible, OfferingType: offeringType, IncludeMarketplace: aws.Bool(false), MaxResults: aws.Int32(100), NextToken: nextToken, Filters: []types.Filter{{Name: aws.String("scope"), Values: []string{string(scope)}}}, } result, err := c.client.DescribeReservedInstancesOfferings(ctx, input) if err != nil { return nil, fmt.Errorf("describe target offerings: %w", err) } out = appendTargetOfferings(out, result.ReservedInstancesOfferings, string(scope)) if isLastEC2Page(result.NextToken) { break } nextToken = result.NextToken if page == maxTargetOfferingPages { // Return what we collected: the picker is best-effort and // partial results are still useful to the user. log.Printf("ListTargetOfferings: pagination cap (%d pages) reached, returning %d offerings", maxTargetOfferingPages, len(out)) } } return out, nil } // normalizationFactorForInstanceType extracts the size from an instance type // (e.g., "m5.xlarge" -> "xlarge") and returns the AWS normalization factor. func normalizationFactorForInstanceType(instanceType string) float64 { parts := strings.SplitN(instanceType, ".", 2) if len(parts) != 2 { return 0 } return exchange.NormalizationFactorForSize(parts[1]) } // MarketplaceListingRequest carries the parameters for // CreateMarketplaceListing. PriceSchedule is required by the AWS API; a // nil/empty slice is rejected before the outbound call is made. type MarketplaceListingRequest struct { // ReservedInstancesID is the AWS ReservedInstancesId to list. ReservedInstancesID string // ClientToken is a caller-supplied idempotency token (UUID). AWS dedupes // CreateReservedInstancesListing calls sharing the same token for the same // RI within a short window. ClientToken string //nolint:gosec // G117 false positive: an idempotency token (UUID), not a credential; matches the configured G101 secret-name pattern only because it contains "token" // PriceSchedule is the per-month price schedule. At least one entry is // required. Each entry specifies how many months the price applies and the // list price per unit. PriceSchedule []MarketplacePriceTier // InstanceCount is the number of Reserved Instances to list. A purchase // row of N RIs must list all N; this mirrors purchase_history.count. Values // <= 0 are rejected before the outbound call so a misconfigured caller can // never silently list a single RI from a multi-count row. InstanceCount int32 } // MarketplacePriceTier represents one tier of the AWS RI Marketplace price // schedule. Term is the number of months this price applies, counting down // from the remaining RI term. Price is the per-unit listing price in USD. type MarketplacePriceTier struct { // Term is the remaining-months count this tier covers. Term int64 // Price is the USD list price per unit for this tier. Price float64 } // MarketplaceListingResult is returned by CreateMarketplaceListing and // DescribeMarketplaceListing. type MarketplaceListingResult struct { // ListingID is the AWS ReservedInstancesListingId. ListingID string // State is the AWS listing status, preserved verbatim from types.ListingStatus. State string } // CreateMarketplaceListing calls ec2:CreateReservedInstancesListing. // Only Standard (not Convertible) RIs can be listed; AWS rejects requests // for Convertible RIs with an explicit API error — the caller should gate on // offering_class == "standard" before calling this to surface a cleaner error. // AWS also requires the seller account to have a US bank account on file; // that precondition is checked in the API handler so the UI can show a // tailored message before making the API call. func (c *Client) CreateMarketplaceListing(ctx context.Context, req MarketplaceListingRequest) (MarketplaceListingResult, error) { if len(req.PriceSchedule) == 0 { return MarketplaceListingResult{}, fmt.Errorf("price schedule must have at least one tier") } if req.ReservedInstancesID == "" { return MarketplaceListingResult{}, fmt.Errorf("reserved instances ID is required") } if req.InstanceCount <= 0 { return MarketplaceListingResult{}, fmt.Errorf("instance count must be a positive integer, got %d", req.InstanceCount) } awsSchedule := make([]types.PriceScheduleSpecification, 0, len(req.PriceSchedule)) for _, tier := range req.PriceSchedule { tier := tier // capture loop var awsSchedule = append(awsSchedule, types.PriceScheduleSpecification{ Term: aws.Int64(tier.Term), Price: aws.Float64(tier.Price), CurrencyCode: types.CurrencyCodeValuesUsd, }) } input := &ec2.CreateReservedInstancesListingInput{ ReservedInstancesId: aws.String(req.ReservedInstancesID), ClientToken: aws.String(req.ClientToken), InstanceCount: aws.Int32(req.InstanceCount), PriceSchedules: awsSchedule, } out, err := c.client.CreateReservedInstancesListing(ctx, input) if err != nil { return MarketplaceListingResult{}, fmt.Errorf("CreateReservedInstancesListing failed: %w", err) } if len(out.ReservedInstancesListings) == 0 { return MarketplaceListingResult{}, fmt.Errorf("CreateReservedInstancesListing returned empty response") } listing := out.ReservedInstancesListings[0] state := "" if listing.Status != "" { state = string(listing.Status) } listingID := aws.ToString(listing.ReservedInstancesListingId) if listingID == "" { return MarketplaceListingResult{}, fmt.Errorf("CreateReservedInstancesListing returned a listing with an empty ID") } return MarketplaceListingResult{ListingID: listingID, State: state}, nil } // DescribeMarketplaceListing polls the status of a single listing by ID. // Returns an error when the listing is not found. func (c *Client) DescribeMarketplaceListing(ctx context.Context, listingID string) (MarketplaceListingResult, error) { out, err := c.client.DescribeReservedInstancesListings(ctx, &ec2.DescribeReservedInstancesListingsInput{ ReservedInstancesListingId: aws.String(listingID), }) if err != nil { return MarketplaceListingResult{}, fmt.Errorf("DescribeReservedInstancesListings failed: %w", err) } if len(out.ReservedInstancesListings) == 0 { return MarketplaceListingResult{}, fmt.Errorf("listing %s not found", listingID) } listing := out.ReservedInstancesListings[0] state := string(listing.Status) resolvedID := aws.ToString(listing.ReservedInstancesListingId) if resolvedID == "" { resolvedID = listingID } return MarketplaceListingResult{ListingID: resolvedID, State: state}, nil } // FetchOfferingClass calls ec2:DescribeReservedInstances to fetch the // offering_class ("standard" or "convertible") for a specific Reserved // Instance ID. Used by the marketplace-list handler to lazily populate // offering_class for rows created before migration 000087 or for externally- // created (pre-CUDly) Standard RIs whose offering_class was never stamped. // Returns an error when the RI is not found (e.g. it has expired or was // exchanged) because a missing RI cannot be listed. func (c *Client) FetchOfferingClass(ctx context.Context, reservedInstancesID string) (string, error) { out, err := c.client.DescribeReservedInstances(ctx, &ec2.DescribeReservedInstancesInput{ ReservedInstancesIds: []string{reservedInstancesID}, }) if err != nil { return "", fmt.Errorf("DescribeReservedInstances(%s): %w", reservedInstancesID, err) } if len(out.ReservedInstances) == 0 { return "", fmt.Errorf("reserved instance %s not found (may have expired or been exchanged)", reservedInstancesID) } return string(out.ReservedInstances[0].OfferingClass), nil } // CancelMarketplaceListing calls ec2:CancelReservedInstancesListing to // withdraw an active listing. Returns the updated listing state. func (c *Client) CancelMarketplaceListing(ctx context.Context, listingID string) (MarketplaceListingResult, error) { out, err := c.client.CancelReservedInstancesListing(ctx, &ec2.CancelReservedInstancesListingInput{ ReservedInstancesListingId: aws.String(listingID), }) if err != nil { return MarketplaceListingResult{}, fmt.Errorf("CancelReservedInstancesListing failed: %w", err) } if len(out.ReservedInstancesListings) == 0 { return MarketplaceListingResult{}, fmt.Errorf("CancelReservedInstancesListing returned empty response") } listing := out.ReservedInstancesListings[0] state := string(listing.Status) resolvedID := aws.ToString(listing.ReservedInstancesListingId) if resolvedID == "" { resolvedID = listingID } return MarketplaceListingResult{ListingID: resolvedID, State: state}, nil }