content/src/scripts/Invoke-PlatformBootstrap.ps1
|
# --------------------------------------------------------------------------- # Invoke-PlatformBootstrap.ps1 # Axeon Core Engine — Data-Driven Azure Landing Zone Orchestrator # # Reads platform-spec.json for global config (naming, auth, environment), # loads a named deployment YAML file, and orchestrates modular Bicep # deployments across one or more resource groups. # # Usage: # ./Invoke-PlatformBootstrap.ps1 -ConfigPath "./platform-spec.json" -Deployment hub-networking # ./Invoke-PlatformBootstrap.ps1 -ConfigPath "./platform-spec.json" -Deployment hub-networking -WhatIf # --------------------------------------------------------------------------- #Requires -Version 7.0 #Requires -Modules Az.Accounts, Az.Resources, powershell-yaml [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [ValidateScript({ Test-Path $_ -PathType Leaf })] [string]$ConfigPath, [Parameter(Mandatory)] [string]$Deployment, [string]$BicepRoot, [string]$DeploymentRoot, [string]$SchemaPath, [switch]$FreeOnly, [string]$PricingCachePath = './azure-pricing.json' ) $ErrorActionPreference = "Stop" $InformationPreference = "Continue" # Dot-source the naming convention engine . (Join-Path $PSScriptRoot "Resolve-ResourceName.ps1") # Dot-source the cost estimation engine (for -FreeOnly support) . (Join-Path $PSScriptRoot "Get-CostEstimate.ps1") # ============================================================================ # HELPER FUNCTIONS # ============================================================================ function Write-Banner { param([string]$Title) $sep = "=" * 65 Write-Host "" Write-Host $sep -ForegroundColor DarkCyan Write-Host " $Title" -ForegroundColor Cyan Write-Host $sep -ForegroundColor DarkCyan Write-Host "" } function Write-StepHeader { param([int]$Step, [string]$Title) Write-Host "[$Step] $Title" -ForegroundColor Yellow Write-Host ("-" * 50) -ForegroundColor DarkGray } function Write-Success { param([string]$Message) Write-Host " [OK] $Message" -ForegroundColor Green } function Write-Detail { param([string]$Message) Write-Host " $Message" -ForegroundColor Gray } # ============================================================================ # STEP 1 — Load & Validate Global Configuration # ============================================================================ function Read-PlatformSpec { param([string]$Path) Write-StepHeader -Step 1 -Title "Loading platform specification" Write-Detail "Config: $Path" $raw = Get-Content $Path -Raw $spec = $raw | ConvertFrom-Json -AsHashtable # Required top-level fields $requiredFields = @('platformId', 'environment', 'location', 'naming') foreach ($field in $requiredFields) { if (-not $spec.ContainsKey($field) -or (-not ($spec[$field] -is [hashtable]) -and [string]::IsNullOrWhiteSpace($spec[$field]))) { throw "platform-spec.json is missing required field: '$field'" } } # Defaults if (-not $spec.ContainsKey('testMode')) { $spec['testMode'] = $false } # Validate naming configuration Write-Detail "Validating naming convention..." $namingValid = Test-NamingConfig -NamingConfig $spec.naming if (-not $namingValid) { throw "Naming configuration is invalid. See warnings above." } Write-Success "Naming convention validated" # Resolve all resource names and attach to spec $spec['_resolvedNames'] = Resolve-AllResourceNames -NamingConfig $spec.naming -Index 0 Write-Success "Configuration loaded" Write-Detail "Platform ID : $($spec.platformId)" Write-Detail "Environment : $($spec.environment)" Write-Detail "Location : $($spec.location)" Write-Detail "Test Mode : $($spec.testMode)" Write-Host "" # Show naming preview Show-NamingPreview -NamingConfig $spec.naming -Index 0 return $spec } function Test-PlatformSpec { param( [hashtable]$Spec, [string]$SchemaFile ) if (-not $SchemaFile -or -not (Test-Path $SchemaFile)) { Write-Detail "Schema validation skipped (no schema found)" return } Write-Detail "Validating against schema: $SchemaFile" $schema = Get-Content $SchemaFile -Raw | ConvertFrom-Json -AsHashtable if ($schema.ContainsKey('required')) { foreach ($field in $schema.required) { if (-not $Spec.ContainsKey($field)) { throw "Schema validation failed: missing required field '$field'" } } } Write-Success "Schema validation passed" Write-Host "" } # ============================================================================ # STEP 1b — Load & Validate Deployment YAML # ============================================================================ function Read-DeploymentSpec { param( [string]$DeploymentName, [string]$DeploymentDir, [hashtable]$ResolvedNames ) $yamlPath = Join-Path $DeploymentDir "$DeploymentName.yaml" if (-not (Test-Path $yamlPath)) { # Also try .yml extension $yamlPath = Join-Path $DeploymentDir "$DeploymentName.yml" } if (-not (Test-Path $yamlPath)) { $available = Get-ChildItem -Path $DeploymentDir -Filter "*.yaml" -ErrorAction SilentlyContinue | ForEach-Object { $_.BaseName } $availableYml = Get-ChildItem -Path $DeploymentDir -Filter "*.yml" -ErrorAction SilentlyContinue | ForEach-Object { $_.BaseName } $all = @($available) + @($availableYml) | Sort-Object -Unique $list = if ($all.Count -gt 0) { $all -join ', ' } else { '(none found)' } throw "Deployment '$DeploymentName' not found at '$DeploymentDir'. Available deployments: $list" } Write-Detail "Deployment file: $yamlPath" $raw = Get-Content $yamlPath -Raw $deploy = $raw | ConvertFrom-Yaml # Validate required fields if (-not $deploy.name) { throw "Deployment file missing required field: 'name'" } if (-not $deploy.modules -or $deploy.modules.Count -eq 0) { throw "Deployment '$($deploy.name)' has no modules defined" } foreach ($mod in $deploy.modules) { if (-not $mod.name) { throw "Deployment module missing required field: 'name'" } if (-not $mod.template) { throw "Module '$($mod.name)' missing required field: 'template'" } if (-not $mod.resourceGroup) { throw "Module '$($mod.name)' missing required field: 'resourceGroup'" } } # Resolve resource group and parameter references through the naming engine foreach ($mod in $deploy.modules) { # Resolve resourceGroup — if it matches a naming.resources key, use resolved name if ($ResolvedNames.ContainsKey($mod.resourceGroup)) { $mod['_resolvedResourceGroup'] = $ResolvedNames[$mod.resourceGroup] } else { # Treat as a literal resource group name $mod['_resolvedResourceGroup'] = $mod.resourceGroup } # Resolve parameters — if a param value matches a naming.resources key, resolve it if ($mod.parameters) { $mod['_resolvedParameters'] = @{} foreach ($key in $mod.parameters.Keys) { $value = $mod.parameters[$key] if ($value -is [string] -and $ResolvedNames.ContainsKey($value)) { $mod['_resolvedParameters'][$key] = $ResolvedNames[$value] } else { $mod['_resolvedParameters'][$key] = $value } } } else { $mod['_resolvedParameters'] = @{} } } Write-Success "Deployment '$($deploy.name)' loaded — $($deploy.modules.Count) module(s)" if ($deploy.description) { Write-Detail "Description: $($deploy.description)" } # Show module summary foreach ($mod in $deploy.modules) { $deps = if ($mod.dependsOn) { " (depends on: $($mod.dependsOn -join ', '))" } else { "" } Write-Detail " - $($mod.name) -> $($mod._resolvedResourceGroup)$deps" } Write-Host "" return $deploy } # ============================================================================ # STEP 2 — Azure Authentication # ============================================================================ function Connect-AzureContext { param([hashtable]$Spec) Write-StepHeader -Step 2 -Title "Authenticating to Azure" # Check if already connected $context = Get-AzContext -ErrorAction SilentlyContinue if ($context) { Write-Success "Already authenticated as: $($context.Account.Id)" Write-Detail "Subscription : $($context.Subscription.Name) ($($context.Subscription.Id))" Write-Host "" return $context } # Detect if running in a CI/cloud environment that supports Managed Identity $tryManagedIdentity = $false if ($env:IDENTITY_ENDPOINT -or $env:MSI_ENDPOINT -or $env:AZURE_FEDERATED_TOKEN_FILE) { $tryManagedIdentity = $true } elseif (-not $env:TERM_PROGRAM -and -not $env:WT_SESSION -and -not $env:SSH_CONNECTION) { try { Write-Detail "Probing Azure IMDS endpoint (2s timeout)..." Invoke-WebRequest -Uri 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/' ` -Headers @{ Metadata = 'true' } ` -TimeoutSec 2 ` -ErrorAction Stop | Out-Null $tryManagedIdentity = $true } catch { Write-Detail "IMDS not reachable — not running on Azure" } } if ($tryManagedIdentity) { try { Write-Detail "Attempting Workload Identity / Managed Identity..." Connect-AzAccount -Identity -ErrorAction Stop | Out-Null $context = Get-AzContext Write-Success "Authenticated via Managed Identity" Write-Detail "Subscription : $($context.Subscription.Name) ($($context.Subscription.Id))" Write-Host "" return $context } catch { Write-Detail "Managed Identity authentication failed: $($_.Exception.Message)" } } Write-Detail "Using interactive login..." Connect-AzAccount -ErrorAction Stop | Out-Null $context = Get-AzContext Write-Success "Authenticated interactively as: $($context.Account.Id)" Write-Detail "Subscription : $($context.Subscription.Name) ($($context.Subscription.Id))" Write-Host "" return $context } # ============================================================================ # STEP 3 — Resource Group Provisioning # ============================================================================ function Ensure-ResourceGroups { param( [hashtable]$Spec, [object]$DeploymentSpec, [switch]$TestMode ) Write-StepHeader -Step 3 -Title "Ensuring resource groups" # Collect unique resource groups from all modules $rgMap = @{} foreach ($mod in $DeploymentSpec.modules) { $rgName = $mod._resolvedResourceGroup if (-not $rgMap.ContainsKey($rgName)) { $rgMap[$rgName] = @() } $rgMap[$rgName] += $mod.name } $location = $Spec.location Write-Detail "Location: $location" Write-Detail "Resource groups required: $($rgMap.Count)" foreach ($rgName in $rgMap.Keys) { $modules = $rgMap[$rgName] -join ', ' Write-Detail "" Write-Detail " $rgName (modules: $modules)" if ($TestMode) { Write-Detail " [Test Mode] Would create/ensure resource group '$rgName'" continue } $existing = Get-AzResourceGroup -Name $rgName -ErrorAction SilentlyContinue if ($existing) { Write-Success " Resource group '$rgName' already exists" } else { if ($PSCmdlet.ShouldProcess($rgName, "Create resource group")) { New-AzResourceGroup -Name $rgName -Location $location -Force | Out-Null Write-Success " Resource group '$rgName' created" } } } Write-Host "" } # ============================================================================ # STEP 4 — Deploy Modules (with dependency ordering) # ============================================================================ function Get-TopologicalOrder { param([array]$Modules) $graph = @{} $inDegree = @{} $byName = @{} foreach ($mod in $Modules) { $name = $mod.name $byName[$name] = $mod if (-not $graph.ContainsKey($name)) { $graph[$name] = @() } if (-not $inDegree.ContainsKey($name)) { $inDegree[$name] = 0 } if ($mod.dependsOn) { foreach ($dep in $mod.dependsOn) { if (-not $graph.ContainsKey($dep)) { $graph[$dep] = @() } $graph[$dep] += $name $inDegree[$name]++ if (-not $inDegree.ContainsKey($dep)) { $inDegree[$dep] = 0 } } } } # Kahn's algorithm $queue = [System.Collections.Queue]::new() foreach ($name in $inDegree.Keys) { if ($inDegree[$name] -eq 0) { $queue.Enqueue($name) } } $ordered = @() while ($queue.Count -gt 0) { $current = $queue.Dequeue() $ordered += $current foreach ($neighbor in $graph[$current]) { $inDegree[$neighbor]-- if ($inDegree[$neighbor] -eq 0) { $queue.Enqueue($neighbor) } } } if ($ordered.Count -ne $Modules.Count) { throw "Circular dependency detected in deployment modules" } return $ordered | ForEach-Object { $byName[$_] } } function Invoke-DeploymentModules { param( [hashtable]$Spec, [object]$DeploymentSpec, [string]$BicepRoot, [switch]$TestMode ) Write-StepHeader -Step 4 -Title "Deploying modules" $sortedModules = Get-TopologicalOrder -Modules $DeploymentSpec.modules $totalModules = $sortedModules.Count $currentModule = 0 $results = @() foreach ($mod in $sortedModules) { $currentModule++ $moduleName = $mod.name $rgName = $mod._resolvedResourceGroup $templatePath = Join-Path $BicepRoot $mod.template Write-Host "" Write-Host " Module $currentModule/$totalModules : $moduleName" -ForegroundColor Cyan Write-Detail " Template : $templatePath" Write-Detail " Resource Group : $rgName" if (-not (Test-Path $templatePath)) { Write-Host " [!] Template not found: $templatePath" -ForegroundColor DarkYellow Write-Detail " Skipping module '$moduleName'" $results += @{ Module = $moduleName; Status = 'Skipped'; Reason = 'Template not found' } continue } # Build parameter object — global location + module-specific resolved params $parameters = @{ location = $Spec.location } foreach ($key in $mod._resolvedParameters.Keys) { $parameters[$key] = $mod._resolvedParameters[$key] } if ($parameters.Count -gt 0) { Write-Detail " Parameters:" foreach ($key in ($parameters.Keys | Sort-Object)) { Write-Detail " $key = $($parameters[$key])" } } $deploymentName = "axeon-$($Spec.platformId)-$moduleName-$(Get-Date -Format 'yyyyMMdd-HHmmss')" if ($TestMode) { $rgExists = Get-AzResourceGroup -Name $rgName -ErrorAction SilentlyContinue if (-not $rgExists) { Write-Detail " [Test Mode] Resource group '$rgName' does not exist yet." Write-Detail " Skipping What-If — the RG would be created in a real run." $results += @{ Module = $moduleName; Status = 'Validated'; Reason = 'RG not yet created' } continue } Write-Detail " [Test Mode] Running What-If..." try { $result = Test-AzResourceGroupDeployment ` -ResourceGroupName $rgName ` -TemplateFile $templatePath ` -TemplateParameterObject $parameters ` -ErrorAction Stop if ($result) { Write-Host " [!] Validation issues:" -ForegroundColor DarkYellow foreach ($err in $result) { Write-Host " - $($err.Message)" -ForegroundColor DarkYellow } $results += @{ Module = $moduleName; Status = 'Warnings'; Reason = 'Validation issues' } } else { Write-Success " Validation passed" $results += @{ Module = $moduleName; Status = 'Validated'; Reason = '' } } } catch { Write-Host " [!] Validation failed: $($_.Exception.Message)" -ForegroundColor DarkYellow $results += @{ Module = $moduleName; Status = 'Failed'; Reason = $_.Exception.Message } } continue } # Full deployment if ($PSCmdlet.ShouldProcess("$moduleName -> $rgName", "Deploy Bicep template")) { Write-Detail " Starting deployment: $deploymentName" $deployment = New-AzResourceGroupDeployment ` -Name $deploymentName ` -ResourceGroupName $rgName ` -TemplateFile $templatePath ` -TemplateParameterObject $parameters ` -ErrorAction Stop Write-Success " Module '$moduleName' deployed" Write-Detail " Provisioning State : $($deployment.ProvisioningState)" if ($deployment.Outputs) { Write-Detail " Outputs:" foreach ($key in $deployment.Outputs.Keys) { Write-Detail " $key = $($deployment.Outputs[$key].Value)" } } $results += @{ Module = $moduleName; Status = 'Deployed'; Reason = '' } } } Write-Host "" return $results } # ============================================================================ # STEP 5 — Summary # ============================================================================ function Write-DeploymentSummary { param( [hashtable]$Spec, [object]$DeploymentSpec, [array]$Results, [switch]$TestMode ) Write-StepHeader -Step 5 -Title "Deployment summary" $mode = if ($TestMode) { "VALIDATION ONLY (Test Mode)" } else { "DEPLOYED" } Write-Host "" Write-Host " Platform ID : $($Spec.platformId)" -ForegroundColor White Write-Host " Environment : $($Spec.environment)" -ForegroundColor White Write-Host " Location : $($Spec.location)" -ForegroundColor White Write-Host " Deployment : $($DeploymentSpec.name)" -ForegroundColor White Write-Host " Status : $mode" -ForegroundColor $(if ($TestMode) { 'Yellow' } else { 'Green' }) Write-Host "" if ($Results -and $Results.Count -gt 0) { Write-Host " Module Results:" -ForegroundColor White foreach ($r in $Results) { $color = switch ($r.Status) { 'Deployed' { 'Green' } 'Validated' { 'Cyan' } 'Warnings' { 'Yellow' } 'Failed' { 'Red' } 'Skipped' { 'DarkGray' } default { 'Gray' } } $detail = if ($r.Reason) { " — $($r.Reason)" } else { "" } Write-Host " [$($r.Status)] $($r.Module)$detail" -ForegroundColor $color } Write-Host "" } if ($TestMode) { Write-Host " To deploy for real, set testMode to false in platform-spec.json" -ForegroundColor DarkGray } } # ============================================================================ # MAIN EXECUTION # ============================================================================ $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() Write-Banner "Axeon Platform Bootstrap Engine" # Resolve paths $scriptDir = $PSScriptRoot if (-not $BicepRoot) { $BicepRoot = Join-Path $scriptDir ".." "bicep" } if (-not $DeploymentRoot) { $DeploymentRoot = Join-Path $scriptDir ".." ".." "deployments" } if (-not $SchemaPath) { $SchemaPath = Join-Path $scriptDir ".." "schemas" "platform-spec.schema.json" } try { # Step 1 — Load & validate global config $spec = Read-PlatformSpec -Path $ConfigPath Test-PlatformSpec -Spec $spec -SchemaFile $SchemaPath # Step 1b — Load & validate deployment $deploySpec = Read-DeploymentSpec ` -DeploymentName $Deployment ` -DeploymentDir $DeploymentRoot ` -ResolvedNames $spec._resolvedNames $isTestMode = [bool]$spec.testMode # Step 2 — Authenticate Connect-AzureContext -Spec $spec # Step 3 — Ensure resource groups Ensure-ResourceGroups -Spec $spec -DeploymentSpec $deploySpec -TestMode:$isTestMode # Step 3b — Free-only cost gate (when -FreeOnly is specified) if ($FreeOnly) { Write-StepHeader -Step 3 -Title "Cost check — Free-only mode" if (-not (Test-Path $PricingCachePath)) { throw "Pricing cache not found at '$PricingCachePath'. Run Update-AxeonPricing first to fetch pricing data." } $filteredModules = @() $skippedModules = @() foreach ($mod in $deploySpec.modules) { $templatePath = Join-Path $BicepRoot $mod.template if (-not (Test-Path $templatePath)) { # Template doesn't exist — keep it (will be handled later as skipped) $filteredModules += $mod continue } # Pass module parameters for accurate resource property matching $modParams = @{} if ($mod.ContainsKey('parameters') -and $mod.parameters) { $modParams = $mod.parameters } $freeStatus = Test-ModuleFreeStatus ` -TemplatePath $templatePath ` -PricingCachePath $PricingCachePath ` -ModuleParameters $modParams if ($freeStatus.IsFree) { Write-Host " [FREE] $($mod.name)" -ForegroundColor Green $filteredModules += $mod } else { $reasons = @() if ($freeStatus.PaidResources.Count -gt 0) { $paidTypes = ($freeStatus.PaidResources | ForEach-Object { $_.Type }) -join ', ' $reasons += "paid: $paidTypes" } if ($freeStatus.UnknownResources.Count -gt 0) { $unknownTypes = ($freeStatus.UnknownResources | ForEach-Object { $_.Type }) -join ', ' $reasons += "unknown pricing: $unknownTypes" } Write-Host " [SKIP] $($mod.name) — $($reasons -join '; ')" -ForegroundColor DarkYellow $skippedModules += $mod } } if ($skippedModules.Count -gt 0) { Write-Host "" Write-Host " Skipped $($skippedModules.Count) module(s) with paid/unknown resources." -ForegroundColor Yellow Write-Host " Deploying $($filteredModules.Count) free module(s) only." -ForegroundColor Green } else { Write-Host "" Write-Host " All modules are free — proceeding with full deployment." -ForegroundColor Green } Write-Host "" # Replace the deployment modules with only the free ones $deploySpec.modules = $filteredModules if ($filteredModules.Count -eq 0) { Write-Host " No free modules to deploy. Exiting." -ForegroundColor Yellow $stopwatch.Stop() Write-Host " Completed in $([math]::Round($stopwatch.Elapsed.TotalSeconds, 1))s" -ForegroundColor DarkGray return } } # Step 4 — Deploy modules $results = Invoke-DeploymentModules ` -Spec $spec ` -DeploymentSpec $deploySpec ` -BicepRoot $BicepRoot ` -TestMode:$isTestMode # Step 5 — Summary Write-DeploymentSummary -Spec $spec -DeploymentSpec $deploySpec -Results $results -TestMode:$isTestMode $stopwatch.Stop() Write-Host " Completed in $([math]::Round($stopwatch.Elapsed.TotalSeconds, 1))s" -ForegroundColor DarkGray Write-Host "" } catch { $stopwatch.Stop() Write-Host "" Write-Host " BOOTSTRAP FAILED" -ForegroundColor Red Write-Host " $($_.Exception.Message)" -ForegroundColor Red Write-Host "" throw } |