feat(cli): size purchases by target RI/SP utilization (--target-utilization, closes #338)#339
feat(cli): size purchases by target RI/SP utilization (--target-utilization, closes #338)#339cristim wants to merge 8 commits into
Conversation
Adds four fields to common.Recommendation to carry the utilization data needed by the upcoming --target-utilization CLI flag: - AverageInstancesUsedPerHour / RecommendedUtilization — the AWS-returned signal (read from the CE response by the recommendations parser in a follow-up commit). - ProjectedUtilization / ProjectedCoverage — populated by the sizing step after we pick our own quantity. Wire-format compatible: all four fields are omitempty so existing consumers (frontend, CSV writer) ignore them until they're populated.
Populates the utilization fields added to common.Recommendation in the previous commit: - parser_ri.go: new parseRIUtilizationSignals helper, called from parseRecommendationDetail. Reads AverageNumberOfInstancesUsedPerHour and AverageUtilization from the existing CE response — no new API call. Nil pointer or parse failure leaves the field at zero (the sizing path treats zero as "no signal" and skips). - parser_sp.go: reads EstimatedAverageUtilization via the existing parseOptionalFloat helper and stores it in RecommendedUtilization. AverageInstancesUsedPerHour is left at zero for SPs — the field isn't applicable; SP sizing uses the utilization value directly. Tests in parser_ri_test.go and parser_sp_test.go cover both happy paths and the nil/unparseable fallbacks.
Adds the user-facing CLI surface for issue #338. The flag accepts a % in [0, 100]; default 0 means "disabled, fall back to --coverage". Sizing logic lands in a follow-up commit. - Config.TargetUtilization (cmd/main.go) - --target-utilization / -u cobra flag with help text spelling out the inversion ("higher target = fewer commitments bought") - Range validation extracted into validateTargetUtilization (keeps validateNumericRanges under the gocyclo 10-complexity threshold); explicit info-log when both --target-utilization and --coverage are set on the same invocation (target wins). Uses cobra's Changed() rather than default-comparison so --coverage 80 (which equals the default) still triggers the notice. validateNumericRanges now takes the *cobra.Command for Changed() access; existing test callers updated to pass nil where the flag-source-detection branch is irrelevant. New TestValidateTargetUtilization covers the new range checks and the both-flags-set path.
The core sizing logic for --target-utilization: - RIs: n_target = floor(AverageInstancesUsedPerHour / (target/100)), capped at rec.Count (we never exceed AWS's recommended ceiling). Drop with explanatory INFO log when n_target == 0 (target unreachable, per the issue AC). Pass through unmodified when avg <= 0 (no signal); counted in a single end-of-run WARN summary. - SPs: when AWS already projects at-or-above target, no scaling. Else ratio = RecommendedUtilization / target (< 1); scale exactly the fields ApplyCoverage scales on the SP branch (SavingsPlanDetails.HourlyCommitment + EstimatedSavings — NOT CommitmentCost/OnDemandCost). Discrimination via IsSavingsPlan(rec.Service) to match the existing ApplyCoverage convention. - Routing: new applySizing(recs, cfg, coverage) helper picks between ApplyTargetUtilization and ApplyCoverage based on cfg.TargetUtilization. The explicit `coverage` parameter lets the CSV path pass its csvModeCoverage-substituted value while the main path passes cfg.Coverage. Helper-wiring at the call sites lands in the next commit. Per-rec dispatch extracted into applyTargetUtilizationOne to keep ApplyTargetUtilization under gocyclo's complexity threshold. Tests cover: RI happy path / AWS-ceiling cap / target unreachable drop / no signal pass-through / target-met-exactly; SP no-op / scale-down / no-signal pass-through; cost fields untouched for RIs and partial cost fields untouched for SPs (so divergence with ApplyCoverage's column set is impossible without a failing test); applySizing routing logic.
Routes the two sites that previously called applyCommonCoverage directly through the new applySizing helper, so --target-utilization takes effect in both pipelines: - cmd/multi_service.go: CSV-input path. The CSV mode short-circuit (skip sizing when csvModeCoverage == 100) is updated to NOT skip when target-utilization is set — TargetUtilization applies regardless of the coverage value. - cmd/multi_service_helpers.go: main recommendation-fetch path (applyCoverageAndOverrides). Drops the per-mode coverage call in favour of applySizing; emits a 🎯 vs 📈 log line based on which sizing mode fired. applyCommonCoverage stays in place (called by applySizing's else branch) so the legacy coverage path is byte-for-byte unchanged when --target-utilization is unset.
Adds three columns to the dry-run / purchase CSV emitted by writeMultiServiceCSVReport: - ProjectedUtilization — populated by ApplyTargetUtilization on the sized rec (RI: avg/n_bought * 100, capped at 100; SP: target or AWS's RecommendedUtilization when no scaling needed). - ProjectedCoverage — RI only (n_bought / avg * 100, capped at 100); blank for SPs since the CE response doesn't expose total-demand-$. - RecommendedUtilization — AWS's projected utilization for the full recommendation count (RI AverageUtilization or SP EstimatedAverageUtilization). Zero values render as blank cells via the new formatPercentOrBlank helper — zero means "unknown / not applicable" (e.g. when running on the legacy coverage path), and "0.0" cells would mislead.
Adds the new flag to the Purchase Configuration table and spells out the semantic difference vs `--coverage` plus the inversion (higher target → fewer commitments). The `--help` copy was added in the earlier flag-declaration commit and is sufficient at the CLI; this is for the README's reference table that's the typical first stop for new users.
Two SP-branch bugs surfaced by post-implementation review of #338: 1. **Details=nil set ProjectedUtilization anyway.** When rec.Details was not a *SavingsPlanDetails (a defensive log-and-pass-through path), the function still set ProjectedUtilization = targetPct on its way out. Result: CSV row showed target utilization while cost fields stayed unscaled — misleading the operator. Now returns the rec unscaled with zero projection metrics on the Details-type mismatch. 2. **HourlyCommitment=0 with positive utilization signal slipped past the no-signal guard.** Cost Explorer occasionally returns SP recs with a $0 HourlyCommitment placeholder. The ratio-scaling branch then did 0 * ratio = 0 while still claiming target utilization was achieved. Added an explicit `HourlyCommitment <= 0` no-signal check above the scaling block — pass through, count in skip summary. Also fills the AC's "target == 100 → only purchases with perfectly- matched existing usage" test gap (TestApplyTargetUtilization_RI_Target100). Verifies the avg < 1 drop case, avg=1.0 buy-1 boundary, avg=10.0 perfect-match, and the avg > N truncation-with-clamp case.
📝 WalkthroughWalkthroughThis PR implements a ChangesTarget Utilization Sizing Feature
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
cmd/validators_test.go (1)
417-423: ⚡ Quick win“Both flags explicitly set” branch is not actually exercised.
Line 453 passes
nilcmd, so thecmd.Flags().Changed("coverage")path never runs. This leaves the precedence-log gate untested.Suggested test adjustment
import ( + "fmt" "os" "path/filepath" "strings" "testing" "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/spf13/cobra" ) @@ - toolCfg = Config{Coverage: tt.coverage, TargetUtilization: tt.target} - err := validateNumericRanges(nil) + toolCfg = Config{Coverage: tt.coverage, TargetUtilization: tt.target} + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Float64("coverage", 80, "") + if tt.name == "target + coverage both set, both valid" { + if err := cmd.Flags().Set("coverage", fmt.Sprintf("%v", tt.coverage)); err != nil { + t.Fatalf("failed to mark coverage as changed: %v", err) + } + } + err := validateNumericRanges(cmd)Also applies to: 450-454
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cmd/validators_test.go` around lines 417 - 423, The test TestValidateTargetUtilization isn't exercising the "both flags explicitly set" branch because it passes nil for cmd so cmd.Flags().Changed("coverage") never runs; update the test to create a real cobra.Command (or equivalent command type used in validators_test.go), register the "coverage" flag, and mark it as set (e.g., via cmd.Flags().Set("coverage", "<value>") or by calling the appropriate Changed mechanism) before calling ValidateTargetUtilization so the cmd.Flags().Changed("coverage") path executes; apply the same fix to the related case around lines 450-454 to ensure the precedence-log gate is covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@cmd/helpers_test.go`:
- Around line 922-930: The test table includes wantProjCovGTE but the test body
never asserts projected coverage; update the test loop in cmd/helpers_test.go to
check the function result's ProjectedCoverage (or the field name used in the
returned recommendation/result) and assert that if tests[i].wantProjCovGTE > 0
then result.ProjectedCoverage >= tests[i].wantProjCovGTE; do this alongside the
existing projected utilization check so projected coverage regressions are
caught (apply the same addition for the other similar table near lines 992-997
that also declares wantProjCovGTE).
---
Nitpick comments:
In `@cmd/validators_test.go`:
- Around line 417-423: The test TestValidateTargetUtilization isn't exercising
the "both flags explicitly set" branch because it passes nil for cmd so
cmd.Flags().Changed("coverage") never runs; update the test to create a real
cobra.Command (or equivalent command type used in validators_test.go), register
the "coverage" flag, and mark it as set (e.g., via cmd.Flags().Set("coverage",
"<value>") or by calling the appropriate Changed mechanism) before calling
ValidateTargetUtilization so the cmd.Flags().Changed("coverage") path executes;
apply the same fix to the related case around lines 450-454 to ensure the
precedence-log gate is covered.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 00f8f8b7-ce2e-43d4-b506-c5bacddfffd5
📒 Files selected for processing (15)
README.mdcmd/helpers.gocmd/helpers_test.gocmd/main.gocmd/multi_service.gocmd/multi_service_csv.gocmd/multi_service_csv_test.gocmd/multi_service_helpers.gocmd/validators.gocmd/validators_test.gopkg/common/types.goproviders/aws/recommendations/parser_ri.goproviders/aws/recommendations/parser_ri_test.goproviders/aws/recommendations/parser_sp.goproviders/aws/recommendations/parser_sp_test.go
| tests := []struct { | ||
| name string | ||
| rec common.Recommendation | ||
| target float64 | ||
| wantDropped bool | ||
| wantCount int | ||
| wantProjUtil float64 // 0 means "don't assert" | ||
| wantProjCovGTE float64 // we assert coverage >= this (handles the float clamping) | ||
| }{ |
There was a problem hiding this comment.
wantProjCovGTE is currently dead test data (coverage isn’t actually asserted).
The RI test describes projected coverage validation, but only projected utilization is checked. That leaves ProjectedCoverage regressions untested.
Suggested patch
@@
tests := []struct {
name string
rec common.Recommendation
target float64
wantDropped bool
wantCount int
wantProjUtil float64 // 0 means "don't assert"
wantProjCovGTE float64 // we assert coverage >= this (handles the float clamping)
}{
@@
name: "RI: target 95 hits cap with non-integer avg",
rec: mkRI(10, 8.5, 85),
target: 95,
wantCount: 8,
wantProjUtil: 100,
+ wantProjCovGTE: 94.0,
},
@@
name: "RI: target 50 hits AWS ceiling cap",
rec: mkRI(10, 10, 90),
target: 50,
wantCount: 10,
wantProjUtil: 100, // 10/10 = 100
+ wantProjCovGTE: 100.0,
},
@@
name: "RI: target 80 hits target exactly",
rec: mkRI(5, 4, 80),
target: 80,
wantCount: 5,
wantProjUtil: 80,
+ wantProjCovGTE: 100.0, // 5/4*100 clamped
},
}
@@
if tt.wantProjUtil > 0 {
if math.Abs(out[0].ProjectedUtilization-tt.wantProjUtil) > 0.01 {
t.Errorf("ProjectedUtilization: got %.4f, want %.4f",
out[0].ProjectedUtilization, tt.wantProjUtil)
}
}
+ if tt.wantProjCovGTE > 0 {
+ assert.GreaterOrEqual(t, out[0].ProjectedCoverage, tt.wantProjCovGTE)
+ }
})
}
}Also applies to: 992-997
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cmd/helpers_test.go` around lines 922 - 930, The test table includes
wantProjCovGTE but the test body never asserts projected coverage; update the
test loop in cmd/helpers_test.go to check the function result's
ProjectedCoverage (or the field name used in the returned recommendation/result)
and assert that if tests[i].wantProjCovGTE > 0 then result.ProjectedCoverage >=
tests[i].wantProjCovGTE; do this alongside the existing projected utilization
check so projected coverage regressions are caught (apply the same addition for
the other similar table near lines 992-997 that also declares wantProjCovGTE).
Summary
--target-utilization(-u) that sizes RI/SP purchases by projected post-purchase utilization instead of--coverage% of recommendations.AverageNumberOfInstancesUsedPerHour,AverageUtilization,EstimatedAverageUtilization) — no new AWS API call.ProjectedUtilization,ProjectedCoverage,RecommendedUtilization.Closes #338.
Why a separate sizing mode
--coverage 80is "buy 80% of each recommendation's instance count." It answers "how much of demand do we cover?" but ignores "how much of what we buy will actually be used?" In bursty/seasonal workloads,--coverage 80can still over-buy, leaving commitments idle.--target-utilization 95is "buy enough that projected utilization stays ≥ 95%." Counter-intuitively, higher target ⇒ fewer commitments bought (the math isused÷bought; raising the target shrinks the bought side). Use it when under-buying-and-spilling-to-on-demand beats over-buying-and-wasting-commitment.The two modes are mutually exclusive: when
--target-utilizationis set,--coverageis ignored with an info log.Math
n_target = floor(AverageInstancesUsedPerHour / (target/100)), capped atrec.Count(never exceed AWS's recommended ceiling). Drop with explanatory INFO log whenn_target == 0(target unreachable). Pass through unmodified when avg is zero (no signal); counted in a single end-of-run skip summary.RecommendedUtilization >= target, no scaling (AWS already projects above target). Elseratio = RecommendedUtilization / target(always < 1); scalesSavingsPlanDetails.HourlyCommitmentandrec.EstimatedSavings— exactly the fieldsApplyCoveragescales on the SP branch, so the two modes stay structurally consistent. Pass through whenRecommendedUtilization <= 0ORHourlyCommitment <= 0(CE placeholder rows).Changes
7d7baf6d1pkg/common: 4 new fields on Recommendationbbbc36a247d83ec948cmd/main.go: flag + Config field + validation79cf1a5adcmd/helpers.go: ApplyTargetUtilization + applySizing9468b0aafcmd/multi_service*.go: route both call sites through applySizing5f1e982e1d40001f6a2bded0b15Test plan
go build ./...cleango test ./...— full suite passes (no regressions in 30+ existing packages)cudly --helpshows--target-utilizationwith the inversion-warning copyTestApplyTargetUtilization_RIcovers happy path / AWS-ceiling cap / target unreachable drop / no signal pass-through / target met exactlyTestApplyTargetUtilization_RI_Target100covers the AC's "target == 100 → only purchases with perfectly-matched existing usage" boundaryTestApplyTargetUtilization_RI_CostScaling— cost fields untouched on RI branch (matches ApplyCoverage)TestApplyTargetUtilization_SP— no-op / scale-down / no-signalTestApplyTargetUtilization_SP_NoSignalGuards—HourlyCommitment=0+ wrong-Details-type edge casesTestApplySizing— routes based oncfg.TargetUtilization > 0TestValidateTargetUtilization— range + both-flags-set behaviourTestParseRIUtilizationSignals+TestParseSavingsPlanDetail_RecommendedUtilization— fall back to zero on nil/unparseableTestWriteMultiServiceCSVReport_UtilizationColumns— header + blank-when-zero formattingDesign notes
ProjectedCoverageAC field, wrong parser function for SPs (parseSavingsPlanDetail, notparseRecommendationDetail), wrong enum identifier names, wrong SP cost-field list, missing explanatory log for unreachable target, and two SP edge cases in this branch. All fixed before the push.Summary by CodeRabbit
New Features
--target-utilizationflag as an alternative sizing option that overrides--coveragewhen set.Documentation
--target-utilizationflag documentation and detailed guidance on flag interactions.