Skip to content

fix(onboarding): flag aws-cn accounts and refuse their Savings Plans purchases - #1248

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

IAmJSD merged 8 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-1194

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

The issue offers "warn or block". This PR does the conservative version it suggests as the safe default:

  • Onboarding: warn, don't block. aws-cn accounts are still accepted, so no existing account is stranded. Account responses now include a visible unsupported_partition notice.
  • Purchases: block Savings Plans only. A Savings Plans purchase for an aws-cn account is refused with an error wrapping savingsplans.ErrUnsupportedPartition, before any provider is built or AWS is called.

Root cause

  • go.mod pinned cloud-commitments-go/providers/aws at aeb8a6f (2026-10-19). That predates cloud-commitments-go#566, so the library had no aws-cn refusal.
  • The platform derived nothing from an account's partition. awsRoleARNRegex accepts arn:aws-cn: role ARNs, and the account API never said Savings Plans are unsupported there.
  • The library guard (after chore(deps): remove the compression override once serve depends on compression >= 1.8.2 #566) only fires when a region it sees is cn-*. A Savings Plans rec can carry no region, so the library alone cannot reliably catch these accounts. The account's role ARN can.

Fix (3 commits, each under ~400 lines)

  1. chore(deps): bumps providers/aws to 92722e6 (the chore(deps): remove the compression override once serve depends on compression >= 1.8.2 #566 merge commit). MVS moves pkg from 541e05e to ff6225d. This is the minimal bump that includes chore(deps): remove the compression override once serve depends on compression >= 1.8.2 #566. It deliberately stops before cloud-commitments-go#587/docs(api): reconcile RI-exchange approve and reject specs with observed behavior #603/fix(plans): a plan with no matching recommendations schedules zero-count purchases instead of failing #609, which rename or remove API.
  2. fix(accounts):
    • Adds config.AWSPartitionFromARN (the existing parseArnPartition now delegates to it), CloudAccount.AWSPartition() (from the role ARN, AWS accounts only) and config.AWSPartitionNotices.
    • CloudAccount gets a derived, non-persisted notices field (omitempty, no DB or migration change).
    • It is filled on create, update, get, list and create-self. For the self-account, which has no role ARN, the list and create-self paths use CUDly's own partition from the STS identity those paths already resolve. No new STS calls were added: get, create and update use only the role ARN.
  3. fix(purchase):
    • rejectUnsupportedPartition runs per rec inside the processPurchaseRecommendations fan-out, before executeSinglePurchase builds the provider.
    • It applies to every Savings Plans slug (common.IsSavingsPlan(mapServiceType(...))) on an aws-cn account. Other recs (e.g. RIs) are unaffected, so a mixed run still ends partially_completed per fix(settings): a lingering inline range error stays visible after Reset settings or a reload because programmatic value sets do not re-run the validator #642.
    • The partition is passed in from executeForAccount (plan fan-out) and from resolveSingleAccountProvider (direct-execute and single-account). That function now returns a small purchaseTarget{externalID, awsPartition} in place of the bare ExternalID.
    • User-facing text, stored on the rec's error and the execution's error: savings plans purchase refused for this AWS China (aws-cn) account: savings plans are not supported in the aws-cn partition: the purchase path is USD-only.

Closes #1194

Verification

All evidence below is unit, mock or fixture evidence. Nothing calls AWS: tests ran with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null because this machine's default AWS profile is invalid.

Fail-before / pass-after. I temporarily disabled the guard and made annotateAccountNotices a no-op, then restored both:

go test ./internal/purchase/ -run 'TestRejectUnsupportedPartition|AWSChina' -count=1
go test ./internal/api/ -run 'PartitionNotice|AWSChinaRoleARN|CommercialRoleARN' -count=1
  • Before:
    • FAIL: TestRejectUnsupportedPartition, TestExecuteForAccount_AWSChinaSavingsPlanRefusedBeforeAnyAWSCall, TestExecutePurchase_SingleAccount_AWSChinaSavingsPlanRefused
    • FAIL: TestCreateAccount_AWSChinaRoleARN_ReturnsUnsupportedPartitionNotice, TestUpdateAccount_..., TestGetAccount_..., TestListAccounts_PartitionNotices
  • After: all PASS. The controls also pass: TestCreateAccount_CommercialRoleARN_HasNoNotice, TestParseArnPartition, and the commercial, GovCloud and unknown-partition cases.

What the tests check:

  • The fan-out test asserts CreateAndValidateProvider is never called for the aws-cn Savings Plans rec. The provider factory mock records calls and fails them.
  • The direct-execute test runs an aws-cn Savings Plans rec next to an EC2 rec. Only the EC2 rec reaches the provider (exactly 1 factory call, and GetServiceClient is never called for the Savings Plans type).
  • TestPinnedSavingsPlansClientRefusesAWSCN builds a real pinned savingsplans.Client for cn-north-1. It uses an HTTP client that counts requests and fails them all, so there is no network access. The test asserts ErrUnsupportedPartition and zero requests.
    • At the old pin the package does not compile: undefined: savingsplans.ErrUnsupportedPartition.

CI-equivalent checks:

  • go build ./... and go vet ./internal/api/ ./internal/purchase/ ./internal/config/
  • go test <pkg> -count=1 for every package in the module, one at a time: all ok, including internal/mocks.
  • golangci-lint v2.10.1 with --allow-parallel-runners on ./internal/api/... ./internal/purchase/... ./internal/config/... (the integration build tag comes from .golangci.yml): 0 issues.
  • gocyclo -over 10 -ignore "_test\.go" on the changed files: silent.
  • scripts/gosec-hook.sh on the changed packages: rc=0.
  • go mod tidy: no further diff.
  • git diff --check: clean.
  • git-secrets: ran on the changed files in a fresh temp repo with an isolated HOME. The clean scan passed, and a planted AKIA key was flagged. scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.

Real-cloud gap. This has not been run against a real aws-cn account or a CUDly deployment hosted in aws-cn.

Out of scope / notes

  • Accounts with no role ARN. For access_keys accounts, or a self-account on ambient credentials whose host partition is unknown, the platform cannot tell the partition without calling AWS. For those accounts:
    • no onboarding notice is shown;
    • purchases rely on the library's region guard (cn-* client, rec or plan-details region).
  • HTTP status. Synchronous execute and approve calls that fail this way still return the existing 502 "execution … failed: …". The message now names the aws-cn refusal, but the status is not mapped to a 4xx. Per-rec errors are aggregated as strings (purchaseErrors []string), so errors.Is can't see the sentinel at executeFailureError. Mapping it would mean carrying error values through the aggregation, which is a broader change.
  • Recommendations. The notice mentions that recommendations may be unavailable (Cost Explorer through us-east-1, per the issue). This PR does not change how recommendations are collected.
  • Other onboarding paths. Federation self-registration and org discovery don't return the notice in their own responses. The accounts then show it in the account list and detail views.
  • Library commits pulled in. The bump also brings every providers/aws and pkg commit between the old and new pins, e.g. cloud-commitments-go#529 (skip non-USD recs at ingestion), fix(db): record the cancel actor on UI-initiated cancels #533, fix(ladder): stop netting elapsed scheduled tranches from the gap #535 (RI account scope, default unchanged) and fix(api): invalid or expired approval tokens and a missing revoke execution answer 500 instead of 4xx #564. The full platform unit suite passes on them.
  • Known unrelated CI failure: govulncheck GO-2026-6809 (fixed by fix(deps): bump Go to 1.26.10 for GO-2026-6809 #1236).

Frontend follow-up for the UI owner

  • Notice. GET/POST/PUT /api/accounts[...] and POST /api/accounts/self now return notices: [{code, message}] when relevant (omitted otherwise). Suggested UI:
    • show each notice as a warning banner on the account detail and edit views, and as a badge or tooltip in the accounts list;
    • show the warning right after creating an aws-cn account;
    • code unsupported_partition means Savings Plans purchases are refused for that account.
  • Hide Savings Plans actions. Optionally, hide or disable Savings Plans purchase actions for accounts carrying that notice.

Labels

The issue currently has no triage labels (gh issue view 1194 --json labels returns []), so there is nothing to mirror. Could a maintainer triage #1194 and mirror its labels onto this PR?

🤖 Generated with Claude Code

IAmJSD and others added 3 commits October 22, 2026 21:45
…he aws-cn Savings Plans refusal

Pins providers/aws to the cloud-commitments-go#566 merge commit, which makes
Savings Plans offering lookups, validation and purchases fail with
savingsplans.ErrUnsupportedPartition before any AWS call when a region they
see is cn-* (aws-cn bills in CNY and the purchase path is USD-only). MVS
moves pkg to ff6225d, the version that commit requires.

Refs LeanerCloud#1194

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

aws-cn accounts were onboarded with no hint that CUDly cannot buy Savings
Plans for them. Account responses (create, update, get, list and the
self-account) now carry a derived, non-persisted "notices" list; an AWS
account whose role ARN is in aws-cn (or a self-account when CUDly itself runs
in aws-cn) gets an "unsupported_partition" notice. Onboarding is still
allowed so existing aws-cn accounts are not stranded.

The ARN partition parsing moves to config.AWSPartitionFromARN so the account
model and the STS host-identity path share it.

Refs LeanerCloud#1194

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Savings Plans rec for an account whose role ARN is in aws-cn now fails
with an error wrapping savingsplans.ErrUnsupportedPartition before a
provider is built, on both the plan fan-out and the single-account
(direct-execute) paths. Other recs in the same execution still run, so a
mixed run ends partially_completed as before. The library's region-based
guard stays as the backstop for accounts with no role ARN.

Closes LeanerCloud#1194

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

coderabbitai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 14 billable files and costs up to $3.50.

Or wait 59 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between eb165b4 and f28e89c.

Files ignored due to path filters: 1
  • go.sum is excluded by !**/*.sum
Files selected for processing: 14
  • go.mod
  • internal/api/handler.go
  • internal/api/handler_accounts.go
  • internal/api/handler_accounts_partition_notice_test.go
  • internal/config/aws_partition.go
  • internal/config/aws_partition_test.go
  • internal/config/types.go
  • internal/purchase/azure_savings_plans_alias_proof_test.go
  • internal/purchase/execution.go
  • internal/purchase/execution_test.go
  • internal/purchase/money_path_regression_test.go
  • internal/purchase/partition_guard.go
  • internal/purchase/partition_guard_test.go
  • internal/purchase/plan_step_proofs_test.go
  • 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 3 commits October 25, 2026 15:59
git-secrets' AWS account-ID detector flagged the inline sourceIdentity
literal in the partition notice test, which failed the pre-commit hook
and the allowlist script's scan of all tracked files. Split the literal
one field per line so the placeholder sits on an `AccountID: "..."`
line that .gitallowed already permits in test files. Same value, same
assertions.

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

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI fix pushed. New head: 8421f033.

Root cause. git-secrets' AWS account-ID detector (--register-aws) matched AccountID: "111111111111" inside the one-line sourceIdentity{...} literal in internal/api/handler_accounts_partition_notice_test.go. The .gitallowed placeholder entry for test files only covers that key/value when it is alone on its line, so the pre-commit hook failed and so did the "file-argument scan of all tracked files" case in scripts/test-git-secrets-allowlist.sh (136/137).

Fix. Commit ae981951 splits the literal so each field is on its own line. AccountID: "111111111111", now matches the existing allowlist entry. The value and the assertions are unchanged, and .gitallowed is untouched. I also merged origin/main into the branch twice, as plain merges with no conflicts.

Checks run locally on the new head:

  • go build ./..., go vet ./..., go vet -tags integration ./... and the tests/e2e vet all pass.
  • golangci-lint v2.10.1 reports 0 issues on internal/api, internal/config and internal/purchase, both with and without --build-tags integration.
  • gocyclo -over 10 on the changed non-test files reports nothing.
  • go test -race -short passes on all three packages.
  • go test -race -tags integration passes on internal/config and internal/purchase. On internal/api, one test (TestIntegration_HistorySummary_TotalsCoverAllInScopeRows) failed on the first run because the overloaded host could not start its Postgres container within 30s. It passed on a rerun.
  • The secrets scan of the changed files prints "clean; planted key flagged".
  • bash scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.

IAmJSD and others added 2 commits October 25, 2026 21:25
Refreshes the branch on current main. The red CI on the previous head
came from the main breakage that LeanerCloud#1418 fixed. No files conflicted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alias proof

Main's LeanerCloud#1305 added a test that calls processPurchaseRecommendations with
the old five-argument signature. This branch adds the awsPartition
argument, so after the merge the purchase test package no longer
compiled. The account in that test is Azure, so it passes "" like the
branch's other non-AWS call sites.

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

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Merged current main into the branch, which picks up #1418's fix for the main breakage behind the earlier red CI. No files conflicted. New head: f28e89c.

The merge did break the build in one place, fixed in a follow-up commit. Main's #1305 added internal/purchase/azure_savings_plans_alias_proof_test.go, which calls processPurchaseRecommendations with the old five-argument signature. This branch adds the awsPartition argument, so the purchase test package no longer compiled. The test's account is Azure, so the call now passes "", like the branch's other non-AWS call sites.

Checks:

  • go build and go vet (default, integration, e2e) pass. golangci-lint reports 0 issues with and without the integration tag, and gocyclo is silent.
  • go test -race -short and go test -race -tags integration pass on api, config and purchase.
  • go mod verify passes, and go mod tidy -diff is clean.
  • The git-secrets scan is clean, and the allowlist test gives 137 PASS.

@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 f28e89c1fcc669a176fdeefabfbed27d2ef1a2ae

Blocking:

  • none

Non-blocking:

  • go.mod:87: through MVS, the new providers/aws@92722e6 pins pkg at ff6225d5ff04. That commit is on the unmerged branch of cloud-commitments-go#564 and is not an ancestor of go's main: GitHub compare main...ff6225d shows it 3 commits ahead. Its pkg/ tree differs from what fix(api): invalid or expired approval tokens and a missing revoke execution answer 500 instead of 4xx #564 actually merged (0dc660d/92722e6). The behavioral difference is common.RDSSizeFlexibleEngine: the branch treats Oracle BYOL as size-flexible, and the merged fix(api): invalid or expired approval tokens and a missing revoke execution answer 500 instead of 4xx #564 deliberately reverted that because it "would let a duplicate through". I checked that this can't reach the platform. go list -deps ./cmd/server ./cmd/lambda doesn't contain pkg/recfilter, and RDSSizeFlexibleEngine isn't referenced by providers/aws@92722e6 or by platform code. Every other pkg change since the old pin 541e05e is from go main. Builds stay reproducible through proxy.golang.org/sum.golang.org. Still, I'd add an explicit require github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261020071945-92722e673741 so the platform pins a reviewed main commit and not a stale PR-branch snapshot. The real fix is in go: providers/aws's own go.mod pins the branch commit.
  • internal/purchase/partition_guard.go:22: aws-cn accounts without a role ARN (access_keys, or a self-account on ambient credentials) rely only on the library's cn-* region guard, as the body says. That's acceptable, but it means "refused without calling AWS" holds only for role-ARN accounts.
  • A refused purchase still surfaces as a 502 on the synchronous execute/approve paths (body "Out of scope" says the same). Issue item 3 asks for "a clear user-facing error". The message is clear, but the status isn't a 4xx. That could be a follow-up.

Attacked:

  • Money path: executeSinglePurchase is the only caller of PurchaseCommitment, and every route reaches it through processPurchaseRecommendations, where the guard runs per rec before provider construction. Both the single-account path (resolveSingleAccountProvider -> purchaseTarget.awsPartition) and the plan fan-out path (account.AWSPartition()) pass the partition. A refused rec goes through the normal v.err aggregation: it records Error, Purchased stays false and no history is written, so nothing is miscounted as bought and RIs in the same run are unaffected. AWSPartitionFromARN matches the old parseArnPartition on edge inputs (arn:, arn::x, arn:aws).
  • Fail-before: forcing rejectUnsupportedPartition to always return nil makes TestRejectUnsupportedPartition, TestExecuteForAccount_AWSChinaSavingsPlanRefusedBeforeAnyAWSCall and TestExecutePurchase_SingleAccount_AWSChinaSavingsPlanRefused FAIL. Restored afterwards.
  • Library bump: I walked aeb8a6f..92722e6 (42 commits, all on go main) and 541e05e..ff6225d for pkg (see above). No downgrade: both old pins are ancestors of the new ones.
  • Ran: go build ./...; go vet ./..., -tags integration ./... and e2e vet; the full go test -race -short -count=1 ./... (all ok, needed because of the bump); go test -race -tags integration -count=1 on internal/purchase, internal/config and internal/api (ok: 74s, 165s, 148s); golangci-lint v2.10.1 with and without --build-tags integration (0 issues); gocyclo -over 10 (clean); go mod tidy (no diff). CI is green on this head.

Body-stale:

Reviewer: rB21. 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 f28e89c1fcc669a176fdeefabfbed27d2ef1a2ae

Blocking:

  • none

Non-blocking:

Attacked:

  • Checked that processPurchaseRecommendations is the only caller of executeSinglePurchase, so the guard covers the plan fan-out (executeForAccount), direct-execute and single-account paths. The refusal goes through the normal per-rec error aggregation, so a mixed SP+RI run ends partially_completed and nothing is double-bought or marked purchased. The idempotency token derivation is untouched. AWSPartitionFromARN matches the old parseArnPartition on edge cases (arn::, no colon, unknown partition). Notices is response-only (omitempty, overwritten on every response, never written to the DB).
  • Fail-before: I disabled the rejectUnsupportedPartition call in execution.go and the acct.Notices = assignment in handler_accounts.go. Then TestExecuteForAccount_AWSChinaSavingsPlanRefusedBeforeAnyAWSCall, TestExecutePurchase_SingleAccount_AWSChinaSavingsPlanRefused, and the Create/Update/Get/List notice tests failed. Restoring both made them pass.
  • Ran go build ./..., go vet ./..., go vet -tags integration ./..., go test -short -count=1 ./... (whole module, because of the dependency bump), go test -race -short and go test -race -tags integration -count=1 on internal/{api,config,purchase}, golangci-lint (0 issues) and gocyclo -over 10 on the changed files (silent). All passed.

Body-stale:

  • none found

Reviewer: rB24. 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 f28e89c1fcc669a176fdeefabfbed27d2ef1a2ae merged onto main (a9b7eb8f), together with #1247, #1248, #1295, #1303, #1310 in that order (combined tree a016458b, Linux, Go 1.26.10): go build ./..., go vet with and without -tags integration, the full go test -race -short -count=1 ./..., and go test -race -tags integration -count=1 on every changed package, against Docker Postgres. All pass. CI Success and pre-commit are green on this head, with 2 approving independent review(s) on it. Nothing was run against any cloud.

@IAmJSD
IAmJSD marked this pull request as ready for review October 26, 2026 00:53
@IAmJSD
IAmJSD merged commit c740425 into LeanerCloud:main Oct 26, 2026
38 checks passed
@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on f28e89c1 (two required: money, auth or security path), green CI, and the combined-tree verification above. Squash-merged at exactly f28e89c1fcc669a176fdeefabfbed27d2ef1a2ae.

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(onboarding): warn or block aws-cn accounts that cannot buy Savings Plans

1 participant