diff --git a/.gitallowed b/.gitallowed index 648653520..9f956278f 100644 --- a/.gitallowed +++ b/.gitallowed @@ -75,7 +75,7 @@ # bracketed letter in PRIVAT[E] for the same self-scan reason as above. ^(SECURITY\.md|\.github/workflows/README\.md):[0-9]+:\|[-| ]+\|$ ^cmd/[a-z_]+\.go:[0-9]+:[[:space:]]*(fmt|AppLogger)\.Println\("-+"\)$ -^cmd/multi_service(_helpers|_csv_run)?\.go:[0-9]+:[[:space:]]*AppLogger\.Printf\("(\\n)?(━)+\\n"\)$ +^cmd/multi_service(_helpers|_csv_run|_fetch_all)?\.go:[0-9]+:[[:space:]]*AppLogger\.Printf\("(\\n)?(━)+\\n"\)$ ^cmd/configure_gcp_test\.go:[0-9]+:// --- addMemberToPolicyBinding -+$ ^cmd/configure_test\.go:[0-9]+:[[:space:]]*assert\.Equal\(t, "-----BEGIN PRIVAT[E] KEY-----\\n\.\.\.", creds\.PrivateKey\)$ ^cmd/configure_gcp_store_test\.go:[0-9]+:[[:space:]]*"private_key": "-----BEGIN PRIVAT[E] KEY-----\\nMIIEvQIBADANBg\.\.\.\\n-----END PRIVATE KEY-----\\n",$ diff --git a/cmd/multi_service_engine_data.go b/cmd/multi_service_engine_data.go new file mode 100644 index 000000000..a96639229 --- /dev/null +++ b/cmd/multi_service_engine_data.go @@ -0,0 +1,130 @@ +package main + +import ( + "context" + "fmt" + "maps" + "slices" + "sort" + "strings" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" +) + +// engineVersionData holds the results of engine version queries. +// failedRegions lists regions whose RDS inventory could not be read +// completely, mapped to the cause; instanceVersions then holds only what was +// read, so recommendations in those regions cannot be checked (issue #2147). +type engineVersionData struct { + instanceVersions map[string][]InstanceEngineVersion + versionInfo map[string]MajorEngineVersionInfo + failedRegions map[string]error +} + +// engineVersionFetcher is the seam tests replace so they never reach AWS. +var engineVersionFetcher = fetchEngineVersionData + +// extendedSupportCheckNeeded reports whether extended-support exclusion will +// run for the given services: it is on by default and applies to RDS only. +func extendedSupportCheckNeeded(cfg Config, services []common.ServiceType) bool { + if cfg.IncludeExtendedSupport { + return false + } + return slices.Contains(services, common.ServiceRDS) +} + +// fetchEngineVersionData queries running instances and major engine versions +// for extended-support exclusion. When needed is false nothing is queried. +// It fails when exclusion data is unavailable as a whole (region listing or +// any engine lifecycle query); per-region instance failures are returned in +// failedRegions and enforced per recommendation by requireRegionInventory. +func fetchEngineVersionData(ctx context.Context, cfg Config, needed bool) (engineVersionData, error) { + data := engineVersionData{ + instanceVersions: make(map[string][]InstanceEngineVersion), + versionInfo: make(map[string]MajorEngineVersionInfo), + failedRegions: make(map[string]error), + } + if !needed { + return data, nil + } + + var err error + data.instanceVersions, data.failedRegions, err = queryInstanceVersions(ctx, cfg) + if err != nil { + return engineVersionData{}, fmt.Errorf("extended-support exclusion data unavailable: %w (re-run, or pass --include-extended-support to skip the check)", err) + } + data.versionInfo, err = queryMajorVersions(ctx, cfg) + if err != nil { + return engineVersionData{}, fmt.Errorf("extended-support exclusion data unavailable: %w (re-run, or pass --include-extended-support to skip the check)", err) + } + return data, nil +} + +// queryInstanceVersions queries running instances for engine version validation. +func queryInstanceVersions(ctx context.Context, cfg Config) (instances map[string][]InstanceEngineVersion, failedRegions map[string]error, err error) { + AppLogger.Printf("šŸ” Querying running RDS instances across all regions to validate engine versions...\n") + instanceVersions, failedRegions, err := queryRunningInstanceEngineVersions(ctx, cfg) + if err != nil { + return nil, nil, err + } + + AppLogger.Printf("āœ… Found %d instance types with version information across all regions\n", len(instanceVersions)) + if len(failedRegions) > 0 { + AppLogger.Printf("āš ļø Warning: RDS inventory unavailable in %s; RDS recommendations in those regions will abort the run, other regions proceed\n", describeFailedRegions(failedRegions)) + } + return instanceVersions, failedRegions, nil +} + +// queryMajorVersions queries major engine versions for extended support detection. +func queryMajorVersions(ctx context.Context, cfg Config) (map[string]MajorEngineVersionInfo, error) { + AppLogger.Printf("šŸ” Querying AWS RDS major engine versions for extended support information...\n") + versionInfo, err := queryMajorEngineVersions(ctx, cfg) + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + if err != nil { + return nil, err + } + + AppLogger.Printf("āœ… Found support information for %d major engine versions\n", len(versionInfo)) + return versionInfo, nil +} + +// describeFailedRegions renders failed regions and causes in a stable order. +func describeFailedRegions(failed map[string]error) string { + names := make([]string, 0, len(failed)) + for region := range failed { + names = append(names, region) + } + sort.Strings(names) + parts := make([]string, 0, len(names)) + for _, region := range names { + parts = append(parts, fmt.Sprintf("%s (%v)", region, failed[region])) + } + return strings.Join(parts, "; ") +} + +// requireRegionInventory fails when an RDS recommendation sits in a region +// (or has no region) whose instance inventory could not be read, because the +// extended-support exclusion cannot be applied to it. Recommendations in +// healthy regions are unaffected. +func requireRegionInventory(recs []common.Recommendation, cfg Config, data engineVersionData) error { + if cfg.IncludeExtendedSupport || len(data.failedRegions) == 0 { + return nil + } + affected := make(map[string]error) + for i := range recs { + if d, ok := recs[i].Details.(*common.DatabaseDetails); !ok || d == nil { + continue + } + if recs[i].Region == "" { + maps.Copy(affected, data.failedRegions) + } else if cause, failed := data.failedRegions[recs[i].Region]; failed { + affected[recs[i].Region] = cause + } + } + if len(affected) == 0 { + return nil + } + return fmt.Errorf("extended-support exclusion cannot be applied: RDS inventory unavailable in %s (re-run, or pass --include-extended-support to skip the check)", describeFailedRegions(affected)) +} diff --git a/cmd/multi_service_fetch_all.go b/cmd/multi_service_fetch_all.go new file mode 100644 index 000000000..0d7599063 --- /dev/null +++ b/cmd/multi_service_fetch_all.go @@ -0,0 +1,197 @@ +package main + +import ( + "context" + "fmt" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/LeanerCloud/cloud-commitments-go/pkg/provider" + "github.com/LeanerCloud/cloud-commitments-go/providers/aws/recommendations" + "github.com/aws/aws-sdk-go-v2/aws" +) + +// checkDuplicates adjusts recommendations against already-owned RIs so the run +// does not double-purchase existing capacity. +// +// It deliberately does NOT apply --max-instances. This function runs once per +// (service, region), and capping here caps each region independently, which +// multiplies the operator's cap by the number of service/region pairs. The cap +// is applied once run-wide instead, after every region has been fetched +// (applyGlobalInstanceLimit in multi_service.go). +// +// The duplicate check is the only guard between a re-run and a double +// purchase, so a failed check (throttling, a describe-call AccessDenied, a +// transient 5xx) must not fall back to the un-deduplicated counts on a +// purchase run: that would buy reserved capacity the account already owns. +// isDryRun selects the behavior on error: a dry run logs a loud warning and +// continues (nothing is bought, so reporting fidelity wins), while a purchase +// run refuses to spend and drops these recommendations entirely: an +// over-purchase of reserved capacity is not reversible. +// +// drops accumulates per-reason drop counts for the end-of-run summary; pass nil to skip. +// +// A recommendation dropped because the check itself failed is counted under +// dropDuplicateCheckFailed, not common.DropDuplicateDedup: the latter means +// "an actual duplicate was found and subtracted", and reusing it for a failed +// check would report the failure as a successful dedup in the summary. +func checkDuplicates( + ctx context.Context, + filteredRecs []common.Recommendation, + serviceClient provider.ServiceClient, + isDryRun bool, + drops *common.DropSummary, +) []common.Recommendation { + // Check for duplicate RIs to avoid double purchasing + duplicateChecker := NewDuplicateChecker(toolCfg.IdempotencyWindowHours) + adjustedRecs, dedupedOut, err := duplicateChecker.AdjustRecommendationsForExistingRIs(ctx, filteredRecs, serviceClient) + if err != nil { + if ctx.Err() != nil { + // Interrupted: terminal in dry and real runs alike, never + // "continue with un-deduplicated counts". The caller stops on ctx.Err(). + return nil + } + if !isDryRun { + AppLogger.Printf(" āŒ Refusing to purchase %d instance(s): could not check for existing RIs (%v). Dropping these recommendations rather than risking a duplicate purchase.\n", CalculateTotalInstances(filteredRecs), err) + drops.Add(dropDuplicateCheckFailed, len(filteredRecs)) + return nil + } + AppLogger.Printf(" āš ļø Warning: Could not check for existing RIs: %v (dry run; continuing with un-deduplicated counts)\n", err) + // Continue with original filteredRecs on error; adjustedRecs is not used in this branch. + } else { + // Always use the adjusted recommendations (they might have different counts even if same length) + originalInstances := CalculateTotalInstances(filteredRecs) + adjustedInstances := CalculateTotalInstances(adjustedRecs) + if originalInstances != adjustedInstances { + AppLogger.Printf(" šŸ” Adjusted recommendations: %d instances → %d instances to avoid duplicate purchases\n", originalInstances, adjustedInstances) + } + drops.Add(common.DropDuplicateDedup, len(dedupedOut)) + filteredRecs = mapRecs(adjustedRecs, refreshCountLinear) + } + + return filteredRecs +} + +// fetchAndFilterRegionRecs fetches, filters, applies coverage, and deduplicates +// recommendations for a single service+region. No purchases are made. +// coverageMap (when non-nil) feeds existing-pool coverage into the sizing +// step for --target-coverage. drops accumulates per-reason drop counts for +// the end-of-run summary; pass nil to skip tracking. +func fetchAndFilterRegionRecs( + ctx context.Context, + awsCfg aws.Config, + recClient provider.RecommendationsClient, + accountCache *AccountAliasCache, + service common.ServiceType, + region string, + regionIndex, totalRegions int, + engineData engineVersionData, + cfg Config, + coverageMap recommendations.PoolCoverageMap, + drops *common.DropSummary, +) ([]common.Recommendation, error) { + AppLogger.Printf("\n šŸ“ [%d/%d] Region: %s\n", regionIndex, totalRegions, region) + + recs, err := fetchRecommendationsForRegion(ctx, recClient, service, region, cfg) + if err != nil { + return nil, err + } + if len(recs) == 0 { + AppLogger.Printf(" ā„¹ļø No recommendations found\n") + return nil, nil + } + AppLogger.Printf(" āœ… Found %d recommendations\n", len(recs)) + + if err := populateRecommendationAccountNames(ctx, recs, accountCache, cfg); err != nil { + return nil, fmt.Errorf("%w: %w", errRecommendationFetch, err) + } + if err := requireRegionInventory(recs, cfg, engineData); err != nil { + return nil, err + } + recs = applyRegionFilters(recs, engineData, region, cfg, drops) + if len(recs) == 0 { + AppLogger.Printf(" ā„¹ļø No recommendations after applying filters\n") + return nil, nil + } + + // Build the regional service client once and reuse for both expiry-aware + // coverage adjustment and the downstream duplicate check. + regionalCfg := awsCfg.Copy() + regionalCfg.Region = region + serviceClient := createServiceClient(service, regionalCfg) + + // Fetch existing commitments up front when --rebuy-window-days is set so + // the sizing step can deduct expiring RIs from ExistingCoveragePct. + // Best-effort: a failure here logs and continues; expiry adjustment + // becomes a no-op for this region. + var expiringCommitments []common.Commitment + if cfg.RebuyWindowDays > 0 && serviceClient != nil { + commits, err := serviceClient.GetExistingCommitments(ctx) + if err != nil { + AppLogger.Printf(" āš ļø Could not fetch existing commitments for expiry check: %v\n", err) + } else { + expiringCommitments = commits + } + } + + recs = applyCoverageAndOverrides(recs, cfg, coverageMap, expiringCommitments, drops) + + // Deduplication: skip recs matching recently-purchased commitments. + // --max-instances is NOT applied here; it is enforced once run-wide by the + // caller so the cap covers every service and region together. + if serviceClient != nil { + recs = checkDuplicates(ctx, recs, serviceClient, effectiveDryRun(cfg), drops) + } + + return recs, nil +} + +// fetchAllRecs collects recommendations from all services and regions without +// purchasing. coverageMap (when non-nil) populates Recommendation.ExistingCoveragePct +// on each rec before sizing, so --target-coverage can subtract what's already +// owned in the same pool. The returned DropSummary accumulates per-reason +// drop counts across every service and region for the end-of-run summary. +func fetchAllRecs( + ctx context.Context, + awsCfg aws.Config, + recClient provider.RecommendationsClient, + accountCache *AccountAliasCache, + servicesToProcess []common.ServiceType, + engineData engineVersionData, + cfg Config, + coverageMap recommendations.PoolCoverageMap, +) ([]common.Recommendation, *common.DropSummary, error) { + all := make([]common.Recommendation, 0) + drops := common.NewDropSummary() + for _, service := range servicesToProcess { + AppLogger.Printf("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + AppLogger.Printf("šŸ” Fetching %s recommendations\n", getServiceDisplayName(service)) + AppLogger.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + + regions, err := determineRegionsForService(ctx, recClient, service, cfg) + if err != nil { + if ctx.Err() != nil { + return nil, drops, nil + } + return nil, nil, fmt.Errorf("%w: determining regions for %s: %w", errRecommendationFetch, getServiceDisplayName(service), err) + } + for i, region := range regions { + if ctx.Err() != nil { + return nil, drops, nil // partial data from an interrupted fetch is not a result + } + recs, err := fetchAndFilterRegionRecs(ctx, awsCfg, recClient, accountCache, service, region, i+1, len(regions), engineData, cfg, coverageMap, drops) + if ctx.Err() != nil { + return nil, drops, nil // interrupted mid-fetch: not a result, not a failure + } + if err != nil { + return nil, nil, err + } + all = append(all, recs...) + } + } + // Checked once over every account seen in any batch, including those the + // filters dropped, so an exact account in another batch is not missed. + if err := requireExactExcludes(accountCache.knownAccounts(), &cfg); err != nil { + return nil, nil, fmt.Errorf("%w: %w", errRecommendationFetch, err) + } + return all, drops, nil +} diff --git a/cmd/multi_service_helpers.go b/cmd/multi_service_helpers.go index 84e0f2a6b..eb0fd7b26 100644 --- a/cmd/multi_service_helpers.go +++ b/cmd/multi_service_helpers.go @@ -2,19 +2,13 @@ package main import ( "context" - "errors" "fmt" - "maps" - "slices" "sort" "strings" "time" "github.com/LeanerCloud/cloud-commitments-go/pkg/common" "github.com/LeanerCloud/cloud-commitments-go/pkg/provider" - awsprovider "github.com/LeanerCloud/cloud-commitments-go/providers/aws" - "github.com/LeanerCloud/cloud-commitments-go/providers/aws/recommendations" - azureprovider "github.com/LeanerCloud/cloud-commitments-go/providers/azure" "github.com/aws/aws-sdk-go-v2/aws" awsec2 "github.com/aws/aws-sdk-go-v2/service/ec2" ) @@ -332,512 +326,3 @@ func determineRegionsForService(ctx context.Context, recClient provider.Recommen AppLogger.Printf("šŸ“ Processing %d region(s)\n", len(regions)) return regions, nil } - -// engineVersionData holds the results of engine version queries. -// failedRegions lists regions whose RDS inventory could not be read -// completely, mapped to the cause; instanceVersions then holds only what was -// read, so recommendations in those regions cannot be checked (issue #2147). -type engineVersionData struct { - instanceVersions map[string][]InstanceEngineVersion - versionInfo map[string]MajorEngineVersionInfo - failedRegions map[string]error -} - -// engineVersionFetcher is the seam tests replace so they never reach AWS. -var engineVersionFetcher = fetchEngineVersionData - -// extendedSupportCheckNeeded reports whether extended-support exclusion will -// run for the given services: it is on by default and applies to RDS only. -func extendedSupportCheckNeeded(cfg Config, services []common.ServiceType) bool { - if cfg.IncludeExtendedSupport { - return false - } - return slices.Contains(services, common.ServiceRDS) -} - -// fetchEngineVersionData queries running instances and major engine versions -// for extended-support exclusion. When needed is false nothing is queried. -// It fails when exclusion data is unavailable as a whole (region listing or -// any engine lifecycle query); per-region instance failures are returned in -// failedRegions and enforced per recommendation by requireRegionInventory. -func fetchEngineVersionData(ctx context.Context, cfg Config, needed bool) (engineVersionData, error) { - data := engineVersionData{ - instanceVersions: make(map[string][]InstanceEngineVersion), - versionInfo: make(map[string]MajorEngineVersionInfo), - failedRegions: make(map[string]error), - } - if !needed { - return data, nil - } - - var err error - data.instanceVersions, data.failedRegions, err = queryInstanceVersions(ctx, cfg) - if err != nil { - return engineVersionData{}, fmt.Errorf("extended-support exclusion data unavailable: %w (re-run, or pass --include-extended-support to skip the check)", err) - } - data.versionInfo, err = queryMajorVersions(ctx, cfg) - if err != nil { - return engineVersionData{}, fmt.Errorf("extended-support exclusion data unavailable: %w (re-run, or pass --include-extended-support to skip the check)", err) - } - return data, nil -} - -// queryInstanceVersions queries running instances for engine version validation. -func queryInstanceVersions(ctx context.Context, cfg Config) (instances map[string][]InstanceEngineVersion, failedRegions map[string]error, err error) { - AppLogger.Printf("šŸ” Querying running RDS instances across all regions to validate engine versions...\n") - instanceVersions, failedRegions, err := queryRunningInstanceEngineVersions(ctx, cfg) - if err != nil { - return nil, nil, err - } - - AppLogger.Printf("āœ… Found %d instance types with version information across all regions\n", len(instanceVersions)) - if len(failedRegions) > 0 { - AppLogger.Printf("āš ļø Warning: RDS inventory unavailable in %s; RDS recommendations in those regions will abort the run, other regions proceed\n", describeFailedRegions(failedRegions)) - } - return instanceVersions, failedRegions, nil -} - -// queryMajorVersions queries major engine versions for extended support detection. -func queryMajorVersions(ctx context.Context, cfg Config) (map[string]MajorEngineVersionInfo, error) { - AppLogger.Printf("šŸ” Querying AWS RDS major engine versions for extended support information...\n") - versionInfo, err := queryMajorEngineVersions(ctx, cfg) - if ctxErr := ctx.Err(); ctxErr != nil { - return nil, ctxErr - } - if err != nil { - return nil, err - } - - AppLogger.Printf("āœ… Found support information for %d major engine versions\n", len(versionInfo)) - return versionInfo, nil -} - -// describeFailedRegions renders failed regions and causes in a stable order. -func describeFailedRegions(failed map[string]error) string { - names := make([]string, 0, len(failed)) - for region := range failed { - names = append(names, region) - } - sort.Strings(names) - parts := make([]string, 0, len(names)) - for _, region := range names { - parts = append(parts, fmt.Sprintf("%s (%v)", region, failed[region])) - } - return strings.Join(parts, "; ") -} - -// requireRegionInventory fails when an RDS recommendation sits in a region -// (or has no region) whose instance inventory could not be read, because the -// extended-support exclusion cannot be applied to it. Recommendations in -// healthy regions are unaffected. -func requireRegionInventory(recs []common.Recommendation, cfg Config, data engineVersionData) error { - if cfg.IncludeExtendedSupport || len(data.failedRegions) == 0 { - return nil - } - affected := make(map[string]error) - for i := range recs { - if d, ok := recs[i].Details.(*common.DatabaseDetails); !ok || d == nil { - continue - } - if recs[i].Region == "" { - maps.Copy(affected, data.failedRegions) - } else if cause, failed := data.failedRegions[recs[i].Region]; failed { - affected[recs[i].Region] = cause - } - } - if len(affected) == 0 { - return nil - } - return fmt.Errorf("extended-support exclusion cannot be applied: RDS inventory unavailable in %s (re-run, or pass --include-extended-support to skip the check)", describeFailedRegions(affected)) -} - -// errRecommendationFetch marks a failure to obtain recommendations from the -// provider, as opposed to a successful but empty answer. -var errRecommendationFetch = errors.New("recommendation fetch failed") - -// fetchRecommendationsForRegion fetches recommendations from AWS for a specific region. -// A provider failure is returned, not logged away: an empty slice with a nil -// error means the provider answered and had nothing to recommend. An -// incomplete-but-usable answer (partial sweep) is returned with a warning. -func fetchRecommendationsForRegion( - ctx context.Context, - recClient provider.RecommendationsClient, - service common.ServiceType, - region string, - cfg Config, -) ([]common.Recommendation, error) { - termStr := "1yr" - if cfg.TermYears == 3 { - termStr = "3yr" - } - - lookback := cfg.RecLookbackPeriod - if lookback == "" { - lookback = recommendations.DefaultRecLookbackPeriod - } - params := common.RecommendationParams{ - Service: service, - PaymentOption: cfg.PaymentOption, - Term: termStr, - LookbackPeriod: lookback, - // Savings Plans specific filters - IncludeSPTypes: cfg.IncludeSPTypes, - ExcludeSPTypes: cfg.ExcludeSPTypes, - SPAccountScope: cfg.SPAccountScope, - RIAccountScope: cfg.RIAccountScope, - } - // For Savings Plans, region is only the endpoint the account-level call is - // signed in (spAPIRegion), not a filter. The provider folds params.Region - // into its include-region set, which would drop every EC2 Instance plan - // recommended outside that endpoint region. - if !common.IsSavingsPlan(service) { - params.Region = region - } - - recs, err := recClient.GetRecommendations(ctx, ¶ms) - stampSavingsPlanRegions(recs) - var incomplete *recommendations.IncompleteRecommendationsError - if errors.As(err, &incomplete) { - AppLogger.Printf(" āš ļø %v\n", incomplete) - return recs, nil - } - if partial := azureprovider.AsPartialSubscriptionFailure(err); partial != nil { - // Keep the subscriptions that did answer, but say plainly that the - // sweep was incomplete: without this the operator would read a short - // list as "little to buy here" rather than "some subscriptions were - // never queried". - AppLogger.Printf(" āš ļø Incomplete: %d of %d Azure subscriptions succeeded; %d could not be queried\n", - partial.Succeeded, partial.Attempted, len(partial.Failed)) - for _, f := range partial.Failed { - AppLogger.Printf(" subscription %s: %v\n", f.SubscriptionID, f.Err) - } - return recs, nil - } - if ctxErr := ctx.Err(); ctxErr != nil { - // Interrupted, not a provider failure: the caller stops on ctx.Err(). - return nil, ctxErr - } - if err != nil { - return nil, fmt.Errorf("%w: %s in %s: %w", errRecommendationFetch, getServiceDisplayName(service), region, err) - } - - return recs, nil -} - -// stampSavingsPlanRegions copies the region Cost Explorer reports for an EC2 -// Instance Savings Plan (kept in Details) into Recommendation.Region, which is -// what the table, CSV and audit log print. Account-level plans have no region -// and are left empty. -func stampSavingsPlanRegions(recs []common.Recommendation) { - for i := range recs { - if recs[i].CommitmentType == common.CommitmentSavingsPlan { - recs[i].Region = awsprovider.EffectiveRegion(recs[i]) - } - } -} - -// populateRecommendationAccountNames populates account names from account IDs. -// An account whose name cannot be resolved gets its ID as the name (member -// accounts cannot call organizations:DescribeAccount). That is fine for -// ID-based account filters; requireNamesResolved fails the run when a -// name-based --include-accounts / --exclude-accounts value needs the name. -func populateRecommendationAccountNames(ctx context.Context, recs []common.Recommendation, accountCache *AccountAliasCache, cfg Config) error { - for i := range recs { - if recs[i].Account != "" { - recs[i].AccountName = accountCache.GetAccountAlias(ctx, recs[i].Account) - } - } - return requireNamesResolved(recs, &cfg) -} - -// applyRegionFilters applies region and instance type filters to recommendations. -func applyRegionFilters( - recs []common.Recommendation, - engineData engineVersionData, - region string, - cfg Config, - drops *common.DropSummary, -) []common.Recommendation { - originalCount := len(recs) - recs = applyFilters(recs, &cfg, engineData.instanceVersions, engineData.versionInfo, region, drops) - - if len(recs) < originalCount { - AppLogger.Printf(" šŸ” After filters: %d recommendations (filtered out %d)\n", len(recs), originalCount-len(recs)) - } - - return recs -} - -// applyCoverageAndOverrides applies sizing (coverage % or target-coverage) -// and count overrides. Sizing mode is selected by cfg.TargetCoverage: > 0 -// routes to ApplyTargetCoverage; otherwise the legacy ApplyCoverage path. -// coverageMap (when non-nil) populates Recommendation.ExistingCoveragePct -// before sizing so the under-buy formula can subtract pool coverage already -// owned. Nil map = no-op; recs keep their default zero values and the -// sizing path falls back to the no-existing-commitments formula. -// -// expiringCommitments (when non-empty and cfg.RebuyWindowDays > 0) reduces -// ExistingCoveragePct further by the share of pool demand attributable to -// RIs expiring within the window, net of replacement RIs started inside the -// coverage lookback (so a re-run does not buy them again), so --target-coverage -// recommends replacements before the cliff. Doesn't run if either input is empty. -// RDS recs sized by the family-NU pass (target-coverage mode) ignore that -// per-rec adjustment and instead get the same commitments and window through -// ApplyFamilyNUSizingRDSWithExpiryOptions, which deducts expiring normalized units -// from the family's coverage and credits replacement RIs bought inside the -// coverage lookback (so a re-run does not buy them again); this deliberately -// raises RDS counts and costs when RIs are about to expire (cli#2132). Callers passing nil commitments -// get the plain ApplyFamilyNUSizingRDS behavior. -// -// drops accumulates per-reason drop counts for the end-of-run summary. -// Pass nil to skip tracking. -func applyCoverageAndOverrides(recs []common.Recommendation, cfg Config, coverageMap recommendations.PoolCoverageMap, expiringCommitments []common.Commitment, drops *common.DropSummary) []common.Recommendation { - recommendations.ApplyCoverageMapToRecommendations(recs, coverageMap) - if cfg.RebuyWindowDays > 0 && len(expiringCommitments) > 0 { - n, missingDemand := recommendations.AdjustExistingCoverageForExpiringCommitmentsWithCoverageOptions( - recs, expiringCommitments, coverageMap, - recommendations.PoolExpiryOptions{WindowDays: cfg.RebuyWindowDays, CoverageLookbackDays: coverageLookbackDays(cfg)}) - if n > 0 { - AppLogger.Printf(" ā° Treating %d recs as partially uncovered (RIs expiring within %d days)\n", n, cfg.RebuyWindowDays) - } - if missingDemand > 0 { - AppLogger.Printf(" āš ļø Skipped expiry adjustment for %d recommendations because pool demand was unavailable; coverage was left unchanged\n", missingDemand) - } - } - // Family-NU sizing for RDS recs: AWS rec API already bundles size-flex - // demand within a family into one rec at one size, so per-pool sizing - // under-buys. Run the family-NU pass first (RDS only, target-coverage - // mode only); non-RDS recs flow through the per-pool path unchanged. - // When TargetCoverage isn't set (legacy --coverage path) the per-pool - // flow handles everything as before. - var sizedRDS []common.Recommendation - rest := recs - if cfg.TargetCoverage > 0 { - var familyDrops recommendations.FamilyDropCounts - var expiry recommendations.FamilyExpiryReport - lookbackDays := coverageLookbackDays(cfg) - sizedRDS, rest, familyDrops, expiry = recommendations.ApplyFamilyNUSizingRDSWithExpiryOptions( - recs, coverageMap, cfg.TargetCoverage, expiringCommitments, - recommendations.FamilyExpiryOptions{WindowDays: cfg.RebuyWindowDays, CoverageLookbackDays: lookbackDays}) - logFamilyExpiry(expiry, cfg.RebuyWindowDays, lookbackDays) - drops.Add(common.DropFamilyAlreadyAtTarget, familyDrops.AlreadyAtTarget) - drops.Add(common.DropFamilyNoNUSignal, familyDrops.NoNUSignal) - drops.Add(common.DropFamilySizedToZero, familyDrops.SizedToZero) - } - filteredRecs := applySizing(rest, cfg, cfg.Coverage, drops) - filteredRecs = append(filteredRecs, sizedRDS...) - if cfg.TargetCoverage > 0 { - AppLogger.Printf(" šŸŽÆ Applying %.1f%% target-coverage: %d recommendations selected (%d via family-NU, %d via per-pool)\n", - cfg.TargetCoverage, len(filteredRecs), len(sizedRDS), len(filteredRecs)-len(sizedRDS)) - } else { - AppLogger.Printf(" šŸ“ˆ Applying %.1f%% coverage: %d recommendations selected\n", cfg.Coverage, len(filteredRecs)) - } - - // Apply count override if specified - if cfg.OverrideCount > 0 { - filteredRecs = ApplyCountOverride(filteredRecs, cfg.OverrideCount) - } - - return filteredRecs -} - -// logFamilyExpiry reports how rebuy-window expiry was folded into the RDS -// family-NU sizing. Unusable RIs (unparsable instance class, or an engine -// spelled differently from the coverage report) were NOT deducted, so they are -// warned about rather than read as "no expiry". -func logFamilyExpiry(expiry recommendations.FamilyExpiryReport, windowDays, lookbackDays int) { - if expiry.ExpiringNU > 0 { - // ExpiringNU is the deduction actually applied after the per-family - // covered/active caps (go#342, go#533), so nothing is logged when it is 0. - AppLogger.Printf(" ā° RDS family sizing: %.0f normalized units of RIs expiring within %d days considered against family coverage; %.0f units of replacement RIs started in the last %d days credited back\n", expiry.ExpiringNU, windowDays, expiry.ReplacementNU, lookbackDays) - } - if expiry.Unusable > 0 { - AppLogger.Printf(" āš ļø %d RDS reserved instances expiring within %d days could not be applied to family sizing (unparsable instance class or no matching coverage family); their expiry was NOT deducted\n", expiry.Unusable, windowDays) - } -} - -// checkDuplicates adjusts recommendations against already-owned RIs so the run -// does not double-purchase existing capacity. -// -// It deliberately does NOT apply --max-instances. This function runs once per -// (service, region), and capping here caps each region independently, which -// multiplies the operator's cap by the number of service/region pairs. The cap -// is applied once run-wide instead, after every region has been fetched -// (applyGlobalInstanceLimit in multi_service.go). -// -// The duplicate check is the only guard between a re-run and a double -// purchase, so a failed check (throttling, a describe-call AccessDenied, a -// transient 5xx) must not fall back to the un-deduplicated counts on a -// purchase run: that would buy reserved capacity the account already owns. -// isDryRun selects the behavior on error: a dry run logs a loud warning and -// continues (nothing is bought, so reporting fidelity wins), while a purchase -// run refuses to spend and drops these recommendations entirely: an -// over-purchase of reserved capacity is not reversible. -// -// drops accumulates per-reason drop counts for the end-of-run summary; pass nil to skip. -// -// A recommendation dropped because the check itself failed is counted under -// dropDuplicateCheckFailed, not common.DropDuplicateDedup: the latter means -// "an actual duplicate was found and subtracted", and reusing it for a failed -// check would report the failure as a successful dedup in the summary. -func checkDuplicates( - ctx context.Context, - filteredRecs []common.Recommendation, - serviceClient provider.ServiceClient, - isDryRun bool, - drops *common.DropSummary, -) []common.Recommendation { - // Check for duplicate RIs to avoid double purchasing - duplicateChecker := NewDuplicateChecker(toolCfg.IdempotencyWindowHours) - adjustedRecs, dedupedOut, err := duplicateChecker.AdjustRecommendationsForExistingRIs(ctx, filteredRecs, serviceClient) - if err != nil { - if ctx.Err() != nil { - // Interrupted: terminal in dry and real runs alike, never - // "continue with un-deduplicated counts". The caller stops on ctx.Err(). - return nil - } - if !isDryRun { - AppLogger.Printf(" āŒ Refusing to purchase %d instance(s): could not check for existing RIs (%v). Dropping these recommendations rather than risking a duplicate purchase.\n", CalculateTotalInstances(filteredRecs), err) - drops.Add(dropDuplicateCheckFailed, len(filteredRecs)) - return nil - } - AppLogger.Printf(" āš ļø Warning: Could not check for existing RIs: %v (dry run; continuing with un-deduplicated counts)\n", err) - // Continue with original filteredRecs on error; adjustedRecs is not used in this branch. - } else { - // Always use the adjusted recommendations (they might have different counts even if same length) - originalInstances := CalculateTotalInstances(filteredRecs) - adjustedInstances := CalculateTotalInstances(adjustedRecs) - if originalInstances != adjustedInstances { - AppLogger.Printf(" šŸ” Adjusted recommendations: %d instances → %d instances to avoid duplicate purchases\n", originalInstances, adjustedInstances) - } - drops.Add(common.DropDuplicateDedup, len(dedupedOut)) - filteredRecs = mapRecs(adjustedRecs, refreshCountLinear) - } - - return filteredRecs -} - -// fetchAndFilterRegionRecs fetches, filters, applies coverage, and deduplicates -// recommendations for a single service+region. No purchases are made. -// coverageMap (when non-nil) feeds existing-pool coverage into the sizing -// step for --target-coverage. drops accumulates per-reason drop counts for -// the end-of-run summary; pass nil to skip tracking. -func fetchAndFilterRegionRecs( - ctx context.Context, - awsCfg aws.Config, - recClient provider.RecommendationsClient, - accountCache *AccountAliasCache, - service common.ServiceType, - region string, - regionIndex, totalRegions int, - engineData engineVersionData, - cfg Config, - coverageMap recommendations.PoolCoverageMap, - drops *common.DropSummary, -) ([]common.Recommendation, error) { - AppLogger.Printf("\n šŸ“ [%d/%d] Region: %s\n", regionIndex, totalRegions, region) - - recs, err := fetchRecommendationsForRegion(ctx, recClient, service, region, cfg) - if err != nil { - return nil, err - } - if len(recs) == 0 { - AppLogger.Printf(" ā„¹ļø No recommendations found\n") - return nil, nil - } - AppLogger.Printf(" āœ… Found %d recommendations\n", len(recs)) - - if err := populateRecommendationAccountNames(ctx, recs, accountCache, cfg); err != nil { - return nil, fmt.Errorf("%w: %w", errRecommendationFetch, err) - } - if err := requireRegionInventory(recs, cfg, engineData); err != nil { - return nil, err - } - recs = applyRegionFilters(recs, engineData, region, cfg, drops) - if len(recs) == 0 { - AppLogger.Printf(" ā„¹ļø No recommendations after applying filters\n") - return nil, nil - } - - // Build the regional service client once and reuse for both expiry-aware - // coverage adjustment and the downstream duplicate check. - regionalCfg := awsCfg.Copy() - regionalCfg.Region = region - serviceClient := createServiceClient(service, regionalCfg) - - // Fetch existing commitments up front when --rebuy-window-days is set so - // the sizing step can deduct expiring RIs from ExistingCoveragePct. - // Best-effort: a failure here logs and continues; expiry adjustment - // becomes a no-op for this region. - var expiringCommitments []common.Commitment - if cfg.RebuyWindowDays > 0 && serviceClient != nil { - commits, err := serviceClient.GetExistingCommitments(ctx) - if err != nil { - AppLogger.Printf(" āš ļø Could not fetch existing commitments for expiry check: %v\n", err) - } else { - expiringCommitments = commits - } - } - - recs = applyCoverageAndOverrides(recs, cfg, coverageMap, expiringCommitments, drops) - - // Deduplication: skip recs matching recently-purchased commitments. - // --max-instances is NOT applied here; it is enforced once run-wide by the - // caller so the cap covers every service and region together. - if serviceClient != nil { - recs = checkDuplicates(ctx, recs, serviceClient, effectiveDryRun(cfg), drops) - } - - return recs, nil -} - -// fetchAllRecs collects recommendations from all services and regions without -// purchasing. coverageMap (when non-nil) populates Recommendation.ExistingCoveragePct -// on each rec before sizing, so --target-coverage can subtract what's already -// owned in the same pool. The returned DropSummary accumulates per-reason -// drop counts across every service and region for the end-of-run summary. -func fetchAllRecs( - ctx context.Context, - awsCfg aws.Config, - recClient provider.RecommendationsClient, - accountCache *AccountAliasCache, - servicesToProcess []common.ServiceType, - engineData engineVersionData, - cfg Config, - coverageMap recommendations.PoolCoverageMap, -) ([]common.Recommendation, *common.DropSummary, error) { - all := make([]common.Recommendation, 0) - drops := common.NewDropSummary() - for _, service := range servicesToProcess { - AppLogger.Printf("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") - AppLogger.Printf("šŸ” Fetching %s recommendations\n", getServiceDisplayName(service)) - AppLogger.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") - - regions, err := determineRegionsForService(ctx, recClient, service, cfg) - if err != nil { - if ctx.Err() != nil { - return nil, drops, nil - } - return nil, nil, fmt.Errorf("%w: determining regions for %s: %w", errRecommendationFetch, getServiceDisplayName(service), err) - } - for i, region := range regions { - if ctx.Err() != nil { - return nil, drops, nil // partial data from an interrupted fetch is not a result - } - recs, err := fetchAndFilterRegionRecs(ctx, awsCfg, recClient, accountCache, service, region, i+1, len(regions), engineData, cfg, coverageMap, drops) - if ctx.Err() != nil { - return nil, drops, nil // interrupted mid-fetch: not a result, not a failure - } - if err != nil { - return nil, nil, err - } - all = append(all, recs...) - } - } - // Checked once over every account seen in any batch, including those the - // filters dropped, so an exact account in another batch is not missed. - if err := requireExactExcludes(accountCache.knownAccounts(), &cfg); err != nil { - return nil, nil, fmt.Errorf("%w: %w", errRecommendationFetch, err) - } - return all, drops, nil -} diff --git a/cmd/multi_service_region_recs.go b/cmd/multi_service_region_recs.go new file mode 100644 index 000000000..dc0516321 --- /dev/null +++ b/cmd/multi_service_region_recs.go @@ -0,0 +1,219 @@ +package main + +import ( + "context" + "errors" + "fmt" + + azureprovider "github.com/LeanerCloud/cloud-commitments-go/providers/azure" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/LeanerCloud/cloud-commitments-go/pkg/provider" + awsprovider "github.com/LeanerCloud/cloud-commitments-go/providers/aws" + "github.com/LeanerCloud/cloud-commitments-go/providers/aws/recommendations" +) + +// errRecommendationFetch marks a failure to obtain recommendations from the +// provider, as opposed to a successful but empty answer. +var errRecommendationFetch = errors.New("recommendation fetch failed") + +// fetchRecommendationsForRegion fetches recommendations from AWS for a specific region. +// A provider failure is returned, not logged away: an empty slice with a nil +// error means the provider answered and had nothing to recommend. An +// incomplete-but-usable answer (partial sweep) is returned with a warning. +func fetchRecommendationsForRegion( + ctx context.Context, + recClient provider.RecommendationsClient, + service common.ServiceType, + region string, + cfg Config, +) ([]common.Recommendation, error) { + termStr := "1yr" + if cfg.TermYears == 3 { + termStr = "3yr" + } + + lookback := cfg.RecLookbackPeriod + if lookback == "" { + lookback = recommendations.DefaultRecLookbackPeriod + } + params := common.RecommendationParams{ + Service: service, + PaymentOption: cfg.PaymentOption, + Term: termStr, + LookbackPeriod: lookback, + // Savings Plans specific filters + IncludeSPTypes: cfg.IncludeSPTypes, + ExcludeSPTypes: cfg.ExcludeSPTypes, + SPAccountScope: cfg.SPAccountScope, + RIAccountScope: cfg.RIAccountScope, + } + // For Savings Plans, region is only the endpoint the account-level call is + // signed in (spAPIRegion), not a filter. The provider folds params.Region + // into its include-region set, which would drop every EC2 Instance plan + // recommended outside that endpoint region. + if !common.IsSavingsPlan(service) { + params.Region = region + } + + recs, err := recClient.GetRecommendations(ctx, ¶ms) + stampSavingsPlanRegions(recs) + var incomplete *recommendations.IncompleteRecommendationsError + if errors.As(err, &incomplete) { + AppLogger.Printf(" āš ļø %v\n", incomplete) + return recs, nil + } + if partial := azureprovider.AsPartialSubscriptionFailure(err); partial != nil { + // Keep the subscriptions that did answer, but say plainly that the + // sweep was incomplete: without this the operator would read a short + // list as "little to buy here" rather than "some subscriptions were + // never queried". + AppLogger.Printf(" āš ļø Incomplete: %d of %d Azure subscriptions succeeded; %d could not be queried\n", + partial.Succeeded, partial.Attempted, len(partial.Failed)) + for _, f := range partial.Failed { + AppLogger.Printf(" subscription %s: %v\n", f.SubscriptionID, f.Err) + } + return recs, nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + // Interrupted, not a provider failure: the caller stops on ctx.Err(). + return nil, ctxErr + } + if err != nil { + return nil, fmt.Errorf("%w: %s in %s: %w", errRecommendationFetch, getServiceDisplayName(service), region, err) + } + + return recs, nil +} + +// stampSavingsPlanRegions copies the region Cost Explorer reports for an EC2 +// Instance Savings Plan (kept in Details) into Recommendation.Region, which is +// what the table, CSV and audit log print. Account-level plans have no region +// and are left empty. +func stampSavingsPlanRegions(recs []common.Recommendation) { + for i := range recs { + if recs[i].CommitmentType == common.CommitmentSavingsPlan { + recs[i].Region = awsprovider.EffectiveRegion(recs[i]) + } + } +} + +// populateRecommendationAccountNames populates account names from account IDs. +// An account whose name cannot be resolved gets its ID as the name (member +// accounts cannot call organizations:DescribeAccount). That is fine for +// ID-based account filters; requireNamesResolved fails the run when a +// name-based --include-accounts / --exclude-accounts value needs the name. +func populateRecommendationAccountNames(ctx context.Context, recs []common.Recommendation, accountCache *AccountAliasCache, cfg Config) error { + for i := range recs { + if recs[i].Account != "" { + recs[i].AccountName = accountCache.GetAccountAlias(ctx, recs[i].Account) + } + } + return requireNamesResolved(recs, &cfg) +} + +// applyRegionFilters applies region and instance type filters to recommendations. +func applyRegionFilters( + recs []common.Recommendation, + engineData engineVersionData, + region string, + cfg Config, + drops *common.DropSummary, +) []common.Recommendation { + originalCount := len(recs) + recs = applyFilters(recs, &cfg, engineData.instanceVersions, engineData.versionInfo, region, drops) + + if len(recs) < originalCount { + AppLogger.Printf(" šŸ” After filters: %d recommendations (filtered out %d)\n", len(recs), originalCount-len(recs)) + } + + return recs +} + +// applyCoverageAndOverrides applies sizing (coverage % or target-coverage) +// and count overrides. Sizing mode is selected by cfg.TargetCoverage: > 0 +// routes to ApplyTargetCoverage; otherwise the legacy ApplyCoverage path. +// coverageMap (when non-nil) populates Recommendation.ExistingCoveragePct +// before sizing so the under-buy formula can subtract pool coverage already +// owned. Nil map = no-op; recs keep their default zero values and the +// sizing path falls back to the no-existing-commitments formula. +// +// expiringCommitments (when non-empty and cfg.RebuyWindowDays > 0) reduces +// ExistingCoveragePct further by the share of pool demand attributable to +// RIs expiring within the window, net of replacement RIs started inside the +// coverage lookback (so a re-run does not buy them again), so --target-coverage +// recommends replacements before the cliff. Doesn't run if either input is empty. +// RDS recs sized by the family-NU pass (target-coverage mode) ignore that +// per-rec adjustment and instead get the same commitments and window through +// ApplyFamilyNUSizingRDSWithExpiryOptions, which deducts expiring normalized units +// from the family's coverage and credits replacement RIs bought inside the +// coverage lookback (so a re-run does not buy them again); this deliberately +// raises RDS counts and costs when RIs are about to expire (cli#2132). Callers passing nil commitments +// get the plain ApplyFamilyNUSizingRDS behavior. +// +// drops accumulates per-reason drop counts for the end-of-run summary. +// Pass nil to skip tracking. +func applyCoverageAndOverrides(recs []common.Recommendation, cfg Config, coverageMap recommendations.PoolCoverageMap, expiringCommitments []common.Commitment, drops *common.DropSummary) []common.Recommendation { + recommendations.ApplyCoverageMapToRecommendations(recs, coverageMap) + if cfg.RebuyWindowDays > 0 && len(expiringCommitments) > 0 { + n, missingDemand := recommendations.AdjustExistingCoverageForExpiringCommitmentsWithCoverageOptions( + recs, expiringCommitments, coverageMap, + recommendations.PoolExpiryOptions{WindowDays: cfg.RebuyWindowDays, CoverageLookbackDays: coverageLookbackDays(cfg)}) + if n > 0 { + AppLogger.Printf(" ā° Treating %d recs as partially uncovered (RIs expiring within %d days)\n", n, cfg.RebuyWindowDays) + } + if missingDemand > 0 { + AppLogger.Printf(" āš ļø Skipped expiry adjustment for %d recommendations because pool demand was unavailable; coverage was left unchanged\n", missingDemand) + } + } + // Family-NU sizing for RDS recs: AWS rec API already bundles size-flex + // demand within a family into one rec at one size, so per-pool sizing + // under-buys. Run the family-NU pass first (RDS only, target-coverage + // mode only); non-RDS recs flow through the per-pool path unchanged. + // When TargetCoverage isn't set (legacy --coverage path) the per-pool + // flow handles everything as before. + var sizedRDS []common.Recommendation + rest := recs + if cfg.TargetCoverage > 0 { + var familyDrops recommendations.FamilyDropCounts + var expiry recommendations.FamilyExpiryReport + lookbackDays := coverageLookbackDays(cfg) + sizedRDS, rest, familyDrops, expiry = recommendations.ApplyFamilyNUSizingRDSWithExpiryOptions( + recs, coverageMap, cfg.TargetCoverage, expiringCommitments, + recommendations.FamilyExpiryOptions{WindowDays: cfg.RebuyWindowDays, CoverageLookbackDays: lookbackDays}) + logFamilyExpiry(expiry, cfg.RebuyWindowDays, lookbackDays) + drops.Add(common.DropFamilyAlreadyAtTarget, familyDrops.AlreadyAtTarget) + drops.Add(common.DropFamilyNoNUSignal, familyDrops.NoNUSignal) + drops.Add(common.DropFamilySizedToZero, familyDrops.SizedToZero) + } + filteredRecs := applySizing(rest, cfg, cfg.Coverage, drops) + filteredRecs = append(filteredRecs, sizedRDS...) + if cfg.TargetCoverage > 0 { + AppLogger.Printf(" šŸŽÆ Applying %.1f%% target-coverage: %d recommendations selected (%d via family-NU, %d via per-pool)\n", + cfg.TargetCoverage, len(filteredRecs), len(sizedRDS), len(filteredRecs)-len(sizedRDS)) + } else { + AppLogger.Printf(" šŸ“ˆ Applying %.1f%% coverage: %d recommendations selected\n", cfg.Coverage, len(filteredRecs)) + } + + // Apply count override if specified + if cfg.OverrideCount > 0 { + filteredRecs = ApplyCountOverride(filteredRecs, cfg.OverrideCount) + } + + return filteredRecs +} + +// logFamilyExpiry reports how rebuy-window expiry was folded into the RDS +// family-NU sizing. Unusable RIs (unparsable instance class, or an engine +// spelled differently from the coverage report) were NOT deducted, so they are +// warned about rather than read as "no expiry". +func logFamilyExpiry(expiry recommendations.FamilyExpiryReport, windowDays, lookbackDays int) { + if expiry.ExpiringNU > 0 { + // ExpiringNU is the deduction actually applied after the per-family + // covered/active caps (go#342, go#533), so nothing is logged when it is 0. + AppLogger.Printf(" ā° RDS family sizing: %.0f normalized units of RIs expiring within %d days considered against family coverage; %.0f units of replacement RIs started in the last %d days credited back\n", expiry.ExpiringNU, windowDays, expiry.ReplacementNU, lookbackDays) + } + if expiry.Unusable > 0 { + AppLogger.Printf(" āš ļø %d RDS reserved instances expiring within %d days could not be applied to family sizing (unparsable instance class or no matching coverage family); their expiry was NOT deducted\n", expiry.Unusable, windowDays) + } +}