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
12 changes: 12 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ type Config struct {
MinSavingsPct float64
MaxBreakEvenMonths int
MinCount int
// CoverageLookbackDays is the number of calendar days of historical
// demand fed to GetReservationCoverage when computing the existing-RI
// coverage map for --target-coverage sizing. A longer window smooths
// seasonal spikes; a shorter one matches a narrow billing-period export
// from the AWS console coverage report. Default 30, matching the CE
// UI default.
CoverageLookbackDays int
// RebuyWindowDays, when > 0, treats existing RIs whose remaining term
// is at most this many days as already uncovered, so --target-coverage
// recommends replacements before they expire. Zero (default) keeps the
Expand Down Expand Up @@ -150,6 +157,11 @@ func init() {
rootCmd.Flags().Float64Var(&toolCfg.MinSavingsPct, "min-savings-pct", 0, "Minimum savings percentage to include a recommendation (0 = no filter)")
rootCmd.Flags().IntVar(&toolCfg.MaxBreakEvenMonths, "max-break-even-months", 0, "Maximum break-even period in months (0 = no filter)")
rootCmd.Flags().IntVar(&toolCfg.MinCount, "min-count", 0, "Minimum instance count to include a recommendation (0 = no filter)")
rootCmd.Flags().IntVar(&toolCfg.CoverageLookbackDays, "coverage-lookback-days", 30,
"Number of calendar days of historical demand fed to GetReservationCoverage "+
"when computing the existing-RI coverage map for --target-coverage sizing. "+
"Match this to your AWS console coverage report window to reconcile "+
"CUDly's ExistingCoverage column against the console export. Default 30.")
rootCmd.Flags().IntVar(&toolCfg.RebuyWindowDays, "rebuy-window-days", 0,
"When >0, treat existing RIs expiring within this many days as already "+
"uncovered, so --target-coverage sizes recommendations to replace them "+
Expand Down
17 changes: 10 additions & 7 deletions cmd/multi_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,6 @@ import (
"github.com/google/uuid"
)

// existingCoverageLookbackDays is the historical window we ask CE to
// summarise existing-RI coverage over for --target-coverage sizing.
// 30 days matches CE's UI default and the GetRIUtilization caller.
const existingCoverageLookbackDays = 30

// fetchExistingCoverage retrieves the existing-RI coverage map from Cost
// Explorer so --target-coverage sizing can subtract what's already owned in
// each pool. Best-effort: a transient CE failure logs a warning and returns
Expand All @@ -36,6 +31,10 @@ const existingCoverageLookbackDays = 30
// Coverage is fetched per-region per-account so CE's org-wide aggregate
// doesn't bleed one account's coverage into another in multi-account orgs.
// Regions come from cfg.Regions if set, otherwise from EC2 DescribeRegions.
//
// The lookback window is cfg.CoverageLookbackDays (default 30, matching the
// CE UI default). Operators reconciling against the AWS console coverage
// report should match this value to the report's own time window.
func fetchExistingCoverage(ctx context.Context, awsCfg aws.Config, recClient provider.RecommendationsClient, cfg Config) recommendations.PoolCoverageMap {
if cfg.TargetCoverage <= 0 {
return nil
Expand All @@ -46,6 +45,10 @@ func fetchExistingCoverage(ctx context.Context, awsCfg aws.Config, recClient pro
// the no-existing-commitments path.
return nil
}
lookbackDays := cfg.CoverageLookbackDays
if lookbackDays <= 0 {
lookbackDays = 30
}
regions := cfg.Regions
if len(regions) == 0 {
allRegions, err := getAllAWSRegions(ctx, awsCfg)
Expand All @@ -55,8 +58,8 @@ func fetchExistingCoverage(ctx context.Context, awsCfg aws.Config, recClient pro
}
regions = allRegions
}
AppLogger.Printf("\n🔎 Fetching existing-RI coverage from Cost Explorer per-account across %d regions (lookback %d days)...\n", len(regions), existingCoverageLookbackDays)
cov, err := adapter.GetRICoverageMap(ctx, existingCoverageLookbackDays, regions)
AppLogger.Printf("\n🔎 Fetching existing-RI coverage from Cost Explorer per-account across %d regions (lookback %d days)...\n", len(regions), lookbackDays)
cov, err := adapter.GetRICoverageMap(ctx, lookbackDays, regions)
if err != nil {
AppLogger.Printf(" ⚠️ Could not fetch existing-RI coverage (%v); sizing will assume zero existing coverage\n", err)
return nil
Expand Down
35 changes: 35 additions & 0 deletions cmd/multi_service_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -768,3 +768,38 @@ func TestFilterAndAdjustRecommendations_OverrideCountApplied(t *testing.T) {
assert.Equal(t, int(toolCfg.OverrideCount), rec.Count)
}
}

// TestFetchExistingCoverage_LookbackDays verifies that fetchExistingCoverage
// honours cfg.CoverageLookbackDays (issue #360). The test uses the
// MockRecommendationsClient which fails the *awsprovider.RecommendationsClientAdapter
// type assertion, exercising the non-AWS-provider early-return path. The key
// assertions are:
// - TargetCoverage=0 always returns nil regardless of lookback.
// - TargetCoverage>0 with a non-AWS client returns nil (non-AWS path,
// no CE call is made).
//
// Threading through the actual lookback value to GetRICoverageMap is
// integration-tested in providers/aws/recommendations (TestGetRICoverageMap_*).
func TestFetchExistingCoverage_LookbackDays(t *testing.T) {
ctx := context.Background()
awsCfg := aws.Config{}
mockClient := &MockRecommendationsClient{}

t.Run("zero TargetCoverage returns nil regardless of lookback", func(t *testing.T) {
cfg := Config{TargetCoverage: 0, CoverageLookbackDays: 14, Regions: []string{"us-east-1"}}
got := fetchExistingCoverage(ctx, awsCfg, mockClient, cfg)
assert.Nil(t, got, "TargetCoverage=0 must short-circuit before any CE call")
})

t.Run("non-AWS adapter returns nil, lookback not needed", func(t *testing.T) {
cfg := Config{TargetCoverage: 80, CoverageLookbackDays: 14, Regions: []string{"us-east-1"}}
got := fetchExistingCoverage(ctx, awsCfg, mockClient, cfg)
assert.Nil(t, got, "non-AWS provider must return nil (no CE integration)")
})

t.Run("custom lookback stored in Config", func(t *testing.T) {
cfg := Config{TargetCoverage: 80, CoverageLookbackDays: 60, Regions: []string{"us-east-1"}}
// CoverageLookbackDays field value is preserved in the struct.
assert.Equal(t, 60, cfg.CoverageLookbackDays)
})
}
5 changes: 5 additions & 0 deletions cmd/validators.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ func validateNumericRanges(cmd *cobra.Command) error {
return err
}

// Validate coverage lookback days
if toolCfg.CoverageLookbackDays < 1 {
return fmt.Errorf("coverage-lookback-days must be >= 1, got: %d", toolCfg.CoverageLookbackDays)
}

// Validate max instances
if toolCfg.MaxInstances < 0 {
return fmt.Errorf("max-instances must be 0 (no limit) or a positive number, got: %d", toolCfg.MaxInstances)
Expand Down
57 changes: 56 additions & 1 deletion cmd/validators_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ func TestValidateNumericRanges(t *testing.T) {
toolCfg.Coverage = 80.0
toolCfg.MaxInstances = 100
toolCfg.OverrideCount = 10
toolCfg.CoverageLookbackDays = 30
},
wantErr: false,
},
{
name: "coverage below zero",
setupFunc: func() {
toolCfg.Coverage = -1.0
toolCfg.CoverageLookbackDays = 30
},
wantErr: true,
errMsg: "coverage percentage must be between 0 and 100",
Expand All @@ -38,6 +40,7 @@ func TestValidateNumericRanges(t *testing.T) {
name: "coverage above 100",
setupFunc: func() {
toolCfg.Coverage = 101.0
toolCfg.CoverageLookbackDays = 30
},
wantErr: true,
errMsg: "coverage percentage must be between 0 and 100",
Expand All @@ -47,6 +50,7 @@ func TestValidateNumericRanges(t *testing.T) {
setupFunc: func() {
toolCfg.Coverage = 80.0
toolCfg.MaxInstances = -1
toolCfg.CoverageLookbackDays = 30
},
wantErr: true,
errMsg: "max-instances must be 0",
Expand All @@ -56,6 +60,7 @@ func TestValidateNumericRanges(t *testing.T) {
setupFunc: func() {
toolCfg.Coverage = 80.0
toolCfg.MaxInstances = MaxReasonableInstances + 1
toolCfg.CoverageLookbackDays = 30
},
wantErr: true,
errMsg: "max-instances",
Expand All @@ -66,6 +71,7 @@ func TestValidateNumericRanges(t *testing.T) {
toolCfg.Coverage = 80.0
toolCfg.MaxInstances = 100
toolCfg.OverrideCount = -1
toolCfg.CoverageLookbackDays = 30
},
wantErr: true,
errMsg: "override-count must be 0",
Expand All @@ -76,6 +82,7 @@ func TestValidateNumericRanges(t *testing.T) {
toolCfg.Coverage = 80.0
toolCfg.MaxInstances = 100
toolCfg.OverrideCount = MaxReasonableInstances + 1
toolCfg.CoverageLookbackDays = 30
},
wantErr: true,
errMsg: "override-count",
Expand Down Expand Up @@ -461,7 +468,7 @@ func TestValidateTargetCoverage(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
toolCfg = Config{Coverage: tt.coverage, TargetCoverage: tt.target}
toolCfg = Config{Coverage: tt.coverage, TargetCoverage: tt.target, CoverageLookbackDays: 30}
var cmd *cobra.Command
if tt.useCobraCmd {
cmd = &cobra.Command{Use: "test"}
Expand All @@ -485,3 +492,51 @@ func TestValidateTargetCoverage(t *testing.T) {
})
}
}

// TestValidateCoverageLookbackDays verifies that validateNumericRanges rejects
// non-positive --coverage-lookback-days and accepts positive values.
func TestValidateCoverageLookbackDays(t *testing.T) {
tests := []struct {
name string
days int
wantErr bool
errSubstr string
}{
{name: "default 30 is valid", days: 30, wantErr: false},
{name: "1 day is valid", days: 1, wantErr: false},
{name: "90 days is valid", days: 90, wantErr: false},
{
name: "zero rejected",
days: 0,
wantErr: true,
errSubstr: "coverage-lookback-days must be >= 1",
},
{
name: "negative rejected",
days: -1,
wantErr: true,
errSubstr: "coverage-lookback-days must be >= 1",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
origCfg := toolCfg
defer func() { toolCfg = origCfg }()
toolCfg = Config{
Coverage: 80,
CoverageLookbackDays: tt.days,
}
err := validateNumericRanges(nil)
if tt.wantErr {
if err == nil {
t.Errorf("validateNumericRanges() expected error containing %q, got nil", tt.errSubstr)
} else if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) {
t.Errorf("validateNumericRanges() error = %v, want substring %q", err, tt.errSubstr)
}
} else if err != nil {
t.Errorf("validateNumericRanges() unexpected error = %v", err)
}
})
}
}
Loading