From 52671319785ec63c59a04bf68c7c44f3e2aba591 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Thu, 15 Jan 2026 17:40:39 +0000 Subject: [PATCH 01/20] feat: flatten permissions --- .../Config-Helpers/Remove-TerraformMetaFileSet.ps1 | 4 +++- .../Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/ALZ/Private/Config-Helpers/Remove-TerraformMetaFileSet.ps1 b/src/ALZ/Private/Config-Helpers/Remove-TerraformMetaFileSet.ps1 index 1eed2be..4bf3a34 100644 --- a/src/ALZ/Private/Config-Helpers/Remove-TerraformMetaFileSet.ps1 +++ b/src/ALZ/Private/Config-Helpers/Remove-TerraformMetaFileSet.ps1 @@ -12,7 +12,9 @@ function Remove-TerraformMetaFileSet { ".terraform.lock.hcl", "examples", "yaml.tf", - ".alzlib" + ".alzlib", + "tfplan", + "tfplan.json" ), [Parameter(Mandatory = $false)] [switch]$writeVerboseLogs diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 index 712660c..fe51aa0 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 @@ -40,6 +40,19 @@ function New-ModuleSetup { return $versionAndPath } + if(-not [string]::IsNullOrWhiteSpace($moduleOverrideFolderPath)) { + Write-Verbose "Using module override folder path, skipping version checks." + return New-FolderStructure ` + -targetDirectory $targetDirectory ` + -url $url ` + -release $desiredRelease ` + -releaseArtifactName $releaseArtifactName ` + -targetFolder $targetFolder ` + -sourceFolder $sourceFolder ` + -overrideSourceDirectoryPath $moduleOverrideFolderPath ` + -replaceFiles:$replaceFiles.IsPresent + } + $latestReleaseTag = $null try { $latestResult = Get-GithubReleaseTag -githubRepoUrl $url -release "latest" From 2c4e4b8874d24ff940d630377d5df61f2bcd631d Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Wed, 21 Jan 2026 15:23:11 +0000 Subject: [PATCH 02/20] fix first run check --- .../Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 index fe51aa0..71470d5 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 @@ -63,7 +63,7 @@ function New-ModuleSetup { } $isAutoVersion = $release -eq "latest" - $firstRun = $null -eq $currentVersion + $firstRun = $null -eq $currentVersion -or $currentVersion -eq "" $shouldDownload = $false if($isAutoVersion -and $upgrade.IsPresent -and $null -eq $latestReleaseTag) { @@ -88,11 +88,11 @@ function New-ModuleSetup { if(!$shouldDownload -or $isFirstRun) { $newVersionAvailable = $false $currentCalculatedVersion = $currentVersion - if($isAutoVersion -and $null -ne $latestReleaseTag -and $latestReleaseTag -ne $currentVersion) { + if(!$isFirstRun -and $isAutoVersion -and $null -ne $latestReleaseTag -and $latestReleaseTag -ne $currentVersion) { $newVersionAvailable = $true } - if(!$isAutoVersion -and $null -ne $latestReleaseTag -and $latestReleaseTag -ne $currentVersion) { + if(!$isFirstRun -and !$isAutoVersion -and $null -ne $latestReleaseTag -and $latestReleaseTag -ne $currentVersion) { $newVersionAvailable = $true } From 1f7bc317a681954294aeba3cc60ce3620a5a3620 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Thu, 22 Jan 2026 11:18:43 +0000 Subject: [PATCH 03/20] prompt for env vars and lazy load context --- .../Request-ALZConfigurationValue.ps1 | 242 +++++++++++------- .../Request-AcceleratorConfigurationInput.ps1 | 17 +- ...st-AcceleratorConfigurationInput.Tests.ps1 | 132 ++++++++++ 3 files changed, 291 insertions(+), 100 deletions(-) create mode 100644 src/Tests/Unit/Private/Request-AcceleratorConfigurationInput.Tests.ps1 diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-ALZConfigurationValue.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-ALZConfigurationValue.ps1 index 2ab28ae..1d18f27 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-ALZConfigurationValue.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-ALZConfigurationValue.ps1 @@ -11,8 +11,10 @@ function Request-ALZConfigurationValue { The Infrastructure as Code type (terraform or bicep). .PARAMETER VersionControl The version control system (github, azure-devops, or local). - .PARAMETER AzureContext - A hashtable containing Azure context information including ManagementGroups, Subscriptions, and Regions arrays. + .PARAMETER AzureContextOutputDirectory + The output directory to pass to Get-AzureContext for caching Azure context data. + .PARAMETER AzureContextClearCache + When set, clears the cached Azure context data before fetching. .PARAMETER SensitiveOnly When set, only prompts for sensitive inputs that are not already set (via environment variables or non-empty config values). .OUTPUTS @@ -30,12 +32,31 @@ function Request-ALZConfigurationValue { [string] $VersionControl, [Parameter(Mandatory = $false)] - [hashtable] $AzureContext = @{ ManagementGroups = @(); Subscriptions = @(); Regions = @() }, + [string] $AzureContextOutputDirectory = "", + + [Parameter(Mandatory = $false)] + [switch] $AzureContextClearCache, [Parameter(Mandatory = $false)] [switch] $SensitiveOnly ) + # Lazy-loaded Azure context - only fetched when first needed + $lazyAzureContext = $null + $azureContextFetched = $false + + function Get-LazyAzureContext { + if (-not $azureContextFetched) { + Set-Variable -Name azureContextFetched -Value $true -Scope 1 + if (-not [string]::IsNullOrWhiteSpace($AzureContextOutputDirectory)) { + Set-Variable -Name lazyAzureContext -Value (Get-AzureContext -OutputDirectory $AzureContextOutputDirectory -ClearCache:$AzureContextClearCache.IsPresent) -Scope 1 + } else { + Set-Variable -Name lazyAzureContext -Value (Get-AzureContext -ClearCache:$AzureContextClearCache.IsPresent) -Scope 1 + } + } + return $lazyAzureContext + } + # Helper function to get a property from schema info safely function Get-SchemaProperty { param($SchemaInfo, $PropertyName, $Default = $null) @@ -70,12 +91,14 @@ function Request-ALZConfigurationValue { $CurrentValue, $SchemaInfo, $Indent = "", - $DefaultDescription = "No description available", - $Subscriptions = @(), - $ManagementGroups = @(), - $Regions = @() + $DefaultDescription = "No description available" ) + # Initialize arrays that will be lazily populated + $Subscriptions = @() + $ManagementGroups = @() + $Regions = @() + $description = Get-SchemaProperty -SchemaInfo $SchemaInfo -PropertyName "description" -Default $DefaultDescription $helpLink = Get-SchemaProperty -SchemaInfo $SchemaInfo -PropertyName "helpLink" $isSensitive = Get-SchemaProperty -SchemaInfo $SchemaInfo -PropertyName "sensitive" -Default $false @@ -243,118 +266,145 @@ function Request-ALZConfigurationValue { # Parse comma-separated values into an array $newValue = @($inputValue -split ',' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } - } elseif ($source -eq "subscription" -and $Subscriptions.Count -gt 0) { - # Show subscription selection list - Write-InformationColored "${Indent} Available subscriptions:" -ForegroundColor Cyan -InformationAction Continue - for ($i = 0; $i -lt $Subscriptions.Count; $i++) { - $sub = $Subscriptions[$i] - if ($sub.id -eq $effectiveDefault) { - Write-InformationColored "${Indent} [$($i + 1)] $($sub.name) ($($sub.id)) (current)" -ForegroundColor Green -InformationAction Continue - } else { - Write-InformationColored "${Indent} [$($i + 1)] $($sub.name) ($($sub.id))" -ForegroundColor White -InformationAction Continue + } elseif ($source -eq "subscription") { + # Lazy load Azure context if we haven't loaded subscriptions yet + if ($Subscriptions.Count -eq 0) { + $ctx = Get-LazyAzureContext + if ($null -ne $ctx -and $ctx.ContainsKey('Subscriptions')) { + $Subscriptions = $ctx.Subscriptions } } - Write-InformationColored "${Indent} [0] Enter manually" -ForegroundColor Gray -InformationAction Continue + if ($Subscriptions.Count -gt 0) { + # Show subscription selection list + Write-InformationColored "${Indent} Available subscriptions:" -ForegroundColor Cyan -InformationAction Continue + for ($i = 0; $i -lt $Subscriptions.Count; $i++) { + $sub = $Subscriptions[$i] + if ($sub.id -eq $effectiveDefault) { + Write-InformationColored "${Indent} [$($i + 1)] $($sub.name) ($($sub.id)) (current)" -ForegroundColor Green -InformationAction Continue + } else { + Write-InformationColored "${Indent} [$($i + 1)] $($sub.name) ($($sub.id))" -ForegroundColor White -InformationAction Continue + } + } + Write-InformationColored "${Indent} [0] Enter manually" -ForegroundColor Gray -InformationAction Continue - $selection = Read-Host "${Indent} Select subscription (1-$($Subscriptions.Count), 0 for manual entry, or press Enter for default)" - if ([string]::IsNullOrWhiteSpace($selection)) { - $newValue = $effectiveDefault - } elseif ($selection -eq "0") { - $newValue = Get-ValidatedGuidInput -PromptText "${Indent} Enter subscription ID" -CurrentValue $effectiveDefault -Indent "${Indent} " - } else { - $selIndex = [int]$selection - 1 - if ($selIndex -ge 0 -and $selIndex -lt $Subscriptions.Count) { - $newValue = $Subscriptions[$selIndex].id - } else { - Write-InformationColored "${Indent} Invalid selection, using default" -ForegroundColor Yellow -InformationAction Continue + $selection = Read-Host "${Indent} Select subscription (1-$($Subscriptions.Count), 0 for manual entry, or press Enter for default)" + if ([string]::IsNullOrWhiteSpace($selection)) { $newValue = $effectiveDefault - } - } - # Require value if required - while ($isRequired -and [string]::IsNullOrWhiteSpace($newValue)) { - Write-InformationColored "${Indent} This field is required. Please select a subscription." -ForegroundColor Red -InformationAction Continue - $selection = Read-Host "${Indent} Select subscription (1-$($Subscriptions.Count), 0 for manual entry)" - if ($selection -eq "0") { - $newValue = Get-ValidatedGuidInput -PromptText "${Indent} Enter subscription ID" -CurrentValue "" -Indent "${Indent} " - } elseif (-not [string]::IsNullOrWhiteSpace($selection)) { + } elseif ($selection -eq "0") { + $newValue = Get-ValidatedGuidInput -PromptText "${Indent} Enter subscription ID" -CurrentValue $effectiveDefault -Indent "${Indent} " + } else { $selIndex = [int]$selection - 1 if ($selIndex -ge 0 -and $selIndex -lt $Subscriptions.Count) { $newValue = $Subscriptions[$selIndex].id + } else { + Write-InformationColored "${Indent} Invalid selection, using default" -ForegroundColor Yellow -InformationAction Continue + $newValue = $effectiveDefault + } + } + # Require value if required + while ($isRequired -and [string]::IsNullOrWhiteSpace($newValue)) { + Write-InformationColored "${Indent} This field is required. Please select a subscription." -ForegroundColor Red -InformationAction Continue + $selection = Read-Host "${Indent} Select subscription (1-$($Subscriptions.Count), 0 for manual entry)" + if ($selection -eq "0") { + $newValue = Get-ValidatedGuidInput -PromptText "${Indent} Enter subscription ID" -CurrentValue "" -Indent "${Indent} " + } elseif (-not [string]::IsNullOrWhiteSpace($selection)) { + $selIndex = [int]$selection - 1 + if ($selIndex -ge 0 -and $selIndex -lt $Subscriptions.Count) { + $newValue = $Subscriptions[$selIndex].id + } } } } - } elseif ($source -eq "managementGroup" -and $ManagementGroups.Count -gt 0) { - # Show management group selection list - Write-InformationColored "${Indent} Available management groups:" -ForegroundColor Cyan -InformationAction Continue - for ($i = 0; $i -lt $ManagementGroups.Count; $i++) { - $mg = $ManagementGroups[$i] - if ($mg.id -eq $effectiveDefault) { - Write-InformationColored "${Indent} [$($i + 1)] $($mg.displayName) ($($mg.id)) (current)" -ForegroundColor Green -InformationAction Continue - } else { - Write-InformationColored "${Indent} [$($i + 1)] $($mg.displayName) ($($mg.id))" -ForegroundColor White -InformationAction Continue + } elseif ($source -eq "managementGroup") { + # Lazy load Azure context if we haven't loaded management groups yet + if ($ManagementGroups.Count -eq 0) { + $ctx = Get-LazyAzureContext + if ($null -ne $ctx -and $ctx.ContainsKey('ManagementGroups')) { + $ManagementGroups = $ctx.ManagementGroups } } - Write-InformationColored "${Indent} [0] Enter manually" -ForegroundColor Gray -InformationAction Continue - Write-InformationColored "${Indent} Press Enter to leave empty (uses Tenant Root Group)" -ForegroundColor Gray -InformationAction Continue + if ($ManagementGroups.Count -gt 0) { + # Show management group selection list + Write-InformationColored "${Indent} Available management groups:" -ForegroundColor Cyan -InformationAction Continue + for ($i = 0; $i -lt $ManagementGroups.Count; $i++) { + $mg = $ManagementGroups[$i] + if ($mg.id -eq $effectiveDefault) { + Write-InformationColored "${Indent} [$($i + 1)] $($mg.displayName) ($($mg.id)) (current)" -ForegroundColor Green -InformationAction Continue + } else { + Write-InformationColored "${Indent} [$($i + 1)] $($mg.displayName) ($($mg.id))" -ForegroundColor White -InformationAction Continue + } + } + Write-InformationColored "${Indent} [0] Enter manually" -ForegroundColor Gray -InformationAction Continue + Write-InformationColored "${Indent} Press Enter to leave empty (uses Tenant Root Group)" -ForegroundColor Gray -InformationAction Continue - $selection = Read-Host "${Indent} Select management group (1-$($ManagementGroups.Count), 0 for manual entry, or press Enter for default)" - if ([string]::IsNullOrWhiteSpace($selection)) { - $newValue = $effectiveDefault - } elseif ($selection -eq "0") { - $newValue = Read-Host "${Indent} Enter management group ID" - if ([string]::IsNullOrWhiteSpace($newValue)) { + $selection = Read-Host "${Indent} Select management group (1-$($ManagementGroups.Count), 0 for manual entry, or press Enter for default)" + if ([string]::IsNullOrWhiteSpace($selection)) { $newValue = $effectiveDefault - } - } else { - $selIndex = [int]$selection - 1 - if ($selIndex -ge 0 -and $selIndex -lt $ManagementGroups.Count) { - $newValue = $ManagementGroups[$selIndex].id + } elseif ($selection -eq "0") { + $newValue = Read-Host "${Indent} Enter management group ID" + if ([string]::IsNullOrWhiteSpace($newValue)) { + $newValue = $effectiveDefault + } } else { - Write-InformationColored "${Indent} Invalid selection, using default" -ForegroundColor Yellow -InformationAction Continue - $newValue = $effectiveDefault + $selIndex = [int]$selection - 1 + if ($selIndex -ge 0 -and $selIndex -lt $ManagementGroups.Count) { + $newValue = $ManagementGroups[$selIndex].id + } else { + Write-InformationColored "${Indent} Invalid selection, using default" -ForegroundColor Yellow -InformationAction Continue + $newValue = $effectiveDefault + } } } - } elseif ($source -eq "azureRegion" -and $Regions.Count -gt 0) { - # Show region selection list - Write-InformationColored "${Indent} Available regions (AZ = Availability Zone support):" -ForegroundColor Cyan -InformationAction Continue - for ($i = 0; $i -lt $Regions.Count; $i++) { - $region = $Regions[$i] - $azIndicator = if ($region.hasAvailabilityZones) { " [AZ]" } else { "" } - if ($region.name -eq $effectiveDefault) { - Write-InformationColored "${Indent} [$($i + 1)] $($region.displayName) ($($region.name))$azIndicator (current)" -ForegroundColor Green -InformationAction Continue - } else { - Write-InformationColored "${Indent} [$($i + 1)] $($region.displayName) ($($region.name))$azIndicator" -ForegroundColor White -InformationAction Continue + } elseif ($source -eq "azureRegion") { + # Lazy load Azure context if we haven't loaded regions yet + if ($Regions.Count -eq 0) { + $ctx = Get-LazyAzureContext + if ($null -ne $ctx -and $ctx.ContainsKey('Regions')) { + $Regions = $ctx.Regions } } - Write-InformationColored "${Indent} [0] Enter manually" -ForegroundColor Gray -InformationAction Continue - - $selection = Read-Host "${Indent} Select region (1-$($Regions.Count), 0 for manual entry, or press Enter for default)" - if ([string]::IsNullOrWhiteSpace($selection)) { - $newValue = $effectiveDefault - } elseif ($selection -eq "0") { - $newValue = Read-Host "${Indent} Enter region name (e.g., uksouth, eastus)" - if ([string]::IsNullOrWhiteSpace($newValue)) { - $newValue = $effectiveDefault + if ($Regions.Count -gt 0) { + # Show region selection list + Write-InformationColored "${Indent} Available regions (AZ = Availability Zone support):" -ForegroundColor Cyan -InformationAction Continue + for ($i = 0; $i -lt $Regions.Count; $i++) { + $region = $Regions[$i] + $azIndicator = if ($region.hasAvailabilityZones) { " [AZ]" } else { "" } + if ($region.name -eq $effectiveDefault) { + Write-InformationColored "${Indent} [$($i + 1)] $($region.displayName) ($($region.name))$azIndicator (current)" -ForegroundColor Green -InformationAction Continue + } else { + Write-InformationColored "${Indent} [$($i + 1)] $($region.displayName) ($($region.name))$azIndicator" -ForegroundColor White -InformationAction Continue + } } - } else { - $selIndex = [int]$selection - 1 - if ($selIndex -ge 0 -and $selIndex -lt $Regions.Count) { - $newValue = $Regions[$selIndex].name - } else { - Write-InformationColored "${Indent} Invalid selection, using default" -ForegroundColor Yellow -InformationAction Continue + Write-InformationColored "${Indent} [0] Enter manually" -ForegroundColor Gray -InformationAction Continue + + $selection = Read-Host "${Indent} Select region (1-$($Regions.Count), 0 for manual entry, or press Enter for default)" + if ([string]::IsNullOrWhiteSpace($selection)) { $newValue = $effectiveDefault - } - } - # Require value if required - while ($isRequired -and [string]::IsNullOrWhiteSpace($newValue)) { - Write-InformationColored "${Indent} This field is required. Please select a region." -ForegroundColor Red -InformationAction Continue - $selection = Read-Host "${Indent} Select region (1-$($Regions.Count), 0 for manual entry)" - if ($selection -eq "0") { + } elseif ($selection -eq "0") { $newValue = Read-Host "${Indent} Enter region name (e.g., uksouth, eastus)" - } elseif (-not [string]::IsNullOrWhiteSpace($selection)) { + if ([string]::IsNullOrWhiteSpace($newValue)) { + $newValue = $effectiveDefault + } + } else { $selIndex = [int]$selection - 1 if ($selIndex -ge 0 -and $selIndex -lt $Regions.Count) { $newValue = $Regions[$selIndex].name + } else { + Write-InformationColored "${Indent} Invalid selection, using default" -ForegroundColor Yellow -InformationAction Continue + $newValue = $effectiveDefault + } + } + # Require value if required + while ($isRequired -and [string]::IsNullOrWhiteSpace($newValue)) { + Write-InformationColored "${Indent} This field is required. Please select a region." -ForegroundColor Red -InformationAction Continue + $selection = Read-Host "${Indent} Select region (1-$($Regions.Count), 0 for manual entry)" + if ($selection -eq "0") { + $newValue = Read-Host "${Indent} Enter region name (e.g., uksouth, eastus)" + } elseif (-not [string]::IsNullOrWhiteSpace($selection)) { + $selIndex = [int]$selection - 1 + if ($selIndex -ge 0 -and $selIndex -lt $Regions.Count) { + $newValue = $Regions[$selIndex].name + } } } } @@ -532,7 +582,7 @@ function Request-ALZConfigurationValue { continue } - $result = Read-InputValue -Key $subKey -CurrentValue $subCurrentValue -SchemaInfo $subSchemaInfo -Indent " " -DefaultDescription "Subscription ID for $subKey" -Subscriptions $AzureContext.Subscriptions -ManagementGroups $AzureContext.ManagementGroups -Regions $AzureContext.Regions + $result = Read-InputValue -Key $subKey -CurrentValue $subCurrentValue -SchemaInfo $subSchemaInfo -Indent " " -DefaultDescription "Subscription ID for $subKey" $subNewValue = $result.Value $subIsSensitive = $result.IsSensitive @@ -595,7 +645,7 @@ function Request-ALZConfigurationValue { } } - $result = Read-InputValue -Key $key -CurrentValue $currentValue -SchemaInfo $schemaInfo -Subscriptions $AzureContext.Subscriptions -ManagementGroups $AzureContext.ManagementGroups -Regions $AzureContext.Regions + $result = Read-InputValue -Key $key -CurrentValue $currentValue -SchemaInfo $schemaInfo $newValue = $result.Value $isSensitive = $result.IsSensitive diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-AcceleratorConfigurationInput.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-AcceleratorConfigurationInput.ps1 index 9337e36..64732b1 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-AcceleratorConfigurationInput.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-AcceleratorConfigurationInput.ps1 @@ -103,13 +103,13 @@ function Request-AcceleratorConfigurationInput { # Prompt for sensitive inputs that are not already set (e.g., PATs) Write-InformationColored "`nChecking for sensitive inputs that need to be provided..." -ForegroundColor Yellow -InformationAction Continue - $azureContext = Get-AzureContext -OutputDirectory $folderConfig.OutputFolderPath -ClearCache:$ClearCache.IsPresent Request-ALZConfigurationValue ` -ConfigFolderPath $folderConfig.ConfigFolderPath ` -IacType $folderConfig.IacType ` -VersionControl $folderConfig.VersionControl ` - -AzureContext $azureContext ` + -AzureContextOutputDirectory $folderConfig.OutputFolderPath ` + -AzureContextClearCache:$ClearCache.IsPresent ` -SensitiveOnly Write-InformationColored "`nProceeding with destroy..." -ForegroundColor Yellow -InformationAction Continue @@ -202,13 +202,22 @@ function Request-AcceleratorConfigurationInput { # Offer to configure inputs interactively (default is Yes) $configureNowResponse = Read-Host "`nWould you like to configure the input values interactively now? (Y/n)" if ($configureNowResponse -ne "n" -and $configureNowResponse -ne "N") { - $azureContext = Get-AzureContext -OutputDirectory $outputFolderPath -ClearCache:$ClearCache.IsPresent + Request-ALZConfigurationValue ` + -ConfigFolderPath $configFolderPath ` + -IacType $selectedIacType ` + -VersionControl $selectedVersionControl ` + -AzureContextOutputDirectory $outputFolderPath ` + -AzureContextClearCache:$ClearCache.IsPresent + } else { + Write-InformationColored "`nChecking for sensitive inputs that need to be provided..." -ForegroundColor Yellow -InformationAction Continue Request-ALZConfigurationValue ` -ConfigFolderPath $configFolderPath ` -IacType $selectedIacType ` -VersionControl $selectedVersionControl ` - -AzureContext $azureContext + -AzureContextOutputDirectory $outputFolderPath ` + -AzureContextClearCache:$ClearCache.IsPresent ` + -SensitiveOnly } # Check for VS Code or VS Code Insiders and offer to open the config folder diff --git a/src/Tests/Unit/Private/Request-AcceleratorConfigurationInput.Tests.ps1 b/src/Tests/Unit/Private/Request-AcceleratorConfigurationInput.Tests.ps1 new file mode 100644 index 0000000..5f6c853 --- /dev/null +++ b/src/Tests/Unit/Private/Request-AcceleratorConfigurationInput.Tests.ps1 @@ -0,0 +1,132 @@ +#------------------------------------------------------------------------- +Set-Location -Path $PSScriptRoot +#------------------------------------------------------------------------- +$ModuleName = 'ALZ' +$PathToManifest = [System.IO.Path]::Combine('..', '..', '..', $ModuleName, "$ModuleName.psd1") +#------------------------------------------------------------------------- +if (Get-Module -Name $ModuleName -ErrorAction 'SilentlyContinue') { + Remove-Module -Name $ModuleName -Force +} +Import-Module $PathToManifest -Force +#------------------------------------------------------------------------- + +InModuleScope 'ALZ' { + Describe 'Request-AcceleratorConfigurationInput Function Tests' -Tag Unit { + BeforeAll { + $WarningPreference = 'SilentlyContinue' + $ErrorActionPreference = 'SilentlyContinue' + } + + Context 'When skipping interactive configuration but continuing' { + It 'invokes SensitiveOnly check for missing sensitive inputs' { + $script:answers = @( + 'C:\\temp\\acc', # target folder + 'n', # overwrite existing folder? + 'n', # configure inputs now? + 'yes' # continue? + ) + $script:idx = 0 + $prompts = [System.Collections.Generic.List[string]]::new() + Mock -CommandName Read-Host -MockWith { + param($Prompt) + $prompts.Add($Prompt) | Out-Null + if ($script:idx -lt $script:answers.Count) { + $script:answers[$script:idx++] + } else { + 'yes' + } + } + + Mock -CommandName Get-NormalizedPath -MockWith { param($Path) $Path } + Mock -CommandName Get-AcceleratorFolderConfiguration -MockWith { + [pscustomobject]@{ + FolderExists = $true + IsValid = $true + ConfigFolderPath = 'C:\\temp\\acc\\config' + InputsYamlPath = 'C:\\temp\\acc\\config\\inputs.yaml' + IacType = 'terraform' + VersionControl = 'github' + OutputFolderPath = 'C:\\temp\\acc\\output' + } + } + Mock -CommandName Resolve-Path -MockWith { param($Path) [pscustomobject]@{ Path = $Path } } + Mock -CommandName Get-Command -MockWith { $null } + Mock -CommandName Get-AzureContext -MockWith { @{ ManagementGroups = @(); Subscriptions = @(); Regions = @() } } + Mock -CommandName Request-ALZConfigurationValue -MockWith { } + Mock -CommandName Get-AcceleratorConfigPath -MockWith { + [pscustomobject]@{ + InputConfigFilePaths = @('inputs.yaml') + StarterAdditionalFiles = @() + } + } + Mock -CommandName ConvertTo-AcceleratorResult -MockWith { + param($Continue, $InputConfigFilePaths, $StarterAdditionalFiles, $OutputFolderPath) + @{ Continue = $Continue; InputConfigFilePaths = $InputConfigFilePaths; StarterAdditionalFiles = $StarterAdditionalFiles; OutputFolderPath = $OutputFolderPath } + } + + $result = Request-AcceleratorConfigurationInput + + # Debug: $prompts and $script:answers can be inspected if needed + + $result.Continue | Should -BeTrue + + Should -Invoke -CommandName Request-ALZConfigurationValue -ParameterFilter { $SensitiveOnly } -Times 1 -Scope It + # Get-AzureContext is now called lazily inside Request-ALZConfigurationValue, not by Request-AcceleratorConfigurationInput + Should -Invoke -CommandName Get-AzureContext -Times 0 -Scope It + } + } + + Context 'When configuring interactively' { + It 'does not invoke SensitiveOnly check' { + $script:answers = @( + 'C:\\temp\\acc', # target folder + 'n', # overwrite existing folder? + '', # configure inputs now? (default yes) + 'yes' # continue? + ) + $script:idx = 0 + Mock -CommandName Read-Host -MockWith { + param($Prompt) + if ($script:idx -lt $script:answers.Count) { + $script:answers[$script:idx++] + } else { + 'yes' + } + } + + Mock -CommandName Get-NormalizedPath -MockWith { param($Path) $Path } + Mock -CommandName Get-AcceleratorFolderConfiguration -MockWith { + [pscustomobject]@{ + FolderExists = $true + IsValid = $true + ConfigFolderPath = 'C:\\temp\\acc\\config' + InputsYamlPath = 'C:\\temp\\acc\\config\\inputs.yaml' + IacType = 'terraform' + VersionControl = 'github' + OutputFolderPath = 'C:\\temp\\acc\\output' + } + } + Mock -CommandName Resolve-Path -MockWith { param($Path) [pscustomobject]@{ Path = $Path } } + Mock -CommandName Get-Command -MockWith { $null } + Mock -CommandName Get-AzureContext -MockWith { @{ ManagementGroups = @(); Subscriptions = @(); Regions = @() } } + Mock -CommandName Request-ALZConfigurationValue -MockWith { } + Mock -CommandName Get-AcceleratorConfigPath -MockWith { + [pscustomobject]@{ + InputConfigFilePaths = @('inputs.yaml') + StarterAdditionalFiles = @() + } + } + Mock -CommandName ConvertTo-AcceleratorResult -MockWith { + param($Continue, $InputConfigFilePaths, $StarterAdditionalFiles, $OutputFolderPath) + @{ Continue = $Continue; InputConfigFilePaths = $InputConfigFilePaths; StarterAdditionalFiles = $StarterAdditionalFiles; OutputFolderPath = $OutputFolderPath } + } + + $result = Request-AcceleratorConfigurationInput + + $result.Continue | Should -BeTrue + + Should -Invoke -CommandName Request-ALZConfigurationValue -ParameterFilter { $SensitiveOnly } -Times 0 -Scope It + } + } + } +} From f54627fb3d7499a1ce6fbc664cced69e09117802 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 23 Jan 2026 09:06:38 +0000 Subject: [PATCH 04/20] add error details for sub placement --- src/ALZ/Public/Remove-PlatformLandingZone.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 index 4ecdb16..3236b7d 100644 --- a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -1078,7 +1078,7 @@ function Remove-PlatformLandingZone { } else { $result = (az account management-group subscription add --name $subscriptionsTargetManagementGroup --subscription $subscription.name 2>&1) if($result) { - Write-ToConsoleLog "Failed to move subscription to target management group: $($subscriptionsTargetManagementGroup), subscription: $($subscription.displayName)" -IsWarning -NoNewLine + Write-ToConsoleLog "Failed to move subscription to target management group: $($subscriptionsTargetManagementGroup), subscription: $($subscription.displayName), $result" -IsWarning -NoNewLine } else { Write-ToConsoleLog "Moved subscription to target management group: $($subscriptionsTargetManagementGroup), subscription: $($subscription.displayName)" -NoNewLine } From 68da1e9d4c40458865877757a007c58acb08cb79 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 23 Jan 2026 12:12:36 +0000 Subject: [PATCH 05/20] fix warning --- src/ALZ/Public/Remove-PlatformLandingZone.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 index 3236b7d..5913599 100644 --- a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -1077,7 +1077,7 @@ function Remove-PlatformLandingZone { -IsPlan -LogFilePath $using:TempLogFileForPlan } else { $result = (az account management-group subscription add --name $subscriptionsTargetManagementGroup --subscription $subscription.name 2>&1) - if($result) { + if($result.ToLower().Contains("Error")) { Write-ToConsoleLog "Failed to move subscription to target management group: $($subscriptionsTargetManagementGroup), subscription: $($subscription.displayName), $result" -IsWarning -NoNewLine } else { Write-ToConsoleLog "Moved subscription to target management group: $($subscriptionsTargetManagementGroup), subscription: $($subscription.displayName)" -NoNewLine @@ -1092,8 +1092,8 @@ function Remove-PlatformLandingZone { -IsPlan -LogFilePath $using:TempLogFileForPlan } else { $result = (az account management-group subscription remove --name $_ --subscription $subscription.name 2>&1) - if($result) { - Write-ToConsoleLog "Failed to remove subscription from management group: $_, subscription: $($subscription.displayName)" -IsWarning -NoNewLine + if($result.ToLower().Contains("Error")) { + Write-ToConsoleLog "Failed to remove subscription from management group: $_, subscription: $($subscription.displayName), $result" -IsWarning -NoNewLine } else { Write-ToConsoleLog "Removed subscription from management group: $_, subscription: $($subscription.displayName)" -NoNewLine } From d7246eb869578feb08831106d57e77be27048131 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 23 Jan 2026 12:13:20 +0000 Subject: [PATCH 06/20] null check --- src/ALZ/Public/Remove-PlatformLandingZone.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 index 5913599..252bba4 100644 --- a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -1077,7 +1077,7 @@ function Remove-PlatformLandingZone { -IsPlan -LogFilePath $using:TempLogFileForPlan } else { $result = (az account management-group subscription add --name $subscriptionsTargetManagementGroup --subscription $subscription.name 2>&1) - if($result.ToLower().Contains("Error")) { + if($result -and $result.ToLower().Contains("Error")) { Write-ToConsoleLog "Failed to move subscription to target management group: $($subscriptionsTargetManagementGroup), subscription: $($subscription.displayName), $result" -IsWarning -NoNewLine } else { Write-ToConsoleLog "Moved subscription to target management group: $($subscriptionsTargetManagementGroup), subscription: $($subscription.displayName)" -NoNewLine @@ -1092,7 +1092,7 @@ function Remove-PlatformLandingZone { -IsPlan -LogFilePath $using:TempLogFileForPlan } else { $result = (az account management-group subscription remove --name $_ --subscription $subscription.name 2>&1) - if($result.ToLower().Contains("Error")) { + if($result -and $result.ToLower().Contains("Error")) { Write-ToConsoleLog "Failed to remove subscription from management group: $_, subscription: $($subscription.displayName), $result" -IsWarning -NoNewLine } else { Write-ToConsoleLog "Removed subscription from management group: $_, subscription: $($subscription.displayName)" -NoNewLine From 9c11d7a72471ae77d3fe931e8d8a4b4e9f79576e Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Wed, 28 Jan 2026 11:40:46 +0000 Subject: [PATCH 07/20] add skip mg check --- src/ALZ/Public/Remove-PlatformLandingZone.ps1 | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 index 252bba4..8b8edc4 100644 --- a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -144,6 +144,13 @@ function Remove-PlatformLandingZone { containing "alz" anywhere in their name). Default: Empty array (delete all deployment stacks) + .PARAMETER AllowNoManagementGroupMatch + A switch parameter that allows the function to continue processing subscriptions even when no valid + management groups are found from the provided list. When specified, a warning is logged instead of an + error, and the function continues to subscription cleanup. This is useful when the management groups + may have already been deleted but you still want to clean up subscriptions. + Default: $false (exit with error if no management groups found) + .EXAMPLE Remove-PlatformLandingZone -ManagementGroups @("alz-test") -AdditionalSubscriptions @("Bootstrap-Sub-001") @@ -321,7 +328,8 @@ function Remove-PlatformLandingZone { [switch]$SkipCustomRoleDefinitionDeletion, [string[]]$ManagementGroupsToDeleteNamePatterns = @(), [string[]]$RoleDefinitionsToDeleteNamePatterns = @(), - [string[]]$DeploymentStacksToDeleteNamePatterns = @() + [string[]]$DeploymentStacksToDeleteNamePatterns = @(), + [switch]$AllowNoManagementGroupMatch ) function Write-ToConsoleLog { @@ -979,8 +987,12 @@ function Remove-PlatformLandingZone { } if($managementGroupsFound.Count -eq 0) { - Write-ToConsoleLog "No valid management groups found from the provided list, exiting..." -IsError - return + if($AllowNoManagementGroupMatch) { + Write-ToConsoleLog "No valid management groups found from the provided list, but continuing due to -AllowNoManagementGroupMatch..." -IsWarning + } else { + Write-ToConsoleLog "No valid management groups found from the provided list, exiting..." -IsError + return + } } if(-not $BypassConfirmation) { From 2b250f7553e89956f6df8822e37560ecd5e8baab Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Wed, 28 Jan 2026 16:20:08 +0000 Subject: [PATCH 08/20] add force subscription placement for use in testing --- src/ALZ/Public/Remove-PlatformLandingZone.ps1 | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 index 8b8edc4..1a98842 100644 --- a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -151,6 +151,15 @@ function Remove-PlatformLandingZone { may have already been deleted but you still want to clean up subscriptions. Default: $false (exit with error if no management groups found) + .PARAMETER ForceSubscriptionPlacement + A switch parameter that forces moving all subscriptions (provided via -Subscriptions or -AdditionalSubscriptions) + to the management group specified in -SubscriptionsTargetManagementGroup. If -SubscriptionsTargetManagementGroup + is not specified, the default management group is determined from the tenant's hierarchy settings + (via az account management-group hierarchy-settings list), falling back to tenant root if no default is configured. + Before moving, the function checks if each subscription is already under the target management group and skips + the move if it is. + Default: $false (do not force placement) + .EXAMPLE Remove-PlatformLandingZone -ManagementGroups @("alz-test") -AdditionalSubscriptions @("Bootstrap-Sub-001") @@ -329,7 +338,8 @@ function Remove-PlatformLandingZone { [string[]]$ManagementGroupsToDeleteNamePatterns = @(), [string[]]$RoleDefinitionsToDeleteNamePatterns = @(), [string[]]$DeploymentStacksToDeleteNamePatterns = @(), - [switch]$AllowNoManagementGroupMatch + [switch]$AllowNoManagementGroupMatch, + [switch]$ForceSubscriptionPlacement ) function Write-ToConsoleLog { @@ -1223,6 +1233,50 @@ function Remove-PlatformLandingZone { $subscriptionsFinal = $subscriptionsFound.ToArray() | Sort-Object -Property name -Unique + # Force subscription placement if requested + if($ForceSubscriptionPlacement -and $subscriptionsFinal.Count -gt 0) { + $targetManagementGroupForPlacement = $SubscriptionsTargetManagementGroup + + if(-not $targetManagementGroupForPlacement) { + # Get default management group from hierarchy settings + $tenantId = (az account show --query "tenantId" -o tsv) + $hierarchySettings = (az account management-group hierarchy-settings list --name $tenantId -o json 2>$null) | ConvertFrom-Json + if($hierarchySettings -and $hierarchySettings.value.defaultManagementGroup) { + $targetManagementGroupForPlacement = $hierarchySettings.value.defaultManagementGroup + Write-ToConsoleLog "No target management group specified, using default management group from hierarchy settings: $targetManagementGroupForPlacement" -IsWarning + } else { + # Fall back to tenant root if no default is configured + $targetManagementGroupForPlacement = $tenantId + Write-ToConsoleLog "No default management group configured in hierarchy settings, using tenant root: $targetManagementGroupForPlacement" -IsWarning + } + } + + if($targetManagementGroupForPlacement) { + Write-ToConsoleLog "Force subscription placement enabled, moving subscriptions to management group: $targetManagementGroupForPlacement" -NoNewLine + + $subscriptionsFinal | ForEach-Object -Parallel { + $subscription = $_ + $targetMg = $using:targetManagementGroupForPlacement + $funcWriteToConsoleLog = $using:funcWriteToConsoleLog + ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog + $TempLogFileForPlan = $using:TempLogFileForPlan + + Write-ToConsoleLog "Moving subscription to management group: $targetMg, subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewLine + if($using:PlanMode) { + Write-ToConsoleLog ` + "Moving subscription to management group: $targetMg, subscription: $($subscription.Name) (ID: $($subscription.Id))", ` + "Would run: az account management-group subscription add --name $targetMg --subscription $($subscription.Id)" ` + -IsPlan -LogFilePath $TempLogFileForPlan + } else { + az account management-group subscription add --name $targetMg --subscription $subscription.Id 2>&1 | Out-Null + Write-ToConsoleLog "Subscription placed in management group: $targetMg, subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewLine + } + } -ThrottleLimit $ThrottleLimit + + Write-ToConsoleLog "Forced subscription placement completed." -IsSuccess + } + } + if($subscriptionsFinal.Count -eq 0) { Write-ToConsoleLog "No subscriptions provided or found, skipping resource group deletion..." -IsWarning } else { From bd983aca4a3189a525eac0abb02c8bb407f2be59 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Wed, 28 Jan 2026 16:32:03 +0000 Subject: [PATCH 09/20] allow regex for mg name --- src/ALZ/Public/Remove-PlatformLandingZone.ps1 | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 index 1a98842..23cd73d 100644 --- a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -28,7 +28,9 @@ function Remove-PlatformLandingZone { Use with extreme caution and ensure you have appropriate backups and authorization before executing. .PARAMETER ManagementGroups - An array of management group IDs or names to process. By default, the function deletes child management groups + An array of regex patterns to match against management group names or display names. The function queries + all management groups in the tenant and matches each pattern against both the name and displayName properties. + Multiple management groups can match a single pattern. By default, the function deletes child management groups one level below these target groups (not the target groups themselves). Use -DeleteTargetManagementGroups to delete the target groups as well. Subscriptions under these management groups will be discovered unless subscriptions are explicitly provided via the -Subscriptions parameter. @@ -982,17 +984,25 @@ function Remove-PlatformLandingZone { } Write-ToConsoleLog "Validating provided management groups..." + + # Query all management groups in the tenant first + $allManagementGroups = (az account management-group list --query "[].{name:name,displayName:displayName}" -o json) | ConvertFrom-Json + foreach($managementGroup in $ManagementGroups) { - $managementGroupObject = (az account management-group show --name $managementGroup) | ConvertFrom-Json + # Treat $managementGroup as a regex and match against name or displayName + $matchingMgs = $allManagementGroups | Where-Object { $_.name -match $managementGroup -or $_.displayName -match $managementGroup } - if($null -eq $managementGroupObject) { - Write-ToConsoleLog "Management group not found: $managementGroup" -IsWarning + if($null -eq $matchingMgs -or $matchingMgs.Count -eq 0) { + Write-ToConsoleLog "Management group not found matching pattern: $managementGroup" -IsWarning continue } - $managementGroupsFound += @{ - Name = $managementGroupObject.name - DisplayName = $managementGroupObject.displayName + foreach($matchedMg in $matchingMgs) { + Write-ToConsoleLog "Found management group matching pattern '$managementGroup': $($matchedMg.name) ($($matchedMg.displayName))" -NoNewLine + $managementGroupsFound += @{ + Name = $matchedMg.name + DisplayName = $matchedMg.displayName + } } } From 1a6506c5f4615e3d48d16e816fe8420a223bf24c Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Wed, 28 Jan 2026 18:54:11 +0000 Subject: [PATCH 10/20] fix MacOS bug --- src/ALZ/Public/Remove-PlatformLandingZone.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 index 23cd73d..9319e90 100644 --- a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -1396,7 +1396,7 @@ function Remove-PlatformLandingZone { if(-not $using:SkipDefenderPlanReset) { Write-ToConsoleLog "Checking for Microsoft Defender for Cloud Plans to reset in subscription: $($subscription.Name) (ID: $($subscription.Id))" - $defenderPlans = (az security pricing list --subscription $subscription.Id) | ConvertFrom-Json + $defenderPlans = (az security pricing list --subscription $subscription.Id 2>$null) | ConvertFrom-Json $defenderPlans.value | Where-Object { -not $_.deprecated } | ForEach-Object -Parallel { $subscription = $using:subscription From fed7d5b0263797ea554e0a69f7b36e4e2643fce1 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Wed, 28 Jan 2026 20:04:31 +0000 Subject: [PATCH 11/20] Improve logging --- src/ALZ/Public/Remove-PlatformLandingZone.ps1 | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 index 9319e90..ad47e67 100644 --- a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -623,7 +623,7 @@ function Remove-PlatformLandingZone { if (!$result) { Write-ToConsoleLog "Deleted orphaned role assignment: $($roleAssignment.roleDefinitionName) from $($ScopeType): $ScopeNameForLogs" -NoNewLine } else { - Write-ToConsoleLog "Failed to delete orphaned role assignment: $($roleAssignment.roleDefinitionName) from $($ScopeType): $ScopeNameForLogs" -IsWarning -NoNewLine + Write-ToConsoleLog "Failed to delete orphaned role assignment: $($roleAssignment.roleDefinitionName) from $($ScopeType): $ScopeNameForLogs", "Full error: $result" -IsWarning -NoNewLine } } -ThrottleLimit $using:ThrottleLimit @@ -723,7 +723,7 @@ function Remove-PlatformLandingZone { if (!$result) { Write-ToConsoleLog "Deleted deployment stack: $($deploymentStack.name) from $($scopeType): $scopeNameForLogs" -NoNewLine } else { - Write-ToConsoleLog "Failed to delete deployment stack: $($deploymentStack.name) from $($scopeType): $scopeNameForLogs" -IsWarning -NoNewLine + Write-ToConsoleLog "Failed to delete deployment stack: $($deploymentStack.name) from $($scopeType): $scopeNameForLogs", "Full error: $result" -IsWarning -NoNewLine } } -ThrottleLimit $ThrottleLimit @@ -781,7 +781,7 @@ function Remove-PlatformLandingZone { if (!$result) { Write-ToConsoleLog "Deleted deployment: $deploymentName from $($scopeType): $scopeNameForLogs" -NoNewLine } else { - Write-ToConsoleLog "Failed to delete deployment: $deploymentName from $($scopeType): $scopeNameForLogs" -IsWarning -NoNewLine + Write-ToConsoleLog "Failed to delete deployment: $deploymentName from $($scopeType): $scopeNameForLogs", "Full error: $result" -IsWarning -NoNewLine } } -ThrottleLimit $ThrottleLimit @@ -884,7 +884,7 @@ function Remove-PlatformLandingZone { if (!$result) { Write-ToConsoleLog "Deleted role assignment '$($assignment.name)' of custom role '$roleDefinitionName'" -NoNewLine } else { - Write-ToConsoleLog "Failed to delete role assignment '$($assignment.name)' of custom role '$roleDefinitionName'" -IsWarning -NoNewLine + Write-ToConsoleLog "Failed to delete role assignment '$($assignment.name)' of custom role '$roleDefinitionName'", "Full error: $result" -IsWarning -NoNewLine } } } -ThrottleLimit $using:ThrottleLimit @@ -905,7 +905,7 @@ function Remove-PlatformLandingZone { if (!$result) { Write-ToConsoleLog "Deleted custom role definition: $($roleDefinition.roleName) (ID: $($roleDefinition.name))" -NoNewLine } else { - Write-ToConsoleLog "Failed to delete custom role definition: $($roleDefinition.roleName) (ID: $($roleDefinition.name))" -IsWarning -NoNewLine + Write-ToConsoleLog "Failed to delete custom role definition: $($roleDefinition.roleName) (ID: $($roleDefinition.name))", "Full error: $result" -IsWarning -NoNewLine } } } @@ -1110,7 +1110,7 @@ function Remove-PlatformLandingZone { } else { $result = (az account management-group subscription add --name $subscriptionsTargetManagementGroup --subscription $subscription.name 2>&1) if($result -and $result.ToLower().Contains("Error")) { - Write-ToConsoleLog "Failed to move subscription to target management group: $($subscriptionsTargetManagementGroup), subscription: $($subscription.displayName), $result" -IsWarning -NoNewLine + Write-ToConsoleLog "Failed to move subscription to target management group: $($subscriptionsTargetManagementGroup), subscription: $($subscription.displayName)", "Full error: $result" -IsWarning -NoNewLine } else { Write-ToConsoleLog "Moved subscription to target management group: $($subscriptionsTargetManagementGroup), subscription: $($subscription.displayName)" -NoNewLine } @@ -1125,7 +1125,7 @@ function Remove-PlatformLandingZone { } else { $result = (az account management-group subscription remove --name $_ --subscription $subscription.name 2>&1) if($result -and $result.ToLower().Contains("Error")) { - Write-ToConsoleLog "Failed to remove subscription from management group: $_, subscription: $($subscription.displayName), $result" -IsWarning -NoNewLine + Write-ToConsoleLog "Failed to remove subscription from management group: $_, subscription: $($subscription.displayName)", "Full error: $result" -IsWarning -NoNewLine } else { Write-ToConsoleLog "Removed subscription from management group: $_, subscription: $($subscription.displayName)" -NoNewLine } @@ -1145,7 +1145,7 @@ function Remove-PlatformLandingZone { } else { $result = (az account management-group delete --name $_ 2>&1) if($result -like "*Error*") { - Write-ToConsoleLog "Failed to delete management group: $_" -IsWarning -NoNewline + Write-ToConsoleLog "Failed to delete management group: $_", "Full error: $result" -IsWarning -NoNewline } else { Write-ToConsoleLog "Deleted management group: $_" -NoNewline } @@ -1377,7 +1377,7 @@ function Remove-PlatformLandingZone { if (!$result) { Write-ToConsoleLog "Deleted resource group for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" -NoNewLine } else { - Write-ToConsoleLog "Delete resource group failed for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" -NoNewLine + Write-ToConsoleLog "Delete resource group failed for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)", "Full error: $result" -NoNewLine Write-ToConsoleLog "It will be retried once the other resource groups in the subscription have reported their status." -NoNewLine $retries = $using:resourceGroupsToRetry $retries.Add($_) From c5acffde6e11e1bba26898e815899eecfca1938b Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Thu, 29 Jan 2026 20:15:26 +0000 Subject: [PATCH 12/20] refactor modules --- src/ALZ/ALZ.psd1 | 6 +- .../Format-TokenizedConfigurationString.ps1 | 2 +- src/ALZ/Private/Config-Helpers/Set-Config.ps1 | 2 +- .../AcceleratorInputSchema.json | 21 +- .../Get-AzureContext.ps1 | 49 +- .../Get-BootstrapAndStarterConfig.ps1 | 4 +- .../Invoke-Terraform.ps1 | 22 +- .../New-Bootstrap.ps1 | 10 +- .../New-FolderStructure.ps1 | 2 +- .../New-ModuleSetup.ps1 | 20 +- .../Request-ALZConfigurationValue.ps1 | 485 +++--------------- .../Request-AcceleratorConfigurationInput.ps1 | 160 +++--- src/ALZ/Private/Shared/Get-GithubRelease.ps1 | 6 +- .../Private/Shared/Get-GithubReleaseTag.ps1 | 2 +- src/ALZ/Private/Shared/Get-OsArchitecture.ps1 | 2 +- src/ALZ/Private/Shared/Get-RandomString.ps1 | 31 ++ .../Shared/Invoke-PromptForConfirmation.ps1 | 58 +++ src/ALZ/Private/Shared/Read-MenuSelection.ps1 | 375 +++++++++++++- .../Shared/Write-InformationColored.ps1 | 24 - src/ALZ/Private/Shared/Write-ToConsoleLog.ps1 | 220 ++++++++ .../Private/Tools/Checks/Test-AlzModule.ps1 | 105 ++++ .../Private/Tools/Checks/Test-AzureCli.ps1 | 57 ++ .../Tools/Checks/Test-AzureDevOpsCli.ps1 | 55 ++ .../Checks/Test-AzureEnvironmentVariable.ps1 | 91 ++++ .../Private/Tools/Checks/Test-GitHubCli.ps1 | 44 ++ .../Tools/Checks/Test-GitInstallation.ps1 | 28 + .../Tools/Checks/Test-PowerShellVersion.ps1 | 35 ++ .../Private/Tools/Checks/Test-YamlModule.ps1 | 72 +++ src/ALZ/Private/Tools/Test-Tooling.ps1 | 361 +++---------- src/ALZ/Public/Deploy-Accelerator.ps1 | 55 +- .../Public/Grant-SubscriptionCreatorRole.ps1 | 30 +- .../Public/New-AcceleratorFolderStructure.ps1 | 22 +- .../Public/Remove-AzureDevOpsAccelerator.ps1 | 359 +++++++++++++ src/ALZ/Public/Remove-GitHubAccelerator.ps1 | 414 +++++++++++++++ src/ALZ/Public/Remove-PlatformLandingZone.ps1 | 345 ++++--------- .../Public/Test-AcceleratorRequirement.ps1 | 10 +- ...dit-ALZConfigurationFilesInPlace.Tests.ps1 | 10 +- ...st-AcceleratorConfigurationInput.Tests.ps1 | 20 +- .../Write-InformationColored.Tests.ps1 | 45 -- .../Unit/Public/Deploy-Accelerator.Tests.ps1 | 2 +- 40 files changed, 2408 insertions(+), 1253 deletions(-) create mode 100644 src/ALZ/Private/Shared/Get-RandomString.ps1 create mode 100644 src/ALZ/Private/Shared/Invoke-PromptForConfirmation.ps1 delete mode 100644 src/ALZ/Private/Shared/Write-InformationColored.ps1 create mode 100644 src/ALZ/Private/Shared/Write-ToConsoleLog.ps1 create mode 100644 src/ALZ/Private/Tools/Checks/Test-AlzModule.ps1 create mode 100644 src/ALZ/Private/Tools/Checks/Test-AzureCli.ps1 create mode 100644 src/ALZ/Private/Tools/Checks/Test-AzureDevOpsCli.ps1 create mode 100644 src/ALZ/Private/Tools/Checks/Test-AzureEnvironmentVariable.ps1 create mode 100644 src/ALZ/Private/Tools/Checks/Test-GitHubCli.ps1 create mode 100644 src/ALZ/Private/Tools/Checks/Test-GitInstallation.ps1 create mode 100644 src/ALZ/Private/Tools/Checks/Test-PowerShellVersion.ps1 create mode 100644 src/ALZ/Private/Tools/Checks/Test-YamlModule.ps1 create mode 100644 src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 create mode 100644 src/ALZ/Public/Remove-GitHubAccelerator.ps1 delete mode 100644 src/Tests/Unit/Private/Write-InformationColored.Tests.ps1 diff --git a/src/ALZ/ALZ.psd1 b/src/ALZ/ALZ.psd1 index 4f1499d..ca8415a 100644 --- a/src/ALZ/ALZ.psd1 +++ b/src/ALZ/ALZ.psd1 @@ -41,6 +41,8 @@ Included Cmdlets: - Grant-SubscriptionCreatorRole: Grants the Subscription Creator role to a specified user or service principal. - Remove-PlatformLandingZone: Removes the deployed Azure Landing Zone from your Azure subscription - New-AcceleratorFolderStructure: Creates a new folder structure for the Azure Landing Zone accelerator with necessary configuration files. +- Remove-GitHubAccelerator: Removes GitHub resources (repositories, teams, runner groups) created by the ALZ accelerator bootstrap. +- Remove-AzureDevOpsAccelerator: Removes Azure DevOps resources (projects, agent pools) created by the ALZ accelerator bootstrap. '@ CompatiblePSEditions = 'Core' @@ -87,7 +89,9 @@ Included Cmdlets: 'Deploy-Accelerator', 'Grant-SubscriptionCreatorRole', 'Remove-PlatformLandingZone', - 'New-AcceleratorFolderStructure' + 'New-AcceleratorFolderStructure', + 'Remove-GitHubAccelerator', + 'Remove-AzureDevOpsAccelerator' ) # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. diff --git a/src/ALZ/Private/Config-Helpers/Format-TokenizedConfigurationString.ps1 b/src/ALZ/Private/Config-Helpers/Format-TokenizedConfigurationString.ps1 index ee05dd6..a9b63ee 100644 --- a/src/ALZ/Private/Config-Helpers/Format-TokenizedConfigurationString.ps1 +++ b/src/ALZ/Private/Config-Helpers/Format-TokenizedConfigurationString.ps1 @@ -15,7 +15,7 @@ function Format-TokenizedConfigurationString { if ($null -ne $configuration.$value) { $returnValue += $configuration.$value.Value } elseif (($null -eq $configuration.$value) -and $isToken) { - Write-InformationColored "Specified replacement token '${value}' not found in configuration." -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "Specified replacement token '${value}' not found in configuration." -IsWarning $returnValue += "{%$value%}" } else { $returnValue += $value diff --git a/src/ALZ/Private/Config-Helpers/Set-Config.ps1 b/src/ALZ/Private/Config-Helpers/Set-Config.ps1 index d1aacad..7e437cf 100644 --- a/src/ALZ/Private/Config-Helpers/Set-Config.ps1 +++ b/src/ALZ/Private/Config-Helpers/Set-Config.ps1 @@ -150,7 +150,7 @@ function Set-Config { continue } - Write-InformationColored "Input not supplied, and no default for $($configurationValue.Name)..." -ForegroundColor Red -InformationAction Continue + Write-ToConsoleLog "Input not supplied, and no default for $($configurationValue.Name)..." -IsError throw "Input not supplied, and no default for $($configurationValue.Name)..." } diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/AcceleratorInputSchema.json b/src/ALZ/Private/Deploy-Accelerator-Helpers/AcceleratorInputSchema.json index a45e5a6..2200d2e 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/AcceleratorInputSchema.json +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/AcceleratorInputSchema.json @@ -30,29 +30,25 @@ "properties": { "management": { "description": "The subscription ID for the Management subscription where logging, monitoring, and automation resources will be deployed", - "type": "string", - "format": "guid", + "type": "guid", "required": true, "source": "subscription" }, "identity": { "description": "The subscription ID for the Identity subscription where identity resources like domain controllers will be deployed", - "type": "string", - "format": "guid", + "type": "guid", "required": true, "source": "subscription" }, "connectivity": { "description": "The subscription ID for the Connectivity subscription where networking resources like hubs, firewalls, and DNS will be deployed", - "type": "string", - "format": "guid", + "type": "guid", "required": true, "source": "subscription" }, "security": { "description": "The subscription ID for the Security subscription where security monitoring and governance resources will be deployed", - "type": "string", - "format": "guid", + "type": "guid", "required": true, "source": "subscription" } @@ -61,8 +57,7 @@ "bootstrap_subscription_id": { "description": "The subscription ID where bootstrap resources will be created. See Decision 8 in the planning phase.", "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/0_planning/#decision-8---choose-the-bootstrap-subscription", - "type": "string", - "format": "guid", + "type": "guid", "required": true, "source": "subscription" }, @@ -190,12 +185,6 @@ "type": "boolean", "required": true }, - "grant_permissions_to_current_user": { - "description": "Whether to grant permissions for the current Azure CLI user to be able to deploy the Platform Landing Zones. Set to false if you plan to configure a third-party Version Control System.", - "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/advancedscenarios/", - "type": "boolean", - "required": true - }, "target_directory": { "description": "The target directory for generated files. Leave empty to use the standard output directory.", "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/advancedscenarios/", diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AzureContext.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AzureContext.ps1 index 57043c3..2e9f8f0 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AzureContext.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AzureContext.ps1 @@ -14,9 +14,9 @@ function Get-AzureContext { When set, clears the cached Azure context and fetches fresh data from Azure. .OUTPUTS Returns a hashtable with the following keys: - - ManagementGroups: Array of objects with id and displayName properties - - Subscriptions: Array of objects with id and name properties - - Regions: Array of objects with name, displayName, and hasAvailabilityZones properties + - ManagementGroups: Array of label/value objects for menu selection + - Subscriptions: Array of label/value objects for menu selection + - Regions: Array of label/value objects for menu selection (includes [AZ] indicator) #> [CmdletBinding()] param( @@ -35,7 +35,7 @@ function Get-AzureContext { # Clear cache if requested if ($ClearCache.IsPresent -and (Test-Path $cacheFilePath)) { Remove-Item -Path $cacheFilePath -Force - Write-InformationColored "Azure context cache cleared." -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "Azure context cache cleared." -IsSuccess } # Check if valid cache exists @@ -45,8 +45,8 @@ function Get-AzureContext { if ($cacheAge.TotalHours -lt $cacheExpirationHours) { try { $cachedContext = Get-Content -Path $cacheFilePath -Raw | ConvertFrom-Json -AsHashtable - Write-InformationColored "Using cached Azure context (cached $([math]::Round($cacheAge.TotalMinutes)) minutes ago). Use -clearCache to refresh." -ForegroundColor Gray -InformationAction Continue - Write-InformationColored " Found $($cachedContext.ManagementGroups.Count) management groups, $($cachedContext.Subscriptions.Count) subscriptions, and $($cachedContext.Regions.Count) regions" -ForegroundColor Gray -InformationAction Continue + Write-ToConsoleLog "Using cached Azure context (cached $([math]::Round($cacheAge.TotalMinutes)) minutes ago). Use -clearCache to refresh." + Write-ToConsoleLog "Found $($cachedContext.ManagementGroups.Count) management groups, $($cachedContext.Subscriptions.Count) subscriptions, and $($cachedContext.Regions.Count) regions" return $cachedContext } catch { Write-Verbose "Failed to read cache file, will fetch fresh data." @@ -60,7 +60,7 @@ function Get-AzureContext { Regions = @() } - Write-InformationColored "Querying Azure for management groups, subscriptions, and regions..." -ForegroundColor Green -InformationAction Continue + Write-ToConsoleLog "Querying Azure for management groups, subscriptions, and regions..." try { # Get the current tenant ID @@ -70,7 +70,15 @@ function Get-AzureContext { # Get management groups $mgResult = az account management-group list --query "[].{id:name, displayName:displayName}" -o json 2>$null if ($LASTEXITCODE -eq 0 -and $mgResult) { - $azureContext.ManagementGroups = $mgResult | ConvertFrom-Json + $mgRaw = $mgResult | ConvertFrom-Json + $azureContext.ManagementGroups = @($mgRaw | ForEach-Object { + @{ + label = "$($_.displayName) ($($_.id))" + value = $_.id + } + }) + } else { + Write-ToConsoleLog "No management groups found or access denied." -IsWarning } # Get subscriptions (filtered to current tenant only, sorted by name) @@ -80,16 +88,33 @@ function Get-AzureContext { $subResult = az account list --query "sort_by([].{id:id, name:name}, &name)" -o json 2>$null } if ($LASTEXITCODE -eq 0 -and $subResult) { - $azureContext.Subscriptions = $subResult | ConvertFrom-Json + $subRaw = $subResult | ConvertFrom-Json + $azureContext.Subscriptions = @($subRaw | ForEach-Object { + @{ + label = "$($_.name) ($($_.id))" + value = $_.id + } + }) + } else { + Write-ToConsoleLog "No subscriptions found or access denied." -IsWarning } # Get regions (sorted by displayName, include availability zone support) $regionResult = az account list-locations --query "sort_by([?metadata.regionType=='Physical'].{name:name, displayName:displayName, hasAvailabilityZones:length(availabilityZoneMappings || ``[]``) > ``0``}, &displayName)" -o json 2>$null if ($LASTEXITCODE -eq 0 -and $regionResult) { - $azureContext.Regions = $regionResult | ConvertFrom-Json + $regionRaw = $regionResult | ConvertFrom-Json + $azureContext.Regions = @($regionRaw | ForEach-Object { + $azIndicator = if ($_.hasAvailabilityZones) { " [AZ]" } else { "" } + @{ + label = "$($_.displayName) ($($_.name))$azIndicator" + value = $_.name + } + }) + } else { + Write-ToConsoleLog "No regions found or access denied." -IsWarning } - Write-InformationColored " Found $($azureContext.ManagementGroups.Count) management groups, $($azureContext.Subscriptions.Count) subscriptions, and $($azureContext.Regions.Count) regions" -ForegroundColor Gray -InformationAction Continue + Write-ToConsoleLog "Found $($azureContext.ManagementGroups.Count) management groups, $($azureContext.Subscriptions.Count) subscriptions, and $($azureContext.Regions.Count) regions" # Save to cache try { @@ -102,7 +127,7 @@ function Get-AzureContext { Write-Verbose "Failed to write cache file: $_" } } catch { - Write-InformationColored " Warning: Could not query Azure resources. You will need to enter IDs manually." -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "Could not query Azure resources. You will need to enter IDs manually." -IsWarning } return $azureContext diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-BootstrapAndStarterConfig.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-BootstrapAndStarterConfig.ps1 index 92f64d5..74977c1 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-BootstrapAndStarterConfig.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-BootstrapAndStarterConfig.ps1 @@ -34,7 +34,7 @@ function Get-BootstrapAndStarterConfig { # Get the bootstrap details and validate it exists (use alias for legacy values) $bootstrapDetails = $bootstrapModules.PsObject.Properties | Where-Object { $_.Name -eq $bootstrap -or $bootstrap -in $_.Value.aliases } if($null -eq $bootstrapDetails) { - Write-InformationColored "The bootstrap type '$bootstrap' that you have selected does not exist. Please try again with a valid bootstrap type..." -ForegroundColor Red -InformationAction Continue + Write-ToConsoleLog "The bootstrap type '$bootstrap' that you have selected does not exist. Please try again with a valid bootstrap type..." -IsError throw } @@ -48,7 +48,7 @@ function Get-BootstrapAndStarterConfig { $starterModuleType = $bootstrapStarterModule.Value $starterModuleDetails = $starterModules.PSObject.Properties | Where-Object { $_.Name -eq $starterModuleType } if($null -eq $starterModuleDetails) { - Write-InformationColored "The starter modules '$($starterModuleType)' for the bootstrap type '$bootstrap' that you have selected does not exist. This could be an issue with your custom configuration, please check and try again..." -ForegroundColor Red -InformationAction Continue + Write-ToConsoleLog "The starter modules '$($starterModuleType)' for the bootstrap type '$bootstrap' that you have selected does not exist. This could be an issue with your custom configuration, please check and try again..." -IsError throw } diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/Invoke-Terraform.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/Invoke-Terraform.ps1 index a456923..f6e0eeb 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/Invoke-Terraform.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/Invoke-Terraform.ps1 @@ -61,7 +61,7 @@ function Invoke-Terraform { } if (!$silent) { - Write-InformationColored "Terraform init has completed, now running the $action..." -ForegroundColor Green -NewLineBefore -InformationAction Continue + Write-ToConsoleLog "Terraform init has completed, now running the $action..." -IsSuccess } $planFileName = "tfplan" @@ -85,7 +85,7 @@ function Invoke-Terraform { } if (!$silent) { - Write-InformationColored "Running Plan Command for $action : $command $arguments" -ForegroundColor Green -NewLineBefore -InformationAction Continue + Write-ToConsoleLog "Running Plan Command for $action : $command $arguments" -IsSuccess & $command $arguments } else { & $command $arguments | Write-Verbose @@ -96,23 +96,23 @@ function Invoke-Terraform { # Stop and display timer $StopWatch.Stop() if (!$silent) { - Write-InformationColored "Time taken to complete Terraform plan:" -ForegroundColor Green -NewLineBefore -InformationAction Continue + Write-ToConsoleLog "Time taken to complete Terraform plan:" -IsSuccess } $StopWatch.Elapsed | Format-Table if ($exitCode -ne 0) { - Write-InformationColored "Terraform plan for $action failed with exit code $exitCode. Please review the error and try again or raise an issue." -ForegroundColor Red -NewLineBefore -InformationAction Continue + Write-ToConsoleLog "Terraform plan for $action failed with exit code $exitCode. Please review the error and try again or raise an issue." -IsError throw "Terraform plan failed with exit code $exitCode. Please review the error and try again or raise an issue." } if (!$autoApprove) { - Write-InformationColored "Terraform plan has completed, please review the plan and confirm you wish to continue." -ForegroundColor Yellow -NewLineBefore -InformationAction Continue + Write-ToConsoleLog "Terraform plan has completed, please review the plan and confirm you wish to continue." -IsWarning $choices = [System.Management.Automation.Host.ChoiceDescription[]] @("&Yes", "&No") $message = "Please confirm you wish to apply the plan." $title = "Confirm Terraform plan" $resultIndex = $host.ui.PromptForChoice($title, $message, $choices, 0) if ($resultIndex -eq 1) { - Write-InformationColored "You have chosen not to apply the plan. Exiting..." -ForegroundColor Red -NewLineBefore -InformationAction Continue + Write-ToConsoleLog "You have chosen not to apply the plan. Exiting..." -IsError return } } @@ -130,7 +130,7 @@ function Invoke-Terraform { $arguments += "$planFileName" if (!$silent) { - Write-InformationColored "Running Apply Command for $action : $command $arguments" -ForegroundColor Green -NewLineBefore -InformationAction Continue + Write-ToConsoleLog "Running Apply Command for $action : $command $arguments" -IsSuccess & $command $arguments } else { & $command $arguments | Write-Verbose @@ -142,7 +142,7 @@ function Invoke-Terraform { $maxAttempts = 5 while ($exitCode -ne 0 -and $currentAttempt -lt $maxAttempts) { - Write-InformationColored "Terraform $action failed with exit code $exitCode. This is likely a transient issue, so we are retrying..." -ForegroundColor Yellow -NewLineBefore -InformationAction Continue + Write-ToConsoleLog "Terraform $action failed with exit code $exitCode. This is likely a transient issue, so we are retrying..." -IsWarning $currentAttempt++ $command = "terraform" $arguments = @() @@ -157,7 +157,7 @@ function Invoke-Terraform { $arguments += "-destroy" } - Write-InformationColored "Running Apply Command for $action : $command $arguments" -ForegroundColor Green -NewLineBefore -InformationAction Continue + Write-ToConsoleLog "Running Apply Command for $action : $command $arguments" -IsSuccess & $command $arguments $exitCode = $LASTEXITCODE } @@ -170,12 +170,12 @@ function Invoke-Terraform { # Stop and display timer $StopWatch.Stop() if (!$silent) { - Write-InformationColored "Time taken to complete Terraform apply:" -ForegroundColor Green -NewLineBefore -InformationAction Continue + Write-ToConsoleLog "Time taken to complete Terraform apply:" -IsSuccess } $StopWatch.Elapsed | Format-Table if ($exitCode -ne 0) { - Write-InformationColored "Terraform $action failed with exit code $exitCode after $maxAttempts attempts. Please review the error and try again or raise an issue." -ForegroundColor Red -NewLineBefore -InformationAction Continue + Write-ToConsoleLog "Terraform $action failed with exit code $exitCode after $maxAttempts attempts. Please review the error and try again or raise an issue." -IsError throw "Terraform $action failed with exit code $exitCode after $maxAttempts attempts. Please review the error and try again or raise an issue." } else { if ($output -ne "") { diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/New-Bootstrap.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/New-Bootstrap.ps1 index e8a4d7c..3c28fdd 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/New-Bootstrap.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/New-Bootstrap.ps1 @@ -84,7 +84,7 @@ function New-Bootstrap { if ($hasStarter) { if (!$inputConfig.starter_module_name.Value) { - Write-InformationColored "No starter module has been specified. Please supply the starter module you wish to deploy..." -ForegroundColor Red -InformationAction Continue + Write-ToConsoleLog "No starter module has been specified. Please supply the starter module you wish to deploy..." -IsError throw "No starter module has been specified. Please supply the starter module you wish to deploy..." } @@ -93,7 +93,7 @@ function New-Bootstrap { $chosenStarterConfig = $starterConfig.starter_modules.Value.$($starter_module_name) if($null -eq $chosenStarterConfig ) { - Write-InformationColored "The starter module name '$($starter_module_name)' does not exist in the starter configuration. Please check your input and try again." -ForegroundColor Red -InformationAction Continue + Write-ToConsoleLog "The starter module name '$($starter_module_name)' does not exist in the starter configuration. Please check your input and try again." -IsError throw "The starter module name '$($starter_module_name)' does not exist in the starter configuration. Please check your input and try again." } @@ -283,7 +283,7 @@ function New-Bootstrap { } # Running terraform init and apply - Write-InformationColored "Thank you for providing those inputs, we are now initializing and applying Terraform to bootstrap your environment..." -ForegroundColor Green -NewLineBefore -InformationAction Continue + Write-ToConsoleLog "Thank you for providing those inputs, we are now initializing and applying Terraform to bootstrap your environment..." -IsSuccess # Get bootstrap_subscription_id from inputConfig if available $bootstrapSubscriptionId = "" @@ -294,10 +294,10 @@ function New-Bootstrap { if ($autoApprove) { Invoke-Terraform -moduleFolderPath $bootstrapModulePath -autoApprove -destroy:$destroy.IsPresent -bootstrapSubscriptionId $bootstrapSubscriptionId } else { - Write-InformationColored "Once the plan is complete you will be prompted to confirm the apply." -ForegroundColor Green -NewLineBefore -InformationAction Continue + Write-ToConsoleLog "Once the plan is complete you will be prompted to confirm the apply." -IsSuccess Invoke-Terraform -moduleFolderPath $bootstrapModulePath -destroy:$destroy.IsPresent -bootstrapSubscriptionId $bootstrapSubscriptionId } - Write-InformationColored "Bootstrap has completed successfully! Thanks for using our tool. Head over to Phase 3 in the documentation to continue..." -ForegroundColor Green -NewLineBefore -InformationAction Continue + Write-ToConsoleLog "Bootstrap has completed successfully! Thanks for using our tool. Head over to Phase 3 in the documentation to continue..." -IsSuccess } } diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/New-FolderStructure.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/New-FolderStructure.ps1 index 3cffdf7..85ab0ba 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/New-FolderStructure.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/New-FolderStructure.ps1 @@ -43,7 +43,7 @@ function New-FolderStructure { if ((Test-Path $path) -and !$replaceFiles) { Write-Verbose "Folder $path already exists, so not copying files." } else { - Write-InformationColored "Copying files from $overrideSourceDirectoryPath to $path" -ForegroundColor Green -InformationAction Continue + Write-ToConsoleLog "Copying files from $overrideSourceDirectoryPath to $path" -IsSuccess if (!(Test-Path $path)) { New-Item -Path $path -ItemType "Directory" } diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 index 71470d5..48cce6b 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 @@ -102,16 +102,16 @@ function New-ModuleSetup { } if($newVersionAvailable) { - Write-InformationColored "INFO: A newer $targetFolder module version is available ($latestReleaseTag). You are currently using $currentCalculatedVersion." -ForegroundColor Cyan -InformationAction Continue - Write-InformationColored " To upgrade, run with the -upgrade flag." -ForegroundColor Cyan -InformationAction Continue + Write-ToConsoleLog "A newer $targetFolder module version is available ($latestReleaseTag). You are currently using $currentCalculatedVersion." + Write-ToConsoleLog "To upgrade, run with the -upgrade flag." -IndentLevel 1 } else { if(!$firstRun) { if($upgrade.IsPresent) { - Write-InformationColored "No upgrade required for $targetFolder module; already at latest version ($currentCalculatedVersion)." -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "No upgrade required for $targetFolder module; already at latest version ($currentCalculatedVersion)." -IsWarning } - Write-InformationColored "Using existing $targetFolder module version ($currentCalculatedVersion)." -ForegroundColor Green -InformationAction Continue + Write-ToConsoleLog "Using existing $targetFolder module version ($currentCalculatedVersion)." -IsSuccess } else { - Write-InformationColored "Using specified $targetFolder module version ($currentCalculatedVersion) for the first run." -ForegroundColor Green -InformationAction Continue + Write-ToConsoleLog "Using specified $targetFolder module version ($currentCalculatedVersion) for the first run." -IsSuccess } } } @@ -120,12 +120,12 @@ function New-ModuleSetup { $previousVersionPath = $versionAndPath.path $desiredRelease = $isAutoVersion ? $latestReleaseTag : $release - Write-InformationColored "Upgrading $targetFolder module from $currentVersion to $desiredRelease" -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "Upgrading $targetFolder module from $currentVersion to $desiredRelease" -IsWarning if (-not $autoApprove.IsPresent) { $confirm = Read-Host "Do you want to proceed with the upgrade? (y/n)" if ($confirm -ne "y" -and $confirm -ne "Y") { - Write-InformationColored "Upgrade declined. Continuing with existing version $currentVersion." -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "Upgrade declined. Continuing with existing version $currentVersion." -IsWarning return $versionAndPath } } @@ -150,15 +150,15 @@ function New-ModuleSetup { foreach ($stateFile in $previousStateFiles) { $previousStateFilePath = $stateFile $newStateFilePath = $previousStateFilePath.Replace($previousVersionPath, $versionAndPath.path) - Write-InformationColored "Copying state file from $previousStateFilePath to $newStateFilePath" -ForegroundColor Green -InformationAction Continue + Write-ToConsoleLog "Copying state file from $previousStateFilePath to $newStateFilePath" -IsSuccess Copy-Item -Path $previousStateFilePath -Destination $newStateFilePath -Force | Out-String | Write-Verbose } } else { Write-Verbose "No state files found at $previousVersionPath - skipping migration" } - Write-InformationColored "Module $targetFolder upgraded from version $currentVersion to $($versionAndPath.releaseTag)." -ForegroundColor Green -InformationAction Continue - Write-InformationColored " If any repository files have been updated in the new version, you'll need to turn off branch protection for the run to succeed..." -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "Module $targetFolder upgraded from version $currentVersion to $($versionAndPath.releaseTag)." -IsSuccess + Write-ToConsoleLog " If any repository files have been updated in the new version, you'll need to turn off branch protection for the run to succeed..." -IsWarning } # Update version data diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-ALZConfigurationValue.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-ALZConfigurationValue.ps1 index 1d18f27..4d6dbbf 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-ALZConfigurationValue.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-ALZConfigurationValue.ps1 @@ -41,22 +41,6 @@ function Request-ALZConfigurationValue { [switch] $SensitiveOnly ) - # Lazy-loaded Azure context - only fetched when first needed - $lazyAzureContext = $null - $azureContextFetched = $false - - function Get-LazyAzureContext { - if (-not $azureContextFetched) { - Set-Variable -Name azureContextFetched -Value $true -Scope 1 - if (-not [string]::IsNullOrWhiteSpace($AzureContextOutputDirectory)) { - Set-Variable -Name lazyAzureContext -Value (Get-AzureContext -OutputDirectory $AzureContextOutputDirectory -ClearCache:$AzureContextClearCache.IsPresent) -Scope 1 - } else { - Set-Variable -Name lazyAzureContext -Value (Get-AzureContext -ClearCache:$AzureContextClearCache.IsPresent) -Scope 1 - } - } - return $lazyAzureContext - } - # Helper function to get a property from schema info safely function Get-SchemaProperty { param($SchemaInfo, $PropertyName, $Default = $null) @@ -66,24 +50,6 @@ function Request-ALZConfigurationValue { return $Default } - # Helper function to validate and prompt for a value with GUID format - function Get-ValidatedGuidInput { - param($PromptText, $CurrentValue, $Indent = " ") - $guidRegex = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" - $newValue = Read-Host "$PromptText" - if ([string]::IsNullOrWhiteSpace($newValue)) { - return $CurrentValue - } - while ($newValue -notmatch $guidRegex) { - Write-InformationColored "${Indent}Invalid GUID format. Please enter a valid GUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)" -ForegroundColor Red -InformationAction Continue - $newValue = Read-Host "$PromptText" - if ([string]::IsNullOrWhiteSpace($newValue)) { - return $CurrentValue - } - } - return $newValue - } - # Helper function to prompt for a single input value function Read-InputValue { param( @@ -91,19 +57,14 @@ function Request-ALZConfigurationValue { $CurrentValue, $SchemaInfo, $Indent = "", - $DefaultDescription = "No description available" + $DefaultDescription = "No description available", + $AzureContext ) - # Initialize arrays that will be lazily populated - $Subscriptions = @() - $ManagementGroups = @() - $Regions = @() - + # Use pre-fetched Azure context data from parent scope $description = Get-SchemaProperty -SchemaInfo $SchemaInfo -PropertyName "description" -Default $DefaultDescription $helpLink = Get-SchemaProperty -SchemaInfo $SchemaInfo -PropertyName "helpLink" $isSensitive = Get-SchemaProperty -SchemaInfo $SchemaInfo -PropertyName "sensitive" -Default $false - $allowedValues = Get-SchemaProperty -SchemaInfo $SchemaInfo -PropertyName "allowedValues" - $format = Get-SchemaProperty -SchemaInfo $SchemaInfo -PropertyName "format" $schemaType = Get-SchemaProperty -SchemaInfo $SchemaInfo -PropertyName "type" -Default "string" $isRequired = Get-SchemaProperty -SchemaInfo $SchemaInfo -PropertyName "required" -Default $false $source = Get-SchemaProperty -SchemaInfo $SchemaInfo -PropertyName "source" @@ -141,365 +102,49 @@ function Request-ALZConfigurationValue { # Determine effective default (don't use placeholders as defaults) $effectiveDefault = if ($isPlaceholder) { "" } elseif ($isArray -and $hasPlaceholderItems) { @() } else { $CurrentValue } - # Display prompt information - Write-InformationColored "`n${Indent}[$Key]" -ForegroundColor Yellow -InformationAction Continue - Write-InformationColored "${Indent} $description" -ForegroundColor White -InformationAction Continue - if ($null -ne $helpLink) { - Write-InformationColored "${Indent} Help: $helpLink" -ForegroundColor Gray -InformationAction Continue - } - if ($isRequired) { - Write-InformationColored "${Indent} Required: Yes" -ForegroundColor Magenta -InformationAction Continue - } - if ($null -ne $allowedValues) { - Write-InformationColored "${Indent} Allowed values: $($allowedValues -join ', ')" -ForegroundColor Gray -InformationAction Continue - } - if ($format -eq "guid") { - Write-InformationColored "${Indent} Format: GUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)" -ForegroundColor Gray -InformationAction Continue - } - if ($schemaType -eq "number") { - Write-InformationColored "${Indent} Format: Integer number" -ForegroundColor Gray -InformationAction Continue - } - if ($schemaType -eq "boolean") { - Write-InformationColored "${Indent} Format: true or false" -ForegroundColor Gray -InformationAction Continue - } - if ($isArray) { - Write-InformationColored "${Indent} Format: Comma-separated list of values" -ForegroundColor Gray -InformationAction Continue + # Build base parameters for Read-MenuSelection + $menuParams = @{ + Title = $Key + HelpText = @($description, $helpLink) + Options = @() + DefaultValue = $effectiveDefault + AllowManualEntry = $true + ManualEntryPrompt = "Enter value (press enter to accept default)" + Type = $schemaType + IsRequired = $isRequired + RequiredMessage = "This field is required. Please enter a value." + IsSensitive = $isSensitive } - # Helper to mask sensitive values - show first 3 and last 3 chars if long enough - function Get-MaskedValue { - param($Value) - if ([string]::IsNullOrWhiteSpace($Value)) { - return "(empty)" - } - $valueStr = $Value.ToString() - if ($valueStr.Length -ge 8) { - # Show first 3 and last 3 characters with asterisks in between - return $valueStr.Substring(0, 3) + "***" + $valueStr.Substring($valueStr.Length - 3) - } else { - # Too short to show partial, just mask completely - return "********" - } - } - - # Show current value (mask if sensitive) + # Customize parameters based on input type if ($isArray) { - $displayCurrentValue = if ($null -eq $CurrentValue -or $CurrentValue.Count -eq 0) { - "(empty)" - } elseif ($hasPlaceholderItems) { - "$($CurrentValue -join ', ') (contains placeholders - requires input)" - } elseif ($isSensitive) { - ($CurrentValue | ForEach-Object { Get-MaskedValue -Value $_ }) -join ", " - } else { - $CurrentValue -join ", " - } - } else { - $displayCurrentValue = if ($isSensitive -and -not [string]::IsNullOrWhiteSpace($CurrentValue)) { - Get-MaskedValue -Value $CurrentValue - } elseif ($isPlaceholder) { - "$CurrentValue (placeholder - requires input)" - } elseif ($CurrentValue -is [bool]) { - # Display booleans in lowercase - if ($CurrentValue) { "true" } else { "false" } - } else { - $CurrentValue - } - } - Write-InformationColored "${Indent} Current value: $displayCurrentValue" -ForegroundColor Gray -InformationAction Continue - - # Build prompt text - if ($isArray) { - # Use effective default (empty if has placeholders) - $effectiveArrayDefault = if ($hasPlaceholderItems) { @() } else { $CurrentValue } - $currentAsString = if ($null -eq $effectiveArrayDefault -or $effectiveArrayDefault.Count -eq 0) { - "" - } elseif ($isSensitive) { - ($effectiveArrayDefault | ForEach-Object { Get-MaskedValue -Value $_ }) -join ", " - } else { - $effectiveArrayDefault -join ", " - } - $promptText = if ([string]::IsNullOrWhiteSpace($currentAsString)) { - "${Indent} Enter values (comma-separated)" - } else { - "${Indent} Enter values (comma-separated, default: $currentAsString)" - } - } else { - $displayDefault = if ($isSensitive -and -not [string]::IsNullOrWhiteSpace($effectiveDefault)) { - Get-MaskedValue -Value $effectiveDefault - } elseif ($effectiveDefault -is [bool]) { - # Display booleans in lowercase - if ($effectiveDefault) { "true" } else { "false" } - } else { - $effectiveDefault - } - $promptText = if ([string]::IsNullOrWhiteSpace($effectiveDefault) -and $effectiveDefault -isnot [bool]) { - "${Indent} Enter value" - } else { - "${Indent} Enter value (default: $displayDefault)" - } - } - - # Get new value based on input type - if ($isSensitive) { - $secureValue = Read-Host "$promptText" -AsSecureString - $newValue = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto( - [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureValue) - ) - if ([string]::IsNullOrWhiteSpace($newValue)) { - $newValue = $effectiveDefault - } - # Require value if required - while ($isRequired -and [string]::IsNullOrWhiteSpace($newValue)) { - Write-InformationColored "${Indent} This field is required. Please enter a value." -ForegroundColor Red -InformationAction Continue - $secureValue = Read-Host "$promptText" -AsSecureString - $newValue = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto( - [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureValue) - ) - } - } elseif ($isArray) { - $inputValue = Read-Host "$promptText" - # Use effective default (empty array if has placeholders) - $effectiveArrayDefault = if ($hasPlaceholderItems) { @() } else { $CurrentValue } - if ([string]::IsNullOrWhiteSpace($inputValue)) { - $newValue = $effectiveArrayDefault - } else { - # Parse comma-separated values into an array - $newValue = @($inputValue -split ',' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) - } + $menuParams.HelpText = @($description, $helpLink, "Format: Comma-separated list of values") + $menuParams.ManualEntryPrompt = "Enter values (comma-separated)" + $menuParams.RequiredMessage = "This field is required. Please enter values." } elseif ($source -eq "subscription") { - # Lazy load Azure context if we haven't loaded subscriptions yet - if ($Subscriptions.Count -eq 0) { - $ctx = Get-LazyAzureContext - if ($null -ne $ctx -and $ctx.ContainsKey('Subscriptions')) { - $Subscriptions = $ctx.Subscriptions - } - } - if ($Subscriptions.Count -gt 0) { - # Show subscription selection list - Write-InformationColored "${Indent} Available subscriptions:" -ForegroundColor Cyan -InformationAction Continue - for ($i = 0; $i -lt $Subscriptions.Count; $i++) { - $sub = $Subscriptions[$i] - if ($sub.id -eq $effectiveDefault) { - Write-InformationColored "${Indent} [$($i + 1)] $($sub.name) ($($sub.id)) (current)" -ForegroundColor Green -InformationAction Continue - } else { - Write-InformationColored "${Indent} [$($i + 1)] $($sub.name) ($($sub.id))" -ForegroundColor White -InformationAction Continue - } - } - Write-InformationColored "${Indent} [0] Enter manually" -ForegroundColor Gray -InformationAction Continue - - $selection = Read-Host "${Indent} Select subscription (1-$($Subscriptions.Count), 0 for manual entry, or press Enter for default)" - if ([string]::IsNullOrWhiteSpace($selection)) { - $newValue = $effectiveDefault - } elseif ($selection -eq "0") { - $newValue = Get-ValidatedGuidInput -PromptText "${Indent} Enter subscription ID" -CurrentValue $effectiveDefault -Indent "${Indent} " - } else { - $selIndex = [int]$selection - 1 - if ($selIndex -ge 0 -and $selIndex -lt $Subscriptions.Count) { - $newValue = $Subscriptions[$selIndex].id - } else { - Write-InformationColored "${Indent} Invalid selection, using default" -ForegroundColor Yellow -InformationAction Continue - $newValue = $effectiveDefault - } - } - # Require value if required - while ($isRequired -and [string]::IsNullOrWhiteSpace($newValue)) { - Write-InformationColored "${Indent} This field is required. Please select a subscription." -ForegroundColor Red -InformationAction Continue - $selection = Read-Host "${Indent} Select subscription (1-$($Subscriptions.Count), 0 for manual entry)" - if ($selection -eq "0") { - $newValue = Get-ValidatedGuidInput -PromptText "${Indent} Enter subscription ID" -CurrentValue "" -Indent "${Indent} " - } elseif (-not [string]::IsNullOrWhiteSpace($selection)) { - $selIndex = [int]$selection - 1 - if ($selIndex -ge 0 -and $selIndex -lt $Subscriptions.Count) { - $newValue = $Subscriptions[$selIndex].id - } - } - } - } + $menuParams.OptionsTitle = "Available subscriptions:" + $menuParams.Options = $AzureContext.Subscriptions + $menuParams.ManualEntryPrompt = "Enter subscription ID" + $menuParams.RequiredMessage = "This field is required. Please select a subscription." + $menuParams.EmptyMessage = "No subscriptions found in Azure context." } elseif ($source -eq "managementGroup") { - # Lazy load Azure context if we haven't loaded management groups yet - if ($ManagementGroups.Count -eq 0) { - $ctx = Get-LazyAzureContext - if ($null -ne $ctx -and $ctx.ContainsKey('ManagementGroups')) { - $ManagementGroups = $ctx.ManagementGroups - } - } - if ($ManagementGroups.Count -gt 0) { - # Show management group selection list - Write-InformationColored "${Indent} Available management groups:" -ForegroundColor Cyan -InformationAction Continue - for ($i = 0; $i -lt $ManagementGroups.Count; $i++) { - $mg = $ManagementGroups[$i] - if ($mg.id -eq $effectiveDefault) { - Write-InformationColored "${Indent} [$($i + 1)] $($mg.displayName) ($($mg.id)) (current)" -ForegroundColor Green -InformationAction Continue - } else { - Write-InformationColored "${Indent} [$($i + 1)] $($mg.displayName) ($($mg.id))" -ForegroundColor White -InformationAction Continue - } - } - Write-InformationColored "${Indent} [0] Enter manually" -ForegroundColor Gray -InformationAction Continue - Write-InformationColored "${Indent} Press Enter to leave empty (uses Tenant Root Group)" -ForegroundColor Gray -InformationAction Continue - - $selection = Read-Host "${Indent} Select management group (1-$($ManagementGroups.Count), 0 for manual entry, or press Enter for default)" - if ([string]::IsNullOrWhiteSpace($selection)) { - $newValue = $effectiveDefault - } elseif ($selection -eq "0") { - $newValue = Read-Host "${Indent} Enter management group ID" - if ([string]::IsNullOrWhiteSpace($newValue)) { - $newValue = $effectiveDefault - } - } else { - $selIndex = [int]$selection - 1 - if ($selIndex -ge 0 -and $selIndex -lt $ManagementGroups.Count) { - $newValue = $ManagementGroups[$selIndex].id - } else { - Write-InformationColored "${Indent} Invalid selection, using default" -ForegroundColor Yellow -InformationAction Continue - $newValue = $effectiveDefault - } - } - } + $menuParams.OptionsTitle = "Available management groups:" + $menuParams.Options = $AzureContext.ManagementGroups + $menuParams.ManualEntryPrompt = "Enter management group ID" + $menuParams.RequiredMessage = "This field is required. Please select a management group." + $menuParams.EmptyMessage = "No management groups found in Azure context." } elseif ($source -eq "azureRegion") { - # Lazy load Azure context if we haven't loaded regions yet - if ($Regions.Count -eq 0) { - $ctx = Get-LazyAzureContext - if ($null -ne $ctx -and $ctx.ContainsKey('Regions')) { - $Regions = $ctx.Regions - } - } - if ($Regions.Count -gt 0) { - # Show region selection list - Write-InformationColored "${Indent} Available regions (AZ = Availability Zone support):" -ForegroundColor Cyan -InformationAction Continue - for ($i = 0; $i -lt $Regions.Count; $i++) { - $region = $Regions[$i] - $azIndicator = if ($region.hasAvailabilityZones) { " [AZ]" } else { "" } - if ($region.name -eq $effectiveDefault) { - Write-InformationColored "${Indent} [$($i + 1)] $($region.displayName) ($($region.name))$azIndicator (current)" -ForegroundColor Green -InformationAction Continue - } else { - Write-InformationColored "${Indent} [$($i + 1)] $($region.displayName) ($($region.name))$azIndicator" -ForegroundColor White -InformationAction Continue - } - } - Write-InformationColored "${Indent} [0] Enter manually" -ForegroundColor Gray -InformationAction Continue - - $selection = Read-Host "${Indent} Select region (1-$($Regions.Count), 0 for manual entry, or press Enter for default)" - if ([string]::IsNullOrWhiteSpace($selection)) { - $newValue = $effectiveDefault - } elseif ($selection -eq "0") { - $newValue = Read-Host "${Indent} Enter region name (e.g., uksouth, eastus)" - if ([string]::IsNullOrWhiteSpace($newValue)) { - $newValue = $effectiveDefault - } - } else { - $selIndex = [int]$selection - 1 - if ($selIndex -ge 0 -and $selIndex -lt $Regions.Count) { - $newValue = $Regions[$selIndex].name - } else { - Write-InformationColored "${Indent} Invalid selection, using default" -ForegroundColor Yellow -InformationAction Continue - $newValue = $effectiveDefault - } - } - # Require value if required - while ($isRequired -and [string]::IsNullOrWhiteSpace($newValue)) { - Write-InformationColored "${Indent} This field is required. Please select a region." -ForegroundColor Red -InformationAction Continue - $selection = Read-Host "${Indent} Select region (1-$($Regions.Count), 0 for manual entry)" - if ($selection -eq "0") { - $newValue = Read-Host "${Indent} Enter region name (e.g., uksouth, eastus)" - } elseif (-not [string]::IsNullOrWhiteSpace($selection)) { - $selIndex = [int]$selection - 1 - if ($selIndex -ge 0 -and $selIndex -lt $Regions.Count) { - $newValue = $Regions[$selIndex].name - } - } - } - } - } elseif ($format -eq "guid") { - $newValue = Get-ValidatedGuidInput -PromptText $promptText -CurrentValue $effectiveDefault -Indent "${Indent} " - # Require value if required - while ($isRequired -and [string]::IsNullOrWhiteSpace($newValue)) { - Write-InformationColored "${Indent} This field is required. Please enter a value." -ForegroundColor Red -InformationAction Continue - $newValue = Get-ValidatedGuidInput -PromptText $promptText -CurrentValue $effectiveDefault -Indent "${Indent} " - } - } elseif ($schemaType -eq "number") { - $newValue = Read-Host "$promptText" - if ([string]::IsNullOrWhiteSpace($newValue)) { - $newValue = $effectiveDefault - } - # Validate integer format and require if required - $intResult = 0 - # Check if effective default is valid, if not clear it - if (-not [string]::IsNullOrWhiteSpace($newValue)) { - $valueToCheck = if ($newValue -is [int]) { $newValue.ToString() } else { $newValue } - while (-not [int]::TryParse($valueToCheck, [ref]$intResult)) { - Write-InformationColored "${Indent} Invalid format. Please enter an integer number." -ForegroundColor Red -InformationAction Continue - $newValue = Read-Host "${Indent} Enter value" - if ([string]::IsNullOrWhiteSpace($newValue)) { - $newValue = "" - break - } - $valueToCheck = $newValue - } - } - # Require value if required - while ($isRequired -and [string]::IsNullOrWhiteSpace($newValue)) { - Write-InformationColored "${Indent} This field is required. Please enter a value." -ForegroundColor Red -InformationAction Continue - $newValue = Read-Host "${Indent} Enter value" - # Re-validate integer format - if (-not [string]::IsNullOrWhiteSpace($newValue)) { - while (-not [int]::TryParse($newValue, [ref]$intResult)) { - Write-InformationColored "${Indent} Invalid format. Please enter an integer number." -ForegroundColor Red -InformationAction Continue - $newValue = Read-Host "${Indent} Enter value" - if ([string]::IsNullOrWhiteSpace($newValue)) { - break - } - } - } - } - # Convert to integer if we have a valid value - if (-not [string]::IsNullOrWhiteSpace($newValue) -and [int]::TryParse($newValue.ToString(), [ref]$intResult)) { - $newValue = $intResult - } + $menuParams.OptionsTitle = "Available regions (AZ = Availability Zone support):" + $menuParams.Options = $AzureContext.Regions + $menuParams.ManualEntryPrompt = "Enter region name (e.g., uksouth, eastus)" + $menuParams.RequiredMessage = "This field is required. Please select a region." + $menuParams.EmptyMessage = "No regions found in Azure context." } elseif ($schemaType -eq "boolean") { - $newValue = Read-Host "$promptText" - if ([string]::IsNullOrWhiteSpace($newValue)) { - $newValue = $effectiveDefault - } - # Validate and convert boolean - if (-not [string]::IsNullOrWhiteSpace($newValue)) { - $validBooleans = @('true', 'false', 'yes', 'no', '1', '0') - $valueStr = $newValue.ToString().ToLower() - while ($validBooleans -notcontains $valueStr) { - Write-InformationColored "${Indent} Invalid format. Please enter true or false." -ForegroundColor Red -InformationAction Continue - $newValue = Read-Host "$promptText" - if ([string]::IsNullOrWhiteSpace($newValue)) { - $newValue = $effectiveDefault - break - } - $valueStr = $newValue.ToString().ToLower() - } - # Convert to actual boolean - if (-not [string]::IsNullOrWhiteSpace($newValue)) { - $valueStr = $newValue.ToString().ToLower() - $newValue = $valueStr -in @('true', 'yes', '1') - } - } - } else { - $newValue = Read-Host "$promptText" - if ([string]::IsNullOrWhiteSpace($newValue)) { - $newValue = $effectiveDefault - } - # Require value if required - while ($isRequired -and [string]::IsNullOrWhiteSpace($newValue)) { - Write-InformationColored "${Indent} This field is required. Please enter a value." -ForegroundColor Red -InformationAction Continue - $newValue = Read-Host "$promptText" - } + $menuParams.ManualEntryPrompt = "Enter value (true/false) (press enter to accept default)" + $menuParams.DefaultValue = $effectiveDefault.ToString().ToLower() } - # Validate against allowed values if specified - if ($null -ne $allowedValues -and -not [string]::IsNullOrWhiteSpace($newValue)) { - while ($allowedValues -notcontains $newValue) { - Write-InformationColored "${Indent} Invalid value. Please choose from: $($allowedValues -join ', ')" -ForegroundColor Red -InformationAction Continue - $newValue = Read-Host "$promptText" - if ([string]::IsNullOrWhiteSpace($newValue)) { - $newValue = $effectiveDefault - } - } - } + $newValue = Read-MenuSelection @menuParams # Return value along with sensitivity info return @{ @@ -509,6 +154,19 @@ function Request-ALZConfigurationValue { } if ($PSCmdlet.ShouldProcess("Configuration files", "prompt for input values")) { + $AzureContext = $null + + # Fetch Azure context once upfront if not in SensitiveOnly mode + if (-not $SensitiveOnly.IsPresent) { + if (-not [string]::IsNullOrWhiteSpace($AzureContextOutputDirectory)) { + $AzureContext = Get-AzureContext -OutputDirectory $AzureContextOutputDirectory -ClearCache:$AzureContextClearCache.IsPresent + } else { + $AzureContext = Get-AzureContext -ClearCache:$AzureContextClearCache.IsPresent + } + } + + Write-Verbose (ConvertTo-Json $AzureContext) + # Load the schema file $schemaPath = Join-Path $PSScriptRoot "AcceleratorInputSchema.json" if (-not (Test-Path $schemaPath)) { @@ -525,8 +183,8 @@ function Request-ALZConfigurationValue { # Process inputs.yaml - prompt for ALL inputs if (Test-Path $inputsYamlPath) { - Write-InformationColored "`n=== Bootstrap Configuration (inputs.yaml) ===" -ForegroundColor Cyan -InformationAction Continue - Write-InformationColored "For more information, see: https://aka.ms/alz/acc/phase0" -ForegroundColor Gray -InformationAction Continue + Write-ToConsoleLog "=== Bootstrap Configuration (inputs.yaml) ===" + Write-ToConsoleLog "For more information, see: https://aka.ms/alz/acc/phase0" # Read the raw content to preserve comments and ordering $inputsYamlContent = Get-Content -Path $inputsYamlPath -Raw @@ -565,10 +223,6 @@ function Request-ALZConfigurationValue { continue } - Write-InformationColored "`n[subscription_ids]" -ForegroundColor Yellow -InformationAction Continue - Write-InformationColored " The subscription IDs for the platform landing zone subscriptions" -ForegroundColor White -InformationAction Continue - Write-InformationColored " Help: https://aka.ms/alz/acc/phase0" -ForegroundColor Gray -InformationAction Continue - $subscriptionIdsSchema = $bootstrapSchema.subscription_ids.properties foreach ($subKey in @($currentValue.Keys)) { @@ -582,7 +236,7 @@ function Request-ALZConfigurationValue { continue } - $result = Read-InputValue -Key $subKey -CurrentValue $subCurrentValue -SchemaInfo $subSchemaInfo -Indent " " -DefaultDescription "Subscription ID for $subKey" + $result = Read-InputValue -Key $subKey -CurrentValue $subCurrentValue -SchemaInfo $subSchemaInfo -DefaultDescription "Subscription ID for $subKey" -AzureContext $AzureContext $subNewValue = $result.Value $subIsSensitive = $result.IsSensitive @@ -632,7 +286,7 @@ function Request-ALZConfigurationValue { $envVarName = "TF_VAR_$key" $envVarValue = [System.Environment]::GetEnvironmentVariable($envVarName) if (-not [string]::IsNullOrWhiteSpace($envVarValue)) { - Write-InformationColored "`n[$key] - Already set via environment variable $envVarName" -ForegroundColor Gray -InformationAction Continue + Write-ToConsoleLog "[$key] - Already set via environment variable $envVarName" continue } @@ -640,12 +294,12 @@ function Request-ALZConfigurationValue { $isPlaceholderValue = $currentValue -is [string] -and $currentValue -match '^\s*<.*>\s*$' $isSetViaEnvVarPlaceholder = $currentValue -is [string] -and $currentValue -like "Set via environment variable*" if (-not [string]::IsNullOrWhiteSpace($currentValue) -and -not $isPlaceholderValue -and -not $isSetViaEnvVarPlaceholder) { - Write-InformationColored "`n[$key] - Already set in configuration" -ForegroundColor Gray -InformationAction Continue + Write-ToConsoleLog "[$key] - Already set in configuration" continue } } - $result = Read-InputValue -Key $key -CurrentValue $currentValue -SchemaInfo $schemaInfo + $result = Read-InputValue -Key $key -CurrentValue $currentValue -SchemaInfo $schemaInfo -AzureContext $AzureContext $newValue = $result.Value $isSensitive = $result.IsSensitive @@ -723,9 +377,20 @@ function Request-ALZConfigurationValue { # Handle array values - convert to YAML inline array format $yamlArrayValue = "[" + (($newValue | ForEach-Object { "`"$_`"" }) -join ", ") + "]" - # Match the existing array or empty value - use greedy match within brackets - # Pattern matches: key: [anything] with optional comment - $pattern = "(?m)^(\s*${key}:\s*)\[[^\]]*\](\s*)(#.*)?$" + # Check if old value is already in array format or a different format (string/placeholder) + $oldValueIsArray = $oldValue -is [System.Collections.IList] + if ($oldValueIsArray) { + # Match the existing array - greedy match within brackets + $pattern = "(?m)^(\s*${key}:\s*)\[[^\]]*\](\s*)(#.*)?$" + } else { + # Old value was a string/placeholder, match quoted or unquoted value + $escapedOldValue = if ([string]::IsNullOrWhiteSpace($oldValue)) { "" } else { [regex]::Escape($oldValue.ToString()) } + if ([string]::IsNullOrWhiteSpace($escapedOldValue)) { + $pattern = "(?m)^(\s*${key}:\s*)`"?`"?(\s*)(#.*)?$" + } else { + $pattern = "(?m)^(\s*${key}:\s*)`"?${escapedOldValue}`"?(\s*)(#.*)?$" + } + } $replacement = "`${1}$yamlArrayValue`${2}`${3}" } elseif ($isBoolean) { # Handle boolean values - no quotes, lowercase true/false @@ -761,16 +426,16 @@ function Request-ALZConfigurationValue { } $updatedContent | Set-Content -Path $inputsYamlPath -Force -NoNewline - Write-InformationColored "`nUpdated inputs.yaml" -ForegroundColor Green -InformationAction Continue + Write-ToConsoleLog "Updated inputs.yaml" -IsSuccess # Display summary of sensitive environment variables if ($sensitiveEnvVars.Count -gt 0) { - Write-InformationColored "`nSensitive values have been set as environment variables:" -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "Sensitive values have been set as environment variables:" -IsWarning foreach ($varKey in $sensitiveEnvVars.Keys) { - Write-InformationColored " $varKey -> $($sensitiveEnvVars[$varKey])" -ForegroundColor Gray -InformationAction Continue + Write-ToConsoleLog "$varKey -> $($sensitiveEnvVars[$varKey])" -IsSelection -IndentLevel 1 } - Write-InformationColored "`nThese environment variables are set for the current process only." -ForegroundColor Gray -InformationAction Continue - Write-InformationColored "The config file contains placeholders indicating the values are set via environment variables." -ForegroundColor Gray -InformationAction Continue + Write-ToConsoleLog "These environment variables are set for the current process only." + Write-ToConsoleLog "The config file contains placeholders indicating the values are set via environment variables." } $configUpdated = $true diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-AcceleratorConfigurationInput.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-AcceleratorConfigurationInput.ps1 index 64732b1..f8bc3e3 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-AcceleratorConfigurationInput.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-AcceleratorConfigurationInput.ps1 @@ -28,20 +28,21 @@ function Request-AcceleratorConfigurationInput { ) if ($PSCmdlet.ShouldProcess("Accelerator folder structure setup", "prompt and create")) { + # Display appropriate header message if ($Destroy.IsPresent) { - Write-InformationColored "Running in destroy mode. Please provide the path to your existing accelerator folder." -ForegroundColor Yellow -NewLineBefore -InformationAction Continue + Write-ToConsoleLog "Running in destroy mode. Please provide the path to your existing accelerator folder." -IsWarning } else { - Write-InformationColored "No input configuration files provided. Let's set up the accelerator folder structure first..." -ForegroundColor Green -NewLineBefore -InformationAction Continue - Write-InformationColored "For more information, see: https://aka.ms/alz/acc/phase2" -ForegroundColor Cyan -InformationAction Continue + Write-ToConsoleLog "No input configuration files provided. Let's set up the accelerator folder structure first..." -IsSuccess + Write-ToConsoleLog "For more information, see: https://aka.ms/alz/acc/phase2" } # Prompt for target folder path (first prompt for both modes) - Write-InformationColored "`nEnter the target folder path for the accelerator files (default: ~/accelerator):" -ForegroundColor Yellow -InformationAction Continue - $targetFolderPathInput = Read-Host "Target folder path" - if ([string]::IsNullOrWhiteSpace($targetFolderPathInput)) { - $targetFolderPathInput = "~/accelerator" - } + $targetFolderPathInput = Read-MenuSelection ` + -Title "Enter the target folder path for the accelerator files:" ` + -DefaultValue "~/accelerator" ` + -AllowManualEntry ` + -ManualEntryPrompt "Target folder path" # Normalize the path $normalizedTargetPath = Get-NormalizedPath -Path $targetFolderPathInput @@ -54,9 +55,18 @@ function Request-AcceleratorConfigurationInput { # If folder exists, ask about overwriting before other prompts if ($folderConfig.FolderExists) { # Ask about overwriting the folder - Write-InformationColored "`nTarget folder '$normalizedTargetPath' already exists." -ForegroundColor Yellow -InformationAction Continue - $forceResponse = Read-Host "Do you want to overwrite it? (y/N)" - if ($forceResponse -eq "y" -or $forceResponse -eq "Y") { + Write-ToConsoleLog "Target folder '$normalizedTargetPath' already exists." -IsWarning + $forceResponse = Read-MenuSelection ` + -Title "Do you want to overwrite the existing folder structure? This will replace existing configuration files." ` + -DefaultValue "no" ` + -Type "boolean" ` + -AllowManualEntry ` + -ManualEntryPrompt "Enter '[y]es' to overwrite or '[n]o' to keep existing" + + Write-Verbose "User overwrite response: $forceResponse" + Write-Verbose $forceResponse.GetType().FullName + + if ($forceResponse) { $forceFlag = $true } else { # User wants to keep existing folder @@ -65,11 +75,11 @@ function Request-AcceleratorConfigurationInput { # Validate config files exist if (-not $folderConfig.IsValid) { if (-not (Test-Path -Path $folderConfig.ConfigFolderPath)) { - Write-InformationColored "ERROR: Config folder not found at '$($folderConfig.ConfigFolderPath)'" -ForegroundColor Red -InformationAction Continue + Write-ToConsoleLog "ERROR: Config folder not found at '$($folderConfig.ConfigFolderPath)'" -IsError } elseif (-not (Test-Path -Path $folderConfig.InputsYamlPath)) { - Write-InformationColored "ERROR: Required configuration file not found: inputs.yaml" -ForegroundColor Red -InformationAction Continue + Write-ToConsoleLog "ERROR: Required configuration file not found: inputs.yaml" -IsError } - Write-InformationColored "Please overwrite the folder structure by choosing 'y', or run New-AcceleratorFolderStructure manually." -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "Please overwrite the folder structure by choosing 'y', or run New-AcceleratorFolderStructure manually." -IsWarning return ConvertTo-AcceleratorResult -Continue $false } } @@ -78,20 +88,20 @@ function Request-AcceleratorConfigurationInput { # Handle destroy mode - validate existing folder and return early if ($Destroy.IsPresent) { if (-not $folderConfig.FolderExists) { - Write-InformationColored "ERROR: Target folder '$normalizedTargetPath' does not exist." -ForegroundColor Red -InformationAction Continue - Write-InformationColored "Cannot destroy a deployment that doesn't exist. Please check the path and try again." -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "ERROR: Target folder '$normalizedTargetPath' does not exist." -IsError + Write-ToConsoleLog "Cannot destroy a deployment that doesn't exist. Please check the path and try again." -IsWarning return ConvertTo-AcceleratorResult -Continue $false } if (-not (Test-Path -Path $folderConfig.ConfigFolderPath)) { - Write-InformationColored "ERROR: Config folder not found at '$($folderConfig.ConfigFolderPath)'" -ForegroundColor Red -InformationAction Continue - Write-InformationColored "Cannot destroy a deployment without configuration files." -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "ERROR: Config folder not found at '$($folderConfig.ConfigFolderPath)'" -IsError + Write-ToConsoleLog "Cannot destroy a deployment without configuration files." -IsWarning return ConvertTo-AcceleratorResult -Continue $false } if (-not (Test-Path -Path $folderConfig.InputsYamlPath)) { - Write-InformationColored "ERROR: Required configuration file not found: inputs.yaml" -ForegroundColor Red -InformationAction Continue - Write-InformationColored "Cannot destroy a deployment without inputs.yaml." -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "ERROR: Required configuration file not found: inputs.yaml" -IsError + Write-ToConsoleLog "Cannot destroy a deployment without inputs.yaml." -IsWarning return ConvertTo-AcceleratorResult -Continue $false } @@ -99,10 +109,10 @@ function Request-AcceleratorConfigurationInput { $configPaths = Get-AcceleratorConfigPath -IacType $folderConfig.IacType -ConfigFolderPath $folderConfig.ConfigFolderPath $resolvedTargetPath = (Resolve-Path -Path $normalizedTargetPath).Path - Write-InformationColored "Using existing folder: $resolvedTargetPath" -ForegroundColor Green -InformationAction Continue + Write-ToConsoleLog "Using existing folder: $resolvedTargetPath" -IsSuccess # Prompt for sensitive inputs that are not already set (e.g., PATs) - Write-InformationColored "`nChecking for sensitive inputs that need to be provided..." -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "Checking for sensitive inputs that need to be provided..." -IsWarning Request-ALZConfigurationValue ` -ConfigFolderPath $folderConfig.ConfigFolderPath ` @@ -112,7 +122,7 @@ function Request-AcceleratorConfigurationInput { -AzureContextClearCache:$ClearCache.IsPresent ` -SensitiveOnly - Write-InformationColored "`nProceeding with destroy..." -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "Proceeding with destroy..." -IsWarning return ConvertTo-AcceleratorResult -Continue $true ` -InputConfigFilePaths $configPaths.InputConfigFilePaths ` @@ -134,7 +144,7 @@ function Request-AcceleratorConfigurationInput { } else { 0 } $selectedIacType = Read-MenuSelection ` - -Title "`nSelect the Infrastructure as Code (IaC) type:" ` + -Title "Select the Infrastructure as Code (IaC) type:" ` -Options $iacTypeOptions ` -DefaultIndex $defaultIacTypeIndex @@ -145,29 +155,27 @@ function Request-AcceleratorConfigurationInput { } else { 0 } $selectedVersionControl = Read-MenuSelection ` - -Title "`nSelect the Version Control System:" ` + -Title "Select the Version Control System:" ` -Options $versionControlOptions ` -DefaultIndex $defaultVcsIndex # Prompt for scenario number (Terraform only) if ($selectedIacType -eq "terraform") { - $scenarioDescriptions = @( - "Full Multi-Region - Hub and Spoke VNet", - "Full Multi-Region - Virtual WAN", - "Full Multi-Region NVA - Hub and Spoke VNet", - "Full Multi-Region NVA - Virtual WAN", - "Management Only", - "Full Single-Region - Hub and Spoke VNet", - "Full Single-Region - Virtual WAN", - "Full Single-Region NVA - Hub and Spoke VNet", - "Full Single-Region NVA - Virtual WAN" + $scenarioOptions = @( + @{ label = "1 - Full Multi-Region - Hub and Spoke VNet"; value = 1 }, + @{ label = "2 - Full Multi-Region - Virtual WAN"; value = 2 }, + @{ label = "3 - Full Multi-Region NVA - Hub and Spoke VNet"; value = 3 }, + @{ label = "4 - Full Multi-Region NVA - Virtual WAN"; value = 4 }, + @{ label = "5 - Management Only"; value = 5 }, + @{ label = "6 - Full Single-Region - Hub and Spoke VNet"; value = 6 }, + @{ label = "7 - Full Single-Region - Virtual WAN"; value = 7 }, + @{ label = "8 - Full Single-Region NVA - Hub and Spoke VNet"; value = 8 }, + @{ label = "9 - Full Single-Region NVA - Virtual WAN"; value = 9 } ) - $scenarioNumbers = 1..$scenarioDescriptions.Count $selectedScenarioNumber = Read-MenuSelection ` - -Title "`nSelect the Terraform scenario (see https://aka.ms/alz/acc/scenarios):" ` - -Options $scenarioNumbers ` - -OptionDescriptions $scenarioDescriptions ` + -Title "Select the Terraform scenario (see https://aka.ms/alz/acc/scenarios):" ` + -Options $scenarioOptions ` -DefaultIndex 0 } } @@ -182,7 +190,7 @@ function Request-AcceleratorConfigurationInput { -outputFolderName $OutputFolderName ` -force:$forceFlag - Write-InformationColored "`nFolder structure created at: $normalizedTargetPath" -ForegroundColor Green -InformationAction Continue + Write-ToConsoleLog "Folder structure created at: $normalizedTargetPath" -IsSuccess } # Resolve the path after folder creation or validation @@ -195,13 +203,19 @@ function Request-AcceleratorConfigurationInput { } if ($useExistingFolder) { - Write-InformationColored "`nUsing existing folder structure at: $resolvedTargetPath" -ForegroundColor Green -InformationAction Continue + Write-ToConsoleLog "Using existing folder structure at: $resolvedTargetPath" -IsSuccess } - Write-InformationColored "Config folder: $configFolderPath" -ForegroundColor Cyan -InformationAction Continue + Write-ToConsoleLog "Config folder: $configFolderPath" # Offer to configure inputs interactively (default is Yes) - $configureNowResponse = Read-Host "`nWould you like to configure the input values interactively now? (Y/n)" - if ($configureNowResponse -ne "n" -and $configureNowResponse -ne "N") { + $configureNowResponse = Read-MenuSelection ` + -Title "Would you like to configure the input values interactively now?" ` + -DefaultValue "yes" ` + -Type "boolean" ` + -AllowManualEntry ` + -ManualEntryPrompt "Enter '[y]es' for interactive mode or '[n]o' to update the file manually later" + + if ($configureNowResponse) { Request-ALZConfigurationValue ` -ConfigFolderPath $configFolderPath ` -IacType $selectedIacType ` @@ -209,7 +223,7 @@ function Request-AcceleratorConfigurationInput { -AzureContextOutputDirectory $outputFolderPath ` -AzureContextClearCache:$ClearCache.IsPresent } else { - Write-InformationColored "`nChecking for sensitive inputs that need to be provided..." -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "Checking for sensitive inputs that need to be provided..." -IsWarning Request-ALZConfigurationValue ` -ConfigFolderPath $configFolderPath ` @@ -233,52 +247,64 @@ function Request-AcceleratorConfigurationInput { } if ($null -ne $vsCodeCommand) { - $openInVsCodeResponse = Read-Host "`nWould you like to open the config folder in $($vsCodeName)? (Y/n)" - if ($openInVsCodeResponse -ne "n" -and $openInVsCodeResponse -ne "N") { - Write-InformationColored "Opening config folder in $vsCodeName..." -ForegroundColor Green -InformationAction Continue + $openInVsCodeResponse = Read-MenuSelection ` + -Title "Would you like to open the config folder in $($vsCodeName)?" ` + -DefaultValue "yes" ` + -Type "boolean" ` + -AllowManualEntry ` + -ManualEntryPrompt "Enter '[y]es' to open or '[n]o' to continue without opening" + + if ($openInVsCodeResponse) { + Write-ToConsoleLog "Opening config folder in $vsCodeName..." -IsSuccess & $vsCodeCommand $configFolderPath } } - Write-InformationColored "`nPlease check and update the configuration files in the config folder before continuing:" -ForegroundColor Yellow -InformationAction Continue - Write-InformationColored " - inputs.yaml: Bootstrap configuration (required)" -ForegroundColor White -InformationAction Continue + Write-ToConsoleLog "Please check and update the configuration files in the config folder before continuing:" -IsWarning + Write-ToConsoleLog " - inputs.yaml: Bootstrap configuration (required)" -IsSelection if ($selectedIacType -eq "terraform") { - Write-InformationColored " - platform-landing-zone.tfvars: Platform configuration (required)" -ForegroundColor White -InformationAction Continue - Write-InformationColored " - starter_locations: Enter the regions for you platform landing zone (required)" -ForegroundColor White -InformationAction Continue - Write-InformationColored " - defender_email_security_contact: Enter the email security contact for Microsoft Defender for Cloud (required)" -ForegroundColor White -InformationAction Continue - Write-InformationColored " - lib/: Library customizations (optional)" -ForegroundColor White -InformationAction Continue + Write-ToConsoleLog " - platform-landing-zone.tfvars: Platform configuration (required)" -IsSelection + Write-ToConsoleLog " - starter_locations: Enter the regions for you platform landing zone (required)" -IsSelection + Write-ToConsoleLog " - defender_email_security_contact: Enter the email security contact for Microsoft Defender for Cloud (required)" -IsSelection + Write-ToConsoleLog " - lib/: Library customizations (optional)" -IsSelection } elseif ($selectedIacType -eq "bicep") { - Write-InformationColored " - platform-landing-zone.yaml: Platform configuration (required)" -ForegroundColor White -InformationAction Continue - Write-InformationColored " - starter_locations: Enter the regions for you platform landing zone (required)" -ForegroundColor White -InformationAction Continue + Write-ToConsoleLog " - platform-landing-zone.yaml: Platform configuration (required)" -IsSelection + Write-ToConsoleLog " - starter_locations: Enter the regions for you platform landing zone (required)" -IsSelection } - Write-InformationColored "`nFor more details, see: https://azure.github.io/Azure-Landing-Zones/accelerator/configuration-files/" -ForegroundColor Cyan -InformationAction Continue + Write-ToConsoleLog "For more details, see: https://azure.github.io/Azure-Landing-Zones/accelerator/configuration-files/" # Prompt to continue or exit - $continueResponse = Read-Host "`nHave you checked and updated the configuration files? Enter 'yes' to continue with deployment, or 'no' to exit and configure later" - if ($continueResponse -ne "yes") { - Write-InformationColored "`nTo continue later, run Deploy-Accelerator with the following parameters:" -ForegroundColor Green -InformationAction Continue + $continueResponse = Read-MenuSelection ` + -Title "Have you checked and updated the configuration files? Ready to continue with deployment?" ` + -DefaultValue "yes" ` + -Type "boolean" ` + -AllowManualEntry ` + -ManualEntryPrompt "Enter '[y]es' to continue or '[n]o' to exit" + + if (!$continueResponse) { + Write-ToConsoleLog "To continue later, run Deploy-Accelerator with the following parameters:" -IsSuccess if ($selectedIacType -eq "terraform") { - Write-InformationColored @" + Write-ToConsoleLog @" Deploy-Accelerator `` -inputs "$configFolderPath/inputs.yaml", "$configFolderPath/platform-landing-zone.tfvars" `` -starterAdditionalFiles "$configFolderPath/lib" `` -output "$outputFolderPath" -"@ -ForegroundColor Cyan -InformationAction Continue +"@ -Color Cyan } elseif ($selectedIacType -eq "bicep") { - Write-InformationColored @" + Write-ToConsoleLog @" Deploy-Accelerator `` -inputs "$configFolderPath/inputs.yaml", "$configFolderPath/platform-landing-zone.yaml" `` -output "$outputFolderPath" -"@ -ForegroundColor Cyan -InformationAction Continue +"@ -Color Cyan } else { - Write-InformationColored @" + Write-ToConsoleLog @" Deploy-Accelerator `` -inputs "$configFolderPath/inputs.yaml" `` -output "$outputFolderPath" -"@ -ForegroundColor Cyan -InformationAction Continue +"@ -Color Cyan } return ConvertTo-AcceleratorResult -Continue $false @@ -287,7 +313,7 @@ Deploy-Accelerator `` # Build the result for continuing with deployment $configPaths = Get-AcceleratorConfigPath -IacType $selectedIacType -ConfigFolderPath $configFolderPath - Write-InformationColored "`nContinuing with deployment..." -ForegroundColor Green -InformationAction Continue + Write-ToConsoleLog "Continuing with deployment..." -IsSuccess return ConvertTo-AcceleratorResult -Continue $true ` -InputConfigFilePaths $configPaths.InputConfigFilePaths ` diff --git a/src/ALZ/Private/Shared/Get-GithubRelease.ps1 b/src/ALZ/Private/Shared/Get-GithubRelease.ps1 index 8469c98..fc1d32b 100644 --- a/src/ALZ/Private/Shared/Get-GithubRelease.ps1 +++ b/src/ALZ/Private/Shared/Get-GithubRelease.ps1 @@ -99,7 +99,7 @@ function Get-GithubRelease { Invoke-WebRequest -Uri $releaseArtifactUrl -OutFile $targetPathForZip -RetryIntervalSec 3 -MaximumRetryCount 100 | Out-String | Write-Verbose if(!(Test-Path $targetPathForZip)) { - Write-InformationColored "Failed to download the release $releaseTag from the GitHub repository $repoOrgPlusRepo" -ForegroundColor Red -InformationAction Continue + Write-ToConsoleLog "Failed to download the release $releaseTag from the GitHub repository $repoOrgPlusRepo" -IsError throw } @@ -117,9 +117,9 @@ function Get-GithubRelease { Copy-Item -Path "$($extractedSubFolder)/$moduleSourceFolder/*" -Destination "$targetVersionPath" -Recurse -Force | Out-String | Write-Verbose Remove-Item -Path "$targetVersionPath/tmp" -Force -Recurse - Write-InformationColored "The directory for $targetVersionPath has been created and populated." -ForegroundColor Green -InformationAction Continue + Write-ToConsoleLog "The directory for $targetVersionPath has been created and populated." -IsSuccess } else { - Write-InformationColored "The directory for $targetVersionPath already exists and has content in it, so we are not overwriting it." -ForegroundColor Green -InformationAction Continue + Write-ToConsoleLog "The directory for $targetVersionPath already exists and has content in it, so we are not overwriting it." -IsSuccess Write-Verbose "===> Content already exists in $releaseDirectory. Skipping" } diff --git a/src/ALZ/Private/Shared/Get-GithubReleaseTag.ps1 b/src/ALZ/Private/Shared/Get-GithubReleaseTag.ps1 index aab6e40..f815b13 100644 --- a/src/ALZ/Private/Shared/Get-GithubReleaseTag.ps1 +++ b/src/ALZ/Private/Shared/Get-GithubReleaseTag.ps1 @@ -55,7 +55,7 @@ function Get-GithubReleaseTag { # Handle transient errors like throttling if ($statusCode -ge 400 -and $statusCode -le 599) { - Write-InformationColored "Retrying as got the Status Code $statusCode, which may be a transient error." -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "Retrying as got the Status Code $statusCode, which may be a transient error." -IsWarning $releaseData = Invoke-RestMethod $repoReleaseUrl -RetryIntervalSec 3 -MaximumRetryCount 100 } diff --git a/src/ALZ/Private/Shared/Get-OsArchitecture.ps1 b/src/ALZ/Private/Shared/Get-OsArchitecture.ps1 index 733187d..5f3e311 100644 --- a/src/ALZ/Private/Shared/Get-OsArchitecture.ps1 +++ b/src/ALZ/Private/Shared/Get-OsArchitecture.ps1 @@ -39,7 +39,7 @@ function Get-OSArchitecture { } if($osAndArchitecture -eq "windows_arm64") { - Write-InformationColored "Windows arm64 is not currently supported by Terraform, so we will pull the Windows amd64 verison instead and run in emulation mode: https://learn.microsoft.com/en-us/windows/arm/apps-on-arm-x86-emulation" -ForegroundColor Yellow -NewLineBefore -InformationAction Continue + Write-ToConsoleLog "Windows arm64 is not currently supported by Terraform, so we will pull the Windows amd64 verison instead and run in emulation mode: https://learn.microsoft.com/en-us/windows/arm/apps-on-arm-x86-emulation" -IsWarning $architecture = "amd64" $osAndArchitecture = "windows_amd64" } diff --git a/src/ALZ/Private/Shared/Get-RandomString.ps1 b/src/ALZ/Private/Shared/Get-RandomString.ps1 new file mode 100644 index 0000000..316c5d0 --- /dev/null +++ b/src/ALZ/Private/Shared/Get-RandomString.ps1 @@ -0,0 +1,31 @@ +function Get-RandomString { + <# + .SYNOPSIS + Generates a random alphanumeric string. + + .DESCRIPTION + This function generates a random string of specified length using uppercase letters, + lowercase letters, and digits. Useful for generating confirmation codes and unique identifiers. + + .PARAMETER Length + The length of the random string to generate. Defaults to 8. + + .EXAMPLE + Get-RandomString + Returns a random 8-character string. + + .EXAMPLE + Get-RandomString -Length 12 + Returns a random 12-character string. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory = $false)] + [int]$Length = 8 + ) + + $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + $string = -join ((1..$Length) | ForEach-Object { $chars[(Get-Random -Maximum $chars.Length)] }) + return $string +} diff --git a/src/ALZ/Private/Shared/Invoke-PromptForConfirmation.ps1 b/src/ALZ/Private/Shared/Invoke-PromptForConfirmation.ps1 new file mode 100644 index 0000000..ccd31e4 --- /dev/null +++ b/src/ALZ/Private/Shared/Invoke-PromptForConfirmation.ps1 @@ -0,0 +1,58 @@ +function Invoke-PromptForConfirmation { + <# + .SYNOPSIS + Prompts the user for a two-stage confirmation before destructive operations. + + .DESCRIPTION + This function implements a two-stage confirmation process for destructive operations. + First, it generates a random 6-character string that the user must type to confirm. + Then, it requires the user to type a final confirmation text (default: "CONFIRM"). + This helps prevent accidental execution of dangerous operations. + + .PARAMETER Message + The warning message to display explaining what will happen. + + .PARAMETER FinalConfirmationText + The text the user must type for final confirmation. Defaults to "CONFIRM". + + .OUTPUTS + [bool] Returns $true if both confirmations pass, $false otherwise. + + .EXAMPLE + $continue = Invoke-PromptForConfirmation -Message "ALL DATA WILL BE DELETED" + if (-not $continue) { return } + + .EXAMPLE + $continue = Invoke-PromptForConfirmation -Message "RESOURCES WILL BE DESTROYED" -FinalConfirmationText "DELETE" + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory = $true)] + [string]$Message, + + [Parameter(Mandatory = $false)] + [string]$FinalConfirmationText = "CONFIRM" + ) + + Write-ToConsoleLog "$Message" -IsWarning + $randomString = (Get-RandomString -Length 6).ToUpper() + Write-ToConsoleLog "If you wish to proceed, type '$randomString' to confirm." -IsPrompt + $confirmation = Read-Host "Enter the confirmation text" + $confirmation = $confirmation.ToUpper().Replace("'","").Replace([System.Environment]::NewLine, "").Trim() + if ($confirmation -ne $randomString.ToUpper()) { + Write-ToConsoleLog "Confirmation text did not match the required input. Exiting without making any changes." -IsError + return $false + } + Write-ToConsoleLog "Initial confirmation received." -IsSuccess + Write-ToConsoleLog "This operation is permanent and cannot be reversed!" -IsWarning + Write-ToConsoleLog "Are you sure you want to proceed? Type '$FinalConfirmationText' to perform the highly destructive operation..." -IsPrompt + $confirmation = Read-Host "Enter the final confirmation text" + $confirmation = $confirmation.ToUpper().Replace("'","").Replace([System.Environment]::NewLine, "").Trim() + if ($confirmation -ne $FinalConfirmationText.ToUpper()) { + Write-ToConsoleLog "Final confirmation did not match the required input. Exiting without making any changes." -IsError + return $false + } + Write-ToConsoleLog "Final confirmation received. Proceeding with destructive operation..." -IsSuccess + return $true +} diff --git a/src/ALZ/Private/Shared/Read-MenuSelection.ps1 b/src/ALZ/Private/Shared/Read-MenuSelection.ps1 index 626fca2..92e38aa 100644 --- a/src/ALZ/Private/Shared/Read-MenuSelection.ps1 +++ b/src/ALZ/Private/Shared/Read-MenuSelection.ps1 @@ -4,64 +4,385 @@ function Read-MenuSelection { Displays a menu of options and prompts the user to select one. .DESCRIPTION This function displays a numbered list of options and prompts the user to select one. - It validates the selection and returns the selected option value. + It validates the selection and returns the selected option value or the value property if objects are provided. .PARAMETER Title - The title/prompt to display before the menu options. + The title/prompt to display before the menu options. If null/empty, no title is shown. + .PARAMETER HelpText + An array of help text lines to display after the title. + .PARAMETER OptionsTitle + Optional line of text to render before the options list. .PARAMETER Options - An array of option values to display. + An array of option values to display. Can be simple strings/values, or objects with 'label' and 'value' properties. + When objects with label/value are provided, the label is displayed and the value is returned. .PARAMETER DefaultIndex The zero-based index of the default option (default: 0). - .PARAMETER OptionDescriptions - Optional descriptions for options. Can be either: - - A hashtable mapping option values to descriptions - - An array of descriptions matching the Options array by index + .PARAMETER DefaultValue + Alternative to DefaultIndex - specify the default value directly. If both are provided, DefaultValue takes precedence. + .PARAMETER AllowManualEntry + When set, adds an option [0] to allow manual entry. + .PARAMETER ManualEntryPrompt + The prompt to display when manual entry is selected (default: "Enter value"). + .PARAMETER ManualEntryValidator + A script block that validates manual entry input. Should return $true if valid, $false otherwise. + The input value is passed as $args[0]. + .PARAMETER ManualEntryErrorMessage + The error message to display when manual entry validation fails. + .PARAMETER IsRequired + When set, the user must provide a value (cannot be empty). + .PARAMETER RequiredMessage + The error message to display when a required field is left empty. + .PARAMETER EmptyMessage + Message to display when the options array is empty. If set and options are empty, shows this message and falls back to manual entry. + .PARAMETER Type + The expected data type for validation: 'string' (default), 'number', 'guid', 'boolean', or 'array'. + When AllowManualEntry is used, input will be validated against this type. + For 'array', comma-separated input is parsed into an array. + .PARAMETER IsSensitive + When set, input is read securely using Read-Host -AsSecureString and the value is masked in display. .OUTPUTS Returns the selected option value. .EXAMPLE $selection = Read-MenuSelection -Title "Select IaC type:" -Options @("terraform", "bicep") -DefaultIndex 0 + .EXAMPLE + $subscriptions = @( + @{ label = "Subscription 1 (sub-id-1)"; value = "sub-id-1" }, + @{ label = "Subscription 2 (sub-id-2)"; value = "sub-id-2" } + ) + $selection = Read-MenuSelection -Title "Select subscription:" -Options $subscriptions -AllowManualEntry -ManualEntryPrompt "Enter subscription ID" #> [CmdletBinding()] param( - [Parameter(Mandatory = $true)] + [Parameter(Mandatory = $false)] [string] $Title, - [Parameter(Mandatory = $true)] - [array] $Options, + [Parameter(Mandatory = $false)] + [string[]] $HelpText = @(), + + [Parameter(Mandatory = $false)] + [string] $OptionsTitle = $null, + + [Parameter(Mandatory = $false)] + [array] $Options = @(), [Parameter(Mandatory = $false)] [int] $DefaultIndex = 0, [Parameter(Mandatory = $false)] - $OptionDescriptions = $null + $DefaultValue = $null, + + [Parameter(Mandatory = $false)] + [switch] $AllowManualEntry, + + [Parameter(Mandatory = $false)] + [string] $ManualEntryPrompt = "Enter value", + + [Parameter(Mandatory = $false)] + [scriptblock] $ManualEntryValidator = $null, + + [Parameter(Mandatory = $false)] + [string] $ManualEntryErrorMessage = "Invalid input. Please try again.", + + [Parameter(Mandatory = $false)] + [switch] $IsRequired, + + [Parameter(Mandatory = $false)] + [string] $RequiredMessage = "This field is required. Please enter a value.", + + [Parameter(Mandatory = $false)] + [string] $EmptyMessage = $null, + + [Parameter(Mandatory = $false)] + [ValidateSet("string", "number", "guid", "boolean", "array")] + [string] $Type = "string", + + [Parameter(Mandatory = $false)] + [switch] $IsSensitive ) - Write-InformationColored $Title -ForegroundColor Yellow -InformationAction Continue + # Built-in type validators + $typeValidators = @{ + "guid" = { + param($value) + return $value -match "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + } + "number" = { + param($value) + $intResult = 0 + return [int]::TryParse($value, [ref]$intResult) + } + "boolean" = { + param($value) + $validBooleans = @('true', 'false', 'yes', 'no', '1', '0', 'y', 'n', 't', 'f') + return $validBooleans -contains $value.ToString().ToLower() + } + "string" = { + param($value) + return $true + } + "array" = { + param($value) + return $true # Arrays are always valid as input + } + } + + $typeErrorMessages = @{ + "guid" = "Invalid GUID format. Please enter a valid GUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)" + "number" = "Invalid format. Please enter an integer number." + "boolean" = "Invalid format. Please enter true or false." + "string" = "Invalid input." + "array" = "Invalid input." + } + # Function to convert value to appropriate type + function ConvertTo-TypedValue { + param($Value, $TargetType, $DefaultValue = $null) + if ([string]::IsNullOrWhiteSpace($Value)) { + return $DefaultValue + } + switch ($TargetType) { + "number" { + $intResult = 0 + if ([int]::TryParse($Value, [ref]$intResult)) { + return $intResult + } + return $Value + } + "boolean" { + $valueStr = $Value.ToString().ToLower() + return $valueStr -in @('true', 'yes', '1', 'y', 't') + } + "array" { + # Parse comma-separated values into an array + return @($Value -split ',' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } + default { + return $Value + } + } + } + + # Get the effective validator - use ManualEntryValidator if provided, otherwise use type validator + $effectiveValidator = if ($null -ne $ManualEntryValidator) { + $ManualEntryValidator + } elseif ($Type -ne "string") { + $typeValidators[$Type] + } else { + $null + } + + # Get the effective error message + $effectiveErrorMessage = if (-not [string]::IsNullOrWhiteSpace($ManualEntryErrorMessage) -and $ManualEntryErrorMessage -ne "Invalid input. Please try again.") { + $ManualEntryErrorMessage + } elseif ($Type -ne "string") { + $typeErrorMessages[$Type] + } else { + $ManualEntryErrorMessage + } + + # Helper function to read input (handles sensitive vs normal input) + function Read-InputValue { + param($Prompt, $Sensitive) + if ($Sensitive) { + $secureValue = Read-Host $Prompt -AsSecureString + return [System.Runtime.InteropServices.Marshal]::PtrToStringAuto( + [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureValue) + ) + } else { + return Read-Host $Prompt + } + } + + # Helper function to mask sensitive values + function Get-MaskedValue { + param($Value) + if ([string]::IsNullOrWhiteSpace($Value)) { + return "(empty)" + } + $valueStr = $Value.ToString() + if ($valueStr.Length -ge 8) { + return $valueStr.Substring(0, 3) + "***" + $valueStr.Substring($valueStr.Length - 3) + } else { + return "********" + } + } + + # Helper function to get the value from an option (handles both simple values and label/value objects) + function Get-OptionValue { + param($Option) + if ($Option -is [hashtable] -and $Option.ContainsKey('value')) { + return $Option.value + } elseif ($Option -is [PSCustomObject] -and $null -ne $Option.PSObject.Properties['value']) { + return $Option.value + } + return $Option + } + + # Helper function to get the label from an option + function Get-OptionLabel { + param($Option) + if ($Option -is [hashtable] -and $Option.ContainsKey('label')) { + return $Option.label + } elseif ($Option -is [PSCustomObject] -and $null -ne $Option.PSObject.Properties['label']) { + return $Option.label + } + return $Option.ToString() + } + + # Determine if we have options to display + $hasOptions = $null -ne $Options -and $Options.Count -gt 0 + + # If DefaultValue is provided and we have options, find its index + if ($null -ne $DefaultValue -and $hasOptions) { + for ($i = 0; $i -lt $Options.Count; $i++) { + if ((Get-OptionValue -Option $Options[$i]) -eq $DefaultValue) { + $DefaultIndex = $i + break + } + } + } + + # Display title if provided + if (-not [string]::IsNullOrWhiteSpace($Title)) { + Write-ToConsoleLog $Title -IsPrompt + } + + # Display help text if provided + foreach ($helpLine in $HelpText) { + if (-not [string]::IsNullOrWhiteSpace($helpLine)) { + Write-ToConsoleLog $helpLine -IsSelection + } + } + + # Display default value and required status + if ($null -ne $DefaultValue -and -not [string]::IsNullOrWhiteSpace($DefaultValue)) { + $displayDefault = if ($IsSensitive.IsPresent) { Get-MaskedValue -Value $DefaultValue } else { $DefaultValue } + Write-ToConsoleLog "Default: $displayDefault" -Color Cyan -IsSelection + } + if ($IsRequired.IsPresent) { + Write-ToConsoleLog "Required: Yes" -Color Yellow -IsSelection + } + + # If no options, go directly to manual entry + if (-not $hasOptions) { + if (-not [string]::IsNullOrWhiteSpace($EmptyMessage)) { + Write-ToConsoleLog $EmptyMessage -IsWarning + } + + $result = $null + do { + $manualInput = Read-InputValue -Prompt $ManualEntryPrompt -Sensitive $IsSensitive.IsPresent + if ([string]::IsNullOrWhiteSpace($manualInput)) { + # For arrays, return empty array or default; for others return default + if ($Type -eq "array") { + if ($null -ne $DefaultValue -and $DefaultValue -is [System.Collections.IList]) { + $result = $DefaultValue + } else { + $result = @() + } + } elseif ($null -ne $DefaultValue -and -not [string]::IsNullOrWhiteSpace($DefaultValue)) { + $result = ConvertTo-TypedValue -Value $DefaultValue -TargetType $Type + } + } else { + # Validate and convert + if ($null -ne $effectiveValidator -and -not [string]::IsNullOrWhiteSpace($manualInput)) { + if (-not (& $effectiveValidator $manualInput)) { + Write-ToConsoleLog $effectiveErrorMessage -IsError + $result = $null + continue + } + } + $result = ConvertTo-TypedValue -Value $manualInput -TargetType $Type -DefaultValue $DefaultValue + } + # Check required - for arrays, check if empty + if ($IsRequired.IsPresent) { + if ($Type -eq "array") { + if ($null -eq $result -or $result.Count -eq 0) { + Write-ToConsoleLog $RequiredMessage -IsError + $result = $null + } + } elseif ([string]::IsNullOrWhiteSpace($result)) { + Write-ToConsoleLog $RequiredMessage -IsError + $result = $null + } + } + } while ($IsRequired.IsPresent -and $null -eq $result) + return $result + } + + # Display options title if provided + if (-not [string]::IsNullOrWhiteSpace($OptionsTitle)) { + Write-ToConsoleLog $OptionsTitle -IsSelection + } + + # Display options for ($i = 0; $i -lt $Options.Count; $i++) { $option = $Options[$i] - $default = if ($i -eq $DefaultIndex) { " (Default)" } else { "" } - - # Get description based on whether it's a hashtable or array - $description = "" - if ($null -ne $OptionDescriptions) { - if ($OptionDescriptions -is [hashtable] -and $OptionDescriptions.ContainsKey($option)) { - $description = " - $($OptionDescriptions[$option])" - } elseif ($OptionDescriptions -is [array] -and $i -lt $OptionDescriptions.Count) { - $description = " - $($OptionDescriptions[$i])" - } + $label = Get-OptionLabel -Option $option + $value = Get-OptionValue -Option $option + $isCurrent = ($null -ne $DefaultValue -and $value -eq $DefaultValue) -or ($null -eq $DefaultValue -and $i -eq $DefaultIndex) + $currentMarker = if ($isCurrent) { " (current)" } else { "" } + + if ($isCurrent) { + Write-ToConsoleLog "[$($i + 1)] $label$currentMarker" -IsSelection -Color Green -IndentLevel 1 + } else { + Write-ToConsoleLog "[$($i + 1)] $label" -IsSelection -IndentLevel 1 } + } + + # Show manual entry option if allowed + if ($AllowManualEntry.IsPresent) { + Write-ToConsoleLog "[0] Enter manually" -IsSelection -IndentLevel 1 + } - Write-InformationColored " [$($i + 1)] $option$description$default" -ForegroundColor White -InformationAction Continue + # Build prompt text + $promptText = "Enter selection (1-$($Options.Count)" + if ($AllowManualEntry.IsPresent) { + $promptText += ", 0 for manual entry" } + $promptText += ", default: $($DefaultIndex + 1))" + # Get selection + $result = $null do { - $selection = Read-Host "Enter selection (1-$($Options.Count), default: $($DefaultIndex + 1))" + $selection = Read-InputValue -Prompt $promptText -Sensitive $IsSensitive.IsPresent + if ([string]::IsNullOrWhiteSpace($selection)) { - $selectedIndex = $DefaultIndex + # Use default + $result = Get-OptionValue -Option $Options[$DefaultIndex] + } elseif ($AllowManualEntry.IsPresent -and $selection -eq "0") { + # Manual entry + do { + + $manualInput = Read-InputValue -Prompt $ManualEntryPrompt -Sensitive $IsSensitive.IsPresent + if ([string]::IsNullOrWhiteSpace($manualInput) -and -not [string]::IsNullOrWhiteSpace($DefaultValue)) { + $result = $DefaultValue + break + } + if ($null -ne $effectiveValidator -and -not [string]::IsNullOrWhiteSpace($manualInput)) { + if (-not (& $effectiveValidator $manualInput)) { + Write-ToConsoleLog $effectiveErrorMessage -IsError + continue + } + } + $result = ConvertTo-TypedValue -Value $manualInput -TargetType $Type + break + } while ($true) } else { $selectedIndex = [int]$selection - 1 + if ($selectedIndex -ge 0 -and $selectedIndex -lt $Options.Count) { + $result = Get-OptionValue -Option $Options[$selectedIndex] + } else { + Write-ToConsoleLog "Invalid selection, please try again." -IsWarning + continue + } + } + + # Check required + if ($IsRequired.IsPresent -and [string]::IsNullOrWhiteSpace($result)) { + Write-ToConsoleLog $RequiredMessage -IsError + $result = $null } - } while ($selectedIndex -lt 0 -or $selectedIndex -ge $Options.Count) + } while ($null -eq $result -and $IsRequired.IsPresent) - return $Options[$selectedIndex] + return $result } diff --git a/src/ALZ/Private/Shared/Write-InformationColored.ps1 b/src/ALZ/Private/Shared/Write-InformationColored.ps1 deleted file mode 100644 index 43c22c8..0000000 --- a/src/ALZ/Private/Shared/Write-InformationColored.ps1 +++ /dev/null @@ -1,24 +0,0 @@ -function Write-InformationColored { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [Object]$MessageData, - [ConsoleColor]$ForegroundColor = $Host.UI.RawUI.ForegroundColor ?? "White", # Make sure we use the current colours by default - [ConsoleColor]$BackgroundColor = $Host.UI.RawUI.BackgroundColor ?? "Black", - [Switch]$NoNewline, - [switch]$NewLineBefore - ) - - if ($NewLineBefore) { - $MessageData = "$([Environment]::NewLine)$MessageData" - } - - $msg = [System.Management.Automation.HostInformationMessage]@{ - Message = $MessageData - ForegroundColor = $ForegroundColor - BackgroundColor = $BackgroundColor - NoNewline = $NoNewline.IsPresent - } - - Write-Information $msg -} diff --git a/src/ALZ/Private/Shared/Write-ToConsoleLog.ps1 b/src/ALZ/Private/Shared/Write-ToConsoleLog.ps1 new file mode 100644 index 0000000..817d56b --- /dev/null +++ b/src/ALZ/Private/Shared/Write-ToConsoleLog.ps1 @@ -0,0 +1,220 @@ +function Write-ToConsoleLog { + <# + .SYNOPSIS + Writes formatted log messages to the console with timestamps and log levels. + + .DESCRIPTION + This function provides consistent console logging with timestamps, log levels, and color coding. + It supports error, warning, success, and plan modes with appropriate coloring. + Can also write to a file when in plan mode. + + .PARAMETER Messages + One or more messages to write to the console. + + .PARAMETER Level + The log level (INFO, ERROR, WARNING, SUCCESS, PLAN). Defaults to INFO. + + .PARAMETER Color + The console color to use. Defaults to Blue for INFO, or determined by Level. + + .PARAMETER NewLine + Adds the newline prefix before the message. + + .PARAMETER Overwrite + Uses carriage return to overwrite the current line (for progress indicators). + + .PARAMETER IsError + Sets the level to ERROR and uses red coloring. + + .PARAMETER IsWarning + Sets the level to WARNING and uses yellow coloring. + + .PARAMETER IsSuccess + Sets the level to SUCCESS and uses green coloring. + + .PARAMETER IsPlan + Sets the level to PLAN and uses gray coloring. Also enables file writing. + + .PARAMETER WriteToFile + Enables writing the message to a log file. + + .PARAMETER LogFilePath + The path to the log file when WriteToFile is enabled. + + .EXAMPLE + Write-ToConsoleLog "Starting process..." + + .EXAMPLE + Write-ToConsoleLog "Operation completed successfully" -IsSuccess + + .EXAMPLE + Write-ToConsoleLog "Something went wrong" -IsError + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, Position = 0)] + [string[]]$Messages, + + [Parameter(Mandatory = $false)] + [string]$Level = "INFO", + + [Parameter(Mandatory = $false)] + [System.ConsoleColor]$Color = [System.ConsoleColor]::White, + + [Parameter(Mandatory = $false)] + [switch]$NewLine, + + [Parameter(Mandatory = $false)] + [switch]$Overwrite, + + [Parameter(Mandatory = $false)] + [switch]$IsError, + + [Parameter(Mandatory = $false)] + [switch]$IsWarning, + + [Parameter(Mandatory = $false)] + [switch]$IsSuccess, + + [Parameter(Mandatory = $false)] + [switch]$IsPlan, + + [Parameter(Mandatory = $false)] + [switch]$IsPrompt, + + [Parameter(Mandatory = $false)] + [switch]$IsSelection, + + [Parameter(Mandatory = $false)] + [switch]$WriteToFile, + + [Parameter(Mandatory = $false)] + [string]$LogFilePath = $null, + + [Parameter(Mandatory = $false)] + [switch]$ShowDateTime, + + [Parameter(Mandatory = $false)] + [switch]$ShowType, + + [Parameter(Mandatory = $false)] + [string]$IndentTemplate = " ", + + [Parameter(Mandatory = $false)] + [int]$IndentLevel = 0, + + [Parameter(Mandatory = $false)] + [array]$Defaults = @( + @{ + Level = "INFO" + Color = [System.ConsoleColor]::Blue + NewLine = $false + ShowDateTime = $true + ShowType = $true + }, + @{ + Level = "ERROR" + Color = [System.ConsoleColor]::Red + NewLine = $true + ShowDateTime = $true + ShowType = $true + }, + @{ + Level = "WARNING" + Color = [System.ConsoleColor]::Yellow + NewLine = $true + ShowDateTime = $true + ShowType = $true + }, + @{ + Level = "SUCCESS" + Color = [System.ConsoleColor]::Green + NewLine = $true + ShowDateTime = $true + ShowType = $true + }, + @{ + Level = "PLAN" + Color = [System.ConsoleColor]::Gray + NewLine = $false + ShowDateTime = $true + ShowType = $true + }, + @{ + Level = "INPUT REQUIRED" + Color = [System.ConsoleColor]::Magenta + NewLine = $true + ShowDateTime = $true + ShowType = $true + }, + @{ + Level = "SELECTION" + Color = [System.ConsoleColor]::White + NewLine = $false + ShowDateTime = $false + ShowType = $false + } + ) + ) + + if ($IsError) { + $Level = "ERROR" + } elseif ($IsWarning) { + $Level = "WARNING" + } elseif ($IsSuccess) { + $Level = "SUCCESS" + } elseif ($IsPlan) { + $Level = "PLAN" + } elseif ($IsPrompt) { + $Level = "INPUT REQUIRED" + } elseif ($IsSelection) { + $Level = "SELECTION" + } + + $defaultSettings = $Defaults | Where-Object { $_.Level -eq $Level } | Select-Object -First 1 + + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff" + + if ($Color -eq [System.ConsoleColor]::White) { + if ($defaultSettings) { + $Color = $defaultSettings.Color + } + } + + $prefix = "" + + if ($Overwrite) { + $prefix = "`r" + } else { + if ($AddNewLine -or ($defaultSettings -and $defaultSettings.NewLine)) { + $prefix = [System.Environment]::NewLine + } + } + + if ($ShowDateTime -or ($defaultSettings -and $defaultSettings.ShowDateTime)) { + $prefix += "[$timestamp] " + } + + if ($ShowType -or ($defaultSettings -and $defaultSettings.ShowType)) { + $prefix += "[$Level] " + } + + if ($IndentLevel -gt 0) { + $indentString = $IndentTemplate * $IndentLevel + $prefix = $indentString + $prefix + } + + $finalMessages = @() + foreach ($Message in $Messages) { + $finalMessages += "$prefix$Message" + } + + if ($finalMessages.Count -gt 1) { + $finalMessages = $finalMessages -join "`n" + } + + Write-Host $finalMessages -ForegroundColor $Color -NoNewline:$Overwrite.IsPresent + if ($WriteToFile -and $LogFilePath) { + Add-Content -Path $LogFilePath -Value $finalMessages + } +} diff --git a/src/ALZ/Private/Tools/Checks/Test-AlzModule.ps1 b/src/ALZ/Private/Tools/Checks/Test-AlzModule.ps1 new file mode 100644 index 0000000..1b38700 --- /dev/null +++ b/src/ALZ/Private/Tools/Checks/Test-AlzModule.ps1 @@ -0,0 +1,105 @@ +function Test-AlzModule { + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [bool]$CheckVersion = $true, + [Parameter(Mandatory = $false)] + [switch]$AllowContinueOnFailure + ) + + $results = @() + $hasFailure = $false + $currentScope = "CurrentUser" + + $importedModule = Get-Module -Name ALZ + $isDevelopmentModule = ($null -ne $importedModule -and $importedModule.Version -eq "0.1.0") + + if ((-not $CheckVersion) -or $isDevelopmentModule) { + Write-Verbose "Skipping ALZ module version check" + + if($isDevelopmentModule) { + $results += @{ + message = "ALZ module version is 0.1.0. Skipping version check as this is a development module." + result = "Warning" + } + } elseif (-not $CheckVersion) { + $results += @{ + message = "ALZ module version check was skipped as 'AlzModuleVersion' was not included in Checks." + result = "Warning" + } + } + } else { + # Check if latest ALZ module is installed + Write-Verbose "Checking ALZ module version" + $alzModuleCurrentVersion = Get-InstalledPSResource -Name ALZ 2>$null | Select-Object -Property Name, Version | Sort-Object Version -Descending | Select-Object -First 1 + if($null -eq $alzModuleCurrentVersion) { + Write-Verbose "ALZ module not found in CurrentUser scope, checking AllUsers scope" + $alzModuleCurrentVersion = Get-InstalledPSResource -Name ALZ -Scope AllUsers 2>$null | Select-Object -Property Name, Version | Sort-Object Version -Descending | Select-Object -First 1 + if($null -ne $alzModuleCurrentVersion) { + Write-Verbose "ALZ module found in AllUsers scope" + $currentScope = "AllUsers" + } + } + + if($null -eq $alzModuleCurrentVersion) { + if($AllowContinueOnFailure.IsPresent) { + $results += @{ + message = "ALZ module is not correctly installed. Please install the latest version using 'Install-PSResource -Name ALZ'. Continuing as -destroy flag is set." + result = "Warning" + } + } else { + $results += @{ + message = "ALZ module is not correctly installed. Please install the latest version using 'Install-PSResource -Name ALZ'." + result = "Failure" + } + $hasFailure = $true + } + } + + $alzModuleLatestVersion = Find-PSResource -Name ALZ + if ($null -ne $alzModuleCurrentVersion) { + if ($alzModuleCurrentVersion.Version -lt $alzModuleLatestVersion.Version) { + if($AllowContinueOnFailure.IsPresent) { + $results += @{ + message = "ALZ module is not the latest version. Your version: $($alzModuleCurrentVersion.Version), Latest version: $($alzModuleLatestVersion.Version). Please update to the latest version using 'Update-PSResource -Name ALZ'. Continuing as -destroy flag is set." + result = "Warning" + } + } else { + $results += @{ + message = "ALZ module is not the latest version. Your version: $($alzModuleCurrentVersion.Version), Latest version: $($alzModuleLatestVersion.Version). Please update to the latest version using 'Update-PSResource -Name ALZ'." + result = "Failure" + } + $hasFailure = $true + } + } else { + if($importedModule.Version -lt $alzModuleLatestVersion.Version) { + Write-Verbose "Imported ALZ module version ($($importedModule.Version)) is older than the latest installed version ($($alzModuleLatestVersion.Version)), re-importing module" + + if($AllowContinueOnFailure.IsPresent) { + $results += @{ + message = "ALZ module has the latest version installed, but not imported. Imported version: ($($importedModule.Version)). Please re-import the module using 'Remove-Module -Name ALZ; Import-Module -Name ALZ -Global' to use the latest version. Continuing as -destroy flag is set." + result = "Warning" + } + } else { + $results += @{ + message = "ALZ module has the latest version installed, but not imported. Imported version: ($($importedModule.Version)). Please re-import the module using 'Remove-Module -Name ALZ; Import-Module -Name ALZ -Global' to use the latest version." + result = "Failure" + } + $hasFailure = $true + } + } else { + $results += @{ + message = "ALZ module is the latest version ($($alzModuleCurrentVersion.Version))." + result = "Success" + } + } + } + } + } + + return @{ + Results = $results + HasFailure = $hasFailure + CurrentScope = $currentScope + } +} diff --git a/src/ALZ/Private/Tools/Checks/Test-AzureCli.ps1 b/src/ALZ/Private/Tools/Checks/Test-AzureCli.ps1 new file mode 100644 index 0000000..fa501e8 --- /dev/null +++ b/src/ALZ/Private/Tools/Checks/Test-AzureCli.ps1 @@ -0,0 +1,57 @@ +function Test-AzureCli { + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [bool]$RequireLogin = $true + ) + + $results = @() + $hasFailure = $false + $azCliInstalledButNotLoggedIn = $false + + # Check if Azure CLI is installed + Write-Verbose "Checking Azure CLI installation" + $azCliPath = Get-Command az -ErrorAction SilentlyContinue + if ($azCliPath) { + $results += @{ + message = "Azure CLI is installed." + result = "Success" + } + + # Check if Azure CLI is logged in + Write-Verbose "Checking Azure CLI login status" + $azCliAccount = $(az account show -o json 2>$null) | ConvertFrom-Json + if ($azCliAccount) { + $results += @{ + message = "Azure CLI is logged in. Tenant ID: $($azCliAccount.tenantId), Subscription: $($azCliAccount.name) ($($azCliAccount.id))" + result = "Success" + } + } else { + $azCliInstalledButNotLoggedIn = $true + if (-not $RequireLogin) { + $results += @{ + message = "Azure CLI is not logged in. Login will be prompted later." + result = "Warning" + } + } else { + $results += @{ + message = "Azure CLI is not logged in. Please login to Azure CLI using 'az login -t `"00000000-0000-0000-0000-000000000000`"', replacing the empty GUID with your tenant ID." + result = "Failure" + } + $hasFailure = $true + } + } + } else { + $results += @{ + message = "Azure CLI is not installed. Follow the instructions here: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli" + result = "Failure" + } + $hasFailure = $true + } + + return @{ + Results = $results + HasFailure = $hasFailure + AzCliInstalledButNotLoggedIn = $azCliInstalledButNotLoggedIn + } +} diff --git a/src/ALZ/Private/Tools/Checks/Test-AzureDevOpsCli.ps1 b/src/ALZ/Private/Tools/Checks/Test-AzureDevOpsCli.ps1 new file mode 100644 index 0000000..a3b1a5a --- /dev/null +++ b/src/ALZ/Private/Tools/Checks/Test-AzureDevOpsCli.ps1 @@ -0,0 +1,55 @@ +function Test-AzureDevOpsCli { + [CmdletBinding()] + param() + + $results = @() + $hasFailure = $false + + Write-Verbose "Checking Azure CLI installation for Azure DevOps" + $azCliPath = Get-Command az -ErrorAction SilentlyContinue + + if ($azCliPath) { + $results += @{ + message = "Azure CLI is installed." + result = "Success" + } + + # Check if Azure DevOps extension is installed + Write-Verbose "Checking Azure DevOps extension" + $extensionList = az extension list -o json 2>$null | ConvertFrom-Json + $devopsExtension = $extensionList | Where-Object { $_.name -eq "azure-devops" } + + if ($devopsExtension) { + $results += @{ + message = "Azure DevOps extension is installed." + result = "Success" + } + } else { + Write-Verbose "Azure DevOps extension not found, attempting to install..." + $null = az extension add --name azure-devops 2>&1 + if ($LASTEXITCODE -eq 0) { + $results += @{ + message = "Azure DevOps extension was installed automatically." + result = "Success" + } + } else { + $results += @{ + message = "Azure DevOps extension is not installed. Install using: az extension add --name azure-devops" + result = "Failure" + } + $hasFailure = $true + } + } + } else { + $results += @{ + message = "Azure CLI is not installed. Follow the instructions here: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli" + result = "Failure" + } + $hasFailure = $true + } + + return @{ + Results = $results + HasFailure = $hasFailure + } +} diff --git a/src/ALZ/Private/Tools/Checks/Test-AzureEnvironmentVariable.ps1 b/src/ALZ/Private/Tools/Checks/Test-AzureEnvironmentVariable.ps1 new file mode 100644 index 0000000..a0b7ad6 --- /dev/null +++ b/src/ALZ/Private/Tools/Checks/Test-AzureEnvironmentVariable.ps1 @@ -0,0 +1,91 @@ +function Test-AzureEnvironmentVariable { + [CmdletBinding()] + param() + + $results = @() + $hasFailure = $false + $envVarsValid = $false + + Write-Verbose "Checking Azure environment variables" + $nonAzCliEnvVars = @( + "ARM_CLIENT_ID", + "ARM_SUBSCRIPTION_ID", + "ARM_TENANT_ID" + ) + + $envVarsSet = $true + $envVarValid = $true + $envVarUnique = $true + $envVarAtLeastOneSet = $false + $envVarsWithValue = @() + $checkedEnvVars = @() + + foreach($envVar in $nonAzCliEnvVars) { + $envVarValue = [System.Environment]::GetEnvironmentVariable($envVar) + if($envVarValue -eq $null -or $envVarValue -eq "" ) { + $envVarsSet = $false + continue + } + $envVarAtLeastOneSet = $true + $envVarsWithValue += $envVar + if($envVarValue -notmatch("^(\{){0,1}[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}(\}){0,1}$")) { + $envVarValid = $false + continue + } + if($checkedEnvVars -contains $envVarValue) { + $envVarUnique = $false + continue + } + $checkedEnvVars += $envVarValue + } + + if($envVarsSet) { + Write-Verbose "Using Service Principal Authentication" + if($envVarValid -and $envVarUnique) { + $results += @{ + message = "Azure environment variables are set and are valid unique GUIDs." + result = "Success" + } + $envVarsValid = $true + } + + if(-not $envVarValid) { + $results += @{ + message = "Azure environment variables are set, but are not all valid GUIDs." + result = "Failure" + } + $hasFailure = $true + } + + if (-not $envVarUnique) { + $envVarValidationOutput = "" + foreach($envVar in $nonAzCliEnvVars) { + $envVarValue = [System.Environment]::GetEnvironmentVariable($envVar) + $envVarValidationOutput += " $envVar ($envVarValue)" + } + $results += @{ + message = "Azure environment variables are set, but are not unique GUIDs. There is at least one duplicate:$envVarValidationOutput." + result = "Failure" + } + $hasFailure = $true + } + } else { + if($envVarAtLeastOneSet) { + $envVarValidationOutput = "" + foreach($envVar in $envVarsWithValue) { + $envVarValue = [System.Environment]::GetEnvironmentVariable($envVar) + $envVarValidationOutput += " $envVar ($envVarValue)" + } + $results += @{ + message = "At least one environment variable is set, but the other expected environment variables are not set. This could cause Terraform to fail in unexpected ways. Set environment variables:$envVarValidationOutput." + result = "Warning" + } + } + } + + return @{ + Results = $results + HasFailure = $hasFailure + EnvVarsValid = $envVarsValid + } +} diff --git a/src/ALZ/Private/Tools/Checks/Test-GitHubCli.ps1 b/src/ALZ/Private/Tools/Checks/Test-GitHubCli.ps1 new file mode 100644 index 0000000..6410f2f --- /dev/null +++ b/src/ALZ/Private/Tools/Checks/Test-GitHubCli.ps1 @@ -0,0 +1,44 @@ +function Test-GitHubCli { + [CmdletBinding()] + param() + + $results = @() + $hasFailure = $false + + Write-Verbose "Checking GitHub CLI installation" + $ghCliPath = Get-Command gh -ErrorAction SilentlyContinue + + if ($ghCliPath) { + $results += @{ + message = "GitHub CLI is installed." + result = "Success" + } + + # Check if GitHub CLI is authenticated + Write-Verbose "Checking GitHub CLI authentication status" + $null = gh auth status 2>&1 + if ($LASTEXITCODE -eq 0) { + $results += @{ + message = "GitHub CLI is authenticated." + result = "Success" + } + } else { + $results += @{ + message = "GitHub CLI is not authenticated. Please authenticate using 'gh auth login'." + result = "Failure" + } + $hasFailure = $true + } + } else { + $results += @{ + message = "GitHub CLI is not installed. Follow the instructions here: https://cli.github.com/" + result = "Failure" + } + $hasFailure = $true + } + + return @{ + Results = $results + HasFailure = $hasFailure + } +} diff --git a/src/ALZ/Private/Tools/Checks/Test-GitInstallation.ps1 b/src/ALZ/Private/Tools/Checks/Test-GitInstallation.ps1 new file mode 100644 index 0000000..94c236d --- /dev/null +++ b/src/ALZ/Private/Tools/Checks/Test-GitInstallation.ps1 @@ -0,0 +1,28 @@ +function Test-GitInstallation { + [CmdletBinding()] + param() + + $results = @() + $hasFailure = $false + + Write-Verbose "Checking Git installation" + $gitPath = Get-Command git -ErrorAction SilentlyContinue + + if ($gitPath) { + $results += @{ + message = "Git is installed." + result = "Success" + } + } else { + $results += @{ + message = "Git is not installed. Follow the instructions here: https://git-scm.com/downloads" + result = "Failure" + } + $hasFailure = $true + } + + return @{ + Results = $results + HasFailure = $hasFailure + } +} diff --git a/src/ALZ/Private/Tools/Checks/Test-PowerShellVersion.ps1 b/src/ALZ/Private/Tools/Checks/Test-PowerShellVersion.ps1 new file mode 100644 index 0000000..abc9f10 --- /dev/null +++ b/src/ALZ/Private/Tools/Checks/Test-PowerShellVersion.ps1 @@ -0,0 +1,35 @@ +function Test-PowerShellVersion { + [CmdletBinding()] + param() + + $results = @() + $hasFailure = $false + + Write-Verbose "Checking PowerShell version" + $powerShellVersionTable = $PSVersionTable + $powerShellVersion = $powerShellVersionTable.PSVersion.ToString() + + if ($powerShellVersionTable.PSVersion.Major -lt 7) { + $results += @{ + message = "PowerShell version $powerShellVersion is not supported. Please upgrade to PowerShell 7.4 or higher. Either switch to the ``pwsh`` prompt or follow the instructions here: https://aka.ms/install-powershell" + result = "Failure" + } + $hasFailure = $true + } elseif ($powerShellVersionTable.PSVersion.Major -eq 7 -and $powerShellVersionTable.PSVersion.Minor -lt 4) { + $results += @{ + message = "PowerShell version $powerShellVersion is not supported. Please upgrade to PowerShell 7.4 or higher. Either switch to the ``pwsh`` prompt or follow the instructions here: https://aka.ms/install-powershell" + result = "Failure" + } + $hasFailure = $true + } else { + $results += @{ + message = "PowerShell version $powerShellVersion is supported." + result = "Success" + } + } + + return @{ + Results = $results + HasFailure = $hasFailure + } +} diff --git a/src/ALZ/Private/Tools/Checks/Test-YamlModule.ps1 b/src/ALZ/Private/Tools/Checks/Test-YamlModule.ps1 new file mode 100644 index 0000000..737bb03 --- /dev/null +++ b/src/ALZ/Private/Tools/Checks/Test-YamlModule.ps1 @@ -0,0 +1,72 @@ +function Test-YamlModule { + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [bool]$AutoInstall = $false, + [Parameter(Mandatory = $false)] + [string]$Scope = "CurrentUser" + ) + + $results = @() + $hasFailure = $false + + Write-Verbose "Checking powershell-yaml module installation" + $yamlModule = Get-InstalledPSResource -Name powershell-yaml 2> $null | Select-Object -Property Name, Version | Sort-Object Version -Descending | Select-Object -First 1 + if($null -eq $yamlModule) { + Write-Verbose "powershell-yaml module not found in CurrentUser scope, checking AllUsers scope" + $yamlModule = Get-InstalledPSResource -Name powershell-yaml -Scope AllUsers 2> $null | Select-Object -Property Name, Version | Sort-Object Version -Descending | Select-Object -First 1 + } + + if ($yamlModule) { + # Import powershell-yaml module if not already loaded + if (-not (Get-Module -Name powershell-yaml)) { + Write-Verbose "Importing powershell-yaml module version $($yamlModule.Version)" + Import-Module -Name powershell-yaml -RequiredVersion $yamlModule.Version -Global + $results += @{ + message = "powershell-yaml module is installed but was not imported, now imported (version $($yamlModule.Version))." + result = "Success" + } + } else { + $results += @{ + message = "powershell-yaml module is installed and imported (version $($yamlModule.Version))." + result = "Success" + } + } + } elseif (-not $AutoInstall) { + Write-Verbose "powershell-yaml module is not installed, skipping installation attempt" + $results += @{ + message = "powershell-yaml module is not installed. Please install it using 'Install-PSResource powershell-yaml -Scope $Scope'." + result = "Failure" + } + $hasFailure = $true + } else { + Write-Verbose "powershell-yaml module is not installed, attempting installation" + $installResult = Install-PSResource powershell-yaml -TrustRepository -Scope $Scope 2>&1 + if($installResult -like "*Access to the path*") { + Write-Verbose "Failed to install powershell-yaml module due to permission issues at $Scope scope." + $results += @{ + message = "powershell-yaml module is not installed. Please install it using an admin terminal with 'Install-PSResource powershell-yaml -Scope $Scope'. Could not install due to permission issues." + result = "Failure" + } + $hasFailure = $true + } elseif ($null -ne $installResult) { + Write-Verbose "Failed to install powershell-yaml module: $installResult" + $results += @{ + message = "powershell-yaml module is not installed. Please install it using 'Install-PSResource powershell-yaml -Scope $Scope'. Attempted installation error: $installResult" + result = "Failure" + } + $hasFailure = $true + } else { + $installedVersion = (Get-InstalledPSResource -Name powershell-yaml -Scope $Scope).Version + $results += @{ + message = "powershell-yaml module was not installed, but has been successfully installed (version $installedVersion)." + result = "Success" + } + } + } + + return @{ + Results = $results + HasFailure = $hasFailure + } +} diff --git a/src/ALZ/Private/Tools/Test-Tooling.ps1 b/src/ALZ/Private/Tools/Test-Tooling.ps1 index 5256d57..1fda274 100644 --- a/src/ALZ/Private/Tools/Test-Tooling.ps1 +++ b/src/ALZ/Private/Tools/Test-Tooling.ps1 @@ -2,13 +2,8 @@ function Test-Tooling { [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory = $false)] - [switch]$skipAlzModuleVersionCheck, - [Parameter(Mandatory = $false)] - [switch]$checkYamlModule, - [Parameter(Mandatory = $false)] - [switch]$skipYamlModuleInstall, - [Parameter(Mandatory = $false)] - [switch]$skipAzureLoginCheck, + [ValidateSet("PowerShell", "Git", "AzureCli", "AzureEnvVars", "AzureCliOrEnvVars", "AzureLogin", "AlzModule", "AlzModuleVersion", "YamlModule", "YamlModuleAutoInstall", "GitHubCli", "AzureDevOpsCli")] + [string[]]$Checks = @("PowerShell", "Git", "AzureCliOrEnvVars", "AzureLogin", "AlzModule", "AlzModuleVersion"), [Parameter(Mandatory = $false)] [switch]$destroy ) @@ -16,306 +11,87 @@ function Test-Tooling { $checkResults = @() $hasFailure = $false $azCliInstalledButNotLoggedIn = $false + $currentScope = "CurrentUser" - # Check if PowerShell is the correct version - Write-Verbose "Checking PowerShell version" - $powerShellVersionTable = $PSVersionTable - $powerShellVersion = $powerShellVersionTable.PSVersion.ToString() - if ($powerShellVersionTable.PSVersion.Major -lt 7) { - $checkResults += @{ - message = "PowerShell version $powerShellVersion is not supported. Please upgrade to PowerShell 7.4 or higher. Either switch to the `pwsh` prompt or follow the instructions here: https://aka.ms/install-powershell" - result = "Failure" - } - $hasFailure = $true - } elseif ($powerShellVersionTable.PSVersion.Major -eq 7 -and $powerShellVersionTable.PSVersion.Minor -lt 4) { - $checkResults += @{ - message = "PowerShell version $powerShellVersion is not supported. Please upgrade to PowerShell 7.4 or higher. Either switch to the `pwsh` prompt or follow the instructions here: https://aka.ms/install-powershell" - result = "Failure" - } - $hasFailure = $true - } else { - $checkResults += @{ - message = "PowerShell version $powerShellVersion is supported." - result = "Success" - } + # Check PowerShell version + if ($Checks -contains "PowerShell") { + $result = Test-PowerShellVersion + $checkResults += $result.Results + if ($result.HasFailure) { $hasFailure = $true } } - # Check if Git is installed - Write-Verbose "Checking Git installation" - $gitPath = Get-Command git -ErrorAction SilentlyContinue - if ($gitPath) { - $checkResults += @{ - message = "Git is installed." - result = "Success" - } - } else { - $checkResults += @{ - message = "Git is not installed. Follow the instructions here: https://git-scm.com/downloads" - result = "Failure" - } - $hasFailure = $true + # Check Git installation + if ($Checks -contains "Git") { + $result = Test-GitInstallation + $checkResults += $result.Results + if ($result.HasFailure) { $hasFailure = $true } } - # Check if using Service Principal Auth - Write-Verbose "Checking Azure environment variables" - $nonAzCliEnvVars = @( - "ARM_CLIENT_ID", - "ARM_SUBSCRIPTION_ID", - "ARM_TENANT_ID" - ) - - $envVarsSet = $true - $envVarValid = $true - $envVarUnique = $true - $envVarAtLeastOneSet = $false - $envVarsWithValue = @() - $checkedEnvVars = @() - foreach($envVar in $nonAzCliEnvVars) { - $envVarValue = [System.Environment]::GetEnvironmentVariable($envVar) - if($envVarValue -eq $null -or $envVarValue -eq "" ) { - $envVarsSet = $false - continue - } - $envVarAtLeastOneSet = $true - $envVarsWithValue += $envVar - if($envVarValue -notmatch("^(\{){0,1}[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}(\}){0,1}$")) { - $envVarValid = $false - continue - } - if($checkedEnvVars -contains $envVarValue) { - $envVarUnique = $false - continue - } - $checkedEnvVars += $envVarValue + # Check Azure Environment Variables only + if ($Checks -contains "AzureEnvVars") { + $result = Test-AzureEnvironmentVariable + $checkResults += $result.Results + if ($result.HasFailure) { $hasFailure = $true } } - if($envVarsSet) { - Write-Verbose "Using Service Principal Authentication, skipping Azure CLI checks" - if($envVarValid -and $envVarUnique) { - $checkResults += @{ - message = "Azure environment variables are set and are valid unique GUIDs." - result = "Success" - } - } - - if(-not $envVarValid) { - $checkResults += @{ - message = "Azure environment variables are set, but are not all valid GUIDs." - result = "Failure" - } - $hasFailure = $true - } - - if (-not $envVarUnique) { - $envVarValidationOutput = "" - foreach($envVar in $nonAzCliEnvVars) { - $envVarValue = [System.Environment]::GetEnvironmentVariable($envVar) - $envVarValidationOutput += " $envVar ($envVarValue)" - } - $checkResults += @{ - message = "Azure environment variables are set, but are not unique GUIDs. There is at least one duplicate:$envVarValidationOutput." - result = "Failure" - } - $hasFailure = $true - } - } else { - if($envVarAtLeastOneSet) { - $envVarValidationOutput = "" - foreach($envVar in $envVarsWithValue) { - $envVarValue = [System.Environment]::GetEnvironmentVariable($envVar) - $envVarValidationOutput += " $envVar ($envVarValue)" - } - $checkResults += @{ - message = "At least one environment variable is set, but the other expected environment variables are not set. This could cause Terraform to fail in unexpected ways. Set environment variables:$envVarValidationOutput." - result = "Warning" - } - } - - # Check if Azure CLI is installed - Write-Verbose "Checking Azure CLI installation" - $azCliPath = Get-Command az -ErrorAction SilentlyContinue - if ($azCliPath) { - $checkResults += @{ - message = "Azure CLI is installed." - result = "Success" - } - - # Check if Azure CLI is logged in - Write-Verbose "Checking Azure CLI login status" - $azCliAccount = $(az account show -o json 2>$null) | ConvertFrom-Json - if ($azCliAccount) { - $checkResults += @{ - message = "Azure CLI is logged in. Tenant ID: $($azCliAccount.tenantId), Subscription: $($azCliAccount.name) ($($azCliAccount.id))" - result = "Success" - } - } else { - $azCliInstalledButNotLoggedIn = $true - if ($skipAzureLoginCheck.IsPresent) { - $checkResults += @{ - message = "Azure CLI is not logged in. Login will be prompted later." - result = "Warning" - } - } else { - $checkResults += @{ - message = "Azure CLI is not logged in. Please login to Azure CLI using 'az login -t `"00000000-0000-0000-0000-000000000000`"', replacing the empty GUID with your tenant ID." - result = "Failure" - } - $hasFailure = $true - } - } - } else { - $checkResults += @{ - message = "Azure CLI is not installed. Follow the instructions here: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli" - result = "Failure" - } - $hasFailure = $true - } + # Check Azure CLI only (used by Remove-PlatformLandingZone) + if ($Checks -contains "AzureCli") { + $requireLogin = $Checks -contains "AzureLogin" + $result = Test-AzureCli -RequireLogin $requireLogin + $checkResults += $result.Results + if ($result.HasFailure) { $hasFailure = $true } + if ($result.AzCliInstalledButNotLoggedIn) { $azCliInstalledButNotLoggedIn = $true } } - $currentScope = "CurrentUser" - - $importedModule = Get-Module -Name ALZ - $isDevelopmentModule = ($null -ne $importedModule -and $importedModule.Version -eq "0.1.0") - if($skipAlzModuleVersionCheck.IsPresent -or $isDevelopmentModule) { - Write-Verbose "Skipping ALZ module version check" - - if($isDevelopmentModule) { - $checkResults += @{ - message = "ALZ module version is 0.1.0. Skipping version check as this is a development module." - result = "Warning" - } - } elseif ($skipAlzModuleVersionCheck.IsPresent) { - $checkResults += @{ - message = "ALZ module version check was explicitly skipped using the -skipAlzModuleVersionRequirementsCheck parameter." - result = "Warning" - } - } - } else { - # Check if latest ALZ module is installed - Write-Verbose "Checking ALZ module version" - $alzModuleCurrentVersion = Get-InstalledPSResource -Name ALZ 2>$null | Select-Object -Property Name, Version | Sort-Object Version -Descending | Select-Object -First 1 - if($null -eq $alzModuleCurrentVersion) { - Write-Verbose "ALZ module not found in CurrentUser scope, checking AllUsers scope" - $alzModuleCurrentVersion = Get-InstalledPSResource -Name ALZ -Scope AllUsers 2>$null | Select-Object -Property Name, Version | Sort-Object Version -Descending | Select-Object -First 1 - if($null -ne $alzModuleCurrentVersion) { - Write-Verbose "ALZ module found in AllUsers scope" - $currentScope = "AllUsers" - } + # Check Azure CLI or Environment Variables (used by Deploy-Accelerator) + # If env vars are valid, skip CLI check; otherwise check CLI + if ($Checks -contains "AzureCliOrEnvVars") { + $envResult = Test-AzureEnvironmentVariable + $checkResults += $envResult.Results + if ($envResult.HasFailure) { $hasFailure = $true } + + # Only check CLI if env vars are not valid + if (-not $envResult.EnvVarsValid) { + $requireLogin = $Checks -contains "AzureLogin" + $cliResult = Test-AzureCli -RequireLogin $requireLogin + $checkResults += $cliResult.Results + if ($cliResult.HasFailure) { $hasFailure = $true } + if ($cliResult.AzCliInstalledButNotLoggedIn) { $azCliInstalledButNotLoggedIn = $true } } + } - if($null -eq $alzModuleCurrentVersion) { - if($destroy.IsPresent) { - $checkResults += @{ - message = "ALZ module is not correctly installed. Please install the latest version using 'Install-PSResource -Name ALZ'. Continuing as -destroy flag is set." - result = "Warning" - } - } else { - $checkResults += @{ - message = "ALZ module is not correctly installed. Please install the latest version using 'Install-PSResource -Name ALZ'." - result = "Failure" - } - $hasFailure = $true - } - } - $alzModuleLatestVersion = Find-PSResource -Name ALZ - if ($null -ne $alzModuleCurrentVersion) { - if ($alzModuleCurrentVersion.Version -lt $alzModuleLatestVersion.Version) { - if($destroy.IsPresent) { - $checkResults += @{ - message = "ALZ module is not the latest version. Your version: $($alzModuleCurrentVersion.Version), Latest version: $($alzModuleLatestVersion.Version). Please update to the latest version using 'Update-PSResource -Name ALZ'. Continuing as -destroy flag is set." - result = "Warning" - } - } else { - $checkResults += @{ - message = "ALZ module is not the latest version. Your version: $($alzModuleCurrentVersion.Version), Latest version: $($alzModuleLatestVersion.Version). Please update to the latest version using 'Update-PSResource -Name ALZ'." - result = "Failure" - } - $hasFailure = $true - } - } else { - if($importedModule.Version -lt $alzModuleLatestVersion.Version) { - Write-Verbose "Imported ALZ module version ($($importedModule.Version)) is older than the latest installed version ($($alzModuleLatestVersion.Version)), re-importing module" + # Check ALZ Module + if ($Checks -contains "AlzModule") { + $checkVersion = $Checks -contains "AlzModuleVersion" + $result = Test-AlzModule -CheckVersion $checkVersion -AllowContinueOnFailure:$destroy.IsPresent + $checkResults += $result.Results + if ($result.HasFailure) { $hasFailure = $true } + if ($result.CurrentScope) { $currentScope = $result.CurrentScope } + } - if($destroy.IsPresent) { - $checkResults += @{ - message = "ALZ module has the latest version installed, but not imported. Imported version: ($($importedModule.Version)). Please re-import the module using 'Remove-Module -Name ALZ; Import-Module -Name ALZ -Global' to use the latest version. Continuing as -destroy flag is set." - result = "Warning" - } - } else { - $checkResults += @{ - message = "ALZ module has the latest version installed, but not imported. Imported version: ($($importedModule.Version)). Please re-import the module using 'Remove-Module -Name ALZ; Import-Module -Name ALZ -Global' to use the latest version." - result = "Failure" - } - $hasFailure = $true - } - } else { - $checkResults += @{ - message = "ALZ module is the latest version ($($alzModuleCurrentVersion.Version))." - result = "Success" - } - } - } - } + # Check YAML Module + if ($Checks -contains "YamlModule") { + $autoInstall = $Checks -contains "YamlModuleAutoInstall" + $result = Test-YamlModule -AutoInstall $autoInstall -Scope $currentScope + $checkResults += $result.Results + if ($result.HasFailure) { $hasFailure = $true } } - # Check if powershell-yaml module is installed (only when YAML files are being used) - if ($checkYamlModule.IsPresent) { - Write-Verbose "Checking powershell-yaml module installation" - $yamlModule = Get-InstalledPSResource -Name powershell-yaml 2> $null | Select-Object -Property Name, Version | Sort-Object Version -Descending | Select-Object -First 1 - if($null -eq $yamlModule) { - Write-Verbose "powershell-yaml module not found in CurrentUser scope, checking AllUsers scope" - $yamlModule = Get-InstalledPSResource -Name powershell-yaml -Scope AllUsers 2> $null | Select-Object -Property Name, Version | Sort-Object Version -Descending | Select-Object -First 1 - } + # Check GitHub CLI + if ($Checks -contains "GitHubCli") { + $result = Test-GitHubCli + $checkResults += $result.Results + if ($result.HasFailure) { $hasFailure = $true } + } - if ($yamlModule) { - # Import powershell-yaml module if not already loaded - if (-not (Get-Module -Name powershell-yaml)) { - Write-Verbose "Importing powershell-yaml module version $($yamlModule.Version)" - Import-Module -Name powershell-yaml -RequiredVersion $yamlModule.Version -Global - $checkResults += @{ - message = "powershell-yaml module is installed but was not imported, now imported (version $($yamlModule.Version))." - result = "Success" - } - } else { - $checkResults += @{ - message = "powershell-yaml module is installed and imported (version $($yamlModule.Version))." - result = "Success" - } - } - } elseif ($skipYamlModuleInstall.IsPresent) { - Write-Verbose "powershell-yaml module is not installed, skipping installation attempt" - $checkResults += @{ - message = "powershell-yaml module is not installed. Please install it using 'Install-PSResource powershell-yaml -Scope $currentScope'." - result = "Failure" - } - $hasFailure = $true - } else { - Write-Verbose "powershell-yaml module is not installed, attempting installation" - $installResult = Install-PSResource powershell-yaml -TrustRepository -Scope $currentScope 2>&1 - if($installResult -like "*Access to the path*") { - Write-Verbose "Failed to install powershell-yaml module due to permission issues at $currentScope scope." - $checkResults += @{ - message = "powershell-yaml module is not installed. Please install it using an admin terminal with 'Install-PSResource powershell-yaml -Scope $currentScope'. Could not install due to permission issues." - result = "Failure" - } - $hasFailure = $true - } elseif ($null -ne $installResult) { - Write-Verbose "Failed to install powershell-yaml module: $installResult" - $checkResults += @{ - message = "powershell-yaml module is not installed. Please install it using 'Install-PSResource powershell-yaml -Scope $currentScope'. Attempted installation error: $installResult" - result = "Failure" - } - $hasFailure = $true - } else { - $installedVersion = (Get-InstalledPSResource -Name powershell-yaml -Scope $currentScope).Version - $checkResults += @{ - message = "powershell-yaml module was not installed, but has been successfully installed (version $installedVersion)." - result = "Success" - } - } - } + # Check Azure DevOps CLI + if ($Checks -contains "AzureDevOpsCli") { + $result = Test-AzureDevOpsCli + $checkResults += $result.Results + if ($result.HasFailure) { $hasFailure = $true } } + # Display results Write-Verbose "Showing check results" Write-Verbose $(ConvertTo-Json $checkResults -Depth 100) $checkResults | ForEach-Object {[PSCustomObject]$_} | Format-Table -Property @{ @@ -332,8 +108,8 @@ function Test-Tooling { }, @{ Label = "Check Details"; Expression = {$_.message} } -AutoSize -Wrap | Out-Host if($hasFailure) { - Write-InformationColored "Accelerator software requirements have no been met, please review and install the missing software." -ForegroundColor Red -InformationAction Continue - Write-InformationColored "Cannot continue with Deployment..." -ForegroundColor Red -InformationAction Continue + Write-ToConsoleLog "Accelerator software requirements have no been met, please review and install the missing software." -IsError + Write-ToConsoleLog "Cannot continue with Deployment..." -IsError throw "Accelerator software requirements have no been met, please review and install the missing software." } @@ -341,3 +117,4 @@ function Test-Tooling { AzCliInstalledButNotLoggedIn = $azCliInstalledButNotLoggedIn } } + diff --git a/src/ALZ/Public/Deploy-Accelerator.ps1 b/src/ALZ/Public/Deploy-Accelerator.ps1 index 2be3bcf..776c91b 100644 --- a/src/ALZ/Public/Deploy-Accelerator.ps1 +++ b/src/ALZ/Public/Deploy-Accelerator.ps1 @@ -234,41 +234,54 @@ function Deploy-Accelerator { # Check software requirements first before any prompting $toolingResult = $null if ($skip_requirements_check.IsPresent) { - Write-InformationColored "WARNING: Skipping the software requirements check..." -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "WARNING: Skipping the software requirements check..." -IsWarning } else { - Write-InformationColored "Checking the software requirements for the Accelerator..." -ForegroundColor Green -InformationAction Continue - $toolingResult = Test-Tooling -skipAlzModuleVersionCheck:$skip_alz_module_version_requirements_check.IsPresent -checkYamlModule:$checkYamlModule -skipYamlModuleInstall:$skip_yaml_module_install.IsPresent -skipAzureLoginCheck:$needsFolderStructureSetup -destroy:$destroy.IsPresent + Write-ToConsoleLog "Checking the software requirements for the Accelerator..." + $checks = @("PowerShell", "Git", "AzureCliOrEnvVars", "AlzModule") + if (-not $needsFolderStructureSetup) { + $checks += "AzureLogin" + } + if (-not $skip_alz_module_version_requirements_check.IsPresent) { + $checks += "AlzModuleVersion" + } + if ($checkYamlModule) { + $checks += "YamlModule" + if (-not $skip_yaml_module_install.IsPresent) { + $checks += "YamlModuleAutoInstall" + } + } + $toolingResult = Test-Tooling -Checks $checks -destroy:$destroy.IsPresent } # If az cli is installed but not logged in, prompt for tenant ID and login with device code if ($needsFolderStructureSetup -and $toolingResult -and $toolingResult.AzCliInstalledButNotLoggedIn) { - Write-InformationColored "`nAzure CLI is installed but not logged in. Let's log you in..." -ForegroundColor Yellow -InformationAction Continue - Write-InformationColored "You'll need your Azure Tenant ID. You can find this in the Azure Portal under Microsoft Entra ID > Overview." -ForegroundColor Cyan -InformationAction Continue + Write-ToConsoleLog "Azure CLI is installed but not logged in. Let's log you in..." -IsWarning + Write-ToConsoleLog "You'll need your Azure Tenant ID. You can find this in the Azure Portal under Microsoft Entra ID > Overview." $tenantId = "" $guidRegex = "^(\{){0,1}[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}(\}){0,1}$" do { - $tenantId = Read-Host "`nEnter your Azure Tenant ID (GUID)" + $tenantId = Read-Host "Enter your Azure Tenant ID (GUID)" if ($tenantId -notmatch $guidRegex) { - Write-InformationColored "Invalid Tenant ID format. Please enter a valid GUID (e.g., 00000000-0000-0000-0000-000000000000)" -ForegroundColor Red -InformationAction Continue + Write-ToConsoleLog "Invalid Tenant ID format. Please enter a valid GUID (e.g., 00000000-0000-0000-0000-000000000000)" -IsError } } while ($tenantId -notmatch $guidRegex) - Write-InformationColored "`nLogging in to Azure using device code authentication..." -ForegroundColor Green -InformationAction Continue - Write-InformationColored "Opening browser to https://microsoft.com/devicelogin for you to authenticate..." -ForegroundColor Cyan -InformationAction Continue + Write-ToConsoleLog "Logging in to Azure using device code authentication..." -IsSuccess + Write-ToConsoleLog "Opening browser to https://microsoft.com/devicelogin for you to authenticate..." try { Start-Process "https://microsoft.com/devicelogin" } catch { - Write-InformationColored "Could not open browser automatically. Please navigate to https://microsoft.com/devicelogin manually." -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "Could not open browser automatically. Please navigate to https://microsoft.com/devicelogin manually." -IsWarning } az login --allow-no-subscriptions --use-device-code --tenant $tenantId if ($LASTEXITCODE -ne 0) { - Write-InformationColored "Azure login failed. Please try again or login manually using 'az login --tenant $tenantId'." -ForegroundColor Red -InformationAction Continue + Write-ToConsoleLog "Azure login failed. Please try again or login manually using 'az login --tenant $tenantId'." -IsError throw "Azure login failed." } - Write-InformationColored "Successfully logged in to Azure!" -ForegroundColor Green -InformationAction Continue + Write-ToConsoleLog "Successfully logged in to Azure!" -IsSuccess } # If no inputs provided, prompt user for folder structure setup @@ -287,7 +300,7 @@ function Deploy-Accelerator { $output_folder_path = $setupResult.OutputFolderPath } - Write-InformationColored "Getting ready to deploy the accelerator with you..." -ForegroundColor Green -NewLineBefore -InformationAction Continue + Write-ToConsoleLog "Getting ready to deploy the accelerator with you..." -IsSuccess if ($PSCmdlet.ShouldProcess("Accelerator setup", "modify")) { @@ -301,9 +314,9 @@ function Deploy-Accelerator { # Check and install tools needed $toolsPath = Join-Path -Path $output_folder_path -ChildPath ".tools" if ($skipInternetChecks) { - Write-InformationColored "Skipping Terraform tool check as you used the skipInternetCheck parameter. Please ensure you have the most recent version of Terraform installed" -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "Skipping Terraform tool check as you used the skipInternetCheck parameter. Please ensure you have the most recent version of Terraform installed" -IsWarning } else { - Write-InformationColored "Checking you have the latest version of Terraform installed..." -ForegroundColor Green -NewLineBefore -InformationAction Continue + Write-ToConsoleLog "Checking you have the latest version of Terraform installed..." -IsSuccess Get-TerraformTool -version "latest" -toolsPath $toolsPath $hclParserToolPath = Get-HCLParserTool -toolVersion "v0.6.0" -toolsPath $toolsPath } @@ -315,7 +328,7 @@ function Deploy-Accelerator { if ($null -ne $envInputConfigPaths -and $envInputConfigPaths -ne "") { $inputConfigFilePaths = $envInputConfigPaths -split "," } else { - Write-InformationColored "No input configuration file path has been provided. Please provide the path(s) to your configuration file(s)..." -ForegroundColor Red -InformationAction Continue + Write-ToConsoleLog "No input configuration file path has been provided. Please provide the path(s) to your configuration file(s)..." -IsError throw "No input configuration file path has been provided. Please provide the path(s) to your configuration file(s)..." } } @@ -354,12 +367,12 @@ function Deploy-Accelerator { # Throw if IAC type is not specified if (!$inputConfig.iac_type.Value) { - Write-InformationColored "No Infrastructure as Code type has been specified. Please supply the IAC type you wish to deploy..." -ForegroundColor Red -InformationAction Continue + Write-ToConsoleLog "No Infrastructure as Code type has been specified. Please supply the IAC type you wish to deploy..." -IsError throw "No Infrastructure as Code type has been specified. Please supply the IAC type you wish to deploy..." } if ($inputConfig.iac_type.Value.ToString() -like "bicep*") { - Write-InformationColored "Although you have selected Bicep, the Accelerator leverages the Terraform tool to bootstrap your Version Control System and Azure. This will not impact your choice of Bicep post this initial bootstrap. Please refer to our documentation for further details..." -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "Although you have selected Bicep, the Accelerator leverages the Terraform tool to bootstrap your Version Control System and Azure. This will not impact your choice of Bicep post this initial bootstrap. Please refer to our documentation for further details..." -IsWarning } # Download the bootstrap modules @@ -367,7 +380,7 @@ function Deploy-Accelerator { $bootstrapPath = "" $bootstrapTargetFolder = "bootstrap" - Write-InformationColored "Checking and Downloading the bootstrap module..." -ForegroundColor Green -NewLineBefore -InformationAction Continue + Write-ToConsoleLog "Checking and Downloading the bootstrap module..." -IsSuccess if($inputConfig.bootstrap_module_override_folder_path.Value.StartsWith("~/" )) { $inputConfig.bootstrap_module_override_folder_path.Value = Join-Path $HOME $inputConfig.bootstrap_module_override_folder_path.Value.Replace("~/", "") @@ -404,7 +417,7 @@ function Deploy-Accelerator { # Request the bootstrap type if not already specified if(!$inputConfig.bootstrap_module_name.Value) { - Write-InformationColored "No bootstrap module has been specified. Please supply the bootstrap module you wish to deploy..." -ForegroundColor Red -InformationAction Continue + Write-ToConsoleLog "No bootstrap module has been specified. Please supply the bootstrap module you wish to deploy..." -IsError throw "No bootstrap module has been specified. Please supply the bootstrap module you wish to deploy..." } @@ -429,7 +442,7 @@ function Deploy-Accelerator { $starterConfig = $null if ($hasStarterModule) { - Write-InformationColored "Checking and downloading the starter module..." -ForegroundColor Green -NewLineBefore -InformationAction Continue + Write-ToConsoleLog "Checking and downloading the starter module..." -IsSuccess if($inputConfig.starter_module_override_folder_path.Value.StartsWith("~/" )) { $inputConfig.starter_module_override_folder_path.Value = Join-Path $HOME $inputConfig.starter_module_override_folder_path.Value.Replace("~/", "") diff --git a/src/ALZ/Public/Grant-SubscriptionCreatorRole.ps1 b/src/ALZ/Public/Grant-SubscriptionCreatorRole.ps1 index 2807c63..8dfb7ad 100644 --- a/src/ALZ/Public/Grant-SubscriptionCreatorRole.ps1 +++ b/src/ALZ/Public/Grant-SubscriptionCreatorRole.ps1 @@ -53,8 +53,8 @@ Grant-SubscriptionCreatorRole -servicePrincipalObjectId "bd42568a-7dd8-489b-bbbb ) # Checks - Write-Host "Checking inputs..." -ForegroundColor Cyan - Write-Host "" + Write-ToConsoleLog "Checking inputs..." + Write-ToConsoleLog "" if($null -eq $servicePrincipalObjectId -or $servicePrincipalObjectId -eq "") { $errorMessage = "The 'Service Principal Object ID' parameter is required. Please provide a valid value and try again." @@ -67,23 +67,23 @@ Grant-SubscriptionCreatorRole -servicePrincipalObjectId "bd42568a-7dd8-489b-bbbb if($null -ne $billingAccountID -and $billingAccountID -ne "" -and $null -ne $billingResourceID -and $billingResourceID -ne "" -and $null -ne $invoiceSectionID -and $invoiceSectionID -ne "") { $billingResourceID = $microsoftCustomerAgreementResourceIDFormat - Write-Host "Microsoft Customer Agreement (MCA) parameters provided..." + Write-ToConsoleLog "Microsoft Customer Agreement (MCA) parameters provided..." } if($null -ne $billingAccountID -and $billingAccountID -ne "" -and $null -ne $enrollmentAccountID -and $enrollmentAccountID -ne "") { $billingResourceID = $enterpriseAgreementResourceIDFormat - Write-Host "Enterpruse Agreement (EA) parameters provided..." + Write-ToConsoleLog "Enterprise Agreement (EA) parameters provided..." } if($null -ne $billingResourceID -and $billingResourceID -ne "") { - Write-Host "Billing Resource ID or required parameters provided..." -ForegroundColor Green + Write-ToConsoleLog "Billing Resource ID or required parameters provided..." -IsSuccess } else { $errorMessage = "No Billing Resource ID or required parameters provided." Write-Error $errorMessage throw $errorMessage } - Write-Host "Checking the specified billing account resource ID '$($billingResourceID)' exists..." -ForegroundColor Yellow + Write-ToConsoleLog "Checking the specified billing account resource ID '$($billingResourceID)' exists..." -IsWarning # Check $billingResourceID is valid and exists $getbillingResourceID = $(az rest --method GET --url "$managementApiPrefix$($billingResourceID)?api-version=2024-04-01") | ConvertFrom-Json @@ -93,12 +93,12 @@ Grant-SubscriptionCreatorRole -servicePrincipalObjectId "bd42568a-7dd8-489b-bbbb Write-Error $errorMessage throw $errorMessage } else { - Write-Host "The specified billing account ID '$($billingResourceID)' exists. Continuing..." -ForegroundColor Green - Write-Host "" + Write-ToConsoleLog "The specified billing account ID '$($billingResourceID)' exists. Continuing..." -IsSuccess + Write-ToConsoleLog "" } # Check $existingSpnMiObjectId is valid and exists - Write-Host "Checking the specified service principal 'Object ID' '$($servicePrincipalObjectId)' exists..." -ForegroundColor Yellow + Write-ToConsoleLog "Checking the specified service principal 'Object ID' '$($servicePrincipalObjectId)' exists..." -IsWarning $getexistingSpnMiObjectId = $(az ad sp show --id $servicePrincipalObjectId) | ConvertFrom-Json if ($null -eq $getexistingSpnMiObjectId) { @@ -110,14 +110,14 @@ Grant-SubscriptionCreatorRole -servicePrincipalObjectId "bd42568a-7dd8-489b-bbbb $finalSpnMiDisplayName = $getexistingSpnMiObjectId.displayName $finalSpnMiType = $getexistingSpnMiObjectId.servicePrincipalType - Write-Host "The specified service principal 'Object ID' '$($servicePrincipalObjectId)' exists with a Display Name of: '$finalSpnMiDisplayName' with a Type of: '$finalSpnMiType'. Continuing..." -ForegroundColor Green - Write-Host "" + Write-ToConsoleLog "The specified service principal 'Object ID' '$($servicePrincipalObjectId)' exists with a Display Name of: '$finalSpnMiDisplayName' with a Type of: '$finalSpnMiType'. Continuing..." -IsSuccess + Write-ToConsoleLog "" } # Grant service principal access to the specified EA billing account $subscriptionCreatorRoleId = "a0bcee42-bf30-4d1b-926a-48d21664ef71" - Write-Host "Pre-reqs passed and complete..." -ForegroundColor Cyan - Write-Host "Granting the 'SubscriptionCreator' role (ID: '$subscriptionCreatorRoleId') on the Billing Account ID of: '$($billingResourceID)' to the AAD Object ID of: '$($finalSpnMiObjectId)' which has the Display Name of: '$($finalSpnMiDisplayName)'..." -ForegroundColor Yellow + Write-ToConsoleLog "Pre-reqs passed and complete..." -IsSuccess + Write-ToConsoleLog "Granting the 'SubscriptionCreator' role (ID: '$subscriptionCreatorRoleId') on the Billing Account ID of: '$($billingResourceID)' to the AAD Object ID of: '$($finalSpnMiObjectId)' which has the Display Name of: '$($finalSpnMiDisplayName)'..." -IsWarning # Get the current AAD Tenant ID $tenantId = $(az account show --query tenantId -o tsv) @@ -141,8 +141,8 @@ Grant-SubscriptionCreatorRole -servicePrincipalObjectId "bd42568a-7dd8-489b-bbbb Write-Error $errorMessage throw $errorMessage } else { - Write-Host "The 'SubscriptionCreator' role has been granted to the service principal." -ForegroundColor Green - Write-Host "" + Write-ToConsoleLog "The 'SubscriptionCreator' role has been granted to the service principal." -IsSuccess + Write-ToConsoleLog "" } return diff --git a/src/ALZ/Public/New-AcceleratorFolderStructure.ps1 b/src/ALZ/Public/New-AcceleratorFolderStructure.ps1 index 780764a..ef13365 100644 --- a/src/ALZ/Public/New-AcceleratorFolderStructure.ps1 +++ b/src/ALZ/Public/New-AcceleratorFolderStructure.ps1 @@ -43,7 +43,7 @@ function New-AcceleratorFolderStructure { } if(Test-Path -Path $targetFolderPath) { if($force.IsPresent) { - Write-Host "Force flag is set, removing existing target folder at $targetFolderPath" + Write-ToConsoleLog "Force flag is set, removing existing target folder at $targetFolderPath" -IsWarning try { Remove-Item -Recurse -Force -Path $targetFolderPath -ErrorAction Stop | Write-Verbose | Out-Null } catch { @@ -53,18 +53,18 @@ function New-AcceleratorFolderStructure { throw "Target folder $targetFolderPath already exists. Please specify a different folder path or remove the existing folder." } } - Write-Host "Creating target folder at $targetFolderPath" + Write-ToConsoleLog "Creating target folder at $targetFolderPath" New-Item -ItemType "directory" -Path $targetFolderPath -Force | Write-Verbose | Out-Null $targetFolderPath = (Resolve-Path -Path $targetFolderPath).Path # Create target folder structure $outputFolder = Join-Path $targetFolderPath $outputFolderName - Write-Host "Creating output folder at $outputFolder" + Write-ToConsoleLog "Creating output folder at $outputFolder" New-Item -ItemType "directory" $outputFolder -Force | Write-Verbose | Out-Null # Create temp folder $tempFolderPath = Join-Path $targetFolderPath "temp" - Write-Host "Creating temp folder at $tempFolderPath" + Write-ToConsoleLog "Creating temp folder at $tempFolderPath" New-Item -ItemType "directory" $tempFolderPath -Force | Write-Verbose | Out-Null # Map the repo @@ -104,8 +104,8 @@ function New-AcceleratorFolderStructure { # Clone the repo and copy the bootstrap and starter configuration files $repo = $repos[$iacType] - Write-Host "Cloning repo $($repo.repoName)" - git clone --depth=1 "https://github.com/Azure/$($repo.repoName)" "$tempFolderPath" | Write-Verbose | Out-Null + Write-ToConsoleLog "Cloning repo $($repo.repoName)" + git clone --depth=1 "https://github.com/Azure/$($repo.repoName)" "$tempFolderPath" 2>&1 | Write-Verbose Set-Location $tempFolderPath Set-Location $currentPath @@ -113,16 +113,16 @@ function New-AcceleratorFolderStructure { $bootstrapExampleFolderPath = "$exampleFolderPath/$($repo.bootstrapExampleFolderPath)" $configFolderPath = Join-Path $targetFolderPath "config" - Write-Host "Creating config folder at $configFolderPath" + Write-ToConsoleLog "Creating config folder at $configFolderPath" New-Item -ItemType "directory" $configFolderPath -Force | Write-Verbose | Out-Null # Copy the bootstrap configuration file - Write-Host "Copying bootstrap configuration file to $($targetFolderPath)/config/inputs.yaml" + Write-ToConsoleLog "Copying bootstrap configuration file to $($targetFolderPath)/config/inputs.yaml" Copy-Item -Path "$tempFolderPath/$bootstrapExampleFolderPath/inputs-$versionControl.yaml" -Destination "$targetFolderPath/config/inputs.yaml" -Force | Write-Verbose | Out-Null if ($repo.hasLibrary) { $libFolderPath = "$($repo.folderToClone)/$($repo.libraryFolderPath)" - Write-Host "Copying library files to $($targetFolderPath)/config" + Write-ToConsoleLog "Copying library files to $($targetFolderPath)/config" Copy-Item -Path "$tempFolderPath/$libFolderPath" -Destination "$targetFolderPath/config" -Recurse -Force | Write-Verbose | Out-Null } @@ -140,11 +140,11 @@ function New-AcceleratorFolderStructure { 9 = "full-single-region-nva/virtual-wan.tfvars" } - Write-Host "Copying platform landing zone configuration file for scenario $scenarioNumber to $($targetFolderPath)/config/platform-landing-zone.tfvars" + Write-ToConsoleLog "Copying platform landing zone configuration file for scenario $scenarioNumber to $($targetFolderPath)/config/platform-landing-zone.tfvars" Copy-Item -Path "$tempFolderPath/$exampleFolderPath/$($scenarios[$scenarioNumber])" -Destination "$targetFolderPath/config/platform-landing-zone.tfvars" -Force | Write-Verbose | Out-Null } elseif ($repo.platformLandingZoneFilePath -ne "") { - Write-Host "Copying platform landing zone configuration file to $($targetFolderPath)/config/platform-landing-zone.yaml" + Write-ToConsoleLog "Copying platform landing zone configuration file to $($targetFolderPath)/config/platform-landing-zone.yaml" Copy-Item -Path "$tempFolderPath/$exampleFolderPath/$($repo.platformLandingZoneFilePath)" -Destination "$targetFolderPath/config/platform-landing-zone.yaml" -Force | Write-Verbose | Out-Null } diff --git a/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 b/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 new file mode 100644 index 0000000..173ecfb --- /dev/null +++ b/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 @@ -0,0 +1,359 @@ +function Remove-AzureDevOpsAccelerator { + <# + .SYNOPSIS + Removes Azure DevOps resources created by the Azure Landing Zone accelerator bootstrap modules. + + .DESCRIPTION + The Remove-AzureDevOpsAccelerator function performs cleanup of Azure DevOps resources that were created + by the Azure Landing Zone accelerator bootstrap process (https://github.com/Azure/accelerator-bootstrap-modules). + This includes projects and optionally agent pools. + + The function operates in the following sequence: + 1. Validates Azure CLI and Azure DevOps extension authentication + 2. Prompts for confirmation (unless bypassed or in plan mode) + 3. Discovers and deletes projects matching the specified patterns + 4. Optionally discovers and deletes agent pools matching the specified patterns + + CRITICAL WARNING: This is a highly destructive operation that will permanently delete Azure DevOps resources. + Use with extreme caution and ensure you have appropriate backups and authorization before executing. + + .PARAMETER AzureDevOpsOrganization + The Azure DevOps organization URL or name. Can be provided as either the full URL + (e.g., https://dev.azure.com/my-org) or just the organization name (e.g., my-org). + This parameter is required. + + .PARAMETER ProjectNamePatterns + An array of regex patterns to match against project names. Projects matching any of these + patterns will be deleted. If empty, no projects will be deleted. + Default: Empty array (no projects deleted) + + .PARAMETER AgentPoolNamePatterns + An array of regex patterns to match against agent pool names. Agent pools matching any of + these patterns will be deleted. If empty, no agent pools will be deleted. Requires the + -IncludeAgentPools switch to be specified. + Default: Empty array (no agent pools deleted) + + .PARAMETER IncludeAgentPools + A switch parameter that enables deletion of agent pools matching the patterns specified in + -AgentPoolNamePatterns. By default, agent pools are not deleted. This is useful for cleaning + up self-hosted agent pools created during the bootstrap process. + Default: $false (do not delete agent pools) + + .PARAMETER BypassConfirmation + A switch parameter that bypasses the interactive confirmation prompts. When specified, the function + waits for the duration specified in -BypassConfirmationTimeoutSeconds before proceeding, allowing + time to cancel. During this timeout, pressing any key will cancel the operation. + WARNING: Use this parameter with extreme caution as it reduces safety checks. + Default: $false (confirmation required) + + .PARAMETER BypassConfirmationTimeoutSeconds + The number of seconds to wait before proceeding when -BypassConfirmation is used. During this + timeout, pressing any key will cancel the operation. This provides a safety window to prevent + accidental deletions. + Default: 30 seconds + + .PARAMETER ThrottleLimit + The maximum number of parallel operations to execute simultaneously. This controls the degree + of parallelism when processing resources. Higher values may improve performance but increase + API throttling risk. + Default: 11 + + .PARAMETER PlanMode + A switch parameter that enables "dry run" mode. When specified, the function displays what + actions would be taken without actually making any changes. This is useful for validating + the scope of operations before executing the actual cleanup. + Default: $false (execute actual deletions) + + .EXAMPLE + Remove-AzureDevOpsAccelerator -AzureDevOpsOrganization "my-org" -ProjectNamePatterns @("^alz-.*") -PlanMode + + Shows what projects matching the pattern "^alz-.*" would be deleted from the "my-org" + organization without making any changes. + + .EXAMPLE + Remove-AzureDevOpsAccelerator -AzureDevOpsOrganization "https://dev.azure.com/my-org" -ProjectNamePatterns @("^alz-.*") + + Deletes all projects matching the pattern "^alz-.*" from the "my-org" organization. + + .EXAMPLE + Remove-AzureDevOpsAccelerator -AzureDevOpsOrganization "my-org" -ProjectNamePatterns @("^alz-.*") -IncludeAgentPools -AgentPoolNamePatterns @("^alz-.*") + + Deletes projects and self-hosted agent pools matching the pattern "^alz-.*" from the + "my-org" organization. + + .EXAMPLE + Remove-AzureDevOpsAccelerator -AzureDevOpsOrganization "my-org" -ProjectNamePatterns @("^test-alz$") -BypassConfirmation -BypassConfirmationTimeoutSeconds 10 + + Deletes the project named exactly "test-alz" with a 10-second confirmation bypass timeout. + + .NOTES + This function requires the Azure CLI with the Azure DevOps extension to be installed and authenticated. + Install Azure CLI: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli + Install Azure DevOps extension: az extension add --name azure-devops + Authenticate: az devops login (supports PAT authentication, az login is not required) + + Required permissions: + - Project Collection Administrator or equivalent permissions to delete projects + - Agent Pool Administrator permissions to delete agent pools + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param ( + [Parameter(Mandatory = $true, HelpMessage = "[REQUIRED] The Azure DevOps organization URL or name.")] + [Alias("org")] + [string]$AzureDevOpsOrganization, + + [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Regex patterns to match project names for deletion.")] + [Alias("projects")] + [string[]]$ProjectNamePatterns = @(), + + [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Regex patterns to match agent pool names for deletion.")] + [Alias("pools")] + [string[]]$AgentPoolNamePatterns = @(), + + [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Include agent pools in the deletion process.")] + [switch]$IncludeAgentPools, + + [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Bypass interactive confirmation prompts.")] + [switch]$BypassConfirmation, + + [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Seconds to wait when bypassing confirmation.")] + [int]$BypassConfirmationTimeoutSeconds = 30, + + [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Maximum parallel operations.")] + [int]$ThrottleLimit = 11, + + [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Enable dry run mode - no changes will be made.")] + [switch]$PlanMode + ) + + function Get-NormalizedOrganizationUrl { + param ( + [string]$Organization + ) + + # If it's already a URL, return it + if ($Organization -match "^https?://") { + return $Organization.TrimEnd('/') + } + + # Otherwise, construct the URL + return "https://dev.azure.com/$Organization" + } + + # Main execution starts here + if ($PSCmdlet.ShouldProcess("Delete Azure DevOps Resources", "delete")) { + + Test-Tooling -Checks @("AzureDevOpsCli") + + $TempLogFileForPlan = "" + if($PlanMode) { + Write-ToConsoleLog "Plan Mode enabled, no changes will be made. All actions will be logged as what would be performed." -IsWarning + $TempLogFileForPlan = (New-TemporaryFile).FullName + } + + $funcWriteToConsoleLog = ${function:Write-ToConsoleLog}.ToString() + + # Normalize organization URL + $organizationUrl = Get-NormalizedOrganizationUrl -Organization $AzureDevOpsOrganization + Write-ToConsoleLog "Using Azure DevOps organization: $organizationUrl" -NoNewLine + + # Configure Azure DevOps CLI defaults + az devops configure --defaults organization=$organizationUrl 2>&1 | Out-Null + + if($BypassConfirmation) { + Write-ToConsoleLog "Bypass confirmation enabled, proceeding without prompts..." -IsWarning + Write-ToConsoleLog "This is a highly destructive operation that will permanently delete Azure DevOps resources!" -IsWarning + Write-ToConsoleLog "We are waiting $BypassConfirmationTimeoutSeconds seconds to allow for cancellation. Press any key to cancel..." -IsWarning + + $keyPressed = $false + $secondsRunning = 0 + + while((-not $keyPressed) -and ($secondsRunning -lt $BypassConfirmationTimeoutSeconds)){ + $keyPressed = [Console]::KeyAvailable + Write-ToConsoleLog ("Waiting for: $($BypassConfirmationTimeoutSeconds-$secondsRunning) seconds. Press any key to cancel...") -IsWarning -Overwrite + Start-Sleep -Seconds 1 + $secondsRunning++ + } + + if($keyPressed) { + Write-ToConsoleLog "Cancellation key pressed, exiting without making any changes..." -IsError + return + } + } + + Write-ToConsoleLog "Thanks for providing the inputs, getting started..." -IsSuccess + + $hasProjectPatterns = $ProjectNamePatterns.Count -gt 0 + $hasAgentPoolPatterns = $IncludeAgentPools -and $AgentPoolNamePatterns.Count -gt 0 + + if(-not $hasProjectPatterns -and -not $hasAgentPoolPatterns) { + Write-ToConsoleLog "No patterns provided for projects or agent pools. Nothing to do. Exiting..." -IsError + return + } + + # Discover resources to delete + $projectsToDelete = @() + $agentPoolsToDelete = @() + + # Discover projects + if($hasProjectPatterns) { + Write-ToConsoleLog "Discovering projects in organization: $organizationUrl" + + $allProjects = (az devops project list --org $organizationUrl -o json 2>$null) | ConvertFrom-Json + if($null -eq $allProjects -or $null -eq $allProjects.value) { + Write-ToConsoleLog "Failed to list projects in organization: $organizationUrl" -IsError + return + } + + $projectList = $allProjects.value + Write-ToConsoleLog "Found $($projectList.Count) total projects in organization: $organizationUrl" -NoNewLine + + foreach($project in $projectList) { + foreach($pattern in $ProjectNamePatterns) { + if($project.name -match $pattern) { + Write-ToConsoleLog "Project matches pattern '$pattern': $($project.name)" -NoNewLine + $projectsToDelete += @{ + Name = $project.name + Id = $project.id + } + break + } + } + } + + Write-ToConsoleLog "Found $($projectsToDelete.Count) projects matching patterns for deletion" -NoNewLine + } + + # Discover agent pools + if($hasAgentPoolPatterns) { + Write-ToConsoleLog "Discovering agent pools in organization: $organizationUrl" + + $allAgentPools = (az pipelines pool list --org $organizationUrl -o json 2>$null) | ConvertFrom-Json + if($null -eq $allAgentPools) { + Write-ToConsoleLog "Failed to list agent pools in organization: $organizationUrl" -IsWarning + $allAgentPools = @() + } + + Write-ToConsoleLog "Found $($allAgentPools.Count) total agent pools in organization: $organizationUrl" -NoNewLine + + foreach($pool in $allAgentPools) { + # Skip system pools (Azure Pipelines, Default, etc.) + if($pool.isHosted -or $pool.poolType -eq "automation") { + Write-ToConsoleLog "Skipping hosted/system pool: $($pool.name)" -NoNewLine + continue + } + + foreach($pattern in $AgentPoolNamePatterns) { + if($pool.name -match $pattern) { + Write-ToConsoleLog "Agent pool matches pattern '$pattern': $($pool.name)" -NoNewLine + $agentPoolsToDelete += @{ + Name = $pool.name + Id = $pool.id + } + break + } + } + } + + Write-ToConsoleLog "Found $($agentPoolsToDelete.Count) agent pools matching patterns for deletion" -NoNewLine + } + + # Confirm deletion + $totalResourcesToDelete = $projectsToDelete.Count + $agentPoolsToDelete.Count + if($totalResourcesToDelete -eq 0) { + Write-ToConsoleLog "No resources found matching the provided patterns. Nothing to delete." -IsWarning + return + } + + if(-not $BypassConfirmation) { + Write-ToConsoleLog "The following Azure DevOps resources will be deleted:" + + if($projectsToDelete.Count -gt 0) { + Write-ToConsoleLog "Projects ($($projectsToDelete.Count)):" + $projectsToDelete | ForEach-Object { Write-ToConsoleLog " - $($_.Name)" -NoNewLine } + } + + if($agentPoolsToDelete.Count -gt 0) { + Write-ToConsoleLog "Agent Pools ($($agentPoolsToDelete.Count)):" + $agentPoolsToDelete | ForEach-Object { Write-ToConsoleLog " - $($_.Name)" -NoNewLine } + } + + if($PlanMode) { + Write-ToConsoleLog "Skipping confirmation for plan mode" + } else { + $continue = Invoke-PromptForConfirmation -message "ALL LISTED AZURE DEVOPS RESOURCES WILL BE PERMANENTLY DELETED" + if(-not $continue) { + Write-ToConsoleLog "Exiting..." + return + } + } + } + + # Delete projects + if($projectsToDelete.Count -gt 0) { + Write-ToConsoleLog "Deleting projects..." + + $projectsToDelete | ForEach-Object -Parallel { + $funcWriteToConsoleLog = $using:funcWriteToConsoleLog + ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog + $TempLogFileForPlan = $using:TempLogFileForPlan + $orgUrl = $using:organizationUrl + + $project = $_ + + if($using:PlanMode) { + Write-ToConsoleLog ` + "Would delete project: $($project.Name)", ` + "Would run: az devops project delete --id $($project.Id) --org $orgUrl --yes" ` + -IsPlan -LogFilePath $TempLogFileForPlan + } else { + Write-ToConsoleLog "Deleting project: $($project.Name)" -NoNewLine + $result = az devops project delete --id $project.Id --org $orgUrl --yes 2>&1 + if($LASTEXITCODE -ne 0) { + Write-ToConsoleLog "Failed to delete project: $($project.Name)", "Full error: $result" -IsWarning -NoNewLine + } else { + Write-ToConsoleLog "Deleted project: $($project.Name)" -NoNewLine + } + } + } -ThrottleLimit $ThrottleLimit + } + + # Delete agent pools + if($agentPoolsToDelete.Count -gt 0) { + Write-ToConsoleLog "Deleting agent pools..." + + $agentPoolsToDelete | ForEach-Object -Parallel { + $funcWriteToConsoleLog = $using:funcWriteToConsoleLog + ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog + $TempLogFileForPlan = $using:TempLogFileForPlan + $orgUrl = $using:organizationUrl + + $pool = $_ + + if($using:PlanMode) { + Write-ToConsoleLog ` + "Would delete agent pool: $($pool.Name)", ` + "Would run: az pipelines pool delete --id $($pool.Id) --org $orgUrl --yes" ` + -IsPlan -LogFilePath $TempLogFileForPlan + } else { + Write-ToConsoleLog "Deleting agent pool: $($pool.Name)" -NoNewLine + $result = az pipelines pool delete --id $pool.Id --org $orgUrl --yes 2>&1 + if($LASTEXITCODE -ne 0) { + Write-ToConsoleLog "Failed to delete agent pool: $($pool.Name)", "Full error: $result" -IsWarning -NoNewLine + } else { + Write-ToConsoleLog "Deleted agent pool: $($pool.Name)" -NoNewLine + } + } + } -ThrottleLimit $ThrottleLimit + } + + Write-ToConsoleLog "Cleanup completed." -IsSuccess + + if($PlanMode) { + Write-ToConsoleLog "Plan mode enabled, no changes were made." -IsWarning + $planLogContents = Get-Content -Path $TempLogFileForPlan -Raw + Write-ToConsoleLog "Plan mode log contents:", $planLogContents -Color Gray + Remove-Item -Path $TempLogFileForPlan -Force + } + } +} diff --git a/src/ALZ/Public/Remove-GitHubAccelerator.ps1 b/src/ALZ/Public/Remove-GitHubAccelerator.ps1 new file mode 100644 index 0000000..75316bc --- /dev/null +++ b/src/ALZ/Public/Remove-GitHubAccelerator.ps1 @@ -0,0 +1,414 @@ +function Remove-GitHubAccelerator { + <# + .SYNOPSIS + Removes GitHub resources created by the Azure Landing Zone accelerator bootstrap modules. + + .DESCRIPTION + The Remove-GitHubAccelerator function performs cleanup of GitHub resources that were created by the + Azure Landing Zone accelerator bootstrap process (https://github.com/Azure/accelerator-bootstrap-modules). + This includes repositories, teams, and optionally runner groups. + + The function operates in the following sequence: + 1. Validates GitHub CLI authentication + 2. Prompts for confirmation (unless bypassed or in plan mode) + 3. Discovers and deletes repositories matching the specified patterns + 4. Discovers and deletes teams matching the specified patterns + 5. Optionally discovers and deletes runner groups matching the specified patterns + + CRITICAL WARNING: This is a highly destructive operation that will permanently delete GitHub resources. + Use with extreme caution and ensure you have appropriate backups and authorization before executing. + + .PARAMETER GitHubOrganization + The GitHub organization name where the resources to be deleted are located. + This parameter is required. + + .PARAMETER RepositoryNamePatterns + An array of regex patterns to match against repository names. Repositories matching any of these + patterns will be deleted. If empty, no repositories will be deleted. + Default: Empty array (no repositories deleted) + + .PARAMETER TeamNamePatterns + An array of regex patterns to match against team names. Teams matching any of these patterns + will be deleted. If empty, no teams will be deleted. + Default: Empty array (no teams deleted) + + .PARAMETER RunnerGroupNamePatterns + An array of regex patterns to match against runner group names. Runner groups matching any of + these patterns will be deleted. If empty, no runner groups will be deleted. Requires the + -IncludeRunnerGroups switch to be specified. + Default: Empty array (no runner groups deleted) + + .PARAMETER IncludeRunnerGroups + A switch parameter that enables deletion of runner groups matching the patterns specified in + -RunnerGroupNamePatterns. By default, runner groups are not deleted. + Default: $false (do not delete runner groups) + + .PARAMETER BypassConfirmation + A switch parameter that bypasses the interactive confirmation prompts. When specified, the function + waits for the duration specified in -BypassConfirmationTimeoutSeconds before proceeding, allowing + time to cancel. During this timeout, pressing any key will cancel the operation. + WARNING: Use this parameter with extreme caution as it reduces safety checks. + Default: $false (confirmation required) + + .PARAMETER BypassConfirmationTimeoutSeconds + The number of seconds to wait before proceeding when -BypassConfirmation is used. During this + timeout, pressing any key will cancel the operation. This provides a safety window to prevent + accidental deletions. + Default: 30 seconds + + .PARAMETER ThrottleLimit + The maximum number of parallel operations to execute simultaneously. This controls the degree + of parallelism when processing resources. Higher values may improve performance but increase + API throttling risk. + Default: 11 + + .PARAMETER PlanMode + A switch parameter that enables "dry run" mode. When specified, the function displays what + actions would be taken without actually making any changes. This is useful for validating + the scope of operations before executing the actual cleanup. + Default: $false (execute actual deletions) + + .EXAMPLE + Remove-GitHubAccelerator -GitHubOrganization "my-org" -RepositoryNamePatterns @("^alz-.*") -PlanMode + + Shows what repositories matching the pattern "^alz-.*" would be deleted from the "my-org" + organization without making any changes. + + .EXAMPLE + Remove-GitHubAccelerator -GitHubOrganization "my-org" -RepositoryNamePatterns @("^alz-.*") -TeamNamePatterns @("^alz-.*") + + Deletes all repositories and teams matching the pattern "^alz-.*" from the "my-org" organization. + + .EXAMPLE + Remove-GitHubAccelerator -GitHubOrganization "my-org" -RepositoryNamePatterns @("^alz-.*", "^landing-zone-.*") -IncludeRunnerGroups -RunnerGroupNamePatterns @("^alz-.*") + + Deletes repositories matching either pattern and runner groups matching "^alz-.*" from the + "my-org" organization. + + .EXAMPLE + Remove-GitHubAccelerator -GitHubOrganization "my-org" -RepositoryNamePatterns @("^test-alz$") -BypassConfirmation -BypassConfirmationTimeoutSeconds 10 + + Deletes the repository named exactly "test-alz" with a 10-second confirmation bypass timeout. + + .NOTES + This function requires the GitHub CLI (gh) to be installed and authenticated. + Install GitHub CLI: https://cli.github.com/ + Authenticate: gh auth login + + Required permissions: + - delete:repo (to delete repositories) + - admin:org (to delete teams and runner groups) + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param ( + [Parameter(Mandatory = $true, HelpMessage = "[REQUIRED] The GitHub organization name.")] + [Alias("org")] + [string]$GitHubOrganization, + + [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Regex patterns to match repository names for deletion.")] + [Alias("repos")] + [string[]]$RepositoryNamePatterns = @(), + + [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Regex patterns to match team names for deletion.")] + [Alias("teams")] + [string[]]$TeamNamePatterns = @(), + + [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Regex patterns to match runner group names for deletion.")] + [Alias("runners")] + [string[]]$RunnerGroupNamePatterns = @(), + + [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Include runner groups in the deletion process.")] + [switch]$IncludeRunnerGroups, + + [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Bypass interactive confirmation prompts.")] + [switch]$BypassConfirmation, + + [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Seconds to wait when bypassing confirmation.")] + [int]$BypassConfirmationTimeoutSeconds = 30, + + [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Maximum parallel operations.")] + [int]$ThrottleLimit = 11, + + [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Enable dry run mode - no changes will be made.")] + [switch]$PlanMode + ) + + # Main execution starts here + if ($PSCmdlet.ShouldProcess("Delete GitHub Resources", "delete")) { + + Test-Tooling -Checks @("GitHubCli") + + $TempLogFileForPlan = "" + if($PlanMode) { + Write-ToConsoleLog "Plan Mode enabled, no changes will be made. All actions will be logged as what would be performed." -IsWarning + $TempLogFileForPlan = (New-TemporaryFile).FullName + } + + $funcWriteToConsoleLog = ${function:Write-ToConsoleLog}.ToString() + + if($BypassConfirmation) { + Write-ToConsoleLog "Bypass confirmation enabled, proceeding without prompts..." -IsWarning + Write-ToConsoleLog "This is a highly destructive operation that will permanently delete GitHub resources!" -IsWarning + Write-ToConsoleLog "We are waiting $BypassConfirmationTimeoutSeconds seconds to allow for cancellation. Press any key to cancel..." -IsWarning + + $keyPressed = $false + $secondsRunning = 0 + + while((-not $keyPressed) -and ($secondsRunning -lt $BypassConfirmationTimeoutSeconds)){ + $keyPressed = [Console]::KeyAvailable + Write-ToConsoleLog ("Waiting for: $($BypassConfirmationTimeoutSeconds-$secondsRunning) seconds. Press any key to cancel...") -IsWarning -Overwrite + Start-Sleep -Seconds 1 + $secondsRunning++ + } + + if($keyPressed) { + Write-ToConsoleLog "Cancellation key pressed, exiting without making any changes..." -IsError + return + } + } + + Write-ToConsoleLog "Thanks for providing the inputs, getting started..." -IsSuccess + + $hasRepositoryPatterns = $RepositoryNamePatterns.Count -gt 0 + $hasTeamPatterns = $TeamNamePatterns.Count -gt 0 + $hasRunnerGroupPatterns = $IncludeRunnerGroups -and $RunnerGroupNamePatterns.Count -gt 0 + + if(-not $hasRepositoryPatterns -and -not $hasTeamPatterns -and -not $hasRunnerGroupPatterns) { + Write-ToConsoleLog "No patterns provided for repositories, teams, or runner groups. Nothing to do. Exiting..." -IsError + return + } + + # Discover resources to delete + $repositoriesToDelete = @() + $teamsToDelete = @() + $runnerGroupsToDelete = @() + + # Discover repositories + if($hasRepositoryPatterns) { + Write-ToConsoleLog "Discovering repositories in organization: $GitHubOrganization" + + $allRepositories = (gh repo list $GitHubOrganization --json name,url --limit 1000) | ConvertFrom-Json + if($null -eq $allRepositories) { + Write-ToConsoleLog "Failed to list repositories in organization: $GitHubOrganization" -IsError + return + } + + Write-ToConsoleLog "Found $($allRepositories.Count) total repositories in organization: $GitHubOrganization" -NoNewLine + + foreach($repo in $allRepositories) { + foreach($pattern in $RepositoryNamePatterns) { + if($repo.name -match $pattern) { + Write-ToConsoleLog "Repository matches pattern '$pattern': $($repo.name)" -NoNewLine + $repositoriesToDelete += @{ + Name = $repo.name + Url = $repo.url + } + break + } + } + } + + Write-ToConsoleLog "Found $($repositoriesToDelete.Count) repositories matching patterns for deletion" -NoNewLine + } + + # Discover teams + if($hasTeamPatterns) { + Write-ToConsoleLog "Discovering teams in organization: $GitHubOrganization" + + $allTeams = (gh api "orgs/$GitHubOrganization/teams" --paginate) | ConvertFrom-Json + if($null -eq $allTeams) { + Write-ToConsoleLog "Failed to list teams in organization: $GitHubOrganization" -IsWarning + $allTeams = @() + } + + Write-ToConsoleLog "Found $($allTeams.Count) total teams in organization: $GitHubOrganization" -NoNewLine + + foreach($team in $allTeams) { + foreach($pattern in $TeamNamePatterns) { + if($team.name -match $pattern -or $team.slug -match $pattern) { + Write-ToConsoleLog "Team matches pattern '$pattern': $($team.name) (slug: $($team.slug))" -NoNewLine + $teamsToDelete += @{ + Name = $team.name + Slug = $team.slug + Id = $team.id + } + break + } + } + } + + Write-ToConsoleLog "Found $($teamsToDelete.Count) teams matching patterns for deletion" -NoNewLine + } + + # Discover runner groups + if($hasRunnerGroupPatterns) { + Write-ToConsoleLog "Discovering runner groups in organization: $GitHubOrganization" + + $runnerGroupsResponse = (gh api "orgs/$GitHubOrganization/actions/runner-groups" --paginate 2>&1) + if($LASTEXITCODE -ne 0) { + Write-ToConsoleLog "Failed to list runner groups in organization: $GitHubOrganization (may require GitHub Enterprise)" -IsWarning + $allRunnerGroups = @() + } else { + $allRunnerGroups = ($runnerGroupsResponse | ConvertFrom-Json).runner_groups + } + + if($null -ne $allRunnerGroups) { + Write-ToConsoleLog "Found $($allRunnerGroups.Count) total runner groups in organization: $GitHubOrganization" -NoNewLine + + foreach($runnerGroup in $allRunnerGroups) { + # Skip the default runner group as it cannot be deleted + if($runnerGroup.name -eq "Default" -or $runnerGroup.default) { + Write-ToConsoleLog "Skipping default runner group: $($runnerGroup.name)" -NoNewLine + continue + } + + foreach($pattern in $RunnerGroupNamePatterns) { + if($runnerGroup.name -match $pattern) { + Write-ToConsoleLog "Runner group matches pattern '$pattern': $($runnerGroup.name)" -NoNewLine + $runnerGroupsToDelete += @{ + Name = $runnerGroup.name + Id = $runnerGroup.id + } + break + } + } + } + + Write-ToConsoleLog "Found $($runnerGroupsToDelete.Count) runner groups matching patterns for deletion" -NoNewLine + } + } + + # Confirm deletion + $totalResourcesToDelete = $repositoriesToDelete.Count + $teamsToDelete.Count + $runnerGroupsToDelete.Count + if($totalResourcesToDelete -eq 0) { + Write-ToConsoleLog "No resources found matching the provided patterns. Nothing to delete." -IsWarning + return + } + + if(-not $BypassConfirmation) { + Write-ToConsoleLog "The following GitHub resources will be deleted:" + + if($repositoriesToDelete.Count -gt 0) { + Write-ToConsoleLog "Repositories ($($repositoriesToDelete.Count)):" + $repositoriesToDelete | ForEach-Object { Write-ToConsoleLog " - $($_.Name)" -NoNewLine } + } + + if($teamsToDelete.Count -gt 0) { + Write-ToConsoleLog "Teams ($($teamsToDelete.Count)):" + $teamsToDelete | ForEach-Object { Write-ToConsoleLog " - $($_.Name) (slug: $($_.Slug))" -NoNewLine } + } + + if($runnerGroupsToDelete.Count -gt 0) { + Write-ToConsoleLog "Runner Groups ($($runnerGroupsToDelete.Count)):" + $runnerGroupsToDelete | ForEach-Object { Write-ToConsoleLog " - $($_.Name)" -NoNewLine } + } + + if($PlanMode) { + Write-ToConsoleLog "Skipping confirmation for plan mode" + } else { + $continue = Invoke-PromptForConfirmation -message "ALL LISTED GITHUB RESOURCES WILL BE PERMANENTLY DELETED" + if(-not $continue) { + Write-ToConsoleLog "Exiting..." + return + } + } + } + + # Delete repositories + if($repositoriesToDelete.Count -gt 0) { + Write-ToConsoleLog "Deleting repositories..." + + $repositoriesToDelete | ForEach-Object -Parallel { + $funcWriteToConsoleLog = $using:funcWriteToConsoleLog + ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog + $TempLogFileForPlan = $using:TempLogFileForPlan + $org = $using:GitHubOrganization + + $repo = $_ + $repoFullName = "$org/$($repo.Name)" + + if($using:PlanMode) { + Write-ToConsoleLog ` + "Would delete repository: $repoFullName", ` + "Would run: gh repo delete $repoFullName --yes" ` + -IsPlan -LogFilePath $TempLogFileForPlan + } else { + Write-ToConsoleLog "Deleting repository: $repoFullName" -NoNewLine + $result = gh repo delete $repoFullName --yes 2>&1 + if($LASTEXITCODE -ne 0) { + Write-ToConsoleLog "Failed to delete repository: $repoFullName", "Full error: $result" -IsWarning -NoNewLine + } else { + Write-ToConsoleLog "Deleted repository: $repoFullName" -NoNewLine + } + } + } -ThrottleLimit $ThrottleLimit + } + + # Delete teams + if($teamsToDelete.Count -gt 0) { + Write-ToConsoleLog "Deleting teams..." + + $teamsToDelete | ForEach-Object -Parallel { + $funcWriteToConsoleLog = $using:funcWriteToConsoleLog + ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog + $TempLogFileForPlan = $using:TempLogFileForPlan + $org = $using:GitHubOrganization + + $team = $_ + + if($using:PlanMode) { + Write-ToConsoleLog ` + "Would delete team: $($team.Name) (slug: $($team.Slug))", ` + "Would run: gh api -X DELETE orgs/$org/teams/$($team.Slug)" ` + -IsPlan -LogFilePath $TempLogFileForPlan + } else { + Write-ToConsoleLog "Deleting team: $($team.Name) (slug: $($team.Slug))" -NoNewLine + $result = gh api -X DELETE "orgs/$org/teams/$($team.Slug)" 2>&1 + if($LASTEXITCODE -ne 0) { + Write-ToConsoleLog "Failed to delete team: $($team.Name)", "Full error: $result" -IsWarning -NoNewLine + } else { + Write-ToConsoleLog "Deleted team: $($team.Name)" -NoNewLine + } + } + } -ThrottleLimit $ThrottleLimit + } + + # Delete runner groups + if($runnerGroupsToDelete.Count -gt 0) { + Write-ToConsoleLog "Deleting runner groups..." + + $runnerGroupsToDelete | ForEach-Object -Parallel { + $funcWriteToConsoleLog = $using:funcWriteToConsoleLog + ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog + $TempLogFileForPlan = $using:TempLogFileForPlan + $org = $using:GitHubOrganization + + $runnerGroup = $_ + + if($using:PlanMode) { + Write-ToConsoleLog ` + "Would delete runner group: $($runnerGroup.Name)", ` + "Would run: gh api -X DELETE orgs/$org/actions/runner-groups/$($runnerGroup.Id)" ` + -IsPlan -LogFilePath $TempLogFileForPlan + } else { + Write-ToConsoleLog "Deleting runner group: $($runnerGroup.Name)" -NoNewLine + $result = gh api -X DELETE "orgs/$org/actions/runner-groups/$($runnerGroup.Id)" 2>&1 + if($LASTEXITCODE -ne 0) { + Write-ToConsoleLog "Failed to delete runner group: $($runnerGroup.Name)", "Full error: $result" -IsWarning -NoNewLine + } else { + Write-ToConsoleLog "Deleted runner group: $($runnerGroup.Name)" -NoNewLine + } + } + } -ThrottleLimit $ThrottleLimit + } + + Write-ToConsoleLog "Cleanup completed." -IsSuccess + + if($PlanMode) { + Write-ToConsoleLog "Plan mode enabled, no changes were made." -IsWarning + $planLogContents = Get-Content -Path $TempLogFileForPlan -Raw + Write-ToConsoleLog "Plan mode log contents:", $planLogContents -Color Gray + Remove-Item -Path $TempLogFileForPlan -Force + } + } +} diff --git a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 index ad47e67..dc8665b 100644 --- a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -344,135 +344,6 @@ function Remove-PlatformLandingZone { [switch]$ForceSubscriptionPlacement ) - function Write-ToConsoleLog { - param ( - [string[]]$Messages, - [string]$Level = "INFO", - [System.ConsoleColor]$Color = [System.ConsoleColor]::Blue, - [switch]$NoNewLine, - [switch]$Overwrite, - [switch]$IsError, - [switch]$IsWarning, - [switch]$IsSuccess, - [switch]$IsPlan, - [switch]$WriteToFile, - [string]$LogFilePath = $null - ) - - $isDefaultColor = $Color -eq [System.ConsoleColor]::Blue - - if($IsError) { - $Level = "ERROR" - } elseif ($IsWarning) { - $Level = "WARNING" - } elseif ($IsSuccess) { - $Level = "SUCCESS" - } elseif ($IsPlan) { - $Level = "PLAN" - $WriteToFile = $true - $NoNewLine = $true - } - - if($isDefaultColor) { - if($Level -eq "ERROR") { - $Color = [System.ConsoleColor]::Red - } elseif ($Level -eq "WARNING") { - $Color = [System.ConsoleColor]::Yellow - } elseif ($Level -eq "SUCCESS") { - $Color = [System.ConsoleColor]::Green - } elseif ($Level -eq "PLAN") { - $Color = [System.ConsoleColor]::Gray - } - } - - $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff" - $prefix = "" - - if ($Overwrite) { - $prefix = "`r" - } else { - if (-not $NoNewLine) { - $prefix = [System.Environment]::NewLine - } - } - - $finalMessages = @() - foreach ($Message in $Messages) { - $finalMessages += "$prefix[$timestamp] [$Level] $Message" - } - - if($finalMessages.Count -gt 1) { - $finalMessages = $finalMessages -join "`n" - } - - Write-Host $finalMessages -ForegroundColor $Color -NoNewline:$Overwrite.IsPresent - if($WriteToFile -and $LogFilePath) { - Add-Content -Path $LogFilePath -Value $finalMessages - } - } - - function Test-RequiredTooling { - Write-ToConsoleLog "Checking the software requirements..." - - $checkResults = @() - $hasFailure = $false - - # Check if Azure CLI is installed - Write-Verbose "Checking Azure CLI installation" - $azCliPath = Get-Command az -ErrorAction SilentlyContinue - if ($azCliPath) { - $checkResults += @{ - message = "Azure CLI is installed." - result = "Success" - } - } else { - $checkResults += @{ - message = "Azure CLI is not installed. Follow the instructions here: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli" - result = "Failure" - } - $hasFailure = $true - } - - # Check if Azure CLI is logged in - Write-Verbose "Checking Azure CLI login status" - $azCliAccount = $(az account show -o json) | ConvertFrom-Json - if ($azCliAccount) { - $checkResults += @{ - message = "Azure CLI is logged in. Tenant ID: $($azCliAccount.tenantId), Subscription: $($azCliAccount.name) ($($azCliAccount.id))" - result = "Success" - } - } else { - $checkResults += @{ - message = "Azure CLI is not logged in. Please login to Azure CLI using 'az login -t `"00000000-0000-0000-0000-000000000000}`"', replacing the empty GUID with your tenant ID." - result = "Failure" - } - $hasFailure = $true - } - - Write-Verbose "Showing check results" - Write-Verbose $(ConvertTo-Json $checkResults -Depth 100) - $checkResults | ForEach-Object {[PSCustomObject]$_} | Format-Table -Property @{ - Label = "Check Result"; Expression = { - switch ($_.result) { - 'Success' { $color = "92"; break } - 'Failure' { $color = "91"; break } - 'Warning' { $color = "93"; break } - default { $color = "0" } - } - $e = [char]27 - "$e[${color}m$($_.result)${e}[0m" - } - }, @{ Label = "Check Details"; Expression = {$_.message} } -AutoSize -Wrap - - if($hasFailure) { - Write-ToConsoleLog "Software requirements have no been met, please review and install the missing software." -IsError - Write-ToConsoleLog "Cannot continue with Deployment..." -IsError - throw "Software requirements have no been met, please review and install the missing software." - } - - Write-ToConsoleLog "All software requirements have been met." -IsSuccess - } - function Get-ManagementGroupChildrenRecursive { param ( [object[]]$ManagementGroups, @@ -490,7 +361,7 @@ function Remove-PlatformLandingZone { $shouldDelete = $false foreach($pattern in $ManagementGroupsToDeleteNamePatterns) { if($mg.name -like "*$pattern*" -or $mg.displayName -like "*$pattern*") { - Write-ToConsoleLog "Including management group for deletion due to pattern match '$pattern': $($mg.name) ($($mg.displayName))" -NoNewLine + Write-ToConsoleLog "Including management group for deletion due to pattern match '$pattern': $($mg.name) ($($mg.displayName))" $shouldDelete = $true break } @@ -498,7 +369,7 @@ function Remove-PlatformLandingZone { if($shouldDelete) { $filteredManagementGroups += $mg } else { - Write-ToConsoleLog "Skipping management group (no pattern match): $($mg.name) ($($mg.displayName))" -NoNewLine + Write-ToConsoleLog "Skipping management group (no pattern match): $($mg.name) ($($mg.displayName))" } } $ManagementGroups = $filteredManagementGroups @@ -514,13 +385,13 @@ function Remove-PlatformLandingZone { $children = $managementGroup.children | Where-Object { $_.type -eq "Microsoft.Management/managementGroups" } if ($children -and $children.Count -gt 0) { - Write-ToConsoleLog "Management group has children: $($managementGroup.name)" -NoNewLine + Write-ToConsoleLog "Management group has children: $($managementGroup.name)" if(!$ManagementGroupsFound.ContainsKey($Depth + 1)) { $ManagementGroupsFound[$Depth + 1] = @() } Get-ManagementGroupChildrenRecursive -ManagementGroups $children -Depth ($Depth + 1) -ManagementGroupsFound $ManagementGroupsFound -ManagementGroupsToDeleteNamePatterns $ManagementGroupsToDeleteNamePatterns } else { - Write-ToConsoleLog "Management group has no children: $($managementGroup.name)" -NoNewLine + Write-ToConsoleLog "Management group has no children: $($managementGroup.name)" } } @@ -540,44 +411,6 @@ function Remove-PlatformLandingZone { return [System.Guid]::TryParse($StringGuid,[System.Management.Automation.PSReference]$ObjectGuid) } - function Invoke-PromptForConfirmation { - param ( - [string]$Message, - [string]$FinalConfirmationText = "CONFIRM" - ) - - Write-ToConsoleLog "$Message" -IsWarning - $randomString = (Get-RandomString -Length 6).ToUpper() - Write-ToConsoleLog "If you wish to proceed, type '$randomString' to confirm." -IsWarning - $confirmation = Read-Host "Enter the confirmation text" - $confirmation = $confirmation.ToUpper().Replace("'","").Replace([System.Environment]::NewLine, "").Trim() - if ($confirmation -ne $randomString.ToUpper()) { - Write-ToConsoleLog "Confirmation text did not match the required input. Exiting without making any changes." -IsError - return $false - } - Write-ToConsoleLog "Initial confirmation received." -IsSuccess - Write-ToConsoleLog "This operation is permanent and cannot be reversed!" -IsWarning - Write-ToConsoleLog "Are you sure you want to proceed? Type '$FinalConfirmationText' to perform the highly destructive operation..." -IsWarning - $confirmation = Read-Host "Enter the final confirmation text" - $confirmation = $confirmation.ToUpper().Replace("'","").Replace([System.Environment]::NewLine, "").Trim() - if ($confirmation -ne $FinalConfirmationText.ToUpper()) { - Write-ToConsoleLog "Final confirmation did not match the required input. Exiting without making any changes." -IsError - return $false - } - Write-ToConsoleLog "Final confirmation received. Proceeding with destructive operation..." -IsSuccess - return $true - } - - function Get-RandomString { - param ( - [int]$Length = 8 - ) - - $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' - $string = -join ((1..$Length) | ForEach-Object { $chars[(Get-Random -Maximum $chars.Length)] }) - return $string - } - function Remove-OrphanedRoleAssignmentsForScope { [CmdletBinding(SupportsShouldProcess = $true)] param ( @@ -595,12 +428,12 @@ function Remove-PlatformLandingZone { $funcWriteToConsoleLog = ${function:Write-ToConsoleLog}.ToString() $isSubscriptionScope = $ScopeType -eq "subscription" - Write-ToConsoleLog "Checking for orphaned role assignments to delete in $($ScopeType): $ScopeNameForLogs" -NoNewLine + Write-ToConsoleLog "Checking for orphaned role assignments to delete in $($ScopeType): $ScopeNameForLogs" $scopePrefix = $isSubscriptionScope ? "/subscriptions" : "/providers/Microsoft.Management/managementGroups" $roleAssignments = (az role assignment list --scope "$scopePrefix/$ScopeId" --query "[?principalName==''].{id:id,principalId:principalId,roleDefinitionName:roleDefinitionName}" -o json) | ConvertFrom-Json if ($roleAssignments -and $roleAssignments.Count -gt 0) { - Write-ToConsoleLog "Found $($roleAssignments.Count) orphaned role assignment(s) in $($ScopeType): $ScopeNameForLogs" -NoNewLine + Write-ToConsoleLog "Found $($roleAssignments.Count) orphaned role assignment(s) in $($ScopeType): $ScopeNameForLogs" $roleAssignments | ForEach-Object -Parallel { $roleAssignment = $_ @@ -609,7 +442,7 @@ function Remove-PlatformLandingZone { $funcWriteToConsoleLog = $using:funcWriteToConsoleLog ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog - Write-ToConsoleLog "Deleting orphaned role assignment: $($roleAssignment.roleDefinitionName) for principal: $($roleAssignment.principalId) from $($ScopeType): $ScopeNameForLogs" -NoNewLine + Write-ToConsoleLog "Deleting orphaned role assignment: $($roleAssignment.roleDefinitionName) for principal: $($roleAssignment.principalId) from $($ScopeType): $ScopeNameForLogs" $result = $null if($using:PlanMode) { Write-ToConsoleLog ` @@ -621,15 +454,15 @@ function Remove-PlatformLandingZone { } if (!$result) { - Write-ToConsoleLog "Deleted orphaned role assignment: $($roleAssignment.roleDefinitionName) from $($ScopeType): $ScopeNameForLogs" -NoNewLine + Write-ToConsoleLog "Deleted orphaned role assignment: $($roleAssignment.roleDefinitionName) from $($ScopeType): $ScopeNameForLogs" } else { - Write-ToConsoleLog "Failed to delete orphaned role assignment: $($roleAssignment.roleDefinitionName) from $($ScopeType): $ScopeNameForLogs", "Full error: $result" -IsWarning -NoNewLine + Write-ToConsoleLog "Failed to delete orphaned role assignment: $($roleAssignment.roleDefinitionName) from $($ScopeType): $ScopeNameForLogs", "Full error: $result" -IsWarning } } -ThrottleLimit $using:ThrottleLimit - Write-ToConsoleLog "All orphaned role assignments processed in $($ScopeType): $ScopeNameForLogs" -NoNewLine + Write-ToConsoleLog "All orphaned role assignments processed in $($ScopeType): $ScopeNameForLogs" } else { - Write-ToConsoleLog "No orphaned role assignments found in $($ScopeType): $ScopeNameForLogs, skipping." -NoNewLine + Write-ToConsoleLog "No orphaned role assignments found in $($ScopeType): $ScopeNameForLogs, skipping." } } @@ -656,7 +489,7 @@ function Remove-PlatformLandingZone { # Delete deployment stacks first (before regular deployments) if(-not $SkipDeploymentStackDeletion) { - Write-ToConsoleLog "Checking for deployment stacks to delete in $($ScopeType): $ScopeNameForLogs" -NoNewLine + Write-ToConsoleLog "Checking for deployment stacks to delete in $($ScopeType): $ScopeNameForLogs" $deploymentStacks = @() if ($isSubscriptionScope) { @@ -672,7 +505,7 @@ function Remove-PlatformLandingZone { $shouldDelete = $false foreach($pattern in $DeploymentStacksToDeleteNamePatterns) { if($stack.name -like "*$pattern*") { - Write-ToConsoleLog "Including deployment stack for deletion due to pattern match '$pattern': $($stack.name)" -NoNewLine + Write-ToConsoleLog "Including deployment stack for deletion due to pattern match '$pattern': $($stack.name)" $shouldDelete = $true break } @@ -680,14 +513,14 @@ function Remove-PlatformLandingZone { if($shouldDelete) { $filteredDeploymentStacks += $stack } else { - Write-ToConsoleLog "Skipping deployment stack (no pattern match): $($stack.name)" -NoNewLine + Write-ToConsoleLog "Skipping deployment stack (no pattern match): $($stack.name)" } } $deploymentStacks = $filteredDeploymentStacks } if ($deploymentStacks -and $deploymentStacks.Count -gt 0) { - Write-ToConsoleLog "Found $($deploymentStacks.Count) deployment stack(s) in $($ScopeType): $ScopeNameForLogs" -NoNewLine + Write-ToConsoleLog "Found $($deploymentStacks.Count) deployment stack(s) in $($ScopeType): $ScopeNameForLogs" $deploymentStacks | ForEach-Object -Parallel { $deploymentStack = $_ @@ -698,7 +531,7 @@ function Remove-PlatformLandingZone { ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog $isSubscriptionScope = $using:isSubscriptionScope - Write-ToConsoleLog "Deleting deployment stack: $($deploymentStack.name) from $($scopeType): $scopeNameForLogs" -NoNewLine + Write-ToConsoleLog "Deleting deployment stack: $($deploymentStack.name) from $($scopeType): $scopeNameForLogs" $result = $null if($isSubscriptionScope) { if($using:PlanMode) { @@ -721,22 +554,22 @@ function Remove-PlatformLandingZone { } if (!$result) { - Write-ToConsoleLog "Deleted deployment stack: $($deploymentStack.name) from $($scopeType): $scopeNameForLogs" -NoNewLine + Write-ToConsoleLog "Deleted deployment stack: $($deploymentStack.name) from $($scopeType): $scopeNameForLogs" } else { - Write-ToConsoleLog "Failed to delete deployment stack: $($deploymentStack.name) from $($scopeType): $scopeNameForLogs", "Full error: $result" -IsWarning -NoNewLine + Write-ToConsoleLog "Failed to delete deployment stack: $($deploymentStack.name) from $($scopeType): $scopeNameForLogs", "Full error: $result" -IsWarning } } -ThrottleLimit $ThrottleLimit - Write-ToConsoleLog "All deployment stacks processed in $($ScopeType): $ScopeNameForLogs" -NoNewLine + Write-ToConsoleLog "All deployment stacks processed in $($ScopeType): $ScopeNameForLogs" } else { - Write-ToConsoleLog "No deployment stacks found in $($ScopeType): $ScopeNameForLogs, skipping." -NoNewLine + Write-ToConsoleLog "No deployment stacks found in $($ScopeType): $ScopeNameForLogs, skipping." } } else { - Write-ToConsoleLog "Skipping deployment stack deletion in $($ScopeType): $ScopeNameForLogs" -NoNewLine + Write-ToConsoleLog "Skipping deployment stack deletion in $($ScopeType): $ScopeNameForLogs" } if(-not $SkipDeploymentDeletion) { - Write-ToConsoleLog "Checking for deployments to delete in $($ScopeType): $ScopeNameForLogs" -NoNewLine + Write-ToConsoleLog "Checking for deployments to delete in $($ScopeType): $ScopeNameForLogs" $deployments = @() if ($isSubscriptionScope) { @@ -746,7 +579,7 @@ function Remove-PlatformLandingZone { } if ($deployments -and $deployments.Count -gt 0) { - Write-ToConsoleLog "Found $($deployments.Count) deployment(s) in $($ScopeType): $scopeNameForLogs" -NoNewLine + Write-ToConsoleLog "Found $($deployments.Count) deployment(s) in $($ScopeType): $scopeNameForLogs" $deployments | ForEach-Object -Parallel { $deploymentName = $_ @@ -756,7 +589,7 @@ function Remove-PlatformLandingZone { ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog $isSubscriptionScope = $using:isSubscriptionScope - Write-ToConsoleLog "Deleting deployment: $deploymentName from $($scopeType): $scopeNameForLogs" -NoNewLine + Write-ToConsoleLog "Deleting deployment: $deploymentName from $($scopeType): $scopeNameForLogs" $result = $null if($isSubscriptionScope) { if($using:PlanMode) { @@ -779,18 +612,18 @@ function Remove-PlatformLandingZone { } if (!$result) { - Write-ToConsoleLog "Deleted deployment: $deploymentName from $($scopeType): $scopeNameForLogs" -NoNewLine + Write-ToConsoleLog "Deleted deployment: $deploymentName from $($scopeType): $scopeNameForLogs" } else { - Write-ToConsoleLog "Failed to delete deployment: $deploymentName from $($scopeType): $scopeNameForLogs", "Full error: $result" -IsWarning -NoNewLine + Write-ToConsoleLog "Failed to delete deployment: $deploymentName from $($scopeType): $scopeNameForLogs", "Full error: $result" -IsWarning } } -ThrottleLimit $ThrottleLimit - Write-ToConsoleLog "All deployments processed in $($scopeType): $scopeNameForLogs" -NoNewLine + Write-ToConsoleLog "All deployments processed in $($scopeType): $scopeNameForLogs" } else { - Write-ToConsoleLog "No deployments found in $($scopeType): $scopeNameForLogs, skipping." -NoNewLine + Write-ToConsoleLog "No deployments found in $($scopeType): $scopeNameForLogs, skipping." } } else { - Write-ToConsoleLog "Skipping deployment deletion in $($ScopeType): $ScopeNameForLogs" -NoNewLine + Write-ToConsoleLog "Skipping deployment deletion in $($ScopeType): $ScopeNameForLogs" } } @@ -811,7 +644,7 @@ function Remove-PlatformLandingZone { $funcWriteToConsoleLog = ${function:Write-ToConsoleLog}.ToString() - Write-ToConsoleLog "Checking for custom role definitions on management group: $ManagementGroupId ($ManagementGroupDisplayName)" -NoNewLine + Write-ToConsoleLog "Checking for custom role definitions on management group: $ManagementGroupId ($ManagementGroupDisplayName)" # Get all custom role definitions scoped to this management group $customRoleDefinitions = (az role definition list --custom-role-only true --scope "/providers/Microsoft.Management/managementGroups/$ManagementGroupId" --query "[].{name:name,roleName:roleName,id:id,assignableScopes:assignableScopes}" -o json) | ConvertFrom-Json @@ -827,7 +660,7 @@ function Remove-PlatformLandingZone { $shouldDelete = $false foreach($pattern in $RoleDefinitionsToDeleteNamePatterns) { if($roleDef.roleName -like "*$pattern*") { - Write-ToConsoleLog "Including custom role definition for deletion due to pattern match '$pattern': $($roleDef.roleName) (ID: $($roleDef.name))" -NoNewLine + Write-ToConsoleLog "Including custom role definition for deletion due to pattern match '$pattern': $($roleDef.roleName) (ID: $($roleDef.name))" $shouldDelete = $true break } @@ -835,36 +668,36 @@ function Remove-PlatformLandingZone { if($shouldDelete) { $filteredRoleDefinitions += $roleDef } else { - Write-ToConsoleLog "Skipping custom role definition (no pattern match): $($roleDef.roleName) (ID: $($roleDef.name))" -NoNewLine + Write-ToConsoleLog "Skipping custom role definition (no pattern match): $($roleDef.roleName) (ID: $($roleDef.name))" } } $customRoleDefinitions = $filteredRoleDefinitions } if (-not $customRoleDefinitions -or $customRoleDefinitions.Count -eq 0) { - Write-ToConsoleLog "No custom role definitions found on management group: $ManagementGroupId ($ManagementGroupDisplayName), skipping." -NoNewLine + Write-ToConsoleLog "No custom role definitions found on management group: $ManagementGroupId ($ManagementGroupDisplayName), skipping." return } - Write-ToConsoleLog "Found $($customRoleDefinitions.Count) custom role definition(s) on management group: $ManagementGroupId ($ManagementGroupDisplayName)" -NoNewLine + Write-ToConsoleLog "Found $($customRoleDefinitions.Count) custom role definition(s) on management group: $ManagementGroupId ($ManagementGroupDisplayName)" # For each custom role definition, find and delete all assignments using Resource Graph, then delete the definition foreach ($roleDefinition in $customRoleDefinitions) { $graphExtension = az extension show --name resource-graph 2>$null if (-not $graphExtension) { - Write-ToConsoleLog "Installing Azure Resource Graph extension for role assignment queries..." -NoNewLine -IsWarning + Write-ToConsoleLog "Installing Azure Resource Graph extension for role assignment queries..." -IsWarning az config set extension.dynamic_install_allow_preview=true 2>$null az extension add --name resource-graph 2>$null } - Write-ToConsoleLog "Processing custom role definition: $($roleDefinition.roleName) (ID: $($roleDefinition.name))" -NoNewLine + Write-ToConsoleLog "Processing custom role definition: $($roleDefinition.roleName) (ID: $($roleDefinition.name))" # Use Resource Graph to find all role assignments for this custom role definition across all scopes $resourceGraphQuery = "authorizationresources | where type == 'microsoft.authorization/roleassignments' | where properties.roleDefinitionId == '/providers/Microsoft.Authorization/RoleDefinitions/$($roleDefinition.name)' | project id, name, properties" $roleAssignments = (az graph query -q $resourceGraphQuery --query "data" --management-groups $ManagementGroupId -o json) | ConvertFrom-Json if ($roleAssignments -and $roleAssignments.Count -gt 0) { - Write-ToConsoleLog "Found $($roleAssignments.Count) role assignment(s) for custom role '$($roleDefinition.roleName)'" -NoNewLine + Write-ToConsoleLog "Found $($roleAssignments.Count) role assignment(s) for custom role '$($roleDefinition.roleName)'" $roleAssignments | ForEach-Object -Parallel { $assignment = $_ @@ -872,7 +705,7 @@ function Remove-PlatformLandingZone { $funcWriteToConsoleLog = $using:funcWriteToConsoleLog ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog - Write-ToConsoleLog "Deleting role assignment '$($assignment.name)' of custom role '$roleDefinitionName' for principal: $($assignment.properties.principalId)" -NoNewLine + Write-ToConsoleLog "Deleting role assignment '$($assignment.name)' of custom role '$roleDefinitionName' for principal: $($assignment.properties.principalId)" if($using:PlanMode) { Write-ToConsoleLog ` @@ -882,18 +715,18 @@ function Remove-PlatformLandingZone { } else { $result = az role assignment delete --ids $assignment.id 2>&1 if (!$result) { - Write-ToConsoleLog "Deleted role assignment '$($assignment.name)' of custom role '$roleDefinitionName'" -NoNewLine + Write-ToConsoleLog "Deleted role assignment '$($assignment.name)' of custom role '$roleDefinitionName'" } else { - Write-ToConsoleLog "Failed to delete role assignment '$($assignment.name)' of custom role '$roleDefinitionName'", "Full error: $result" -IsWarning -NoNewLine + Write-ToConsoleLog "Failed to delete role assignment '$($assignment.name)' of custom role '$roleDefinitionName'", "Full error: $result" -IsWarning } } } -ThrottleLimit $using:ThrottleLimit } else { - Write-ToConsoleLog "No role assignments found for custom role '$($roleDefinition.roleName)'" -NoNewLine + Write-ToConsoleLog "No role assignments found for custom role '$($roleDefinition.roleName)'" } # Now delete the custom role definition itself - Write-ToConsoleLog "Deleting custom role definition: $($roleDefinition.roleName) (ID: $($roleDefinition.name))" -NoNewLine + Write-ToConsoleLog "Deleting custom role definition: $($roleDefinition.roleName) (ID: $($roleDefinition.name))" if($PlanMode) { Write-ToConsoleLog ` @@ -903,20 +736,20 @@ function Remove-PlatformLandingZone { } else { $result = az role definition delete --name $roleDefinition.name --scope "/providers/Microsoft.Management/managementGroups/$ManagementGroupId" 2>&1 if (!$result) { - Write-ToConsoleLog "Deleted custom role definition: $($roleDefinition.roleName) (ID: $($roleDefinition.name))" -NoNewLine + Write-ToConsoleLog "Deleted custom role definition: $($roleDefinition.roleName) (ID: $($roleDefinition.name))" } else { - Write-ToConsoleLog "Failed to delete custom role definition: $($roleDefinition.roleName) (ID: $($roleDefinition.name))", "Full error: $result" -IsWarning -NoNewLine + Write-ToConsoleLog "Failed to delete custom role definition: $($roleDefinition.roleName) (ID: $($roleDefinition.name))", "Full error: $result" -IsWarning } } } - Write-ToConsoleLog "All custom role definitions processed for management group: $ManagementGroupId ($ManagementGroupDisplayName)" -NoNewLine + Write-ToConsoleLog "All custom role definitions processed for management group: $ManagementGroupId ($ManagementGroupDisplayName)" } # Main execution starts here if ($PSCmdlet.ShouldProcess("Delete Management Groups and Clean Subscriptions", "delete")) { - Test-RequiredTooling + Test-Tooling -Checks @("AzureCli", "AzureLogin") Write-ToConsoleLog "This cmdlet uses preview features of the Azure CLI. By continuing, you agree to install preview extensions." -IsWarning @@ -998,7 +831,7 @@ function Remove-PlatformLandingZone { } foreach($matchedMg in $matchingMgs) { - Write-ToConsoleLog "Found management group matching pattern '$managementGroup': $($matchedMg.name) ($($matchedMg.displayName))" -NoNewLine + Write-ToConsoleLog "Found management group matching pattern '$managementGroup': $($matchedMg.name) ($($matchedMg.displayName))" $managementGroupsFound += @{ Name = $matchedMg.name DisplayName = $matchedMg.displayName @@ -1017,7 +850,7 @@ function Remove-PlatformLandingZone { if(-not $BypassConfirmation) { Write-ToConsoleLog "The following Management Groups will be processed for removal:" - $managementGroupsFound | ForEach-Object { Write-ToConsoleLog "Management Group: $($_.Name) ($($_.DisplayName))" -NoNewLine } + $managementGroupsFound | ForEach-Object { Write-ToConsoleLog "Management Group: $($_.Name) ($($_.DisplayName))" } if($PlanMode) { Write-ToConsoleLog "Skipping confirmation for plan mode" @@ -1053,7 +886,7 @@ function Remove-PlatformLandingZone { $managementGroupId = $_.Name $managementGroupDisplayName = $_.DisplayName - Write-ToConsoleLog "Finding management group: $managementGroupId ($managementGroupDisplayName)" -NoNewLine + Write-ToConsoleLog "Finding management group: $managementGroupId ($managementGroupDisplayName)" $topLevelManagementGroup = (az account management-group show --name $managementGroupId --expand --recurse) | ConvertFrom-Json $hasChildren = $topLevelManagementGroup.children -and $topLevelManagementGroup.children.Count -gt 0 @@ -1067,7 +900,7 @@ function Remove-PlatformLandingZone { $patternsToUse = $deleteTargetManagementGroups ? @() : $using:ManagementGroupsToDeleteNamePatterns $managementGroupsToDelete = Get-ManagementGroupChildrenRecursive -ManagementGroups @($targetManagementGroups) -ManagementGroupsToDeleteNamePatterns $patternsToUse } else { - Write-ToConsoleLog "Management group has no children: $managementGroupId ($managementGroupDisplayName)" -NoNewLine + Write-ToConsoleLog "Management group has no children: $managementGroupId ($managementGroupDisplayName)" } $reverseKeys = $managementGroupsToDelete.Keys | Sort-Object -Descending @@ -1078,7 +911,7 @@ function Remove-PlatformLandingZone { foreach($depth in $reverseKeys) { $managementGroups = $managementGroupsToDelete[$depth] - Write-ToConsoleLog "Deleting management groups at depth: $depth" -NoNewLine + Write-ToConsoleLog "Deleting management groups at depth: $depth" $managementGroups | ForEach-Object -Parallel { $subscriptionsFound = $using:subscriptionsFound @@ -1088,9 +921,9 @@ function Remove-PlatformLandingZone { $subscriptions = (az account management-group subscription show-sub-under-mg --name $_) | ConvertFrom-Json if ($subscriptions.Count -gt 0) { - Write-ToConsoleLog "Management group has subscriptions: $_" -NoNewLine + Write-ToConsoleLog "Management group has subscriptions: $_" foreach ($subscription in $subscriptions) { - Write-ToConsoleLog "Removing subscription from management group: $_, subscription: $($subscription.displayName)" -NoNewLine + Write-ToConsoleLog "Removing subscription from management group: $_, subscription: $($subscription.displayName)" if(-not $subscriptionsProvided) { $subscriptionsFound.Add( @{ @@ -1101,7 +934,7 @@ function Remove-PlatformLandingZone { } if($subscriptionsTargetManagementGroup) { - Write-ToConsoleLog "Moving subscription from management group $_ to target management group: $($subscriptionsTargetManagementGroup), subscription: $($subscription.displayName)" -NoNewLine + Write-ToConsoleLog "Moving subscription from management group $_ to target management group: $($subscriptionsTargetManagementGroup), subscription: $($subscription.displayName)" if($using:PlanMode) { Write-ToConsoleLog ` "Moving subscription from management group $_ to target management group: $($subscriptionsTargetManagementGroup), subscription: $($subscription.displayName)", ` @@ -1110,13 +943,13 @@ function Remove-PlatformLandingZone { } else { $result = (az account management-group subscription add --name $subscriptionsTargetManagementGroup --subscription $subscription.name 2>&1) if($result -and $result.ToLower().Contains("Error")) { - Write-ToConsoleLog "Failed to move subscription to target management group: $($subscriptionsTargetManagementGroup), subscription: $($subscription.displayName)", "Full error: $result" -IsWarning -NoNewLine + Write-ToConsoleLog "Failed to move subscription to target management group: $($subscriptionsTargetManagementGroup), subscription: $($subscription.displayName)", "Full error: $result" -IsWarning } else { - Write-ToConsoleLog "Moved subscription to target management group: $($subscriptionsTargetManagementGroup), subscription: $($subscription.displayName)" -NoNewLine + Write-ToConsoleLog "Moved subscription to target management group: $($subscriptionsTargetManagementGroup), subscription: $($subscription.displayName)" } } } else { - Write-ToConsoleLog "Removing subscription from management group $($_): $($subscription.displayName)" -NoNewLine + Write-ToConsoleLog "Removing subscription from management group $($_): $($subscription.displayName)" if($using:PlanMode) { Write-ToConsoleLog ` "Removing subscription from management group $($_): $($subscription.displayName)", ` @@ -1125,18 +958,18 @@ function Remove-PlatformLandingZone { } else { $result = (az account management-group subscription remove --name $_ --subscription $subscription.name 2>&1) if($result -and $result.ToLower().Contains("Error")) { - Write-ToConsoleLog "Failed to remove subscription from management group: $_, subscription: $($subscription.displayName)", "Full error: $result" -IsWarning -NoNewLine + Write-ToConsoleLog "Failed to remove subscription from management group: $_, subscription: $($subscription.displayName)", "Full error: $result" -IsWarning } else { - Write-ToConsoleLog "Removed subscription from management group: $_, subscription: $($subscription.displayName)" -NoNewLine + Write-ToConsoleLog "Removed subscription from management group: $_, subscription: $($subscription.displayName)" } } } } } else { - Write-ToConsoleLog "Management group has no subscriptions: $_" -NoNewline + Write-ToConsoleLog "Management group has no subscriptions: $_" } - Write-ToConsoleLog "Deleting management group: $_" -NoNewline + Write-ToConsoleLog "Deleting management group: $_" if($using:PlanMode) { Write-ToConsoleLog ` "Deleting management group: $_", ` @@ -1145,9 +978,9 @@ function Remove-PlatformLandingZone { } else { $result = (az account management-group delete --name $_ 2>&1) if($result -like "*Error*") { - Write-ToConsoleLog "Failed to delete management group: $_", "Full error: $result" -IsWarning -NoNewline + Write-ToConsoleLog "Failed to delete management group: $_", "Full error: $result" -IsWarning } else { - Write-ToConsoleLog "Deleted management group: $_" -NoNewline + Write-ToConsoleLog "Deleted management group: $_" } } } -ThrottleLimit $using:ThrottleLimit @@ -1179,7 +1012,7 @@ function Remove-PlatformLandingZone { } -ThrottleLimit $ThrottleLimit } else { - Write-ToConsoleLog "Skipping deployment and deployment stack deletion for management groups" -NoNewLine + Write-ToConsoleLog "Skipping deployment and deployment stack deletion for management groups" } # Delete orphaned role assignments from target management groups that are not being deleted @@ -1203,7 +1036,7 @@ function Remove-PlatformLandingZone { } -ThrottleLimit $ThrottleLimit } else { - Write-ToConsoleLog "Skipping orphaned role assignment deletion for management groups" -NoNewLine + Write-ToConsoleLog "Skipping orphaned role assignment deletion for management groups" } } @@ -1236,7 +1069,7 @@ function Remove-PlatformLandingZone { Write-ToConsoleLog "Additional subscription not found, skipping: $subscription" -IsWarning continue } - Write-ToConsoleLog "Adding additional subscription: $($subscriptionObject.Name) (ID: $($subscriptionObject.Id))" -NoNewLine + Write-ToConsoleLog "Adding additional subscription: $($subscriptionObject.Name) (ID: $($subscriptionObject.Id))" $subscriptionsFound.Add($subscriptionObject) } } @@ -1262,7 +1095,7 @@ function Remove-PlatformLandingZone { } if($targetManagementGroupForPlacement) { - Write-ToConsoleLog "Force subscription placement enabled, moving subscriptions to management group: $targetManagementGroupForPlacement" -NoNewLine + Write-ToConsoleLog "Force subscription placement enabled, moving subscriptions to management group: $targetManagementGroupForPlacement" $subscriptionsFinal | ForEach-Object -Parallel { $subscription = $_ @@ -1271,7 +1104,7 @@ function Remove-PlatformLandingZone { ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog $TempLogFileForPlan = $using:TempLogFileForPlan - Write-ToConsoleLog "Moving subscription to management group: $targetMg, subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewLine + Write-ToConsoleLog "Moving subscription to management group: $targetMg, subscription: $($subscription.Name) (ID: $($subscription.Id))" if($using:PlanMode) { Write-ToConsoleLog ` "Moving subscription to management group: $targetMg, subscription: $($subscription.Name) (ID: $($subscription.Id))", ` @@ -1279,7 +1112,7 @@ function Remove-PlatformLandingZone { -IsPlan -LogFilePath $TempLogFileForPlan } else { az account management-group subscription add --name $targetMg --subscription $subscription.Id 2>&1 | Out-Null - Write-ToConsoleLog "Subscription placed in management group: $targetMg, subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewLine + Write-ToConsoleLog "Subscription placed in management group: $targetMg, subscription: $($subscription.Name) (ID: $($subscription.Id))" } } -ThrottleLimit $ThrottleLimit @@ -1292,7 +1125,7 @@ function Remove-PlatformLandingZone { } else { if(-not $BypassConfirmation) { Write-ToConsoleLog "The following Subscriptions were provided or discovered during management group cleanup:" - $subscriptionsFinal | ForEach-Object { Write-ToConsoleLog "Name: $($_.Name), ID: $($_.Id)" -NoNewline } + $subscriptionsFinal | ForEach-Object { Write-ToConsoleLog "Name: $($_.Name), ID: $($_.Id)" } if($PlanMode) { Write-ToConsoleLog "Skipping confirmation for plan mode" @@ -1319,14 +1152,14 @@ function Remove-PlatformLandingZone { $planMode = $using:PlanMode $subscription = $_ - Write-ToConsoleLog "Finding resource groups for subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewline + Write-ToConsoleLog "Finding resource groups for subscription: $($subscription.Name) (ID: $($subscription.Id))" $resourceGroups = (az group list --subscription $subscription.Id) | ConvertFrom-Json if ($resourceGroups.Count -eq 0) { - Write-ToConsoleLog "No resource groups found for subscription: $($subscription.Name) (ID: $($subscription.Id)), skipping." -NoNewline + Write-ToConsoleLog "No resource groups found for subscription: $($subscription.Name) (ID: $($subscription.Id)), skipping." } else { - Write-ToConsoleLog "Found resource groups for subscription: $($subscription.Name) (ID: $($subscription.Id)), count: $($resourceGroups.Count)" -NoNewline + Write-ToConsoleLog "Found resource groups for subscription: $($subscription.Name) (ID: $($subscription.Id)), count: $($resourceGroups.Count)" $resourceGroupsToDelete = @() $resourceGroupsToRetainNamePatterns = $using:ResourceGroupsToRetainNamePatterns @@ -1336,7 +1169,7 @@ function Remove-PlatformLandingZone { foreach ($pattern in $resourceGroupsToRetainNamePatterns) { if ($resourceGroup.name -match $pattern) { - Write-ToConsoleLog "Retaining resource group as it matches the pattern '$pattern': $($resourceGroup.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewLine + Write-ToConsoleLog "Retaining resource group as it matches the pattern '$pattern': $($resourceGroup.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" $foundMatch = $true break } @@ -1363,7 +1196,7 @@ function Remove-PlatformLandingZone { $resourceGroupName = $_.ResourceGroupName $subscription = $_.Subscription - Write-ToConsoleLog "Deleting resource group for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" -NoNewLine + Write-ToConsoleLog "Deleting resource group for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" $result = $null if($using:PlanMode) { Write-ToConsoleLog ` @@ -1375,21 +1208,21 @@ function Remove-PlatformLandingZone { } if (!$result) { - Write-ToConsoleLog "Deleted resource group for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" -NoNewLine + Write-ToConsoleLog "Deleted resource group for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" } else { - Write-ToConsoleLog "Delete resource group failed for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)", "Full error: $result" -NoNewLine - Write-ToConsoleLog "It will be retried once the other resource groups in the subscription have reported their status." -NoNewLine + Write-ToConsoleLog "Delete resource group failed for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)", "Full error: $result" + Write-ToConsoleLog "It will be retried once the other resource groups in the subscription have reported their status." $retries = $using:resourceGroupsToRetry $retries.Add($_) } } -ThrottleLimit $using:ThrottleLimit if($resourceGroupsToRetry.Count -gt 0) { - Write-ToConsoleLog "Some resource groups failed to delete and will be retried in subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewLine + Write-ToConsoleLog "Some resource groups failed to delete and will be retried in subscription: $($subscription.Name) (ID: $($subscription.Id))" $shouldRetry = $true $resourceGroupsToDelete = $resourceGroupsToRetry.ToArray() } else { - Write-ToConsoleLog "All resource groups deleted successfully in subscription: $($subscription.Name) (ID: $($subscription.Id))." -NoNewLine + Write-ToConsoleLog "All resource groups deleted successfully in subscription: $($subscription.Name) (ID: $($subscription.Id))." } } } @@ -1404,7 +1237,7 @@ function Remove-PlatformLandingZone { ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog if ($_.pricingTier -ne "Free") { - Write-ToConsoleLog "Resetting Microsoft Defender for Cloud Plan to Free for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewLine + Write-ToConsoleLog "Resetting Microsoft Defender for Cloud Plan to Free for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" $result = $null if($using:PlanMode) { Write-ToConsoleLog ` @@ -1415,7 +1248,7 @@ function Remove-PlatformLandingZone { $result = (az security pricing create --name $_.name --tier "Free" --subscription $subscription.Id 2>&1) } if ($result -like "*must be 'Standard'*") { - Write-ToConsoleLog "Resetting Microsoft Defender for Cloud Plan to Standard as Free is not supported for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewLine + Write-ToConsoleLog "Resetting Microsoft Defender for Cloud Plan to Standard as Free is not supported for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" if($using:PlanMode) { Write-ToConsoleLog ` "Resetting Microsoft Defender for Cloud Plan to Standard for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))", ` @@ -1425,13 +1258,13 @@ function Remove-PlatformLandingZone { $result = az security pricing create --name $_.name --tier "Standard" --subscription $subscription.Id } } - Write-ToConsoleLog "Microsoft Defender for Cloud Plan reset for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewLine + Write-ToConsoleLog "Microsoft Defender for Cloud Plan reset for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" } else { - Write-ToConsoleLog "Microsoft Defender for Cloud Plan is already set to Free for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id)), skipping." -NoNewLine + Write-ToConsoleLog "Microsoft Defender for Cloud Plan is already set to Free for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id)), skipping." } } -ThrottleLimit $using:ThrottleLimit } else { - Write-ToConsoleLog "Skipping Microsoft Defender for Cloud Plans reset in subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewLine + Write-ToConsoleLog "Skipping Microsoft Defender for Cloud Plans reset in subscription: $($subscription.Name) (ID: $($subscription.Id))" } if(-not $using:SkipDeploymentDeletion -or -not $using:SkipDeploymentStackDeletion) { @@ -1446,7 +1279,7 @@ function Remove-PlatformLandingZone { -SkipDeploymentDeletion:$using:SkipDeploymentDeletion ` -DeploymentStacksToDeleteNamePatterns $using:DeploymentStacksToDeleteNamePatterns } else { - Write-ToConsoleLog "Skipping subscription level deployment and deployment stack deletion in subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewLine + Write-ToConsoleLog "Skipping subscription level deployment and deployment stack deletion in subscription: $($subscription.Name) (ID: $($subscription.Id))" } if(-not $using:SkipOrphanedRoleAssignmentDeletion) { @@ -1458,7 +1291,7 @@ function Remove-PlatformLandingZone { -PlanMode:$using:PlanMode ` -TempLogFileForPlan $using:TempLogFileForPlan } else { - Write-ToConsoleLog "Skipping orphaned role assignment deletion in subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewLine + Write-ToConsoleLog "Skipping orphaned role assignment deletion in subscription: $($subscription.Name) (ID: $($subscription.Id))" } } -ThrottleLimit $ThrottleLimit @@ -1484,7 +1317,7 @@ function Remove-PlatformLandingZone { } -ThrottleLimit $ThrottleLimit } else { - Write-ToConsoleLog "Skipping custom role definition deletion for management groups" -NoNewLine + Write-ToConsoleLog "Skipping custom role definition deletion for management groups" } Write-ToConsoleLog "Cleanup completed." -IsSuccess @@ -1492,7 +1325,7 @@ function Remove-PlatformLandingZone { if($PlanMode) { Write-ToConsoleLog "Plan mode enabled, no changes were made." -IsWarning $planLogContents = Get-Content -Path $TempLogFileForPlan -Raw - Write-ToConsoleLog "Plan mode log contents:`n$planLogContents" -Color Gray + Write-ToConsoleLog "Plan mode log contents:", $planLogContents -Color Gray Remove-Item -Path $TempLogFileForPlan -Force } } diff --git a/src/ALZ/Public/Test-AcceleratorRequirement.ps1 b/src/ALZ/Public/Test-AcceleratorRequirement.ps1 index 8f9397c..b520782 100644 --- a/src/ALZ/Public/Test-AcceleratorRequirement.ps1 +++ b/src/ALZ/Public/Test-AcceleratorRequirement.ps1 @@ -8,6 +8,8 @@ function Test-AcceleratorRequirement { C:\PS> Test-AcceleratorRequirement .EXAMPLE C:\PS> Test-AcceleratorRequirement -Verbose + .EXAMPLE + C:\PS> Test-AcceleratorRequirement -Checks @("GitHubCli") .OUTPUTS Boolean - True if all requirements are met, false if not. .NOTES @@ -18,10 +20,10 @@ function Test-AcceleratorRequirement { param ( [Parameter( Mandatory = $false, - HelpMessage = "[OPTIONAL] Determines whether to skip the requirements check for the ALZ PowerShell Module version only. This is not recommended." + HelpMessage = "[OPTIONAL] Specifies which checks to run. Valid values: PowerShell, Git, AzureCli, AzureEnvVars, AzureCliOrEnvVars, AzureLogin, AlzModule, AlzModuleVersion, YamlModule, YamlModuleAutoInstall, GitHubCli, AzureDevOpsCli" )] - [Alias("skipAlzModuleVersionRequirementsCheck")] - [switch] $skip_alz_module_version_requirements_check + [ValidateSet("PowerShell", "Git", "AzureCli", "AzureEnvVars", "AzureCliOrEnvVars", "AzureLogin", "AlzModule", "AlzModuleVersion", "YamlModule", "YamlModuleAutoInstall", "GitHubCli", "AzureDevOpsCli")] + [string[]]$Checks = @("PowerShell", "Git", "AzureCliOrEnvVars", "AzureLogin", "AlzModule", "AlzModuleVersion") ) - Test-Tooling -skipAlzModuleVersionCheck:$skip_alz_module_version_requirements_check.IsPresent + Test-Tooling -Checks $Checks } diff --git a/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 b/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 index 09b5ab1..dfa879c 100644 --- a/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 +++ b/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 @@ -16,7 +16,7 @@ InModuleScope 'ALZ' { $testFile1Name = "test.parameters.all.json" Mock -CommandName Out-File -MockWith { - Write-InformationColored "Out-File was called with $FilePath and $InputObject" -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog "Out-File was called with $FilePath and $InputObject" -IsWarning } Mock -CommandName Get-ChildItem -ParameterFilter { $Path -match 'config$' } -MockWith { @@ -459,14 +459,14 @@ InModuleScope 'ALZ' { $contentAfterParsing.parameters.parLogging.value = "logs/dev/eastus" $contentStringAfterParsing = ConvertTo-Json -InputObject $contentAfterParsing - Write-InformationColored $contentStringAfterParsing -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog $contentStringAfterParsing -IsWarning Should -Invoke -CommandName Out-File -ParameterFilter { $FilePath -eq "test1.parameters.json" -and $InputObject -eq $contentStringAfterParsing } -Scope It $contentAfterParsing = ConvertFrom-Json -InputObject $secondFileContent -AsHashtable $contentAfterParsing.parameters.parLocation.value = 'eastus' $contentStringAfterParsing = ConvertTo-Json -InputObject $contentAfterParsing - Write-InformationColored $contentStringAfterParsing -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog $contentStringAfterParsing -IsWarning Should -Invoke -CommandName Out-File -ParameterFilter { $FilePath -eq "test2.parameters.json" -and $InputObject -eq $contentStringAfterParsing } -Scope It } @@ -540,14 +540,14 @@ InModuleScope 'ALZ' { $contentAfterParsing.parameters.parCompanyPrefix.value = 'value1' $contentStringAfterParsing = ConvertTo-Json -InputObject $contentAfterParsing - Write-InformationColored $contentStringAfterParsing -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog $contentStringAfterParsing -IsWarning Should -Invoke -CommandName Out-File -ParameterFilter { $FilePath -eq "test1.parameters.json" -and $InputObject -eq $contentStringAfterParsing } -Scope It $contentAfterParsing = ConvertFrom-Json -InputObject $secondFileContent -AsHashtable $contentAfterParsing.parameters.parCompanyPrefix.value = 'value2' $contentStringAfterParsing = ConvertTo-Json -InputObject $contentAfterParsing - Write-InformationColored $contentStringAfterParsing -ForegroundColor Yellow -InformationAction Continue + Write-ToConsoleLog $contentStringAfterParsing -IsWarning Should -Invoke -CommandName Out-File -ParameterFilter { $FilePath -eq "test2.parameters.json" -and $InputObject -eq $contentStringAfterParsing } -Scope It } diff --git a/src/Tests/Unit/Private/Request-AcceleratorConfigurationInput.Tests.ps1 b/src/Tests/Unit/Private/Request-AcceleratorConfigurationInput.Tests.ps1 index 5f6c853..7f17587 100644 --- a/src/Tests/Unit/Private/Request-AcceleratorConfigurationInput.Tests.ps1 +++ b/src/Tests/Unit/Private/Request-AcceleratorConfigurationInput.Tests.ps1 @@ -20,10 +20,10 @@ InModuleScope 'ALZ' { Context 'When skipping interactive configuration but continuing' { It 'invokes SensitiveOnly check for missing sensitive inputs' { $script:answers = @( - 'C:\\temp\\acc', # target folder - 'n', # overwrite existing folder? - 'n', # configure inputs now? - 'yes' # continue? + 'C:\\temp\\acc', # target folder (free text entry) + 'false', # overwrite existing folder? (boolean: false = No) + '2', # configure inputs now? (2 = No) + '' # continue? (empty = default Yes) ) $script:idx = 0 $prompts = [System.Collections.Generic.List[string]]::new() @@ -33,7 +33,7 @@ InModuleScope 'ALZ' { if ($script:idx -lt $script:answers.Count) { $script:answers[$script:idx++] } else { - 'yes' + '1' } } @@ -79,10 +79,10 @@ InModuleScope 'ALZ' { Context 'When configuring interactively' { It 'does not invoke SensitiveOnly check' { $script:answers = @( - 'C:\\temp\\acc', # target folder - 'n', # overwrite existing folder? - '', # configure inputs now? (default yes) - 'yes' # continue? + 'C:\\temp\\acc', # target folder (free text entry) + 'false', # overwrite existing folder? (boolean: false = No) + '', # configure inputs now? (empty = default Yes) + '' # continue? (empty = default Yes) ) $script:idx = 0 Mock -CommandName Read-Host -MockWith { @@ -90,7 +90,7 @@ InModuleScope 'ALZ' { if ($script:idx -lt $script:answers.Count) { $script:answers[$script:idx++] } else { - 'yes' + '1' } } diff --git a/src/Tests/Unit/Private/Write-InformationColored.Tests.ps1 b/src/Tests/Unit/Private/Write-InformationColored.Tests.ps1 deleted file mode 100644 index a2dc665..0000000 --- a/src/Tests/Unit/Private/Write-InformationColored.Tests.ps1 +++ /dev/null @@ -1,45 +0,0 @@ -#------------------------------------------------------------------------- -Set-Location -Path $PSScriptRoot -#------------------------------------------------------------------------- -$ModuleName = 'ALZ' -$PathToManifest = [System.IO.Path]::Combine('..', '..', '..', $ModuleName, "$ModuleName.psd1") -#------------------------------------------------------------------------- -if (Get-Module -Name $ModuleName -ErrorAction 'SilentlyContinue') { - #if the module is already in memory, remove it - Remove-Module -Name $ModuleName -Force -} -Import-Module $PathToManifest -Force -#------------------------------------------------------------------------- - -InModuleScope 'ALZ' { - Describe 'Write-InformationColored Function Tests' -Tag Unit { - BeforeAll { - $WarningPreference = 'SilentlyContinue' - $ErrorActionPreference = 'SilentlyContinue' - } - Context 'Initialize config get the correct base values' { - BeforeEach { - Mock -CommandName Write-Information -MockWith { - $null - } - } - It 'should make sure that the information it is printed correctly' { - Write-InformationColored -Message 'test' -ForegroundColor 'Green' - $info = [System.Management.Automation.HostInformationMessage]@{ - Message = 'test' - ForegroundColor = 'Green' - BackgroundColor = $Host.UI.RawUI.BackgroundColor - NoNewline = $false - } - - # Check that Write-Information was called with the correct parameters - Assert-MockCalled -CommandName Write-Information -Exactly 1 -Scope It -ParameterFilter { - $MessageData.Message -eq $info.Message -and ` - $MessageData.ForegroundColor -eq $info.ForegroundColor -and ` - $MessageData.BackgroundColor -eq $info.BackgroundColor -and ` - $MessageData.NoNewline -eq $info.NoNewline - } - } - } - } -} diff --git a/src/Tests/Unit/Public/Deploy-Accelerator.Tests.ps1 b/src/Tests/Unit/Public/Deploy-Accelerator.Tests.ps1 index e94f6fe..a56054b 100644 --- a/src/Tests/Unit/Public/Deploy-Accelerator.Tests.ps1 +++ b/src/Tests/Unit/Public/Deploy-Accelerator.Tests.ps1 @@ -99,7 +99,7 @@ InModuleScope 'ALZ' { Mock -CommandName Get-GithubRelease -MockWith { $("v0.0.1") } - Mock -CommandName Write-InformationColored + Mock -CommandName Write-ToConsoleLog Mock -CommandName Get-HCLParserTool -MockWith { "test" } From b6bebb1ceb30a56a5bbdde4709ad222ee2f1dd7a Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 30 Jan 2026 13:15:14 +0000 Subject: [PATCH 13/20] fix mg checks --- src/ALZ/Private/Tools/Checks/Test-AzureCli.ps1 | 16 ++++++++++++++++ src/ALZ/Public/Remove-PlatformLandingZone.ps1 | 7 ++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/ALZ/Private/Tools/Checks/Test-AzureCli.ps1 b/src/ALZ/Private/Tools/Checks/Test-AzureCli.ps1 index fa501e8..ebeeef1 100644 --- a/src/ALZ/Private/Tools/Checks/Test-AzureCli.ps1 +++ b/src/ALZ/Private/Tools/Checks/Test-AzureCli.ps1 @@ -26,6 +26,22 @@ function Test-AzureCli { message = "Azure CLI is logged in. Tenant ID: $($azCliAccount.tenantId), Subscription: $($azCliAccount.name) ($($azCliAccount.id))" result = "Success" } + + # Verify access token can be obtained/refreshed + Write-Verbose "Checking Azure CLI access token" + $tokenResult = $(az account get-access-token -o json 2>$null) | ConvertFrom-Json + if ($tokenResult -and $tokenResult.accessToken) { + $results += @{ + message = "Azure CLI access token is valid." + result = "Success" + } + } else { + $results += @{ + message = "Azure CLI access token could not be obtained. Please re-authenticate using 'az login -t `"$($azCliAccount.tenantId)`"'." + result = "Failure" + } + $hasFailure = $true + } } else { $azCliInstalledButNotLoggedIn = $true if (-not $RequireLogin) { diff --git a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 index dc8665b..f23a942 100644 --- a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -887,7 +887,12 @@ function Remove-PlatformLandingZone { $managementGroupDisplayName = $_.DisplayName Write-ToConsoleLog "Finding management group: $managementGroupId ($managementGroupDisplayName)" - $topLevelManagementGroup = (az account management-group show --name $managementGroupId --expand --recurse) | ConvertFrom-Json + $topLevelManagementGroup = (az account management-group show --name $managementGroupId --expand --recurse 2>$null) | ConvertFrom-Json + + if($null -eq $topLevelManagementGroup) { + Write-ToConsoleLog "Management group '$managementGroupId' was listed but could not be retrieved (it may have been deleted or you may not have access). Skipping..." -IsWarning + return + } $hasChildren = $topLevelManagementGroup.children -and $topLevelManagementGroup.children.Count -gt 0 From d31f57e1a601b00fddf72c0c2c5e9cde14fde53d Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 30 Jan 2026 13:33:46 +0000 Subject: [PATCH 14/20] improve logging --- .../Private/Deploy-Accelerator-Helpers/Invoke-Terraform.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/Invoke-Terraform.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/Invoke-Terraform.ps1 index f6e0eeb..c7296f9 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/Invoke-Terraform.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/Invoke-Terraform.ps1 @@ -130,7 +130,7 @@ function Invoke-Terraform { $arguments += "$planFileName" if (!$silent) { - Write-ToConsoleLog "Running Apply Command for $action : $command $arguments" -IsSuccess + Write-ToConsoleLog "Running Apply Command for $action : $command $arguments" & $command $arguments } else { & $command $arguments | Write-Verbose @@ -157,7 +157,7 @@ function Invoke-Terraform { $arguments += "-destroy" } - Write-ToConsoleLog "Running Apply Command for $action : $command $arguments" -IsSuccess + Write-ToConsoleLog "Retry Attempt $($currentAttempt) of $($maxAttempts): Running Apply Command for $action : $command $arguments" & $command $arguments $exitCode = $LASTEXITCODE } @@ -170,7 +170,7 @@ function Invoke-Terraform { # Stop and display timer $StopWatch.Stop() if (!$silent) { - Write-ToConsoleLog "Time taken to complete Terraform apply:" -IsSuccess + Write-ToConsoleLog "Time taken to complete Terraform apply:" } $StopWatch.Elapsed | Format-Table From 215b25ef170b17029ea151ad630a852db9c61816 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 30 Jan 2026 14:07:26 +0000 Subject: [PATCH 15/20] fix logging issue --- .../Public/Remove-AzureDevOpsAccelerator.ps1 | 32 +++++++------- src/ALZ/Public/Remove-GitHubAccelerator.ps1 | 44 +++++++++---------- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 b/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 index 173ecfb..5367284 100644 --- a/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 +++ b/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 @@ -155,7 +155,7 @@ function Remove-AzureDevOpsAccelerator { # Normalize organization URL $organizationUrl = Get-NormalizedOrganizationUrl -Organization $AzureDevOpsOrganization - Write-ToConsoleLog "Using Azure DevOps organization: $organizationUrl" -NoNewLine + Write-ToConsoleLog "Using Azure DevOps organization: $organizationUrl" # Configure Azure DevOps CLI defaults az devops configure --defaults organization=$organizationUrl 2>&1 | Out-Null @@ -206,12 +206,12 @@ function Remove-AzureDevOpsAccelerator { } $projectList = $allProjects.value - Write-ToConsoleLog "Found $($projectList.Count) total projects in organization: $organizationUrl" -NoNewLine + Write-ToConsoleLog "Found $($projectList.Count) total projects in organization: $organizationUrl" foreach($project in $projectList) { foreach($pattern in $ProjectNamePatterns) { if($project.name -match $pattern) { - Write-ToConsoleLog "Project matches pattern '$pattern': $($project.name)" -NoNewLine + Write-ToConsoleLog "Project matches pattern '$pattern': $($project.name)" $projectsToDelete += @{ Name = $project.name Id = $project.id @@ -221,7 +221,7 @@ function Remove-AzureDevOpsAccelerator { } } - Write-ToConsoleLog "Found $($projectsToDelete.Count) projects matching patterns for deletion" -NoNewLine + Write-ToConsoleLog "Found $($projectsToDelete.Count) projects matching patterns for deletion" } # Discover agent pools @@ -234,18 +234,18 @@ function Remove-AzureDevOpsAccelerator { $allAgentPools = @() } - Write-ToConsoleLog "Found $($allAgentPools.Count) total agent pools in organization: $organizationUrl" -NoNewLine + Write-ToConsoleLog "Found $($allAgentPools.Count) total agent pools in organization: $organizationUrl" foreach($pool in $allAgentPools) { # Skip system pools (Azure Pipelines, Default, etc.) if($pool.isHosted -or $pool.poolType -eq "automation") { - Write-ToConsoleLog "Skipping hosted/system pool: $($pool.name)" -NoNewLine + Write-ToConsoleLog "Skipping hosted/system pool: $($pool.name)" continue } foreach($pattern in $AgentPoolNamePatterns) { if($pool.name -match $pattern) { - Write-ToConsoleLog "Agent pool matches pattern '$pattern': $($pool.name)" -NoNewLine + Write-ToConsoleLog "Agent pool matches pattern '$pattern': $($pool.name)" $agentPoolsToDelete += @{ Name = $pool.name Id = $pool.id @@ -255,7 +255,7 @@ function Remove-AzureDevOpsAccelerator { } } - Write-ToConsoleLog "Found $($agentPoolsToDelete.Count) agent pools matching patterns for deletion" -NoNewLine + Write-ToConsoleLog "Found $($agentPoolsToDelete.Count) agent pools matching patterns for deletion" } # Confirm deletion @@ -270,12 +270,12 @@ function Remove-AzureDevOpsAccelerator { if($projectsToDelete.Count -gt 0) { Write-ToConsoleLog "Projects ($($projectsToDelete.Count)):" - $projectsToDelete | ForEach-Object { Write-ToConsoleLog " - $($_.Name)" -NoNewLine } + $projectsToDelete | ForEach-Object { Write-ToConsoleLog " - $($_.Name)" } } if($agentPoolsToDelete.Count -gt 0) { Write-ToConsoleLog "Agent Pools ($($agentPoolsToDelete.Count)):" - $agentPoolsToDelete | ForEach-Object { Write-ToConsoleLog " - $($_.Name)" -NoNewLine } + $agentPoolsToDelete | ForEach-Object { Write-ToConsoleLog " - $($_.Name)" } } if($PlanMode) { @@ -307,12 +307,12 @@ function Remove-AzureDevOpsAccelerator { "Would run: az devops project delete --id $($project.Id) --org $orgUrl --yes" ` -IsPlan -LogFilePath $TempLogFileForPlan } else { - Write-ToConsoleLog "Deleting project: $($project.Name)" -NoNewLine + Write-ToConsoleLog "Deleting project: $($project.Name)" $result = az devops project delete --id $project.Id --org $orgUrl --yes 2>&1 if($LASTEXITCODE -ne 0) { - Write-ToConsoleLog "Failed to delete project: $($project.Name)", "Full error: $result" -IsWarning -NoNewLine + Write-ToConsoleLog "Failed to delete project: $($project.Name)", "Full error: $result" -IsWarning } else { - Write-ToConsoleLog "Deleted project: $($project.Name)" -NoNewLine + Write-ToConsoleLog "Deleted project: $($project.Name)" } } } -ThrottleLimit $ThrottleLimit @@ -336,12 +336,12 @@ function Remove-AzureDevOpsAccelerator { "Would run: az pipelines pool delete --id $($pool.Id) --org $orgUrl --yes" ` -IsPlan -LogFilePath $TempLogFileForPlan } else { - Write-ToConsoleLog "Deleting agent pool: $($pool.Name)" -NoNewLine + Write-ToConsoleLog "Deleting agent pool: $($pool.Name)" $result = az pipelines pool delete --id $pool.Id --org $orgUrl --yes 2>&1 if($LASTEXITCODE -ne 0) { - Write-ToConsoleLog "Failed to delete agent pool: $($pool.Name)", "Full error: $result" -IsWarning -NoNewLine + Write-ToConsoleLog "Failed to delete agent pool: $($pool.Name)", "Full error: $result" -IsWarning } else { - Write-ToConsoleLog "Deleted agent pool: $($pool.Name)" -NoNewLine + Write-ToConsoleLog "Deleted agent pool: $($pool.Name)" } } } -ThrottleLimit $ThrottleLimit diff --git a/src/ALZ/Public/Remove-GitHubAccelerator.ps1 b/src/ALZ/Public/Remove-GitHubAccelerator.ps1 index 75316bc..a401d36 100644 --- a/src/ALZ/Public/Remove-GitHubAccelerator.ps1 +++ b/src/ALZ/Public/Remove-GitHubAccelerator.ps1 @@ -193,12 +193,12 @@ function Remove-GitHubAccelerator { return } - Write-ToConsoleLog "Found $($allRepositories.Count) total repositories in organization: $GitHubOrganization" -NoNewLine + Write-ToConsoleLog "Found $($allRepositories.Count) total repositories in organization: $GitHubOrganization" foreach($repo in $allRepositories) { foreach($pattern in $RepositoryNamePatterns) { if($repo.name -match $pattern) { - Write-ToConsoleLog "Repository matches pattern '$pattern': $($repo.name)" -NoNewLine + Write-ToConsoleLog "Repository matches pattern '$pattern': $($repo.name)" $repositoriesToDelete += @{ Name = $repo.name Url = $repo.url @@ -208,7 +208,7 @@ function Remove-GitHubAccelerator { } } - Write-ToConsoleLog "Found $($repositoriesToDelete.Count) repositories matching patterns for deletion" -NoNewLine + Write-ToConsoleLog "Found $($repositoriesToDelete.Count) repositories matching patterns for deletion" } # Discover teams @@ -221,12 +221,12 @@ function Remove-GitHubAccelerator { $allTeams = @() } - Write-ToConsoleLog "Found $($allTeams.Count) total teams in organization: $GitHubOrganization" -NoNewLine + Write-ToConsoleLog "Found $($allTeams.Count) total teams in organization: $GitHubOrganization" foreach($team in $allTeams) { foreach($pattern in $TeamNamePatterns) { if($team.name -match $pattern -or $team.slug -match $pattern) { - Write-ToConsoleLog "Team matches pattern '$pattern': $($team.name) (slug: $($team.slug))" -NoNewLine + Write-ToConsoleLog "Team matches pattern '$pattern': $($team.name) (slug: $($team.slug))" $teamsToDelete += @{ Name = $team.name Slug = $team.slug @@ -237,7 +237,7 @@ function Remove-GitHubAccelerator { } } - Write-ToConsoleLog "Found $($teamsToDelete.Count) teams matching patterns for deletion" -NoNewLine + Write-ToConsoleLog "Found $($teamsToDelete.Count) teams matching patterns for deletion" } # Discover runner groups @@ -253,18 +253,18 @@ function Remove-GitHubAccelerator { } if($null -ne $allRunnerGroups) { - Write-ToConsoleLog "Found $($allRunnerGroups.Count) total runner groups in organization: $GitHubOrganization" -NoNewLine + Write-ToConsoleLog "Found $($allRunnerGroups.Count) total runner groups in organization: $GitHubOrganization" foreach($runnerGroup in $allRunnerGroups) { # Skip the default runner group as it cannot be deleted if($runnerGroup.name -eq "Default" -or $runnerGroup.default) { - Write-ToConsoleLog "Skipping default runner group: $($runnerGroup.name)" -NoNewLine + Write-ToConsoleLog "Skipping default runner group: $($runnerGroup.name)" continue } foreach($pattern in $RunnerGroupNamePatterns) { if($runnerGroup.name -match $pattern) { - Write-ToConsoleLog "Runner group matches pattern '$pattern': $($runnerGroup.name)" -NoNewLine + Write-ToConsoleLog "Runner group matches pattern '$pattern': $($runnerGroup.name)" $runnerGroupsToDelete += @{ Name = $runnerGroup.name Id = $runnerGroup.id @@ -274,7 +274,7 @@ function Remove-GitHubAccelerator { } } - Write-ToConsoleLog "Found $($runnerGroupsToDelete.Count) runner groups matching patterns for deletion" -NoNewLine + Write-ToConsoleLog "Found $($runnerGroupsToDelete.Count) runner groups matching patterns for deletion" } } @@ -290,17 +290,17 @@ function Remove-GitHubAccelerator { if($repositoriesToDelete.Count -gt 0) { Write-ToConsoleLog "Repositories ($($repositoriesToDelete.Count)):" - $repositoriesToDelete | ForEach-Object { Write-ToConsoleLog " - $($_.Name)" -NoNewLine } + $repositoriesToDelete | ForEach-Object { Write-ToConsoleLog " - $($_.Name)" } } if($teamsToDelete.Count -gt 0) { Write-ToConsoleLog "Teams ($($teamsToDelete.Count)):" - $teamsToDelete | ForEach-Object { Write-ToConsoleLog " - $($_.Name) (slug: $($_.Slug))" -NoNewLine } + $teamsToDelete | ForEach-Object { Write-ToConsoleLog " - $($_.Name) (slug: $($_.Slug))" } } if($runnerGroupsToDelete.Count -gt 0) { Write-ToConsoleLog "Runner Groups ($($runnerGroupsToDelete.Count)):" - $runnerGroupsToDelete | ForEach-Object { Write-ToConsoleLog " - $($_.Name)" -NoNewLine } + $runnerGroupsToDelete | ForEach-Object { Write-ToConsoleLog " - $($_.Name)" } } if($PlanMode) { @@ -333,12 +333,12 @@ function Remove-GitHubAccelerator { "Would run: gh repo delete $repoFullName --yes" ` -IsPlan -LogFilePath $TempLogFileForPlan } else { - Write-ToConsoleLog "Deleting repository: $repoFullName" -NoNewLine + Write-ToConsoleLog "Deleting repository: $repoFullName" $result = gh repo delete $repoFullName --yes 2>&1 if($LASTEXITCODE -ne 0) { - Write-ToConsoleLog "Failed to delete repository: $repoFullName", "Full error: $result" -IsWarning -NoNewLine + Write-ToConsoleLog "Failed to delete repository: $repoFullName", "Full error: $result" -IsWarning } else { - Write-ToConsoleLog "Deleted repository: $repoFullName" -NoNewLine + Write-ToConsoleLog "Deleted repository: $repoFullName" } } } -ThrottleLimit $ThrottleLimit @@ -362,12 +362,12 @@ function Remove-GitHubAccelerator { "Would run: gh api -X DELETE orgs/$org/teams/$($team.Slug)" ` -IsPlan -LogFilePath $TempLogFileForPlan } else { - Write-ToConsoleLog "Deleting team: $($team.Name) (slug: $($team.Slug))" -NoNewLine + Write-ToConsoleLog "Deleting team: $($team.Name) (slug: $($team.Slug))" $result = gh api -X DELETE "orgs/$org/teams/$($team.Slug)" 2>&1 if($LASTEXITCODE -ne 0) { - Write-ToConsoleLog "Failed to delete team: $($team.Name)", "Full error: $result" -IsWarning -NoNewLine + Write-ToConsoleLog "Failed to delete team: $($team.Name)", "Full error: $result" -IsWarning } else { - Write-ToConsoleLog "Deleted team: $($team.Name)" -NoNewLine + Write-ToConsoleLog "Deleted team: $($team.Name)" } } } -ThrottleLimit $ThrottleLimit @@ -391,12 +391,12 @@ function Remove-GitHubAccelerator { "Would run: gh api -X DELETE orgs/$org/actions/runner-groups/$($runnerGroup.Id)" ` -IsPlan -LogFilePath $TempLogFileForPlan } else { - Write-ToConsoleLog "Deleting runner group: $($runnerGroup.Name)" -NoNewLine + Write-ToConsoleLog "Deleting runner group: $($runnerGroup.Name)" $result = gh api -X DELETE "orgs/$org/actions/runner-groups/$($runnerGroup.Id)" 2>&1 if($LASTEXITCODE -ne 0) { - Write-ToConsoleLog "Failed to delete runner group: $($runnerGroup.Name)", "Full error: $result" -IsWarning -NoNewLine + Write-ToConsoleLog "Failed to delete runner group: $($runnerGroup.Name)", "Full error: $result" -IsWarning } else { - Write-ToConsoleLog "Deleted runner group: $($runnerGroup.Name)" -NoNewLine + Write-ToConsoleLog "Deleted runner group: $($runnerGroup.Name)" } } } -ThrottleLimit $ThrottleLimit From 721750170a505dbb3ec7fc36df7cdaf51cdbc07a Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 30 Jan 2026 14:16:49 +0000 Subject: [PATCH 16/20] fix logging --- src/ALZ/Private/Shared/Write-ToConsoleLog.ps1 | 9 ++++++++- src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 | 2 +- src/ALZ/Public/Remove-GitHubAccelerator.ps1 | 2 +- src/ALZ/Public/Remove-PlatformLandingZone.ps1 | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/ALZ/Private/Shared/Write-ToConsoleLog.ps1 b/src/ALZ/Private/Shared/Write-ToConsoleLog.ps1 index 817d56b..f4f6224 100644 --- a/src/ALZ/Private/Shared/Write-ToConsoleLog.ps1 +++ b/src/ALZ/Private/Shared/Write-ToConsoleLog.ps1 @@ -111,6 +111,7 @@ function Write-ToConsoleLog { NewLine = $false ShowDateTime = $true ShowType = $true + WriteToFile = $false }, @{ Level = "ERROR" @@ -118,6 +119,7 @@ function Write-ToConsoleLog { NewLine = $true ShowDateTime = $true ShowType = $true + WriteToFile = $false }, @{ Level = "WARNING" @@ -125,6 +127,7 @@ function Write-ToConsoleLog { NewLine = $true ShowDateTime = $true ShowType = $true + WriteToFile = $false }, @{ Level = "SUCCESS" @@ -132,6 +135,7 @@ function Write-ToConsoleLog { NewLine = $true ShowDateTime = $true ShowType = $true + WriteToFile = $false }, @{ Level = "PLAN" @@ -139,6 +143,7 @@ function Write-ToConsoleLog { NewLine = $false ShowDateTime = $true ShowType = $true + WriteToFile = $true }, @{ Level = "INPUT REQUIRED" @@ -146,6 +151,7 @@ function Write-ToConsoleLog { NewLine = $true ShowDateTime = $true ShowType = $true + WriteToFile = $false }, @{ Level = "SELECTION" @@ -153,6 +159,7 @@ function Write-ToConsoleLog { NewLine = $false ShowDateTime = $false ShowType = $false + WriteToFile = $false } ) ) @@ -214,7 +221,7 @@ function Write-ToConsoleLog { } Write-Host $finalMessages -ForegroundColor $Color -NoNewline:$Overwrite.IsPresent - if ($WriteToFile -and $LogFilePath) { + if (($WriteToFile -or ($defaultSettings -and $defaultSettings.WriteToFile)) -and $LogFilePath) { Add-Content -Path $LogFilePath -Value $finalMessages } } diff --git a/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 b/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 index 5367284..1bffde9 100644 --- a/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 +++ b/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 @@ -352,7 +352,7 @@ function Remove-AzureDevOpsAccelerator { if($PlanMode) { Write-ToConsoleLog "Plan mode enabled, no changes were made." -IsWarning $planLogContents = Get-Content -Path $TempLogFileForPlan -Raw - Write-ToConsoleLog "Plan mode log contents:", $planLogContents -Color Gray + Write-ToConsoleLog @("Plan mode log contents:", $planLogContents) -Color Gray Remove-Item -Path $TempLogFileForPlan -Force } } diff --git a/src/ALZ/Public/Remove-GitHubAccelerator.ps1 b/src/ALZ/Public/Remove-GitHubAccelerator.ps1 index a401d36..720d080 100644 --- a/src/ALZ/Public/Remove-GitHubAccelerator.ps1 +++ b/src/ALZ/Public/Remove-GitHubAccelerator.ps1 @@ -407,7 +407,7 @@ function Remove-GitHubAccelerator { if($PlanMode) { Write-ToConsoleLog "Plan mode enabled, no changes were made." -IsWarning $planLogContents = Get-Content -Path $TempLogFileForPlan -Raw - Write-ToConsoleLog "Plan mode log contents:", $planLogContents -Color Gray + Write-ToConsoleLog @("Plan mode log contents:", $planLogContents) -Color Gray Remove-Item -Path $TempLogFileForPlan -Force } } diff --git a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 index f23a942..2379332 100644 --- a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -1330,7 +1330,7 @@ function Remove-PlatformLandingZone { if($PlanMode) { Write-ToConsoleLog "Plan mode enabled, no changes were made." -IsWarning $planLogContents = Get-Content -Path $TempLogFileForPlan -Raw - Write-ToConsoleLog "Plan mode log contents:", $planLogContents -Color Gray + Write-ToConsoleLog @("Plan mode log contents:", $planLogContents) -Color Gray Remove-Item -Path $TempLogFileForPlan -Force } } From eddd08e52922359deb34865244d3a3640db16181 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 30 Jan 2026 14:30:27 +0000 Subject: [PATCH 17/20] fix github removal script --- .../Private/Tools/Checks/Test-GitHubCli.ps1 | 27 ++++++++++++++++++- src/ALZ/Public/Remove-GitHubAccelerator.ps1 | 15 +++-------- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/ALZ/Private/Tools/Checks/Test-GitHubCli.ps1 b/src/ALZ/Private/Tools/Checks/Test-GitHubCli.ps1 index 6410f2f..339000b 100644 --- a/src/ALZ/Private/Tools/Checks/Test-GitHubCli.ps1 +++ b/src/ALZ/Private/Tools/Checks/Test-GitHubCli.ps1 @@ -16,12 +16,37 @@ function Test-GitHubCli { # Check if GitHub CLI is authenticated Write-Verbose "Checking GitHub CLI authentication status" - $null = gh auth status 2>&1 + $authStatus = gh auth status 2>&1 if ($LASTEXITCODE -eq 0) { $results += @{ message = "GitHub CLI is authenticated." result = "Success" } + + # Check if admin:org scope is available + Write-Verbose "Checking GitHub CLI scopes for admin:org" + if ($authStatus -match "admin:org") { + $results += @{ + message = "GitHub CLI has admin:org scope." + result = "Success" + } + } else { + Write-ToConsoleLog "GitHub CLI is missing admin:org scope. Requesting scope refresh..." -IsWarning + # Prompt user to add the admin:org scope + gh auth refresh -h github.com -s admin:org + if ($LASTEXITCODE -eq 0) { + $results += @{ + message = "GitHub CLI admin:org scope added successfully." + result = "Success" + } + } else { + $results += @{ + message = "Failed to add admin:org scope. Please run 'gh auth refresh -h github.com -s admin:org' manually." + result = "Failure" + } + $hasFailure = $true + } + } } else { $results += @{ message = "GitHub CLI is not authenticated. Please authenticate using 'gh auth login'." diff --git a/src/ALZ/Public/Remove-GitHubAccelerator.ps1 b/src/ALZ/Public/Remove-GitHubAccelerator.ps1 index 720d080..460d292 100644 --- a/src/ALZ/Public/Remove-GitHubAccelerator.ps1 +++ b/src/ALZ/Public/Remove-GitHubAccelerator.ps1 @@ -34,15 +34,9 @@ function Remove-GitHubAccelerator { .PARAMETER RunnerGroupNamePatterns An array of regex patterns to match against runner group names. Runner groups matching any of - these patterns will be deleted. If empty, no runner groups will be deleted. Requires the - -IncludeRunnerGroups switch to be specified. + these patterns will be deleted. If empty, no runner groups will be deleted. Default: Empty array (no runner groups deleted) - .PARAMETER IncludeRunnerGroups - A switch parameter that enables deletion of runner groups matching the patterns specified in - -RunnerGroupNamePatterns. By default, runner groups are not deleted. - Default: $false (do not delete runner groups) - .PARAMETER BypassConfirmation A switch parameter that bypasses the interactive confirmation prompts. When specified, the function waits for the duration specified in -BypassConfirmationTimeoutSeconds before proceeding, allowing @@ -80,7 +74,7 @@ function Remove-GitHubAccelerator { Deletes all repositories and teams matching the pattern "^alz-.*" from the "my-org" organization. .EXAMPLE - Remove-GitHubAccelerator -GitHubOrganization "my-org" -RepositoryNamePatterns @("^alz-.*", "^landing-zone-.*") -IncludeRunnerGroups -RunnerGroupNamePatterns @("^alz-.*") + Remove-GitHubAccelerator -GitHubOrganization "my-org" -RepositoryNamePatterns @("^alz-.*", "^landing-zone-.*") -RunnerGroupNamePatterns @("^alz-.*") Deletes repositories matching either pattern and runner groups matching "^alz-.*" from the "my-org" organization. @@ -117,9 +111,6 @@ function Remove-GitHubAccelerator { [Alias("runners")] [string[]]$RunnerGroupNamePatterns = @(), - [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Include runner groups in the deletion process.")] - [switch]$IncludeRunnerGroups, - [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Bypass interactive confirmation prompts.")] [switch]$BypassConfirmation, @@ -171,7 +162,7 @@ function Remove-GitHubAccelerator { $hasRepositoryPatterns = $RepositoryNamePatterns.Count -gt 0 $hasTeamPatterns = $TeamNamePatterns.Count -gt 0 - $hasRunnerGroupPatterns = $IncludeRunnerGroups -and $RunnerGroupNamePatterns.Count -gt 0 + $hasRunnerGroupPatterns = $RunnerGroupNamePatterns.Count -gt 0 if(-not $hasRepositoryPatterns -and -not $hasTeamPatterns -and -not $hasRunnerGroupPatterns) { Write-ToConsoleLog "No patterns provided for repositories, teams, or runner groups. Nothing to do. Exiting..." -IsError From 47b7ecbb0d6b721e7d423bda564e185b7d1402a2 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 30 Jan 2026 14:36:14 +0000 Subject: [PATCH 18/20] better 0 result handling --- src/ALZ/Public/Remove-GitHubAccelerator.ps1 | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/ALZ/Public/Remove-GitHubAccelerator.ps1 b/src/ALZ/Public/Remove-GitHubAccelerator.ps1 index 460d292..16791b2 100644 --- a/src/ALZ/Public/Remove-GitHubAccelerator.ps1 +++ b/src/ALZ/Public/Remove-GitHubAccelerator.ps1 @@ -178,12 +178,12 @@ function Remove-GitHubAccelerator { if($hasRepositoryPatterns) { Write-ToConsoleLog "Discovering repositories in organization: $GitHubOrganization" - $allRepositories = (gh repo list $GitHubOrganization --json name,url --limit 1000) | ConvertFrom-Json - if($null -eq $allRepositories) { + $repositoriesResponse = (gh repo list $GitHubOrganization --json name,url --limit 1000 2>&1) + if($LASTEXITCODE -ne 0) { Write-ToConsoleLog "Failed to list repositories in organization: $GitHubOrganization" -IsError return } - + $allRepositories = @($repositoriesResponse | ConvertFrom-Json) Write-ToConsoleLog "Found $($allRepositories.Count) total repositories in organization: $GitHubOrganization" foreach($repo in $allRepositories) { @@ -206,12 +206,12 @@ function Remove-GitHubAccelerator { if($hasTeamPatterns) { Write-ToConsoleLog "Discovering teams in organization: $GitHubOrganization" - $allTeams = (gh api "orgs/$GitHubOrganization/teams" --paginate) | ConvertFrom-Json - if($null -eq $allTeams) { - Write-ToConsoleLog "Failed to list teams in organization: $GitHubOrganization" -IsWarning - $allTeams = @() + $teamsResponse = (gh api "orgs/$GitHubOrganization/teams" --paginate 2>&1) + if($LASTEXITCODE -ne 0) { + Write-ToConsoleLog "Failed to list teams in organization: $GitHubOrganization" -IsError + return } - + $allTeams = @($teamsResponse | ConvertFrom-Json) Write-ToConsoleLog "Found $($allTeams.Count) total teams in organization: $GitHubOrganization" foreach($team in $allTeams) { From 06059caedce5d9f8c5df415d7b1bf2ca2aa7c618 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 30 Jan 2026 14:52:43 +0000 Subject: [PATCH 19/20] fix ado removal --- .../Public/Remove-AzureDevOpsAccelerator.ps1 | 36 ++++++--------- src/ALZ/Public/Remove-GitHubAccelerator.ps1 | 44 +++++++++---------- 2 files changed, 35 insertions(+), 45 deletions(-) diff --git a/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 b/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 index 1bffde9..53a7a4d 100644 --- a/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 +++ b/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 @@ -17,7 +17,7 @@ function Remove-AzureDevOpsAccelerator { CRITICAL WARNING: This is a highly destructive operation that will permanently delete Azure DevOps resources. Use with extreme caution and ensure you have appropriate backups and authorization before executing. - .PARAMETER AzureDevOpsOrganization + .PARAMETER Organization The Azure DevOps organization URL or name. Can be provided as either the full URL (e.g., https://dev.azure.com/my-org) or just the organization name (e.g., my-org). This parameter is required. @@ -29,16 +29,9 @@ function Remove-AzureDevOpsAccelerator { .PARAMETER AgentPoolNamePatterns An array of regex patterns to match against agent pool names. Agent pools matching any of - these patterns will be deleted. If empty, no agent pools will be deleted. Requires the - -IncludeAgentPools switch to be specified. + these patterns will be deleted. If empty, no agent pools will be deleted. Default: Empty array (no agent pools deleted) - .PARAMETER IncludeAgentPools - A switch parameter that enables deletion of agent pools matching the patterns specified in - -AgentPoolNamePatterns. By default, agent pools are not deleted. This is useful for cleaning - up self-hosted agent pools created during the bootstrap process. - Default: $false (do not delete agent pools) - .PARAMETER BypassConfirmation A switch parameter that bypasses the interactive confirmation prompts. When specified, the function waits for the duration specified in -BypassConfirmationTimeoutSeconds before proceeding, allowing @@ -65,24 +58,24 @@ function Remove-AzureDevOpsAccelerator { Default: $false (execute actual deletions) .EXAMPLE - Remove-AzureDevOpsAccelerator -AzureDevOpsOrganization "my-org" -ProjectNamePatterns @("^alz-.*") -PlanMode + Remove-AzureDevOpsAccelerator -Organization "my-org" -ProjectNamePatterns @("^alz-.*") -PlanMode Shows what projects matching the pattern "^alz-.*" would be deleted from the "my-org" organization without making any changes. .EXAMPLE - Remove-AzureDevOpsAccelerator -AzureDevOpsOrganization "https://dev.azure.com/my-org" -ProjectNamePatterns @("^alz-.*") + Remove-AzureDevOpsAccelerator -Organization "https://dev.azure.com/my-org" -ProjectNamePatterns @("^alz-.*") Deletes all projects matching the pattern "^alz-.*" from the "my-org" organization. .EXAMPLE - Remove-AzureDevOpsAccelerator -AzureDevOpsOrganization "my-org" -ProjectNamePatterns @("^alz-.*") -IncludeAgentPools -AgentPoolNamePatterns @("^alz-.*") + Remove-AzureDevOpsAccelerator -Organization "my-org" -ProjectNamePatterns @("^alz-.*") -AgentPoolNamePatterns @("^alz-.*") Deletes projects and self-hosted agent pools matching the pattern "^alz-.*" from the "my-org" organization. .EXAMPLE - Remove-AzureDevOpsAccelerator -AzureDevOpsOrganization "my-org" -ProjectNamePatterns @("^test-alz$") -BypassConfirmation -BypassConfirmationTimeoutSeconds 10 + Remove-AzureDevOpsAccelerator -Organization "my-org" -ProjectNamePatterns @("^test-alz$") -BypassConfirmation -BypassConfirmationTimeoutSeconds 10 Deletes the project named exactly "test-alz" with a 10-second confirmation bypass timeout. @@ -99,8 +92,8 @@ function Remove-AzureDevOpsAccelerator { [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter(Mandatory = $true, HelpMessage = "[REQUIRED] The Azure DevOps organization URL or name.")] - [Alias("org")] - [string]$AzureDevOpsOrganization, + [Alias("org", "AzureDevOpsOrganization")] + [string]$Organization, [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Regex patterns to match project names for deletion.")] [Alias("projects")] @@ -110,9 +103,6 @@ function Remove-AzureDevOpsAccelerator { [Alias("pools")] [string[]]$AgentPoolNamePatterns = @(), - [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Include agent pools in the deletion process.")] - [switch]$IncludeAgentPools, - [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Bypass interactive confirmation prompts.")] [switch]$BypassConfirmation, @@ -154,7 +144,7 @@ function Remove-AzureDevOpsAccelerator { $funcWriteToConsoleLog = ${function:Write-ToConsoleLog}.ToString() # Normalize organization URL - $organizationUrl = Get-NormalizedOrganizationUrl -Organization $AzureDevOpsOrganization + $organizationUrl = Get-NormalizedOrganizationUrl -Organization $Organization Write-ToConsoleLog "Using Azure DevOps organization: $organizationUrl" # Configure Azure DevOps CLI defaults @@ -184,7 +174,7 @@ function Remove-AzureDevOpsAccelerator { Write-ToConsoleLog "Thanks for providing the inputs, getting started..." -IsSuccess $hasProjectPatterns = $ProjectNamePatterns.Count -gt 0 - $hasAgentPoolPatterns = $IncludeAgentPools -and $AgentPoolNamePatterns.Count -gt 0 + $hasAgentPoolPatterns = $AgentPoolNamePatterns.Count -gt 0 if(-not $hasProjectPatterns -and -not $hasAgentPoolPatterns) { Write-ToConsoleLog "No patterns provided for projects or agent pools. Nothing to do. Exiting..." -IsError @@ -237,9 +227,9 @@ function Remove-AzureDevOpsAccelerator { Write-ToConsoleLog "Found $($allAgentPools.Count) total agent pools in organization: $organizationUrl" foreach($pool in $allAgentPools) { - # Skip system pools (Azure Pipelines, Default, etc.) - if($pool.isHosted -or $pool.poolType -eq "automation") { - Write-ToConsoleLog "Skipping hosted/system pool: $($pool.name)" + # Skip hosted pools (Microsoft-hosted Azure Pipelines) + if($pool.isHosted) { + Write-ToConsoleLog "Skipping hosted pool: $($pool.name)" continue } diff --git a/src/ALZ/Public/Remove-GitHubAccelerator.ps1 b/src/ALZ/Public/Remove-GitHubAccelerator.ps1 index 16791b2..2e0eb07 100644 --- a/src/ALZ/Public/Remove-GitHubAccelerator.ps1 +++ b/src/ALZ/Public/Remove-GitHubAccelerator.ps1 @@ -18,7 +18,7 @@ function Remove-GitHubAccelerator { CRITICAL WARNING: This is a highly destructive operation that will permanently delete GitHub resources. Use with extreme caution and ensure you have appropriate backups and authorization before executing. - .PARAMETER GitHubOrganization + .PARAMETER Organization The GitHub organization name where the resources to be deleted are located. This parameter is required. @@ -63,24 +63,24 @@ function Remove-GitHubAccelerator { Default: $false (execute actual deletions) .EXAMPLE - Remove-GitHubAccelerator -GitHubOrganization "my-org" -RepositoryNamePatterns @("^alz-.*") -PlanMode + Remove-GitHubAccelerator -Organization "my-org" -RepositoryNamePatterns @("^alz-.*") -PlanMode Shows what repositories matching the pattern "^alz-.*" would be deleted from the "my-org" organization without making any changes. .EXAMPLE - Remove-GitHubAccelerator -GitHubOrganization "my-org" -RepositoryNamePatterns @("^alz-.*") -TeamNamePatterns @("^alz-.*") + Remove-GitHubAccelerator -Organization "my-org" -RepositoryNamePatterns @("^alz-.*") -TeamNamePatterns @("^alz-.*") Deletes all repositories and teams matching the pattern "^alz-.*" from the "my-org" organization. .EXAMPLE - Remove-GitHubAccelerator -GitHubOrganization "my-org" -RepositoryNamePatterns @("^alz-.*", "^landing-zone-.*") -RunnerGroupNamePatterns @("^alz-.*") + Remove-GitHubAccelerator -Organization "my-org" -RepositoryNamePatterns @("^alz-.*", "^landing-zone-.*") -RunnerGroupNamePatterns @("^alz-.*") Deletes repositories matching either pattern and runner groups matching "^alz-.*" from the "my-org" organization. .EXAMPLE - Remove-GitHubAccelerator -GitHubOrganization "my-org" -RepositoryNamePatterns @("^test-alz$") -BypassConfirmation -BypassConfirmationTimeoutSeconds 10 + Remove-GitHubAccelerator -Organization "my-org" -RepositoryNamePatterns @("^test-alz$") -BypassConfirmation -BypassConfirmationTimeoutSeconds 10 Deletes the repository named exactly "test-alz" with a 10-second confirmation bypass timeout. @@ -96,8 +96,8 @@ function Remove-GitHubAccelerator { [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter(Mandatory = $true, HelpMessage = "[REQUIRED] The GitHub organization name.")] - [Alias("org")] - [string]$GitHubOrganization, + [Alias("org", "GitHubOrganization")] + [string]$Organization, [Parameter(Mandatory = $false, HelpMessage = "[OPTIONAL] Regex patterns to match repository names for deletion.")] [Alias("repos")] @@ -176,15 +176,15 @@ function Remove-GitHubAccelerator { # Discover repositories if($hasRepositoryPatterns) { - Write-ToConsoleLog "Discovering repositories in organization: $GitHubOrganization" + Write-ToConsoleLog "Discovering repositories in organization: $Organization" - $repositoriesResponse = (gh repo list $GitHubOrganization --json name,url --limit 1000 2>&1) + $repositoriesResponse = (gh repo list $Organization --json name,url --limit 1000 2>&1) if($LASTEXITCODE -ne 0) { - Write-ToConsoleLog "Failed to list repositories in organization: $GitHubOrganization" -IsError + Write-ToConsoleLog "Failed to list repositories in organization: $Organization" -IsError return } $allRepositories = @($repositoriesResponse | ConvertFrom-Json) - Write-ToConsoleLog "Found $($allRepositories.Count) total repositories in organization: $GitHubOrganization" + Write-ToConsoleLog "Found $($allRepositories.Count) total repositories in organization: $Organization" foreach($repo in $allRepositories) { foreach($pattern in $RepositoryNamePatterns) { @@ -204,15 +204,15 @@ function Remove-GitHubAccelerator { # Discover teams if($hasTeamPatterns) { - Write-ToConsoleLog "Discovering teams in organization: $GitHubOrganization" + Write-ToConsoleLog "Discovering teams in organization: $Organization" - $teamsResponse = (gh api "orgs/$GitHubOrganization/teams" --paginate 2>&1) + $teamsResponse = (gh api "orgs/$Organization/teams" --paginate 2>&1) if($LASTEXITCODE -ne 0) { - Write-ToConsoleLog "Failed to list teams in organization: $GitHubOrganization" -IsError + Write-ToConsoleLog "Failed to list teams in organization: $Organization" -IsError return } $allTeams = @($teamsResponse | ConvertFrom-Json) - Write-ToConsoleLog "Found $($allTeams.Count) total teams in organization: $GitHubOrganization" + Write-ToConsoleLog "Found $($allTeams.Count) total teams in organization: $Organization" foreach($team in $allTeams) { foreach($pattern in $TeamNamePatterns) { @@ -233,18 +233,18 @@ function Remove-GitHubAccelerator { # Discover runner groups if($hasRunnerGroupPatterns) { - Write-ToConsoleLog "Discovering runner groups in organization: $GitHubOrganization" + Write-ToConsoleLog "Discovering runner groups in organization: $Organization" - $runnerGroupsResponse = (gh api "orgs/$GitHubOrganization/actions/runner-groups" --paginate 2>&1) + $runnerGroupsResponse = (gh api "orgs/$Organization/actions/runner-groups" --paginate 2>&1) if($LASTEXITCODE -ne 0) { - Write-ToConsoleLog "Failed to list runner groups in organization: $GitHubOrganization (may require GitHub Enterprise)" -IsWarning + Write-ToConsoleLog "Failed to list runner groups in organization: $Organization (may require GitHub Enterprise)" -IsWarning $allRunnerGroups = @() } else { $allRunnerGroups = ($runnerGroupsResponse | ConvertFrom-Json).runner_groups } if($null -ne $allRunnerGroups) { - Write-ToConsoleLog "Found $($allRunnerGroups.Count) total runner groups in organization: $GitHubOrganization" + Write-ToConsoleLog "Found $($allRunnerGroups.Count) total runner groups in organization: $Organization" foreach($runnerGroup in $allRunnerGroups) { # Skip the default runner group as it cannot be deleted @@ -313,7 +313,7 @@ function Remove-GitHubAccelerator { $funcWriteToConsoleLog = $using:funcWriteToConsoleLog ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog $TempLogFileForPlan = $using:TempLogFileForPlan - $org = $using:GitHubOrganization + $org = $using:Organization $repo = $_ $repoFullName = "$org/$($repo.Name)" @@ -343,7 +343,7 @@ function Remove-GitHubAccelerator { $funcWriteToConsoleLog = $using:funcWriteToConsoleLog ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog $TempLogFileForPlan = $using:TempLogFileForPlan - $org = $using:GitHubOrganization + $org = $using:Organization $team = $_ @@ -372,7 +372,7 @@ function Remove-GitHubAccelerator { $funcWriteToConsoleLog = $using:funcWriteToConsoleLog ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog $TempLogFileForPlan = $using:TempLogFileForPlan - $org = $using:GitHubOrganization + $org = $using:Organization $runnerGroup = $_ From ca16869b1e673d78809ae38af286ad6a98e50ca0 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 30 Jan 2026 15:05:10 +0000 Subject: [PATCH 20/20] fix ado script --- src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 b/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 index 53a7a4d..00a67aa 100644 --- a/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 +++ b/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 @@ -323,11 +323,11 @@ function Remove-AzureDevOpsAccelerator { if($using:PlanMode) { Write-ToConsoleLog ` "Would delete agent pool: $($pool.Name)", ` - "Would run: az pipelines pool delete --id $($pool.Id) --org $orgUrl --yes" ` + "Would run: az devops invoke --org $orgUrl --area distributedtask --resource pools --route-parameters poolId=$($pool.Id) --http-method DELETE --api-version 7.1" ` -IsPlan -LogFilePath $TempLogFileForPlan } else { Write-ToConsoleLog "Deleting agent pool: $($pool.Name)" - $result = az pipelines pool delete --id $pool.Id --org $orgUrl --yes 2>&1 + $result = az devops invoke --org $orgUrl --area distributedtask --resource pools --route-parameters poolId=$($pool.Id) --http-method DELETE --api-version 7.1 2>&1 if($LASTEXITCODE -ne 0) { Write-ToConsoleLog "Failed to delete agent pool: $($pool.Name)", "Full error: $result" -IsWarning } else {