Skip to content

Commit 16398bd

Browse files
cristimclaude
andauthored
fix(cmd): refuse an EC2 Instance Savings Plan without a family in the dry run too (#2261)
* fix(cmd): refuse an EC2 Instance Savings Plan without a family in the dry run too validatePurchasePreconditions required only a region for an EC2 Instance Savings Plan. With no instance family and no offering ID, the real run refuses in the offering lookup (ambiguous or missing offerings) while the dry run reported success. Require the family unless an offering ID is set. The completeness fixture now carries a family, as a real Cost Explorer EC2 Instance recommendation does. Closes #2164 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmd): reject whitespace-only EC2 Instance family in SP precondition validateEC2InstanceSPOffering checked InstanceFamily == "", so a family of spaces passed the dry run when OfferingID was empty; the real lookup then sent it as the instanceFamily filter. Trim before the empty check and cover it in TestValidatePurchasePreconditions. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 295cd13 commit 16398bd

4 files changed

Lines changed: 33 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
1616
- Recommendations with no account ID or name are now reported in the drop summary
1717
when an account filter (`--include-accounts` or `--exclude-accounts`) removes
1818
them (#1613).
19+
- A dry run now refuses an EC2 Instance Savings Plan that has no instance family
20+
and no offering ID, the same way the real run does, instead of reporting
21+
success for a purchase the offering lookup would reject (#2164).
1922
- A purchase result with `Success=true` and a non-nil `Error` is now treated as
2023
a failure in the Ctrl-C summary, the audit-stop message, the per-service
2124
success/failed counts and the non-zero exit. The CSV `Success` column keeps the

‎cmd/recommendation_completeness_proxy_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ func (p *completenessProxy) spRecommendation(body []byte) (int, string, string)
305305
const invalid = `{"HourlyCommitmentToPurchase":"not-a-number","EstimatedMonthlySavingsAmount":"10","UpfrontCost":"3"}`
306306
details := valid
307307
if request.SavingsPlansType == "EC2_INSTANCE_SP" {
308-
details = strings.TrimSuffix(valid, "}") + `,"SavingsPlansDetails":{"Region":"us-east-1"}}`
308+
details = strings.TrimSuffix(valid, "}") + `,"SavingsPlansDetails":{"InstanceFamily":"m5","Region":"us-east-1"}}`
309309
}
310310
switch p.details {
311311
case "mixed":

‎cmd/savings_plan_purchase.go‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"fmt"
55
"strconv"
6+
"strings"
67
"time"
78

89
"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
@@ -151,9 +152,22 @@ func validateSavingsPlanPreconditions(rec common.Recommendation) error {
151152
if fmt.Sprintf(spCommitmentFormat, d.HourlyCommitment) == "0.00" {
152153
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)
153154
}
154-
if rec.Service == common.ServiceSavingsPlansEC2Instance && d.Region == "" {
155+
if rec.Service == common.ServiceSavingsPlansEC2Instance {
156+
return validateEC2InstanceSPOffering(d)
157+
}
158+
return nil
159+
}
160+
161+
// validateEC2InstanceSPOffering mirrors the library's offering lookup: an
162+
// explicit offering ID skips it, otherwise it needs the region and family to
163+
// pick exactly one offering.
164+
func validateEC2InstanceSPOffering(d *common.SavingsPlanDetails) error {
165+
if d.Region == "" {
155166
return fmt.Errorf("EC2 Instance Savings Plan needs a region to pick the offering")
156167
}
168+
if d.OfferingID == "" && strings.TrimSpace(d.InstanceFamily) == "" {
169+
return fmt.Errorf("EC2 Instance Savings Plan needs an instance family or an offering ID to pick the offering")
170+
}
157171
return nil
158172
}
159173

‎cmd/savings_plan_purchase_test.go‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,14 @@ func TestValidatePurchasePreconditions(t *testing.T) {
258258
ok := map[string]common.Recommendation{
259259
"compute sp": parserShapedSP(),
260260
"ec2 instance sp": validEC2InstanceSP(),
261-
"ec2": validEC2Rec(),
262-
"rds": {Service: common.ServiceRDS, Region: "us-east-1"},
261+
"ec2 instance sp offering id no family": mut(validEC2InstanceSP(), func(r *common.Recommendation) {
262+
r.Details = &common.SavingsPlanDetails{PlanType: "EC2Instance", HourlyCommitment: 1, Region: "eu-west-1", OfferingID: "o-1"}
263+
}),
264+
"compute sp no family": mut(parserShapedSP(), func(r *common.Recommendation) {
265+
r.Details = &common.SavingsPlanDetails{PlanType: "Compute", HourlyCommitment: 1}
266+
}),
267+
"ec2": validEC2Rec(),
268+
"rds": {Service: common.ServiceRDS, Region: "us-east-1"},
263269
}
264270
for name, rec := range ok {
265271
assert.NoError(t, validatePurchasePreconditions(rec, rec.Region, defaultTestAccount), name)
@@ -274,6 +280,12 @@ func TestValidatePurchasePreconditions(t *testing.T) {
274280
"ec2 instance sp no region": mut(validEC2InstanceSP(), func(r *common.Recommendation) {
275281
r.Details = &common.SavingsPlanDetails{PlanType: "EC2Instance", HourlyCommitment: 1, InstanceFamily: "m5"}
276282
}),
283+
"ec2 instance sp no family no offering": mut(validEC2InstanceSP(), func(r *common.Recommendation) {
284+
r.Details = &common.SavingsPlanDetails{PlanType: "EC2Instance", HourlyCommitment: 1, Region: "eu-west-1"}
285+
}),
286+
"ec2 instance sp whitespace family no offering": mut(validEC2InstanceSP(), func(r *common.Recommendation) {
287+
r.Details = &common.SavingsPlanDetails{PlanType: "EC2Instance", HourlyCommitment: 1, Region: "eu-west-1", InstanceFamily: " "}
288+
}),
277289
"sp all": mut(parserShapedSP(), func(r *common.Recommendation) { r.Service = common.ServiceSavingsPlansAll }),
278290
"ec2 nil": mut(validEC2Rec(), func(r *common.Recommendation) { r.Details = nil }),
279291
"ec2 tenancy": mut(validEC2Rec(), func(r *common.Recommendation) { r.Details.(*common.ComputeDetails).Tenancy = "" }),

0 commit comments

Comments
 (0)