Skip to content

fix(cli): validate --input-csv Service cells and bound Count - #2281

Merged
IAmJSD merged 3 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-379
Oct 26, 2026
Merged

IAmJSD merged 3 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-379

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Most of this issue is already fixed on main. #2145 made Term and PaymentOption required and checks each row against the allow-lists validatePaymentAndTerm uses. It also rejects a Count below 1. Two parts of the issue were still open, and this PR fixes them:

  1. Service cell (audit A10-007 in the issue body). parseCSVRecord passed the Service cell straight to common.ServiceType. Values like the --services alias elasticsearch, RDS, rds (trailing space), the savingsplans umbrella or savingsplans-compute all loaded without error. The purchase loop then found no client for them in createServiceClient and skipped the row with Service client not yet implemented. The run bought less than the file asked for and still ended normally.
  2. Count upper bound (from the issue's suggested fix). A per-row Count had no ceiling.

Fix

  • Service is now parsed with the existing parseCSVEnum. The allowed values are the canonical slugs from getAllServices(), which are the names writeMultiServiceCSV writes, so a report from one run still loads as input to the next. A blank or unknown value fails the whole load with the row and column, the same way a bad Term or PaymentOption does. Matching is exact, like the Term/PaymentOption checks from fix(cli): validate CSV headers, encoding and numeric bounds #2145. Flag aliases are rejected on purpose rather than normalized: the report never writes them, and rejecting them keeps this input path strict.
  • parseCSVCount now rejects Count > MaxReasonableInstances (10000). This is the same ceiling validateFlags already puts on --max-instances and --override-count.
  • docs/cli/README.md (--input-csv row) and CHANGELOG.md are updated.

Closes #379

Verification

All of this is unit and fixture evidence on macOS, using temporary CSV files. The end-to-end test runs runToolFromCSV as a dry run against the repo's isolateAWSEnv fake endpoint. No real cloud account was used.

New tests in cmd/multi_service_csv_strict_test.go:

  • TestLoadRecommendationsFromCSV_ServiceValues: blank, whitespace, elasticsearch, rds , RDS, savingsplans, savingsplans-compute and dynamodb each fail with line 3 and column 1 "Service".
  • TestLoadRecommendationsFromCSV_ServiceAccepted: every service in getAllServices() still loads.
  • TestLoadRecommendationsFromCSV_CountUpperBound: Count = 10000 loads and 10001 fails on column 4 "Count".
  • TestRunToolFromCSV_RejectsUnknownService: an elasticsearch row stops runToolFromCSV before any AWS request (awsHits == 0) and before any report is written.

Fail before, at origin/main c134fc7 with only the tests applied:

--- FAIL: TestLoadRecommendationsFromCSV_ServiceValues   (all 8 subtests: "An error is expected but got nil")
--- PASS: TestLoadRecommendationsFromCSV_ServiceAccepted
--- FAIL: TestLoadRecommendationsFromCSV_CountUpperBound
--- FAIL: TestRunToolFromCSV_RejectsUnknownService       (run proceeded and called the fake RDS endpoint)

Pass after, at the PR head: all four pass.

Commands:

go build -o <tmp>/cudly ./cmd
go vet ./... && go vet -tags integration ./...
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null go test -short -race -count=1 ./...   # ok (cmd, 120s)
golangci-lint v2.10.1 run ./...                     # 0 issues
gocyclo -over 10 -ignore "_test\.go" cmd/           # silent
bash scripts/gosec-hook.sh <changed files>          # 0 issues
markdownlint-cli 0.47.0 CHANGELOG.md docs/cli/README.md   # clean
git-secrets --scan (fresh temp repo, isolated HOME, setup-git-secrets.sh + --register-aws)   # clean
bash scripts/test-git-secrets-allowlist.sh          # 44 PASS, 0 FAIL

The tests ran with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null because the default profile on the dev machine is invalid. The new tests don't need this (isolateAWSEnv already sets them).

Not covered: no real --input-csv purchase against an AWS account.

Scope notes

  • This is a breaking change for hand-edited CSVs that use --services aliases (elasticsearch, savingsplans-*, savingsplans) or a different case in the Service column. Those rows were already never purchased. They were skipped mid-run, and now the load fails with an error naming the cell.
  • If the maintainer prefers to normalize aliases through parseServices instead of rejecting them, that is a small follow-up. The savingsplans umbrella has no single service, so it would still need to be rejected.
  • Leading and trailing spaces in Service are rejected rather than trimmed, to match the exact matching that Term and PaymentOption use.

Labels: I can't edit labels on this repo. Could a maintainer please copy the issue's triage labels to this PR: bug, triaged, priority/p3, severity/low, urgency/eventually, impact/few, effort/s, type/bug.

🤖 Generated with Claude Code

Term and PaymentOption cells are already checked against allow-lists
(LeanerCloud#2145), but the Service cell was cast straight to a ServiceType. A
--services alias such as "elasticsearch", "RDS", "rds " or the
"savingsplans" umbrella loaded fine, then the purchase loop found no
service client for it and skipped the row with "Service client not yet
implemented", so the run quietly bought less than the file asked for.

Service is now parsed with parseCSVEnum against the canonical slugs from
getAllServices() (what the CSV report writes), failing the load with the
row and column. Count is also capped at MaxReasonableInstances, the
ceiling validateFlags already applies to --max-instances and
--override-count.

Closes LeanerCloud#379

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Caution

Review failed

The pull request is closed.

Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE ac1478283ad32198c0259076bbf5facc38a9468a

Blocking:

  • none

Non-blocking:

  • docs/cli/README.md:94: the --input-csv row hard-codes the ten service slugs, while the code takes them from getAllServices(). If a service is added later, the docs will drift. Saying "the service names the CSV report writes" and listing them once in a single place would avoid that.
  • cmd/multi_service_csv.go:265: the Count ceiling applies per row. A file with many rows of 10000 each still has no total ceiling. That is outside this issue's scope and matches the issue's suggested fix, so this is only a note.
  • The issue suggested "log and skip invalid rows". The PR fails the whole load instead, which matches the precedent from fix(cli): validate CSV headers, encoding and numeric bounds #2145 and the A10-007 finding ("an error naming the bad cell"). I agree with that choice. The breaking change for hand-edited CSVs that use aliases is disclosed in the body.

Attacked:

  • Round trip: confirmed the accepted set (getAllServices()) equals the cases in createServiceClient's switch, so every accepted row has a client. I also checked that the library's recommendation paths set rec.Service to those same slugs: RI parsers use params.Service and SP uses serviceSlugForPlanType. The generic relational-db/cache/search/data-warehouse types only appear on existing Commitments, never in the report. The savingsplans umbrella fallback from serviceSlugForPlanType was already unroutable before this PR. The TOTAL row is still skipped before parseCSVRecord.
  • Fail-before: restored cmd/multi_service_csv.go from origin/main and kept the tests. TestLoadRecommendationsFromCSV_ServiceValues, _CountUpperBound and TestRunToolFromCSV_RejectsUnknownService all FAIL. With the head restored, they pass.
  • Ran go build -o $T/cudly ./cmd, go vet ./... and go vet -tags integration ./... (clean), go test -race -short -count=1 ./cmd/ (ok, 149s), golangci-lint v2.10.1 on ./cmd/... (0 issues) and gocyclo -over 10 cmd/multi_service_csv.go (silent).

Body-stale:

Reviewer: rB8. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE ac1478283ad32198c0259076bbf5facc38a9468a

Blocking:

  • none

Non-blocking:

  • cmd/multi_service_csv.go:191: the go library's serviceSlugForPlanType falls back to ServiceSavingsPlansAll ("savingsplans") for an unrecognized plan type. If such a rec ever reached a report, feeding that report back with --input-csv would now fail the whole load. Before this PR it would have skipped that one row. This is fail-closed and probably unreachable, since the CLI only queries known plan types. It's mentioned only because the PR body says "a report from one run still loads as input to the next".
  • The issue's suggested fix was "log and skip invalid rows", while this PR (and fix(cli): validate CSV headers, encoding and numeric bounds #2145 before it) fails the whole load. I think that's the better choice for a purchase path, and the body says so. Mentioning it so the change in behavior is a deliberate decision.

Attacked:

  • Checked that validCSVServices (from getAllServices()) matches the cases in createServiceClient (cmd/main.go:258-284) exactly. Every accepted value gets a client, and nothing with a client is rejected.
  • Checked that the four SP slugs the library writes into recs (savings-plans-compute|ec2instance|sagemaker|database, from parser_sp.go:serviceSlugForPlanType) are the canonical values the PR accepts, while the hyphen-less savingsplans-* flag aliases are rejected. The trailing TOTAL row is still skipped before parseCSVRecord (multi_service_csv.go:171), so cudly's own reports still round-trip.
  • parseCSVEnum matches exactly and rejects blank values, consistent with Term and PaymentOption. The Count ceiling uses the same MaxReasonableInstances that validateFlags uses.
  • Fail-before: restored origin/main cmd/multi_service_csv.go and kept the tests. TestLoadRecommendationsFromCSV_ServiceValues, ..._CountUpperBound and TestRunToolFromCSV_RejectsUnknownService FAIL, and ..._ServiceAccepted passes, as the body says. All four pass at the head.
  • Ran go build -o <tmp> ./cmd, go vet ./..., go vet -tags integration ./... and go test -race -short -count=1 ./cmd (ok, 127s). Also golangci-lint v2.10.1 on ./cmd/... (0 issues) and gocyclo -over 10 on multi_service_csv.go (silent).

Body-stale:

  • none. The fail-before parent c134fc7b is older than the current main, but the result reproduces against the current origin/main.

Reviewer: rB30. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

Conflict: docs/cli/README.md, the --input-csv row. Combined both: this
branch's Service and Count rules, then main's (LeanerCloud#2270) EC2 Engine,
Tenancy, Scope and AvailabilityZone rules. Dropped "the last column"
from LeanerCloud#2270's sentence: since LeanerCloud#2274 the AvailabilityZone column is no
longer last (raised in review of LeanerCloud#2270).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Merged origin/main (now ae3c1e3d) after #2270 landed. There was one conflict, in the --input-csv row of docs/cli/README.md; I combined both rule sets, this branch's Service and Count rules plus #2270's EC2 zone rules, and dropped #2270's stale "last column" wording. No code conflict. Build, vet and go test -race ./... pass; secrets scan clean. Needs a fresh review at this head (docs-only change since the approvals).

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE ae3c1e3dee3d3d1e6fa487002753cf4618e07193

Blocking:

  • none

Non-blocking:

  • cmd/multi_service_csv.go:266: the new ceiling is per row only. A file with many rows of Count=10000 is still unbounded in total, and only --max-instances limits it. That fits the issue's suggested fix (a per-recommendation cap), so I'm noting it, not asking for a change.
  • cmd/multi_service_csv.go:94: the round trip holds because the CLI gets RI recommendations from the Cost Explorer client, which stamps params.Service (a canonical slug), and SP recommendations through serviceSlugForPlanType. One edge: for an SP plan type it doesn't know, that function falls back to savingsplans (ServiceSavingsPlansAll). A report holding such a row would now fail to reload instead of being skipped mid-run. The change is intended and the error is clearer, but the "a report from one run still loads" claim has that one exception.
  • Behaviour change for hand-edited CSVs using --services aliases or other casing. The PR body flags this and the CHANGELOG covers it. Maintainers may later prefer normalizing through parseServices. Rejecting is the stricter and safer default for a purchase input.

Attacked:

  • I checked the routing and the allow-list. validCSVServices = getAllServices() = exactly the cases in createServiceClient (cmd/main.go:242-284), so every accepted value gets a client and no routable value is rejected. The TOTAL row is still skipped before validation (multi_service_csv.go:168). loadCSVRecommendations (via prepareCSVPurchaseRun) is the only production caller. The error fires before the audit-log, filter, confirm and AWS steps, so it fails closed. I found no repo CSV fixtures or docs that use aliases.
  • Issue fix(cli): --input-csv Term and PaymentOption fields bypass validateFlags — bad values cause confusing API errors #379 acceptance criteria: Term/PaymentOption are already on main (fix(cli): validate CSV headers, encoding and numeric bounds #2145), and Count ≥1 was already there. This PR adds the ≤ MaxReasonableInstances bound and covers A10-007 (Service). So Closes is right.
  • Fail-before: I restored cmd/multi_service_csv.go from origin/main and kept the tests. TestLoadRecommendationsFromCSV_ServiceValues, _CountUpperBound and TestRunToolFromCSV_RejectsUnknownService FAIL. _ServiceAccepted passes either way, which is expected because it's a regression guard. After restoring the PR's version, all pass.
  • Commands: go build -o /dev/null ./..., go vet ./..., go vet -tags integration ./... (cmd has no integration tests and there is no tests/e2e), go test -race -short -count=1 ./cmd/ ok (135s), golangci-lint v2.10.1 with and without --build-tags integration gives 0 issues, gocyclo -over 10 cmd/multi_service_csv.go is silent.

Body-stale:

  • none (the round-trip claim has the minor exception noted above)

Reviewer: rB32. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE ae3c1e3dee3d3d1e6fa487002753cf4618e07193

Blocking:

  • none

Non-blocking:

  • cmd/multi_service_csv.go:265-268: the normal (non-CSV) run puts no per-recommendation ceiling on rec.Count; it is AWS's RecommendedInstanceQuantity after sizing. So in theory a run can write a report row with Count > 10000 that this loader then refuses. In practice that is very unlikely, and failing closed is the right direction for a purchase input. It only matters if someone relies on report-to-input round trips for very large fleets.
  • docs/cli/README.md:94: the edit also drops "the last column of the reports cudly writes" for AvailabilityZone. That is a correct fix, since csvThresholdColumns (SavingsPct, BreakEven) are now appended after csvDetailColumns. The PR body doesn't mention it, so this is just a heads-up.
  • The elasticsearch / savingsplans-* / case-variant rejection is a deliberate behaviour choice. Those rows were already never purchased, so nothing that used to buy now stops buying. Normalizing through parseServices is the stated alternative. I agree with rejecting.

Attacked: Head is still ae3c1e3. Reviewed the three-dot diff against origin/main. Allow-list vs routing: getAllServices() (cmd/main.go:242) is exactly the set createServiceClient builds a client for, so no row that could previously be purchased is now rejected. Round trip: the writer emits string(rec.Service). RI recs carry params.Service (a canonical slug), and SP recs carry serviceSlugForPlanType, which only falls back to the savingsplans umbrella for an unknown plan type, and that was already unroutable. SP rows are written with Count=1, so the 10000 cap cannot reject them. loadCSVRecommendations has a single caller (runToolFromCSV), and the whole load fails before any client or AWS call. Fail-before: checked out cmd/multi_service_csv.go from origin/main with the new tests kept. _ServiceValues, _CountUpperBound and TestRunToolFromCSV_RejectsUnknownService FAIL, and _ServiceAccepted passes as the body says. After restoring, all pass. Ran: go build -o /dev/null ./..., go vet ./..., go vet -tags integration ./..., go test -race -short -count=1 ./... (cmd ok, 137s), golangci-lint 2.10.1 on ./cmd with and without --build-tags integration (0 issues), gocyclo -over 10 on multi_service_csv.go (silent), secrets-scan (clean; planted key flagged), scripts/test-git-secrets-allowlist.sh (44 PASS). There are no integration-tagged tests in this repo.

Body-stale: none

Reviewer: rB31. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification at ae3c1e3dee3d3d1e6fa487002753cf4618e07193 merged onto main: build, vet and go test -race ./... pass. CI Success and pre-commit are green on this head, with two approving independent reviews (rB31, rB32). Merged by IAmJSD's agent at IAmJSD's request (acting owner while the maintainers are away). Squash-merged at exactly ae3c1e3dee3d3d1e6fa487002753cf4618e07193.

@IAmJSD
IAmJSD marked this pull request as ready for review October 26, 2026 03:36
@IAmJSD
IAmJSD merged commit 0429536 into LeanerCloud:main Oct 26, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(cli): --input-csv Term and PaymentOption fields bypass validateFlags — bad values cause confusing API errors

1 participant