Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -463,6 +482,7 @@ jobs:
- terraform-validate
- security-scan
- e2e-tests
- azure-role-parity
if: always()

steps:
Expand Down
113 changes: 113 additions & 0 deletions scripts/check-azure-role-parity.sh
Original file line number Diff line number Diff line change
@@ -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 <path>] [--arm-file <path>]
#
# 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
45 changes: 45 additions & 0 deletions scripts/test-azure-role-parity.sh
Original file line number Diff line number Diff line change
@@ -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 ]]
31 changes: 31 additions & 0 deletions scripts/testdata/role-parity/drifted-arm.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
]
}
32 changes: 32 additions & 0 deletions scripts/testdata/role-parity/matching-arm.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
]
}
25 changes: 25 additions & 0 deletions scripts/testdata/role-parity/matching-tf.tf.fixture
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading