diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index 5d9cf83b3..375582cd0 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -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) { diff --git a/cmd/multi_service_engine_versions.go b/cmd/multi_service_engine_versions.go index bd38e1451..7c5105884 100644 --- a/cmd/multi_service_engine_versions.go +++ b/cmd/multi_service_engine_versions.go @@ -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 diff --git a/cmd/multi_service_engine_versions_test.go b/cmd/multi_service_engine_versions_test.go index fda7e3782..ef7fe2380 100644 --- a/cmd/multi_service_engine_versions_test.go +++ b/cmd/multi_service_engine_versions_test.go @@ -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") +} diff --git a/cmd/recommendation_completeness_test.go b/cmd/recommendation_completeness_test.go index 4f897eb01..54923e7c2 100644 --- a/cmd/recommendation_completeness_test.go +++ b/cmd/recommendation_completeness_test.go @@ -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]) } } diff --git a/cmd/reservation_expiry_command_test.go b/cmd/reservation_expiry_command_test.go index 7ddd009cc..0306cf900 100644 --- a/cmd/reservation_expiry_command_test.go +++ b/cmd/reservation_expiry_command_test.go @@ -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{ diff --git a/docs/cli/filtering.md b/docs/cli/filtering.md index 97d4c21fe..7ada1e3f9 100644 --- a/docs/cli/filtering.md +++ b/docs/cli/filtering.md @@ -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 @@ -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 @@ -164,7 +164,14 @@ cudly --services rds,elasticache --min-savings-pct 20 --max-break-even-months 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). @@ -216,6 +223,10 @@ cudly --services rds \ --min-pool-size 5 ``` +## Coverage sizing + +`--coverage ` 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. diff --git a/go.mod b/go.mod index 41f8ee0c6..3d08a3539 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index a2f4806a1..0047803b7 100644 --- a/go.sum +++ b/go.sum @@ -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=