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 cmd/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ func TestApplySizing_LegacyCoverageRecordsCountsFlooredToZero(t *testing.T) {
require.Len(t, out, 1)
assert.Equal(t, 1, out[0].Count)
assert.Equal(t, 1, drops.Total())
assert.Contains(t, drops.FormatOneLine(), common.DropTargetSizedToZero)
assert.Contains(t, drops.FormatOneLine(), common.DropCoverageSizedToZero)
}

func TestAdjustRecommendationsForExisting(t *testing.T) {
Expand Down
20 changes: 19 additions & 1 deletion cmd/multi_service_engine_versions.go
Original file line number Diff line number Diff line change
Expand Up @@ -504,13 +504,31 @@ func adjustRecommendationForExcludedVersions(rec common.Recommendation, instance
if newCount != originalCount {
log.Printf("📉 Adjusting recommendation for %s %s in %s: %d instances → %d instances (excluded %d extended support instances)",
recEngine, rec.ResourceType, rec.Region, originalCount, newCount, excludedCount)
rec.Count = newCount
return resizeAfterExclusion(rec, newCount)
}
}

return rec
}

// resizeAfterExclusion sets rec to newCount instances with its money fields
// scaled to match. A count of zero is the caller's drop signal and carries no
// money. A resize that common.ResizeCount refuses is also returned as zero
// rather than as a count with unscaled money.
func resizeAfterExclusion(rec common.Recommendation, newCount int) common.Recommendation {
if newCount < 1 {
rec.Count = 0
return rec
}
resized, err := common.ResizeCount(rec, newCount)
if err != nil {
log.Printf("❌ Dropping recommendation after extended-support exclusion: %v", err)
rec.Count = 0
return rec
}
return resized
}

// shouldExcludeForExtendedSupport reports whether a single running instance
// should be excluded from the recommendation because its engine version is in
// extended support. Instances in other regions or with other engines never
Expand Down
34 changes: 34 additions & 0 deletions cmd/multi_service_engine_versions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -872,3 +872,37 @@ func TestQueryRunningInstanceEngineVersions_ErrorHandling(t *testing.T) {
})
}
}

// TestAdjustRecommendationForExcludedVersions_ScalesMoneyWithCount: cutting the
// count must scale the money fields with it, or the report keeps the full
// recommendation's cost for fewer instances.
func TestAdjustRecommendationForExcludedVersions_ScalesMoneyWithCount(t *testing.T) {
monthly := 30.0
recommendation := common.Recommendation{
Service: common.ServiceRDS,
Region: "us-west-2",
ResourceType: "db.r6g.large",
Count: 3,
CommitmentCost: 900,
OnDemandCost: 600,
EstimatedSavings: 150,
RecurringMonthlyCost: &monthly,
Details: &common.DatabaseDetails{Engine: "aurora-mysql"},
}
instanceVersions := map[string][]InstanceEngineVersion{
"db.r6g.large": {
{Engine: "aurora-mysql", EngineVersion: "5.7.mysql_aurora.2.12.0", InstanceClass: "db.r6g.large", Region: "us-west-2"},
},
}

result := adjustRecommendationForExcludedVersions(recommendation, instanceVersions, createTestVersionInfo())

assert.Equal(t, 2, result.Count)
assert.InDelta(t, 600.0, result.CommitmentCost, 0.01, "2/3 of 900")
assert.InDelta(t, 400.0, result.OnDemandCost, 0.01)
assert.InDelta(t, 100.0, result.EstimatedSavings, 0.01)
if assert.NotNil(t, result.RecurringMonthlyCost) {
assert.InDelta(t, 20.0, *result.RecurringMonthlyCost, 0.01)
}
assert.InDelta(t, 30.0, monthly, 0.001, "the input's pointer is not mutated")
}
2 changes: 1 addition & 1 deletion cmd/recommendation_completeness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ func assertSPCompleteness(t *testing.T, output, service, details, logs string) {
if plan == "EC2_INSTANCE_SP" {
wantRegion = "us-east-1"
}
for field, want := range map[string]string{"Service": completenessSPService(plan), "Region": wantRegion, "DetailsRegion": wantRegion, "ResourceType": "", "Count": "1", "Term": "1yr", "PaymentOption": "no-upfront", "UpfrontPayment": "3.00", "RecurringMonthlyCost": "1460.00", "EstimatedSavings": "10.00", "Success": "true", "Error": ""} {
for field, want := range map[string]string{"Service": completenessSPService(plan), "Region": wantRegion, "DetailsRegion": wantRegion, "ResourceType": "", "Count": "1", "Term": "1yr", "PaymentOption": "no-upfront", "UpfrontPayment": "3.00", "RecurringMonthlyCost": "1459.75", "EstimatedSavings": "10.00", "Success": "true", "Error": ""} {
require.Equal(t, want, columns[field], "CSV %s; row=%v", field, rows[i+1])
}
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/reservation_expiry_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ func (p *completenessProxy) reservationExpiryResponse(host, op string, body []by
case "AWSInsightsIndexService.GetReservationPurchaseRecommendation":
var request map[string]any
require.NoError(p.t, json.Unmarshal(body, &request))
require.Equal(p.t, map[string]any{"Service": "Amazon Elastic Compute Cloud - Compute", "AccountScope": "LINKED", "TermInYears": "ONE_YEAR", "PaymentOption": "PARTIAL_UPFRONT", "LookbackPeriodInDays": "SEVEN_DAYS"}, request)
require.Equal(p.t, map[string]any{"Service": "Amazon Elastic Compute Cloud - Compute", "AccountScope": "LINKED", "TermInYears": "ONE_YEAR", "PaymentOption": "PARTIAL_UPFRONT", "LookbackPeriodInDays": "SEVEN_DAYS", "ServiceSpecification": map[string]any{"EC2Specification": map[string]any{"OfferingClass": "CONVERTIBLE"}}}, request)
var details []map[string]any
for i := 0; i < s.rows; i++ {
details = append(details, map[string]any{
Expand Down
17 changes: 14 additions & 3 deletions docs/cli/filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ cudly --services rds --exclude-accounts sandbox
--exclude-regions Exclude recommendations for these AWS regions (comma-separated)
```

When `--regions` is omitted, cudly enumerates all opted-in AWS regions via EC2 `DescribeRegions` and queries each of them; discovering regions from Cost Explorer recommendations is only a fallback used when that region listing fails. (Savings Plans are account-level and are always queried once via `us-east-1` when `--regions` is empty.) `--include-regions` and `--exclude-regions` are applied afterwards as a filter on the fetched recommendations (matching each recommendation's region), so they reduce the results but do not change which regions are queried. If both `--regions` and `--include-regions` are set, the queried regions come from `--regions` and the results are then narrowed by the include/exclude lists.
When `--regions` is omitted, cudly takes the regions from the Cost Explorer recommendations themselves (not from EC2 `DescribeRegions`), so recommendations in regions that only a linked account has opted into are included. Savings Plans are account-level and are always queried once via `us-east-1`; `--regions` is ignored for them, and `--include-regions` / `--exclude-regions` are what narrow Savings Plan results. `--include-regions` and `--exclude-regions` are applied afterwards as a filter on the fetched recommendations (matching each recommendation's region), so they reduce the results but do not change which regions are queried. If both `--regions` and `--include-regions` are set, the queried regions come from `--regions` and the results are then narrowed by the include/exclude lists.

```bash
# Only US and EU recommendations
Expand All @@ -56,7 +56,7 @@ cudly --services rds --exclude-regions ap-southeast-1
--exclude-engines Exclude these database engines (comma-separated)
```

Engine names are matched against the engine field returned by the Cost Explorer recommendation (e.g. `mysql`, `postgresql`, `redis`, `memcached`, `aurora-postgresql`). Comparisons are case-insensitive.
Engine names are matched against the engine field returned by the Cost Explorer recommendation (e.g. `mysql`, `postgresql`, `redis`, `memcached`, `aurora-postgresql`). Comparisons are case-insensitive. EC2 recommendations match on the platform string, so `--exclude-engines windows` excludes `Windows` but does not match `Windows with SQL Server`; list each platform you want to exclude.

```bash
# Only Redis ElastiCache RIs
Expand Down Expand Up @@ -164,7 +164,14 @@ cudly --services rds,elasticache --min-savings-pct 20
--max-break-even-months <int> Maximum break-even period in months (0 = no filter)
```

Drop recommendations where the break-even period exceeds this many months. A break-even period is the number of months until the upfront cost of the RI is recovered by the recurring discount. Recommendations without a computable break-even period (e.g. no-upfront) pass through unfiltered.
Drop recommendations where the break-even period exceeds this many months. A break-even period is the number of months until the upfront payment is recovered by the recurring discount:

```text
gross monthly savings = EstimatedSavings + upfront / term months
break-even months = upfront / gross monthly savings
```

`EstimatedSavings` is already net of the amortized upfront, so the term amortization is added back before dividing. A provider-supplied break-even wins over the formula. No-upfront recommendations (upfront 0) have a break-even of 0 and always pass. A recommendation with an upfront payment whose break-even cannot be computed (negative or non-finite money, or a term other than 1 or 3 years) is dropped, with the reason in the drop summary, because the limit cannot be enforced on it. If net savings are not positive the break-even is infinite and the recommendation is dropped.

**Refused on `--input-csv` runs.** A recommendations CSV carries no break-even column, so this filter has nothing to read. Combining the two flags fails at startup rather than running with the threshold silently unenforced; drop the flag, or filter the CSV before passing it in. Teaching the CSV format to carry the column is tracked in [#1819](https://github.com/LeanerCloud/CUDly/issues/1819).

Expand Down Expand Up @@ -216,6 +223,10 @@ cudly --services rds \
--min-pool-size 5
```

## Coverage sizing

`--coverage <percent>` scales each recommendation's count by the percentage and rounds the result **down**. A row whose count rounds to 0 is dropped and counted in the drop summary as `--coverage-sized-to-zero`. For example, `--coverage 50` turns a count of 1 into 0 and drops it, and a count of 3 into 1. Money fields are scaled by the same ratio as the count. `--target-coverage` sizing reports its own `target-sized-to-zero` reason.

## Combining filters

All filters compose with AND semantics: a recommendation must pass every active filter to be included.
Expand Down
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,10 @@ require (

require (
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261013184038-e9a33bb14d0d
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261013184038-e9a33bb14d0d
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261007133317-58c25f04c49b
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261006205158-7ff8c1aee1bb
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261013212804-9b35837e78d1
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261013212804-9b35837e78d1
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261013212804-9b35837e78d1
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261013212804-9b35837e78d1
github.com/aws/aws-sdk-go-v2/service/organizations v1.45.3
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.3
github.com/aws/aws-sdk-go-v2/service/sts v1.33.17
Expand Down
16 changes: 8 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,14 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
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-20261013184038-e9a33bb14d0d h1:LzTtaDpdS0FJJ0x9kRbrrn4Zrm85HLccnO9L8Wp8toc=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261013184038-e9a33bb14d0d/go.mod h1:8RTmzPazA8IeVZfn7wNaU7ILx0VK5eHZQozO21Mvtq8=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261013184038-e9a33bb14d0d h1:NlNTNHOpftHUfSNmVJKAHhA6RP0uLRJOAlk8xmhJjy8=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261013184038-e9a33bb14d0d/go.mod h1:no7i/7i+W37+qhEOVU8F/0dP5FhO3wtzvKIN0+tvL3A=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261007133317-58c25f04c49b h1:yurugmfdZjf+dqjfqfDyfoBR3IAiFUpMKQ8URnZnJzQ=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261007133317-58c25f04c49b/go.mod h1:iIvPuWLWd+SXqLHAQNdodYMUAAcq7PnwfScXWgARt5A=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261006205158-7ff8c1aee1bb h1:AJ4nKDtttjiTDemo9PDp2esoc35ymVaiCSr0zIewwpY=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261006205158-7ff8c1aee1bb/go.mod h1:vLtjCWuAcgFc6vlY4l4wpWOYvlj4itk25qjreylWpDQ=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261013212804-9b35837e78d1 h1:i4up20HOy0TFHwnryHWSUzfE0t4EgyYIG6zvosCdssg=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261013212804-9b35837e78d1/go.mod h1:8RTmzPazA8IeVZfn7wNaU7ILx0VK5eHZQozO21Mvtq8=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261013212804-9b35837e78d1 h1:c4JuGObXDFP+oqC7CD+0Sn9ljzlUciVikW7iMDCNNa4=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261013212804-9b35837e78d1/go.mod h1:no7i/7i+W37+qhEOVU8F/0dP5FhO3wtzvKIN0+tvL3A=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261013212804-9b35837e78d1 h1:aYYu7JDJQcoriUKg/m+IgTJruc2ugfBLXafAC2LQy00=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261013212804-9b35837e78d1/go.mod h1:8vqO2ZlDzjX/Z4bESZVp/IenhTifkySeF0VJwQKihLA=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261013212804-9b35837e78d1 h1:LPI8x3lOnrvr4w3oX8cHsCLoXuCSLSUhcHbe4WCMUSQ=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261013212804-9b35837e78d1/go.mod h1:J4H0b34EwQQ4p9cM3amsIU9tFmbzecS8E00ybjVp56g=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/config v1.29.12 h1:Y/2a+jLPrPbHpFkpAAYkVEtJmxORlXoo5k2g1fa2sUo=
Expand Down
Loading