Skip to content

Commit 1ceb7ea

Browse files
committed
fix(marketplace): resolve 3 blocking defects in #808 RI sell flow
Defect 1 -- migration number collision: Renumber marketplace migration from 000068 (already deployed, skipped by golang-migrate on prod) to 000084 (next free after main's 000083). Verified: main tops at 000083; #1277 uses 000082; no open PR holds 000084. All "000068" references in SQL comments and types.go updated to 000084. Defect 2 -- offering_class never written: CUDly's EC2 client hardwires OfferingClassTypeConvertible; savePurchaseHistory never stamped the field, so every row landed with offering_class=NULL and the Sell button never rendered. Fix: (a) stamp offering_class='convertible' in savePurchaseHistory for all AWS EC2 purchases so future CUDly-bought rows are correctly classified at write time; (b) add FetchOfferingClass to the marketplaceEC2Client interface and implement it via DescribeReservedInstances so the marketplace-list handler can lazily populate offering_class for pre-migration rows and for externally-created Standard RIs (purchased before CUDly existed); (c) add StampOfferingClass to ConfigStore + PostgresStore + mock to persist the fetched class so subsequent requests skip the extra AWS call; (d) a new populateOfferingClass helper in the handler ties it together -- it runs when offering_class is empty after validateMarketplaceListRequest and before the definitive "standard only" gate. How a 'standard' row comes to exist: an externally-created Standard RI will get its offering_class populated from AWS DescribeReservedInstances on the first POST .../marketplace-list call and the value persisted for future calls. Rows purchased by CUDly are stamped 'convertible' at write time (CUDly only ever buys Convertible EC2 RIs). Defect 3 -- $0 price guardrail gap: resolveMarketplacePriceSchedule accepted Price >= 0, allowing zero-dollar listings. Fix: reject Price <= 0 with a clear error; add a named constant awsMarketplaceMinPriceFloorFraction (5%) and a total-schedule floor check so a schedule that sums to less than 5% of the prorated residual value is also rejected with an explicit message. Extract floor check into checkSuppliedScheduleFloor and the class-check predicate into isKnownNonStandardOfferingClass to keep all touched functions below gocyclo 10. Regression tests: - TestMigration084_MarketplaceColumns: integration test proves the three new columns exist and round-trip after migrating a fresh DB through 000084. - TestMarketplaceList_EmptyOfferingClassFetchedStandard: proves an externally-created Standard RI (offering_class="") is listable after the lazy-populate path fetches and stamps 'standard' from AWS. - TestMarketplaceList_EmptyOfferingClassFetchedConvertible: proves the gate still rejects when AWS reports 'convertible' even if DB had no class. - TestResolveMarketplacePriceSchedule_ZeroPriceRejected: Price=0 rejected. - TestResolveMarketplacePriceSchedule_BelowFloorRejected: sub-floor rejected.
1 parent 531f838 commit 1ceb7ea

13 files changed

Lines changed: 342 additions & 10 deletions

‎internal/analytics/collector_test.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,10 @@ func (m *mockConfigStore) ClaimMarketplaceListingSlot(_ context.Context, _ strin
434434
return true, nil
435435
}
436436

437+
func (m *mockConfigStore) StampOfferingClass(_ context.Context, _, _ string) error {
438+
return nil
439+
}
440+
437441
// strPtr is a test helper for *string fields.
438442
func strPtr(s string) *string { return &s }
439443

‎internal/api/handler_marketplace.go‎

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ type marketplaceEC2Client interface {
3838
CreateMarketplaceListing(ctx context.Context, req ec2svc.MarketplaceListingRequest) (ec2svc.MarketplaceListingResult, error)
3939
DescribeMarketplaceListing(ctx context.Context, listingID string) (ec2svc.MarketplaceListingResult, error)
4040
CancelMarketplaceListing(ctx context.Context, listingID string) (ec2svc.MarketplaceListingResult, error)
41+
// FetchOfferingClass calls AWS DescribeReservedInstances to determine
42+
// whether a given Reserved Instance is 'standard' or 'convertible'. Used
43+
// when the purchase_history row has no offering_class stored (pre-migration
44+
// 000084 rows and externally-created Standard RIs).
45+
FetchOfferingClass(ctx context.Context, reservedInstancesID string) (string, error)
4146
}
4247

4348
// buildMarketplaceEC2Client honors the injected factory for tests, falling
@@ -100,8 +105,10 @@ func (h *Handler) validateMarketplaceListRequest(ctx context.Context, req *event
100105
return nil, body, NewClientError(404, "purchase not found")
101106
}
102107

103-
// Only Standard RIs can be listed on the Marketplace.
104-
if !strings.EqualFold(row.OfferingClass, "standard") {
108+
// Reject explicitly non-standard classes before the RBAC call (fast path).
109+
// Empty offering_class is allowed here: the listing handler populates it
110+
// lazily from AWS before performing the definitive check.
111+
if isKnownNonStandardOfferingClass(row.OfferingClass) {
105112
return nil, body, NewClientError(400, "only Standard Reserved Instances can be listed on the AWS Marketplace; this purchase has offering_class="+row.OfferingClass)
106113
}
107114

@@ -158,6 +165,23 @@ func (h *Handler) marketplaceList(ctx context.Context, req *events.LambdaFunctio
158165

159166
ec2Client := h.buildMarketplaceEC2Client(cfg)
160167

168+
// Populate offering_class from AWS when the DB row has none. This covers
169+
// two cases: (1) pre-migration 000084 rows purchased by CUDly before this
170+
// column existed, and (2) externally-created Standard RIs whose
171+
// offering_class was never stamped. After population the value is persisted
172+
// so subsequent requests do not require an extra AWS API call.
173+
offeringClass := row.OfferingClass
174+
if offeringClass == "" {
175+
offeringClass, err = h.populateOfferingClass(ctx, purchaseID, ec2Client)
176+
if err != nil {
177+
return nil, err
178+
}
179+
}
180+
// Definitive offering_class gate: only Standard RIs can be listed.
181+
if !strings.EqualFold(offeringClass, "standard") {
182+
return nil, NewClientError(400, "only Standard Reserved Instances can be listed on the AWS Marketplace; this purchase has offering_class="+offeringClass)
183+
}
184+
161185
awsSchedule := make([]ec2svc.MarketplacePriceTier, 0, len(schedule))
162186
for _, t := range schedule {
163187
awsSchedule = append(awsSchedule, ec2svc.MarketplacePriceTier{
@@ -244,6 +268,25 @@ func (h *Handler) reserveAndCreateListing(ctx context.Context, purchaseID string
244268
return result, nil
245269
}
246270

271+
// populateOfferingClass calls AWS DescribeReservedInstances to determine the
272+
// offering class for an RI whose purchase_history row lacks one, then persists
273+
// the result so subsequent requests are served from the DB. Returns the class
274+
// string on success; returns an error if AWS reports no such RI.
275+
//
276+
// The DB stamp is best-effort: a write failure is logged but does not block the
277+
// listing request because the in-memory class value is authoritative for this
278+
// request.
279+
func (h *Handler) populateOfferingClass(ctx context.Context, purchaseID string, ec2Client marketplaceEC2Client) (string, error) {
280+
class, err := ec2Client.FetchOfferingClass(ctx, purchaseID)
281+
if err != nil {
282+
return "", fmt.Errorf("offering_class not set and could not fetch from AWS: %w", err)
283+
}
284+
if stampErr := h.config.StampOfferingClass(ctx, purchaseID, class); stampErr != nil {
285+
logging.Errorf("marketplace: failed to persist offering_class %q for purchase %s: %v", class, purchaseID, stampErr)
286+
}
287+
return class, nil
288+
}
289+
247290
// releaseMarketplaceClaim restores a purchase_history row's listing fields to
248291
// the state captured before ClaimMarketplaceListingSlot reserved the slot. It
249292
// runs on every failure path after a successful claim so a failed listing
@@ -419,6 +462,16 @@ const awsMarketplaceNetFactor = 1 - awsMarketplaceFeePercent/100.0
419462
// seller's remaining cost basis. Applied before AWS deducts its fee.
420463
const awsMarketplaceBuyerDiscountFactor = 0.95
421464

465+
// awsMarketplaceMinPriceFloorFraction is the minimum ratio of prorated
466+
// residual value that a caller-supplied price schedule must total across its
467+
// tiers. At 5%, a schedule totalling less than 1/20 of what the default
468+
// schedule would offer is rejected with a 400 so a user cannot accidentally
469+
// (or maliciously) list an RI at $0 or a nominal amount. The default schedule
470+
// uses awsMarketplaceBuyerDiscountFactor (95%) of residual; this floor is
471+
// intentionally far below that to give sellers flexibility while preventing
472+
// zero-price listings.
473+
const awsMarketplaceMinPriceFloorFraction = 0.05
474+
422475
// awsMarketplaceClientFaultCodes is the set of AWS error codes that represent
423476
// client-side faults for Marketplace listing operations. These map to 4xx
424477
// responses so the caller receives an actionable message. Server-side AWS
@@ -462,16 +515,56 @@ func mapAWSMarketplaceError(opMsg string, err error) error {
462515
// term field, which would overprice older RIs).
463516
// originalTerm is the full contract term in months, used to prorate the
464517
// upfront cost to its remaining value.
518+
// isKnownNonStandardOfferingClass reports true when offering_class is
519+
// explicitly set to a non-standard value (e.g. "convertible"). An empty string
520+
// returns false because the class is not yet known and will be resolved lazily.
521+
func isKnownNonStandardOfferingClass(class string) bool {
522+
return class != "" && !strings.EqualFold(class, "standard")
523+
}
524+
525+
// checkSuppliedScheduleFloor returns a non-nil error when the total value of
526+
// a caller-supplied price schedule falls below awsMarketplaceMinPriceFloorFraction
527+
// of the computed residual value. Extracted from resolveMarketplacePriceSchedule
528+
// to keep that function within the gocyclo budget. The check is skipped when
529+
// residual is zero (fully-elapsed term or $0 RI) to avoid spurious rejections.
530+
func checkSuppliedScheduleFloor(tiers []MarketplacePriceTier, remainingMonths, originalTerm int, upfrontCost, monthlyCost float64) error {
531+
if remainingMonths <= 0 {
532+
return nil
533+
}
534+
upfrontRemaining := 0.0
535+
if originalTerm > 0 {
536+
upfrontRemaining = upfrontCost * (float64(remainingMonths) / float64(originalTerm))
537+
}
538+
residual := upfrontRemaining + monthlyCost*float64(remainingMonths)
539+
if residual <= 0 {
540+
return nil
541+
}
542+
floor := residual * awsMarketplaceMinPriceFloorFraction
543+
var total float64
544+
for _, t := range tiers {
545+
total += t.Price * float64(t.TermMonths)
546+
}
547+
if total < floor {
548+
return fmt.Errorf(
549+
"total listing price (%.2f) is below the minimum floor (%.2f, %.0f%% of residual value %.2f); raise your price schedule or omit it to use the default",
550+
total, floor, awsMarketplaceMinPriceFloorFraction*100, residual)
551+
}
552+
return nil
553+
}
554+
465555
func resolveMarketplacePriceSchedule(supplied []MarketplacePriceTier, remainingMonths, originalTerm int, upfrontCost, monthlyCost float64) ([]MarketplacePriceTier, error) {
466556
if len(supplied) > 0 {
467557
for i, t := range supplied {
468558
if t.TermMonths <= 0 {
469559
return nil, fmt.Errorf("price_schedule[%d]: term_months must be a positive integer", i)
470560
}
471-
if t.Price < 0 {
472-
return nil, fmt.Errorf("price_schedule[%d]: price must be non-negative", i)
561+
if t.Price <= 0 {
562+
return nil, fmt.Errorf("price_schedule[%d]: price must be positive (received %.4f); use a non-zero listing price", i, t.Price)
473563
}
474564
}
565+
if err := checkSuppliedScheduleFloor(supplied, remainingMonths, originalTerm, upfrontCost, monthlyCost); err != nil {
566+
return nil, err
567+
}
475568
return supplied, nil
476569
}
477570

‎internal/api/handler_marketplace_test.go‎

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ const validMarketplacePurchaseID = "11111111-1111-1111-1111-111111111111"
2525
// Each field, when set, overrides the corresponding method's behavior and
2626
// records the last request so tests can assert on what was sent to AWS.
2727
type stubMarketplaceEC2 struct {
28-
createFn func(ctx context.Context, req ec2svc.MarketplaceListingRequest) (ec2svc.MarketplaceListingResult, error)
29-
cancelFn func(ctx context.Context, listingID string) (ec2svc.MarketplaceListingResult, error)
28+
createFn func(ctx context.Context, req ec2svc.MarketplaceListingRequest) (ec2svc.MarketplaceListingResult, error)
29+
cancelFn func(ctx context.Context, listingID string) (ec2svc.MarketplaceListingResult, error)
30+
fetchClassFn func(ctx context.Context, reservedInstancesID string) (string, error)
3031

3132
lastCreateReq ec2svc.MarketplaceListingRequest
3233
createCallCount int
@@ -56,6 +57,14 @@ func (s *stubMarketplaceEC2) CancelMarketplaceListing(ctx context.Context, listi
5657
return ec2svc.MarketplaceListingResult{ListingID: listingID, State: config.ListingStateCancelled}, nil
5758
}
5859

60+
func (s *stubMarketplaceEC2) FetchOfferingClass(ctx context.Context, reservedInstancesID string) (string, error) {
61+
if s.fetchClassFn != nil {
62+
return s.fetchClassFn(ctx, reservedInstancesID)
63+
}
64+
// Default: return "standard" so tests that set offering_class="" still proceed.
65+
return "standard", nil
66+
}
67+
5968
// marketplaceTestAPIError is an smithy.APIError with a controllable fault so
6069
// the error-mapping tests can exercise both the client- and server-fault paths.
6170
type marketplaceTestAPIError struct {
@@ -450,3 +459,103 @@ func TestMarketplaceList_ClaimErrorMapsToInternal(t *testing.T) {
450459
assert.Equal(t, 0, ec2.createCallCount)
451460
cfgStore.AssertExpectations(t)
452461
}
462+
463+
// --- Regression tests for the three blocking defects fixed in this PR ---
464+
465+
// TestResolveMarketplacePriceSchedule_ZeroPriceRejected verifies Defect 3:
466+
// Price=0 must be rejected with a 400 so a user cannot list an RI for free.
467+
// This is the fail-before/pass-after regression test (the old code accepted
468+
// Price >= 0, so Price=0 was accepted; the fix requires Price > 0).
469+
func TestResolveMarketplacePriceSchedule_ZeroPriceRejected(t *testing.T) {
470+
_, err := resolveMarketplacePriceSchedule([]MarketplacePriceTier{
471+
{TermMonths: 6, Price: 0},
472+
}, 6, 12, 1200, 0)
473+
require.Error(t, err, "Price=0 must be rejected")
474+
assert.Contains(t, err.Error(), "positive")
475+
}
476+
477+
// TestResolveMarketplacePriceSchedule_BelowFloorRejected verifies Defect 3:
478+
// A schedule whose total value is below awsMarketplaceMinPriceFloorFraction of
479+
// the residual must be rejected. The fixture has residual=$1200 (upfront $1200,
480+
// no monthly, remaining=12 of 12 months) so the floor at 5% is $60; a price of
481+
// $0.01/month for 12 months totals $0.12 and must be rejected.
482+
func TestResolveMarketplacePriceSchedule_BelowFloorRejected(t *testing.T) {
483+
_, err := resolveMarketplacePriceSchedule([]MarketplacePriceTier{
484+
{TermMonths: 12, Price: 0.01},
485+
}, 12, 12, 1200, 0)
486+
require.Error(t, err, "sub-floor schedule must be rejected")
487+
assert.Contains(t, err.Error(), "floor")
488+
assert.Contains(t, err.Error(), "residual")
489+
}
490+
491+
// TestMarketplaceList_EmptyOfferingClassFetchedStandard verifies Defect 2:
492+
// When purchase_history.offering_class is empty (pre-migration rows and
493+
// externally-created RIs), the handler fetches the class from AWS, persists
494+
// it, and proceeds when AWS reports "standard". This is the sell path becoming
495+
// reachable for externally-created Standard RIs.
496+
func TestMarketplaceList_EmptyOfferingClassFetchedStandard(t *testing.T) {
497+
cfgStore := &MockConfigStore{}
498+
authSvc := &MockAuthService{}
499+
adminSession(authSvc)
500+
501+
// Row has no offering_class (simulates pre-migration or externally-created RI).
502+
row := standardRow()
503+
row.OfferingClass = ""
504+
cfgStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID).
505+
Return(row, nil)
506+
// The handler must stamp the fetched class back to the DB.
507+
cfgStore.On("StampOfferingClass", mock.Anything, validMarketplacePurchaseID, "standard").
508+
Return(nil)
509+
cfgStore.On("ClaimMarketplaceListingSlot", mock.Anything, validMarketplacePurchaseID).
510+
Return(true, nil)
511+
cfgStore.On("UpdatePurchaseHistoryListing", mock.Anything, validMarketplacePurchaseID, "ril-default", config.ListingStateActive).
512+
Return(nil)
513+
514+
// AWS reports this RI is "standard".
515+
ec2 := &stubMarketplaceEC2{
516+
fetchClassFn: func(_ context.Context, id string) (string, error) {
517+
assert.Equal(t, validMarketplacePurchaseID, id)
518+
return "standard", nil
519+
},
520+
}
521+
h := newMarketplaceHandler(cfgStore, authSvc, ec2)
522+
resp, err := h.marketplaceList(context.Background(), marketplaceReq(), validMarketplacePurchaseID)
523+
524+
require.NoError(t, err, "standard RI with empty offering_class must be listable after lazy-populate")
525+
typed, ok := resp.(*MarketplaceListResponse)
526+
require.True(t, ok)
527+
assert.Equal(t, "ril-default", typed.ListingID)
528+
cfgStore.AssertExpectations(t)
529+
}
530+
531+
// TestMarketplaceList_EmptyOfferingClassFetchedConvertible verifies Defect 2
532+
// guard: when AWS reports the RI is "convertible", the handler must reject
533+
// with 400 even if offering_class was empty in the DB (not silently proceed).
534+
func TestMarketplaceList_EmptyOfferingClassFetchedConvertible(t *testing.T) {
535+
cfgStore := &MockConfigStore{}
536+
authSvc := &MockAuthService{}
537+
adminSession(authSvc)
538+
539+
row := standardRow()
540+
row.OfferingClass = ""
541+
cfgStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID).
542+
Return(row, nil)
543+
// The handler stamps the fetched class back even when it is convertible.
544+
cfgStore.On("StampOfferingClass", mock.Anything, validMarketplacePurchaseID, "convertible").
545+
Return(nil)
546+
547+
ec2 := &stubMarketplaceEC2{
548+
fetchClassFn: func(_ context.Context, _ string) (string, error) {
549+
return "convertible", nil
550+
},
551+
}
552+
h := newMarketplaceHandler(cfgStore, authSvc, ec2)
553+
_, err := h.marketplaceList(context.Background(), marketplaceReq(), validMarketplacePurchaseID)
554+
555+
require.Error(t, err)
556+
ce, ok := IsClientError(err)
557+
require.True(t, ok)
558+
assert.Equal(t, 400, ce.code)
559+
assert.Contains(t, err.Error(), "Standard")
560+
cfgStore.AssertExpectations(t)
561+
}

‎internal/config/interfaces.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,14 @@ type StoreInterface interface {
212212
// on subsequent poll/cancel transitions (issue #292).
213213
UpdatePurchaseHistoryListing(ctx context.Context, purchaseID, listingID, listingState string) error
214214

215+
// StampOfferingClass writes the offering_class value to a purchase_history
216+
// row identified by purchase_id. Called by the marketplace-list handler
217+
// when offering_class is absent in the DB (pre-migration 000084 rows and
218+
// externally-created Standard RIs): after fetching the class from AWS
219+
// DescribeReservedInstances it is persisted so subsequent requests do not
220+
// incur an extra AWS API call.
221+
StampOfferingClass(ctx context.Context, purchaseID, offeringClass string) error
222+
215223
// ClaimMarketplaceListingSlot atomically reserves the marketplace-listing
216224
// slot for a purchase_history row so two concurrent marketplace-list
217225
// requests cannot both proceed to create a duplicate AWS listing (issue

‎internal/config/store_postgres.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1682,6 +1682,20 @@ func (s *PostgresStore) UpdatePurchaseHistoryListing(ctx context.Context, purcha
16821682
return nil
16831683
}
16841684

1685+
// StampOfferingClass writes the offering_class value onto a purchase_history
1686+
// row identified by purchase_id. Used by the marketplace-list handler to
1687+
// lazily persist offering_class fetched from AWS DescribeReservedInstances for
1688+
// rows created before migration 000084 or for externally-created Standard RIs.
1689+
// A no-match (row not found) is treated as a non-fatal warning by callers.
1690+
func (s *PostgresStore) StampOfferingClass(ctx context.Context, purchaseID, offeringClass string) error {
1691+
query := `UPDATE purchase_history SET offering_class = $1 WHERE purchase_id = $2`
1692+
_, err := s.db.Exec(ctx, query, offeringClass, purchaseID)
1693+
if err != nil {
1694+
return fmt.Errorf("failed to stamp offering_class for purchase %s: %w", purchaseID, err)
1695+
}
1696+
return nil
1697+
}
1698+
16851699
// ClaimMarketplaceListingSlot atomically reserves the marketplace-listing slot
16861700
// for a purchase_history row so two concurrent marketplace-list requests cannot
16871701
// both proceed to create a duplicate AWS listing (issue #292). The single

‎internal/config/types.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -791,15 +791,15 @@ type PurchaseHistoryRecord struct {
791791
// OfferingClass records whether this commitment is a 'standard' or
792792
// 'convertible' RI. NULL on pre-migration rows. The Sell-on-Marketplace
793793
// button renders only when this equals "standard" (issue #292).
794-
// Persisted in purchase_history via migration 000068.
794+
// Persisted in purchase_history via migration 000084.
795795
OfferingClass string `json:"offering_class,omitempty" dynamodbav:"offering_class,omitempty"`
796796
// ListingID is the AWS ReservedInstancesListingId returned by
797797
// CreateReservedInstancesListing. Empty when the RI has not been
798-
// listed. Persisted in purchase_history via migration 000068.
798+
// listed. Persisted in purchase_history via migration 000084.
799799
ListingID string `json:"listing_id,omitempty" dynamodbav:"listing_id,omitempty"`
800800
// ListingState mirrors the AWS marketplace listing state (see the
801801
// ListingState* constants). Empty when not listed. Persisted in
802-
// purchase_history via migration 000068.
802+
// purchase_history via migration 000084.
803803
ListingState string `json:"listing_state,omitempty" dynamodbav:"listing_state,omitempty"`
804804
}
805805

internal/database/postgres/migrations/000068_purchase_history_marketplace_listing.down.sql renamed to internal/database/postgres/migrations/000084_purchase_history_marketplace_listing.down.sql

File renamed without changes.

internal/database/postgres/migrations/000068_purchase_history_marketplace_listing.up.sql renamed to internal/database/postgres/migrations/000084_purchase_history_marketplace_listing.up.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
-- Migration 000068: add RI Marketplace listing columns to purchase_history.
1+
-- Migration 000084: add RI Marketplace listing columns to purchase_history.
22
--
33
-- offering_class: 'convertible' or 'standard'; NULL for pre-migration rows.
44
-- The Sell button renders only when offering_class = 'standard' and the RI is

0 commit comments

Comments
 (0)