diff --git a/cmd/savings_plan_purchase.go b/cmd/savings_plan_purchase.go index 1f795f7d8..d293b839b 100644 --- a/cmd/savings_plan_purchase.go +++ b/cmd/savings_plan_purchase.go @@ -16,6 +16,12 @@ import ( // regions and are not supported for Savings Plan purchases. const spAPIRegion = "us-east-1" +// spCommitmentFormat is how the library formats HourlyCommitment for the +// CreateSavingsPlan call (savingsplans/client.go). A positive value that +// formats as "0.00" would be sent as a zero commitment, so preconditions +// reject it. +const spCommitmentFormat = "%.2f" + // savingsPlanRowKey identifies one Savings Plan purchase. Two rows with the // same key buy the same plan twice. func savingsPlanRowKey(rec common.Recommendation) string { @@ -142,6 +148,9 @@ func validateSavingsPlanPreconditions(rec common.Recommendation) error { if d.HourlyCommitment <= 0 { return fmt.Errorf("%s recommendation has no positive hourly commitment (got %v)", rec.Service, d.HourlyCommitment) } + if fmt.Sprintf(spCommitmentFormat, d.HourlyCommitment) == "0.00" { + return fmt.Errorf("%s recommendation's hourly commitment %v would be sent as 0.00; it must be at least 0.005", rec.Service, d.HourlyCommitment) + } if rec.Service == common.ServiceSavingsPlansEC2Instance && d.Region == "" { return fmt.Errorf("EC2 Instance Savings Plan needs a region to pick the offering") } diff --git a/cmd/savings_plan_purchase_test.go b/cmd/savings_plan_purchase_test.go index dbf095228..d4b7455c5 100644 --- a/cmd/savings_plan_purchase_test.go +++ b/cmd/savings_plan_purchase_test.go @@ -286,6 +286,25 @@ func TestValidatePurchasePreconditions(t *testing.T) { } } +// TestValidatePurchasePreconditions_RejectsCommitmentThatRoundsToZero covers +// issue #2163: the library sends the hourly commitment as "%.2f", so a positive +// value below 0.005 would reach CreateSavingsPlan as "0.00". +func TestValidatePurchasePreconditions_RejectsCommitmentThatRoundsToZero(t *testing.T) { + withCommitment := func(c float64) common.Recommendation { + rec := parserShapedSP() + rec.Details = &common.SavingsPlanDetails{PlanType: "Compute", HourlyCommitment: c} + return rec + } + for _, c := range []float64{0.001, 0.004, 0.00499} { + err := validatePurchasePreconditions(withCommitment(c), "", defaultTestAccount) + require.Error(t, err, "commitment %v rounds to 0.00 and must be rejected", c) + assert.Contains(t, err.Error(), "0.00", "commitment %v", c) + } + for _, c := range []float64{0.005, 0.01, 1.5} { + assert.NoError(t, validatePurchasePreconditions(withCommitment(c), "", defaultTestAccount), "commitment %v", c) + } +} + func TestPurchaseSingleRec_DryRunReportsPreconditionFailure(t *testing.T) { cfg := Config{CallerAccountID: defaultTestAccount, AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")} broken := parserShapedSP()