Skip to content

fix(iac/azure): remove nonexistent Microsoft.Capacity action from reservation-purchaser role - #1800

Merged
cristim merged 1 commit into
mainfrom
fix/1794-invalid-capacity-purchase-action
Aug 11, 2026
Merged

cristim merged 1 commit into
mainfrom
fix/1794-invalid-capacity-purchase-action

Conversation

@cristim

@cristim cristim commented Aug 11, 2026 •

Copy link
Copy Markdown
Member

Summary

Microsoft.Capacity/reservationOrders/purchase/action does not exist in Azure's operation catalog. Validated all 12 actions the custom reservation-purchaser role granted, against the live catalog (az provider operation show, both namespace-level operations[] and resourceTypes[].operations[], for Microsoft.Capacity and Microsoft.BillingBenefits): 11 valid, this one absent.

Azure rejects the entire role definition with InvalidActionOrNotAction when an unknown action is present, so the role was never created. This is the root cause of two distinct failures:

  • Azure deploy on main has failed on every run since 2026-07-19. The runtime module's data.azurerm_role_definition lookup finds nothing, because the bootstrap stack's azurerm_role_definition was rejected outright.
  • The customer-facing ARM template fails identically. arm/CUDly-CrossSubscription/template.json carries the same invalid action; anyone deploying it to onboard a subscription hits the same InvalidActionOrNotAction rejection.

Root cause vs. the role's actual purpose

The role's purpose is legitimate: the built-in "Reservation Purchaser" role genuinely lacks Microsoft.Capacity/reservationOrders/write, Microsoft.Capacity/calculatePrice/action, and every Microsoft.BillingBenefits action, which is what causes 403s on production purchases. Only the one action name was wrong. Purchasing a reservation is PUT /providers/Microsoft.Capacity/reservationOrders/{id}, i.e. Microsoft.Capacity/reservationOrders/write, already present in the list. Fixed:

  • The description on the role definition and every comment citing the fictional action, in terraform/modules/iam/azure/cudly-reservation-role/main.tf, arm/CUDly-CrossSubscription/template.json, iac/federation/azure-target/terraform/main.tf, terraform/modules/compute/azure/container-apps/main.tf.
  • Removed the invalid action from the TF module's actions list and from the ARM template's actions list and its role-assignment description.

Provider registration was tested and ruled out, not merely considered. Both Microsoft.Capacity and Microsoft.BillingBenefits were NotRegistered on the subscription, which looked like a plausible cause since Azure validates role actions against provider catalogs. Both were registered, confirmed Registered, and the identical apply was re-run: it failed with the identical InvalidActionOrNotAction error on the same action. Removing the action is what fixed it.

The two registrations are additive and reversible and have been left in place, but they were an incidental change to the subscription rather than part of the fix, and are recorded here so they are not mistaken for one.

Verified in production, not just locally

Applied this fix against a live subscription and re-ran the previously-failing deploy:

  • data.azurerm_role_definition.cudly_reservation_purchaser: Read complete after 0s
  • azurerm_role_assignment.reservations_purchaser: Creation complete after 26s

The three-week-old InvalidActionOrNotAction / role-not-found failure is gone.

The deploy is still red after this fix, for an unrelated, pre-existing reason — noted here so a red run isn't mistaken for this PR failing. Two other role assignments (cost_management_reader, subscription_reader) now fail with 409 RoleAssignmentExists: they already exist in Azure but are absent from Terraform state, because neither sets an explicit name, so azurerm mints a new UUID per apply while Azure dedupes on (scope, role, principal) — a missing state entry is a permanent 409 with no state to reconcile against. This is state drift, unrelated to the invalid-action bug, and is being tracked and fixed separately. Not addressed in this PR.

Customer-facing blast radius

arm/CUDly-CrossSubscription/template.json is the template customers deploy directly (az deployment sub create) to grant CUDly cross-subscription access. It carried the identical invalid action, so it was almost certainly failing for customers too, not just in our own CI.

Test fixtures: parity is not validity

scripts/testdata/role-parity/ feeds scripts/check-azure-role-parity.sh, which asserts the ARM template and TF module agree on actions/notActions/dataActions/notDataActions (compared as a set: both sides are extracted then piped through sort -u, then diff'd) and that no grant escapes subscription scope. 34 of the 35 fixture files carried the same invalid action as a copy-pasted baseline (matching-tf.tf.fixture plus every *-arm.json except drifted-arm.json, which is the fixture that intentionally omits it to test the actions-mismatch axis).

The parity checker never caught this because both sides carried the same invalid action. The fixtures proved ARM and TF agreed while both were invalid — nothing in the suite could have caught a shared mistake, because parity is not validity: the checker only proves the two sides agree, never that either names a real Azure action. Fixed the fixtures by substituting a real, previously-unused action (Microsoft.BillingBenefits/register/action, already present in production) for the invalid one, uniformly across the TF baseline and every ARM fixture that carried it. A bare deletion was considered and rejected: since the checker's actions comparison is set-based (order doesn't matter, confirmed by reading extract_tf_list/extract_arm_list, both sort -u, and compare_action_lists's diff), deleting without replacing would desync the action count between the single shared TF baseline and 33 of the 35 ARM fixtures, which would make the actions check fail first on scope/casing/collision fixtures whose whole purpose is exercising a different axis — passing those tests for the wrong reason instead of the one they're built to catch, while breaking the 5 fixtures asserting a clean exit 0. Verified: bash scripts/test-azure-role-parity.sh → 36 passed, 0 failed. bash scripts/check-azure-role-parity.sh (against the real production files) → both checks pass.

Not attempted here, tracked as a follow-up: validating actions against Azure's live operation catalog in CI. That needs network access from the CI runner and is a separate design decision from this fix.

CI diagnostic correction

.github/workflows/deploy-azure.yml's "Explain a missing bootstrap role" step assumed the bootstrap stack had simply never been applied. It had been applied and rejected, not skipped. Corrected the message: the first thing to check on any recurrence is now InvalidActionOrNotAction in the bootstrap stack's own apply log, pointing at this fix.

Verification

  • git grep -n -F "reservationOrders/purchase/action" → zero hits.
  • python3 -m json.tool arm/CUDly-CrossSubscription/template.json → parses clean; same for all touched fixture JSON files.
  • terraform fmt -check -recursive → clean for all three touched Terraform directories.
  • terraform validate → passes for all three touched Terraform directories (terraform/modules/iam/azure/cudly-reservation-role, iac/federation/azure-target/terraform, terraform/modules/compute/azure/container-apps).
  • bash scripts/test-azure-role-parity.sh → 36 passed, 0 failed.
  • bash scripts/check-azure-role-parity.sh (production files, no overrides) → actions/notActions/dataActions/notDataActions match; all scopes subscription-anchored.
  • Pre-commit hooks → all passed (Terraform format/validate/lint, JSON syntax, secret scans, Trivy).
  • A real terraform apply against a live subscription confirmed the role definition now creates successfully (see "Verified in production" above).

Closes #1794

Summary by CodeRabbit

  • Bug Fixes

    • Azure reservation purchases now use the correct permissions, improving reliability for reservation creation and pricing workflows.
    • Resolved bootstrap role deployment failures caused by an invalid Azure action.
    • Improved diagnostics for identifying missing permissions and role-definition issues.
  • Documentation

    • Updated reservation role guidance to reflect the required purchase, pricing, registration, and reservation permissions.
    • Expanded troubleshooting steps for failed bootstrap deployments and production reservation purchases.

…ervation-purchaser role

Microsoft.Capacity/reservationOrders/purchase/action does not exist in
Azure's operation catalog. Validated all 12 actions in the custom
reservation-purchaser role against the live catalog (az provider
operation show, both namespace-level and resourceType-level
operations): 11 valid, this one absent.

Azure rejects the entire role definition with InvalidActionOrNotAction
when it appears in the actions list, so the role was never created,
which is why the Azure deploy has failed on every main run since
2026-07-19 (the bootstrap stack's data.azurerm_role_definition lookup
finds nothing). It is also why the customer-facing ARM template in
arm/CUDly-CrossSubscription fails for anyone deploying it to onboard a
subscription.

The role's actual purpose is legitimate: the built-in Reservation
Purchaser role genuinely lacks Microsoft.Capacity/reservationOrders/write,
Microsoft.Capacity/calculatePrice/action, and every
Microsoft.BillingBenefits action, which is what causes 403s on
production purchases. Only the one action name was wrong; the role's
description and every comment citing it are corrected to name the real
gap instead. Purchasing a reservation is
Microsoft.Capacity/reservationOrders/write, already present in the
list.

Removed the action from every occurrence: the Terraform module, the
customer-facing ARM template (both the actions list and two
descriptions), the two comments in consuming modules, and all 34
scripts/testdata/role-parity/*-arm.json fixtures plus the TF baseline
fixture that also carried it. The fixtures are replaced with a real,
previously-absent action (Microsoft.BillingBenefits/register/action)
rather than deleted outright, since the role-parity checker compares
TF and ARM actions as a set (sort -u then diff) and most fixtures use
a single shared TF baseline: a bare deletion would desync the actions
count across 33 of the 35 fixtures and mask the scope/casing/collision
axis each one actually exists to test. Full fixture suite verified
green after the change (36/36).

The parity checker itself never caught this because both the ARM and
TF sides carried the same invalid action: parity is not validity, and
neither side of the checker validates actions against Azure's actual
catalog. That is a separate, larger effort (needs network access in
CI) and is not attempted here.

Also corrects the "Explain a missing bootstrap role" CI diagnostic in
deploy-azure.yml, which assumed the bootstrap stack had simply never
been applied. It had been applied and failed, so the role was rejected
rather than skipped; the message now points at InvalidActionOrNotAction
in the bootstrap stack's own apply log as the first thing to check.

Verified by a real terraform apply against a live subscription: the
role definition now creates successfully.
@cristim cristim added triaged Item has been triaged priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens urgency/now Drop other things impact/all-users Affects every user effort/m Days type/bug Defect labels Aug 11, 2026
@coderabbitai

coderabbitai Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ecc53144-2e8c-470b-983d-8b2f3a6cbe41

📥 Commits

Reviewing files that changed from the base of the PR and between ddeeb98 and f12423a.

📒 Files selected for processing (40)
  • .github/workflows/deploy-azure.yml
  • arm/CUDly-CrossSubscription/template.json
  • iac/federation/azure-target/terraform/main.tf
  • scripts/testdata/role-parity/canonical-scope-variants-arm.json
  • scripts/testdata/role-parity/collision-assignablescopes-benign-first-arm.json
  • scripts/testdata/role-parity/collision-assignablescopes-evil-first-arm.json
  • scripts/testdata/role-parity/collision-properties-evil-first-arm.json
  • scripts/testdata/role-parity/collision-roledefinitionid-evil-first-arm.json
  • scripts/testdata/role-parity/collision-root-resources-evil-first-arm.json
  • scripts/testdata/role-parity/collision-type-evil-first-arm.json
  • scripts/testdata/role-parity/dataactions-wildcard-arm.json
  • scripts/testdata/role-parity/decorative-variables-arm.json
  • scripts/testdata/role-parity/deploymentscript-scope-escape-arm.json
  • scripts/testdata/role-parity/deploymentstack-scope-escape-arm.json
  • scripts/testdata/role-parity/foreign-subscription-literal-with-canonical-arm.json
  • scripts/testdata/role-parity/legacy-child-keyvault-roleassignment-owner-arm.json
  • scripts/testdata/role-parity/legacy-child-roleassignment-owner-arm.json
  • scripts/testdata/role-parity/lowercase-tenant-scope-arm.json
  • scripts/testdata/role-parity/lowercase-type-wildcard-actions-arm.json
  • scripts/testdata/role-parity/matching-arm.json
  • scripts/testdata/role-parity/matching-tf.tf.fixture
  • scripts/testdata/role-parity/mgmt-group-schema-arm.json
  • scripts/testdata/role-parity/miscased-assignablescopes-arm.json
  • scripts/testdata/role-parity/miscased-properties-arm.json
  • scripts/testdata/role-parity/miscased-roledefinitionid-arm.json
  • scripts/testdata/role-parity/miscased-scope-arm.json
  • scripts/testdata/role-parity/nested-deployment-arm.json
  • scripts/testdata/role-parity/obfuscated-tenant-scope-arm.json
  • scripts/testdata/role-parity/other-subscription-arm.json
  • scripts/testdata/role-parity/pim-roleassignment-schedule-owner-arm.json
  • scripts/testdata/role-parity/pim-roleeligibility-owner-arm.json
  • scripts/testdata/role-parity/second-permissions-entry-arm.json
  • scripts/testdata/role-parity/space-inside-literal-arm.json
  • scripts/testdata/role-parity/tenant-scope-arm.json
  • scripts/testdata/role-parity/unallowed-roledefinitionid-arm.json
  • scripts/testdata/role-parity/uppercase-allowed-roledefinitionid-arm.json
  • scripts/testdata/role-parity/uppercase-canonical-scope-arm.json
  • scripts/testdata/role-parity/uppercase-guid-literal-arm.json
  • terraform/modules/compute/azure/container-apps/main.tf
  • terraform/modules/iam/azure/cudly-reservation-role/main.tf

📝 Walkthrough

Walkthrough

The Azure reservation role now uses reservationOrders/write, calculatePrice/action, and Billing Benefits actions. Role-parity fixtures and bootstrap diagnostics were updated to match the corrected permissions.

Changes

Azure reservation role correction

Layer / File(s) Summary
Update reservation role permissions
arm/CUDly-CrossSubscription/template.json, iac/federation/azure-target/terraform/main.tf, terraform/modules/...
The role removes Microsoft.Capacity/reservationOrders/purchase/action and documents the required reservation and Billing Benefits actions.
Align ARM and Terraform parity fixtures
scripts/testdata/role-parity/*
Parity fixtures replace the obsolete purchase action with Microsoft.BillingBenefits/register/action.
Improve bootstrap failure diagnostics
.github/workflows/deploy-azure.yml
Bootstrap troubleshooting checks for InvalidActionOrNotAction before checking permissions, role existence, and naming.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related issues

  • LeanerCloud/CUDly#1799 — Directly addresses the invalid Azure RBAC action and related onboarding diagnostics.

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: removing the nonexistent Azure action from the reservation-purchaser role.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/1794-invalid-capacity-purchase-action

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/all-users Affects every user priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens triaged Item has been triaged type/bug Defect urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ci(azure): Deploy to Azure Container Apps has failed on every main run for 3 weeks — no Azure change since 2026-07-19 is deployed

1 participant