From d14520ad9bf3fccd47cef56a1dd5f3dd09c22263 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 28 May 2026 19:59:55 +0200 Subject: [PATCH 1/7] feat(marketplace): sell/cancel Standard RIs on AWS Marketplace - backend (closes #292) Add sell-on-Marketplace support for Standard Reserved Instances: - migration 000060: add offering_class, listing_id, listing_state to purchase_history - auth: sell-any and sell-own actions; sell-own added to DefaultUserPermissions - config: extend PurchaseHistoryRecord, StoreInterface with GetPurchaseHistoryByPurchaseID + UpdatePurchaseHistoryListing - ec2 client: CreateReservedInstancesListing, DescribeReservedInstancesListings, CancelReservedInstancesListing - api: handler_marketplace.go with marketplaceList/Cancel, authorizeSessionSell (sell-any/sell-own RBAC), default price schedule (95% of residual value) - router: POST /api/purchases/{id}/marketplace-list + /marketplace-cancel routes - all tests updated for the new interface methods and permission count --- internal/analytics/collector_test.go | 7 + internal/api/handler.go | 5 + internal/api/handler_marketplace.go | 326 ++++++++++++++++++ internal/api/mocks_test.go | 13 + internal/api/router.go | 14 + internal/auth/service_group_test.go | 23 +- internal/auth/types.go | 20 ++ internal/auth/types_test.go | 6 +- internal/config/interfaces.go | 10 + internal/config/store_postgres.go | 120 ++++++- .../config/store_postgres_pgxmock_test.go | 12 +- internal/config/types.go | 13 + ...chase_history_marketplace_listing.down.sql | 5 + ...urchase_history_marketplace_listing.up.sql | 18 + internal/mocks/stores.go | 15 + internal/purchase/mocks_test.go | 13 + internal/scheduler/scheduler_test.go | 13 + internal/server/test_helpers_test.go | 7 + providers/aws/services/ec2/client.go | 131 +++++++ 19 files changed, 750 insertions(+), 21 deletions(-) create mode 100644 internal/api/handler_marketplace.go create mode 100644 internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.down.sql create mode 100644 internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.up.sql diff --git a/internal/analytics/collector_test.go b/internal/analytics/collector_test.go index 9068a43f..880a1311 100644 --- a/internal/analytics/collector_test.go +++ b/internal/analytics/collector_test.go @@ -359,6 +359,13 @@ func (m *mockConfigStore) UpsertRIUtilizationCache(_ context.Context, _ string, return nil } +func (m *mockConfigStore) GetPurchaseHistoryByPurchaseID(_ context.Context, _ string) (*config.PurchaseHistoryRecord, error) { + return nil, nil +} +func (m *mockConfigStore) UpdatePurchaseHistoryListing(_ context.Context, _, _, _ string) error { + return nil +} + // TestNewCollector tests the NewCollector function func TestNewCollector(t *testing.T) { t.Run("returns error when analytics store is nil", func(t *testing.T) { diff --git a/internal/api/handler.go b/internal/api/handler.go index bf3cc3ea..1a259729 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -81,6 +81,11 @@ type Handler struct { // armreservations-backed client. azureExchangeFactory func(subscriptionID string) azureExchangeClient + // Optional marketplace EC2 client factory injected by tests. When nil + // (the production default), buildMarketplaceEC2Client uses + // awsprovider.NewEC2ClientDirect. + marketplaceEC2Factory func(aws.Config) marketplaceEC2Client + // Optional account-resolver injection point used by the reshape // handler integration test. When nil (the production default), the // handler calls h.resolveAWSCloudAccountID which in turn invokes diff --git a/internal/api/handler_marketplace.go b/internal/api/handler_marketplace.go new file mode 100644 index 00000000..a587e7b4 --- /dev/null +++ b/internal/api/handler_marketplace.go @@ -0,0 +1,326 @@ +package api + +// handler_marketplace.go implements the Sell-on-Marketplace flow for Standard +// Reserved Instances (issue #292). +// +// Endpoints: +// POST /api/purchases/{id}/marketplace-list create a listing +// POST /api/purchases/{id}/marketplace-cancel cancel an active listing +// +// Both endpoints require the caller to hold sell-any:purchases (admin or a +// custom operator group) or sell-own:purchases for rows they purchased +// themselves. The purchase_id in the URL path is the AWS ReservedInstancesId +// stamped into purchase_history.purchase_id at completion. + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/LeanerCloud/CUDly/internal/auth" + "github.com/LeanerCloud/CUDly/pkg/logging" + awsprovider "github.com/LeanerCloud/CUDly/providers/aws" + ec2svc "github.com/LeanerCloud/CUDly/providers/aws/services/ec2" + "github.com/aws/aws-lambda-go/events" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/google/uuid" +) + +// marketplaceEC2Client is the narrow EC2 interface the marketplace handlers +// need. Using a minimal interface keeps test stubs small. +type marketplaceEC2Client interface { + CreateMarketplaceListing(ctx context.Context, req ec2svc.MarketplaceListingRequest) (ec2svc.MarketplaceListingResult, error) + DescribeMarketplaceListing(ctx context.Context, listingID string) (ec2svc.MarketplaceListingResult, error) + CancelMarketplaceListing(ctx context.Context, listingID string) (ec2svc.MarketplaceListingResult, error) +} + +// buildMarketplaceEC2Client honours the injected factory for tests, falling +// back to the direct AWS SDK constructor in production. +func (h *Handler) buildMarketplaceEC2Client(cfg aws.Config) marketplaceEC2Client { + if h.marketplaceEC2Factory != nil { + return h.marketplaceEC2Factory(cfg) + } + return awsprovider.NewEC2ClientDirect(cfg) +} + +// MarketplacePriceTier is the JSON-decodable shape accepted in the request body. +type MarketplacePriceTier struct { + // TermMonths is the remaining-months count this tier covers. + TermMonths int64 `json:"term_months"` + // Price is the USD list price per unit for this tier. + Price float64 `json:"price"` +} + +// MarketplaceListRequest is the request body for POST .../marketplace-list. +// PriceSchedule is optional: when absent the handler computes a default from +// the row's upfront_cost, monthly_cost, term, and a 5% discount to attract +// buyers (documented in the response body so the caller can see what was used). +type MarketplaceListRequest struct { + PriceSchedule []MarketplacePriceTier `json:"price_schedule,omitempty"` +} + +// MarketplaceListResponse is the JSON response body for a successful listing. +type MarketplaceListResponse struct { + ListingID string `json:"listing_id"` + ListingState string `json:"listing_state"` + PriceSchedule []MarketplacePriceTier `json:"price_schedule"` + AWSFeePercent float64 `json:"aws_fee_percent"` + Note string `json:"note,omitempty"` +} + +// marketplaceList handles POST /api/purchases/{id}/marketplace-list. +// The {id} must be the purchase_history.purchase_id (AWS ReservedInstancesId). +func (h *Handler) marketplaceList(ctx context.Context, req *events.LambdaFunctionURLRequest, purchaseID string) (any, error) { + session, err := h.requireSession(ctx, req) + if err != nil { + return nil, err + } + + if err := validateUUID(purchaseID); err != nil { + return nil, err + } + + // Look up the purchase_history row to validate offering_class + get metadata. + row, err := h.config.GetPurchaseHistoryByPurchaseID(ctx, purchaseID) + if err != nil { + return nil, fmt.Errorf("failed to look up purchase: %w", err) + } + if row == nil { + return nil, NewClientError(404, "purchase not found") + } + + // Only Standard RIs can be listed on the Marketplace. + if !strings.EqualFold(row.OfferingClass, "standard") { + return nil, NewClientError(400, "only Standard Reserved Instances can be listed on the AWS Marketplace; this purchase has offering_class="+row.OfferingClass) + } + + // Enforce sell-any / sell-own RBAC. + if err := h.authorizeSessionSell(ctx, session, row.CloudAccountID); err != nil { + return nil, err + } + + // Reject if a listing is already active to avoid duplicate listings. + if strings.EqualFold(row.ListingState, "active") { + return nil, NewClientError(409, fmt.Sprintf("an active marketplace listing %s already exists for this RI; cancel it first", row.ListingID)) + } + + // Decode optional body. + var body MarketplaceListRequest + if len(req.Body) > 0 { + if err := json.Unmarshal([]byte(req.Body), &body); err != nil { + return nil, NewClientError(400, "invalid request body: "+err.Error()) + } + } + + // Validate and normalise the price schedule. + schedule, err := resolveMarketplacePriceSchedule(body.PriceSchedule, row.Term, row.UpfrontCost, row.MonthlyCost) + if err != nil { + return nil, NewClientError(400, err.Error()) + } + + // Build per-provider AWS config for the region the RI lives in. + cfg, err := h.loadAWSConfigWithRegion(ctx, row.Region) + if err != nil { + return nil, fmt.Errorf("failed to load AWS config: %w", err) + } + + ec2Client := h.buildMarketplaceEC2Client(cfg) + + awsSchedule := make([]ec2svc.MarketplacePriceTier, 0, len(schedule)) + for _, t := range schedule { + awsSchedule = append(awsSchedule, ec2svc.MarketplacePriceTier{ + Term: t.TermMonths, + Price: t.Price, + }) + } + + result, err := ec2Client.CreateMarketplaceListing(ctx, ec2svc.MarketplaceListingRequest{ + ReservedInstancesID: purchaseID, + ClientToken: uuid.New().String(), + PriceSchedule: awsSchedule, + }) + if err != nil { + // Preserve the AWS error message verbatim for 4xx errors (e.g. missing + // seller account, invalid listing) so the frontend can surface it. + logging.Warnf("marketplace: CreateReservedInstancesListing for purchase %s failed: %v", purchaseID, err) + return nil, NewClientError(502, "AWS marketplace listing failed: "+err.Error()) + } + + // Persist the listing ID and state. + if dbErr := h.config.UpdatePurchaseHistoryListing(ctx, purchaseID, result.ListingID, result.State); dbErr != nil { + // Log the error but return success to the caller — the listing was + // created in AWS; a DB write failure here must not be surfaced as a + // 5xx that could cause the user to create a duplicate listing. + logging.Errorf("marketplace: listing created (%s / %s) but DB update failed: %v", result.ListingID, result.State, dbErr) + } + + return &MarketplaceListResponse{ + ListingID: result.ListingID, + ListingState: result.State, + PriceSchedule: schedule, + AWSFeePercent: 12, + Note: "AWS charges a 12% transaction fee on the listing proceeds. Net proceeds = ListingPrice * 0.88.", + }, nil +} + +// marketplaceCancel handles POST /api/purchases/{id}/marketplace-cancel. +func (h *Handler) marketplaceCancel(ctx context.Context, req *events.LambdaFunctionURLRequest, purchaseID string) (any, error) { + session, err := h.requireSession(ctx, req) + if err != nil { + return nil, err + } + + if err := validateUUID(purchaseID); err != nil { + return nil, err + } + + row, err := h.config.GetPurchaseHistoryByPurchaseID(ctx, purchaseID) + if err != nil { + return nil, fmt.Errorf("failed to look up purchase: %w", err) + } + if row == nil { + return nil, NewClientError(404, "purchase not found") + } + + if err := h.authorizeSessionSell(ctx, session, row.CloudAccountID); err != nil { + return nil, err + } + + if !strings.EqualFold(row.ListingState, "active") { + return nil, NewClientError(409, "no active listing found for this RI; current state: "+row.ListingState) + } + + cfg, err := h.loadAWSConfigWithRegion(ctx, row.Region) + if err != nil { + return nil, fmt.Errorf("failed to load AWS config: %w", err) + } + + ec2Client := h.buildMarketplaceEC2Client(cfg) + + result, err := ec2Client.CancelMarketplaceListing(ctx, row.ListingID) + if err != nil { + logging.Warnf("marketplace: CancelReservedInstancesListing for listing %s failed: %v", row.ListingID, err) + return nil, NewClientError(502, "AWS cancel listing failed: "+err.Error()) + } + + if dbErr := h.config.UpdatePurchaseHistoryListing(ctx, purchaseID, result.ListingID, result.State); dbErr != nil { + logging.Errorf("marketplace: listing cancelled (%s) but DB update failed: %v", result.ListingID, dbErr) + } + + return map[string]string{"listing_id": result.ListingID, "listing_state": result.State}, nil +} + +// authorizeSessionSell returns nil when the session is permitted to perform a +// sell/marketplace action under the sell-any / sell-own RBAC rules. The +// cloudAccountID is the cloud account that owns the RI (used for sell-own to +// confirm the session's allowed accounts cover that account). Returns a 403 +// ClientError otherwise. +// +// sell-own semantics: a non-admin user can list/cancel RIs for cloud accounts +// they are permitted to access (allowed_accounts covers the account). This is +// intentionally looser than cancel-own (which checks the session UserID against +// created_by_user_id) because purchase_history rows lack a created_by_user_id. +func (h *Handler) authorizeSessionSell(ctx context.Context, session *Session, cloudAccountID *string) error { + if h.auth == nil { + return NewClientError(500, "authentication service not configured") + } + + // Admins are recognised by holding the full-access admin capability + // (auth migrated from role-based to group-membership-only, issue #907). + isAdmin, err := h.auth.HasPermissionAPI(ctx, session.UserID, auth.ActionAdmin, auth.ResourceAll) + if err != nil { + return fmt.Errorf("permission check failed: %w", err) + } + if isAdmin { + return nil + } + + hasAny, err := h.auth.HasPermissionAPI(ctx, session.UserID, auth.ActionSellAny, auth.ResourcePurchases) + if err != nil { + return fmt.Errorf("permission check failed: %w", err) + } + if hasAny { + return nil + } + + hasOwn, err := h.auth.HasPermissionAPI(ctx, session.UserID, auth.ActionSellOwn, auth.ResourcePurchases) + if err != nil { + return fmt.Errorf("permission check failed: %w", err) + } + if !hasOwn { + return NewClientError(403, "permission denied: requires sell-any or sell-own on purchases") + } + + // sell-own: verify the session covers the cloud account that holds the RI. + // When cloudAccountID is nil (ambient/legacy row), deny for non-admins. + if cloudAccountID == nil { + return NewClientError(403, "permission denied: cannot sell an RI from an ambiguous (non-per-account) purchase row without sell-any") + } + return h.authorizeAllowedAccount(ctx, session, *cloudAccountID) +} + +// authorizeAllowedAccount returns nil when the session's allowed_accounts +// permit access to the given cloud account UUID. Returns 403 otherwise. +func (h *Handler) authorizeAllowedAccount(ctx context.Context, session *Session, cloudAccountID string) error { + if h.auth != nil { + // Admins are recognised by holding the full-access admin capability + // (auth migrated from role-based to group-membership-only, issue #907). + isAdmin, err := h.auth.HasPermissionAPI(ctx, session.UserID, auth.ActionAdmin, auth.ResourceAll) + if err != nil { + return fmt.Errorf("admin permission check failed: %w", err) + } + if isAdmin { + return nil + } + } + allowed, err := h.getAllowedAccounts(ctx, session) + if err != nil { + return fmt.Errorf("failed to check allowed accounts: %w", err) + } + // Empty list means "no restriction" (the user has access to all accounts). + if len(allowed) == 0 { + return nil + } + for _, id := range allowed { + if id == "*" || id == cloudAccountID { + return nil + } + } + return NewClientError(403, "permission denied: purchase is in a cloud account not covered by your session's allowed accounts") +} + +// resolveMarketplacePriceSchedule returns a normalised price schedule for the +// given RI. When the caller supplied an explicit schedule it is validated and +// returned unchanged. When the caller omitted the schedule (nil / empty), a +// single-tier default is computed: total remaining value * 0.95 (5% discount +// to attract buyers; the 12% AWS fee is applied by the Marketplace on top). +// +// remainingMonths is rec.Term (AWS RI term in months), upfrontCost is the +// original upfront paid, monthlyCost is the ongoing recurring charge. +func resolveMarketplacePriceSchedule(supplied []MarketplacePriceTier, remainingMonths int, upfrontCost, monthlyCost float64) ([]MarketplacePriceTier, error) { + if len(supplied) > 0 { + for i, t := range supplied { + if t.TermMonths <= 0 { + return nil, fmt.Errorf("price_schedule[%d]: term_months must be a positive integer", i) + } + if t.Price < 0 { + return nil, fmt.Errorf("price_schedule[%d]: price must be non-negative", i) + } + } + return supplied, nil + } + + // Default: spread (upfront + recurring) * 0.95 across remaining term. + if remainingMonths <= 0 { + remainingMonths = 1 // defensive: should not happen for an active RI + } + totalValue := upfrontCost + (monthlyCost * float64(remainingMonths)) + listPrice := totalValue * 0.95 + if listPrice < 0 { + listPrice = 0 + } + return []MarketplacePriceTier{ + {TermMonths: int64(remainingMonths), Price: listPrice}, + }, nil +} diff --git a/internal/api/mocks_test.go b/internal/api/mocks_test.go index 858618a4..a479df12 100644 --- a/internal/api/mocks_test.go +++ b/internal/api/mocks_test.go @@ -212,6 +212,19 @@ func (m *MockConfigStore) GetPurchaseHistoryFiltered(ctx context.Context, provid return args.Get(0).([]config.PurchaseHistoryRecord), args.Error(1) } +func (m *MockConfigStore) GetPurchaseHistoryByPurchaseID(ctx context.Context, purchaseID string) (*config.PurchaseHistoryRecord, error) { + args := m.Called(ctx, purchaseID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*config.PurchaseHistoryRecord), args.Error(1) +} + +func (m *MockConfigStore) UpdatePurchaseHistoryListing(ctx context.Context, purchaseID, listingID, listingState string) error { + args := m.Called(ctx, purchaseID, listingID, listingState) + return args.Error(0) +} + func (m *MockConfigStore) GetExecutionByID(ctx context.Context, executionID string) (*config.PurchaseExecution, error) { args := m.Called(ctx, executionID) if args.Get(0) == nil { diff --git a/internal/api/router.go b/internal/api/router.go index e5a3e700..1fc05f42 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -178,6 +178,12 @@ func (r *Router) registerRoutes() { // the retry-any/retry-own RBAC matrix. {PathPrefix: "/api/purchases/retry/", Method: "POST", Handler: r.retryPurchaseHandler, Auth: AuthUser}, + // RI Marketplace listing (issue #292). Session-authed only; the + // handler enforces the sell-any/sell-own RBAC matrix and checks + // that the purchase_history row has offering_class='standard'. + {PathPrefix: "/api/purchases/", PathSuffix: "/marketplace-list", Method: "POST", Handler: r.marketplaceListHandler, Auth: AuthUser}, + {PathPrefix: "/api/purchases/", PathSuffix: "/marketplace-cancel", Method: "POST", Handler: r.marketplaceCancelHandler, Auth: AuthUser}, + // Planned purchases endpoints (must come before generic /api/purchases/{id}). // All now AuthUser (PR-A of #660): handler-level requirePermission // is the actual gate for pause/resume/run/delete. @@ -533,6 +539,14 @@ func (r *Router) retryPurchaseHandler(ctx context.Context, req *events.LambdaFun return r.h.retryPurchase(ctx, req, params["id"]) } +func (r *Router) marketplaceListHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { + return r.h.marketplaceList(ctx, req, params["id"]) +} + +func (r *Router) marketplaceCancelHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { + return r.h.marketplaceCancel(ctx, req, params["id"]) +} + func (r *Router) getPlannedPurchasesHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { return r.h.getPlannedPurchases(ctx, req) } diff --git a/internal/auth/service_group_test.go b/internal/auth/service_group_test.go index b5d9a3ee..0708775e 100644 --- a/internal/auth/service_group_test.go +++ b/internal/auth/service_group_test.go @@ -278,11 +278,12 @@ func TestService_GetUserPermissions(t *testing.T) { permissions, err := service.GetUserPermissions(ctx, "user-123") require.NoError(t, err) - // 11 = 6 read/plan-author + delete:plans (PR-A #660) + // 12 = 6 read/plan-author + delete:plans (PR-A #660) // + update:purchases (PR-A #660) // + cancel-own:purchases (issue #46) - // + retry-own:purchases (issue #47) + approve-own:purchases (issue #286). - assert.Len(t, permissions, 11) + // + retry-own:purchases (issue #47) + approve-own:purchases (issue #286) + // + sell-own:purchases (issue #292). + assert.Len(t, permissions, 12) mockStore.AssertExpectations(t) }) @@ -351,10 +352,11 @@ func TestService_GetUserPermissions(t *testing.T) { permissions, err := service.GetUserPermissions(ctx, "user-123") require.NoError(t, err) - // 11 standard-group (incl. delete:plans (PR-A #660) + update:purchases (PR-A #660) - // + cancel-own (#46) + retry-own (#47) + approve-own (#286):purchases) - // + 1 group1 + 1 group2 = 13 - assert.Len(t, permissions, 13) + // 12 standard-group (incl. delete:plans (PR-A #660) + update:purchases (PR-A #660) + // + cancel-own (#46) + retry-own (#47) + approve-own (#286) + // + sell-own (#292):purchases) + // + 1 group1 + 1 group2 = 14 + assert.Len(t, permissions, 14) mockStore.AssertExpectations(t) }) @@ -397,11 +399,12 @@ func TestService_GetUserPermissions(t *testing.T) { require.NoError(t, err) // Should have only the resolvable group's permissions; the missing // group is skipped. - // 11 = 6 read/plan-author + delete:plans (PR-A #660) + // 12 = 6 read/plan-author + delete:plans (PR-A #660) // + update:purchases (PR-A #660) // + cancel-own:purchases (issue #46) - // + retry-own:purchases (issue #47) + approve-own:purchases (issue #286). - assert.Len(t, permissions, 11) + // + retry-own:purchases (issue #47) + approve-own:purchases (issue #286) + // + sell-own:purchases (issue #292). + assert.Len(t, permissions, 12) mockStore.AssertExpectations(t) }) diff --git a/internal/auth/types.go b/internal/auth/types.go index 5f8fc76d..83a814e8 100644 --- a/internal/auth/types.go +++ b/internal/auth/types.go @@ -367,6 +367,20 @@ const ( // contact_email gate (PR #101), not these verbs. ActionApproveOwn = "approve-own" ActionApproveAny = "approve-any" + // ActionSellOwn / ActionSellAny gate the "Sell on Marketplace" button on + // Standard RI purchase history rows (issue #292). Mirror image of the + // cancel-{own,any} / retry-{own,any} / approve-{own,any} family: + // + // * RoleAdmin — implicit via {ActionAdmin, ResourceAll}; covers + // both verbs. + // * RoleUser — DefaultUserPermissions() adds sell-own:purchases so + // every user can list RIs they purchased themselves. + // * RoleReadOnly — neither verb. + // + // sell-any has no default non-admin grant; the constant exists so a + // custom operator group can list any Standard RI without admin escalation. + ActionSellOwn = "sell-own" + ActionSellAny = "sell-any" ) // Predefined resources @@ -432,6 +446,12 @@ func DefaultUserPermissions() []Permission { // token approve path stays as an escape hatch for non-session // approvers. {Action: ActionApproveOwn, Resource: ResourcePurchases}, + // sell-own:purchases — every authenticated user can list Standard + // RIs they purchased themselves on the AWS Marketplace (issue #292). + // The handler still requires the purchase_history row to carry + // offering_class = 'standard' and the cloud account to match the + // row's cloud_account_id before creating the listing. + {Action: ActionSellOwn, Resource: ResourcePurchases}, } } diff --git a/internal/auth/types_test.go b/internal/auth/types_test.go index 430b9ad3..02d001b7 100644 --- a/internal/auth/types_test.go +++ b/internal/auth/types_test.go @@ -20,8 +20,9 @@ func TestDefaultPermissions(t *testing.T) { // + update:purchases (PR-A #660) // + cancel-own:purchases (issue #46) // + retry-own:purchases (issue #47) - // + approve-own:purchases (issue #286) = 11. - assert.Len(t, perms, 11) + // + approve-own:purchases (issue #286) + // + sell-own:purchases (issue #292) = 12. + assert.Len(t, perms, 12) actions := make(map[string]bool) for _, p := range perms { @@ -39,6 +40,7 @@ func TestDefaultPermissions(t *testing.T) { assert.True(t, actions[ActionCancelOwn+":"+ResourcePurchases]) assert.True(t, actions[ActionRetryOwn+":"+ResourcePurchases]) assert.True(t, actions[ActionApproveOwn+":"+ResourcePurchases]) + assert.True(t, actions[ActionSellOwn+":"+ResourcePurchases]) }) t.Run("DefaultReadOnlyPermissions returns readonly access", func(t *testing.T) { diff --git a/internal/config/interfaces.go b/internal/config/interfaces.go index 72c179e5..c27bfe87 100644 --- a/internal/config/interfaces.go +++ b/internal/config/interfaces.go @@ -111,6 +111,16 @@ type StoreInterface interface { // limit, so the /api/history handler silently dropped the // provider/account_ids/start/end query params the frontend was sending. GetPurchaseHistoryFiltered(ctx context.Context, providerFilter string, accountIDs []string, start, end *time.Time, limit int) ([]PurchaseHistoryRecord, error) + // GetPurchaseHistoryByPurchaseID returns the purchase_history row with + // the given purchase_id (AWS ReservedInstancesId / Azure reservation ID) + // or nil when not found. Used by the marketplace-list handler (issue + // #292) to validate offering_class and look up the cloud account. + GetPurchaseHistoryByPurchaseID(ctx context.Context, purchaseID string) (*PurchaseHistoryRecord, error) + // UpdatePurchaseHistoryListing stamps the AWS marketplace listing_id and + // listing_state onto a purchase_history row. Called after + // CreateReservedInstancesListing succeeds (listing_state="active") and + // on subsequent poll/cancel transitions (issue #292). + UpdatePurchaseHistoryListing(ctx context.Context, purchaseID, listingID, listingState string) error // RI Exchange history SaveRIExchangeRecord(ctx context.Context, record *RIExchangeRecord) error diff --git a/internal/config/store_postgres.go b/internal/config/store_postgres.go index 0df9e89d..1f43bf0f 100644 --- a/internal/config/store_postgres.go +++ b/internal/config/store_postgres.go @@ -1309,8 +1309,8 @@ func (s *PostgresStore) SavePurchaseHistory(ctx context.Context, record *Purchas account_id, purchase_id, timestamp, provider, service, region, resource_type, count, term, payment, upfront_cost, monthly_cost, estimated_savings, plan_id, plan_name, ramp_step, cloud_account_id, - source - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + source, offering_class + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) ` _, err := s.db.Exec(ctx, query, @@ -1332,6 +1332,7 @@ func (s *PostgresStore) SavePurchaseHistory(ctx context.Context, record *Purchas record.RampStep, record.CloudAccountID, record.Source, + nullStringFromString(record.OfferingClass), ) if err != nil { @@ -1341,12 +1342,105 @@ func (s *PostgresStore) SavePurchaseHistory(ctx context.Context, record *Purchas return nil } +// UpdatePurchaseHistoryListing stamps the marketplace listing fields onto a +// purchase_history row identified by its purchase_id (RI reservation ID). +// Called by the marketplace-list handler after CreateReservedInstancesListing +// succeeds (listing_id + listing_state="active") and by the status-poll path +// when AWS reports closed or cancelled. +func (s *PostgresStore) UpdatePurchaseHistoryListing(ctx context.Context, purchaseID, listingID, listingState string) error { + query := ` + UPDATE purchase_history + SET listing_id = $1, listing_state = $2 + WHERE purchase_id = $3 + ` + tag, err := s.db.Exec(ctx, query, listingID, listingState, purchaseID) + if err != nil { + return fmt.Errorf("failed to update listing state for purchase %s: %w", purchaseID, err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("purchase %s not found in history", purchaseID) + } + return nil +} + +// GetPurchaseHistoryByPurchaseID returns the single purchase_history row with +// the given purchase_id (AWS ReservedInstancesId / Azure reservation ID etc.) +// or nil when no such row exists. Used by the marketplace-list handler to +// validate offering_class and look up the cloud account before calling AWS. +func (s *PostgresStore) GetPurchaseHistoryByPurchaseID(ctx context.Context, purchaseID string) (*PurchaseHistoryRecord, error) { + query := ` + SELECT account_id, purchase_id, timestamp, provider, service, region, + resource_type, count, term, payment, upfront_cost, monthly_cost, + estimated_savings, plan_id, plan_name, ramp_step, cloud_account_id, + offering_class, listing_id, listing_state + FROM purchase_history + WHERE purchase_id = $1 + LIMIT 1 + ` + rows, err := s.db.Query(ctx, query, purchaseID) + if err != nil { + return nil, fmt.Errorf("failed to query purchase history by purchase_id: %w", err) + } + defer rows.Close() + + if !rows.Next() { + return nil, rows.Err() + } + + var rec PurchaseHistoryRecord + var planID, planName, cloudAccountID, offeringClass, listingID, listingState sql.NullString + if err := rows.Scan( + &rec.AccountID, + &rec.PurchaseID, + &rec.Timestamp, + &rec.Provider, + &rec.Service, + &rec.Region, + &rec.ResourceType, + &rec.Count, + &rec.Term, + &rec.Payment, + &rec.UpfrontCost, + &rec.MonthlyCost, + &rec.EstimatedSavings, + &planID, + &planName, + &rec.RampStep, + &cloudAccountID, + &offeringClass, + &listingID, + &listingState, + ); err != nil { + return nil, fmt.Errorf("failed to scan purchase history: %w", err) + } + if planID.Valid { + rec.PlanID = planID.String + } + if planName.Valid { + rec.PlanName = planName.String + } + if cloudAccountID.Valid { + rec.CloudAccountID = &cloudAccountID.String + } + if offeringClass.Valid { + rec.OfferingClass = offeringClass.String + } + if listingID.Valid { + rec.ListingID = listingID.String + } + if listingState.Valid { + rec.ListingState = listingState.String + } + return &rec, rows.Err() +} + // GetPurchaseHistory retrieves purchase history for an account func (s *PostgresStore) GetPurchaseHistory(ctx context.Context, accountID string, limit int) ([]PurchaseHistoryRecord, error) { query := ` SELECT account_id, purchase_id, timestamp, provider, service, region, resource_type, count, term, payment, upfront_cost, monthly_cost, - estimated_savings, plan_id, plan_name, ramp_step, cloud_account_id + estimated_savings, plan_id, plan_name, ramp_step, cloud_account_id, + offering_class, listing_id, listing_state FROM purchase_history WHERE account_id = $1 ORDER BY timestamp DESC @@ -1361,7 +1455,8 @@ func (s *PostgresStore) GetAllPurchaseHistory(ctx context.Context, limit int) ([ query := ` SELECT account_id, purchase_id, timestamp, provider, service, region, resource_type, count, term, payment, upfront_cost, monthly_cost, - estimated_savings, plan_id, plan_name, ramp_step, cloud_account_id + estimated_savings, plan_id, plan_name, ramp_step, cloud_account_id, + offering_class, listing_id, listing_state FROM purchase_history ORDER BY timestamp DESC LIMIT $1 @@ -1423,7 +1518,8 @@ func (s *PostgresStore) GetPurchaseHistoryFiltered( query := fmt.Sprintf(` SELECT account_id, purchase_id, timestamp, provider, service, region, resource_type, count, term, payment, upfront_cost, monthly_cost, - estimated_savings, plan_id, plan_name, ramp_step, cloud_account_id + estimated_savings, plan_id, plan_name, ramp_step, cloud_account_id, + offering_class, listing_id, listing_state FROM purchase_history%s ORDER BY timestamp DESC LIMIT $%d @@ -1443,7 +1539,7 @@ func (s *PostgresStore) queryPurchaseHistory(ctx context.Context, query string, records := make([]PurchaseHistoryRecord, 0) for rows.Next() { var record PurchaseHistoryRecord - var planID, planName, cloudAccountID sql.NullString + var planID, planName, cloudAccountID, offeringClass, listingID, listingState sql.NullString err := rows.Scan( &record.AccountID, @@ -1463,6 +1559,9 @@ func (s *PostgresStore) queryPurchaseHistory(ctx context.Context, query string, &planName, &record.RampStep, &cloudAccountID, + &offeringClass, + &listingID, + &listingState, ) if err != nil { return nil, fmt.Errorf("failed to scan purchase history: %w", err) @@ -1478,6 +1577,15 @@ func (s *PostgresStore) queryPurchaseHistory(ctx context.Context, query string, if cloudAccountID.Valid { record.CloudAccountID = &cloudAccountID.String } + if offeringClass.Valid { + record.OfferingClass = offeringClass.String + } + if listingID.Valid { + record.ListingID = listingID.String + } + if listingState.Valid { + record.ListingState = listingState.String + } records = append(records, record) } diff --git a/internal/config/store_postgres_pgxmock_test.go b/internal/config/store_postgres_pgxmock_test.go index c9ccc8d6..727aef60 100644 --- a/internal/config/store_postgres_pgxmock_test.go +++ b/internal/config/store_postgres_pgxmock_test.go @@ -471,16 +471,20 @@ func TestPGXMock_GetPurchaseHistory_Success(t *testing.T) { "account_id", "purchase_id", "timestamp", "provider", "service", "region", "resource_type", "count", "term", "payment", "upfront_cost", "monthly_cost", "estimated_savings", "plan_id", "plan_name", "ramp_step", "cloud_account_id", + "offering_class", "listing_id", "listing_state", } rows := pgxmock.NewRows(cols). AddRow("acc-1", "pur-1", now, "aws", "ec2", "us-east-1", "m5.large", 2, 1, "no-upfront", 100.0, 50.0, 200.0, sql.NullString{Valid: true, String: "plan-1"}, sql.NullString{Valid: true, String: "My Plan"}, - 1, sql.NullString{Valid: true, String: "cloud-acct-1"}). + 1, sql.NullString{Valid: true, String: "cloud-acct-1"}, + sql.NullString{Valid: true, String: "standard"}, + sql.NullString{}, sql.NullString{}). AddRow("acc-1", "pur-2", now, "aws", "rds", "us-west-2", "db.t3.medium", 1, 3, "all-upfront", 200.0, 0.0, 100.0, - sql.NullString{}, sql.NullString{}, 0, sql.NullString{}) + sql.NullString{}, sql.NullString{}, 0, sql.NullString{}, + sql.NullString{}, sql.NullString{}, sql.NullString{}) mock.ExpectQuery("SELECT").WithArgs(pgxmock.AnyArg(), pgxmock.AnyArg()).WillReturnRows(rows) records, err := store.GetPurchaseHistory(ctx, "acc-1", 10) @@ -499,6 +503,7 @@ var purchaseHistoryCols = []string{ "account_id", "purchase_id", "timestamp", "provider", "service", "region", "resource_type", "count", "term", "payment", "upfront_cost", "monthly_cost", "estimated_savings", "plan_id", "plan_name", "ramp_step", "cloud_account_id", + "offering_class", "listing_id", "listing_state", } // purchaseHistoryRow builds a single AddRow tuple matching purchaseHistoryCols. @@ -507,6 +512,7 @@ func purchaseHistoryRow(now time.Time, provider, acct string) []interface{} { acct, "pur-1", now, provider, "ec2", "us-east-1", "m5.large", 1, 1, "no-upfront", 100.0, 50.0, 200.0, sql.NullString{}, sql.NullString{}, 0, sql.NullString{}, + sql.NullString{}, sql.NullString{}, sql.NullString{}, } } @@ -1473,7 +1479,7 @@ func TestPGXMock_SavePurchaseHistory_Success(t *testing.T) { store := storeWith(mock) ctx := context.Background() - mock.ExpectExec("INSERT INTO purchase_history").WithArgs(anyArgsCfg(18)...). + mock.ExpectExec("INSERT INTO purchase_history").WithArgs(anyArgsCfg(19)...). WillReturnResult(pgxmock.NewResult("INSERT", 1)) err := store.SavePurchaseHistory(ctx, &PurchaseHistoryRecord{ diff --git a/internal/config/types.go b/internal/config/types.go index caf518f6..e825aca0 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -530,6 +530,19 @@ type PurchaseHistoryRecord struct { // persistence (resolved at read time). The UI renders this in the // Approval Queue "Created by" column instead of the raw UUID. CreatedByUserEmail string `json:"created_by_user_email,omitempty" dynamodbav:"-"` + // OfferingClass records whether this commitment is a 'standard' or + // 'convertible' RI. NULL on pre-migration rows. The Sell-on-Marketplace + // button renders only when this equals "standard" (issue #292). + // Persisted in purchase_history via migration 000060. + OfferingClass string `json:"offering_class,omitempty" dynamodbav:"offering_class,omitempty"` + // ListingID is the AWS ReservedInstancesListingId returned by + // CreateReservedInstancesListing. Empty when the RI has not been + // listed. Persisted in purchase_history via migration 000060. + ListingID string `json:"listing_id,omitempty" dynamodbav:"listing_id,omitempty"` + // ListingState mirrors the AWS marketplace listing state: "active", + // "cancelled", or "closed" (sold). Empty when not listed. + // Persisted in purchase_history via migration 000060. + ListingState string `json:"listing_state,omitempty" dynamodbav:"listing_state,omitempty"` } // RIExchangeRecord represents a record in the ri_exchange_history table diff --git a/internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.down.sql b/internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.down.sql new file mode 100644 index 00000000..dda98d24 --- /dev/null +++ b/internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.down.sql @@ -0,0 +1,5 @@ +-- Revert migration 000060 +ALTER TABLE purchase_history + DROP COLUMN IF EXISTS offering_class, + DROP COLUMN IF EXISTS listing_id, + DROP COLUMN IF EXISTS listing_state; diff --git a/internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.up.sql b/internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.up.sql new file mode 100644 index 00000000..673749cb --- /dev/null +++ b/internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.up.sql @@ -0,0 +1,18 @@ +-- Migration 000060: add RI Marketplace listing columns to purchase_history. +-- +-- offering_class: 'convertible' or 'standard'; NULL for pre-migration rows. +-- The Sell button renders only when offering_class = 'standard' and the RI is +-- active with remaining term. +-- +-- listing_id: the AWS ReservedInstancesListingId returned by +-- CreateReservedInstancesListing. NULL when not listed. +-- listing_state: mirrors the AWS listing state (active/cancelled/closed). NULL +-- when not listed. +-- +-- All three columns are nullable and added with IF NOT EXISTS so the +-- migration is idempotent on re-apply. + +ALTER TABLE purchase_history + ADD COLUMN IF NOT EXISTS offering_class TEXT, + ADD COLUMN IF NOT EXISTS listing_id TEXT, + ADD COLUMN IF NOT EXISTS listing_state TEXT; diff --git a/internal/mocks/stores.go b/internal/mocks/stores.go index fdbffafa..4ba5f42e 100644 --- a/internal/mocks/stores.go +++ b/internal/mocks/stores.go @@ -198,6 +198,21 @@ func (m *MockConfigStore) GetPurchaseHistoryFiltered(ctx context.Context, provid return args.Get(0).([]config.PurchaseHistoryRecord), args.Error(1) } +// GetPurchaseHistoryByPurchaseID mocks the single-row lookup by purchase_id (issue #292). +func (m *MockConfigStore) GetPurchaseHistoryByPurchaseID(ctx context.Context, purchaseID string) (*config.PurchaseHistoryRecord, error) { + args := m.Called(ctx, purchaseID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*config.PurchaseHistoryRecord), args.Error(1) +} + +// UpdatePurchaseHistoryListing mocks stamping listing_id and listing_state (issue #292). +func (m *MockConfigStore) UpdatePurchaseHistoryListing(ctx context.Context, purchaseID, listingID, listingState string) error { + args := m.Called(ctx, purchaseID, listingID, listingState) + return args.Error(0) +} + func (m *MockConfigStore) SaveRIExchangeRecord(ctx context.Context, record *config.RIExchangeRecord) error { args := m.Called(ctx, record) return args.Error(0) diff --git a/internal/purchase/mocks_test.go b/internal/purchase/mocks_test.go index b34ebfcd..43b6e738 100644 --- a/internal/purchase/mocks_test.go +++ b/internal/purchase/mocks_test.go @@ -360,6 +360,19 @@ func (m *MockConfigStore) GetPurchaseHistoryFiltered(ctx context.Context, provid return args.Get(0).([]config.PurchaseHistoryRecord), args.Error(1) } +func (m *MockConfigStore) GetPurchaseHistoryByPurchaseID(ctx context.Context, purchaseID string) (*config.PurchaseHistoryRecord, error) { + args := m.Called(ctx, purchaseID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*config.PurchaseHistoryRecord), args.Error(1) +} + +func (m *MockConfigStore) UpdatePurchaseHistoryListing(ctx context.Context, purchaseID, listingID, listingState string) error { + args := m.Called(ctx, purchaseID, listingID, listingState) + return args.Error(0) +} + func (m *MockConfigStore) SaveRIExchangeRecord(ctx context.Context, record *config.RIExchangeRecord) error { args := m.Called(ctx, record) return args.Error(0) diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index a292ad51..f0f62af6 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -185,6 +185,19 @@ func (m *MockConfigStore) GetPurchaseHistoryFiltered(ctx context.Context, provid return args.Get(0).([]config.PurchaseHistoryRecord), args.Error(1) } +func (m *MockConfigStore) GetPurchaseHistoryByPurchaseID(ctx context.Context, purchaseID string) (*config.PurchaseHistoryRecord, error) { + args := m.Called(ctx, purchaseID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*config.PurchaseHistoryRecord), args.Error(1) +} + +func (m *MockConfigStore) UpdatePurchaseHistoryListing(ctx context.Context, purchaseID, listingID, listingState string) error { + args := m.Called(ctx, purchaseID, listingID, listingState) + return args.Error(0) +} + func (m *MockConfigStore) GetExecutionByID(ctx context.Context, executionID string) (*config.PurchaseExecution, error) { args := m.Called(ctx, executionID) if args.Get(0) == nil { diff --git a/internal/server/test_helpers_test.go b/internal/server/test_helpers_test.go index ddd51abe..1e2c3a24 100644 --- a/internal/server/test_helpers_test.go +++ b/internal/server/test_helpers_test.go @@ -283,3 +283,10 @@ func (m *mockConfigStoreForHealth) GetPendingExecutionsTx(ctx context.Context, _ func (m *mockConfigStoreForHealth) WithTx(_ context.Context, fn func(tx pgx.Tx) error) error { return fn(nil) } + +func (m *mockConfigStoreForHealth) GetPurchaseHistoryByPurchaseID(_ context.Context, _ string) (*config.PurchaseHistoryRecord, error) { + return nil, nil +} +func (m *mockConfigStoreForHealth) UpdatePurchaseHistoryListing(_ context.Context, _, _, _ string) error { + return nil +} diff --git a/providers/aws/services/ec2/client.go b/providers/aws/services/ec2/client.go index 275946a8..e293abf0 100644 --- a/providers/aws/services/ec2/client.go +++ b/providers/aws/services/ec2/client.go @@ -28,6 +28,9 @@ type EC2API interface { GetReservedInstancesExchangeQuote(ctx context.Context, params *ec2.GetReservedInstancesExchangeQuoteInput, optFns ...func(*ec2.Options)) (*ec2.GetReservedInstancesExchangeQuoteOutput, error) AcceptReservedInstancesExchangeQuote(ctx context.Context, params *ec2.AcceptReservedInstancesExchangeQuoteInput, optFns ...func(*ec2.Options)) (*ec2.AcceptReservedInstancesExchangeQuoteOutput, error) 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 @@ -889,3 +892,131 @@ func normalizationFactorForInstanceType(instanceType string) float64 { } 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 + // 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 +} + +// 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 state: active, cancelled, closed, pending-fulfillment, etc. + 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") + } + + 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(1), + 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 := "" + if listing.ReservedInstancesListingId != nil { + listingID = aws.ToString(listing.ReservedInstancesListingId) + } + 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 := "" + if listing.ReservedInstancesListingId != nil { + resolvedID = aws.ToString(listing.ReservedInstancesListingId) + } + return MarketplaceListingResult{ListingID: resolvedID, State: state}, 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 := "" + if listing.ReservedInstancesListingId != nil { + resolvedID = aws.ToString(listing.ReservedInstancesListingId) + } + return MarketplaceListingResult{ListingID: resolvedID, State: state}, nil +} From d20ff09915f3cca7a995fcee8acf38345be3164a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 28 May 2026 20:00:08 +0200 Subject: [PATCH 2/7] feat(marketplace): sell/cancel Standard RIs on AWS Marketplace - frontend (closes #292) Add Sell on Marketplace / Cancel listing buttons to purchase history rows: - types.ts: extend HistoryPurchase with offering_class, listing_id, listing_state - api/purchases.ts: createMarketplaceListing + cancelMarketplaceListing; re-export from api/index.ts - history.ts: canSellOnMarketplace / canCancelMarketplaceListing predicates; renderActionCell renders Sell/Cancel buttons for Standard RI completed rows; wireRowActionHandlers wires confirm-dialog + API + toast + reload for both buttons --- frontend/src/api/index.ts | 6 +- frontend/src/api/purchases.ts | 45 ++++++++++++++ frontend/src/history.ts | 113 +++++++++++++++++++++++++++++++++- frontend/src/types.ts | 11 ++++ 4 files changed, 172 insertions(+), 3 deletions(-) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 4aa14e77..bac58a79 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -138,9 +138,11 @@ export { resumePlannedPurchase, runPlannedPurchase, deletePlannedPurchase, - createPlannedPurchases + createPlannedPurchases, + createMarketplaceListing, + cancelMarketplaceListing } from './purchases'; -export type { RetryPurchaseResult } from './purchases'; +export type { RetryPurchaseResult, MarketplacePriceTier, MarketplaceListResult } from './purchases'; // Re-export users functions export { diff --git a/frontend/src/api/purchases.ts b/frontend/src/api/purchases.ts index 16941e21..7c5b7955 100644 --- a/frontend/src/api/purchases.ts +++ b/frontend/src/api/purchases.ts @@ -134,3 +134,48 @@ export async function createPlannedPurchases(planId: string, count: number, star body: JSON.stringify({ count, start_date: startDate }) }); } + +// ── RI Marketplace (issue #292) ─────────────────────────────────────────────── + +export interface MarketplacePriceTier { + /** Number of months remaining this price tier covers. */ + term_months: number; + /** USD list price per unit for this tier. */ + price: number; +} + +export interface MarketplaceListResult { + listing_id: string; + listing_state: string; + price_schedule: MarketplacePriceTier[]; + aws_fee_percent: number; + note?: string; +} + +/** + * Create an AWS RI Marketplace listing for a Standard Reserved Instance. + * purchaseId is the purchase_history.purchase_id (AWS ReservedInstancesId). + * priceSchedule is optional: when omitted the backend computes a default. + */ +export async function createMarketplaceListing( + purchaseId: string, + priceSchedule?: MarketplacePriceTier[], +): Promise { + const body = priceSchedule && priceSchedule.length > 0 + ? JSON.stringify({ price_schedule: priceSchedule }) + : undefined; + return apiRequest(`/purchases/${purchaseId}/marketplace-list`, { + method: 'POST', + ...(body ? { body } : {}), + }); +} + +/** + * Cancel an active AWS RI Marketplace listing. + */ +export async function cancelMarketplaceListing(purchaseId: string): Promise<{ listing_id: string; listing_state: string }> { + return apiRequest<{ listing_id: string; listing_state: string }>( + `/purchases/${purchaseId}/marketplace-cancel`, + { method: 'POST' }, + ); +} diff --git a/frontend/src/history.ts b/frontend/src/history.ts index 1d25f6ba..ce8180cd 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -472,6 +472,39 @@ function retryThresholdReached(p: HistoryPurchase): boolean { return (p.retry_attempt_n ?? 0) >= RETRY_THRESHOLD; } +// canSellOnMarketplace returns true when the current session is permitted +// to list the given completed history row on the AWS RI Marketplace +// (issue #292). UX gate only -- the backend authorizeSessionSell in +// internal/api/handler_marketplace.go remains the security boundary; a +// false-positive here surfaces as a 403 toast on click. +// +// Conditions: +// * row must be a completed purchase (status "completed" or absent); +// * offering_class must be "standard"; +// * no active listing already (listing_state != "active"); and +// * admin, or non-admin user (sell-own covers their own accounts -- +// we can't efficiently check per-account ownership client-side, so +// we show the button for all non-admin users and let the backend 403 +// when the account is out of scope). +function canSellOnMarketplace(p: HistoryPurchase): boolean { + const status = normalizeStatus(p).toLowerCase(); + if (status !== 'completed') return false; + if ((p.offering_class || '').toLowerCase() !== 'standard') return false; + if ((p.listing_state || '').toLowerCase() === 'active') return false; + const user = getCurrentUser(); + if (!user) return false; + return true; +} + +// canCancelMarketplaceListing returns true when there is an active listing +// that the current session can cancel. +function canCancelMarketplaceListing(p: HistoryPurchase): boolean { + if ((p.listing_state || '').toLowerCase() !== 'active') return false; + const user = getCurrentUser(); + if (!user) return false; + return true; +} + // shortExecID renders the first 8 chars of a UUID so inline lineage // links ("Retried as #abc12345") stay readable in the table cell. The // full ID is preserved in the data-history-status attribute so the @@ -494,7 +527,9 @@ function sameRowActions(btn: HTMLButtonElement): HTMLButtonElement[] { const cell = btn.closest('td') || btn.parentElement; if (!cell) return [btn]; return Array.from( - cell.querySelectorAll('.history-approve-btn, .history-cancel-btn'), + cell.querySelectorAll( + '.history-approve-btn, .history-cancel-btn, .history-marketplace-sell-btn, .history-marketplace-cancel-btn', + ), ); } @@ -577,6 +612,18 @@ function renderActionCell(p: HistoryPurchase): string { return lineage.join(' '); } + // Completed Standard RI rows: offer Sell on Marketplace (issue #292). + // The button is placed here (after lineage links) so it does not + // interfere with the Retry / Approve / Cancel affordances above. + if (p.purchase_id) { + if (canCancelMarketplaceListing(p)) { + return ``; + } + if (canSellOnMarketplace(p)) { + return ``; + } + } + return escapeHtml(p.plan_name || '-'); } @@ -869,6 +916,70 @@ function wireRowActionHandlers(container: HTMLElement): void { } }); }); + + // Wire Sell on Marketplace button (issue #292) + container.querySelectorAll('.history-marketplace-sell-btn[data-marketplace-sell-id]').forEach(btn => { + btn.addEventListener('click', async () => { + const id = btn.dataset['marketplaceSellId']; + if (!id) return; + const ok = await confirmDialog({ + title: 'List this RI on the AWS Marketplace?', + body: 'This will create a binding listing on the AWS Marketplace. AWS charges a 12% transaction fee on proceeds. Make sure your AWS account has a US bank account on file as a Marketplace seller. This action cannot be undone without cancelling the listing.', + confirmLabel: 'Sell on Marketplace', + destructive: false, + }); + if (!ok) return; + const rowActions = sameRowActions(btn); + rowActions.forEach(b => { b.disabled = true; }); + try { + await api.createMarketplaceListing(id); + } catch (sellError) { + console.error('Failed to list RI on Marketplace:', sellError); + const err = sellError as Error; + showToast({ message: `Failed to list on Marketplace: ${err.message || 'unknown error'}`, kind: 'error' }); + rowActions.forEach(b => { b.disabled = false; }); + return; + } + showToast({ message: 'RI listed on Marketplace successfully', kind: 'success', timeout: 5_000 }); + try { + await loadHistory(); + } catch (reloadError) { + console.error('Failed to reload history after Marketplace listing:', reloadError); + } + }); + }); + + // Wire Cancel listing button (issue #292) + container.querySelectorAll('.history-marketplace-cancel-btn[data-marketplace-cancel-id]').forEach(btn => { + btn.addEventListener('click', async () => { + const id = btn.dataset['marketplaceCancelId']; + if (!id) return; + const ok = await confirmDialog({ + title: 'Cancel this Marketplace listing?', + body: 'This will remove the listing from the AWS Marketplace. Any existing buyer negotiations will be cancelled. You can relist the RI at any time.', + confirmLabel: 'Cancel listing', + destructive: true, + }); + if (!ok) return; + const rowActions = sameRowActions(btn); + rowActions.forEach(b => { b.disabled = true; }); + try { + await api.cancelMarketplaceListing(id); + } catch (cancelError) { + console.error('Failed to cancel Marketplace listing:', cancelError); + const err = cancelError as Error; + showToast({ message: `Failed to cancel listing: ${err.message || 'unknown error'}`, kind: 'error' }); + rowActions.forEach(b => { b.disabled = false; }); + return; + } + showToast({ message: 'Marketplace listing cancelled', kind: 'success', timeout: 5_000 }); + try { + await loadHistory(); + } catch (reloadError) { + console.error('Failed to reload history after Marketplace cancel:', reloadError); + } + }); + }); } // isPendingRow returns true when a history row represents a purchase diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ca2f495d..ddfbcea1 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -245,6 +245,17 @@ export interface HistoryPurchase { // absent otherwise. Replaces the Retry button entirely — there is // no actionable retry from a persistent misconfig. ops_hint?: string; + // OfferingClass is "standard" or "convertible" for EC2 RIs. Absent + // on non-EC2 rows and on rows written before migration 000060. + // The "Sell on Marketplace" button only renders when this equals + // "standard" (issue #292). + offering_class?: string; + // ListingID is the AWS ReservedInstancesListingId. Non-empty when an + // active or recently-closed marketplace listing exists (issue #292). + listing_id?: string; + // ListingState is the AWS marketplace listing state: "active", + // "cancelled", or "closed". Empty when not listed. + listing_state?: string; } // Savings Analytics types From 2e22d555c4541111827f15d24b3acc19e92f841c Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 30 May 2026 19:06:40 +0200 Subject: [PATCH 3/7] fix(history): address CR wave4 frontend marketplace findings - canSellOnMarketplace: gate on remaining term >= 1 month computed from purchase timestamp + total term; matured Standard RIs no longer show Sell - sell click handler: open pricing/schedule modal before calling createMarketplaceListing so users see RI summary, default list price, and 12% fee breakdown before confirming - lineage actions cell: build trailingActions[] first then combine with lineage[] so Sell/Cancel buttons render on retry-descendant rows --- frontend/src/history.ts | 88 +++++++++++++++++++++++++++++++++++------ 1 file changed, 75 insertions(+), 13 deletions(-) diff --git a/frontend/src/history.ts b/frontend/src/history.ts index ce8180cd..16875917 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -493,6 +493,16 @@ function canSellOnMarketplace(p: HistoryPurchase): boolean { if ((p.listing_state || '').toLowerCase() === 'active') return false; const user = getCurrentUser(); if (!user) return false; + // Guard against listing a matured RI: compute remaining months from the + // purchase timestamp and the total term. term is in months; timestamp is + // the purchase date. We require at least 1 full month remaining. + const termMonths = typeof p.term === 'number' ? p.term : Number(p.term) || 0; + if (termMonths <= 0) return false; + const purchaseMs = new Date(p.timestamp).getTime(); + if (!Number.isFinite(purchaseMs)) return false; + const elapsedMonths = (Date.now() - purchaseMs) / (1000 * 60 * 60 * 24 * 30.4375); + const remainingMonths = termMonths - elapsedMonths; + if (remainingMonths < 1) return false; return true; } @@ -608,22 +618,23 @@ function renderActionCell(p: HistoryPurchase): string { // see "this is a retry" provenance. lineage.push(`↻ Retry #${p.retry_attempt_n}`); } - if (lineage.length > 0) { - return lineage.join(' '); - } - // Completed Standard RI rows: offer Sell on Marketplace (issue #292). - // The button is placed here (after lineage links) so it does not - // interfere with the Retry / Approve / Cancel affordances above. + // Build the trailing action buttons first so they compose with lineage + // links — a retry-descendant can also have an active listing that needs + // a Cancel button. + const trailingActions: string[] = []; if (p.purchase_id) { if (canCancelMarketplaceListing(p)) { - return ``; - } - if (canSellOnMarketplace(p)) { - return ``; + trailingActions.push(``); + } else if (canSellOnMarketplace(p)) { + trailingActions.push(``); } } + if (lineage.length > 0 || trailingActions.length > 0) { + return [...lineage, ...trailingActions].join(' '); + } + return escapeHtml(p.plan_name || '-'); } @@ -917,18 +928,69 @@ function wireRowActionHandlers(container: HTMLElement): void { }); }); - // Wire Sell on Marketplace button (issue #292) + // Wire Sell on Marketplace button (issue #292). + // Flow: pricing/schedule modal (RI summary + default price + 12% fee) → + // user confirms → createMarketplaceListing. We never skip the pricing + // modal (CR finding: going straight from confirmDialog to the API call + // denies the user informed consent about the price and fee). container.querySelectorAll('.history-marketplace-sell-btn[data-marketplace-sell-id]').forEach(btn => { btn.addEventListener('click', async () => { const id = btn.dataset['marketplaceSellId']; if (!id) return; + + // Look up the purchase record so we can show a meaningful price summary. + const purchase = lastPurchases.find(p => p.purchase_id === id); + + // Build a pricing modal body with RI summary and fee breakdown. + const bodyEl = document.createElement('div'); + bodyEl.className = 'marketplace-pricing-modal-body'; + + if (purchase) { + const termMonths = typeof purchase.term === 'number' ? purchase.term : Number(purchase.term) || 0; + const purchaseMs = new Date(purchase.timestamp).getTime(); + const elapsedMonths = Number.isFinite(purchaseMs) + ? (Date.now() - purchaseMs) / (1000 * 60 * 60 * 24 * 30.4375) + : 0; + const remainingMonths = Math.max(0, Math.round(termMonths - elapsedMonths)); + const upfront = purchase.upfront_cost ?? 0; + const monthly = purchase.monthly_cost ?? 0; + const totalValue = upfront + monthly * remainingMonths; + const listPrice = totalValue * 0.95; + const netProceeds = listPrice * 0.88; + + const summaryEl = document.createElement('dl'); + summaryEl.className = 'marketplace-pricing-summary'; + const addRow = (label: string, value: string): void => { + const dt = document.createElement('dt'); + dt.textContent = label; + const dd = document.createElement('dd'); + dd.textContent = value; + summaryEl.appendChild(dt); + summaryEl.appendChild(dd); + }; + addRow('RI ID', id); + addRow('Region', purchase.region || '-'); + addRow('Resource type', purchase.resource_type || '-'); + addRow('Remaining term', remainingMonths === 1 ? '1 month' : `${remainingMonths} months`); + addRow('Default list price', formatCurrency(listPrice)); + addRow('AWS fee (12%)', formatCurrency(listPrice * 0.12)); + addRow('Estimated net proceeds', formatCurrency(netProceeds)); + bodyEl.appendChild(summaryEl); + } + + const noteEl = document.createElement('p'); + noteEl.className = 'marketplace-pricing-note'; + noteEl.textContent = 'AWS charges a 12% transaction fee on proceeds. The default schedule prices the listing at 5% below remaining value. You can adjust pricing by contacting your administrator or modifying the schedule via the API. This action cannot be undone without cancelling the listing.'; + bodyEl.appendChild(noteEl); + const ok = await confirmDialog({ title: 'List this RI on the AWS Marketplace?', - body: 'This will create a binding listing on the AWS Marketplace. AWS charges a 12% transaction fee on proceeds. Make sure your AWS account has a US bank account on file as a Marketplace seller. This action cannot be undone without cancelling the listing.', - confirmLabel: 'Sell on Marketplace', + body: bodyEl, + confirmLabel: 'Confirm listing', destructive: false, }); if (!ok) return; + const rowActions = sameRowActions(btn); rowActions.forEach(b => { b.disabled = true; }); try { From b3a9edb835d8222cae0708c9f4e25efd4c02ec86 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 30 May 2026 19:06:56 +0200 Subject: [PATCH 4/7] fix(marketplace): address CR wave4 Go handler findings - Compute actual remaining months from purchase timestamp and total term via computeRemainingMonths; removes overpricing of older RIs in the default price schedule (was using full contract term) - Map AWS errors via mapAWSMarketplaceError: inspect smithy.APIError code and fault; client-fault errors return 4xx instead of blanket 502 - On DB failure after successful listing creation, attempt compensating rollback via CancelMarketplaceListing to prevent AWS/DB desync; return internal error rather than false success to the caller - Same DB-failure fix for cancel handler: return error on UpdatePurchaseHistoryListing failure so the caller knows the state is out of sync --- internal/api/handler_marketplace.go | 91 ++++++++++++++++++++++++----- 1 file changed, 78 insertions(+), 13 deletions(-) diff --git a/internal/api/handler_marketplace.go b/internal/api/handler_marketplace.go index a587e7b4..017d2340 100644 --- a/internal/api/handler_marketplace.go +++ b/internal/api/handler_marketplace.go @@ -15,8 +15,11 @@ package api import ( "context" "encoding/json" + "errors" "fmt" + "math" "strings" + "time" "github.com/LeanerCloud/CUDly/internal/auth" "github.com/LeanerCloud/CUDly/pkg/logging" @@ -24,6 +27,7 @@ import ( ec2svc "github.com/LeanerCloud/CUDly/providers/aws/services/ec2" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-sdk-go-v2/aws" + smithy "github.com/aws/smithy-go" "github.com/google/uuid" ) @@ -113,8 +117,13 @@ func (h *Handler) marketplaceList(ctx context.Context, req *events.LambdaFunctio } } + // Compute actual remaining months from the purchase timestamp and total + // term so the default price schedule reflects real remaining value + // rather than the full contract term (which overprices older RIs). + remainingMonths := computeRemainingMonths(row.Timestamp, row.Term) + // Validate and normalise the price schedule. - schedule, err := resolveMarketplacePriceSchedule(body.PriceSchedule, row.Term, row.UpfrontCost, row.MonthlyCost) + schedule, err := resolveMarketplacePriceSchedule(body.PriceSchedule, remainingMonths, row.UpfrontCost, row.MonthlyCost) if err != nil { return nil, NewClientError(400, err.Error()) } @@ -141,18 +150,21 @@ func (h *Handler) marketplaceList(ctx context.Context, req *events.LambdaFunctio PriceSchedule: awsSchedule, }) if err != nil { - // Preserve the AWS error message verbatim for 4xx errors (e.g. missing - // seller account, invalid listing) so the frontend can surface it. logging.Warnf("marketplace: CreateReservedInstancesListing for purchase %s failed: %v", purchaseID, err) - return nil, NewClientError(502, "AWS marketplace listing failed: "+err.Error()) + return nil, mapAWSMarketplaceError("AWS marketplace listing failed", err) } - // Persist the listing ID and state. + // Persist the listing ID and state. On DB failure, attempt a compensating + // rollback (cancel the just-created listing) to avoid a desync where the + // user sees success but the listing is invisible in subsequent renders. if dbErr := h.config.UpdatePurchaseHistoryListing(ctx, purchaseID, result.ListingID, result.State); dbErr != nil { - // Log the error but return success to the caller — the listing was - // created in AWS; a DB write failure here must not be surfaced as a - // 5xx that could cause the user to create a duplicate listing. - logging.Errorf("marketplace: listing created (%s / %s) but DB update failed: %v", result.ListingID, result.State, dbErr) + logging.Errorf("marketplace: listing created (%s / %s) but DB update failed: %v — attempting rollback", result.ListingID, result.State, dbErr) + if _, rollbackErr := ec2Client.CancelMarketplaceListing(ctx, result.ListingID); rollbackErr != nil { + logging.Errorf("marketplace: rollback cancel for listing %s also failed: %v", result.ListingID, rollbackErr) + } else { + logging.Warnf("marketplace: listing %s rolled back (cancelled) after DB failure", result.ListingID) + } + return nil, fmt.Errorf("listing created but could not be persisted; listing has been rolled back: %w", dbErr) } return &MarketplaceListResponse{ @@ -201,11 +213,15 @@ func (h *Handler) marketplaceCancel(ctx context.Context, req *events.LambdaFunct result, err := ec2Client.CancelMarketplaceListing(ctx, row.ListingID) if err != nil { logging.Warnf("marketplace: CancelReservedInstancesListing for listing %s failed: %v", row.ListingID, err) - return nil, NewClientError(502, "AWS cancel listing failed: "+err.Error()) + return nil, mapAWSMarketplaceError("AWS cancel listing failed", err) } + // The listing is already cancelled in AWS; there is no compensating rollback + // available if the DB write fails. Return an internal error so the caller + // knows the state is out of sync and can retry or contact an administrator. if dbErr := h.config.UpdatePurchaseHistoryListing(ctx, purchaseID, result.ListingID, result.State); dbErr != nil { - logging.Errorf("marketplace: listing cancelled (%s) but DB update failed: %v", result.ListingID, dbErr) + logging.Errorf("marketplace: listing cancelled in AWS (%s) but DB update failed: %v — state is out of sync", result.ListingID, dbErr) + return nil, fmt.Errorf("listing cancelled in AWS but could not be persisted: %w", dbErr) } return map[string]string{"listing_id": result.ListingID, "listing_state": result.State}, nil @@ -290,14 +306,63 @@ func (h *Handler) authorizeAllowedAccount(ctx context.Context, session *Session, return NewClientError(403, "permission denied: purchase is in a cloud account not covered by your session's allowed accounts") } +// computeRemainingMonths returns the number of whole months remaining on an RI +// given its purchase timestamp and total term in months. The result is floored +// at 1 so defensive callers always get a positive value. +func computeRemainingMonths(purchaseTime time.Time, termMonths int) int { + if purchaseTime.IsZero() || termMonths <= 0 { + return 1 + } + elapsed := time.Since(purchaseTime) + elapsedMonths := elapsed.Hours() / (24 * 30.4375) + remaining := float64(termMonths) - elapsedMonths + r := int(math.Floor(remaining)) + if r < 1 { + return 1 + } + return r +} + +// awsMarketplaceClientFaultCodes is the set of AWS error codes that represent +// client-side faults for Marketplace listing operations. These map to 4xx +// responses so the caller receives an actionable message. Server-side AWS +// errors remain 5xx. +var awsMarketplaceClientFaultCodes = map[string]bool{ + "InvalidReservedInstancesId": true, + "InvalidReservedInstancesId.NotFound": true, + "InvalidParameterValue": true, + "InvalidParameter": true, + "IncorrectState": true, + "InvalidReservedInstancesListingId": true, + "ReservedInstancesListingAlreadyExists": true, + "SellerNotRegistered": true, + "AuthFailure": true, + "UnauthorizedOperation": true, +} + +// mapAWSMarketplaceError maps an AWS SDK error to an appropriate ClientError. +// AWS client-fault errors (4xx-category codes) produce a 4xx response with the +// original AWS message so the caller gets actionable feedback. All other errors +// produce a 502 (AWS-side failure). +func mapAWSMarketplaceError(opMsg string, err error) error { + var apiErr smithy.APIError + if errors.As(err, &apiErr) { + if awsMarketplaceClientFaultCodes[apiErr.ErrorCode()] || apiErr.ErrorFault() == smithy.FaultClient { + return NewClientError(400, apiErr.ErrorMessage()) + } + } + return NewClientError(502, opMsg+": "+err.Error()) +} + // resolveMarketplacePriceSchedule returns a normalised price schedule for the // given RI. When the caller supplied an explicit schedule it is validated and // returned unchanged. When the caller omitted the schedule (nil / empty), a // single-tier default is computed: total remaining value * 0.95 (5% discount // to attract buyers; the 12% AWS fee is applied by the Marketplace on top). // -// remainingMonths is rec.Term (AWS RI term in months), upfrontCost is the -// original upfront paid, monthlyCost is the ongoing recurring charge. +// remainingMonths must be the actual remaining months (computed via +// computeRemainingMonths from purchase timestamp and total term — NOT the raw +// term field, which would overprice older RIs). func resolveMarketplacePriceSchedule(supplied []MarketplacePriceTier, remainingMonths int, upfrontCost, monthlyCost float64) ([]MarketplacePriceTier, error) { if len(supplied) > 0 { for i, t := range supplied { From 658fd944cb23ca96010be9a81aa35a25622e14fa Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 30 May 2026 19:07:07 +0200 Subject: [PATCH 5/7] fix(migrations): add settlement columns to 000060 marketplace migration Add listed_at, listing_price_schedule, listing_proceeds_received, and listing_fee_paid columns for marketplace settlement tracking. All nullable, consistent with existing offering_class/listing_id/listing_state nullability. Down migration updated to drop the new columns. --- ...00060_purchase_history_marketplace_listing.down.sql | 6 +++++- .../000060_purchase_history_marketplace_listing.up.sql | 10 +++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.down.sql b/internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.down.sql index dda98d24..20a53aa5 100644 --- a/internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.down.sql +++ b/internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.down.sql @@ -2,4 +2,8 @@ ALTER TABLE purchase_history DROP COLUMN IF EXISTS offering_class, DROP COLUMN IF EXISTS listing_id, - DROP COLUMN IF EXISTS listing_state; + DROP COLUMN IF EXISTS listing_state, + DROP COLUMN IF EXISTS listed_at, + DROP COLUMN IF EXISTS listing_price_schedule, + DROP COLUMN IF EXISTS listing_proceeds_received, + DROP COLUMN IF EXISTS listing_fee_paid; diff --git a/internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.up.sql b/internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.up.sql index 673749cb..9b81ee49 100644 --- a/internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.up.sql +++ b/internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.up.sql @@ -13,6 +13,10 @@ -- migration is idempotent on re-apply. ALTER TABLE purchase_history - ADD COLUMN IF NOT EXISTS offering_class TEXT, - ADD COLUMN IF NOT EXISTS listing_id TEXT, - ADD COLUMN IF NOT EXISTS listing_state TEXT; + ADD COLUMN IF NOT EXISTS offering_class TEXT, + ADD COLUMN IF NOT EXISTS listing_id TEXT, + ADD COLUMN IF NOT EXISTS listing_state TEXT, + ADD COLUMN IF NOT EXISTS listed_at TIMESTAMP WITH TIME ZONE, + ADD COLUMN IF NOT EXISTS listing_price_schedule JSONB, + ADD COLUMN IF NOT EXISTS listing_proceeds_received NUMERIC, + ADD COLUMN IF NOT EXISTS listing_fee_paid NUMERIC; From b31cba8c72b99dcf87b7438bad8ac93df806986f Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 30 May 2026 19:07:19 +0200 Subject: [PATCH 6/7] fix(ec2): validate non-empty listing ID from CreateReservedInstancesListing Return an error when AWS returns a listing with an empty ReservedInstancesListingId rather than propagating a blank ID that would silently make rollback and describe calls fail. --- providers/aws/services/ec2/client.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/providers/aws/services/ec2/client.go b/providers/aws/services/ec2/client.go index e293abf0..921aba43 100644 --- a/providers/aws/services/ec2/client.go +++ b/providers/aws/services/ec2/client.go @@ -972,9 +972,9 @@ func (c *Client) CreateMarketplaceListing(ctx context.Context, req MarketplaceLi if listing.Status != "" { state = string(listing.Status) } - listingID := "" - if listing.ReservedInstancesListingId != nil { - listingID = aws.ToString(listing.ReservedInstancesListingId) + 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 } From dee81b29fd6e0f99f91cf1a4d627c981f70e18e3 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 1 Jun 2026 19:27:37 +0200 Subject: [PATCH 7/7] fix(pr-808): compile + CR findings + pre-commit Repair the stalled marketplace sell/cancel work: - Add the three missing marketplace methods to MockEC2Client so the ec2 package compiles against the extended EC2API interface (CreateReservedInstancesListing, DescribeReservedInstancesListings, CancelReservedInstancesListing). - Extract validateMarketplaceListRequest from marketplaceList to bring its cyclomatic complexity back under the gocyclo threshold. - Regenerate frontend/src/permissions.generated.ts to include the new sell-own:purchases permission (pre-commit codegen check). - Harden Describe/Cancel marketplace paths to fall back to the caller-supplied listing ID when AWS omits ReservedInstancesListingId, so downstream persistence never stores an empty ID (CR finding). --- frontend/src/permissions.generated.ts | 1 + internal/api/handler_marketplace.go | 50 +++++++++++++++-------- providers/aws/services/ec2/client.go | 12 +++--- providers/aws/services/ec2/client_test.go | 24 +++++++++++ 4 files changed, 64 insertions(+), 23 deletions(-) diff --git a/frontend/src/permissions.generated.ts b/frontend/src/permissions.generated.ts index 3065e83b..92bf2e95 100644 --- a/frontend/src/permissions.generated.ts +++ b/frontend/src/permissions.generated.ts @@ -24,6 +24,7 @@ export const USER_PERMS: ReadonlySet = new Set([ 'create:plans', 'delete:plans', 'retry-own:purchases', + 'sell-own:purchases', 'update:plans', 'update:purchases', 'view:history', diff --git a/internal/api/handler_marketplace.go b/internal/api/handler_marketplace.go index 017d2340..7a25050a 100644 --- a/internal/api/handler_marketplace.go +++ b/internal/api/handler_marketplace.go @@ -22,6 +22,7 @@ import ( "time" "github.com/LeanerCloud/CUDly/internal/auth" + "github.com/LeanerCloud/CUDly/internal/config" "github.com/LeanerCloud/CUDly/pkg/logging" awsprovider "github.com/LeanerCloud/CUDly/providers/aws" ec2svc "github.com/LeanerCloud/CUDly/providers/aws/services/ec2" @@ -66,57 +67,72 @@ type MarketplaceListRequest struct { // MarketplaceListResponse is the JSON response body for a successful listing. type MarketplaceListResponse struct { - ListingID string `json:"listing_id"` - ListingState string `json:"listing_state"` - PriceSchedule []MarketplacePriceTier `json:"price_schedule"` - AWSFeePercent float64 `json:"aws_fee_percent"` - Note string `json:"note,omitempty"` + ListingID string `json:"listing_id"` + ListingState string `json:"listing_state"` + PriceSchedule []MarketplacePriceTier `json:"price_schedule"` + AWSFeePercent float64 `json:"aws_fee_percent"` + Note string `json:"note,omitempty"` } -// marketplaceList handles POST /api/purchases/{id}/marketplace-list. -// The {id} must be the purchase_history.purchase_id (AWS ReservedInstancesId). -func (h *Handler) marketplaceList(ctx context.Context, req *events.LambdaFunctionURLRequest, purchaseID string) (any, error) { +// validateMarketplaceListRequest performs the pre-flight checks shared by the +// listing flow: session auth, UUID validation, row lookup, offering_class / +// RBAC / duplicate-listing gates, and optional body decode. It returns the +// validated history row and the decoded request body. Extracted from +// marketplaceList to keep that handler's cyclomatic complexity in check. +func (h *Handler) validateMarketplaceListRequest(ctx context.Context, req *events.LambdaFunctionURLRequest, purchaseID string) (*config.PurchaseHistoryRecord, MarketplaceListRequest, error) { + var body MarketplaceListRequest + session, err := h.requireSession(ctx, req) if err != nil { - return nil, err + return nil, body, err } if err := validateUUID(purchaseID); err != nil { - return nil, err + return nil, body, err } // Look up the purchase_history row to validate offering_class + get metadata. row, err := h.config.GetPurchaseHistoryByPurchaseID(ctx, purchaseID) if err != nil { - return nil, fmt.Errorf("failed to look up purchase: %w", err) + return nil, body, fmt.Errorf("failed to look up purchase: %w", err) } if row == nil { - return nil, NewClientError(404, "purchase not found") + return nil, body, NewClientError(404, "purchase not found") } // Only Standard RIs can be listed on the Marketplace. if !strings.EqualFold(row.OfferingClass, "standard") { - return nil, NewClientError(400, "only Standard Reserved Instances can be listed on the AWS Marketplace; this purchase has offering_class="+row.OfferingClass) + return nil, body, NewClientError(400, "only Standard Reserved Instances can be listed on the AWS Marketplace; this purchase has offering_class="+row.OfferingClass) } // Enforce sell-any / sell-own RBAC. if err := h.authorizeSessionSell(ctx, session, row.CloudAccountID); err != nil { - return nil, err + return nil, body, err } // Reject if a listing is already active to avoid duplicate listings. if strings.EqualFold(row.ListingState, "active") { - return nil, NewClientError(409, fmt.Sprintf("an active marketplace listing %s already exists for this RI; cancel it first", row.ListingID)) + return nil, body, NewClientError(409, fmt.Sprintf("an active marketplace listing %s already exists for this RI; cancel it first", row.ListingID)) } // Decode optional body. - var body MarketplaceListRequest if len(req.Body) > 0 { if err := json.Unmarshal([]byte(req.Body), &body); err != nil { - return nil, NewClientError(400, "invalid request body: "+err.Error()) + return nil, body, NewClientError(400, "invalid request body: "+err.Error()) } } + return row, body, nil +} + +// marketplaceList handles POST /api/purchases/{id}/marketplace-list. +// The {id} must be the purchase_history.purchase_id (AWS ReservedInstancesId). +func (h *Handler) marketplaceList(ctx context.Context, req *events.LambdaFunctionURLRequest, purchaseID string) (any, error) { + row, body, err := h.validateMarketplaceListRequest(ctx, req, purchaseID) + if err != nil { + return nil, err + } + // Compute actual remaining months from the purchase timestamp and total // term so the default price schedule reflects real remaining value // rather than the full contract term (which overprices older RIs). diff --git a/providers/aws/services/ec2/client.go b/providers/aws/services/ec2/client.go index 921aba43..6e58e218 100644 --- a/providers/aws/services/ec2/client.go +++ b/providers/aws/services/ec2/client.go @@ -993,9 +993,9 @@ func (c *Client) DescribeMarketplaceListing(ctx context.Context, listingID strin } listing := out.ReservedInstancesListings[0] state := string(listing.Status) - resolvedID := "" - if listing.ReservedInstancesListingId != nil { - resolvedID = aws.ToString(listing.ReservedInstancesListingId) + resolvedID := aws.ToString(listing.ReservedInstancesListingId) + if resolvedID == "" { + resolvedID = listingID } return MarketplaceListingResult{ListingID: resolvedID, State: state}, nil } @@ -1014,9 +1014,9 @@ func (c *Client) CancelMarketplaceListing(ctx context.Context, listingID string) } listing := out.ReservedInstancesListings[0] state := string(listing.Status) - resolvedID := "" - if listing.ReservedInstancesListingId != nil { - resolvedID = aws.ToString(listing.ReservedInstancesListingId) + resolvedID := aws.ToString(listing.ReservedInstancesListingId) + if resolvedID == "" { + resolvedID = listingID } return MarketplaceListingResult{ListingID: resolvedID, State: state}, nil } diff --git a/providers/aws/services/ec2/client_test.go b/providers/aws/services/ec2/client_test.go index e5085a76..dfe8c942 100644 --- a/providers/aws/services/ec2/client_test.go +++ b/providers/aws/services/ec2/client_test.go @@ -75,6 +75,30 @@ func (m *MockEC2Client) CreateTags(ctx context.Context, params *ec2.CreateTagsIn return args.Get(0).(*ec2.CreateTagsOutput), args.Error(1) } +func (m *MockEC2Client) CreateReservedInstancesListing(ctx context.Context, params *ec2.CreateReservedInstancesListingInput, optFns ...func(*ec2.Options)) (*ec2.CreateReservedInstancesListingOutput, error) { + args := m.Called(ctx, params) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*ec2.CreateReservedInstancesListingOutput), args.Error(1) +} + +func (m *MockEC2Client) DescribeReservedInstancesListings(ctx context.Context, params *ec2.DescribeReservedInstancesListingsInput, optFns ...func(*ec2.Options)) (*ec2.DescribeReservedInstancesListingsOutput, error) { + args := m.Called(ctx, params) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*ec2.DescribeReservedInstancesListingsOutput), args.Error(1) +} + +func (m *MockEC2Client) CancelReservedInstancesListing(ctx context.Context, params *ec2.CancelReservedInstancesListingInput, optFns ...func(*ec2.Options)) (*ec2.CancelReservedInstancesListingOutput, error) { + args := m.Called(ctx, params) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*ec2.CancelReservedInstancesListingOutput), args.Error(1) +} + func TestNewClient(t *testing.T) { t.Parallel() cfg := aws.Config{