Sync from dev @ 497baf0
Source: main (497baf0) Excluded: live tenant exports, generated artifacts, and dev-only tooling.
This commit is contained in:
@@ -24,6 +24,8 @@ Expected result: **zero matches** outside of this release checklist.
|
||||
- [ ] `azure-pipelines-restore.yml` contains no hardcoded tenant domain, email, or service connection name.
|
||||
- [ ] `azure-pipelines-review-sync.yml` contains no hardcoded tenant-specific values.
|
||||
- [ ] `scripts/common.py` uses a generic fallback name (not `CQRE_Intune_Backupper`).
|
||||
- [ ] `infra/change-probe/` contains no tenant-specific IDs, secrets, or connection strings.
|
||||
- [ ] `infra/change-probe/local.settings.json` is excluded (only `.example` should exist).
|
||||
- [ ] `tenant-state/` contains only placeholder files (`.gitkeep`, `README.md`).
|
||||
- [ ] `prod-as-built.md` has been deleted.
|
||||
- [ ] All markdown documentation uses generic examples (`contoso.onmicrosoft.com`, `astral-backup@contoso.com`, `sc-astral-backup`).
|
||||
|
||||
@@ -130,6 +130,64 @@ After importing `azure-pipelines-restore.yml`, find its definition ID:
|
||||
2. Set `forceFullRun=true` to get a complete initial snapshot.
|
||||
3. Verify that `tenant-state/` is populated and a rolling PR is created.
|
||||
|
||||
## Step 11: Provision the event-driven change probe (optional but recommended)
|
||||
|
||||
The change probe replaces the previous hourly polling model with responsive, event-driven backup triggers.
|
||||
|
||||
### Option A: Automated provisioning
|
||||
|
||||
Run the unified provisioning script:
|
||||
|
||||
```powershell
|
||||
.\deploy\provision-change-probe.ps1 `
|
||||
-TenantName "contoso.onmicrosoft.com" `
|
||||
-ResourceGroupName "rg-astral-probe" `
|
||||
-Location "westeurope" `
|
||||
-DeployFunctionApp
|
||||
```
|
||||
|
||||
The script will create an Entra app, grant admin consent, provision Azure resources, and deploy the Function App.
|
||||
|
||||
### Option B: Manual provisioning
|
||||
|
||||
If you prefer manual setup:
|
||||
|
||||
1. **Create an app registration** in Entra ID for the probe.
|
||||
2. **Grant admin consent** for:
|
||||
- `DeviceManagementConfiguration.Read.All`
|
||||
- `DeviceManagementApps.Read.All`
|
||||
- `AuditLog.Read.All`
|
||||
- `Directory.Read.All`
|
||||
3. **Create a client secret** and note the value.
|
||||
4. **Provision Azure resources**:
|
||||
- Resource Group
|
||||
- Storage Account (Standard LRS)
|
||||
- Function App (Linux Consumption, Python 3.11)
|
||||
5. **Configure Function App settings**:
|
||||
| Setting | Value |
|
||||
|---|---|
|
||||
| `AzureWebJobsStorage` | Storage account connection string |
|
||||
| `PROBE_APP_ID` | App registration client ID |
|
||||
| `PROBE_APP_SECRET` | App registration client secret |
|
||||
| `TENANT_ID` | Your Microsoft 365 tenant ID |
|
||||
| `ADO_ORGANIZATION` | Your Azure DevOps org name |
|
||||
| `ADO_PROJECT` | Your Azure DevOps project name |
|
||||
| `ADO_PIPELINE_ID` | Definition ID of `azure-pipelines.yml` |
|
||||
| `ADO_TOKEN` | Azure DevOps PAT with **Build (read & execute)** |
|
||||
| `ADO_BRANCH` | `main` (or your baseline branch) |
|
||||
6. **Deploy the function package** using `WEBSITE_RUN_FROM_PACKAGE` (see `infra/change-probe/README.md`).
|
||||
|
||||
### Verify the probe
|
||||
|
||||
1. Make a test change in Intune (e.g., create a temporary device configuration profile).
|
||||
2. Wait 5–20 minutes for the audit log to propagate.
|
||||
3. Check the `ProbeState` table in your Storage Account — the `singleton/default` entity should show `debouncer.state = armed`.
|
||||
4. After the quiet window (default 15 min) elapses, a queue message will be emitted.
|
||||
5. The `queue_consumer` will dequeue it and queue the backup pipeline.
|
||||
6. Verify the pipeline run appears in Azure DevOps with reason `manual` (API-triggered runs show as manual).
|
||||
|
||||
> **Note:** The probe uses the same Entra app as the main backup pipeline. You can reuse the app registration created by `bootstrap-tenant.ps1` if you add the `AuditLog.Read.All` permission and create a client secret for it.
|
||||
|
||||
## Optional: progressive feature rollout
|
||||
|
||||
| Phase | What to enable |
|
||||
|
||||
578
deploy/provision-change-probe.ps1
Executable file
578
deploy/provision-change-probe.ps1
Executable file
@@ -0,0 +1,578 @@
|
||||
#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"
|
||||
Reference in New Issue
Block a user