Skip to content

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

Draft
IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-2174
Draft

IAmJSD wants to merge 1 commit 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

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

This branch has not been deployed

No deployments
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