scripts/helpers/common.ps1
|
<# .SYNOPSIS Shared utility functions for the Azure Demo Environment (ADE) project. Sourced by deploy.ps1, destroy.ps1, and runbooks. #> Set-StrictMode -Version Latest # ─── Logging ───────────────────────────────────────────────────────────────── # Set this variable to an absolute path to mirror all log output to a file. $script:AdeLogFile = $null # Set to $true to enable Debug-level console output (wired to -Verbose in all entry scripts). $script:AdeVerbose = $false enum AdeLogLevel { Info Success Warning Error Step Debug } function Write-AdeLog { param( [Parameter(Mandatory)][string]$Message, [AdeLogLevel]$Level = [AdeLogLevel]::Info, [switch]$NoNewline ) $timestamp = Get-Date -Format 'HH:mm:ss' $color, $prefix = switch ($Level) { ([AdeLogLevel]::Info) { 'Cyan', 'INFO ' } ([AdeLogLevel]::Success) { 'Green', 'OK ' } ([AdeLogLevel]::Warning) { 'Yellow', 'WARN ' } ([AdeLogLevel]::Error) { 'Red', 'ERR ' } ([AdeLogLevel]::Step) { 'Magenta', 'STEP ' } ([AdeLogLevel]::Debug) { 'Gray', 'DBG ' } } # Debug messages are suppressed from the console unless -Verbose was passed. # They are always written to the log file when one is configured. if ($Level -eq [AdeLogLevel]::Debug -and -not $script:AdeVerbose) { if ($script:AdeLogFile) { $plain = "[$timestamp] [$prefix] $Message" if ($NoNewline) { [System.IO.File]::AppendAllText($script:AdeLogFile, $plain) } else { Add-Content -LiteralPath $script:AdeLogFile -Value $plain } } return } $plain = "[$timestamp] [$prefix] $Message" if ($NoNewline) { Write-Host -NoNewline "[$timestamp] " Write-Host -NoNewline "[$prefix] $Message" -ForegroundColor $color } else { Write-Host -NoNewline "[$timestamp] " Write-Host "[$prefix] $Message" -ForegroundColor $color } if ($script:AdeLogFile) { if ($NoNewline) { [System.IO.File]::AppendAllText($script:AdeLogFile, $plain) } else { Add-Content -LiteralPath $script:AdeLogFile -Value $plain } } } function Write-AdeSection { param([Parameter(Mandatory)][string]$Title) $line = '─' * 60 Write-Host "" Write-Host $line -ForegroundColor DarkGray Write-Host " $Title" -ForegroundColor White Write-Host $line -ForegroundColor DarkGray Write-Host "" if ($script:AdeLogFile) { Add-Content -LiteralPath $script:AdeLogFile -Value '' Add-Content -LiteralPath $script:AdeLogFile -Value $line Add-Content -LiteralPath $script:AdeLogFile -Value " $Title" Add-Content -LiteralPath $script:AdeLogFile -Value $line Add-Content -LiteralPath $script:AdeLogFile -Value '' } } # ─── Azure CLI wrapper ──────────────────────────────────────────────────────── function Invoke-AzCmd { <# .SYNOPSIS Executes an az CLI command and returns parsed output. Throws a terminating error on non-zero exit code with the stderr message. .PARAMETER Arguments The az command arguments as a single string (everything after 'az'). Split on whitespace with NO quote handling — a quoted value like --name "my rg" becomes three tokens, not two. Values containing quotes therefore throw: use ArgumentList for anything with spaces. .PARAMETER ArgumentList The az command arguments as a pre-split array. Eliminates whitespace-split bugs when template paths, resource group names, or parameter values contain spaces. .PARAMETER Silent Suppress the command echo. #> param( [string]$Arguments = '', [string[]]$ArgumentList, [switch]$Silent, [switch]$AllowFailure ) # The string form splits on whitespace and cannot honor quoting. Failing loudly # here beats the silent alternative: a quoted value like --name "my rg" would # otherwise be split into broken tokens and produce an opaque az error. if (-not $ArgumentList -and $Arguments -match '["'']') { throw ("Invoke-AzCmd -Arguments splits on whitespace and does not honor quotes " + "(got: $Arguments). Use -ArgumentList to pass values containing spaces.") } $cmdArgs = if ($ArgumentList) { $ArgumentList } else { $Arguments -split '\s+' | Where-Object { $_ } } $displayCmd = if ($ArgumentList) { $ArgumentList -join ' ' } else { $Arguments } if (-not $Silent) { Write-AdeLog "az $displayCmd" -Level Debug } $output = az $cmdArgs 2>&1 $exitCode = $LASTEXITCODE if ($exitCode -ne 0 -and -not $AllowFailure) { $errMsg = ($output | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) -join ' ' if (-not $errMsg) { $errMsg = $output -join ' ' } throw "az command failed (exit $exitCode): az $displayCmd`n$errMsg" } # Return stdout lines only (filter out ErrorRecord objects) $stdout = $output | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] } if ($stdout) { $joined = $stdout -join "`n" try { return $joined | ConvertFrom-Json -Depth 20 } catch { return $stdout } } return $null } # ─── Profile loading ────────────────────────────────────────────────────────── function Get-AdeProfile { <# .SYNOPSIS Loads and returns a deployment profile. Accepts a profile name (e.g. 'full') or an absolute/relative path to a custom JSON file. #> param([Parameter(Mandatory)][string]$ProfileNameOrPath) $builtIn = Join-Path -Path $PSScriptRoot -ChildPath '..\..\config\profiles' -AdditionalChildPath "$ProfileNameOrPath.json" $profilePath = if (Test-Path $ProfileNameOrPath -PathType Leaf) { $ProfileNameOrPath } elseif (Test-Path $builtIn -PathType Leaf) { $builtIn } else { throw "Profile '$ProfileNameOrPath' not found. Built-in profiles: full, minimal, compute-only, databases-only, networking-only, security-focus" } Write-AdeLog "Loading profile: $(Resolve-Path $profilePath)" -Level Info $adeProfile = Get-Content $profilePath -Raw | ConvertFrom-Json -Depth 20 # Inject globalSettings.tags from defaults.json so Build-AdeTags always has data $tagsProp = $adeProfile.PSObject.Properties['tags'] if (-not $tagsProp -or $null -eq $tagsProp.Value) { $defaultsPath = Join-Path $PSScriptRoot '..\..\config\defaults.json' if (Test-Path $defaultsPath) { $defaults = Get-Content $defaultsPath -Raw | ConvertFrom-Json -Depth 20 $adeProfile | Add-Member -NotePropertyName 'tags' -NotePropertyValue $defaults.globalSettings.tags -Force } } return $adeProfile } # ─── Module dependency order ────────────────────────────────────────────────── function Get-AdeDeploymentOrder { <# .SYNOPSIS Returns the ordered list of modules that are enabled in the given profile. Order is fixed to satisfy dependencies: monitoring -> networking -> security -> compute -> storage -> databases -> appservices -> containers -> governance #> param([Parameter(Mandatory)][psobject]$Profile) $order = @('monitoring', 'networking', 'security', 'compute', 'storage', 'databases', 'appservices', 'containers', 'integration', 'ai', 'data', 'governance') $enabled = [System.Collections.Generic.List[string]]::new() foreach ($module in $order) { if (-not $Profile.modules.PSObject.Properties[$module]) { continue } $moduleConfig = $Profile.modules.$module if ($moduleConfig -and $moduleConfig.enabled -eq $true) { $enabled.Add($module) } } return $enabled } # ─── Resource group helpers ─────────────────────────────────────────────────── function New-AdeResourceGroup { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Location, [Parameter(Mandatory)][hashtable]$Tags ) # Build tag list, filtering out null/empty values $tagArgs = @($Tags.GetEnumerator() | Where-Object { $null -ne $_.Value -and $_.Value -ne '' } | ForEach-Object { "$($_.Key)=$($_.Value)" }) Write-AdeLog "Ensuring resource group: $Name" -Level Info # In WhatIf mode do not create or modify any real infrastructure. if ([bool]$WhatIfPreference) { Write-AdeLog "What if: would ensure resource group '$Name' in '$Location'" -Level Info return } # Check whether the RG already exists so we can detect a location conflict # before `az group create` emits an opaque API error. $existing = az group show --name $Name --output json 2>$null if ($LASTEXITCODE -eq 0 -and $existing) { $existingLocation = ($existing | ConvertFrom-Json).location if ($existingLocation -ne $Location) { # deploy.ps1 auto-detects and corrects $Location before reaching here. # This warning fires only if New-AdeResourceGroup is called directly # with a mismatched location (e.g. from a custom script). Write-AdeLog "Resource group '$Name' exists in '$existingLocation'; ignoring requested location '$Location' and reusing '$existingLocation'." -Level Warning $Location = $existingLocation } Write-AdeLog "Resource group already exists: $Name ($existingLocation)" -Level Info # Update tags on the existing RG without touching its location. az group update --name $Name --tags @tagArgs --output none if ($LASTEXITCODE -ne 0) { Write-AdeLog "Could not update tags on $Name (non-fatal)." -Level Warning } return } az group create ` --name $Name ` --location $Location ` --tags @tagArgs ` --output none if ($LASTEXITCODE -ne 0) { throw "Failed to create resource group: $Name" } Write-AdeLog "Resource group ready: $Name" -Level Success } function Remove-AdeResourceGroup { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)][string]$Name, [switch]$NoWait ) # Remove any resource locks first so deletion doesn't fail $locks = az lock list --resource-group $Name --query "[].id" -o tsv 2>$null foreach ($lockId in $locks) { if ($lockId -and $PSCmdlet.ShouldProcess($lockId, 'Remove resource lock')) { Write-AdeLog "Removing lock: $lockId" -Level Warning az lock delete --ids $lockId --output none } } if (-not $PSCmdlet.ShouldProcess($Name, 'Delete resource group')) { return } Write-AdeLog "Deleting resource group: $Name" -Level Warning if ($NoWait) { az group delete --name $Name --yes --no-wait --output none } else { az group delete --name $Name --yes --output none } if ($LASTEXITCODE -ne 0) { throw "az group delete failed for '$Name' (exit $LASTEXITCODE). The resource group may still exist." } } # ─── Bicep deployment helpers ───────────────────────────────────────────────── function Invoke-AdeBicepDeployment { [CmdletBinding(SupportsShouldProcess)] <# .SYNOPSIS Deploys a Bicep module to a specific resource group. Returns the outputs object. Streams per-resource progress to the console while the deployment runs. #> param( [Parameter(Mandatory)][string]$ResourceGroup, [Parameter(Mandatory)][string]$TemplatePath, [Parameter(Mandatory)][string]$DeploymentName, [hashtable]$Parameters = @{}, [int]$PollIntervalSeconds = 5 ) if (-not (Test-Path $TemplatePath)) { throw "Bicep template not found: $TemplatePath" } # Build parameter arguments. # Scalars stay inline as 'key=value'. Complex values (hashtables, arrays, psobjects) # are written to a temp ARM-format parameters file and referenced as '@file' to avoid # Windows command-line double-quote stripping when JSON is embedded in an argument string. $paramArgs = @() $tempParamsFile = $null $fileParams = @{} foreach ($key in $Parameters.Keys) { $val = $Parameters[$key] if ($val -is [hashtable] -or $val -is [System.Collections.Hashtable] -or $val -is [pscustomobject] -or $val -is [array] -or $val -is [System.Collections.ArrayList]) { $fileParams[$key] = @{ value = $val } } elseif ($val -is [string] -and ( $key -match '(?i)password|secret|apikey|accesskey' -or $val -match '[&|<>^"''`$!#%]')) { # Route credential parameters (key-name match) and strings containing # shell-unsafe characters through the JSON file. This prevents plaintext # secrets from appearing in the debug log or being misinterpreted by the # shell (e.g. & splits commands, $ expands variables). $fileParams[$key] = @{ value = $val } } else { $paramArgs += "$key=$val" } } if ($fileParams.Count -gt 0) { $tempParamsFile = New-AdeTempJsonPath -Prefix 'ade' -Purpose 'parameters' $json = $fileParams | ConvertTo-Json -Depth 20 # Use .NET WriteAllText rather than Set-Content so the write is not skipped # when $WhatIfPreference is active (Set-Content honours ShouldProcess). [System.IO.File]::WriteAllText($tempParamsFile, $json, [System.Text.UTF8Encoding]::new($false)) $paramArgs = @("@$tempParamsFile") + $paramArgs } try { # What-if: run synchronously and return immediately if ([bool]$WhatIfPreference) { # az deployment group what-if requires the RG to exist. In WhatIf mode # New-AdeResourceGroup skips creation, so the RG may not be present. # Fall back to a descriptive log message in that case. $null = Invoke-AzCmd -ArgumentList @('group', 'show', '--name', $ResourceGroup, '--output', 'none') -AllowFailure -Silent if ($LASTEXITCODE -ne 0) { Write-AdeLog "What if: would deploy '$([System.IO.Path]::GetFileName($TemplatePath))' to '$ResourceGroup' (resource group does not exist yet — would be created first)" -Level Info return $null } $argList = @( 'deployment', 'group', 'what-if', '--resource-group', $ResourceGroup, '--name', $DeploymentName, '--template-file', $TemplatePath, '--output', 'table' ) if ($paramArgs.Count -gt 0) { $argList += '--parameters'; $argList += $paramArgs } Invoke-AzCmd -ArgumentList $argList return $null } # Snapshot resources already in the RG before deployment so we can distinguish # newly created resources from those that were already present (updated/no-op). $preDeployNames = @{} $preList = Invoke-AzCmd -ArgumentList @( 'resource', 'list', '--resource-group', $ResourceGroup, '--query', '[].{name:name,type:type}', '--output', 'json' ) -Silent -AllowFailure if ($preList) { foreach ($r in @($preList)) { $preDeployNames["$($r.type)/$($r.name)"] = $true } } # Submit deployment asynchronously so we can stream per-resource progress $argList = @( 'deployment', 'group', 'create', '--resource-group', $ResourceGroup, '--name', $DeploymentName, '--template-file', $TemplatePath, '--output', 'none', '--no-wait' ) if ($paramArgs.Count -gt 0) { $argList += '--parameters'; $argList += $paramArgs } $null = Invoke-AzCmd -ArgumentList $argList # Stream per-resource progress by polling deployment operations via Invoke-AzCmd # so unit tests can mock all az calls through a single seam. $seenOps = @{} $loggedViaOps = @{} # type/name keys logged during polling; post-deploy skips duplicates $hasNewViaOps = $false $showableOps = @('Create', 'Delete', 'Deploy') $terminalStates = @('Succeeded', 'Failed', 'Canceled') $depState = 'Running' $showResult = $null $pollStart = [DateTime]::UtcNow $maxPollSeconds = 5400 # 90 min — the deploy job timeout-minutes: 120 is the hard cap do { # Back off as the deployment ages: 1x interval for the first 2 minutes, # 3x until 10 minutes, then 6x (default 5s -> 15s -> 30s). Long-running # modules (AKS, APIM, gateways) otherwise generate thousands of ARM # reads over a full deploy and risk 429 throttling. An explicit 0 # (unit tests) always stays 0. $pollElapsed = ([DateTime]::UtcNow - $pollStart).TotalSeconds $pollBackoff = if ($pollElapsed -gt 600) { 6 } elseif ($pollElapsed -gt 120) { 3 } else { 1 } Start-Sleep -Seconds ($PollIntervalSeconds * $pollBackoff) if (([DateTime]::UtcNow - $pollStart).TotalSeconds -gt $maxPollSeconds) { throw "Deployment '$DeploymentName' timed out after $([int]($maxPollSeconds / 60)) minutes of polling. The deployment may still be running in Azure — check the portal." } $ops = Invoke-AzCmd -ArgumentList @( 'deployment', 'group', 'operation', 'list', '--resource-group', $ResourceGroup, '--name', $DeploymentName, '--output', 'json' ) -Silent -AllowFailure if ($ops) { foreach ($op in $ops) { $opId = $op.operationId $state = $op.properties.provisioningState $opType = $op.properties.provisioningOperation $resName = $op.properties.targetResource.resourceName $resType = ($op.properties.targetResource.resourceType -split '/')[-1] # Only show meaningful resource operations, skip ARM internals if ($opType -notin $showableOps -or -not $resName) { continue } if (-not $seenOps.ContainsKey($opId)) { $seenOps[$opId] = $state } elseif ($seenOps[$opId] -ne $state) { $seenOps[$opId] = $state } else { continue # state unchanged — nothing to do } # Log on terminal state transition (real-time, with correct timestamp) $resKey = "$($op.properties.targetResource.resourceType)/$resName" if ($state -eq 'Succeeded') { $isNew = -not $preDeployNames.ContainsKey($resKey) $label = if ($isNew) { '[new]' } else { '[existing]' } $logLevel = if ($isNew) { 'Success' } else { 'Info' } Write-AdeLog " $resType '$resName' $label" -Level $logLevel $loggedViaOps[$resKey] = $true if ($isNew) { $hasNewViaOps = $true } } elseif ($state -in @('Failed', 'Canceled')) { Write-AdeLog " $resType '$resName' — $state" -Level Error } } } $showResult = Invoke-AzCmd -ArgumentList @( 'deployment', 'group', 'show', '--resource-group', $ResourceGroup, '--name', $DeploymentName, '--output', 'json' ) -Silent -AllowFailure if ($showResult -and $showResult.properties) { $depState = $showResult.properties.provisioningState } } while ($depState -notin $terminalStates) if ($depState -ne 'Succeeded') { $errMsg = if ($showResult -and $showResult.properties.error) { $err = $showResult.properties.error # Prefer the inner details messages — they contain the actual failure reason # (e.g. VaultAlreadyExists). Fall back to the outer message if no details exist. $detailMsgs = @($err.details | Where-Object { $_.message } | ForEach-Object { "$($_.code): $($_.message)" }) # If all detail messages are the generic ResourceDeploymentFailure wrapper, # the real reason is one level deeper in the deployment operations. $nonGenericDetails = @($detailMsgs | Where-Object { $_ -notmatch '^ResourceDeploymentFailure' }) $allGeneric = $detailMsgs.Count -gt 0 -and $nonGenericDetails.Count -eq 0 if ($allGeneric -or $detailMsgs.Count -eq 0) { $ops = Invoke-AzCmd -ArgumentList @( 'deployment', 'operation', 'group', 'list', '--resource-group', $ResourceGroup, '--name', $DeploymentName, '--output', 'json' ) -Silent -AllowFailure $opMsgs = @() if ($ops) { $opMsgs = @($ops | Where-Object { $_.properties.provisioningState -eq 'Failed' -and $_.properties.statusMessage.error.message } | ForEach-Object { $opErr = $_.properties.statusMessage.error "$($opErr.code): $($opErr.message)" }) } if ($opMsgs.Count -gt 0) { $opMsgs -join ' | ' } elseif ($detailMsgs.Count -gt 0) { $detailMsgs -join ' | ' } else { $err.message } } else { $detailMsgs -join ' | ' } } else { $depState } throw "Deployment '$DeploymentName' $depState`: $errMsg" } # Post-deployment resource summary — diff against pre-deploy snapshot. $newResources = @() $existingResources = @() $deployedList = Invoke-AzCmd -ArgumentList @( 'resource', 'list', '--resource-group', $ResourceGroup, '--query', '[].{name:name,type:type}', '--output', 'json' ) -Silent -AllowFailure if ($deployedList) { foreach ($r in @($deployedList)) { $shortType = ($r.type -split '/')[-1] $key = "$($r.type)/$($r.name)" if ($loggedViaOps.ContainsKey($key)) { continue } # already logged during polling if ($preDeployNames.ContainsKey($key)) { $existingResources += " $shortType '$($r.name)'" } else { $newResources += " $shortType '$($r.name)'" } } foreach ($line in $newResources) { Write-AdeLog "$line [new]" -Level Success } foreach ($line in $existingResources) { Write-AdeLog "$line [existing]" -Level Info } } if ($showResult -and $showResult.properties) { return [pscustomobject]@{ Outputs = $showResult.properties.outputs HasNewResources = ($newResources.Count -gt 0 -or $hasNewViaOps) } } return $null } finally { if ($tempParamsFile -and (Test-Path $tempParamsFile)) { Remove-Item -LiteralPath $tempParamsFile -Force -ErrorAction SilentlyContinue -WhatIf:$false } } } # ─── Tag builder ────────────────────────────────────────────────────────────── function Build-AdeTags { param( [Parameter(Mandatory)][psobject]$Profile, [string]$Module = '' ) $tags = @{ project = $Profile.tags.project purpose = $Profile.tags.purpose environment = $Profile.tags.environment managedBy = $Profile.tags.managedBy deployedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') profile = $Profile.profileName } if ($Module) { $tags['module'] = $Module } return $tags } # ─── Output helpers ─────────────────────────────────────────────────────────── function Get-AdeDeploymentOutput { <# .SYNOPSIS Safely retrieves a named output value from a deployment outputs object. #> param( [psobject]$Outputs, [Parameter(Mandatory)][string]$Key ) if ($null -eq $Outputs) { return '' } $prop = $Outputs.PSObject.Properties[$Key] if ($null -eq $prop) { return '' } $val = $prop.Value if ($null -eq $val) { return '' } return $val.value } # ─── Temporary files ────────────────────────────────────────────────────────── function New-AdeTempJsonPath { <# .SYNOPSIS Returns a unique JSON temp-file path without creating the file. .DESCRIPTION [System.IO.Path]::GetTempFileName() creates a file immediately. Appending ".json" to that path leaves the original temp file orphaned. This helper only builds a path, so callers create and clean up exactly one file. #> param( [string]$Prefix = 'ade', [string]$Purpose = 'tmp' ) $safePrefix = if ($Prefix) { $Prefix -replace '[^A-Za-z0-9._-]', '-' } else { 'ade' } $safePurpose = if ($Purpose) { $Purpose -replace '[^A-Za-z0-9._-]', '-' } else { 'tmp' } $fileName = '{0}-{1}.{2}.json' -f $safePrefix, ([System.Guid]::NewGuid().ToString('N')), $safePurpose return [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), $fileName) } # ─── Destructive action confirmation ───────────────────────────────────────── function Confirm-AdeDestructiveAction { <# .SYNOPSIS Asks the user to confirm an irreversible action (delete / purge) that a deployment needs to perform as a pre-flight workaround. .DESCRIPTION Deployments should never delete resources silently. This gate prompts interactively and defaults to No. Under -Force or CI (no interactive console) it auto-approves — matching the existing non-interactive behavior — but still logs a warning so the action is visible in the log. #> param( [Parameter(Mandatory)][string]$Action, [switch]$Force ) if ($Force -or [bool]$env:CI -or [bool]$env:GITHUB_ACTIONS) { Write-AdeLog "$Action — auto-approved (-Force / CI)." -Level Warning return $true } Write-Host "" $answer = Read-Host " $Action. Proceed? [y/N]" return $answer -match '^[Yy]$' } # ─── Deployer public IP ─────────────────────────────────────────────────────── function Get-AdeDeployerPublicIp { <# .SYNOPSIS Best-effort detection of the deployer's public IPv4 address. .DESCRIPTION Used to scope the SQL Server firewall rule to the machine running the deployment (workstation or CI runner) instead of opening 0.0.0.0/0. Tries several well-known echo services; returns $null when none respond or the response is not a valid IPv4 address. Callers must treat $null as "no deployer rule" and warn accordingly. #> param() $services = @( 'https://api.ipify.org', 'https://ifconfig.me/ip', 'https://icanhazip.com' ) foreach ($svc in $services) { try { Write-AdeLog "Detecting deployer public IP via $svc" -Level Debug $ip = (Invoke-RestMethod -Uri $svc -TimeoutSec 10).ToString().Trim() # Accept IPv4 only — SQL firewall rules do not support IPv6. if ($ip -match '^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$' -and -not (($ip -split '\.') | Where-Object { [int]$_ -gt 255 })) { return $ip } Write-AdeLog " ✗ $svc returned '$ip' (not a valid IPv4 address)" -Level Debug } catch { Write-AdeLog " ✗ $svc unreachable: $($_.Exception.Message)" -Level Debug } } return $null } # ─── Password generation ───────────────────────────────────────────────────── function New-AdePassword { <# .SYNOPSIS Generates a cryptographically random password as a SecureString. .DESCRIPTION Guarantees at least two characters from each class (upper, lower, digit, symbol) using ambiguity-free character sets, fills the remainder from the union of all classes, and shuffles with Fisher-Yates — all driven by a crypto RNG. Meets Azure VM / SQL / PostgreSQL / MySQL complexity rules. The plaintext is discarded immediately after SecureString conversion. #> [OutputType([securestring])] param( [ValidateRange(12, 128)] [int]$Length = 16 ) # Ambiguity-free sets: no I/l/1/O/0 look-alikes (passwords may be hand-typed) $upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ' $lower = 'abcdefghjkmnpqrstuvwxyz' $digits = '23456789' $symbols = '!@#$%^&*' $all = $upper + $lower + $digits + $symbols $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() try { # First $Length bytes pick characters, second $Length bytes drive the shuffle. $bytes = [byte[]]::new($Length * 2) $rng.GetBytes($bytes) $pwChars = [System.Collections.Generic.List[char]]::new() # Two guaranteed characters per class (Azure complexity with headroom) $pwChars.Add($upper[$bytes[0] % $upper.Length]) $pwChars.Add($upper[$bytes[1] % $upper.Length]) $pwChars.Add($lower[$bytes[2] % $lower.Length]) $pwChars.Add($lower[$bytes[3] % $lower.Length]) $pwChars.Add($digits[$bytes[4] % $digits.Length]) $pwChars.Add($digits[$bytes[5] % $digits.Length]) $pwChars.Add($symbols[$bytes[6] % $symbols.Length]) $pwChars.Add($symbols[$bytes[7] % $symbols.Length]) for ($i = 8; $i -lt $Length; $i++) { $pwChars.Add($all[$bytes[$i] % $all.Length]) } # Fisher-Yates shuffle using the second half of the crypto bytes for ($i = $pwChars.Count - 1; $i -gt 0; $i--) { $j = $bytes[$Length + ($i % $Length)] % ($i + 1) $tmp = $pwChars[$i]; $pwChars[$i] = $pwChars[$j]; $pwChars[$j] = $tmp } $plain = -join $pwChars $secure = ConvertTo-SecureString $plain -AsPlainText -Force $plain = $null # discard plaintext from memory return $secure } finally { $rng.Dispose() } } # ─── Key Vault secrets ──────────────────────────────────────────────────────── function Get-AdeKeyVaultSecret { <# .SYNOPSIS Reads a Key Vault secret value (data plane) as a SecureString. .DESCRIPTION Return contract — callers depend on the distinction: SecureString the secret exists and was read $null the secret cleanly does NOT exist (SecretNotFound) throws any other failure (Forbidden, firewall, network) after MaxAttempts — so "unreadable" is never mistaken for "absent", which would silently rotate a password that is still in use on deployed resources. Retries cover RBAC role-assignment propagation delay after a deploy. #> [OutputType([securestring])] param( [Parameter(Mandatory)][string]$VaultName, [Parameter(Mandatory)][string]$SecretName, [ValidateRange(1, 10)][int]$MaxAttempts = 3 ) $retryDelays = @(0, 5, 15) # seconds to wait before attempt 1, 2, 3+ $lastError = '' for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { $delay = $retryDelays[[Math]::Min($attempt - 1, $retryDelays.Count - 1)] if ($delay -gt 0) { Write-AdeLog " retrying secret read in ${delay}s (RBAC propagation)..." -Level Debug Start-Sleep -Seconds $delay } Write-AdeLog "az keyvault secret show --vault-name $VaultName --name $SecretName (attempt $attempt/$MaxAttempts)" -Level Debug $output = az keyvault secret show --vault-name $VaultName --name $SecretName --query value -o tsv 2>&1 if ($LASTEXITCODE -eq 0) { $value = (@($output | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] }) -join '').Trim() if ($value) { return (ConvertTo-SecureString $value -AsPlainText -Force) } Write-AdeLog "Secret '$SecretName' in vault '$VaultName' has an empty value — treating as absent." -Level Debug return $null } $errText = (@($output | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) -join ' ') if (-not $errText) { $errText = @($output) -join ' ' } if ($errText -match 'SecretNotFound') { Write-AdeLog "Secret '$SecretName' not found in vault '$VaultName'." -Level Debug return $null } $lastError = $errText Write-AdeLog " ✗ secret read attempt $attempt failed: $errText" -Level Debug } throw ("Could not read secret '$SecretName' from Key Vault '$VaultName' after $MaxAttempts attempts: $lastError. " + "The secret exists (or its existence could not be ruled out) but its value is unreadable — refusing to " + "generate a replacement so passwords already in use are not silently rotated.") } function Get-AdeKeyVaultSecretNames { <# .SYNOPSIS Lists secret names in a Key Vault via the ARM control plane. .DESCRIPTION Uses the management endpoint (az rest) instead of the data plane, so it works even when the vault firewall blocks the caller (hardened mode with publicNetworkAccess Disabled) and requires no data-plane RBAC — Reader on the resource group suffices. Follows nextLink pagination. Returns @() when the list cannot be retrieved; callers must treat that as "existence unknown", not "no secrets". #> [OutputType([string[]])] param( [Parameter(Mandatory)][string]$SubscriptionId, [Parameter(Mandatory)][string]$ResourceGroup, [Parameter(Mandatory)][string]$VaultName ) $names = [System.Collections.Generic.List[string]]::new() $url = "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup" + "/providers/Microsoft.KeyVault/vaults/$VaultName/secrets?api-version=2023-07-01" while ($url) { Write-AdeLog "az rest --method GET --url $url" -Level Debug $raw = az rest --method GET --url $url 2>$null if ($LASTEXITCODE -ne 0 -or -not $raw) { return @() } try { $parsed = (@($raw) -join "`n") | ConvertFrom-Json -ErrorAction Stop foreach ($item in @($parsed.value)) { if ($item -and $item.name) { $names.Add($item.name) } } $nextProp = $parsed.PSObject.Properties['nextLink'] $url = if ($nextProp -and $nextProp.Value) { $nextProp.Value } else { $null } } catch { return @() } } return $names.ToArray() } # ─── Feature flag accessor ─────────────────────────────────────────────────── function Get-AdeObjectPropertyValue { <# .SYNOPSIS Safely reads a named property from either a PSCustomObject or hashtable. #> param( [object]$InputObject, [Parameter(Mandatory)][string]$Name ) if ($null -eq $InputObject) { return $null } if ($InputObject -is [System.Collections.IDictionary]) { if ($InputObject.Contains($Name)) { return $InputObject[$Name] } return $null } $prop = $InputObject.PSObject.Properties[$Name] if ($null -eq $prop) { return $null } return $prop.Value } function Get-AdeModuleFeatures { <# .SYNOPSIS Safely retrieves the features object for a module from a deployment profile. .DESCRIPTION Centralizes the repeated StrictMode-safe profile traversal used by deploy and validation code. Returns an empty object when the module or features block is absent so callers can use Get-FeatureFlag directly. #> param( [Parameter(Mandatory)][object]$Profile, [Parameter(Mandatory)][string]$ModuleName ) $empty = [pscustomobject]@{} $modules = Get-AdeObjectPropertyValue -InputObject $Profile -Name 'modules' if ($null -eq $modules) { return $empty } $moduleConfig = Get-AdeObjectPropertyValue -InputObject $modules -Name $ModuleName if ($null -eq $moduleConfig) { return $empty } $features = Get-AdeObjectPropertyValue -InputObject $moduleConfig -Name 'features' if ($null -eq $features) { return $empty } return $features } function Get-FeatureFlag { <# .SYNOPSIS Safe feature flag accessor — works under Set-StrictMode -Version Latest. Returns $Default when the property doesn't exist on the object (avoids PropertyNotFoundException). Supports PSCustomObject and hashtable-backed feature objects. #> param( [object]$Features, [string]$Name, $Default = $false ) $value = Get-AdeObjectPropertyValue -InputObject $Features -Name $Name if ($null -eq $value) { return $Default } return $value } Write-AdeLog "common.ps1 loaded" -Level Debug |