Skip to content

Commit b8ecf51

Browse files
cristimclaude
andcommitted
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>
1 parent 0c869b4 commit b8ecf51

4 files changed

Lines changed: 29 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: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,22 @@ func validateSavingsPlanPreconditions(rec common.Recommendation) error {
151151
if fmt.Sprintf(spCommitmentFormat, d.HourlyCommitment) == "0.00" {
152152
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)
153153
}
154-
if rec.Service == common.ServiceSavingsPlansEC2Instance && d.Region == "" {
154+
if rec.Service == common.ServiceSavingsPlansEC2Instance {
155+
return validateEC2InstanceSPOffering(d)
156+
}
157+
return nil
158+
}
159+
160+
// validateEC2InstanceSPOffering mirrors the library's offering lookup: an
161+
// explicit offering ID skips it, otherwise it needs the region and family to
162+
// pick exactly one offering.
163+
func validateEC2InstanceSPOffering(d *common.SavingsPlanDetails) error {
164+
if d.Region == "" {
155165
return fmt.Errorf("EC2 Instance Savings Plan needs a region to pick the offering")
156166
}
167+
if d.OfferingID == "" && d.InstanceFamily == "" {
168+
return fmt.Errorf("EC2 Instance Savings Plan needs an instance family or an offering ID to pick the offering")
169+
}
157170
return nil
158171
}
159172

‎cmd/savings_plan_purchase_test.go‎

Lines changed: 11 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,9 @@ 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+
}),
277286
"sp all": mut(parserShapedSP(), func(r *common.Recommendation) { r.Service = common.ServiceSavingsPlansAll }),
278287
"ec2 nil": mut(validEC2Rec(), func(r *common.Recommendation) { r.Details = nil }),
279288
"ec2 tenancy": mut(validEC2Rec(), func(r *common.Recommendation) { r.Details.(*common.ComputeDetails).Tenancy = "" }),

0 commit comments

Comments
 (0)