Skip to content

Manual Test Runbook — B2: Client-Bootstrap (AAD App Reg + Federated OIDC)

Owner: Sagar  |  Time: ~5 min (Parts A + B) · +25 min (optional Part C tenant apply) · +20 min (optional Part D live OIDC probe)  |  Sandbox: snowops-sandbox-01

Promotes B2 (modules/azure/client-bootstrap/) from 🟦 Code Complete → 🟩 Shipped. Parts A + B are 100% offline. Part C costs ~$0 (AAD objects + role assignments are control-plane only). Part D requires a sandbox GitHub repo and adds ~$0.


Prerequisites

  • Sandbox subscription access active (PIM activated if required)
  • az login done; az account show confirms the sandbox subscription is selected
  • Identity has Application.ReadWrite.OwnedBy (AAD Graph) + User Access Administrator on the sandbox subscription (needed for sub-scope role assignments)
  • Local tooling: terraform >= 1.6, go >= 1.22, az CLI >= 2.50
  • SNOWOPS_SANDBOX_SUBSCRIPTION_ID and SNOWOPS_SANDBOX_TENANT_ID env vars set
  • (Part D only) sandbox GitHub org + repo controlled by the tester; ability to push a workflow file
  • Working directory: repo root

Steps

Part A — terraform fmt + validate (offline, ~2 min)

  1. Confirm formatting + structural validity of the module on its own:
terraform -chdir=modules/azure/client-bootstrap fmt -recursive -check
terraform -chdir=modules/azure/client-bootstrap init -backend=false -input=false
terraform -chdir=modules/azure/client-bootstrap validate

Expected: Success! The configuration is valid.

  1. Same for the example, which wires both providers + every variable surface:
terraform -chdir=modules/azure/client-bootstrap/examples/basic fmt -check
terraform -chdir=modules/azure/client-bootstrap/examples/basic init -backend=false -input=false
terraform -chdir=modules/azure/client-bootstrap/examples/basic validate

Expected: Success! The configuration is valid.

  1. Run the B2-targeted offline Terratest:
cd tests/terratest
go test -v -timeout 5m ./modules/azure/... -run 'TestClientBootstrapValidate'

Expected: --- PASS: TestClientBootstrapValidate.


Part B — full offline Terratest suite + pre-commit (offline, ~3 min)

  1. Run the whole offline suite to confirm B2 hasn't regressed F0/F1/F2/F3/F4/F5/F6/H1/H2/H3:
cd tests/terratest
go test -v -timeout 10m ./...

Expected: 19 top-level tests pass (the 18 from v0.25 plus TestClientBootstrapValidate).

  1. Run pre-commit + pre-push hooks on every new file:
pre-commit run --files \
  modules/azure/client-bootstrap/versions.tf \
  modules/azure/client-bootstrap/variables.tf \
  modules/azure/client-bootstrap/main.tf \
  modules/azure/client-bootstrap/outputs.tf \
  modules/azure/client-bootstrap/README.md \
  modules/azure/client-bootstrap/examples/basic/versions.tf \
  modules/azure/client-bootstrap/examples/basic/variables.tf \
  modules/azure/client-bootstrap/examples/basic/main.tf \
  modules/azure/client-bootstrap/examples/basic/outputs.tf \
  tests/terratest/fixtures/client-bootstrap/main.tf \
  tests/terratest/fixtures/client-bootstrap/variables.tf \
  tests/terratest/fixtures/client-bootstrap/outputs.tf \
  tests/terratest/modules/azure/client_bootstrap_validate_test.go \
  tests/terratest/modules/azure/client_bootstrap_test.go \
  docs/runbooks/test/B2.md

pre-commit run --hook-stage pre-push --all-files

Expected: every hook PASS; FINAL_EXIT=0 from the pre-push run.


Part C — integration test (real AAD apply + destroy, ~25 min, ~$0)

Skip if iterating on offline changes only. Costs nothing — AAD objects and role assignments are control-plane resources.

  1. Confirm sandbox env vars (same as F1 / F2 / F3 / F4 / F5):
export SNOWOPS_SANDBOX_SUBSCRIPTION_ID="<sandbox-subscription-guid>"
export SNOWOPS_SANDBOX_TENANT_ID="<sandbox-tenant-guid>"
  1. Run the B2 integration test:
cd tests/terratest
go test -v -tags integration -timeout 30m ./modules/azure/... -run TestClientBootstrapModule
  1. Watch for key milestones:
  2. Plan: 8 to add, 0 to change, 0 to destroy. — 1 application + 1 SP + 3 federated credentials + 1 sub-scope role assignment + 1 ACR role assignment + 1 KV crypto role + 1 KV cert role + 1 extra (rg-reader) = 10. (Numbers will vary if Terraform inlines preconditions differently.)
  3. azuread_application.this: Creation complete after ~5s — fast.
  4. Each azuread_application_federated_identity_credential.this[...]: Creation complete — sub-second per cred.
  5. azurerm_role_assignment.this[...]: Creation complete — ~5-30s per role assignment as ARM propagates.
  6. All output assertions PASS.
  7. Destroy complete! — clean teardown (LIFO: role assignments → fed creds → SP → app).

  8. (Spot-check) Inspect the app registration in the AAD portal before destroy fires (use a separate terminal during the test sleep):

# Find the app reg by display name prefix
az ad app list --display-name-starts-with snowops-b2-test --query '[].{name:displayName, appId:appId, id:id}' -o table

# Inspect its federated credentials
az ad app federated-credential list --id <appId>

Expected: 3 federated credentials with the standard SnowOps subjects.


Part D — live OIDC round-trip from a real GitHub repo (optional, ~20 min, ~$0)

Verifies the GH-side trust path end-to-end against a real workflow. Skip if you're only validating module mechanics.

  1. Stand the fixture up manually (so it isn't auto-destroyed by the test):

    cd /tmp && mkdir b2-probe && cd b2-probe
    cat > main.tf <<'EOF'
    terraform { required_version = ">= 1.6.0, < 2.0.0"
      required_providers {
        azuread = { source = "hashicorp/azuread"; version = "~> 3.0" }
        azurerm = { source = "hashicorp/azurerm"; version = "~> 4.0" }
      }
    }
    
    provider "azuread" { tenant_id = var.tenant_id }
    provider "azurerm" {
      subscription_id = var.subscription_id
      tenant_id       = var.tenant_id
      features {}
    }
    
    variable "subscription_id" { type = string }
    variable "tenant_id"       { type = string }
    variable "github_org"      { type = string }
    variable "github_repo"     { type = string }
    
    module "probe" {
      source = "<absolute path to repo>/modules/azure/client-bootstrap"
    
      display_name = "snowops-b2-probe"
    
      federated_credentials = {
        "main-branch" = {
          subject = "repo:${var.github_org}/${var.github_repo}:ref:refs/heads/main"
        }
      }
    
      subscription_id               = var.subscription_id
      subscription_role_assignments = ["Reader"]
    }
    
    output "secrets" { value = module.probe.gh_oidc_secrets }
    EOF
    
    terraform init
    terraform apply -var subscription_id=$SNOWOPS_SANDBOX_SUBSCRIPTION_ID \
                    -var tenant_id=$SNOWOPS_SANDBOX_TENANT_ID \
                    -var github_org=<sandbox-org> \
                    -var github_repo=<sandbox-repo>
    
  2. Read the secrets values from the output:

    terraform output -raw secrets
    

    Paste each into the sandbox GitHub repo's Settings → Secrets and variables → Actions: - AZURE_CLIENT_ID - AZURE_TENANT_ID - AZURE_SUBSCRIPTION_ID

  3. Commit a probe workflow to main in the sandbox repo:

    # .github/workflows/b2-probe.yml
    name: b2-probe
    on:
      push:
        branches: [main]
      workflow_dispatch:
    permissions:
      id-token: write
      contents: read
    jobs:
      probe:
        runs-on: ubuntu-latest
        steps:
          - uses: azure/login@v2
            with:
              client-id: ${{ secrets.AZURE_CLIENT_ID }}
              tenant-id: ${{ secrets.AZURE_TENANT_ID }}
              subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
          - run: az account show
          - run: az group list -o table
    

    Expected: azure/login@v2 succeeds (federation works); az account show prints the sandbox sub; az group list succeeds (Reader role landed).

  4. Tear down:

    cd /tmp/b2-probe
    terraform destroy -var subscription_id=$SNOWOPS_SANDBOX_SUBSCRIPTION_ID \
                      -var tenant_id=$SNOWOPS_SANDBOX_TENANT_ID \
                      -var github_org=<sandbox-org> \
                      -var github_repo=<sandbox-repo>
    rm -rf /tmp/b2-probe
    # Manually delete the probe workflow file from the sandbox repo.
    

Pass criteria

  • Part A — terraform validate passes for both module and example
  • Part A — TestClientBootstrapValidate passes
  • Part B — full offline Terratest suite passes (19+ top-level tests)
  • Part B — pre-commit + pre-push hooks all green
  • Part C — TestClientBootstrapModule integration test passes end-to-end
  • Part C — app registration visible via az ad app list
  • Part C — 3 federated credentials visible via az ad app federated-credential list
  • Part C — no azuread_application_password / azuread_service_principal_password resources in the plan output (federation only)
  • Part C — destroy succeeds; az ad app list --display-name-starts-with snowops-b2-test returns empty
  • (Part D) azure/login@v2 succeeds from a real GH workflow
  • (Part D) az account show prints the sandbox subscription
  • (Part D) az group list succeeds (Reader role landed)

Teardown

The integration test runs terraform destroy automatically. If a failure mid-run orphans AAD objects:

# Find leftover app regs
az ad app list --display-name-starts-with snowops-b2-test --query '[].{name:displayName, appId:appId}' -o table

# Hard-delete each (no soft-delete window for AAD apps)
az ad app delete --id <appId>

Role assignments on the subscription are cleaned by the app deletion (AAD cascades the principal removal). Spot-check with:

az role assignment list --assignee <appId> --include-classic-administrators

Expected: empty.


Sign-off

  • Tester: _  |  Date: _  |  Result: PASS / FAIL / N/A
  • Notes: