From a057648f9a273e27bfb837fda9b6eee3471499c3 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 17 Oct 2026 16:53:16 +0200 Subject: [PATCH 1/2] fix(filters): report recommendations an account filter cannot place A recommendation with neither an account ID nor a name cannot be placed by an account filter. Under --include-accounts it is still dropped (it cannot be shown to be included) but is now counted in the drop summary as unidentified-account. Under --exclude-accounts alone it was kept and could be purchased, since it cannot be shown to be outside the excluded accounts; the run now fails with an error. This is reachable on live runs when Cost Explorer omits the account ID. The --input-csv path passes no DropSummary, so the include-filter drop is not counted there. M2 (name filter without CSV names) needs no change: the existing error already tells the operator to filter by account ID. Refs #1613 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +++ cmd/multi_service_filters.go | 50 +++++++++++++++++++++- cmd/multi_service_filters_test.go | 70 +++++++++++++++++++++++++++++++ cmd/multi_service_helpers.go | 3 ++ docs/cli/filtering.md | 2 + 5 files changed, 129 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 388f6888c..9d698ce2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- A recommendation with no account ID and no account name is no longer dropped + silently by `--include-accounts` (it is counted in the drop summary as + `unidentified-account`), and `--exclude-accounts` alone now stops the run with + an error instead of letting such a recommendation through. A live run with + `--exclude-accounts` therefore stops when Cost Explorer returns a + recommendation without an account ID (#1613). - A purchase result with `Success=true` and a non-nil `Error` is now treated as a failure in the Ctrl-C summary, the audit-stop message, the per-service success/failed counts and the non-zero exit. The CSV `Success` column keeps the diff --git a/cmd/multi_service_filters.go b/cmd/multi_service_filters.go index 0e2baa92c..dac605642 100644 --- a/cmd/multi_service_filters.go +++ b/cmd/multi_service_filters.go @@ -68,6 +68,12 @@ func processRecommendation(rec *common.Recommendation, cfg *Config, instanceVers // operator-scoping choices, not drops worth surfacing in the summary. // --min-pool-size drops are counted separately in applyFilters before // this function runs, so no drop reason is surfaced here. + // The exception is a recommendation with no account ID or name under + // --include-accounts: it is not a scoping choice but data the filter + // cannot place, so it is counted. + if isUnidentifiedAccount(rec) && len(cfg.IncludeAccounts) > 0 && passesWithoutAccountFilters(rec, cfg) { + return *rec, false, dropUnidentifiedAccount + } return *rec, false, "" } @@ -168,6 +174,24 @@ func shouldIncludeEngine(rec *common.Recommendation, cfg *Config) bool { return filtersFromConfig(cfg).IncludesEngine(rec) } +// dropUnidentifiedAccount is the DropSummary reason for a recommendation that +// carries neither an account ID nor an account name and is dropped by +// --include-accounts because it cannot be shown to belong to an included account. +const dropUnidentifiedAccount = "unidentified-account (--include-accounts)" + +func isUnidentifiedAccount(rec *common.Recommendation) bool { + return rec.Account == "" && rec.AccountName == "" +} + +// passesWithoutAccountFilters reports whether rec passes every dimension filter +// except the account ones, so an unidentified-account drop is only counted for +// a recommendation the account filter alone removed. +func passesWithoutAccountFilters(rec *common.Recommendation, cfg *Config) bool { + c := *cfg + c.IncludeAccounts, c.ExcludeAccounts = nil, nil + return passesDimensionFilters(rec, &c) +} + // shouldIncludeAccount checks if an account should be included based on filters. func shouldIncludeAccount(accountName string, cfg *Config) bool { return shouldIncludeAccountIDOrName("", accountName, cfg) @@ -196,15 +220,37 @@ func shouldIncludeAccountIDOrName(accountID, accountName string, cfg *Config) bo // that can be decided without the account's name. var accountIDPattern = regexp.MustCompile(`^\d{12}$`) -// requireResolvedAccountNames runs both account-filter pre-checks over a full -// set of recommendations: requireNamesResolved and requireExactExcludes. +// requireResolvedAccountNames runs the account-filter pre-checks over a full +// set of recommendations: requireIdentifiedForExclude, requireNamesResolved and +// requireExactExcludes. func requireResolvedAccountNames(recs []common.Recommendation, cfg *Config) error { + if err := requireIdentifiedForExclude(recs, cfg); err != nil { + return err + } if err := requireNamesResolved(recs, cfg); err != nil { return err } return requireExactExcludes(recs, cfg) } +// requireIdentifiedForExclude fails when --exclude-accounts is the only account +// filter and a recommendation has neither an account ID nor a name. Such a +// recommendation cannot be shown to be outside the excluded accounts, and +// carrying on would let it be purchased. Under --include-accounts the same +// recommendation is dropped instead (it cannot be shown to be included) and +// counted in the drop summary, so no error is needed there. +func requireIdentifiedForExclude(recs []common.Recommendation, cfg *Config) error { + if len(cfg.ExcludeAccounts) == 0 || len(cfg.IncludeAccounts) > 0 { + return nil + } + for i := range recs { + if isUnidentifiedAccount(&recs[i]) { + return fmt.Errorf("cannot apply --exclude-accounts: a %s %s recommendation in %s has no account ID or name, so --exclude-accounts cannot rule it out; use --include-accounts with the accounts to buy in, or (for --input-csv) fill the Account column", recs[i].Service, recs[i].ResourceType, recs[i].Region) + } + } + return nil +} + // requireNamesResolved fails when an account filter holds a name and a // recommendation's account name is unknown (its name lookup failed, so the ID // stands in for it, or the CSV row carries no name). Such a recommendation diff --git a/cmd/multi_service_filters_test.go b/cmd/multi_service_filters_test.go index a98a52698..3defa734d 100644 --- a/cmd/multi_service_filters_test.go +++ b/cmd/multi_service_filters_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "log" "os" @@ -945,3 +946,72 @@ func TestRequireExactExcludes(t *testing.T) { }) } } + +const testUnidentifiedAcct = "123456789012" + +func TestApplyFilters_UnidentifiedAccountUnderInclude(t *testing.T) { + unidentified := common.Recommendation{Service: common.ServiceRDS, Region: "us-east-1", ResourceType: "db.t3.micro", Count: 1} + named := common.Recommendation{Service: common.ServiceRDS, Region: "us-east-1", ResourceType: "db.t3.micro", Count: 1, Account: testUnidentifiedAcct, AccountName: "prod"} + tests := []struct { + name string + cfg Config + recs []common.Recommendation + wantKept int + wantDrops int + }{ + {"include drops it and counts it", Config{IncludeExtendedSupport: true, IncludeAccounts: []string{"prod"}}, []common.Recommendation{unidentified, named}, 1, 1}, + {"include with other region mismatch is not counted", Config{IncludeExtendedSupport: true, IncludeAccounts: []string{"prod"}, IncludeRegions: []string{"eu-west-1"}}, []common.Recommendation{unidentified}, 0, 0}, + {"no account filter keeps it", Config{IncludeExtendedSupport: true}, []common.Recommendation{unidentified}, 1, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + drops := common.NewDropSummary() + got := applyFilters(tt.recs, &tt.cfg, nil, nil, "", drops) + assert.Len(t, got, tt.wantKept) + assert.Equal(t, tt.wantDrops, drops.Total()) + if tt.wantDrops > 0 { + assert.Contains(t, drops.FormatOneLine(), "unidentified-account") + } + }) + } +} + +func TestRequireResolvedAccountNames_UnidentifiedRecUnderExcludeOnly(t *testing.T) { + unidentified := common.Recommendation{Service: common.ServiceRDS, Region: "us-east-1", ResourceType: "db.t3.micro"} + named := common.Recommendation{Account: testUnidentifiedAcct, AccountName: "prod"} + tests := []struct { + name string + cfg Config + recs []common.Recommendation + wantErr bool + }{ + {"exclude-only fails loud", Config{ExcludeAccounts: []string{testUnidentifiedAcct}}, []common.Recommendation{named, unidentified}, true}, + {"exclude with include drops instead of failing", Config{IncludeAccounts: []string{"prod"}, ExcludeAccounts: []string{testUnidentifiedAcct}}, []common.Recommendation{unidentified}, false}, + {"no account filter is fine", Config{}, []common.Recommendation{unidentified}, false}, + {"exclude-only with all rows identified is fine", Config{ExcludeAccounts: []string{testUnidentifiedAcct}}, []common.Recommendation{named}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := requireResolvedAccountNames(tt.recs, &tt.cfg) + if !tt.wantErr { + assert.NoError(t, err) + return + } + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "no account ID or name") + } + }) + } +} + +func TestPopulateRecommendationAccountNames_UnidentifiedRecUnderExcludeOnly(t *testing.T) { + cache := NewAccountAliasCacheWithClient(&MockOrganizationsClient{}) + recs := []common.Recommendation{{Service: common.ServiceRDS, Region: "us-east-1", ResourceType: "db.t3.micro"}} + + err := populateRecommendationAccountNames(context.Background(), recs, cache, Config{ExcludeAccounts: []string{testUnidentifiedAcct}}) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "no account ID or name") + assert.Contains(t, err.Error(), "--include-accounts") + } + assert.NoError(t, populateRecommendationAccountNames(context.Background(), recs, cache, Config{})) +} diff --git a/cmd/multi_service_helpers.go b/cmd/multi_service_helpers.go index ec1f8acc8..08615fd9e 100644 --- a/cmd/multi_service_helpers.go +++ b/cmd/multi_service_helpers.go @@ -546,6 +546,9 @@ func populateRecommendationAccountNames(ctx context.Context, recs []common.Recom recs[i].AccountName = accountCache.GetAccountAlias(ctx, recs[i].Account) } } + if err := requireIdentifiedForExclude(recs, &cfg); err != nil { + return err + } return requireNamesResolved(recs, &cfg) } diff --git a/docs/cli/filtering.md b/docs/cli/filtering.md index 19838dc1d..8620c5807 100644 --- a/docs/cli/filtering.md +++ b/docs/cli/filtering.md @@ -28,6 +28,8 @@ An `--exclude-accounts` value that matches no account exactly but is part of one When a filter value is a name (anything other than a 12-digit account ID) and an account's name cannot be resolved, cudly stops with an error instead of guessing: carrying on would treat an account you excluded as "not excluded". ID-only filters never need the name. The same rule applies to `--input-csv` replay, where the name comes from the CSV's `AccountName` column; a row whose name column holds the account ID counts as unresolved. +A recommendation with neither an account ID nor an account name (for example a CSV row with empty `Account` and `AccountName`) cannot be placed by an account filter. With `--include-accounts` it is dropped, because it cannot be shown to belong to an included account, and the end-of-run drop summary counts it as `unidentified-account (--include-accounts)`. With only `--exclude-accounts` set, cudly stops with an error (`a ... recommendation in ... has no account ID or name`) instead of buying it. This also applies to a live run: if Cost Explorer returns a recommendation without an account ID, a run with `--exclude-accounts` now stops. Use `--include-accounts` with the accounts to buy in, or (for `--input-csv`) fill the `Account` column. + ```bash # Only process two accounts cudly --services rds --include-accounts prod-account,staging-account From f235840f95cb096c7f34fee472b6de78f1521bb2 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 17 Oct 2026 18:35:31 +0200 Subject: [PATCH 2/2] fix(filters): drop and count unidentified recs under exclude-only instead of aborting A recommendation with neither an account ID nor a name is already dropped by shouldIncludeAccountIDOrName whenever any account filter is set, so the exclude-only refusal turned a safe drop into a run-aborting error. Remove requireIdentifiedForExclude from the live and CSV paths and count the drop in the summary as unidentified-account (--exclude-accounts), mirroring the include side. Refs #1613 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 ++--- cmd/multi_service_filters.go | 61 +++++++++++++---------------- cmd/multi_service_filters_test.go | 64 ++++++++++++++++++------------- cmd/multi_service_helpers.go | 3 -- docs/cli/filtering.md | 2 +- 5 files changed, 69 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d698ce2a..5bab5c6b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed -- A recommendation with no account ID and no account name is no longer dropped - silently by `--include-accounts` (it is counted in the drop summary as - `unidentified-account`), and `--exclude-accounts` alone now stops the run with - an error instead of letting such a recommendation through. A live run with - `--exclude-accounts` therefore stops when Cost Explorer returns a - recommendation without an account ID (#1613). +- Recommendations with no account ID or name are now reported in the drop summary + when an account filter (`--include-accounts` or `--exclude-accounts`) removes + them (#1613). - A purchase result with `Success=true` and a non-nil `Error` is now treated as a failure in the Ctrl-C summary, the audit-stop message, the per-service success/failed counts and the non-zero exit. The CSV `Success` column keeps the diff --git a/cmd/multi_service_filters.go b/cmd/multi_service_filters.go index dac605642..9a1876bfa 100644 --- a/cmd/multi_service_filters.go +++ b/cmd/multi_service_filters.go @@ -68,13 +68,10 @@ func processRecommendation(rec *common.Recommendation, cfg *Config, instanceVers // operator-scoping choices, not drops worth surfacing in the summary. // --min-pool-size drops are counted separately in applyFilters before // this function runs, so no drop reason is surfaced here. - // The exception is a recommendation with no account ID or name under - // --include-accounts: it is not a scoping choice but data the filter + // The exception is a recommendation with no account ID or name under an + // account filter: it is not a scoping choice but data the filter // cannot place, so it is counted. - if isUnidentifiedAccount(rec) && len(cfg.IncludeAccounts) > 0 && passesWithoutAccountFilters(rec, cfg) { - return *rec, false, dropUnidentifiedAccount - } - return *rec, false, "" + return *rec, false, unidentifiedAccountDropReason(rec, cfg) } // Apply engine version filters - adjust instance count by subtracting extended support versions. @@ -174,10 +171,28 @@ func shouldIncludeEngine(rec *common.Recommendation, cfg *Config) bool { return filtersFromConfig(cfg).IncludesEngine(rec) } -// dropUnidentifiedAccount is the DropSummary reason for a recommendation that -// carries neither an account ID nor an account name and is dropped by -// --include-accounts because it cannot be shown to belong to an included account. -const dropUnidentifiedAccount = "unidentified-account (--include-accounts)" +// DropSummary reasons for a recommendation that carries neither an account ID +// nor an account name and is dropped by an account filter. With both filters +// set the include label is used. +const ( + dropUnidentifiedInclude = "unidentified-account (--include-accounts)" + dropUnidentifiedExclude = "unidentified-account (--exclude-accounts)" +) + +// unidentifiedAccountDropReason returns the drop reason when rec has no account +// ID or name and the account filter alone removed it, else "". +func unidentifiedAccountDropReason(rec *common.Recommendation, cfg *Config) string { + if !isUnidentifiedAccount(rec) || !passesWithoutAccountFilters(rec, cfg) { + return "" + } + if len(cfg.IncludeAccounts) > 0 { + return dropUnidentifiedInclude + } + if len(cfg.ExcludeAccounts) > 0 { + return dropUnidentifiedExclude + } + return "" +} func isUnidentifiedAccount(rec *common.Recommendation) bool { return rec.Account == "" && rec.AccountName == "" @@ -220,37 +235,15 @@ func shouldIncludeAccountIDOrName(accountID, accountName string, cfg *Config) bo // that can be decided without the account's name. var accountIDPattern = regexp.MustCompile(`^\d{12}$`) -// requireResolvedAccountNames runs the account-filter pre-checks over a full -// set of recommendations: requireIdentifiedForExclude, requireNamesResolved and -// requireExactExcludes. +// requireResolvedAccountNames runs both account-filter pre-checks over a full +// set of recommendations: requireNamesResolved and requireExactExcludes. func requireResolvedAccountNames(recs []common.Recommendation, cfg *Config) error { - if err := requireIdentifiedForExclude(recs, cfg); err != nil { - return err - } if err := requireNamesResolved(recs, cfg); err != nil { return err } return requireExactExcludes(recs, cfg) } -// requireIdentifiedForExclude fails when --exclude-accounts is the only account -// filter and a recommendation has neither an account ID nor a name. Such a -// recommendation cannot be shown to be outside the excluded accounts, and -// carrying on would let it be purchased. Under --include-accounts the same -// recommendation is dropped instead (it cannot be shown to be included) and -// counted in the drop summary, so no error is needed there. -func requireIdentifiedForExclude(recs []common.Recommendation, cfg *Config) error { - if len(cfg.ExcludeAccounts) == 0 || len(cfg.IncludeAccounts) > 0 { - return nil - } - for i := range recs { - if isUnidentifiedAccount(&recs[i]) { - return fmt.Errorf("cannot apply --exclude-accounts: a %s %s recommendation in %s has no account ID or name, so --exclude-accounts cannot rule it out; use --include-accounts with the accounts to buy in, or (for --input-csv) fill the Account column", recs[i].Service, recs[i].ResourceType, recs[i].Region) - } - } - return nil -} - // requireNamesResolved fails when an account filter holds a name and a // recommendation's account name is unknown (its name lookup failed, so the ID // stands in for it, or the CSV row carries no name). Such a recommendation diff --git a/cmd/multi_service_filters_test.go b/cmd/multi_service_filters_test.go index 3defa734d..84589bc50 100644 --- a/cmd/multi_service_filters_test.go +++ b/cmd/multi_service_filters_test.go @@ -383,7 +383,7 @@ func TestShouldIncludeAccount(t *testing.T) { { name: "In include list", accountID: "123456789012", - includeAccounts: []string{"123456789012", "210987654321"}, + includeAccounts: []string{"123456789012", "111111111111"}, excludeAccounts: []string{}, expected: true, }, @@ -947,7 +947,10 @@ func TestRequireExactExcludes(t *testing.T) { } } -const testUnidentifiedAcct = "123456789012" +const ( + testUnidentifiedAcct = "123456789012" + testOtherAcct = "999888777666" +) func TestApplyFilters_UnidentifiedAccountUnderInclude(t *testing.T) { unidentified := common.Recommendation{Service: common.ServiceRDS, Region: "us-east-1", ResourceType: "db.t3.micro", Count: 1} @@ -976,42 +979,51 @@ func TestApplyFilters_UnidentifiedAccountUnderInclude(t *testing.T) { } } -func TestRequireResolvedAccountNames_UnidentifiedRecUnderExcludeOnly(t *testing.T) { - unidentified := common.Recommendation{Service: common.ServiceRDS, Region: "us-east-1", ResourceType: "db.t3.micro"} - named := common.Recommendation{Account: testUnidentifiedAcct, AccountName: "prod"} +func TestApplyFilters_UnidentifiedAccountUnderExclude(t *testing.T) { + unidentified := common.Recommendation{Service: common.ServiceRDS, Region: "us-east-1", ResourceType: "db.t3.micro", Count: 1} + named := common.Recommendation{Service: common.ServiceRDS, Region: "us-east-1", ResourceType: "db.t3.micro", Count: 1, Account: testUnidentifiedAcct, AccountName: "prod"} + other := common.Recommendation{Service: common.ServiceRDS, Region: "us-east-1", ResourceType: "db.t3.micro", Count: 1, Account: testOtherAcct, AccountName: "dev"} tests := []struct { - name string - cfg Config - recs []common.Recommendation - wantErr bool + name string + cfg Config + recs []common.Recommendation + wantKept int + wantDrops int + wantReason string }{ - {"exclude-only fails loud", Config{ExcludeAccounts: []string{testUnidentifiedAcct}}, []common.Recommendation{named, unidentified}, true}, - {"exclude with include drops instead of failing", Config{IncludeAccounts: []string{"prod"}, ExcludeAccounts: []string{testUnidentifiedAcct}}, []common.Recommendation{unidentified}, false}, - {"no account filter is fine", Config{}, []common.Recommendation{unidentified}, false}, - {"exclude-only with all rows identified is fine", Config{ExcludeAccounts: []string{testUnidentifiedAcct}}, []common.Recommendation{named}, false}, + {"exclude-only drops and counts it, keeps others", Config{IncludeExtendedSupport: true, ExcludeAccounts: []string{testUnidentifiedAcct}}, []common.Recommendation{unidentified, named, other}, 1, 1, "unidentified-account (--exclude-accounts)"}, + {"exclude-only with other region mismatch is not counted", Config{IncludeExtendedSupport: true, ExcludeAccounts: []string{testUnidentifiedAcct}, IncludeRegions: []string{"eu-west-1"}}, []common.Recommendation{unidentified}, 0, 0, ""}, + {"include and exclude count it once under the include reason", Config{IncludeExtendedSupport: true, IncludeAccounts: []string{"dev"}, ExcludeAccounts: []string{testUnidentifiedAcct}}, []common.Recommendation{unidentified, other}, 1, 1, "unidentified-account (--include-accounts)"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := requireResolvedAccountNames(tt.recs, &tt.cfg) - if !tt.wantErr { - assert.NoError(t, err) - return - } - if assert.Error(t, err) { - assert.Contains(t, err.Error(), "no account ID or name") + drops := common.NewDropSummary() + got := applyFilters(tt.recs, &tt.cfg, nil, nil, "", drops) + assert.Len(t, got, tt.wantKept) + assert.Equal(t, tt.wantDrops, drops.Total()) + if tt.wantReason != "" { + assert.Contains(t, drops.FormatOneLine(), tt.wantReason) } }) } } +func TestCSVExcludeOnly_UnidentifiedRowFilteredOutWithoutError(t *testing.T) { + unidentified := common.Recommendation{Service: common.ServiceRDS, Region: "us-east-1", ResourceType: "db.t3.micro", Count: 1} + other := common.Recommendation{Service: common.ServiceRDS, Region: "us-east-1", ResourceType: "db.t3.micro", Count: 1, Account: testOtherAcct, AccountName: "dev"} + cfg := Config{IncludeExtendedSupport: true, ExcludeAccounts: []string{testUnidentifiedAcct}} + recs := []common.Recommendation{unidentified, other} + + assert.NoError(t, requireResolvedAccountNames(recs, &cfg)) + got := applyFilters(recs, &cfg, nil, nil, "", common.NewDropSummary()) + if assert.Len(t, got, 1) { + assert.Equal(t, testOtherAcct, got[0].Account) + } +} + func TestPopulateRecommendationAccountNames_UnidentifiedRecUnderExcludeOnly(t *testing.T) { cache := NewAccountAliasCacheWithClient(&MockOrganizationsClient{}) recs := []common.Recommendation{{Service: common.ServiceRDS, Region: "us-east-1", ResourceType: "db.t3.micro"}} - err := populateRecommendationAccountNames(context.Background(), recs, cache, Config{ExcludeAccounts: []string{testUnidentifiedAcct}}) - if assert.Error(t, err) { - assert.Contains(t, err.Error(), "no account ID or name") - assert.Contains(t, err.Error(), "--include-accounts") - } - assert.NoError(t, populateRecommendationAccountNames(context.Background(), recs, cache, Config{})) + assert.NoError(t, populateRecommendationAccountNames(context.Background(), recs, cache, Config{ExcludeAccounts: []string{testUnidentifiedAcct}})) } diff --git a/cmd/multi_service_helpers.go b/cmd/multi_service_helpers.go index 08615fd9e..ec1f8acc8 100644 --- a/cmd/multi_service_helpers.go +++ b/cmd/multi_service_helpers.go @@ -546,9 +546,6 @@ func populateRecommendationAccountNames(ctx context.Context, recs []common.Recom recs[i].AccountName = accountCache.GetAccountAlias(ctx, recs[i].Account) } } - if err := requireIdentifiedForExclude(recs, &cfg); err != nil { - return err - } return requireNamesResolved(recs, &cfg) } diff --git a/docs/cli/filtering.md b/docs/cli/filtering.md index 8620c5807..c8c4206a8 100644 --- a/docs/cli/filtering.md +++ b/docs/cli/filtering.md @@ -28,7 +28,7 @@ An `--exclude-accounts` value that matches no account exactly but is part of one When a filter value is a name (anything other than a 12-digit account ID) and an account's name cannot be resolved, cudly stops with an error instead of guessing: carrying on would treat an account you excluded as "not excluded". ID-only filters never need the name. The same rule applies to `--input-csv` replay, where the name comes from the CSV's `AccountName` column; a row whose name column holds the account ID counts as unresolved. -A recommendation with neither an account ID nor an account name (for example a CSV row with empty `Account` and `AccountName`) cannot be placed by an account filter. With `--include-accounts` it is dropped, because it cannot be shown to belong to an included account, and the end-of-run drop summary counts it as `unidentified-account (--include-accounts)`. With only `--exclude-accounts` set, cudly stops with an error (`a ... recommendation in ... has no account ID or name`) instead of buying it. This also applies to a live run: if Cost Explorer returns a recommendation without an account ID, a run with `--exclude-accounts` now stops. Use `--include-accounts` with the accounts to buy in, or (for `--input-csv`) fill the `Account` column. +A recommendation with neither an account ID nor an account name (for example a CSV row with empty `Account` and `AccountName`) cannot be placed by an account filter, so it is dropped under either `--include-accounts` or `--exclude-accounts`. On live runs the end-of-run drop summary itemises it as `unidentified-account (--include-accounts)` or `unidentified-account (--exclude-accounts)` (the include label when both filters are set), provided it passed every other filter. On `--input-csv` runs there is no drop summary, so such rows are counted only in the "filtered out N" line. ```bash # Only process two accounts