Skip to content

fix(csv): round-trip the EC2 AvailabilityZone and refuse zonal rows without one in the dry run - #2270

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

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

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

Root cause

csvDetailColumns carried EC2 Tenancy and Scope but not AvailabilityZone, so a zonal EC2 row exported by cudly and replayed with --input-csv came back with ComputeDetails.AvailabilityZone empty. Since the go pin bump in #2173 (go#305 / fb39c9d) the library buys a zonal RI only in the rec's AZ and refuses a zonal rec without one (buildEC2OfferingQuery), so the real run refused the row. validatePurchasePreconditions never looked at the AZ, so the dry run reported success for the same row.

Fix

  1. CSV round-trip (cmd/multi_service_csv_details.go): new AvailabilityZone detail column, appended last after Scope (the "append, never reorder" rule from fix(cli): validate CSV headers, encoding and numeric bounds #2145 / owner item 42; columns are looked up by name). The writer fills it from ComputeDetails.AvailabilityZone (pointer and value shapes); the reader puts it back. It is blank for regional EC2 rows and every other service. It is not a required column, so the required set from fix(cli): validate CSV headers, encoding and numeric bounds #2145 (Service, Region, ResourceType, Count, Term, PaymentOption) does not change and older CSVs still load.
  2. Dry-run parity (cmd/savings_plan_purchase.go): the EC2 branch moves into validateEC2Preconditions (keeps gocyclo under 10) and adds validateEC2AvailabilityZone, which mirrors the library: zonal scope (Availability Zone, plus the legacy availability-zone / availability zone spellings parseEC2Scope accepts) with a blank or whitespace AZ is refused, and Region scope with a non-blank AZ is refused. Both the main path (purchaseSingleRec) and the CSV path (runOnePurchase) already call this before the dry-run/real split, so dry run and real run now refuse the same rows before any AWS call.
  3. Docs: --input-csv row in docs/cli/README.md documents the column and the refusal. There is also a CHANGELOG entry.

User-visible change: every CSV report gets one more trailing column. A zonal EC2 row from a CSV written before this change (no AvailabilityZone column) is now refused in the dry run too, with a message saying to re-export or add the AZ. Since #2173 the real run already refused it.

Closes #2174

Verification

All of this is unit, fixture and mock-transport evidence (awsFixtureStub, anonymous credentials, isolateAWSEnv deny endpoint). No real AWS call, no --purchase / --yes, and no real-account coverage: there is no live check of a zonal offering lookup.

New tests in cmd/csv_zonal_az_test.go:

  • TestCSVRoundTrip_ZonalEC2KeepsAvailabilityZone: a zonal rec (us-east-1b) and a regional rec go through the real writeMultiServiceCSVReport and back through loadRecommendationsFromCSV. The zonal row keeps us-east-1b and the regional row stays blank.
  • TestCSVRealPurchase_ZonalEC2BuysInItsAZ (write -> read -> purchase): the exported CSV is loaded and bought with runCSVPurchases(isDryRun=false) against the stub. Every DescribeReservedInstancesOfferings request carries AvailabilityZone=us-east-1b. The purchase uses the us-east-1b offering, not the us-east-1a one listed first in the fixture. The purchase report keeps the AZ, and nothing reaches the deny endpoint.
  • TestCSVDryRun_RefusesZonalEC2RowsTheRealRunRefuses: runCSVPurchases(isDryRun=true) on five CSVs. A zonal row from a CSV without the column, a zonal row with a blank AZ, and a regional row with an AZ each get Success=false and the refusal in Error. A zonal row with its AZ and a regional row with no AZ both succeed. No offering or purchase request is made.
  • TestValidatePurchasePreconditions_EC2AvailabilityZone: covers the scope spellings, the blank and whitespace AZ cases, and both directions of the check.
  • TestDetailCells_AvailabilityZone: covers the pointer and value ComputeDetails shapes in the writer.

Fail before, pass after. I restored the parent's cmd/multi_service_csv_details.go and cmd/savings_plan_purchase.go (origin/main 83a7193) and kept the new tests:

test parent this PR
RoundTrip_ZonalEC2KeepsAvailabilityZone FAIL (expected "us-east-1b", actual "") PASS
RealPurchase_ZonalEC2BuysInItsAZ FAIL (row refused by the library, errPurchasesFailed) PASS
DryRun: zonal without column / blank AZ / regional with AZ FAIL (Success true, Error empty) PASS
DryRun: zonal with AZ / regional without AZ (controls) PASS PASS
ValidatePurchasePreconditions_EC2AvailabilityZone FAIL (An error is expected but got nil) PASS

Commands, run on macOS with go1.26 and AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null because this machine's default AWS profile is invalid:

  • go test ./cmd -count=1: ok (the full package; ./cmd is the only package in the module)
  • go test -race -short -count=1 ./cmd -run 'Zonal|AvailabilityZone|ValidatePurchasePreconditions|CSV|DetailCells': ok
  • make build, go vet ./..., go mod tidy -diff, make tidy-check: clean
  • golangci-lint run ./... (v2.10.1): 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 in a fresh git init copy with an isolated HOME: clean. scripts/test-git-secrets-allowlist.sh: 44 PASS, 0 FAIL

Out of scope (noted, not changed)

  • The dry run still does not reject an unknown EC2 Scope or Tenancy value (for example regional, which the library's parseEC2Scope refuses). The new AZ check only acts on recognized scopes, and the library still refuses the rest at purchase. Closing that gap would mean changing the existing validEC2Rec fixture (Scope: "regional") that many tests share, so I left it for a separate issue.
  • The duplicate check (existing-RI deduction) does not tell zonal RIs apart by AZ. That is unchanged here.

Maintainer: please mirror the issue's triage labels on this PR: triaged, priority/p2, severity/medium, urgency/this-quarter, impact/few, effort/m, type/bug (I have no label permission on this repo).

🤖 Generated with Claude Code

…ithout one in the dry run

The CSV report carried EC2 Tenancy and Scope but not AvailabilityZone, so a
zonal EC2 row exported and replayed with --input-csv lost its AZ. Since the
go pin bump (go#305) the library buys a zonal RI only in the rec's AZ and
refuses a zonal rec without one, so the replayed row was refused at
purchase time while validatePurchasePreconditions, which does not look at
the AZ, let the dry run report success.

Append an AvailabilityZone detail column after Scope (blank for regional
rows and other services; columns are looked up by name, so older CSVs
still load) and read it back into ComputeDetails. Mirror the library's
scope/AZ check in the EC2 preconditions so the dry run refuses a zonal row
with no AZ, or a regional row that names one, exactly as the real run does.

Closes LeanerCloud#2174

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

coderabbitai Bot commented Oct 19, 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 and others added 2 commits October 20, 2026 14:23
Main's TestPurchaseSingleRec_RefusesInconsistentEC2ScopeBeforeAnyAWSCall
(from LeanerCloud#2173) expects the refusal to mention "availability zone". This
branch's pre-check now answers first, so its messages carry the phrase too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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 427d481f1e608113d5c9461432febcd8399b40f1

Blocking:

  • none

Non-blocking:

  • docs/cli/README.md (--input-csv row): it says AvailabilityZone is "the last column of the reports cudly writes". That stopped being true when this branch merged main with fix(cli): apply --min-savings-pct and --max-break-even-months on --input-csv #2274: the header is baseHeader + csvDetailColumns + csvThresholdColumns (cmd/multi_service_csv.go:336), so the order is now ..., Scope, AvailabilityZone, SavingsPercentage, BreakEvenMonths. Suggest "after Scope" instead.
  • Same point from the contract side: AZ goes inside the detail block, so SavingsPercentage/BreakEvenMonths each shift one place to the right. Readers look columns up by name, and nothing is tagged or released yet (fix(cli): apply --min-savings-pct and --max-break-even-months on --input-csv #2274 is in [Unreleased]), so nothing breaks. Strictly, though, "append, never reorder" would put the new column after BreakEvenMonths. That's a conscious choice to make now, before the first release fixes the layout. Either way works for cudly's own reader.

Attacked:

  • Library parity: I read the pinned go providers/aws@dcb5ba0b4061 parseEC2Scope + resolveExchangeZone. The new validateEC2AvailabilityZone matches them exactly: case-insensitive region / availability-zone / availability zone, AZ trimmed, zonal with a blank AZ refused, regional with an AZ refused. It is called from validatePurchasePreconditions before the dry/real split on both the main and CSV paths, so the dry run can no longer pass a row the real run refuses. detailCells sizes from len(csvDetailColumns) and the totals row pads with that length, so there is no index drift.
  • Fail-before: in a copy of the head, I restored origin/main's cmd/multi_service_csv_details.go and cmd/savings_plan_purchase.go and kept the new tests. TestCSVRoundTrip_ZonalEC2KeepsAvailabilityZone, TestCSVRealPurchase_ZonalEC2BuysInItsAZ, the three refusing subcases of TestCSVDryRun_RefusesZonalEC2RowsTheRealRunRefuses, TestDetailCells_AvailabilityZone and TestValidatePurchasePreconditions_EC2AvailabilityZone all fail. They pass on the head.
  • Ran go build -o /dev/null ./..., then go vet ./... with and without -tags integration, and go test -race -short -count=1 ./cmd. golangci-lint v2.10.1 on ./cmd reports 0 issues with and without --build-tags integration. gocyclo -over 10 on the two changed files is silent. The race test passed on two separate runs. A first run failed while my fail-before copy of the same package was running tests at the same time. I lost that log (output truncated), and the two later solo runs both passed, so I think it was contention between the two concurrent runs, not this change. I'm mentioning it in case a CI flake shows up.
  • Closes #2174 fits: all three items (round-trip with a write->read->purchase test, dry-run refusal, docs) are covered.

Body-stale:

Reviewer: rB7. 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 427d481f1e608113d5c9461432febcd8399b40f1

Blocking:

  • none

Non-blocking:

  • cmd/multi_service_csv_details.go:83: AvailabilityZone is read untrimmed and isn't checked against the row's Region. Neither is a safety problem. The check and the library's resolveExchangeZone both trim. An AZ from another region makes the region-scoped DescribeReservedInstancesOfferings filter match nothing, so the row fails closed. Still, a dry run reports success for a row like Region=us-east-1, AvailabilityZone=eu-west-1a, which the real run then fails with "no offering". A follow-up could check the AZ against the region prefix (or the existing isValidRegion shape) so the dry run covers that case too.
  • The existing-RI deduction still doesn't tell zonal RIs apart by AZ, as the PR body says. That would be worth its own issue, since a zonal RI in us-east-1a currently counts against a zonal rec for us-east-1b.

Attacked:

  • Compared validateEC2AvailabilityZone against the pinned library (providers/aws@v0.0.0-20261020034927-dcb5ba0b4061, services/ec2/client.go: parseEC2Scope, buildEC2OfferingQuery, resolveExchangeZone). The scope spellings, lowercasing, the TrimSpace and both directions of the AZ check match. Unknown scopes fall through to the library, as the body says.
  • Confirmed both purchase paths call validatePurchasePreconditions before the isDryRun split: purchaseSingleRec at multi_service.go:650 and runOnePurchase at :1044. The header change is append-only and the TOTAL row width follows len(csvDetailColumns).
  • Checked that TestCSVRealPurchase_ZonalEC2BuysInItsAZ asserts on the stub's actual form values: the AZ filter on every DescribeReservedInstancesOfferings call, and the purchased offering ID being the 1b offering rather than the first one listed.
  • Fail-before: restored origin/main cmd/multi_service_csv_details.go and cmd/savings_plan_purchase.go and kept the tests. RoundTrip, RealPurchase, the three DryRun refusal subtests, DetailCells and ValidatePurchasePreconditions_EC2AvailabilityZone all FAIL. They 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, 141s). Also golangci-lint v2.10.1 on ./cmd/... (0 issues) and gocyclo -over 10 on both changed files (silent). The cmd package has no integration-tagged tests.

Body-stale:

  • "origin/main 83a7193" as the fail-before parent is out of date after the main merges. The result still 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.

@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 427d481f1e608113d5c9461432febcd8399b40f1

Addendum to my review above. The verdict is unchanged. I checked earlier reviews after posting, and one of them raised a point I had missed:

Blocking:

  • none

Non-blocking:

Attacked: git show 427d481f:cmd/multi_service_csv_thresholds.go (the threshold columns are documented as "appended after csvDetailColumns"). git tag --contains on the commit that added it returns nothing.

Body-stale:

  • "appended last after Scope": it is no longer last.

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.

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification at 427d481f1e608113d5c9461432febcd8399b40f1 merged onto current main: go build, go vet and go test -race -count=1 ./... pass. CI Success and pre-commit are green on this head, with two approving independent reviews (rB7, rB30). Merged by IAmJSD's agent at IAmJSD's request (acting owner while the maintainers are away). The AvailabilityZone column sits before #2274's threshold columns; this is safe because readers look columns up by header and nothing is released yet. A release note should say so. Squash-merged at exactly 427d481f1e608113d5c9461432febcd8399b40f1.

@IAmJSD
IAmJSD marked this pull request as ready for review October 26, 2026 03:19
@IAmJSD
IAmJSD merged commit 50e8450 into LeanerCloud:main Oct 26, 2026
12 checks passed
IAmJSD added a commit to IAmJSD/cloud-commitments-cli that referenced this pull request Oct 26, 2026
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>
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(csv): zonal EC2 rows lose their AvailabilityZone in the CSV round-trip and dry run misses the refusal

1 participant