scripts/helpers/validate.ps1
|
#Requires -Version 7.0 <# .SYNOPSIS Pre-flight validation for the Azure Demo Environment. Run before any deployment to catch missing dependencies early. #> Set-StrictMode -Version Latest function Test-AdePrerequisites { <# .SYNOPSIS Checks all required tools and Azure auth are in place. Returns $true if all checks pass, throws on first failure if -StopOnError. #> param( [string]$Mode = 'default', [switch]$StopOnError ) $failures = [System.Collections.Generic.List[string]]::new() Write-AdeSection "Pre-flight Checks" # ── PowerShell version ──────────────────────────────────────────────────── Write-AdeLog "PowerShell version: $($PSVersionTable.PSVersion)" -Level Info if ($PSVersionTable.PSVersion.Major -lt 7) { $failures.Add("PowerShell 7+ required. Current: $($PSVersionTable.PSVersion). Install from https://aka.ms/powershell") } else { Write-AdeLog "PowerShell $($PSVersionTable.PSVersion) ✓" -Level Success } # ── Azure CLI ───────────────────────────────────────────────────────────── Write-AdeLog "az version --output json" -Level Debug $azVerJson = az version --output json 2>$null if ($LASTEXITCODE -ne 0 -or -not $azVerJson) { $failures.Add("Azure CLI not found. Install from https://aka.ms/installazurecliwindows") } else { $azVer = ($azVerJson | ConvertFrom-Json).'azure-cli' Write-AdeLog "Azure CLI $azVer ✓" -Level Success } # ── Bicep CLI ───────────────────────────────────────────────────────────── Write-AdeLog "az bicep version" -Level Debug $bicepVersion = az bicep version 2>$null if ($LASTEXITCODE -ne 0) { Write-AdeLog "Bicep not installed — attempting install via 'az bicep install'" -Level Warning Write-AdeLog "az bicep install" -Level Debug $null = az bicep install 2>$null if ($LASTEXITCODE -ne 0) { $failures.Add("Bicep CLI could not be installed automatically. Run: az bicep install") } else { Write-AdeLog "Bicep installed ✓" -Level Success } } else { Write-AdeLog "Bicep $($bicepVersion -replace 'Bicep CLI version ','') ✓" -Level Success } # ── CLI extension auto-install ─────────────────────────────────────────── # Enable dynamic install so any extension command (e.g. app-insights, rdbms-connect) # is installed automatically and silently on first use, without interactive prompts. Write-AdeLog "az config set extension.use_dynamic_install=yes_without_prompt" -Level Debug $null = az config set extension.use_dynamic_install=yes_without_prompt 2>$null if ($LASTEXITCODE -ne 0) { $failures.Add("Could not configure Azure CLI extension auto-install. Run: az config set extension.use_dynamic_install=yes_without_prompt") } else { Write-AdeLog "CLI extension auto-install enabled ✓" -Level Success } # ── Azure CLI login ─────────────────────────────────────────────────────── Write-AdeLog "az account show --output json" -Level Debug $accountJson = az account show --output json 2>$null $account = if ($accountJson) { $accountJson | ConvertFrom-Json } else { $null } if ($LASTEXITCODE -ne 0 -or -not $account) { $failures.Add("Not logged in to Azure CLI. Run: az login") } else { Write-AdeLog "Logged in as: $($account.user.name)" -Level Success Write-AdeLog "Subscription: $($account.name) [$($account.id)]" -Level Info } # ── EncryptionAtHost feature (hardened mode only) ───────────────────────── # Required for hardened compute: VMs deployed with EncryptionAtHost = true will # fail with an opaque ARM error if the feature is not registered on the subscription. if ($Mode -eq 'hardened') { Write-AdeLog "Checking EncryptionAtHost feature registration (required for hardened compute)..." -Level Info Write-AdeLog "az feature show --name EncryptionAtHost --namespace Microsoft.Compute --query properties.state" -Level Debug $featureState = az feature show --name EncryptionAtHost --namespace Microsoft.Compute --query properties.state -o tsv 2>$null if ($featureState -eq 'Registered') { Write-AdeLog "EncryptionAtHost feature Registered ✓" -Level Success } else { $failures.Add("EncryptionAtHost feature is '$featureState' on this subscription — required for hardened compute (encryption at host). Run: az feature register --name EncryptionAtHost --namespace Microsoft.Compute && az provider register -n Microsoft.Compute (allow 15-30 min to propagate)") } } # ── Result ──────────────────────────────────────────────────────────────── if ($failures.Count -gt 0) { Write-Host "" foreach ($failure in $failures) { Write-AdeLog $failure -Level Error } if ($StopOnError) { throw "Pre-flight checks failed. Resolve the issues above and retry." } return $false } Write-AdeLog "All pre-flight checks passed." -Level Success return $true } function Test-AdeSubscription { <# .SYNOPSIS Validates the target subscription exists and the caller has Contributor+. #> param([Parameter(Mandatory)][string]$SubscriptionId) Write-AdeLog "Validating subscription: $SubscriptionId" -Level Info $rawJson = az account show --subscription $SubscriptionId --output json 2>$null $sub = if ($rawJson) { $rawJson | ConvertFrom-Json } else { $null } if ($LASTEXITCODE -ne 0 -or -not $sub) { throw "Subscription '$SubscriptionId' not found or not accessible." } # Check caller has at least Contributor at subscription scope. # az ad signed-in-user show only works for interactive users; fall back to # SP object ID lookup when running under OIDC (e.g. GitHub Actions). Write-AdeLog "az ad signed-in-user show --query id" -Level Debug $callerId = az ad signed-in-user show --query id -o tsv 2>$null if (-not $callerId) { Write-AdeLog "az account show --query user.name (SP fallback)" -Level Debug $appId = az account show --query 'user.name' -o tsv 2>$null Write-AdeLog "az ad sp show --id $appId" -Level Debug $callerId = az ad sp show --id $appId --query id -o tsv 2>$null } if ($callerId) { $callerId = $callerId.Trim().Trim('"') } if (-not $callerId) { Write-AdeLog "Could not resolve caller object ID — skipping Contributor check." -Level Warning } else { $roleQuery = "[?roleDefinitionName == 'Owner' || roleDefinitionName == 'Contributor'].roleDefinitionName" # Build principal list: caller + all transitive group memberships. # az role assignment list --assignee does NOT expand groups at MG scope. $principalsToCheck = [System.Collections.Generic.List[string]]@($callerId) Write-AdeLog "az ad user get-member-groups --id $callerId" -Level Debug $groupIds = az ad user get-member-groups --id $callerId --security-enabled-only true ` --query "[].id" -o tsv 2>$null if ($groupIds) { $groupIds -split "`n" | Where-Object { $_ } | ForEach-Object { $principalsToCheck.Add($_) } } Write-AdeLog "Role check: $($principalsToCheck.Count) principal(s) to check" -Level Debug # Build scope list: subscription first, then full MG ancestry (innermost → root). # --include-inherited does NOT climb to parent management groups. $scopesToCheck = [System.Collections.Generic.List[string]]@("/subscriptions/$SubscriptionId") Write-AdeLog "az account management-group entities list (ancestry for $SubscriptionId)" -Level Debug $mgRaw = az account management-group entities list ` --query "[?name=='$SubscriptionId'].parentNameChain" ` -o json 2>$null $mgEntities = if ($mgRaw) { $mgRaw | ConvertFrom-Json } else { $null } if ($mgEntities -and $mgEntities[0]) { # parentNameChain is ordered outermost→innermost; reverse so we check nearest MG first [array]::Reverse($mgEntities[0]) foreach ($mgName in $mgEntities[0]) { $scopesToCheck.Add("/providers/Microsoft.Management/managementGroups/$mgName") } } Write-AdeLog "Role check: $($scopesToCheck.Count) scope(s) to check" -Level Debug $assignedRole = $null $assignedScope = $null :outer foreach ($scope in $scopesToCheck) { foreach ($principal in $principalsToCheck) { Write-AdeLog "az role assignment list --assignee $principal --scope $scope" -Level Debug $result = az role assignment list ` --assignee $principal ` --scope $scope ` --query $roleQuery ` -o tsv 2>$null if ($result) { $assignedRole = ($result -split "`n" | Select-Object -First 1) $assignedScope = $scope break outer } } } if ($assignedRole) { $via = if ($assignedScope -like "*/managementGroups/*") { " (via management group)" } else { "" } Write-AdeLog "Role confirmed: $assignedRole on $($sub.name)$via" -Level Success } else { Write-AdeLog "WARNING: Could not confirm Contributor/Owner role on subscription '$SubscriptionId'. Deployment may fail." -Level Warning } } return $sub } function Test-AdeProfile { <# .SYNOPSIS Validates a loaded profile object for required fields and logical consistency. #> param([Parameter(Mandatory)][psobject]$Profile) Write-AdeLog "Validating profile: $($Profile.profileName)" -Level Info # monitoring must be enabled if ANY other module (except governance) is enabled. # governance (Automation Account, budget, policy assignments) has no Log Analytics dependency. $anyNonMonitoring = $Profile.modules.PSObject.Properties | Where-Object { $_.Name -notin @('monitoring', 'governance') -and $_.Value.enabled -eq $true } if ($anyNonMonitoring -and $Profile.modules.monitoring.enabled -ne $true) { throw "Profile '$($Profile.profileName)': 'monitoring' must be enabled whenever any other module is enabled (Log Analytics is a shared dependency)." } # networking must be enabled if compute, databases, containers, appservices, integration, ai, or data are enabled $requiresNetworking = @('compute', 'databases', 'containers', 'appservices', 'integration', 'ai', 'data') $netProp = $Profile.modules.PSObject.Properties['networking'] $netEnabled = $null -ne $netProp -and $netProp.Value.enabled -eq $true foreach ($mod in $requiresNetworking) { $modProp = $Profile.modules.PSObject.Properties[$mod] if ($null -ne $modProp -and $modProp.Value.enabled -eq $true -and -not $netEnabled) { throw "Profile '$($Profile.profileName)': '$mod' requires 'networking' to be enabled." } } # security (Key Vault) is recommended for compute and containers $requiresSecurity = @('compute', 'containers') $secProp = $Profile.modules.PSObject.Properties['security'] $secEnabled = $null -ne $secProp -and $secProp.Value.enabled -eq $true foreach ($mod in $requiresSecurity) { $modProp = $Profile.modules.PSObject.Properties[$mod] if ($null -ne $modProp -and $modProp.Value.enabled -eq $true -and -not $secEnabled) { Write-AdeLog "Profile '$($Profile.profileName)': '$mod' is enabled without 'security'. Key Vault-backed secrets will be skipped." -Level Warning } } Write-AdeLog "Profile validation passed." -Level Success } function Confirm-AdeDeployment { <# .SYNOPSIS Displays a deployment summary and asks for user confirmation (unless -Force). #> param( [Parameter(Mandatory)][psobject]$Profile, [Parameter(Mandatory)][string]$Location, [Parameter(Mandatory)][string]$Prefix, [Parameter(Mandatory)][string]$SubscriptionId, [string]$Mode = 'default', [psobject]$BudgetPlan = $null, [switch]$Force ) $enabledModules = ($Profile.modules.PSObject.Properties | Where-Object { $_.Value.enabled -eq $true }).Name # Governance features (v2 schema) $govFeatures = Get-AdeModuleFeatures -Profile $Profile -ModuleName 'governance' $budgetAmount = Get-FeatureFlag -Features $govFeatures -Name 'budgetAmount' -Default 300 $budgetSummary = if ($BudgetPlan -and $BudgetPlan.PSObject.Properties['Summary']) { $BudgetPlan.Summary } elseif ((Get-FeatureFlag -Features $govFeatures -Name 'budget') -eq $true) { "`$$budgetAmount/month" } else { 'disabled' } $autoShutdown = if ((Get-FeatureFlag -Features $govFeatures -Name 'automationAccount') -eq $true) { '19:00 UTC (weekdays)' } else { 'disabled' } $expensiveOn = @() $netFeatures = Get-AdeModuleFeatures -Profile $Profile -ModuleName 'networking' $fwValue = Get-FeatureFlag -Features $netFeatures -Name 'enableFirewall' -Default 'None' if ($fwValue -ne 'None') { $expensiveOn += "Firewall ($fwValue)" } if ((Get-FeatureFlag -Features $netFeatures -Name 'enableAppGateway') -eq $true) { $expensiveOn += 'AppGateway' } if ((Get-FeatureFlag -Features $netFeatures -Name 'enableVpnGateway') -eq $true) { $expensiveOn += 'VpnGateway' } if ((Get-FeatureFlag -Features $netFeatures -Name 'enableDdos') -eq $true) { $expensiveOn += 'DDoS ⚠️' } $secFeatures = Get-AdeModuleFeatures -Profile $Profile -ModuleName 'security' if ((Get-FeatureFlag -Features $secFeatures -Name 'defenderForCloud') -eq $true) { $expensiveOn += 'Defender' } if ((Get-FeatureFlag -Features $secFeatures -Name 'sentinel') -eq $true) { $expensiveOn += 'Sentinel ⚠️' } $intFeatures = Get-AdeModuleFeatures -Profile $Profile -ModuleName 'integration' if ((Get-FeatureFlag -Features $intFeatures -Name 'apiManagement') -eq $true) { $expensiveOn += 'APIM' } Write-AdeSection "Deployment Summary" Write-Host " Profile : " -NoNewline; Write-Host $Profile.profileName -ForegroundColor Cyan Write-Host " Mode : " -NoNewline; Write-Host $Mode -ForegroundColor $(if ($Mode -eq 'hardened') { 'Yellow' } else { 'Cyan' }) Write-Host " Description : " -NoNewline; Write-Host $Profile.description -ForegroundColor Gray Write-Host " Location : " -NoNewline; Write-Host $Location -ForegroundColor Cyan Write-Host " Resource prefix : " -NoNewline; Write-Host $Prefix -ForegroundColor Cyan Write-Host " Subscription : " -NoNewline; Write-Host $SubscriptionId -ForegroundColor Cyan Write-Host " Modules enabled : " -NoNewline; Write-Host ($enabledModules -join ', ') -ForegroundColor Green Write-Host " Auto-shutdown : " -NoNewline; Write-Host $autoShutdown -ForegroundColor Yellow Write-Host " Budget alert : " -NoNewline; Write-Host $budgetSummary -ForegroundColor Yellow if ($expensiveOn.Count -gt 0) { Write-Host " Costly resources : " -NoNewline; Write-Host ($expensiveOn -join ', ') -ForegroundColor Red } Write-Host "" if (-not $Force) { $confirm = Read-Host "Proceed with deployment? [y/N]" if ($confirm -notmatch '^[Yy]$') { throw "Deployment cancelled by user." } } } function Test-AdePermissions { <# .SYNOPSIS Checks the caller has 'User Access Administrator' (or 'Owner') at subscription scope. Required when the Automation Account managed-identity role assignment will be deployed. Throws when -StopOnError is set and the permission is missing. #> param( [Parameter(Mandatory)][string]$SubscriptionId, [switch]$StopOnError ) Write-AdeLog "Checking role-assignment permissions on subscription '$SubscriptionId'" -Level Info # Resolve caller object ID — works for interactive users and OIDC service principals. Write-AdeLog "az ad signed-in-user show --query id" -Level Debug $callerId = az ad signed-in-user show --query id -o tsv 2>$null if (-not $callerId) { Write-AdeLog "az account show --query user.name (SP fallback)" -Level Debug $appId = az account show --query 'user.name' -o tsv 2>$null Write-AdeLog "az ad sp show --id $appId" -Level Debug $callerId = az ad sp show --id $appId --query id -o tsv 2>$null } if ($callerId) { $callerId = $callerId.Trim().Trim('"') } if (-not $callerId) { Write-AdeLog "Could not resolve caller object ID — skipping UAA check." -Level Warning return $false } Write-AdeLog "az role assignment list --assignee $callerId --subscription $SubscriptionId --include-inherited" -Level Debug $assignments = az role assignment list ` --assignee $callerId ` --subscription $SubscriptionId ` --include-inherited ` --query "[?roleDefinitionName == 'Owner' || roleDefinitionName == 'User Access Administrator'].roleDefinitionName" ` -o tsv 2>$null if (-not $assignments) { $grantCmd = "az role assignment create --assignee $callerId --role 'User Access Administrator' --scope /subscriptions/$SubscriptionId" $message = "Caller '$callerId' lacks 'Owner' or 'User Access Administrator' at subscription '$SubscriptionId'. " + "This role is required to create the Automation Account managed-identity role assignment. " + "Grant it with: $grantCmd" if ($StopOnError) { throw $message } Write-AdeLog $message -Level Error return $false } $grantedRole = ($assignments -split "`n")[0].Trim() Write-AdeLog "Permission confirmed: '$grantedRole' on '$SubscriptionId' ✓" -Level Success return $true } Write-AdeLog "validate.ps1 loaded" -Level Debug |