#requires -Version 5.1 <# .SYNOPSIS One-stop provisioning script for the ASTRAL change probe. .DESCRIPTION This script handles the entire probe deployment in one pass: 1. Creates (or updates) a dedicated Entra app registration with Graph permissions. 2. Grants admin consent. 3. Provisions Azure resources (Resource Group, Storage Account, Function App). 4. Configures Function App settings. 5. Optionally deploys the function code if the Azure Functions Core Tools (func) are installed. Any parameter omitted on the command line is prompted for interactively. .PARAMETER AppDisplayName Display name for the Entra app registration. Default: "ASTRAL Change Probe". .PARAMETER ResourceGroup Azure resource group name. Default: "rg-astral-probe". .PARAMETER Location Azure region. Default: "westeurope". .PARAMETER SubscriptionId Azure subscription ID. If omitted, the current default subscription is used. .PARAMETER AdoOrganization Azure DevOps organization name (e.g. "contoso"). .PARAMETER AdoProject Azure DevOps project name. .PARAMETER AdoPipelineId Azure DevOps pipeline ID (numeric). .PARAMETER AdoToken Azure DevOps Personal Access Token with Build (Read & Execute) scope. .PARAMETER AdoBranch Git branch the pipeline should run against. Default: "main". .PARAMETER QuietWindowMinutes Debouncer quiet window. Default: 15. .PARAMETER CooldownMinutes Debouncer cooldown. Default: 30. .EXAMPLE .\provision-change-probe.ps1 .EXAMPLE .\provision-change-probe.ps1 -AdoOrganization "cqre" -AdoProject "ASTRAL" -AdoPipelineId "42" #> [CmdletBinding()] param ( [string]$AppDisplayName = "ASTRAL Change Probe", [string]$ResourceGroup = "rg-astral-probe", [string]$Location = "westeurope", [string]$SubscriptionId = "", [string]$AdoOrganization = "", [string]$AdoProject = "", [string]$AdoPipelineId = "", [string]$AdoToken = "", [string]$AdoBranch = "main", [int]$QuietWindowMinutes = 15, [int]$CooldownMinutes = 30 ) $ErrorActionPreference = "Stop" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- function Get-OrPrompt { param ([string]$Value, [string]$Prompt, [switch]$Sensitive) if ($Value) { return $Value } if ($Sensitive) { return Read-Host -Prompt $Prompt -AsSecureString | ForEach-Object { [PSCredential]::New("x", $_).GetNetworkCredential().Password } } return Read-Host -Prompt $Prompt } function Test-Command { param ([string]$Name) return [bool](Get-Command $Name -ErrorAction SilentlyContinue) } function Invoke-AzCli { param ( [string[]]$ArgumentList, [switch]$NoRetry ) # Clone the array so recursive calls don't double-append --subscription. $argsCopy = @() + $ArgumentList if ($SubscriptionId) { $argsCopy += @("--subscription", $SubscriptionId) } # Suppress Python SyntaxWarnings that leak from the Azure CLI into stderr/stdout. $env:PYTHONWARNINGS = "ignore" $output = & az @argsCopy 2>&1 $env:PYTHONWARNINGS = "" if ($LASTEXITCODE -ne 0) { $outputStrings = @() $hasSubNotFound = $false foreach ($line in $output) { $str = if ($line -is [string]) { $line } else { $line.ToString() } $outputStrings += $str if ($str -match "SubscriptionNotFound") { $hasSubNotFound = $true } } $outputString = $outputStrings -join "`n" if ((-not $NoRetry) -and $hasSubNotFound) { Write-Host "`nARM returned SubscriptionNotFound. Clearing token cache and re-authenticating..." -ForegroundColor Yellow $subTenantId = Get-SubscriptionTenantId -SubId $SubscriptionId $promptTenant = if ($subTenantId) { $subTenantId } else { $tenantId } & az account clear | Out-Null & az login --tenant $promptTenant | Out-Host if ($LASTEXITCODE -ne 0) { throw "az login --tenant $promptTenant failed." } # Explicitly set subscription and give token cache time to settle. & az account set --subscription $SubscriptionId | Out-Null Start-Sleep -Seconds 2 Invoke-AzCli -ArgumentList $ArgumentList -NoRetry return } throw "az command failed: az $($argsCopy -join ' ')`n$outputString" } return $output } function Test-ModuleInstalled { param ([string]$Name) $mod = Get-Module -ListAvailable -Name $Name | Select-Object -First 1 if (-not $mod) { Write-Host "Installing module: $Name" -ForegroundColor Cyan Install-Module $Name -Scope CurrentUser -Force -AllowClobber } } # --------------------------------------------------------------------------- # Prerequisites # --------------------------------------------------------------------------- Write-Host "=== ASTRAL Change Probe Provisioning ===" -ForegroundColor Green if (-not (Test-Command "az")) { throw "Azure CLI (az) is not installed or not in PATH. Install from https://aka.ms/installazurecli" } Write-Host "Checking Microsoft Graph modules..." -ForegroundColor Cyan Test-ModuleInstalled "Microsoft.Graph.Applications" Import-Module Microsoft.Graph.Applications # --------------------------------------------------------------------------- # Interactive prompts # --------------------------------------------------------------------------- Write-Host "`n--- Azure DevOps Settings ---" -ForegroundColor Cyan $AdoOrganization = Get-OrPrompt -Value $AdoOrganization -Prompt "Azure DevOps Organization (e.g. 'cqre')" $AdoProject = Get-OrPrompt -Value $AdoProject -Prompt "Azure DevOps Project" $AdoPipelineId = Get-OrPrompt -Value $AdoPipelineId -Prompt "Azure DevOps Pipeline ID (numeric)" $AdoToken = Get-OrPrompt -Value $AdoToken -Prompt "Azure DevOps PAT (Build Read & Execute)" -Sensitive # --------------------------------------------------------------------------- # Graph authentication # --------------------------------------------------------------------------- Write-Host "`nConnecting to Microsoft Graph..." -ForegroundColor Cyan Connect-MgGraph -Scopes "Application.ReadWrite.All","AppRoleAssignment.ReadWrite.All","Directory.Read.All" -NoWelcome $tenant = Get-MgOrganization | Select-Object -First 1 Write-Host "Tenant: $($tenant.DisplayName) ($($tenant.Id))" -ForegroundColor Green # --------------------------------------------------------------------------- # App registration # --------------------------------------------------------------------------- $requiredPermissions = @( "AuditLog.Read.All", "DeviceManagementApps.Read.All", "DeviceManagementConfiguration.Read.All", "DeviceManagementManagedDevices.Read.All", "DeviceManagementScripts.Read.All", "DeviceManagementServiceConfig.Read.All" ) $graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" if (-not $graphSp) { throw "Microsoft Graph service principal not found." } $appRoles = @() foreach ($permName in $requiredPermissions) { $appRole = $graphSp.AppRoles | Where-Object { $_.Value -eq $permName } | Select-Object -First 1 if (-not $appRole) { Write-Warning "Permission '$permName' not found. Skipping." continue } $appRoles += $appRole } $resourceAccess = @() foreach ($ar in $appRoles) { $resourceAccess += @{ id = $ar.Id; type = "Role" } } $requiredResourceAccess = @( @{ resourceAppId = $graphSp.AppId resourceAccess = $resourceAccess } ) $existingApp = Get-MgApplication -Filter "displayName eq '$AppDisplayName'" | Select-Object -First 1 if ($existingApp) { Write-Host "Found existing app registration: $($existingApp.AppId)" -ForegroundColor Yellow $app = $existingApp Update-MgApplication -ApplicationId $app.Id -RequiredResourceAccess $requiredResourceAccess Write-Host "Updated required resource access." -ForegroundColor Green } else { Write-Host "Creating app registration: $AppDisplayName" -ForegroundColor Cyan $app = New-MgApplication -DisplayName $AppDisplayName -SignInAudience "AzureADMyOrg" -RequiredResourceAccess $requiredResourceAccess Write-Host "Created app registration. AppId: $($app.AppId)" -ForegroundColor Green } $sp = Get-MgServicePrincipal -Filter "appId eq '$($app.AppId)'" | Select-Object -First 1 if (-not $sp) { Write-Host "Creating service principal..." -ForegroundColor Cyan $sp = New-MgServicePrincipal -AppId $app.AppId } Write-Host "Granting admin consent..." -ForegroundColor Cyan foreach ($ar in $appRoles) { $existingAssignment = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id | Where-Object { $_.AppRoleId -eq $ar.Id } if (-not $existingAssignment) { New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id -PrincipalId $sp.Id -ResourceId $graphSp.Id -AppRoleId $ar.Id | Out-Null } } Write-Host "Admin consent granted." -ForegroundColor Green # Client secret $secretDescription = "ChangeProbeSecret" $appWithCreds = Get-MgApplication -ApplicationId $app.Id -Property "id,passwordCredentials" $existingSecrets = $appWithCreds.PasswordCredentials | Where-Object { $_.DisplayName -eq $secretDescription } foreach ($cred in $existingSecrets) { Write-Host "Removing old client secret ($($cred.KeyId))..." -ForegroundColor Yellow Remove-MgApplicationPassword -ApplicationId $app.Id -BodyParameter @{ "keyId" = $cred.KeyId } } Write-Host "Creating new client secret (valid 1 year)..." -ForegroundColor Cyan $passwordCred = @{ displayName = $secretDescription endDateTime = (Get-Date).AddYears(1).ToString("o") } $secret = Add-MgApplicationPassword -ApplicationId $app.Id -BodyParameter $passwordCred $probeAppId = $app.AppId $probeAppSecret = $secret.SecretText $tenantId = $tenant.Id # --------------------------------------------------------------------------- # Azure authentication # --------------------------------------------------------------------------- Write-Host "`n--- Azure Resources ---" -ForegroundColor Cyan function Ensure-AzLogin { param ([string]$TenantId) try { $null = Invoke-AzCli -ArgumentList @("account", "show", "--output", "none") } catch { if ($_ -match "az login") { $answer = Read-Host -Prompt "You are not logged in to Azure CLI. Run 'az login' now? [Y/n]" if ($answer -eq "" -or $answer -match "^[Yy]") { if ($TenantId) { & az login --tenant $TenantId | Out-Host } else { & az login | Out-Host } if ($LASTEXITCODE -ne 0) { throw "az login failed. Please run 'az login' manually and retry." } } else { throw "Azure login required. Run 'az login' and retry." } } else { throw } } } Ensure-AzLogin -TenantId $tenantId function Select-Subscription { param ([string]$CurrentId) # Run az directly and filter out stderr warning objects so only stdout strings reach ConvertFrom-Json. $lines = & az account list --output json 2>&1 $stringLines = $lines | Where-Object { $_ -is [string] } if ($LASTEXITCODE -ne 0) { $errorLines = $lines | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] } | ForEach-Object { $_.ToString() } throw "az account list failed:`n$($errorLines -join "`n")" } $subs = ($stringLines -join "`n") | ConvertFrom-Json if ($subs.Count -eq 0) { throw "No Azure subscriptions found. Ensure your account has access to at least one subscription." } if ($subs.Count -eq 1) { $sub = $subs[0] Invoke-AzCli -ArgumentList @("account", "set", "--subscription", $sub.id) return $sub } Write-Host "`nAvailable subscriptions:" -ForegroundColor Cyan for ($i = 0; $i -lt $subs.Count; $i++) { $marker = if ($subs[$i].id -eq $CurrentId) { " (*)" } else { "" } Write-Host " [$i] $($subs[$i].name) ($($subs[$i].id))$marker" } $selection = Read-Host -Prompt "Select subscription by number" if (-not [int]::TryParse($selection, [ref]$null)) { throw "Invalid selection. Aborting." } $chosen = $subs[[int]$selection] if (-not $chosen) { throw "Invalid selection. Aborting." } Invoke-AzCli -ArgumentList @("account", "set", "--subscription", $chosen.id) return $chosen } $azLines = & az account show --output json 2>&1 $azStringLines = $azLines | Where-Object { $_ -is [string] } if ($LASTEXITCODE -ne 0) { $azErrorLines = $azLines | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] } | ForEach-Object { $_.ToString() } throw "az account show failed:`n$($azErrorLines -join "`n")" } $azAccount = ($azStringLines -join "`n") | ConvertFrom-Json $currentSubId = $azAccount.id function Get-SubscriptionTenantId { param ([string]$SubId) $lines = & az account list --output json 2>&1 $stringLines = $lines | Where-Object { $_ -is [string] } $subs = ($stringLines -join "`n") | ConvertFrom-Json $sub = $subs | Where-Object { $_.id -eq $SubId } | Select-Object -First 1 if ($sub) { return $sub.tenantId } else { return $null } } if ($SubscriptionId) { Invoke-AzCli -ArgumentList @("account", "set", "--subscription", $SubscriptionId) $subTenantId = Get-SubscriptionTenantId -SubId $SubscriptionId $azTenantLines = & az account show --query tenantId --output tsv 2>&1 | Where-Object { $_ -is [string] } $azTenantId = ($azTenantLines -join "").Trim() if ($subTenantId -and $azTenantId -ne $subTenantId) { Write-Host "`nSubscription '$SubscriptionId' belongs to tenant '$subTenantId' but current az context is '$azTenantId'." -ForegroundColor Yellow Write-Host "Re-authenticating to the subscription's tenant..." -ForegroundColor Yellow & az account clear | Out-Null & az login --tenant $subTenantId | Out-Host if ($LASTEXITCODE -ne 0) { throw "az login --tenant $subTenantId failed." } Invoke-AzCli -ArgumentList @("account", "set", "--subscription", $SubscriptionId) } Write-Host "Using specified subscription: $SubscriptionId" -ForegroundColor Green } else { $chosenSub = Select-Subscription -CurrentId $currentSubId $SubscriptionId = $chosenSub.id $subTenantId = $chosenSub.tenantId $azTenantLines = & az account show --query tenantId --output tsv 2>&1 | Where-Object { $_ -is [string] } $azTenantId = ($azTenantLines -join "").Trim() if ($subTenantId -and $azTenantId -ne $subTenantId) { Write-Host "`nSubscription '$SubscriptionId' belongs to tenant '$subTenantId' but current az context is '$azTenantId'." -ForegroundColor Yellow Write-Host "Re-authenticating to the subscription's tenant..." -ForegroundColor Yellow & az account clear | Out-Null & az login --tenant $subTenantId | Out-Host if ($LASTEXITCODE -ne 0) { throw "az login --tenant $subTenantId failed." } $chosenSub = Select-Subscription -CurrentId $SubscriptionId $SubscriptionId = $chosenSub.id } Write-Host "Using subscription: $SubscriptionId" -ForegroundColor Green } # Validate the subscription is accessible for ARM operations (catches tenant mismatches). try { $null = Invoke-AzCli -ArgumentList @("group", "list", "--output", "none") } catch { if ($_ -match "SubscriptionNotFound") { Write-Host "`nThe selected subscription is listed but ARM operations fail with 'SubscriptionNotFound'." -ForegroundColor Yellow Write-Host "This usually means the subscription belongs to a different Entra tenant." -ForegroundColor Yellow $subTenantId = Get-SubscriptionTenantId -SubId $SubscriptionId $promptTenant = if ($subTenantId) { $subTenantId } else { $tenantId } $answer = Read-Host -Prompt "Run 'az login --tenant $promptTenant' now and retry? [Y/n]" if ($answer -eq "" -or $answer -match "^[Yy]") { & az account clear | Out-Null & az login --tenant $promptTenant | Out-Host if ($LASTEXITCODE -ne 0) { throw "az login --tenant failed. Please run it manually and retry." } $chosenSub = Select-Subscription -CurrentId $SubscriptionId $SubscriptionId = $chosenSub.id Write-Host "Using subscription: $SubscriptionId" -ForegroundColor Green # Validate again $null = Invoke-AzCli -ArgumentList @("group", "list", "--output", "none") } else { throw "Subscription validation failed. Run 'az login --tenant $promptTenant' and retry." } } else { throw } } # --------------------------------------------------------------------------- # Resource Group # --------------------------------------------------------------------------- Write-Host "Ensuring resource group '$ResourceGroup'..." -ForegroundColor Cyan Invoke-AzCli -ArgumentList @("group", "create", "--name", $ResourceGroup, "--location", $Location, "--output", "none") # Quick diagnostic: confirm ARM can read back the RG in this subscription. try { $diag = Invoke-AzCli -ArgumentList @("group", "show", "--name", $ResourceGroup, "--query", "id", "--output", "tsv") Write-Host "ARM context OK (RG id: $diag)" -ForegroundColor Green } catch { Write-Host "WARNING: ARM diagnostic failed: $_" -ForegroundColor Yellow } # --------------------------------------------------------------------------- # Storage Account # --------------------------------------------------------------------------- $randomSuffix = [System.Guid]::NewGuid().ToString("n").Substring(0, 8) $StorageName = "stastralprobe$randomSuffix" $FunctionAppName = "func-astral-probe-$randomSuffix" function Wait-ProviderRegistration { param ([string]$Namespace) $state = "" $attempts = 0 while ($state -ne "Registered" -and $attempts -lt 30) { $state = Invoke-AzCli -ArgumentList @("provider", "show", "--namespace", $Namespace, "--query", "registrationState", "--output", "tsv") if ($state -eq "Registered") { break } Start-Sleep -Seconds 10 $attempts++ } if ($state -ne "Registered") { throw "Timed out waiting for $Namespace provider to register." } } Write-Host "Creating storage account '$StorageName'..." -ForegroundColor Cyan # Ensure Microsoft.Storage provider is registered (required for new subscriptions). $storageProv = Invoke-AzCli -ArgumentList @("provider", "show", "--namespace", "Microsoft.Storage", "--query", "registrationState", "--output", "tsv") if ($storageProv -ne "Registered") { Write-Host "Registering Microsoft.Storage provider..." -ForegroundColor Yellow Invoke-AzCli -ArgumentList @("provider", "register", "--namespace", "Microsoft.Storage") Wait-ProviderRegistration -Namespace "Microsoft.Storage" Write-Host "Microsoft.Storage registered." -ForegroundColor Green } Invoke-AzCli -ArgumentList @( "storage", "account", "create", "--name", $StorageName, "--resource-group", $ResourceGroup, "--location", $Location, "--sku", "Standard_LRS", "--kind", "StorageV2", "--output", "none" ) $storageConnection = Invoke-AzCli -ArgumentList @( "storage", "account", "show-connection-string", "--name", $StorageName, "--resource-group", $ResourceGroup, "--query", "connectionString", "--output", "tsv" ) # --------------------------------------------------------------------------- # Table and Queue # --------------------------------------------------------------------------- Write-Host "Creating Table and Queue..." -ForegroundColor Cyan Invoke-AzCli -ArgumentList @("storage", "table", "create", "--name", "ProbeState", "--connection-string", $storageConnection, "--output", "none") Invoke-AzCli -ArgumentList @("storage", "queue", "create", "--name", "backup-trigger-queue", "--connection-string", $storageConnection, "--output", "none") # --------------------------------------------------------------------------- # Function App # --------------------------------------------------------------------------- # Ensure Microsoft.Web provider is registered (required for Function Apps). $webProv = Invoke-AzCli -ArgumentList @("provider", "show", "--namespace", "Microsoft.Web", "--query", "registrationState", "--output", "tsv") if ($webProv -ne "Registered") { Write-Host "Registering Microsoft.Web provider..." -ForegroundColor Yellow Invoke-AzCli -ArgumentList @("provider", "register", "--namespace", "Microsoft.Web") Wait-ProviderRegistration -Namespace "Microsoft.Web" Write-Host "Microsoft.Web registered." -ForegroundColor Green } Write-Host "Creating Function App '$FunctionAppName'..." -ForegroundColor Cyan Invoke-AzCli -ArgumentList @( "functionapp", "create", "--name", $FunctionAppName, "--resource-group", $ResourceGroup, "--storage-account", $StorageName, "--consumption-plan-location", $Location, "--os-type", "Linux", "--runtime", "python", "--runtime-version", "3.11", "--functions-version", "4", "--output", "none" ) # --------------------------------------------------------------------------- # App Settings # --------------------------------------------------------------------------- Write-Host "Configuring Function App settings..." -ForegroundColor Cyan Invoke-AzCli -ArgumentList @( "functionapp", "config", "appsettings", "set", "--name", $FunctionAppName, "--resource-group", $ResourceGroup, "--settings", "AzureWebJobsStorage=$storageConnection", "FUNCTIONS_EXTENSION_VERSION=~4", "FUNCTIONS_WORKER_RUNTIME=python", "WEBSITE_RUN_FROM_PACKAGE=1", "PROBE_APP_ID=$probeAppId", "PROBE_APP_SECRET=$probeAppSecret", "TENANT_ID=$tenantId", "GRAPH_TOKEN=", "ADO_ORGANIZATION=$AdoOrganization", "ADO_PROJECT=$AdoProject", "ADO_PIPELINE_ID=$AdoPipelineId", "ADO_TOKEN=$AdoToken", "ADO_BRANCH=$AdoBranch", "PROBE_QUIET_WINDOW_MINUTES=$QuietWindowMinutes", "PROBE_COOLDOWN_MINUTES=$CooldownMinutes", "REPO_ROOT=/home/site/wwwroot", "--output", "none" ) # --------------------------------------------------------------------------- # Optional: code deployment # --------------------------------------------------------------------------- $funcAvailable = Test-Command "func" if ($funcAvailable) { $repoRoot = Split-Path -Parent $PSScriptRoot $probePath = Join-Path $repoRoot "infra" "change-probe" if (Test-Path $probePath) { $deployNow = Read-Host -Prompt "`nDeploy function code now? [Y/n]" if ($deployNow -eq "" -or $deployNow -match "^[Yy]") { Write-Host "Deploying function code..." -ForegroundColor Cyan Push-Location $probePath try { & func azure functionapp publish $FunctionAppName if ($LASTEXITCODE -ne 0) { Write-Warning "Function deployment returned exit code $LASTEXITCODE. You can retry manually later." } } finally { Pop-Location } } } } else { Write-Host "`nAzure Functions Core Tools (func) not found. Skipping code deployment." -ForegroundColor Yellow Write-Host "Install from https://github.com/Azure/azure-functions-core-tools#installing" -ForegroundColor Yellow } # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- Write-Host "`n=== Provisioning Complete ===" -ForegroundColor Green Write-Host "Subscription: $SubscriptionId" Write-Host "Resource Group: $ResourceGroup" Write-Host "Storage Account: $StorageName" Write-Host "Function App: $FunctionAppName" Write-Host "App Registration: $probeAppId" Write-Host "`nNext steps:" Write-Host " - Verify the timer trigger in the Azure Portal or with:" Write-Host " az functionapp function show --name $FunctionAppName --resource-group $ResourceGroup --function-name probe_timer" Write-Host " - To redeploy code later:" Write-Host " cd infra/change-probe && func azure functionapp publish $FunctionAppName"