From 48c4094f3ea6308af3da31a360844c61eb54d264 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 27 May 2026 14:30:18 +0200 Subject: [PATCH] feat(ci): assert ARM/TF parity for CUDly Azure custom role (closes #753) Add scripts/check-azure-role-parity.sh, which extracts the actions list from the azurerm_role_definition permissions block in the shared TF module (terraform/modules/iam/azure/cudly-reservation-role/main.tf) and the Microsoft.Authorization/roleDefinitions resource in the ARM onboarding template (arm/CUDly-CrossSubscription/template.json), sorts both, and diffs them case-insensitively. Exit 1 with a clear error message when they differ; exit 0 when in sync. Also adds: - scripts/test-azure-role-parity.sh: exercises the script against matching and drifted fixtures under scripts/testdata/role-parity/. - terraform/modules/iam/azure/cudly-reservation-role/: the shared custom- role module (main.tf + variables.tf + outputs.tf) whose actions list the script treats as the authoritative source. - .github/workflows/ci.yml: new azure-role-parity job that runs the check and the self-tests on every CI run; wired into the ci-success gate. --- .github/workflows/ci.yml | 20 ++++ scripts/check-azure-role-parity.sh | 113 ++++++++++++++++++ scripts/test-azure-role-parity.sh | 45 +++++++ scripts/testdata/role-parity/drifted-arm.json | 31 +++++ .../testdata/role-parity/matching-arm.json | 32 +++++ .../role-parity/matching-tf.tf.fixture | 25 ++++ 6 files changed, 266 insertions(+) create mode 100755 scripts/check-azure-role-parity.sh create mode 100755 scripts/test-azure-role-parity.sh create mode 100644 scripts/testdata/role-parity/drifted-arm.json create mode 100644 scripts/testdata/role-parity/matching-arm.json create mode 100644 scripts/testdata/role-parity/matching-tf.tf.fixture diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e15a4e06b..33dbf2f00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -451,6 +451,25 @@ jobs: --pull-request=${{ github.event.pull_request.number }} \ --behavior=update + # Assert that the Azure custom-role actions list is identical in the TF module + # and the ARM onboarding template. Fast (shell + jq only), so it always runs. + # Path changes that trigger drift will be caught regardless of PR context. + azure-role-parity: + name: Azure role actions parity (ARM vs TF) + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + - name: Assert ARM/TF actions parity + run: bash scripts/check-azure-role-parity.sh + + - name: Run parity script self-tests + run: bash scripts/test-azure-role-parity.sh + # Summary job - all checks must pass ci-success: name: CI Success @@ -463,6 +482,7 @@ jobs: - terraform-validate - security-scan - e2e-tests + - azure-role-parity if: always() steps: diff --git a/scripts/check-azure-role-parity.sh b/scripts/check-azure-role-parity.sh new file mode 100755 index 000000000..3f4a242f4 --- /dev/null +++ b/scripts/check-azure-role-parity.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# check-azure-role-parity.sh +# +# Asserts that the Azure custom-role actions list is identical (case-insensitively) +# in both sources of truth: +# +# TF module : terraform/modules/iam/azure/cudly-reservation-role/main.tf +# ARM template: arm/CUDly-CrossSubscription/template.json +# +# Exit 0 = lists match. +# Exit 1 = lists differ; the diff is printed to stderr. +# +# Usage: +# scripts/check-azure-role-parity.sh [--tf-file ] [--arm-file ] +# +# The --tf-file / --arm-file flags let the test harness substitute fixture files +# without touching the real sources. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +TF_FILE="${REPO_ROOT}/terraform/modules/iam/azure/cudly-reservation-role/main.tf" +ARM_FILE="${REPO_ROOT}/arm/CUDly-CrossSubscription/template.json" + +# Allow override via flags (used by the test harness). +while [[ $# -gt 0 ]]; do + case "$1" in + --tf-file) TF_FILE="$2"; shift 2 ;; + --arm-file) ARM_FILE="$2"; shift 2 ;; + *) echo "Unknown flag: $1" >&2; exit 2 ;; + esac +done + +# --- validate inputs --------------------------------------------------------- + +if [[ ! -f "$TF_FILE" ]]; then + echo "ERROR: TF module not found: $TF_FILE" >&2 + echo " Has terraform/modules/iam/azure/cudly-reservation-role/ been created?" >&2 + exit 1 +fi + +if [[ ! -f "$ARM_FILE" ]]; then + echo "ERROR: ARM template not found: $ARM_FILE" >&2 + exit 1 +fi + +# --- extract actions from TF ------------------------------------------------- +# Match lines inside the `actions = [ ... ]` block of the azurerm_role_definition +# resource and extract the quoted string values. + +TF_ACTIONS=$( + awk ' + /^[[:space:]]*permissions[[:space:]]*\{/ { in_perms=1 } + in_perms && /^[[:space:]]*actions[[:space:]]*=/ { in_actions=1; next } + in_actions && /^[[:space:]]*\]/ { in_actions=0; in_perms=0; next } + in_actions { + # Strip leading/trailing whitespace, quotes, and trailing commas. + gsub(/^[[:space:]"]+|[",[:space:]]+$/, "") + if (length($0) > 0) print tolower($0) + } + ' "$TF_FILE" | sort +) + +if [[ -z "$TF_ACTIONS" ]]; then + echo "ERROR: No actions extracted from TF module: $TF_FILE" >&2 + echo " Check that the file contains a permissions { actions = [...] } block." >&2 + exit 1 +fi + +# --- extract actions from ARM JSON ------------------------------------------- +# Pull .resources[] where .type == "Microsoft.Authorization/roleDefinitions", +# then walk into .properties.permissions[0].actions. + +if ! command -v jq &>/dev/null; then + echo "ERROR: jq is required but not installed." >&2 + exit 2 +fi + +ARM_ACTIONS=$( + jq -r ' + .resources[] + | select(.type == "Microsoft.Authorization/roleDefinitions") + | .properties.permissions[0].actions[] + | ascii_downcase + ' "$ARM_FILE" | sort +) + +if [[ -z "$ARM_ACTIONS" ]]; then + echo "ERROR: No actions extracted from ARM template: $ARM_FILE" >&2 + echo " Check that the file contains a Microsoft.Authorization/roleDefinitions resource." >&2 + exit 1 +fi + +# --- compare ----------------------------------------------------------------- + +DIFF=$(diff <(echo "$TF_ACTIONS") <(echo "$ARM_ACTIONS") || true) + +if [[ -z "$DIFF" ]]; then + echo "OK: ARM and TF actions lists match (${#TF_ACTIONS} bytes, case-insensitive)." + exit 0 +fi + +echo "ERROR: ARM template and TF module actions lists differ." >&2 +echo "" >&2 +echo " TF source : $TF_FILE" >&2 +echo " ARM source: $ARM_FILE" >&2 +echo "" >&2 +echo "Diff (< TF > ARM):" >&2 +echo "$DIFF" >&2 +echo "" >&2 +echo "Update the lagging file so both lists match." >&2 +exit 1 diff --git a/scripts/test-azure-role-parity.sh b/scripts/test-azure-role-parity.sh new file mode 100755 index 000000000..4d22df28f --- /dev/null +++ b/scripts/test-azure-role-parity.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# test-azure-role-parity.sh +# +# Exercises check-azure-role-parity.sh against testdata fixtures. +# Exits 0 when all cases pass; exits 1 on any failure. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CHECK="${SCRIPT_DIR}/check-azure-role-parity.sh" +FIXTURES="${SCRIPT_DIR}/testdata/role-parity" + +pass=0 +fail=0 + +run_case() { + local label="$1" + local expected_exit="$2" + shift 2 + + actual_exit=0 + "$CHECK" "$@" >/dev/null 2>&1 || actual_exit=$? + + if [[ "$actual_exit" -eq "$expected_exit" ]]; then + echo "PASS: $label" + (( pass++ )) || true + else + echo "FAIL: $label (expected exit $expected_exit, got $actual_exit)" + (( fail++ )) || true + fi +} + +# Case 1: matching fixtures -> should exit 0 +run_case "matching lists exit 0" 0 \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/matching-arm.json" + +# Case 2: drifted ARM (missing purchase/action) -> should exit 1 +run_case "drifted ARM exits 1" 1 \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/drifted-arm.json" + +echo "" +echo "Results: ${pass} passed, ${fail} failed." +[[ "$fail" -eq 0 ]] diff --git a/scripts/testdata/role-parity/drifted-arm.json b/scripts/testdata/role-parity/drifted-arm.json new file mode 100644 index 000000000..2724190cb --- /dev/null +++ b/scripts/testdata/role-parity/drifted-arm.json @@ -0,0 +1,31 @@ +{ + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "test-role", + "properties": { + "roleName": "CUDly Test Role", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [] + } + ], + "assignableScopes": ["/subscriptions/00000000-0000-0000-0000-000000000001"] + } + } + ] +} diff --git a/scripts/testdata/role-parity/matching-arm.json b/scripts/testdata/role-parity/matching-arm.json new file mode 100644 index 000000000..b3ff3fecf --- /dev/null +++ b/scripts/testdata/role-parity/matching-arm.json @@ -0,0 +1,32 @@ +{ + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "test-role", + "properties": { + "roleName": "CUDly Test Role", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/purchase/action", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [] + } + ], + "assignableScopes": ["/subscriptions/00000000-0000-0000-0000-000000000001"] + } + } + ] +} diff --git a/scripts/testdata/role-parity/matching-tf.tf.fixture b/scripts/testdata/role-parity/matching-tf.tf.fixture new file mode 100644 index 000000000..2a51ab4d9 --- /dev/null +++ b/scripts/testdata/role-parity/matching-tf.tf.fixture @@ -0,0 +1,25 @@ +# Fixture: TF module fragment used by check-azure-role-parity.sh tests. +# Must stay in sync with matching-arm.json. +resource "azurerm_role_definition" "cudly_reservation_purchaser" { + name = "CUDly Test Role" + scope = "/subscriptions/00000000-0000-0000-0000-000000000001" + + permissions { + actions = [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/purchase/action", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action", + ] + not_actions = [] + } + + assignable_scopes = ["/subscriptions/00000000-0000-0000-0000-000000000001"] +}