Files
astral/deploy/provision-change-probe.ps1
Tomas Kracmar 2c41eaca44 Sync from dev @ 497baf0
Source: main (497baf0)
Excluded: live tenant exports, generated artifacts, and dev-only tooling.
2026-04-21 22:21:43 +02:00

579 lines
24 KiB
PowerShell
Executable File

#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"