Skip to content

Commit 0fd7e72

Browse files
committed
fix(cli): validate min-pool-size, remove duplicate filter call, lint fixes
- Reject fractional and negative --min-pool-size values in validateNumericRanges (instance counts are integers; 1.5 is nonsensical as a threshold) - Add test cases for negative, fractional, zero, and whole-number inputs - Remove redundant shouldIncludePoolSize call from passesDimensionFilters; applyFilters already handles it with logging, so the silent re-check was dead code that obscured the observable path - Replace []string accumulator (poolDrops) with an int counter since labels are already logged per-rec and the summary only needs a count - Fix rangeValCopy: use index loop to avoid copying 360-byte Recommendation - Fix equalFold: use strings.EqualFold instead of ToLower+== in engine checks - Fix godot: add missing periods to exported comments in filters and validators
1 parent 8e72a02 commit 0fd7e72

3 files changed

Lines changed: 109 additions & 48 deletions

File tree

cmd/multi_service_filters.go

Lines changed: 39 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,21 @@ import (
99
"github.com/LeanerCloud/CUDly/pkg/common"
1010
)
1111

12-
// applyFilters applies region, instance type, engine, and engine version filters to recommendations
13-
// currentRegion is the region being processed in the current loop iteration - if non-empty, only recommendations for that region are included
12+
// applyFilters applies region, instance type, engine, and engine version filters to recommendations.
13+
// currentRegion is the region being processed in the current loop iteration; if non-empty, only
14+
// recommendations for that region are included.
1415
func applyFilters(recs []common.Recommendation, cfg Config, instanceVersions map[string][]InstanceEngineVersion, versionInfo map[string]MajorEngineVersionInfo, currentRegion string) []common.Recommendation {
1516
var filtered []common.Recommendation
16-
var poolDrops []string
17+
var poolDropCount int
1718
var poolDropInstances float64
1819

19-
for _, rec := range recs {
20+
for i := range recs {
21+
rec := recs[i]
2022
if cfg.MinPoolSize > 0 && !shouldIncludePoolSize(rec, cfg) {
2123
poolDropInstances += rec.AverageInstancesUsedPerHour
2224
label := fmt.Sprintf("%s/%s/%s", rec.Service, rec.Region, rec.ResourceType)
2325
log.Printf("INFO: --min-pool-size=%.1f dropped %s (avg=%.2f < threshold)", cfg.MinPoolSize, label, rec.AverageInstancesUsedPerHour)
24-
poolDrops = append(poolDrops, label)
26+
poolDropCount++
2527
continue
2628
}
2729
adjusted, include := processRecommendation(rec, cfg, instanceVersions, versionInfo, currentRegion)
@@ -30,8 +32,8 @@ func applyFilters(recs []common.Recommendation, cfg Config, instanceVersions map
3032
}
3133
}
3234

33-
if len(poolDrops) > 0 {
34-
log.Printf("INFO: --min-pool-size dropped %d recommendation(s) (%.2f avg instances/hr total)", len(poolDrops), poolDropInstances)
35+
if poolDropCount > 0 {
36+
log.Printf("INFO: --min-pool-size dropped %d recommendation(s) (%.2f avg instances/hr total)", poolDropCount, poolDropInstances)
3537
}
3638

3739
return filtered
@@ -42,9 +44,9 @@ func applyFilters(recs []common.Recommendation, cfg Config, instanceVersions map
4244
// boolean-filter checks are delegated to passesDimensionFilters to keep
4345
// this function under gocyclo's complexity threshold.
4446
func processRecommendation(rec common.Recommendation, cfg Config, instanceVersions map[string][]InstanceEngineVersion, versionInfo map[string]MajorEngineVersionInfo, currentRegion string) (common.Recommendation, bool) {
45-
// Filter to only recommendations for the current region being processed
46-
// This prevents duplicating recommendations across all regions
47-
// Skip this filter for Savings Plans as they are account-level, not regional
47+
// Filter to only recommendations for the current region being processed.
48+
// This prevents duplicating recommendations across all regions.
49+
// Skip this filter for Savings Plans as they are account-level, not regional.
4850
if currentRegion != "" && rec.Region != currentRegion && !common.IsSavingsPlan(rec.Service) {
4951
return rec, false
5052
}
@@ -53,10 +55,10 @@ func processRecommendation(rec common.Recommendation, cfg Config, instanceVersio
5355
return rec, false
5456
}
5557

56-
// Apply engine version filters - adjust instance count by subtracting extended support versions
58+
// Apply engine version filters - adjust instance count by subtracting extended support versions.
5759
if !cfg.IncludeExtendedSupport {
5860
rec = adjustRecommendationForExcludedVersions(rec, instanceVersions, versionInfo)
59-
// Skip if all instances were excluded (count reduced to 0)
61+
// Skip if all instances were excluded (count reduced to 0).
6062
if rec.Count <= 0 {
6163
return rec, false
6264
}
@@ -66,11 +68,11 @@ func processRecommendation(rec common.Recommendation, cfg Config, instanceVersio
6668
}
6769

6870
// passesDimensionFilters runs the stateless include/exclude checks on
69-
// region, instance type, engine, account, and pool size. Returns false on
71+
// region, instance type, engine, and account. Returns false on
7072
// the first failing filter. Split out of processRecommendation to keep
7173
// each function's cyclomatic complexity under the gocyclo limit; the
7274
// dimension filters here are pure functions of rec + cfg with no side
73-
// effects.
75+
// effects. Pool-size filtering is handled with logging in applyFilters.
7476
func passesDimensionFilters(rec common.Recommendation, cfg Config) bool {
7577
if !shouldIncludeRegion(rec.Region, cfg) {
7678
return false
@@ -84,21 +86,18 @@ func passesDimensionFilters(rec common.Recommendation, cfg Config) bool {
8486
if !shouldIncludeAccount(rec.AccountName, cfg) {
8587
return false
8688
}
87-
if !shouldIncludePoolSize(rec, cfg) {
88-
return false
89-
}
9089
return true
9190
}
9291

9392
// shouldIncludePoolSize filters out RI recommendations for pools whose
9493
// AverageInstancesUsedPerHour is below cfg.MinPoolSize. The purpose is to
9594
// drop tiny pools where integer-arithmetic sizing forces 100% coverage
96-
// regardless of --target-coverage (e.g. avg=1 with target=80% floor(0.8)=0
95+
// regardless of --target-coverage (e.g. avg=1 with target=80% -> floor(0.8)=0
9796
// drops, ceil(0.8)=1 over-covers). Setting --min-pool-size=2 keeps pools
9897
// where target can be meaningfully approximated.
9998
//
10099
// Pass-through cases: filter disabled (MinPoolSize<=0), or rec has no
101-
// per-hour signal (avg<=0 SPs and recs CE didn't return usage for).
100+
// per-hour signal (avg<=0 -- SPs and recs CE didn't return usage for).
102101
// Those pools aren't sized via the per-hour formula so the filter doesn't
103102
// apply to them.
104103
func shouldIncludePoolSize(rec common.Recommendation, cfg Config) bool {
@@ -111,53 +110,53 @@ func shouldIncludePoolSize(rec common.Recommendation, cfg Config) bool {
111110
return rec.AverageInstancesUsedPerHour >= cfg.MinPoolSize
112111
}
113112

114-
// shouldIncludeRegion checks if a region should be included based on filters
113+
// shouldIncludeRegion checks if a region should be included based on filters.
115114
func shouldIncludeRegion(region string, cfg Config) bool {
116-
// If include list is specified, region must be in it
115+
// If include list is specified, region must be in it.
117116
if len(cfg.IncludeRegions) > 0 && !slices.Contains(cfg.IncludeRegions, region) {
118117
return false
119118
}
120119

121-
// If exclude list is specified, region must not be in it
120+
// If exclude list is specified, region must not be in it.
122121
if slices.Contains(cfg.ExcludeRegions, region) {
123122
return false
124123
}
125124

126125
return true
127126
}
128127

129-
// shouldIncludeInstanceType checks if an instance type should be included based on filters
128+
// shouldIncludeInstanceType checks if an instance type should be included based on filters.
130129
func shouldIncludeInstanceType(instanceType string, cfg Config) bool {
131-
// If include list is specified, instance type must be in it
130+
// If include list is specified, instance type must be in it.
132131
if len(cfg.IncludeInstanceTypes) > 0 && !slices.Contains(cfg.IncludeInstanceTypes, instanceType) {
133132
return false
134133
}
135134

136-
// If exclude list is specified, instance type must not be in it
135+
// If exclude list is specified, instance type must not be in it.
137136
if slices.Contains(cfg.ExcludeInstanceTypes, instanceType) {
138137
return false
139138
}
140139

141140
return true
142141
}
143142

144-
// shouldIncludeEngine checks if a recommendation should be included based on engine filters
143+
// shouldIncludeEngine checks if a recommendation should be included based on engine filters.
145144
func shouldIncludeEngine(rec common.Recommendation, cfg Config) bool {
146-
// Extract engine from recommendation
145+
// Extract engine from recommendation.
147146
engine := getEngineFromRecommendation(rec)
148147
if engine == "" {
149-
// If no engine info, include by default unless there's an include list
148+
// If no engine info, include by default unless there's an include list.
150149
return len(cfg.IncludeEngines) == 0
151150
}
152151

153-
// Normalize engine name to lowercase for comparison
152+
// Normalize engine name to lowercase for comparison.
154153
engine = strings.ToLower(engine)
155154

156-
// If include list is specified, engine must be in it
155+
// If include list is specified, engine must be in it.
157156
if len(cfg.IncludeEngines) > 0 {
158157
found := false
159158
for _, e := range cfg.IncludeEngines {
160-
if strings.ToLower(e) == engine {
159+
if strings.EqualFold(e, engine) {
161160
found = true
162161
break
163162
}
@@ -167,10 +166,10 @@ func shouldIncludeEngine(rec common.Recommendation, cfg Config) bool {
167166
}
168167
}
169168

170-
// If exclude list is specified, engine must not be in it
169+
// If exclude list is specified, engine must not be in it.
171170
if len(cfg.ExcludeEngines) > 0 {
172171
for _, e := range cfg.ExcludeEngines {
173-
if strings.ToLower(e) == engine {
172+
if strings.EqualFold(e, engine) {
174173
return false
175174
}
176175
}
@@ -179,29 +178,29 @@ func shouldIncludeEngine(rec common.Recommendation, cfg Config) bool {
179178
return true
180179
}
181180

182-
// shouldIncludeAccount checks if an account should be included based on filters
181+
// shouldIncludeAccount checks if an account should be included based on filters.
183182
func shouldIncludeAccount(accountName string, cfg Config) bool {
184-
// If account name is empty and there are filters, skip it (unless include list is empty)
183+
// If account name is empty and there are filters, skip it (unless include list is empty).
185184
if accountName == "" {
186185
return len(cfg.IncludeAccounts) == 0 && len(cfg.ExcludeAccounts) == 0
187186
}
188187

189188
accountLower := strings.ToLower(accountName)
190189

191-
// Check include list
190+
// Check include list.
192191
if !checkIncludeList(accountLower, cfg.IncludeAccounts) {
193192
return false
194193
}
195194

196-
// Check exclude list
195+
// Check exclude list.
197196
if checkExcludeList(accountLower, cfg.ExcludeAccounts) {
198197
return false
199198
}
200199

201200
return true
202201
}
203202

204-
// checkIncludeList checks if an account matches the include filters
203+
// checkIncludeList checks if an account matches the include filters.
205204
func checkIncludeList(accountLower string, includeAccounts []string) bool {
206205
if len(includeAccounts) == 0 {
207206
return true
@@ -216,7 +215,7 @@ func checkIncludeList(accountLower string, includeAccounts []string) bool {
216215
return false
217216
}
218217

219-
// checkExcludeList checks if an account matches any exclude filters
218+
// checkExcludeList checks if an account matches any exclude filters.
220219
func checkExcludeList(accountLower string, excludeAccounts []string) bool {
221220
for _, filter := range excludeAccounts {
222221
if accountMatchesFilter(accountLower, filter) {
@@ -226,7 +225,7 @@ func checkExcludeList(accountLower string, excludeAccounts []string) bool {
226225
return false
227226
}
228227

229-
// accountMatchesFilter checks if an account matches a filter pattern (exact or substring match)
228+
// accountMatchesFilter checks if an account matches a filter pattern (exact or substring match).
230229
func accountMatchesFilter(accountLower, filter string) bool {
231230
filterLower := strings.ToLower(filter)
232231
return filterLower == accountLower || strings.Contains(accountLower, filterLower)

cmd/validators.go

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111
"github.com/spf13/cobra"
1212
)
1313

14-
// validateFlags performs validation on command line flags before execution
14+
// validateFlags performs validation on command line flags before execution.
1515
func validateFlags(cmd *cobra.Command, args []string) error {
1616
if err := validateNumericRanges(cmd); err != nil {
1717
return err
@@ -69,6 +69,22 @@ func validateNumericRanges(cmd *cobra.Command) error {
6969
return fmt.Errorf("override-count (%d) exceeds reasonable limit of %d", toolCfg.OverrideCount, MaxReasonableInstances)
7070
}
7171

72+
if err := validateMinPoolSize(); err != nil {
73+
return err
74+
}
75+
76+
return nil
77+
}
78+
79+
// validateMinPoolSize checks that --min-pool-size is a non-negative whole number.
80+
// Instance counts are integers, so a fractional threshold is nonsensical.
81+
func validateMinPoolSize() error {
82+
if toolCfg.MinPoolSize < 0 {
83+
return fmt.Errorf("min-pool-size must be 0 (disabled) or a positive whole number, got: %.2f", toolCfg.MinPoolSize)
84+
}
85+
if toolCfg.MinPoolSize > 0 && toolCfg.MinPoolSize != float64(int64(toolCfg.MinPoolSize)) {
86+
return fmt.Errorf("min-pool-size must be a whole number (instance counts are integers), got: %.2f", toolCfg.MinPoolSize)
87+
}
7288
return nil
7389
}
7490

@@ -92,7 +108,7 @@ func validateTargetCoverage(cmd *cobra.Command) error {
92108
return nil
93109
}
94110

95-
// validatePaymentAndTerm validates payment options and term configuration
111+
// validatePaymentAndTerm validates payment options and term configuration.
96112
func validatePaymentAndTerm() error {
97113
// Validate payment option
98114
validPaymentOptions := map[string]bool{
@@ -113,7 +129,7 @@ func validatePaymentAndTerm() error {
113129
return warnRDS3YearNoUpfront()
114130
}
115131

116-
// warnRDS3YearNoUpfront warns if RDS service is selected with 3-year no-upfront
132+
// warnRDS3YearNoUpfront warns if RDS service is selected with 3-year no-upfront.
117133
func warnRDS3YearNoUpfront() error {
118134
// In CSV-input mode the payment option comes from each row, not the
119135
// --payment flag (which keeps its no-upfront default), so this flag-based
@@ -138,7 +154,7 @@ func warnRDS3YearNoUpfront() error {
138154
return nil
139155
}
140156

141-
// containsService checks if a service exists in the slice
157+
// containsService checks if a service exists in the slice.
142158
func containsService(services []common.ServiceType, service common.ServiceType) bool {
143159
for _, svc := range services {
144160
if svc == service {
@@ -148,7 +164,7 @@ func containsService(services []common.ServiceType, service common.ServiceType)
148164
return false
149165
}
150166

151-
// validateFilePaths validates CSV input/output paths
167+
// validateFilePaths validates CSV input/output paths.
152168
func validateFilePaths() error {
153169
// Validate CSV output path if provided
154170
if toolCfg.CSVOutput != "" {
@@ -173,7 +189,7 @@ func validateFilePaths() error {
173189
return nil
174190
}
175191

176-
// validateFilterFlags validates filter configuration flags
192+
// validateFilterFlags validates filter configuration flags.
177193
func validateFilterFlags() error {
178194
// Check for region conflicts
179195
if err := validateNoConflicts(toolCfg.IncludeRegions, toolCfg.ExcludeRegions, "region"); err != nil {
@@ -201,7 +217,7 @@ func validateFilterFlags() error {
201217
return nil
202218
}
203219

204-
// validateNoConflicts checks that include and exclude lists don't overlap
220+
// validateNoConflicts checks that include and exclude lists don't overlap.
205221
func validateNoConflicts(include, exclude []string, itemType string) error {
206222
if len(include) == 0 || len(exclude) == 0 {
207223
return nil
@@ -218,7 +234,7 @@ func validateNoConflicts(include, exclude []string, itemType string) error {
218234
return nil
219235
}
220236

221-
// validateInstanceTypes performs basic validation on instance type names
237+
// validateInstanceTypes performs basic validation on instance type names.
222238
func validateInstanceTypes(instanceTypes []string) error {
223239
if len(instanceTypes) == 0 {
224240
return nil

cmd/validators_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,52 @@ func TestValidateNumericRanges(t *testing.T) {
8787
wantErr: true,
8888
errMsg: "override-count",
8989
},
90+
{
91+
name: "min-pool-size negative",
92+
setupFunc: func() {
93+
toolCfg.Coverage = 80.0
94+
toolCfg.MaxInstances = 0
95+
toolCfg.OverrideCount = 0
96+
toolCfg.CoverageLookbackDays = 30
97+
toolCfg.MinPoolSize = -1.0
98+
},
99+
wantErr: true,
100+
errMsg: "min-pool-size must be 0 (disabled) or a positive whole number",
101+
},
102+
{
103+
name: "min-pool-size fractional rejected",
104+
setupFunc: func() {
105+
toolCfg.Coverage = 80.0
106+
toolCfg.MaxInstances = 0
107+
toolCfg.OverrideCount = 0
108+
toolCfg.CoverageLookbackDays = 30
109+
toolCfg.MinPoolSize = 1.5
110+
},
111+
wantErr: true,
112+
errMsg: "min-pool-size must be a whole number",
113+
},
114+
{
115+
name: "min-pool-size disabled (zero)",
116+
setupFunc: func() {
117+
toolCfg.Coverage = 80.0
118+
toolCfg.MaxInstances = 0
119+
toolCfg.OverrideCount = 0
120+
toolCfg.CoverageLookbackDays = 30
121+
toolCfg.MinPoolSize = 0
122+
},
123+
wantErr: false,
124+
},
125+
{
126+
name: "min-pool-size whole number accepted",
127+
setupFunc: func() {
128+
toolCfg.Coverage = 80.0
129+
toolCfg.MaxInstances = 0
130+
toolCfg.OverrideCount = 0
131+
toolCfg.CoverageLookbackDays = 30
132+
toolCfg.MinPoolSize = 2.0
133+
},
134+
wantErr: false,
135+
},
90136
}
91137

92138
for _, tt := range tests {

0 commit comments

Comments
 (0)