|
| 1 | +<# |
| 2 | +.SYNOPSIS |
| 3 | + Deploys the Azure-native BitLockerKeyMonitor stack from infra/main.bicep, |
| 4 | + using pure az CLI (no dependency on azd). |
| 5 | +
|
| 6 | +.DESCRIPTION |
| 7 | + This script is the Azure counterpart of Deploy-Remote.ps1 (which targets |
| 8 | + the on-prem Windows Server topology). |
| 9 | +
|
| 10 | + It performs: |
| 11 | + 1. Prerequisite check - az CLI, bicep, dotnet SDK |
| 12 | + 2. Authentication check - reuses existing az login |
| 13 | + 3. Parameter resolution - signed-in user principalId, etc. |
| 14 | + 4. Bicep build - lints + compiles infra/main.bicep |
| 15 | + 5. What-if preview - shows planned changes (skippable) |
| 16 | + 6. Subscription-scope deployment - az deployment sub create |
| 17 | + 7. Post-provision - prints outputs, hints for the SQL secret |
| 18 | + 8. (optional) App publish - publishes Functions + Web.Azure zip and |
| 19 | + pushes them via az functionapp/webapp deploy |
| 20 | +
|
| 21 | + Idempotent: running it again on the same EnvironmentName updates the |
| 22 | + existing resources rather than recreating them. |
| 23 | +
|
| 24 | +.PARAMETER SubscriptionId |
| 25 | + Azure subscription. Defaults to the currently selected one. |
| 26 | +
|
| 27 | +.PARAMETER Location |
| 28 | + Azure region (default: italynorth). |
| 29 | +
|
| 30 | +.PARAMETER EnvironmentName |
| 31 | + Short name used as the azd-env-name tag and in resource names (default: dev). |
| 32 | +
|
| 33 | +.PARAMETER SqlAdminPrincipalId |
| 34 | + Object ID of the Entra ID principal (user or group) to set as SQL AAD admin. |
| 35 | + Defaults to the signed-in user. |
| 36 | +
|
| 37 | +.PARAMETER SqlAdminLoginName |
| 38 | + Display name for the SQL AAD admin (default: signed-in user UPN). |
| 39 | +
|
| 40 | +.PARAMETER SkipPreview |
| 41 | + Skip the what-if preview step (useful in CI). |
| 42 | +
|
| 43 | +.PARAMETER SkipDeploy |
| 44 | + Only run validation + preview, do not actually deploy. |
| 45 | +
|
| 46 | +.PARAMETER PublishApps |
| 47 | + After provisioning, publish + deploy the Functions and Web.Azure apps. |
| 48 | +
|
| 49 | +.PARAMETER PopulateSqlSecret |
| 50 | + After provisioning, prompt for the SQL connection string and write it |
| 51 | + to the Key Vault secret 'sql-connection-string'. |
| 52 | +
|
| 53 | +.EXAMPLE |
| 54 | + # Validate only (no changes to Azure) |
| 55 | + .\Deploy-Azure.ps1 -SkipDeploy |
| 56 | +
|
| 57 | +.EXAMPLE |
| 58 | + # Full deploy of infra + apps to dev environment |
| 59 | + .\Deploy-Azure.ps1 -EnvironmentName dev -PublishApps -PopulateSqlSecret |
| 60 | +
|
| 61 | +.EXAMPLE |
| 62 | + # Production deploy to West Europe with a security group as SQL admin |
| 63 | + .\Deploy-Azure.ps1 -EnvironmentName prod -Location westeurope ` |
| 64 | + -SqlAdminPrincipalId 11111111-2222-3333-4444-555555555555 ` |
| 65 | + -SqlAdminLoginName "BitLockerMonitor-SqlAdmins" ` |
| 66 | + -PublishApps |
| 67 | +#> |
| 68 | +[CmdletBinding()] |
| 69 | +param( |
| 70 | + [string]$SubscriptionId, |
| 71 | + [string]$Location = 'italynorth', |
| 72 | + [ValidatePattern('^[a-z0-9-]{1,20}$')] |
| 73 | + [string]$EnvironmentName = 'dev', |
| 74 | + [string]$SqlAdminPrincipalId, |
| 75 | + [string]$SqlAdminLoginName, |
| 76 | + [switch]$SkipPreview, |
| 77 | + [switch]$SkipDeploy, |
| 78 | + [switch]$PublishApps, |
| 79 | + [switch]$PopulateSqlSecret |
| 80 | +) |
| 81 | + |
| 82 | +$ErrorActionPreference = 'Stop' |
| 83 | +$script:RepoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path |
| 84 | +$script:BicepEntry = Join-Path $RepoRoot 'infra\main.bicep' |
| 85 | +$script:DeploymentName = "blkmon-$EnvironmentName-$(Get-Date -Format 'yyyyMMddHHmmss')" |
| 86 | + |
| 87 | +function Write-Step($msg) { |
| 88 | + Write-Host "" |
| 89 | + Write-Host "==> $msg" -ForegroundColor Cyan |
| 90 | +} |
| 91 | + |
| 92 | +function Assert-Command($name, $hint) { |
| 93 | + if (-not (Get-Command $name -ErrorAction SilentlyContinue)) { |
| 94 | + throw "Missing prerequisite: $name. $hint" |
| 95 | + } |
| 96 | +} |
| 97 | + |
| 98 | +# --- 1. Prerequisite check ---------------------------------------------------- |
| 99 | + |
| 100 | +Write-Step "1/8 Checking prerequisites" |
| 101 | +Assert-Command 'az' "Install Azure CLI: https://aka.ms/installazurecli" |
| 102 | +Assert-Command 'dotnet' "Install .NET 10 SDK: https://dot.net" |
| 103 | + |
| 104 | +$azVer = (az version --output json | ConvertFrom-Json).'azure-cli' |
| 105 | +Write-Host " az CLI : $azVer" |
| 106 | +Write-Host " .NET SDK : $(dotnet --version)" |
| 107 | + |
| 108 | +# Ensure bicep is available (az auto-installs on first use, but pre-flight here) |
| 109 | +az bicep version --only-show-errors 2>$null | Out-Null |
| 110 | +if ($LASTEXITCODE -ne 0) { |
| 111 | + Write-Host " Installing Bicep..." |
| 112 | + az bicep install --only-show-errors | Out-Null |
| 113 | +} |
| 114 | +$bicepVer = az bicep version --only-show-errors 2>&1 | Select-Object -First 1 |
| 115 | +Write-Host " Bicep : $bicepVer" |
| 116 | + |
| 117 | +if (-not (Test-Path $BicepEntry)) { |
| 118 | + throw "Bicep entry point not found: $BicepEntry" |
| 119 | +} |
| 120 | + |
| 121 | +# --- 2. Authentication -------------------------------------------------------- |
| 122 | + |
| 123 | +Write-Step "2/8 Verifying Azure authentication" |
| 124 | +$account = az account show --output json 2>$null | ConvertFrom-Json |
| 125 | +if (-not $account) { |
| 126 | + throw "Not logged in to az CLI. Run: az login" |
| 127 | +} |
| 128 | + |
| 129 | +if ($SubscriptionId) { |
| 130 | + az account set --subscription $SubscriptionId | Out-Null |
| 131 | + $account = az account show --output json | ConvertFrom-Json |
| 132 | +} |
| 133 | +$SubscriptionId = $account.id |
| 134 | + |
| 135 | +Write-Host " Tenant : $($account.tenantId)" |
| 136 | +Write-Host " Subscription : $($account.name) ($SubscriptionId)" |
| 137 | +Write-Host " Signed in as : $($account.user.name)" |
| 138 | + |
| 139 | +# --- 3. Parameter resolution -------------------------------------------------- |
| 140 | + |
| 141 | +Write-Step "3/8 Resolving deployment parameters" |
| 142 | +$principalId = az ad signed-in-user show --query id -o tsv |
| 143 | +$principalUpn = $account.user.name |
| 144 | + |
| 145 | +if (-not $SqlAdminPrincipalId) { $SqlAdminPrincipalId = $principalId } |
| 146 | +if (-not $SqlAdminLoginName) { $SqlAdminLoginName = $principalUpn } |
| 147 | + |
| 148 | +$paramObj = @{ |
| 149 | + '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#' |
| 150 | + contentVersion = '1.0.0.0' |
| 151 | + parameters = @{ |
| 152 | + environmentName = @{ value = $EnvironmentName } |
| 153 | + location = @{ value = $Location } |
| 154 | + principalId = @{ value = $principalId } |
| 155 | + tenantId = @{ value = $account.tenantId } |
| 156 | + sqlAdminPrincipalId = @{ value = $SqlAdminPrincipalId } |
| 157 | + sqlAdminLoginName = @{ value = $SqlAdminLoginName } |
| 158 | + } |
| 159 | +} |
| 160 | +$paramFile = Join-Path $env:TEMP "blkmon-params-$EnvironmentName.json" |
| 161 | +$paramObj | ConvertTo-Json -Depth 10 | Set-Content -Path $paramFile -Encoding utf8 |
| 162 | + |
| 163 | +Write-Host " environmentName = $EnvironmentName" |
| 164 | +Write-Host " location = $Location" |
| 165 | +Write-Host " principalId = $principalId" |
| 166 | +Write-Host " sqlAdminPrincipalId = $SqlAdminPrincipalId" |
| 167 | +Write-Host " sqlAdminLoginName = $SqlAdminLoginName" |
| 168 | +Write-Host " parameters file = $paramFile" |
| 169 | + |
| 170 | +# --- 4. Bicep build (lint + compile) ----------------------------------------- |
| 171 | + |
| 172 | +Write-Step "4/8 Building Bicep (lint + compile)" |
| 173 | +$bicepOut = Join-Path $env:TEMP "blkmon-bicep-$EnvironmentName" |
| 174 | +if (-not (Test-Path $bicepOut)) { New-Item -ItemType Directory -Path $bicepOut | Out-Null } |
| 175 | +az bicep build --file $BicepEntry --outdir $bicepOut |
| 176 | +if ($LASTEXITCODE -ne 0) { throw "Bicep build failed." } |
| 177 | +Write-Host " OK -> $bicepOut\main.json" |
| 178 | + |
| 179 | +# --- 5. What-if preview ------------------------------------------------------ |
| 180 | + |
| 181 | +if (-not $SkipPreview) { |
| 182 | + Write-Step "5/8 What-if preview (no changes applied yet)" |
| 183 | + az deployment sub what-if ` |
| 184 | + --name $DeploymentName ` |
| 185 | + --location $Location ` |
| 186 | + --template-file $BicepEntry ` |
| 187 | + --parameters "@$paramFile" |
| 188 | + if ($LASTEXITCODE -ne 0) { throw "What-if preview failed." } |
| 189 | +} |
| 190 | +else { |
| 191 | + Write-Step "5/8 What-if preview SKIPPED (-SkipPreview)" |
| 192 | +} |
| 193 | + |
| 194 | +if ($SkipDeploy) { |
| 195 | + Write-Host "" |
| 196 | + Write-Host "SkipDeploy specified. Stopping after validation + preview." -ForegroundColor Yellow |
| 197 | + return |
| 198 | +} |
| 199 | + |
| 200 | +# --- 6. Subscription-scope deployment ---------------------------------------- |
| 201 | + |
| 202 | +Write-Step "6/8 Deploying (az deployment sub create)" |
| 203 | +Write-Host " Deployment name: $DeploymentName" |
| 204 | +$deploy = az deployment sub create ` |
| 205 | + --name $DeploymentName ` |
| 206 | + --location $Location ` |
| 207 | + --template-file $BicepEntry ` |
| 208 | + --parameters "@$paramFile" ` |
| 209 | + --output json | ConvertFrom-Json |
| 210 | + |
| 211 | +if ($LASTEXITCODE -ne 0 -or $deploy.properties.provisioningState -ne 'Succeeded') { |
| 212 | + throw "Deployment failed (state: $($deploy.properties.provisioningState))." |
| 213 | +} |
| 214 | + |
| 215 | +Write-Host " Deployment state: $($deploy.properties.provisioningState)" |
| 216 | + |
| 217 | +$outputs = $deploy.properties.outputs |
| 218 | +$script:Outputs = @{} |
| 219 | +foreach ($prop in $outputs.PSObject.Properties) { |
| 220 | + $script:Outputs[$prop.Name] = $prop.Value.value |
| 221 | +} |
| 222 | + |
| 223 | +Write-Host "" |
| 224 | +Write-Host "Deployment outputs:" -ForegroundColor Green |
| 225 | +$script:Outputs.GetEnumerator() | Sort-Object Name | ForEach-Object { |
| 226 | + Write-Host (" {0,-32} = {1}" -f $_.Key, $_.Value) |
| 227 | +} |
| 228 | + |
| 229 | +# --- 7. Optional: populate SQL connection string in Key Vault ---------------- |
| 230 | + |
| 231 | +Write-Step "7/8 SQL connection string secret" |
| 232 | +if ($PopulateSqlSecret) { |
| 233 | + $kvName = $script:Outputs['AZURE_KEY_VAULT_NAME'] |
| 234 | + $sqlFqdn = $script:Outputs['AZURE_SQL_SERVER_FQDN'] |
| 235 | + $sqlDb = $script:Outputs['AZURE_SQL_DATABASE_NAME'] |
| 236 | + |
| 237 | + $defaultCs = "Server=tcp:$sqlFqdn,1433;Database=$sqlDb;Authentication=Active Directory Default;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" |
| 238 | + Write-Host " Suggested connection string (using Active Directory Default auth, picks up the managed identity automatically):" |
| 239 | + Write-Host " $defaultCs" -ForegroundColor DarkGray |
| 240 | + $cs = Read-Host " Press Enter to accept, or paste an override" |
| 241 | + if ([string]::IsNullOrWhiteSpace($cs)) { $cs = $defaultCs } |
| 242 | + |
| 243 | + az keyvault secret set --vault-name $kvName --name 'sql-connection-string' --value $cs --output none |
| 244 | + if ($LASTEXITCODE -ne 0) { throw "Failed to write sql-connection-string to Key Vault." } |
| 245 | + Write-Host " OK -> kv://$kvName/secrets/sql-connection-string" |
| 246 | +} |
| 247 | +else { |
| 248 | + $kvName = $script:Outputs['AZURE_KEY_VAULT_NAME'] |
| 249 | + Write-Host " SKIPPED. To populate later run:" -ForegroundColor Yellow |
| 250 | + Write-Host " az keyvault secret set --vault-name $kvName --name sql-connection-string --value '<connection-string>'" |
| 251 | +} |
| 252 | + |
| 253 | +# --- 8. Optional: publish + deploy the apps --------------------------------- |
| 254 | + |
| 255 | +Write-Step "8/8 Application code deploy" |
| 256 | +if ($PublishApps) { |
| 257 | + $funcAppHost = $script:Outputs['FUNCTIONS_URI'] |
| 258 | + $webAppHost = $script:Outputs['WEB_URI'] -replace '^https://', '' |
| 259 | + $funcAppName = ($funcAppHost -split '\.')[0] |
| 260 | + $webAppName = ($webAppHost -split '\.')[0] |
| 261 | + |
| 262 | + $stagingRoot = Join-Path $env:TEMP "blkmon-publish-$EnvironmentName" |
| 263 | + if (Test-Path $stagingRoot) { Remove-Item $stagingRoot -Recurse -Force } |
| 264 | + New-Item -ItemType Directory -Path $stagingRoot | Out-Null |
| 265 | + |
| 266 | + # --- Functions --- |
| 267 | + $funcOut = Join-Path $stagingRoot 'func' |
| 268 | + Write-Host " Publishing Functions -> $funcOut" |
| 269 | + dotnet publish (Join-Path $RepoRoot 'src\BitLockerKeyMonitor.Functions\BitLockerKeyMonitor.Functions.csproj') ` |
| 270 | + -c Release -o $funcOut --nologo -v minimal |
| 271 | + if ($LASTEXITCODE -ne 0) { throw "dotnet publish (Functions) failed." } |
| 272 | + $funcZip = Join-Path $stagingRoot 'func.zip' |
| 273 | + Compress-Archive -Path "$funcOut\*" -DestinationPath $funcZip -Force |
| 274 | + Write-Host " Deploying Functions -> $funcAppName" |
| 275 | + az functionapp deployment source config-zip --resource-group $script:Outputs['AZURE_RESOURCE_GROUP'] ` |
| 276 | + --name $funcAppName --src $funcZip --output none |
| 277 | + if ($LASTEXITCODE -ne 0) { throw "Function App zip deploy failed." } |
| 278 | + |
| 279 | + # --- Web.Azure --- |
| 280 | + $webOut = Join-Path $stagingRoot 'web' |
| 281 | + Write-Host " Publishing Web.Azure -> $webOut" |
| 282 | + dotnet publish (Join-Path $RepoRoot 'src\BitLockerKeyMonitor.Web.Azure\BitLockerKeyMonitor.Web.Azure.csproj') ` |
| 283 | + -c Release -o $webOut --nologo -v minimal |
| 284 | + if ($LASTEXITCODE -ne 0) { throw "dotnet publish (Web.Azure) failed." } |
| 285 | + $webZip = Join-Path $stagingRoot 'web.zip' |
| 286 | + Compress-Archive -Path "$webOut\*" -DestinationPath $webZip -Force |
| 287 | + Write-Host " Deploying Web.Azure -> $webAppName" |
| 288 | + az webapp deploy --resource-group $script:Outputs['AZURE_RESOURCE_GROUP'] ` |
| 289 | + --name $webAppName --src-path $webZip --type zip --output none |
| 290 | + if ($LASTEXITCODE -ne 0) { throw "Web App zip deploy failed." } |
| 291 | + |
| 292 | + Write-Host "" |
| 293 | + Write-Host " Apps deployed." |
| 294 | + Write-Host " Web : $($script:Outputs['WEB_URI'])" |
| 295 | + Write-Host " Functions: https://$funcAppHost" |
| 296 | +} |
| 297 | +else { |
| 298 | + Write-Host " SKIPPED. To publish app code later run this script with -PublishApps" -ForegroundColor Yellow |
| 299 | +} |
| 300 | + |
| 301 | +Write-Host "" |
| 302 | +Write-Host "Done. Subscription: $($account.name)" -ForegroundColor Green |
| 303 | +Write-Host "Resource group : $($script:Outputs['AZURE_RESOURCE_GROUP'])" |
| 304 | +Write-Host "Key Vault : $($script:Outputs['AZURE_KEY_VAULT_NAME'])" |
| 305 | +Write-Host "App Configuration: $($script:Outputs['AZURE_APP_CONFIG_ENDPOINT'])" |
| 306 | +Write-Host "Web URL : $($script:Outputs['WEB_URI'])" |
| 307 | +Write-Host "Functions URL : $($script:Outputs['FUNCTIONS_URI'])" |
0 commit comments