Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions internal/api/handler_marketplace.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/LeanerCloud/cloud-commitments-platform/internal/auth"
"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
"github.com/LeanerCloud/cloud-commitments-platform/internal/credentials"
"github.com/LeanerCloud/cloud-commitments-platform/internal/purchase"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-sdk-go-v2/aws"
ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
Expand Down Expand Up @@ -309,11 +310,11 @@ func (h *Handler) keepUncanceledListing(ctx context.Context, purchaseID string,
if state == "" {
state = config.ListingStatePending
}
cancelDetail := cancelErr.Error()
_, cancelDetail := purchase.SummarizeProviderError(cancelErr)
if isRoleAssumptionFailure(cancelErr) {
cancelDetail = marketplaceRoleAssumptionMessage
}
msg := fmt.Sprintf("marketplace listing %s was created but could not be saved, and canceling it failed (%s); the listing may still be active on AWS", listing.ListingID, cancelDetail)
msg := fmt.Sprintf("marketplace listing %s was created but could not be saved, and canceling it failed (%s); the listing may still be active on AWS (reference: purchase %s)", listing.ListingID, cancelDetail, purchaseID)
if err := h.config.UpdatePurchaseHistoryListing(ctx, purchaseID, listing.ListingID, state); err != nil {
if isListingEndedConflict(err) {
return listingCanceledConcurrently(listing.ListingID)
Expand Down Expand Up @@ -472,8 +473,8 @@ func roleAssumptionOperationError(err error) *smithy.OperationError {
const marketplaceSellerNotRegisteredReason = "seller_not_registered"

// 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. SellerNotRegistered
// AWS client-fault errors (4xx-category codes) produce a 400 with a category
// message, never the AWS text, which carries ARNs and request IDs. SellerNotRegistered
// is the exception: its 400 carries marketplaceSellerNotRegisteredMessage and
// reason seller_not_registered instead. All other errors produce a 502
// (AWS-side failure). STS AssumeRole failures are classified by
Expand All @@ -489,12 +490,23 @@ func mapAWSMarketplaceError(opMsg string, err error) error {
map[string]any{"reason": marketplaceSellerNotRegisteredReason})
}
if awsMarketplaceClientFaultCodes[apiErr.ErrorCode()] || apiErr.ErrorFault() == smithy.FaultClient {
return NewClientError(400, apiErr.ErrorMessage())
logging.Warnf("marketplace: %s: provider rejected the request: %v", opMsg, err)
category, msg := purchase.SummarizeProviderError(err)
if category == purchase.ErrCategoryUnknown {
msg = marketplaceRejectedMessage
}
return NewClientError(400, msg)
}
}
return NewClientError(502, opMsg+": "+err.Error())
logging.Warnf("marketplace: %s: %v", opMsg, err)
_, msg := purchase.SummarizeProviderError(err)
return NewClientError(502, opMsg+": "+msg)
}

// marketplaceRejectedMessage is the fixed text for a client-fault rejection
// the error classifier has no category for; the provider's text stays in the log.
const marketplaceRejectedMessage = "AWS rejected the listing request as invalid: check the listing parameters"

// isKnownNonStandardOfferingClass reports true when offering_class is
// explicitly set to a non-standard value (e.g. "convertible"). An empty string
// returns false because the class is not yet known and will be resolved lazily.
Expand Down
2 changes: 1 addition & 1 deletion internal/api/handler_marketplace_attempt.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ func (h *Handler) createAndPersistListing(ctx context.Context, purchaseID string
// the listing unless it rejected the request as a client fault, so only
// then is the token dropped; otherwise the retry resends it.
h.releaseMarketplaceClaim(ctx, purchaseID, claim, !isClientFaultError(mapped))
logging.Warnf("marketplace: CreateReservedInstancesListing for purchase %s failed: %v", purchaseID, err)
logging.Warnf("marketplace: CreateReservedInstancesListing for purchase %s failed", purchaseID)
return ec2MarketplaceListingResult{}, mapped
}

Expand Down
2 changes: 1 addition & 1 deletion internal/api/handler_marketplace_cancel.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ func (h *Handler) cancelListing(ctx context.Context, purchaseID string, seen *co
if err != nil {
ended, ok := endedMarketplaceListing(ctx, ec2Client, listingID, err)
if !ok {
logging.Warnf("marketplace: CancelReservedInstancesListing for listing %s failed: %v", listingID, err)
logging.Warnf("marketplace: CancelReservedInstancesListing for listing %s failed", listingID)
return nil, mapAWSMarketplaceError("AWS cancel listing failed", err)
}
result = ended
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ func TestMarketplaceListCancelFailureKeepsListingRecorded(t *testing.T) {
require.True(t, ok, "%v", err)
assert.Equal(t, 502, ce.code)
assert.NotContains(t, ce.message, "rolled back")
assert.Contains(t, ce.message, "RequestLimitExceeded")
// The provider's text stays in the log; the user gets the summary and a reference (#1702).
assert.NotContains(t, ce.message, "RequestLimitExceeded")
assert.Contains(t, ce.message, "reference: purchase "+row.PurchaseID)

stored, err := store.GetPurchaseHistoryByPurchaseID(ctx, row.PurchaseID)
require.NoError(t, err)
Expand Down
14 changes: 10 additions & 4 deletions internal/api/handler_marketplace_credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/LeanerCloud/cloud-commitments-platform/internal/auth"
"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
"github.com/LeanerCloud/cloud-commitments-platform/internal/credentials"
"github.com/LeanerCloud/cloud-commitments-platform/internal/purchase"
"github.com/aws/aws-sdk-go-v2/aws"
awscreds "github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/smithy-go"
Expand Down Expand Up @@ -248,7 +249,8 @@ func TestMarketplaceList_CompensationAssumeRoleDeniedReturnsFixedMessage(t *test
require.True(t, ok, "expected a client error, got %v", err)
assert.Equal(t, 502, ce.code)
assert.Contains(t, ce.message, marketplaceRoleAssumptionMessage)
assert.NotContains(t, ce.message, marketplaceHostAccount)
// The purchase ID in the reference is a test UUID whose last segment equals the fake host account.
assert.NotContains(t, strings.ReplaceAll(ce.message, validMarketplacePurchaseID, ""), marketplaceHostAccount)
assert.NotContains(t, ce.message, "arn:aws:")
}

Expand Down Expand Up @@ -468,7 +470,11 @@ func TestMarketplaceHandlers_NoCloudAccountRow(t *testing.T) {
}
}

func TestMapAWSMarketplaceError_GenuineEC2ErrorsKeepTheirOwnMessage(t *testing.T) {
func TestMapAWSMarketplaceError_GenuineEC2ErrorsKeepTheirOwnCategory(t *testing.T) {
want := map[string]string{
"InvalidReservedInstancesId.NotFound": purchase.ErrCategoryMessage(purchase.ErrCategoryUnavailable),
"UnauthorizedOperation": purchase.ErrCategoryMessage(purchase.ErrCategoryAccessDenied),
}
for _, code := range []string{"InvalidReservedInstancesId.NotFound", "UnauthorizedOperation"} {
t.Run(code, func(t *testing.T) {
const msg = "the ec2 service said no"
Expand All @@ -482,7 +488,7 @@ func TestMapAWSMarketplaceError_GenuineEC2ErrorsKeepTheirOwnMessage(t *testing.T

require.True(t, ok)
assert.Equal(t, 400, ce.code)
assert.Equal(t, msg, ce.message)
assert.Equal(t, want[code], ce.message, "a category message, not the provider text")
assert.NotEqual(t, marketplaceRoleAssumptionMessage, ce.message)
})
}
Expand Down Expand Up @@ -565,6 +571,6 @@ func TestMapAWSMarketplaceError_OtherClientFaultsHaveNoReason(t *testing.T) {

require.True(t, ok)
assert.Equal(t, 400, ce.code)
assert.Equal(t, "bad price", ce.message)
assert.Equal(t, marketplaceRejectedMessage, ce.message)
assert.Nil(t, ce.Details())
}
5 changes: 3 additions & 2 deletions internal/api/handler_marketplace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
ec2svc "github.com/LeanerCloud/cloud-commitments-go/providers/aws/services/ec2"
"github.com/LeanerCloud/cloud-commitments-platform/internal/auth"
"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
"github.com/LeanerCloud/cloud-commitments-platform/internal/purchase"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-sdk-go-v2/aws"
smithy "github.com/aws/smithy-go"
Expand Down Expand Up @@ -274,7 +275,7 @@ func TestMarketplaceList_AWSClientFaultMapsTo400(t *testing.T) {
ce, ok := IsClientError(err)
require.True(t, ok)
assert.Equal(t, 400, ce.code)
assert.Contains(t, err.Error(), "bad RI")
assert.Contains(t, err.Error(), marketplaceRejectedMessage)
}

func TestMarketplaceList_AWSServerFaultMapsTo502(t *testing.T) {
Expand Down Expand Up @@ -399,7 +400,7 @@ func TestMarketplaceList_DBFailureAndCancelFailureKeepsListing(t *testing.T) {
assert.Equal(t, 502, ce.code)
assert.NotContains(t, ce.message, "rolled back")
assert.Contains(t, ce.message, "ril-default")
assert.Contains(t, ce.message, "throttled")
assert.Contains(t, ce.message, purchase.ErrCategoryMessage(purchase.ErrCategoryUnknown))
assert.Contains(t, ce.message, tc.wantMsg)
assert.Equal(t, 1, ec2.cancelCallCount)
cfgStore.AssertNotCalled(t, "ReleaseMarketplaceListingClaim", mock.Anything, validMarketplacePurchaseID, mock.Anything, mock.Anything, mock.Anything)
Expand Down
13 changes: 3 additions & 10 deletions internal/api/handler_purchases.go
Original file line number Diff line number Diff line change
Expand Up @@ -552,15 +552,7 @@ func logRawExecuteFailure(executionID string, err error) {

func summarizeExecuteFailure(executionID string, err error) (category, message string) {
logRawExecuteFailure(executionID, err)
category, message = purchase.SummarizeProviderError(err)
if category == purchase.ErrCategoryUnknown {
// Only known server-authored refusals (for example "re-fetch the
// recommendation") stay readable; any other text is the category message.
if text, ok := purchase.ServerNoteMessage(err); ok {
message = text
}
}
return category, message
return purchase.SummarizeFailure(err)
}

// unclaimedExecuteError answers a failure where the funnel returned no row:
Expand Down Expand Up @@ -1500,7 +1492,8 @@ func (h *Handler) approveWithDelay(ctx context.Context, execution *config.Purcha
logging.Errorf("purchase[%s]: token approve failed to schedule: %v", execution.ExecutionID, err)
return nil, NewClientError(500, "failed to schedule execution")
}
return nil, NewClientError(500, fmt.Sprintf("failed to schedule execution %s: %v", execution.ExecutionID, err))
logging.Errorf("purchase[%s]: failed to schedule execution: %v", execution.ExecutionID, err)
return nil, NewClientError(500, fmt.Sprintf("failed to schedule execution (reference: execution %s)", execution.ExecutionID))
}
// Best-effort scheduled notification email.
h.sendPurchaseScheduledEmail(ctx, updated, actor)
Expand Down
20 changes: 15 additions & 5 deletions internal/api/handler_purchases_revoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (
"github.com/LeanerCloud/cloud-commitments-platform/internal/auth"
"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
"github.com/LeanerCloud/cloud-commitments-platform/internal/ctxutil"
"github.com/LeanerCloud/cloud-commitments-platform/internal/purchase"
"github.com/aws/aws-lambda-go/events"
"github.com/jackc/pgx/v5"
)
Expand Down Expand Up @@ -597,7 +598,7 @@ func (h *Handler) calculateAzureRevoke(ctx context.Context, req *events.LambdaFu
}, nil)
if err != nil {
if isAzureClientError(err) {
return nil, NewClientError(400, fmt.Sprintf("Azure refund calculation rejected: %v", err))
return nil, azureRefundRejected("Azure refund calculation rejected", purchaseID, err)
}
return nil, fmt.Errorf("revoke/calculate: CalculateRefund failed: %w", err)
}
Expand Down Expand Up @@ -824,7 +825,7 @@ func (h *Handler) callAzureReturn(

// Step 1: CalculateRefund -> sessionID + quoted amount (TOCTOU check).
quantity := int32(record.Count) // #nosec G115 -- Azure reservation count validated at purchase; bounded by API limits (<<math.MaxInt32) //nolint:gosec
sessionID, calcRefundAmount, calcRefundCurrency, err := h.azureCalculateRefund(ctx, calcClient, orderID, reservationID, quantity)
sessionID, calcRefundAmount, calcRefundCurrency, err := h.azureCalculateRefund(ctx, calcClient, record.PurchaseID, orderID, reservationID, quantity)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -970,7 +971,7 @@ func checkConfirmedRefund(confirmed revokeConfirmBody, calcRefundAmount *float64
// azureCalculateRefund runs the CalculateRefund step and parses out the session
// ID (required by Return) and the quoted refund amount/currency (for the TOCTOU
// check). Errors are classified into 400 (client) vs 500 (transient).
func (h *Handler) azureCalculateRefund(ctx context.Context, calcClient azureCalculateRefundClient, orderID, reservationID string, quantity int32) (string, *float64, string, error) { //nolint:gocritic // unnamedResult: return names would conflict with body locals
func (h *Handler) azureCalculateRefund(ctx context.Context, calcClient azureCalculateRefundClient, purchaseID, orderID, reservationID string, quantity int32) (string, *float64, string, error) { //nolint:gocritic // unnamedResult: return names would conflict with body locals
calcResp, err := calcClient.Post(ctx, orderID, armreservations.CalculateRefundRequest{
Properties: &armreservations.CalculateRefundRequestProperties{
ReservationToReturn: &armreservations.ReservationToReturn{
Expand All @@ -982,7 +983,7 @@ func (h *Handler) azureCalculateRefund(ctx context.Context, calcClient azureCalc
}, nil)
if err != nil {
if isAzureClientError(err) {
return "", nil, "", NewClientError(400, fmt.Sprintf("Azure refund calculation rejected: %v", err))
return "", nil, "", azureRefundRejected("Azure refund calculation rejected", purchaseID, err)
}
return "", nil, "", fmt.Errorf("revoke azure: CalculateRefund failed: %w", err)
}
Expand Down Expand Up @@ -1030,7 +1031,7 @@ func (h *Handler) handleAzureReturnError(reqCtx context.Context, record *config.
return NewClientError(422, "Azure reservation return window has closed; the 7-day refund period has expired")
}
if isAzureClientError(err) {
return NewClientError(400, fmt.Sprintf("Azure refund rejected: %v", err))
return azureRefundRejected("Azure refund rejected", record.PurchaseID, err)
}
return fmt.Errorf("revoke azure: Return failed: %w", err)
}
Expand Down Expand Up @@ -1191,6 +1192,15 @@ func isAzureClientError(err error) bool {
return false
}

// azureRefundRejected is the 400 for an Azure refund step the provider
// refused: a category message and the purchase ID, never Azure's own text,
// which carries subscription URLs and request IDs. The raw error is logged.
func azureRefundRejected(step, purchaseID string, err error) error {
logging.Warnf("revoke azure: %s for purchase %s: %v", step, purchaseID, err)
_, msg := purchase.SummarizeProviderError(err)
return NewClientError(400, fmt.Sprintf("%s: %s (reference: purchase %s)", step, msg, purchaseID))
}

// isAzureWindowEdgeError reports whether err is an Azure RefundPolicyViolated
// rejection from the Return API. This specific error code is returned when the
// reservation's 7-day return window has closed (either because the request
Expand Down
16 changes: 16 additions & 0 deletions internal/api/handler_recommendations.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/LeanerCloud/cloud-commitments-go/pkg/exchange"
"github.com/LeanerCloud/cloud-commitments-platform/internal/auth"
"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
"github.com/LeanerCloud/cloud-commitments-platform/internal/purchase"
"github.com/aws/aws-lambda-go/events"
)

Expand Down Expand Up @@ -354,6 +355,7 @@ func (h *Handler) getRecommendationsFreshness(ctx context.Context, req *events.L
if freshness == nil {
return nil, nil
}
freshness = withSanitizedCollectionError(freshness)
scope, err := h.getAccountScope(ctx, session)
if err != nil {
return nil, fmt.Errorf("failed to get allowed accounts: %w", err)
Expand All @@ -367,6 +369,20 @@ func (h *Handler) getRecommendationsFreshness(ctx context.Context, req *events.L
// accounts the scope allows. The state row is global, so naming an account the
// caller cannot see would leak that it exists. An unrestricted scope sees all
// of it; an account-less (ambient) hold has no IDs to show a restricted caller.
// withSanitizedCollectionError returns f with last_collection_error reduced to
// known server notes or a category message: rows stored before the collector
// summarized provider errors hold the provider's raw text. The stored value
// is not rewritten.
func withSanitizedCollectionError(f *config.RecommendationsFreshness) *config.RecommendationsFreshness {
if f == nil || f.LastCollectionError == nil {
return f
}
out := *f
msg := purchase.SanitizeStoredError(*f.LastCollectionError)
out.LastCollectionError = &msg
return &out
}

func scopeHeldAccounts(f *config.RecommendationsFreshness, scope auth.AccountScope) *config.RecommendationsFreshness {
out := *f
if scope.AllowsAll() || f.LastCollectionPartial == nil {
Expand Down
4 changes: 2 additions & 2 deletions internal/api/handler_recommendations_refresh.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ func (h *Handler) runMarkedCollection(ctx context.Context, token string) (*confi
if err != nil {
return nil, fmt.Errorf("failed to read freshness after async invoke: %w", err)
}
return freshness, nil
return withSanitizedCollectionError(freshness), nil
}
// Non-Lambda (HTTP) mode: collect synchronously. ClearCollectionStarted
// is called by the scheduler's deferred clearCollectionStartedBestEffort,
Expand All @@ -169,7 +169,7 @@ func (h *Handler) runMarkedCollection(ctx context.Context, token string) (*confi
if err != nil {
return nil, fmt.Errorf("failed to read freshness after collect: %w", err)
}
return freshness, nil
return withSanitizedCollectionError(freshness), nil
}

// rollbackMarkedCollection releases this caller's own in-flight marker after
Expand Down
17 changes: 14 additions & 3 deletions internal/api/handler_ri_exchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
"github.com/LeanerCloud/cloud-commitments-platform/internal/credentials"
"github.com/LeanerCloud/cloud-commitments-platform/internal/ctxutil"
"github.com/LeanerCloud/cloud-commitments-platform/internal/purchase"
"github.com/LeanerCloud/cloud-commitments-platform/internal/ricost"
)

Expand Down Expand Up @@ -1345,7 +1346,8 @@ func mapAzureExchangeError(opMsg string, err error) error {
return refusal
}
if isAzureClientError(err) {
return NewClientError(400, err.Error())
logging.Warnf("ri-exchange: %s: provider rejected the request: %v", opMsg, err)
return NewClientError(400, exchangeRejectedMessage)
}
return NewClientError(500, opMsg)
}
Expand Down Expand Up @@ -2107,11 +2109,16 @@ func mapAWSExchangeError(opMsg string, err error) error {
}
var apiErr smithy.APIError
if errors.As(err, &apiErr) && awsExchangeClientFaultCodes[apiErr.ErrorCode()] {
return NewClientError(400, apiErr.ErrorMessage())
logging.Warnf("ri-exchange: %s: provider rejected the request: %v", opMsg, err)
return NewClientError(400, exchangeRejectedMessage)
}
return NewClientError(500, opMsg)
}

// exchangeRejectedMessage is the provider-neutral text for a client-fault
// rejection; the provider's own text stays in the log.
const exchangeRejectedMessage = "the exchange request was rejected as invalid: check the offering and count"

// Response types

// ConvertibleRIsResponse holds the list of convertible RIs.
Expand Down Expand Up @@ -2342,8 +2349,10 @@ func (h *Handler) getRIExchangeHistory(ctx context.Context, req *events.LambdaFu

// Strip approval tokens — single-use secrets must not be included in
// a read-only response that could be cached, logged, or screen-shared.
// Stored errors may hold provider text; only known server notes pass.
for i := range records {
records[i].ApprovalToken = ""
records[i].Error = purchase.SanitizeStoredError(records[i].Error)
}

return &RIExchangeHistoryResponse{Records: records}, nil
Expand Down Expand Up @@ -2984,7 +2993,9 @@ func (h *Handler) executeApprovedExchange(ctx context.Context, id string, record
saveCtx, cancelSave := ctxutil.DetachedWithTimeout(ctx, riExchangeTerminalSaveTimeout)
defer cancelSave()
if execErr != nil {
return h.failExchange(saveCtx, id, execErr.Error())
logging.Errorf("RI exchange %s execution failed: %v", id, execErr)
_, reason := purchase.SummarizeFailure(execErr)
return h.failExchange(saveCtx, id, reason)
}

// H3: persist the amount AWS actually accepted, not the reserved ceiling.
Expand Down
2 changes: 1 addition & 1 deletion internal/api/handler_ri_exchange_azure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1602,7 +1602,7 @@ func TestExecuteAzureExchange_CommitFailureClassification(t *testing.T) {
wantCode int
wantDetail string
}{
{"azure client fault", &azcore.ResponseError{StatusCode: 409, ErrorCode: "ReservationAlreadyExchanged"}, 400, "ReservationAlreadyExchanged"},
{"azure client fault", &azcore.ResponseError{StatusCode: 409, ErrorCode: "ReservationAlreadyExchanged"}, 400, "the exchange request was rejected as invalid"},
{"transient failure", fmt.Errorf("azure: ExecuteExchange: transport timeout"), 500, "exchange execution failed"},
}
for _, tt := range tests {
Expand Down
Loading
Loading