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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261008100513-a9a426224031
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261003204812-9962786e0695
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261009122502-bcc0dfc49227
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261007130138-e24345ce68f8
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261009093408-2c18f303abfc
github.com/aws/aws-lambda-go v1.47.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261008100513-a9a426224031 h1:e3DVjk0VIGfDTW3flfD7j6aDiae9T8JINQciL3IXPjo=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261008100513-a9a426224031/go.mod h1:ApWBliDXe099f3oDXBz41K/I9v4bHvn1dG/BGoRmHlw=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261003204812-9962786e0695 h1:DfNEBzFS7/MaBZljLGRRRUaarfnXXZyBE/6iozzp9/k=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261003204812-9962786e0695/go.mod h1:d4nsy61/Ptib0SxqMg0ble3yksVm3+QbtUtmGja82PA=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261009122502-bcc0dfc49227 h1:S9pAwtTnwt02+Fo1uVWYgtmGyB1koQAHmBSficHOZ2E=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261009122502-bcc0dfc49227/go.mod h1:yJoe/zCikjjlGsy47BZTTuKUcLkLVDwqgLEYklWjlm4=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261007130138-e24345ce68f8 h1:4qx5bagtW25H5X+whaZ/3tWXP7VGyG/qcypTh8yK7Hc=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261007130138-e24345ce68f8/go.mod h1:iIvPuWLWd+SXqLHAQNdodYMUAAcq7PnwfScXWgARt5A=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261009093408-2c18f303abfc h1:hCLUvpI5U42BRh4M9EvjKaBg70P2ijbKxO9eNn7wMK0=
Expand Down
2 changes: 1 addition & 1 deletion internal/api/execution_scope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ type scopeTestKind struct {
// the post-scope constraint checks and reaches the mutation.
func scopeTestRec(id string, account *string) config.RecommendationRecord {
return config.RecommendationRecord{
ID: id, Provider: "aws", Service: "ec2", Region: "us-east-1",
ID: id, Provider: "aws", Service: "rds", Region: "us-east-1",
Count: 1, Term: 1, Payment: "all-upfront", UpfrontCost: 100, CloudAccountID: account,
}
}
Expand Down
11 changes: 8 additions & 3 deletions internal/api/handler_purchases.go
Original file line number Diff line number Diff line change
Expand Up @@ -2013,9 +2013,14 @@ func checkRetryEligibilityGates(failedExec *config.PurchaseExecution, req *event
// potentially a re-drive. purchase.RedriveRefusalReason is the same
// predicate the reaper's automatic re-drive gates on: it returns a
// reason exactly when the provider offers nothing that would collapse
// the second attempt onto the first, which today means Azure
// savings-plans (no server-side idempotency key, timestamp-derived
// order alias) plus any provider the predicate does not recognize.
// the second attempt onto the first, which today means AWS EC2 and
// Redshift (no client token; a lost response is reported as outcome
// unknown), Azure savings-plans (no server-side idempotency key,
// timestamp-derived order alias) plus any provider the predicate does
// not recognize. A failed EC2 or Redshift row therefore cannot be
// retried; the operator checks the account and starts a new purchase.
// If plan steps ever carry recommendations (platform#609), a failed EC2
// step will be un-retryable too and needs its own recovery path.
//
// This gate runs FIRST and, unlike the threshold below, ?force=true
// does NOT override it: a savings plan cannot be canceled, so there
Expand Down
2 changes: 1 addition & 1 deletion internal/api/handler_purchases_retry_fanout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func fanoutPlanAccounts() []config.CloudAccount {
func fanoutRecs() []config.RecommendationRecord {
return []config.RecommendationRecord{
{
Provider: "aws", Service: "ec2", ResourceType: "m5.large",
Provider: "aws", Service: "rds", ResourceType: "db.r5.large",
Region: "us-east-1", Count: 1, Term: 1,
UpfrontCost: 300, Selected: true,
},
Expand Down
43 changes: 42 additions & 1 deletion internal/api/handler_purchases_retry_redrive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ const (
redriveUnknownExecID = "22222222-3333-4444-5555-666666666604"
redriveForceExecID = "22222222-3333-4444-5555-666666666605"
redriveMixedExecID = "22222222-3333-4444-5555-666666666606"
redriveAWSNoTokenExecID = "22222222-3333-4444-5555-666666666607"
redriveAWSRDSExecID = "22222222-3333-4444-5555-666666666608"

redriveLineageKey = "lineage-1668"
)
Expand Down Expand Up @@ -248,7 +250,7 @@ func TestRetryOfLandedAzureSavingsPlanIsNotForceOverridable(t *testing.T) {
// front of it. The AWS rec here is deliberately first, so a gate that checked
// only the leading rec would pass this row straight through to Azure.
func TestRetryOfMixedExecutionWithOneAzureSavingsPlanIsRefused(t *testing.T) {
failed := redriveFailedRow(redriveMixedExecID, "aws", "ec2")
failed := redriveFailedRow(redriveMixedExecID, "aws", "rds")
failed.Recommendations = append(failed.Recommendations, config.RecommendationRecord{
Provider: "azure",
Service: "savingsplans",
Expand Down Expand Up @@ -301,3 +303,42 @@ func TestRetryOfFailedAzureReservationStillPurchasesOnce(t *testing.T) {
assert.Equal(t, []string{common.DeriveIdempotencyToken(failed.IdempotencyKey, 0)}, tokens,
"the retry must fire exactly one purchase, under the predecessor's token so Azure's two-step lookup dedupes it")
}

// TestRetryOfFailedAWSEC2OrRedshiftRowIsRefused is the MON-02 platform
// regression guard. EC2 and Redshift purchases take no client token, so the
// provider sends them once and reports a lost response as "purchase outcome
// unknown": the commitment may exist. Retrying such a row must not create a
// successor that buys again, whatever spelling the row carries (the canonical
// compute and data-warehouse slugs dispatch to the same clients as ec2 and
// redshift) and with ?force=true too.
func TestRetryOfFailedAWSEC2OrRedshiftRowIsRefused(t *testing.T) {
for _, tc := range []struct{ provider, service string }{
{"aws", "ec2"}, {"aws", "compute"}, {"aws", "redshift"}, {"aws", "data-warehouse"}, {"", "ec2"},
} {
tc := tc
t.Run(tc.provider+"/"+tc.service, func(t *testing.T) {
failed := redriveFailedRow(redriveAWSNoTokenExecID, tc.provider, tc.service)
failed.Error = "failed to purchase EC2 RI: purchase outcome unknown: the request may have been sent and the commitment may exist; reconcile before retrying"

for _, req := range []*events.LambdaFunctionURLRequest{sessionRetryReq(), sessionRetryReqWithForce()} {
tokens, err := purchasesFiredByRetry(t, failed, req)

assert.Empty(t, tokens,
"a retry of an EC2/Redshift row after an unknown outcome must buy nothing")
assertRedriveRefused(t, err, "Check EC2 Reserved Instances or Redshift Reserved Nodes")
}
})
}
}

// TestRetryOfFailedAWSRDSRowStillPurchasesOnce is the over-blocking control:
// RDS uses a deterministic reservation ID, so its retry is safe and must keep
// working. A predicate that refused every AWS row would pass the test above.
func TestRetryOfFailedAWSRDSRowStillPurchasesOnce(t *testing.T) {
failed := redriveFailedRow(redriveAWSRDSExecID, "aws", "rds")

tokens, err := purchasesFiredByRetry(t, failed, sessionRetryReq())

require.NoError(t, err, "an AWS RDS retry is idempotent and must still be allowed")
assert.Len(t, tokens, 1, "the RDS retry must purchase exactly once")
}
26 changes: 13 additions & 13 deletions internal/api/handler_purchases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3767,7 +3767,7 @@ func TestHandler_retryPurchase_Admin_AllowsAny(t *testing.T) {
Status: "failed",
Error: "send failed: transient SES throttle",
CreatedByUserID: &creator,
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1, UpfrontCost: 100}},
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "rds", Term: 1, UpfrontCost: 100}},
}
session := &Session{UserID: retryCallerID, Email: "admin@example.com"}
// Admin (Administrators-group member) modeled as a retry-any holder; the
Expand All @@ -3790,7 +3790,7 @@ func TestHandler_retryPurchase_StoresHashOfEmailedToken(t *testing.T) {
ExecutionID: retryExecID,
Status: "failed",
CreatedByUserID: &creator,
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1, UpfrontCost: 100, CloudAccountID: &accountID}},
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "rds", Term: 1, UpfrontCost: 100, CloudAccountID: &accountID}},
}
session := &Session{UserID: retryCallerID, Email: "admin@example.com"}
handler, mockConfig, _ := buildSessionRetryHandler(failed, session, true, false)
Expand Down Expand Up @@ -3822,7 +3822,7 @@ func TestHandler_retryPurchase_RetryAny_AllowsAny(t *testing.T) {
Status: "failed",
Error: "send failed: transient SES throttle",
CreatedByUserID: &creator,
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1, UpfrontCost: 100}},
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "rds", Term: 1, UpfrontCost: 100}},
}
session := &Session{UserID: retryCallerID, Email: "ops@example.com"}
runSessionRetryAllowed(t, failed, session, true, false, sessionRetryReq())
Expand All @@ -3836,7 +3836,7 @@ func TestHandler_retryPurchase_RetryOwn_AllowsCreator(t *testing.T) {
Error: "send failed: SES recipient mailbox full",
CreatedByUserID: &creator,
RetryAttemptN: 2, // already retried twice
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1, UpfrontCost: 100}},
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "rds", Term: 1, UpfrontCost: 100}},
}
session := &Session{UserID: retryCallerID, Email: "u1@example.com"}
newExec, updated := runSessionRetryAllowed(t, failed, session, false, true, sessionRetryReq())
Expand Down Expand Up @@ -3946,7 +3946,7 @@ func TestHandler_retryPurchase_PersistentFailure_NoMatch_AllowsRetry(t *testing.
Status: "failed",
Error: "send failed: SES throttle exceeded, please retry",
CreatedByUserID: &creator,
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1, UpfrontCost: 100}},
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "rds", Term: 1, UpfrontCost: 100}},
}
session := &Session{UserID: retryCallerID}
// Caller owns the row; retry-own authorizes it (issue #907).
Expand All @@ -3960,7 +3960,7 @@ func TestHandler_retryPurchase_Threshold_BlocksAtFive_NoForce(t *testing.T) {
Status: "failed",
RetryAttemptN: 5, // already at the threshold
CreatedByUserID: &creator,
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1, UpfrontCost: 100}},
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "rds", Term: 1, UpfrontCost: 100}},
}
session := &Session{UserID: retryCallerID}
// Caller owns the row; retry-own authorizes it (issue #907).
Expand All @@ -3983,7 +3983,7 @@ func TestHandler_retryPurchase_Threshold_AllowsWithForce(t *testing.T) {
Status: "failed",
RetryAttemptN: 5,
CreatedByUserID: &creator,
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1, UpfrontCost: 100}},
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "rds", Term: 1, UpfrontCost: 100}},
}
session := &Session{UserID: retryCallerID}
// Caller owns the row; retry-own authorizes it (issue #907).
Expand All @@ -3998,7 +3998,7 @@ func TestHandler_retryPurchase_JustUnderThreshold_AllowsNoForce(t *testing.T) {
Status: "failed",
RetryAttemptN: 4, // n=4 < threshold=5 → allowed
CreatedByUserID: &creator,
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1, UpfrontCost: 100}},
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "rds", Term: 1, UpfrontCost: 100}},
}
session := &Session{UserID: retryCallerID}
// Caller owns the row; retry-own authorizes it (issue #907).
Expand Down Expand Up @@ -4060,7 +4060,7 @@ func TestHandler_retryPurchase_ConcurrentRetryRace_SecondCASLoses(t *testing.T)
ExecutionID: retryExecID,
Status: "failed",
CreatedByUserID: &creator,
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1, UpfrontCost: 100}},
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "rds", Term: 1, UpfrontCost: 100}},
}
session := &Session{UserID: retryCallerID}
// Caller owns the row; retry-own authorizes it (issue #907). Both
Expand Down Expand Up @@ -4114,7 +4114,7 @@ func TestHandler_retryPurchase_PreservesPlanMetadata(t *testing.T) {
Status: "failed",
Error: "send failed: transient SES throttle",
CreatedByUserID: &creator,
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1, UpfrontCost: 100}},
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "rds", Term: 1, UpfrontCost: 100}},
}
session := &Session{UserID: retryCallerID}
// Caller owns the row; retry-own authorizes it (issue #907).
Expand Down Expand Up @@ -4181,7 +4181,7 @@ func TestHandler_retryPurchase_PermissionConstraintsDenied(t *testing.T) {
Error: "send failed: transient SES throttle",
CreatedByUserID: &creator,
Recommendations: []config.RecommendationRecord{
{Provider: "aws", Service: "ec2", Region: "us-east-1", Term: 1, UpfrontCost: 5000},
{Provider: "aws", Service: "rds", Region: "us-east-1", Term: 1, UpfrontCost: 5000},
},
}
session := &Session{UserID: retryCallerID, Email: "capped@example.com"}
Expand Down Expand Up @@ -4232,7 +4232,7 @@ func TestPersistRetryExecution_ApprovalTokenNotUUID(t *testing.T) {
Status: "failed",
Error: "ses throttle",
CreatedByUserID: &creator,
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1, UpfrontCost: 100}},
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "rds", Term: 1, UpfrontCost: 100}},
}
session := &Session{UserID: retryCallerID, Email: "admin@example.com"}
// Caller owns the row; retry-own authorizes it (issue #907).
Expand Down Expand Up @@ -4261,7 +4261,7 @@ func TestPersistRetryExecution_ApprovalTokenExpiresAtSet(t *testing.T) {
Status: "failed",
Error: "ses throttle",
CreatedByUserID: &creator,
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1, UpfrontCost: 100}},
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "rds", Term: 1, UpfrontCost: 100}},
}
session := &Session{UserID: retryCallerID, Email: "admin@example.com"}

Expand Down
10 changes: 6 additions & 4 deletions internal/api/purchase_pricing.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,10 +264,12 @@ func checkRequestDetailDiscriminators(req, match *config.RecommendationRecord, i
// Platform/Scope/AZConfig/MemoryGB) precisely because its true
// configuration was never recorded, so there is nothing known to
// contradict. But once origin IS known, an empty match is ALSO a mismatch,
// not a pass: buildOfferingFilters substitutes the provider default for an
// empty match field, which can silently buy a different configuration than
// the one origin recorded (e.g. origin=dedicated tenancy, match=legacy-
// empty -> the purchase would resolve to the default-tenancy substitute).
// not a pass: a client may substitute a default for an empty match field,
// which can silently buy a different configuration than the one origin
// recorded (e.g. origin=dedicated tenancy, match=legacy-empty -> the purchase
// would resolve to the default-tenancy substitute). The AWS EC2 client
// rejects an empty Platform, Tenancy or Scope outright, but this check still
// has to refuse it here, before the purchase is attempted.
// "Unknown" is therefore refused right alongside "different", not treated
// as compatible.
func purchaseDetailMismatch(origin, match common.ServiceDetails) string {
Expand Down
2 changes: 1 addition & 1 deletion internal/api/retry_carveout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func retryCarveoutFailedExec() *config.PurchaseExecution {
Error: "send failed: transient SES throttle",
CreatedByUserID: &creator,
Recommendations: []config.RecommendationRecord{
{Provider: "aws", Service: "ec2", Term: 1, UpfrontCost: 100},
{Provider: "aws", Service: "rds", Term: 1, UpfrontCost: 100},
},
}
}
Expand Down
13 changes: 7 additions & 6 deletions internal/purchase/armed_redrive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,12 +217,13 @@ func TestFirstAzureSavingsPlanPurchaseStillExecutes(t *testing.T) {
}

// TestSafeProviderRetrySuccessorStillExecutes is the second negative control:
// the guard must be as narrow as the provider gap. An AWS retry successor
// reproduces its ClientToken, so the provider collapses a re-drive onto the
// original and the retry must still execute.
// the guard must be as narrow as the provider gap. An AWS RDS retry successor
// reproduces its deterministic reservation ID, so the provider collapses a
// re-drive onto the original and the retry must still execute. EC2 and
// Redshift are the AWS services that lack such a guard (see the Refuses tests).
func TestSafeProviderRetrySuccessorStillExecutes(t *testing.T) {
exec := armedExecution("aws", "ec2", 3, "approved")
mgr, store, rec := armedHarness(t, common.ServiceEC2)
exec := armedExecution("aws", "rds", 3, "approved")
mgr, store, rec := armedHarness(t, common.ServiceRDS)
store.On("GetExecutionByID", mock.Anything, exec.ExecutionID).Return(exec, nil).Maybe()
expectClaim(store, exec, []string{"approved", "pending", "notified"}, "running")

Expand All @@ -231,7 +232,7 @@ func TestSafeProviderRetrySuccessorStillExecutes(t *testing.T) {
require.NoError(t, mgr.ProcessMessage(context.Background(), string(body)))

assert.Equal(t, 1, rec.count(),
"an AWS retry successor dedupes at the provider via ClientToken and must still execute; the guard must not widen beyond the providers that lack a duplicate guard")
"an AWS RDS retry successor dedupes at the provider via its reservation ID and must still execute; the guard must not widen beyond the providers that lack a duplicate guard")
}

// TestArmedRowIsDefusedNotLeftArmed pins the disposition of a refused row. A
Expand Down
Loading
Loading