Skip to content

Commit 834b57e

Browse files
robgrameCopilot
andcommitted
feat(azure): add Deploy-Azure.ps1 + AZURE-DEPLOY.md (pure az CLI, no azd)
Standalone PowerShell deployer that mirrors Deploy-Remote.ps1 (on-prem) for the Azure stack. Deploy-Azure.ps1 - 8 steps end-to-end: 1. Prerequisite check (az, dotnet, bicep auto-install) 2. Authentication (reuses existing az login session) 3. Parameter resolution (signed-in user principalId + UPN) 4. Bicep build (lint + compile to ARM JSON) 5. What-if preview (az deployment sub what-if) 6. Subscription-scope deployment (az deployment sub create) 7. Optional Key Vault secret population (-PopulateSqlSecret) 8. Optional app code zip-deploy (-PublishApps) Flags: -SkipPreview, -SkipDeploy, -PublishApps, -PopulateSqlSecret. Idempotent. Independent from azd CLI installation. docs/AZURE-DEPLOY.md - operator guide: - Prerequisites + required permissions (Owner or Contributor + User Access Admin) - Quick start examples - Per-parameter reference table - 8-step explanation - Output reference + idempotency notes - Teardown procedure (group delete + KV/AppConfig purge) - Comparison vs azd up - Troubleshooting table (5 common errors) .azure/plan.md - Section 7 Validation Proof added, Status -> Validated. Side effects: dotnet publish auto-added UserSecretsId to two csproj (harmless). Verified: Deploy-Azure.ps1 -SkipDeploy run end-to-end: - prerequisites OK (az 2.77.0, .NET 10.0.300, Bicep 0.41.2) - bicep build clean - what-if: 33 resources to Create, 10 role assignments as 'unsupported' (expected: MI principalId resolved at runtime) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c0f1830 commit 834b57e

5 files changed

Lines changed: 456 additions & 2 deletions

File tree

.azure/plan.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Azure Deployment Plan — BitLocker Key Monitor (Azure-native rewrite)
22

3-
> **Status:** Ready for Validation (Phase 2 complete — solution scaffolded, Bicep + azure.yaml authored, App Configuration & Key Vault wired)
3+
> **Status:** Validated (Phase 2 + validation complete — ready for `azure-deploy`)
44
> **Branch:** `feature/azure-native`
55
> **Generated:** 2026-06-08
66
> **Source repo:** `robgrame/BitLockerKeyMonitor`
@@ -469,6 +469,30 @@ Production sizing would bump SQL to GP_S_Gen5_4 (or GP) and Web to S1/P0v3, rais
469469

470470
---
471471

472+
## Section 7: Validation Proof
473+
474+
Validation run on **2026-06-08** by the `azure-validate` skill against environment `blkmon-dev` (subscription `b45c5b53-d8f3-4a4c-9fe5-5537818a9886`, location `italynorth`).
475+
476+
| # | Command | Result |
477+
|---|---------|--------|
478+
| 1 | `azd version` | 1.25.5 (commit 5516392) ✅ |
479+
| 2 | `azd config set auth.useAzCliAuth true` + `azd auth login --check-status` | Logged in as `doc@mslabs.it`|
480+
| 3 | `azd env new blkmon-dev --subscription b45c5b53-... --location italynorth` | Env created and set as default ✅ |
481+
| 4 | `azd env set AZURE_PRINCIPAL_ID 77efa9e7-bb8d-4770-9c1d-30c87603ee8a` | Set ✅ |
482+
| 5 | `azd env set AZURE_SQL_ADMIN_PRINCIPAL_ID 77efa9e7-...` + `AZURE_SQL_ADMIN_LOGIN_NAME doc@mslabs.it` | Set ✅ |
483+
| 6 | `az bicep build --file infra/main.bicep` | 0 errors / 0 warnings (after fixing utcNow scope + rotation policy type casing) ✅ |
484+
| 7 | `azd provision --preview --no-prompt` | Generated provisioning preview in 1m23s; 11 top-level resources to Create (RG, App Insights, KV, Log Analytics, SB, SQL Server, Storage, 2 App Service Plans, 2 Web Apps for func + web). App Configuration, queues, SQL DB, MIs, role assignments included as child/dependent resources. ✅ |
485+
| 8 | `dotnet build BitLockerKeyMonitor.Azure.slnx -c Release` | 0 warn / 0 err ✅ |
486+
| 9 | `dotnet build BitLockerKeyMonitor.slnx` (regression) | 0 warn / 0 err ✅ |
487+
| 10 | `dotnet test BitLockerKeyMonitor.slnx` | 50/50 passed ✅ |
488+
| 11 | `azd package --no-prompt` | func + web packaged successfully (zip artifacts in temp) ✅ |
489+
490+
### Known caveats (not blockers for infra smoke test)
491+
492+
- `Functions/Program.cs` and `Web.Azure/Program.cs` are still template defaults — no App Configuration bootstrap, no Service Bus triggers, no Blazor pages beyond the template scaffold. Deploy will produce **empty** but reachable app endpoints. This is intentional for the first round: it validates Bicep + identity + RBAC wiring before we write application code.
493+
- The `sql-connection-string` Key Vault secret is created with placeholder value `PLACEHOLDER_SET_VIA_POSTPROVISION_HOOK`. Until populated, App Configuration's KV-reference for `blkmon:ConnectionStrings:SqlDb$blkmon-dev` will resolve to that placeholder. Post-provision hook in `azure.yaml` reminds the operator.
494+
- SQL AAD admin is set to the deployer principal (`doc@mslabs.it`) instead of a security group — adequate for dev, should be moved to a group before any prod environment.
495+
472496
## 13. Two-Solution Topology & Feature Parity
473497

474498
### Why two solutions

Deploy-Azure.ps1

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
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

Comments
 (0)