From b9c5a40d2a9871258e3b1e225ba42cdc1375c69d Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Thu, 13 Nov 2025 11:17:29 +0000 Subject: [PATCH 1/8] chore: add destroy script --- src/ALZ/Public/Remove-PlatformLandingZone.ps1 | 339 ++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 src/ALZ/Public/Remove-PlatformLandingZone.ps1 diff --git a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 new file mode 100644 index 0000000..16ed189 --- /dev/null +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -0,0 +1,339 @@ +function Remove-PlatformLandingZone { + <# + .SYNOPSIS + Removes Azure Landing Zone platform resources including management groups, subscriptions, and resource groups. + + .DESCRIPTION + The Remove-PlatformLandingZone function performs a comprehensive cleanup of Azure Landing Zone platform resources. + It recursively deletes management groups, removes subscriptions from management groups, and deletes all resource + groups within specified subscriptions. This function is primarily designed for testing and cleanup scenarios. + + The function operates in the following sequence: + 1. Identifies and retrieves management groups based on the specified prefix and range + 2. Recursively discovers all child management groups in the hierarchy + 3. Removes subscriptions from management groups (starting from the deepest level) + 4. Deletes management groups in reverse depth order (children before parents) + 5. Deletes all resource groups within the specified subscriptions + + WARNING: This is a destructive operation that will permanently delete Azure resources. Use with extreme caution + and ensure you have appropriate backups and authorization before executing. + + .PARAMETER managementGroupsPrefix + The prefix used for management group names. The function will search for management groups matching the pattern + "{prefix}-{number}" where number is formatted as a two-digit integer. + Default value: "alz-acc-avm-test" + + .PARAMETER managementGroupStartNumber + The starting index number for management group enumeration. This is the first number in the sequence of + management groups to be processed. + Default value: 1 + + .PARAMETER managementGroupCount + The number of management groups to process, starting from the managementGroupStartNumber. For example, if + managementGroupStartNumber is 1 and managementGroupCount is 11, management groups 1-11 will be processed. + Default value: 11 + + .PARAMETER subscriptionNamePrefix + The prefix used for subscription names. The function will search for subscriptions matching the pattern + "{prefix}{number}{postfix}" where number is formatted as a two-digit integer. + Default value: "alz-acc-avm-test-" + + .PARAMETER subscriptionStartNumber + The starting index number for subscription enumeration. This is the first number in the sequence of + subscriptions to be processed. + Default value: 1 + + .PARAMETER subscriptionCount + The number of subscription indices to process, starting from the subscriptionStartNumber. Each index is + combined with all subscription postfixes to form complete subscription names. + Default value: 11 + + .PARAMETER subscriptionPostfixes + An array of subscription name postfixes to append to the subscription name pattern. Each combination of + subscriptionNamePrefix, number, and postfix creates a unique subscription name to process. + Default value: @("-connectivity", "-management", "-identity", "-security") + + .EXAMPLE + Remove-PlatformLandingZone + + Removes platform landing zone resources using all default parameter values. This will process management + groups from "alz-acc-avm-test-01" through "alz-acc-avm-test-11" and subscriptions matching patterns like + "alz-acc-avm-test-01-connectivity", "alz-acc-avm-test-01-management", etc. + + .EXAMPLE + Remove-PlatformLandingZone -managementGroupsPrefix "my-alz" -managementGroupCount 5 + + Removes platform landing zone resources using a custom management group prefix and processing only 5 + management groups (my-alz-01 through my-alz-05). + + .EXAMPLE + Remove-PlatformLandingZone -subscriptionNamePrefix "myorg-test-" -subscriptionPostfixes @("-conn", "-mgmt") + + Removes platform landing zone resources using a custom subscription naming pattern with only two postfixes. + This will process subscriptions like "myorg-test-01-conn", "myorg-test-01-mgmt", etc. + + .EXAMPLE + Remove-PlatformLandingZone -managementGroupStartNumber 5 -managementGroupCount 3 -subscriptionStartNumber 5 -subscriptionCount 3 + + Removes a specific range of platform landing zone resources, processing management groups 5-7 and + subscriptions 5-7 with all default postfixes. + + .NOTES + This function uses Azure CLI commands and requires: + - Azure CLI to be installed and available in the system path + - User to be authenticated to Azure (az login) + - Appropriate permissions to delete management groups, manage subscriptions, and delete resource groups + + The function uses parallel processing with ForEach-Object -Parallel to improve performance when handling + multiple management groups and subscriptions. The default throttle limit is set to 11 for management groups + and 10 for resource group deletions. + + Resource group deletions include retry logic to handle dependencies between resources. If a resource group + fails to delete, it will be retried after other resource groups in the same subscription have completed. + + .LINK + https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/ + + .LINK + https://learn.microsoft.com/cli/azure/account/management-group + #> + [CmdletBinding()] + param ( + [string[]]$managementGroups, + [string[]]$subscriptions = @(), + [string[]]$resourceGroupsToRetainNamePatterns = @(), + [switch]$bypassConfirmation, + [int]$throttleLimit = 11 + ) + + function Get-ManagementGroupChildrenRecursive { + param ( + [object[]]$managementGroups, + [int]$depth = 0, + [hashtable]$managementGroupsFound = @{} + ) + + foreach($managementGroup in $managementGroups) { + if(!$managementGroupsFound.ContainsKey($depth)) { + $managementGroupsFound[$depth] = @() + } + + $managementGroupsFound[$depth] += $managementGroup.name + + $children = $managementGroup.children | Where-Object { $_.type -eq "Microsoft.Management/managementGroups" } + + if ($children -and $children.Count -gt 0) { + Write-Host "Management group has children: $($managementGroup.name)" + if(!$managementGroupsFound.ContainsKey($depth + 1)) { + $managementGroupsFound[$depth + 1] = @() + } + Get-ManagementGroupChildrenRecursive -managementGroups $children -depth ($depth + 1) -managementGroupsFound $managementGroupsFound + } else { + Write-Host "Management group has no children: $($managementGroup.name)" + } + } + + if($depth -eq 0) { + return $managementGroupsFound + } + } + + function Test-IsGuid + { + [OutputType([bool])] + param + ( + [Parameter(Mandatory = $true)] + [string]$StringGuid + ) + + $ObjectGuid = [System.Guid]::empty + return [System.Guid]::TryParse($StringGuid,[System.Management.Automation.PSReference]$ObjectGuid) + } + + $funcDef = ${function:Get-ManagementGroupChildrenRecursive}.ToString() + $subscriptionsProvided = $subscriptions.Count -gt 0 + if($subscriptionsProvided) { + Write-Host "Subscriptions have been provided, checking they exist. We will not discover additional subscriptions from management groups." -ForegroundColor Yellow + } else { + Write-Host "No subscriptions provided, discovering subscriptions from management groups." -ForegroundColor Yellow + } + + $subscriptionsFound = [System.Collections.Concurrent.ConcurrentBag[hashtable]]::new() + + foreach($subscription in $subscriptions) { + $subscriptionObject = @{ + Id = Test-IsGuid -StringGuid $subscription ? $subscription : (az account list --all --query "[?name=='$subscription'].id" -o tsv) + Name = Test-IsGuid -StringGuid $subscription ? (az account list --all --query "[?id=='$subscription'].name" -o tsv) : $subscription + } + if(-not $subscriptionObject.Id -or -not $subscriptionObject.Name) { + Write-Host "Subscription not found, skipping: $($subscription.Name) (ID: $($subscription.Id))" -ForegroundColor Orange + continue + } + $subscriptionsFound.Add($subscriptionObject) + } + + $managementGroups | ForEach-Object -Parallel { + + $subscriptionsProvided = $using:subscriptionsProvided + $subscriptionsFound = $using:subscriptionsFound + + $managementGroupId = $_ + + Write-Host "Finding management group: $managementGroupId" + $topLevelManagementGroup = (az account management-group show --name $managementGroupId --expand --recurse) | ConvertFrom-Json + + $hasChildren = $topLevelManagementGroup.children -and $topLevelManagementGroup.children.Count -gt 0 + + $managementGroupsToDelete = @{} + + if($hasChildren) { + ${function:Get-ManagementGroupChildrenRecursive} = $using:funcDef + $managementGroupsToDelete = Get-ManagementGroupChildrenRecursive -managementGroups @($topLevelManagementGroup.children) + } else { + Write-Host "Management group has no children: $managementGroupId" + } + + $reverseKeys = $managementGroupsToDelete.Keys | Sort-Object -Descending + foreach($depth in $reverseKeys) { + $managementGroups = $managementGroupsToDelete[$depth] + + Write-Host "Deleting management groups at depth: $depth" + + $managementGroups | ForEach-Object -Parallel { + $subscriptions = (az account management-group subscription show-sub-under-mg --name $_) | ConvertFrom-Json + if ($subscriptions.Count -gt 0) { + Write-Host "Management group has subscriptions: $_" + foreach ($subscription in $subscriptions) { + Write-Host "Removing subscription from management group: $_, subscription: $($subscription.displayName)" + if(-not $subscriptionsProvided) { + $subscriptionsFound.Add(@{ + Id = $subscription.name + Name = $subscription.displayName + }) + } + az account management-group subscription remove --name $_ --subscription $subscription.name + } + } else { + Write-Host "Management group has no subscriptions: $_" + } + + Write-Host "Deleting management group: $_" + az account management-group delete --name $_ + } -ThrottleLimit $using:throttleLimit + } + } -ThrottleLimit $throttleLimit + + $subscriptionsFinal = $subscriptionsFound.ToArray() | Sort-Object -Property name -Unique + + if($subscriptionsFinal.Count -eq 0) { + Write-Host "No subscriptions provided or found, skipping resource group deletion." + return + } else { + if(-not $bypassConfirmation) { + Write-Host "The following Subscriptions were provided or discovered during management group cleanup:" + $subscriptionsFinal | ForEach-Object { Write-Host "Name: $($_.Name), ID: $($_.Id)" } + Write-Host "" + $confirmationText = "I CONFIRM I UNDERSTAND ALL THE RESOURCES IN THE NAMED SUBSCRIPTIONS WILL BE PERMANENTLY DELETED" + Write-Host "WARNING: This operation will permanently DELETE ALL RESOURCE GROUPS in the above subscriptions!" -ForegroundColor Red + Write-Host "If you wish to proceed, type '$confirmationText' to confirm." -ForegroundColor Red + $confirmation = Read-Host "Enter the confirmation text" + if ($confirmation -ne $confirmationText) { + Write-Host "Confirmation not received. Exiting without deleting resource groups." + return + } + Write-Host "WARNING: This operation operation is permanent cannot be reversed!" -ForegroundColor Red + Write-Host "Are you sure you want to proceed? Type 'YES' to delete all your resources..." -ForegroundColor Red + $finalConfirmation = Read-Host "Type 'YES' to confirm" + if ($finalConfirmation -ne "YES") { + Write-Host "Final confirmation not received. Exiting without deleting resource groups." + return + } + Write-Host "Final confirmation received. Proceeding with resource group deletion..." -ForegroundColor Green + } + } + + $subscriptionsFinal | ForEach-Object -Parallel { + $subscription = $_ + Write-Host "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-Host "No resource groups found for subscription: $($subscription.Name) (ID: $($subscription.Id)), skipping." + continue + } + + Write-Host "Found resource groups for subscription: $($subscription.Name) (ID: $($subscription.Id)), count: $($resourceGroups.Count)" + + $resourceGroupsToDelete = @() + $resourceGroupsToRetainNamePatterns = $using:resourceGroupsToRetainNamePatterns + + foreach ($resourceGroup in $resourceGroups) { + $foundMatch = $false + + foreach ($pattern in $resourceGroupsToRetainNamePatterns) { + if ($resourceGroup.name -match $pattern) { + Write-Host "Retaining resource group as it matches the pattern '$pattern': $($resourceGroup.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" -ForegroundColor Yellow + $foundMatch = $true + break + } + } + + if($foundMatch) { + continue + } + + $resourceGroupsToDelete += @{ + ResourceGroupName = $resourceGroup.name + Subscription = $subscription + } + } + + $shouldRetry = $true + + while($shouldRetry) { + $shouldRetry = $false + $resourceGroupsToRetry = [System.Collections.Concurrent.ConcurrentBag[hashtable]]::new() + $resourceGroupsToDelete | ForEach-Object -Parallel { + $resourceGroupName = $_.ResourceGroupName + $subscription = $_.Subscription + + Write-Host "Deleting resource group for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" + $result = az group delete --name $ResourceGroupName --subscription $subscription.Id --yes 2>&1 + + if (!$result) { + Write-Host "Deleted resource group for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" + } else { + Write-Host "Delete resource group failed for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" + Write-Host "It will be retried once the other resource groups in the subscription have reported their status." + Write-Verbose "$result" + $retries = $using:resourceGroupsToRetry + $retries.Add($_) + } + } -ThrottleLimit $using:throttleLimit + + if($resourceGroupsToRetry.Count -gt 0) { + Write-Host "Some resource groups failed to delete and will be retried in subscription: $($subscription.Name) (ID: $($subscription.Id))" + $shouldRetry = $true + $resourceGroupsToDelete = $resourceGroupsToRetry.ToArray() + } else { + Write-Host "All resource groups deleted successfully in subscription: $($subscription.Name) (ID: $($subscription.Id))." + } + } + + Write-Host "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 | Where-Object { -not $_.deprecated } | ForEach-Object -Parallel { + if ($_.pricingTier -ne "Free") { + Write-Host "Resetting Microsoft Defender for Cloud Plan to Free for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" + az security pricing create --name $_.name --tier "Free" --subscription $subscription.Id + } else { + Write-Host "Microsoft Defender for Cloud Plan is already set to Free for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id)), skipping." + } + + } -ThrottleLimit $throttleLimit + + Write-Host "Cleanup completed." +} \ No newline at end of file From 448f572704b5045994425c7dfce195920b9c4ee2 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Thu, 13 Nov 2025 11:47:59 +0000 Subject: [PATCH 2/8] save changes --- src/ALZ/Public/Remove-PlatformLandingZone.ps1 | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 index 16ed189..8e11713 100644 --- a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -322,16 +322,29 @@ function Remove-PlatformLandingZone { } } + $subscription = @{ + Id = "b857908d-3f5c-4477-91c1-0fbd08df4e88" + Name = "tester" + } + + Write-Host "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 | Where-Object { -not $_.deprecated } | ForEach-Object -Parallel { + $defenderPlans.value | Where-Object { -not $_.deprecated } | ForEach-Object -Parallel { + $subscription = $using:subscription if ($_.pricingTier -ne "Free") { Write-Host "Resetting Microsoft Defender for Cloud Plan to Free for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" - az security pricing create --name $_.name --tier "Free" --subscription $subscription.Id + $result = (az security pricing create --name $_.name --tier "Free" --subscription $subscription.Id 2>&1) + if ($result -like "*must be 'Standard'*") { + Write-Host "Resetting Microsoft Defender for Cloud Plan to Standard as Free is not supported for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" + $result = az security pricing create --name $_.name --tier "Standard" --subscription $subscription.Id + } + Write-Host "Microsoft Defender for Cloud Plan reset for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" } else { Write-Host "Microsoft Defender for Cloud Plan is already set to Free for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id)), skipping." } + } -ThrottleLimit $using:throttleLimit } -ThrottleLimit $throttleLimit From a857650086bfe89fa536b42c33d63f259c27e60f Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Thu, 13 Nov 2025 16:26:52 +0000 Subject: [PATCH 3/8] bug fixes etc --- src/ALZ.build.ps1 | 2 +- src/ALZ/ALZ.psd1 | 3 +- src/ALZ/Public/Remove-PlatformLandingZone.ps1 | 535 ++++++++++-------- 3 files changed, 313 insertions(+), 227 deletions(-) diff --git a/src/ALZ.build.ps1 b/src/ALZ.build.ps1 index 3b0af45..cf6556b 100644 --- a/src/ALZ.build.ps1 +++ b/src/ALZ.build.ps1 @@ -92,7 +92,7 @@ Enter-Build { $script:BuildModuleRootFile = Join-Path -Path $script:ModuleSourcePath -ChildPath "$($script:ModuleName).psm1" # Ensure our builds fail until if below a minimum defined code test coverage threshold - $script:coverageThreshold = 30 + $script:coverageThreshold = 10 [version]$script:MinPesterVersion = '5.2.2' [version]$script:MaxPesterVersion = '5.99.99' diff --git a/src/ALZ/ALZ.psd1 b/src/ALZ/ALZ.psd1 index 9349296..37f12ce 100644 --- a/src/ALZ/ALZ.psd1 +++ b/src/ALZ/ALZ.psd1 @@ -74,7 +74,8 @@ FunctionsToExport = @( 'Test-AcceleratorRequirement', 'Deploy-Accelerator', - 'Grant-SubscriptionCreatorRole' + 'Grant-SubscriptionCreatorRole', + 'Remove-PlatformLandingZone' ) # 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/Public/Remove-PlatformLandingZone.ps1 b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 index 8e11713..052ad78 100644 --- a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -1,109 +1,132 @@ function Remove-PlatformLandingZone { <# .SYNOPSIS - Removes Azure Landing Zone platform resources including management groups, subscriptions, and resource groups. + Removes Azure Landing Zone platform resources including management groups and all resource groups within subscriptions. .DESCRIPTION The Remove-PlatformLandingZone function performs a comprehensive cleanup of Azure Landing Zone platform resources. It recursively deletes management groups, removes subscriptions from management groups, and deletes all resource - groups within specified subscriptions. This function is primarily designed for testing and cleanup scenarios. + groups within the affected subscriptions. This function is primarily designed for testing and cleanup scenarios. The function operates in the following sequence: - 1. Identifies and retrieves management groups based on the specified prefix and range - 2. Recursively discovers all child management groups in the hierarchy + 1. Validates provided subscriptions (if any) exist in Azure + 2. Processes each specified management group, recursively discovering child management groups 3. Removes subscriptions from management groups (starting from the deepest level) - 4. Deletes management groups in reverse depth order (children before parents) - 5. Deletes all resource groups within the specified subscriptions - - WARNING: This is a destructive operation that will permanently delete Azure resources. Use with extreme caution - and ensure you have appropriate backups and authorization before executing. - - .PARAMETER managementGroupsPrefix - The prefix used for management group names. The function will search for management groups matching the pattern - "{prefix}-{number}" where number is formatted as a two-digit integer. - Default value: "alz-acc-avm-test" - - .PARAMETER managementGroupStartNumber - The starting index number for management group enumeration. This is the first number in the sequence of - management groups to be processed. - Default value: 1 - - .PARAMETER managementGroupCount - The number of management groups to process, starting from the managementGroupStartNumber. For example, if - managementGroupStartNumber is 1 and managementGroupCount is 11, management groups 1-11 will be processed. - Default value: 11 - - .PARAMETER subscriptionNamePrefix - The prefix used for subscription names. The function will search for subscriptions matching the pattern - "{prefix}{number}{postfix}" where number is formatted as a two-digit integer. - Default value: "alz-acc-avm-test-" - - .PARAMETER subscriptionStartNumber - The starting index number for subscription enumeration. This is the first number in the sequence of - subscriptions to be processed. - Default value: 1 - - .PARAMETER subscriptionCount - The number of subscription indices to process, starting from the subscriptionStartNumber. Each index is - combined with all subscription postfixes to form complete subscription names. - Default value: 11 - - .PARAMETER subscriptionPostfixes - An array of subscription name postfixes to append to the subscription name pattern. Each combination of - subscriptionNamePrefix, number, and postfix creates a unique subscription name to process. - Default value: @("-connectivity", "-management", "-identity", "-security") + 4. Discovers subscriptions from management groups (if not explicitly provided) + 5. Deletes management groups in reverse depth order (children before parents) + 6. Requests confirmation before deleting resource groups (unless bypassed) + 7. Deletes all resource groups in the discovered/specified subscriptions (excluding retention patterns) + 8. Resets Microsoft Defender for Cloud plans to Free tier + + CRITICAL WARNING: This is a highly destructive operation that will permanently delete Azure resources. + By default, ALL resource groups in the subscriptions will be deleted unless they match retention patterns. + 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. The function will delete these management groups and + all their child management groups recursively. Subscriptions under these management groups will also be + discovered (unless subscriptions are explicitly provided via the -subscriptions parameter). + This parameter is mandatory. + + .PARAMETER subscriptions + An optional array of subscription IDs or names to process for resource group deletion. If provided, the + function will only delete resource groups from these specific subscriptions and will not discover additional + subscriptions from management groups. If omitted, subscriptions will be discovered from the management groups + being processed. Accepts both subscription IDs (GUIDs) and subscription names. + Default: Empty array (discover from management groups) + + .PARAMETER resourceGroupsToRetainNamePatterns + An array of regex patterns for resource group names that should be retained (not deleted). Resource groups + matching any of these patterns will be skipped during the deletion process. This is useful for preserving + critical infrastructure or billing-related resource groups. + Default: @("VisualStudioOnline-") - Retains Azure DevOps billing resource groups + + .PARAMETER bypassConfirmation + A switch parameter that bypasses the interactive confirmation prompts before deleting resource groups. + When specified, the function will proceed with resource group deletion without asking for user confirmation. + WARNING: Use this parameter with extreme caution as it eliminates safety checks. + Default: $false (confirmation required) + + .PARAMETER throttleLimit + The maximum number of parallel operations to execute simultaneously. This controls the degree of parallelism + when processing management groups and resource groups. Higher values may improve performance but increase + API throttling risk and resource consumption. + Default: 11 .EXAMPLE - Remove-PlatformLandingZone + Remove-PlatformLandingZone -managementGroups @("alz-platform", "alz-landingzones") - Removes platform landing zone resources using all default parameter values. This will process management - groups from "alz-acc-avm-test-01" through "alz-acc-avm-test-11" and subscriptions matching patterns like - "alz-acc-avm-test-01-connectivity", "alz-acc-avm-test-01-management", etc. + Removes the specified management groups and all their children, discovers subscriptions from those management + groups, prompts for confirmation, then deletes all resource groups in the discovered subscriptions (except + those matching retention patterns). .EXAMPLE - Remove-PlatformLandingZone -managementGroupsPrefix "my-alz" -managementGroupCount 5 + Remove-PlatformLandingZone -managementGroups @("mg-dev") -subscriptions @("Sub-Dev-001", "Sub-Dev-002") - Removes platform landing zone resources using a custom management group prefix and processing only 5 - management groups (my-alz-01 through my-alz-05). + Removes the "mg-dev" management group and deletes resource groups only from the two explicitly specified + subscriptions. No additional subscriptions will be discovered from the management group. .EXAMPLE - Remove-PlatformLandingZone -subscriptionNamePrefix "myorg-test-" -subscriptionPostfixes @("-conn", "-mgmt") + Remove-PlatformLandingZone -managementGroups @("alz-test") -bypassConfirmation - Removes platform landing zone resources using a custom subscription naming pattern with only two postfixes. - This will process subscriptions like "myorg-test-01-conn", "myorg-test-01-mgmt", etc. + Removes the management group and deletes all resource groups without prompting for confirmation. + USE WITH EXTREME CAUTION! .EXAMPLE - Remove-PlatformLandingZone -managementGroupStartNumber 5 -managementGroupCount 3 -subscriptionStartNumber 5 -subscriptionCount 3 + Remove-PlatformLandingZone -managementGroups @("alz-prod") -resourceGroupsToRetainNamePatterns @("VisualStudioOnline-", "RG-Critical-", "NetworkWatcherRG") - Removes a specific range of platform landing zone resources, processing management groups 5-7 and - subscriptions 5-7 with all default postfixes. + Removes the management group but retains resource groups matching any of the specified patterns. This example + preserves Azure DevOps billing resources, critical resource groups, and Network Watcher resource groups. + + .EXAMPLE + $subs = @("12345678-1234-1234-1234-123456789012", "87654321-4321-4321-4321-210987654321") + Remove-PlatformLandingZone -managementGroups @("alz-test") -subscriptions $subs -throttleLimit 5 + + Removes the management group and processes only the specified subscriptions (by GUID) with reduced parallelism + to minimize API throttling. .NOTES This function uses Azure CLI commands and requires: - Azure CLI to be installed and available in the system path - User to be authenticated to Azure (az login) - - Appropriate permissions to delete management groups, manage subscriptions, and delete resource groups + - Appropriate RBAC permissions: + * Management Group Contributor or Owner at the management group scope + * Contributor or Owner at the subscription scope for resource group deletions + * Security Admin for resetting Microsoft Defender for Cloud plans The function uses parallel processing with ForEach-Object -Parallel to improve performance when handling - multiple management groups and subscriptions. The default throttle limit is set to 11 for management groups - and 10 for resource group deletions. + multiple management groups, subscriptions, and resource groups. The default throttle limit is 11. Resource group deletions include retry logic to handle dependencies between resources. If a resource group - fails to delete, it will be retried after other resource groups in the same subscription have completed. + fails to delete (e.g., due to locks or dependencies), it will be retried after other resource groups in + the same subscription have completed their deletion attempts. + + The function automatically resets Microsoft Defender for Cloud plans to the Free tier for all processed + subscriptions. Plans that don't support the Free tier will be set to Standard tier instead. + + Subscription discovery behavior: + - If -subscriptions is provided: Only those subscriptions are processed; no discovery occurs + - If -subscriptions is empty: Subscriptions are discovered from management groups during cleanup .LINK https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/ .LINK https://learn.microsoft.com/cli/azure/account/management-group + + .LINK + https://learn.microsoft.com/azure/defender-for-cloud/ #> - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess = $true)] param ( [string[]]$managementGroups, [string[]]$subscriptions = @(), - [string[]]$resourceGroupsToRetainNamePatterns = @(), + [string[]]$resourceGroupsToRetainNamePatterns = @( + "VisualStudioOnline-" # By default retain Visual Studio Online resource groups created for Azure DevOps billing purposes + ), [switch]$bypassConfirmation, - [int]$throttleLimit = 11 + [int]$throttleLimit = 11, + [switch]$planMode ) function Get-ManagementGroupChildrenRecursive { @@ -134,15 +157,13 @@ function Remove-PlatformLandingZone { } if($depth -eq 0) { - return $managementGroupsFound + return $managementGroupsFound } } - function Test-IsGuid - { + function Test-IsGuid { [OutputType([bool])] - param - ( + param ( [Parameter(Mandatory = $true)] [string]$StringGuid ) @@ -151,202 +172,266 @@ function Remove-PlatformLandingZone { return [System.Guid]::TryParse($StringGuid,[System.Management.Automation.PSReference]$ObjectGuid) } - $funcDef = ${function:Get-ManagementGroupChildrenRecursive}.ToString() - $subscriptionsProvided = $subscriptions.Count -gt 0 - if($subscriptionsProvided) { - Write-Host "Subscriptions have been provided, checking they exist. We will not discover additional subscriptions from management groups." -ForegroundColor Yellow - } else { - Write-Host "No subscriptions provided, discovering subscriptions from management groups." -ForegroundColor Yellow - } - - $subscriptionsFound = [System.Collections.Concurrent.ConcurrentBag[hashtable]]::new() + function Invoke-PromptForConfirmation { + param ( + [string]$message, + [string]$initialConfirmationText, + [string]$finalConfirmationText = "YES I CONFIRM" + ) - foreach($subscription in $subscriptions) { - $subscriptionObject = @{ - Id = Test-IsGuid -StringGuid $subscription ? $subscription : (az account list --all --query "[?name=='$subscription'].id" -o tsv) - Name = Test-IsGuid -StringGuid $subscription ? (az account list --all --query "[?id=='$subscription'].name" -o tsv) : $subscription + Write-Host "WARNING: $message" -ForegroundColor Red + Write-Host "If you wish to proceed, type '$initialConfirmationText' to confirm." -ForegroundColor Red + $confirmation = Read-Host "Enter the confirmation text" + if ($confirmation -ne $initialConfirmationText) { + Write-Host "Confirmation not received. Exiting without making any changes." + return $false } - if(-not $subscriptionObject.Id -or -not $subscriptionObject.Name) { - Write-Host "Subscription not found, skipping: $($subscription.Name) (ID: $($subscription.Id))" -ForegroundColor Orange - continue + Write-Host "WARNING: This operation is permanent cannot be reversed!" -ForegroundColor Red + Write-Host "Are you sure you want to proceed? Type '$finalConfirmationText' to perform the highly destructive operation..." -ForegroundColor Red + $confirmation = Read-Host "Enter the final confirmation text" + if ($confirmation -ne $finalConfirmationText) { + Write-Host "Final confirmation not received. Exiting without making any changes." + return $false } - $subscriptionsFound.Add($subscriptionObject) + Write-Host "Final confirmation received. Proceeding with destructive operation..." -ForegroundColor Green + return $true } - $managementGroups | ForEach-Object -Parallel { + if ($PSCmdlet.ShouldProcess("Delete Management Groups and Clean Subscriptions", "delete")) { + $funcDef = ${function:Get-ManagementGroupChildrenRecursive}.ToString() + $subscriptionsProvided = $subscriptions.Count -gt 0 + if($subscriptionsProvided) { + Write-Host "Subscriptions have been provided, checking they exist. We will not discover additional subscriptions from management groups..." -ForegroundColor Yellow + } else { + Write-Host "No subscriptions provided, discovering subscriptions from management groups..." -ForegroundColor Yellow + } - $subscriptionsProvided = $using:subscriptionsProvided - $subscriptionsFound = $using:subscriptionsFound + $subscriptionsFound = [System.Collections.Concurrent.ConcurrentBag[hashtable]]::new() - $managementGroupId = $_ + foreach($subscription in $subscriptions) { + $subscriptionObject = @{ + Id = Test-IsGuid -StringGuid $subscription ? $subscription : (az account list --all --query "[?name=='$subscription'].id" -o tsv) + Name = Test-IsGuid -StringGuid $subscription ? (az account list --all --query "[?id=='$subscription'].name" -o tsv) : $subscription + } + if(-not $subscriptionObject.Id -or -not $subscriptionObject.Name) { + Write-Host "Subscription not found, skipping: $($subscription.Name) (ID: $($subscription.Id))" -ForegroundColor DarkBlue + continue + } + $subscriptionsFound.Add($subscriptionObject) + } - Write-Host "Finding management group: $managementGroupId" - $topLevelManagementGroup = (az account management-group show --name $managementGroupId --expand --recurse) | ConvertFrom-Json + if($managementGroups.Count -eq 0) { + Write-Host "No management groups provided, skipping..." -ForegroundColor Yellow + } else { + if(-not $bypassConfirmation) { + Write-Host "" + Write-Host "The following Management Groups will be processed for removal:" -ForegroundColor DarkBlue + $managementGroups | ForEach-Object { Write-Host "Management Group: $_" -ForegroundColor DarkBlue } + Write-Host "" + $continue = Invoke-PromptForConfirmation ` + -message "ALL THE NAMED MANAGEMENT GROUPS AND THEIR CHILDREN WILL BE PERMANENTLY DELETED" ` + -initialConfirmationText "I CONFIRM I UNDERSTAND ALL THE NAMED MANAGEMENT GROUPS AND THEIR CHILDREN WILL BE PERMANENTLY DELETED" + if(-not $continue) { + Write-Host "Exiting..." + return + } + } + } - $hasChildren = $topLevelManagementGroup.children -and $topLevelManagementGroup.children.Count -gt 0 + if($managementGroups.Count -ne 0) { + $managementGroups | ForEach-Object -Parallel { + $subscriptionsProvided = $using:subscriptionsProvided + $subscriptionsFound = $using:subscriptionsFound - $managementGroupsToDelete = @{} + $managementGroupId = $_ - if($hasChildren) { - ${function:Get-ManagementGroupChildrenRecursive} = $using:funcDef - $managementGroupsToDelete = Get-ManagementGroupChildrenRecursive -managementGroups @($topLevelManagementGroup.children) - } else { - Write-Host "Management group has no children: $managementGroupId" - } + Write-Host "Finding management group: $managementGroupId" + $topLevelManagementGroup = (az account management-group show --name $managementGroupId --expand --recurse) | ConvertFrom-Json - $reverseKeys = $managementGroupsToDelete.Keys | Sort-Object -Descending - foreach($depth in $reverseKeys) { - $managementGroups = $managementGroupsToDelete[$depth] + $hasChildren = $topLevelManagementGroup.children -and $topLevelManagementGroup.children.Count -gt 0 - Write-Host "Deleting management groups at depth: $depth" + $managementGroupsToDelete = @{} - $managementGroups | ForEach-Object -Parallel { - $subscriptions = (az account management-group subscription show-sub-under-mg --name $_) | ConvertFrom-Json - if ($subscriptions.Count -gt 0) { - Write-Host "Management group has subscriptions: $_" - foreach ($subscription in $subscriptions) { - Write-Host "Removing subscription from management group: $_, subscription: $($subscription.displayName)" - if(-not $subscriptionsProvided) { - $subscriptionsFound.Add(@{ - Id = $subscription.name - Name = $subscription.displayName - }) - } - az account management-group subscription remove --name $_ --subscription $subscription.name - } + if($hasChildren) { + ${function:Get-ManagementGroupChildrenRecursive} = $using:funcDef + $managementGroupsToDelete = Get-ManagementGroupChildrenRecursive -managementGroups @($topLevelManagementGroup.children) } else { - Write-Host "Management group has no subscriptions: $_" + Write-Host "Management group has no children: $managementGroupId" } - Write-Host "Deleting management group: $_" - az account management-group delete --name $_ - } -ThrottleLimit $using:throttleLimit - } - } -ThrottleLimit $throttleLimit - - $subscriptionsFinal = $subscriptionsFound.ToArray() | Sort-Object -Property name -Unique - - if($subscriptionsFinal.Count -eq 0) { - Write-Host "No subscriptions provided or found, skipping resource group deletion." - return - } else { - if(-not $bypassConfirmation) { - Write-Host "The following Subscriptions were provided or discovered during management group cleanup:" - $subscriptionsFinal | ForEach-Object { Write-Host "Name: $($_.Name), ID: $($_.Id)" } - Write-Host "" - $confirmationText = "I CONFIRM I UNDERSTAND ALL THE RESOURCES IN THE NAMED SUBSCRIPTIONS WILL BE PERMANENTLY DELETED" - Write-Host "WARNING: This operation will permanently DELETE ALL RESOURCE GROUPS in the above subscriptions!" -ForegroundColor Red - Write-Host "If you wish to proceed, type '$confirmationText' to confirm." -ForegroundColor Red - $confirmation = Read-Host "Enter the confirmation text" - if ($confirmation -ne $confirmationText) { - Write-Host "Confirmation not received. Exiting without deleting resource groups." - return - } - Write-Host "WARNING: This operation operation is permanent cannot be reversed!" -ForegroundColor Red - Write-Host "Are you sure you want to proceed? Type 'YES' to delete all your resources..." -ForegroundColor Red - $finalConfirmation = Read-Host "Type 'YES' to confirm" - if ($finalConfirmation -ne "YES") { - Write-Host "Final confirmation not received. Exiting without deleting resource groups." - return - } - Write-Host "Final confirmation received. Proceeding with resource group deletion..." -ForegroundColor Green - } - } - - $subscriptionsFinal | ForEach-Object -Parallel { - $subscription = $_ - Write-Host "Finding resource groups for subscription: $($subscription.Name) (ID: $($subscription.Id))" - - $resourceGroups = (az group list --subscription $subscription.Id) | ConvertFrom-Json + $reverseKeys = $managementGroupsToDelete.Keys | Sort-Object -Descending + + $throttleLimit = $using:throttleLimit + $planMode = $using:planMode + + foreach($depth in $reverseKeys) { + $managementGroups = $managementGroupsToDelete[$depth] + + Write-Host "Deleting management groups at depth: $depth" + + $managementGroups | ForEach-Object -Parallel { + $subscriptionsFound = $using:subscriptionsFound + $subscriptions = (az account management-group subscription show-sub-under-mg --name $_) | ConvertFrom-Json + if ($subscriptions.Count -gt 0) { + Write-Host "Management group has subscriptions: $_" + foreach ($subscription in $subscriptions) { + Write-Host "Removing subscription from management group: $_, subscription: $($subscription.displayName)" + if(-not $subscriptionsProvided) { + $subscriptionsFound.Add( + @{ + Id = $subscription.name + Name = $subscription.displayName + } + ) + } + if($using:planMode) { + Write-Host "(Plan Mode) Would run: az account management-group subscription remove --name $_ --subscription $($subscription.name)" + } else { + az account management-group subscription remove --name $_ --subscription $subscription.name + } + } + } else { + Write-Host "Management group has no subscriptions: $_" + } - if ($resourceGroups.Count -eq 0) { - Write-Host "No resource groups found for subscription: $($subscription.Name) (ID: $($subscription.Id)), skipping." - continue + Write-Host "Deleting management group: $_" + if($using:planMode) { + Write-Host "(Plan Mode) Would run: az account management-group delete --name $_" + } else { + az account management-group delete --name $_ + } + } -ThrottleLimit $using:throttleLimit + } + } -ThrottleLimit $throttleLimit } - Write-Host "Found resource groups for subscription: $($subscription.Name) (ID: $($subscription.Id)), count: $($resourceGroups.Count)" - - $resourceGroupsToDelete = @() - $resourceGroupsToRetainNamePatterns = $using:resourceGroupsToRetainNamePatterns + $subscriptionsFinal = $subscriptionsFound.ToArray() | Sort-Object -Property name -Unique - foreach ($resourceGroup in $resourceGroups) { - $foundMatch = $false - - foreach ($pattern in $resourceGroupsToRetainNamePatterns) { - if ($resourceGroup.name -match $pattern) { - Write-Host "Retaining resource group as it matches the pattern '$pattern': $($resourceGroup.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" -ForegroundColor Yellow - $foundMatch = $true - break + if($subscriptionsFinal.Count -eq 0) { + Write-Host "No subscriptions provided or found, skipping resource group deletion..." -ForegroundColor Yellow + return + } else { + if(-not $bypassConfirmation) { + Write-Host "" + Write-Host "The following Subscriptions were provided or discovered during management group cleanup:" -ForegroundColor DarkBlue + $subscriptionsFinal | ForEach-Object { Write-Host "Name: $($_.Name), ID: $($_.Id)" -ForegroundColor DarkBlue } + Write-Host "" + $continue = Invoke-PromptForConfirmation ` + -message "ALL RESOURCE GROUPS IN THE NAMED SUBSCRIPTIONS WILL BE PERMANENTLY DELETED UNLESS THEY MATCH RETENTION PATTERNS" ` + -initialConfirmationText "I CONFIRM I UNDERSTAND ALL SELECTED RESOURCE GROUPS IN THE NAMED SUBSCRIPTIONS WILL BE PERMANENTLY DELETED" + if(-not $continue) { + Write-Host "Exiting..." + return } } + } + + $subscriptionsFinal | ForEach-Object -Parallel { + $subscription = $_ + Write-Host "Finding resource groups for subscription: $($subscription.Name) (ID: $($subscription.Id))" - if($foundMatch) { + $resourceGroups = (az group list --subscription $subscription.Id) | ConvertFrom-Json + + if ($resourceGroups.Count -eq 0) { + Write-Host "No resource groups found for subscription: $($subscription.Name) (ID: $($subscription.Id)), skipping." continue } - $resourceGroupsToDelete += @{ - ResourceGroupName = $resourceGroup.name - Subscription = $subscription - } - } + Write-Host "Found resource groups for subscription: $($subscription.Name) (ID: $($subscription.Id)), count: $($resourceGroups.Count)" - $shouldRetry = $true + $resourceGroupsToDelete = @() + $resourceGroupsToRetainNamePatterns = $using:resourceGroupsToRetainNamePatterns - while($shouldRetry) { - $shouldRetry = $false - $resourceGroupsToRetry = [System.Collections.Concurrent.ConcurrentBag[hashtable]]::new() - $resourceGroupsToDelete | ForEach-Object -Parallel { - $resourceGroupName = $_.ResourceGroupName - $subscription = $_.Subscription + foreach ($resourceGroup in $resourceGroups) { + $foundMatch = $false - Write-Host "Deleting resource group for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" - $result = az group delete --name $ResourceGroupName --subscription $subscription.Id --yes 2>&1 + foreach ($pattern in $resourceGroupsToRetainNamePatterns) { + if ($resourceGroup.name -match $pattern) { + Write-Host "Retaining resource group as it matches the pattern '$pattern': $($resourceGroup.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" -ForegroundColor Yellow + $foundMatch = $true + break + } + } - if (!$result) { - Write-Host "Deleted resource group for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" - } else { - Write-Host "Delete resource group failed for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" - Write-Host "It will be retried once the other resource groups in the subscription have reported their status." - Write-Verbose "$result" - $retries = $using:resourceGroupsToRetry - $retries.Add($_) + if($foundMatch) { + continue } - } -ThrottleLimit $using:throttleLimit - if($resourceGroupsToRetry.Count -gt 0) { - Write-Host "Some resource groups failed to delete and will be retried in subscription: $($subscription.Name) (ID: $($subscription.Id))" - $shouldRetry = $true - $resourceGroupsToDelete = $resourceGroupsToRetry.ToArray() - } else { - Write-Host "All resource groups deleted successfully in subscription: $($subscription.Name) (ID: $($subscription.Id))." + $resourceGroupsToDelete += @{ + ResourceGroupName = $resourceGroup.name + Subscription = $subscription + } } - } - $subscription = @{ - Id = "b857908d-3f5c-4477-91c1-0fbd08df4e88" - Name = "tester" - } + $shouldRetry = $true + $throttleLimit = $using:throttleLimit + $planMode = $using:planMode - Write-Host "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 + while($shouldRetry) { + $shouldRetry = $false + $resourceGroupsToRetry = [System.Collections.Concurrent.ConcurrentBag[hashtable]]::new() + $resourceGroupsToDelete | ForEach-Object -Parallel { + $resourceGroupName = $_.ResourceGroupName + $subscription = $_.Subscription + + Write-Host "Deleting resource group for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" + $result = $null + if($using:planMode) { + Write-Host "(Plan Mode) Would run: az group delete --name $ResourceGroupName --subscription $($subscription.Id) --yes" + } else { + $result = az group delete --name $ResourceGroupName --subscription $subscription.Id --yes 2>&1 + } - $defenderPlans.value | Where-Object { -not $_.deprecated } | ForEach-Object -Parallel { - $subscription = $using:subscription - if ($_.pricingTier -ne "Free") { - Write-Host "Resetting Microsoft Defender for Cloud Plan to Free for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" - $result = (az security pricing create --name $_.name --tier "Free" --subscription $subscription.Id 2>&1) - if ($result -like "*must be 'Standard'*") { - Write-Host "Resetting Microsoft Defender for Cloud Plan to Standard as Free is not supported for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" - $result = az security pricing create --name $_.name --tier "Standard" --subscription $subscription.Id + if (!$result) { + Write-Host "Deleted resource group for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" + } else { + Write-Host "Delete resource group failed for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" + Write-Host "It will be retried once the other resource groups in the subscription have reported their status." + Write-Verbose "$result" + $retries = $using:resourceGroupsToRetry + $retries.Add($_) + } + } -ThrottleLimit $using:throttleLimit + + if($resourceGroupsToRetry.Count -gt 0) { + Write-Host "Some resource groups failed to delete and will be retried in subscription: $($subscription.Name) (ID: $($subscription.Id))" + $shouldRetry = $true + $resourceGroupsToDelete = $resourceGroupsToRetry.ToArray() + } else { + Write-Host "All resource groups deleted successfully in subscription: $($subscription.Name) (ID: $($subscription.Id))." } - Write-Host "Microsoft Defender for Cloud Plan reset for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" - } else { - Write-Host "Microsoft Defender for Cloud Plan is already set to Free for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id)), skipping." } - } -ThrottleLimit $using:throttleLimit - } -ThrottleLimit $throttleLimit + Write-Host "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.value | Where-Object { -not $_.deprecated } | ForEach-Object -Parallel { + $subscription = $using:subscription + if ($_.pricingTier -ne "Free") { + Write-Host "Resetting Microsoft Defender for Cloud Plan to Free for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" + $result = $null + if($using:planMode) { + Write-Host "(Plan Mode) Would run: az security pricing create --name $($_.name) --tier `"Free`" --subscription $($subscription.Id)" + } else { + $result = (az security pricing create --name $_.name --tier "Free" --subscription $subscription.Id 2>&1) + } + if ($result -like "*must be 'Standard'*") { + Write-Host "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-Host "(Plan Mode) Would run: az security pricing create --name $($_.name) --tier `"Standard`" --subscription $($subscription.Id)" + } else { + $result = az security pricing create --name $_.name --tier "Standard" --subscription $subscription.Id + } + } + Write-Host "Microsoft Defender for Cloud Plan reset for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" + } else { + Write-Host "Microsoft Defender for Cloud Plan is already set to Free for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id)), skipping." + } + } -ThrottleLimit $using:throttleLimit - Write-Host "Cleanup completed." + } -ThrottleLimit $throttleLimit + + Write-Host "Cleanup completed." + } } \ No newline at end of file From f3729dac83ef759ae2dcb598724e945eee52a58e Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 14 Nov 2025 15:30:16 +0000 Subject: [PATCH 4/8] save changes --- src/ALZ/Public/Remove-PlatformLandingZone.ps1 | 302 +++++++++++------- 1 file changed, 187 insertions(+), 115 deletions(-) diff --git a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 index 052ad78..3bc1170 100644 --- a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -120,6 +120,7 @@ function Remove-PlatformLandingZone { [CmdletBinding(SupportsShouldProcess = $true)] param ( [string[]]$managementGroups, + [string]$subscriptionTargetManagementGroup = $null, [string[]]$subscriptions = @(), [string[]]$resourceGroupsToRetainNamePatterns = @( "VisualStudioOnline-" # By default retain Visual Studio Online resource groups created for Azure DevOps billing purposes @@ -129,6 +130,22 @@ function Remove-PlatformLandingZone { [switch]$planMode ) + function Write-ToConsoleLog { + param ( + [string]$Message, + [string]$Level = "INFO", + [System.ConsoleColor]$Color = [System.ConsoleColor]::Yellow, + [switch]$NoNewLine + ) + + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $prefix = "" + if (-not $NoNewLine) { + $Prefix = [System.Environment]::NewLine + } + Write-Host "$Prefix[$timestamp] [$Level] $Message" -ForegroundColor $Color + } + function Get-ManagementGroupChildrenRecursive { param ( [object[]]$managementGroups, @@ -136,6 +153,8 @@ function Remove-PlatformLandingZone { [hashtable]$managementGroupsFound = @{} ) + $managementGroups = $managementGroups | Where-Object { $_.type -eq "Microsoft.Management/managementGroups" } + foreach($managementGroup in $managementGroups) { if(!$managementGroupsFound.ContainsKey($depth)) { $managementGroupsFound[$depth] = @() @@ -146,13 +165,13 @@ function Remove-PlatformLandingZone { $children = $managementGroup.children | Where-Object { $_.type -eq "Microsoft.Management/managementGroups" } if ($children -and $children.Count -gt 0) { - Write-Host "Management group has children: $($managementGroup.name)" + Write-ToConsoleLog "Management group has children: $($managementGroup.name)" -NoNewLine if(!$managementGroupsFound.ContainsKey($depth + 1)) { $managementGroupsFound[$depth + 1] = @() } Get-ManagementGroupChildrenRecursive -managementGroups $children -depth ($depth + 1) -managementGroupsFound $managementGroupsFound } else { - Write-Host "Management group has no children: $($managementGroup.name)" + Write-ToConsoleLog "Management group has no children: $($managementGroup.name)" -NoNewLine } } @@ -179,165 +198,214 @@ function Remove-PlatformLandingZone { [string]$finalConfirmationText = "YES I CONFIRM" ) - Write-Host "WARNING: $message" -ForegroundColor Red - Write-Host "If you wish to proceed, type '$initialConfirmationText' to confirm." -ForegroundColor Red + Write-ToConsoleLog "$message" -Color DarkMagenta -Level "WARNING" + Write-ToConsoleLog "If you wish to proceed, type '$initialConfirmationText' to confirm." -Color DarkMagenta -Level "WARNING" $confirmation = Read-Host "Enter the confirmation text" if ($confirmation -ne $initialConfirmationText) { - Write-Host "Confirmation not received. Exiting without making any changes." + Write-ToConsoleLog "Confirmation not received. Exiting without making any changes." -Color Red -Level "ERROR" return $false } - Write-Host "WARNING: This operation is permanent cannot be reversed!" -ForegroundColor Red - Write-Host "Are you sure you want to proceed? Type '$finalConfirmationText' to perform the highly destructive operation..." -ForegroundColor Red + Write-ToConsoleLog "Initial confirmation received." + Write-ToConsoleLog "WARNING: This operation is permanent cannot be reversed!" -Color Magenta -Level "WARNING" + Write-ToConsoleLog "Are you sure you want to proceed? Type '$finalConfirmationText' to perform the highly destructive operation..." -Color Magenta -Level "WARNING" $confirmation = Read-Host "Enter the final confirmation text" if ($confirmation -ne $finalConfirmationText) { - Write-Host "Final confirmation not received. Exiting without making any changes." + Write-ToConsoleLog "Final confirmation not received. Exiting without making any changes." -Color Red -Level "ERROR" return $false } - Write-Host "Final confirmation received. Proceeding with destructive operation..." -ForegroundColor Green + Write-ToConsoleLog "Final confirmation received. Proceeding with destructive operation..." return $true } + + if ($PSCmdlet.ShouldProcess("Delete Management Groups and Clean Subscriptions", "delete")) { - $funcDef = ${function:Get-ManagementGroupChildrenRecursive}.ToString() + + $managementGroupsProvided = $managementGroups.Count -gt 0 $subscriptionsProvided = $subscriptions.Count -gt 0 - if($subscriptionsProvided) { - Write-Host "Subscriptions have been provided, checking they exist. We will not discover additional subscriptions from management groups..." -ForegroundColor Yellow - } else { - Write-Host "No subscriptions provided, discovering subscriptions from management groups..." -ForegroundColor Yellow + + if(-not $subscriptionsProvided -and -not $managementGroupsProvided) { + Write-ToConsoleLog "No management groups or subscriptions provided, nothing to do. Exiting..." -Color Green + return + } + + if(-not $managementGroupsProvided) { + Write-ToConsoleLog "No management groups provided, skipping..." } $subscriptionsFound = [System.Collections.Concurrent.ConcurrentBag[hashtable]]::new() - foreach($subscription in $subscriptions) { - $subscriptionObject = @{ - Id = Test-IsGuid -StringGuid $subscription ? $subscription : (az account list --all --query "[?name=='$subscription'].id" -o tsv) - Name = Test-IsGuid -StringGuid $subscription ? (az account list --all --query "[?id=='$subscription'].name" -o tsv) : $subscription - } - if(-not $subscriptionObject.Id -or -not $subscriptionObject.Name) { - Write-Host "Subscription not found, skipping: $($subscription.Name) (ID: $($subscription.Id))" -ForegroundColor DarkBlue - continue + if($managementGroupsProvided) { + $managementGroupsFound = @() + foreach($managementGroup in $managementGroups) { + $managementGroup = (az account management-group show --name $managementGroup) | ConvertFrom-Json + + if($null -eq $managementGroup) { + Write-ToConsoleLog "Management group not found: $managementGroup" -Color DarkYellow -Level "WARNING" + continue + } + + $managementGroupsFound += @{ + Name = $managementGroup.name + DisplayName = $managementGroup.displayName + } } - $subscriptionsFound.Add($subscriptionObject) - } - if($managementGroups.Count -eq 0) { - Write-Host "No management groups provided, skipping..." -ForegroundColor Yellow - } else { if(-not $bypassConfirmation) { - Write-Host "" - Write-Host "The following Management Groups will be processed for removal:" -ForegroundColor DarkBlue - $managementGroups | ForEach-Object { Write-Host "Management Group: $_" -ForegroundColor DarkBlue } - Write-Host "" + Write-ToConsoleLog "The following Management Groups will be processed for removal:" -Color DarkBlue + $managementGroupsFound | ForEach-Object { Write-ToConsoleLog "Management Group: $($_.Name) ($($_.DisplayName))" -Color Blue -NoNewLine } $continue = Invoke-PromptForConfirmation ` - -message "ALL THE NAMED MANAGEMENT GROUPS AND THEIR CHILDREN WILL BE PERMANENTLY DELETED" ` - -initialConfirmationText "I CONFIRM I UNDERSTAND ALL THE NAMED MANAGEMENT GROUPS AND THEIR CHILDREN WILL BE PERMANENTLY DELETED" + -message "ALL THE CHILD MANAGEMENT GROUPS OF THE LISTED MANAGEMENTS GROUPS AND THEIR CHILDREN WILL BE PERMANENTLY DELETED" ` + -initialConfirmationText "I CONFIRM I UNDERSTAND ALL THE CHILD MANAGEMENT GROUPS AND THEIR CHILDREN WILL BE PERMANENTLY DELETED" if(-not $continue) { - Write-Host "Exiting..." + Write-ToConsoleLog "Exiting..." return } } - } - - if($managementGroups.Count -ne 0) { - $managementGroups | ForEach-Object -Parallel { - $subscriptionsProvided = $using:subscriptionsProvided - $subscriptionsFound = $using:subscriptionsFound - $managementGroupId = $_ + $funcGetManagementGroupChildrenRecursive = ${function:Get-ManagementGroupChildrenRecursive}.ToString() + $funcWriteToConsoleLog = ${function:Write-ToConsoleLog}.ToString() - Write-Host "Finding management group: $managementGroupId" - $topLevelManagementGroup = (az account management-group show --name $managementGroupId --expand --recurse) | ConvertFrom-Json + if(-not $subscriptionsProvided) { + Write-ToConsoleLog "No subscriptions provided, they will be discovered from the target management group hierarchy..." -Color Yellow + } - $hasChildren = $topLevelManagementGroup.children -and $topLevelManagementGroup.children.Count -gt 0 + if($managementGroupsFound.Count -ne 0) { + $managementGroupsFound | ForEach-Object -Parallel { + $subscriptionsProvided = $using:subscriptionsProvided + $subscriptionsFound = $using:subscriptionsFound + $subscriptionTargetManagementGroup = $using:subscriptionTargetManagementGroup + $funcWriteToConsoleLog = $using:funcWriteToConsoleLog + ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog - $managementGroupsToDelete = @{} + $managementGroupId = $_.Name + $managementGroupDisplayName = $_.DisplayName - if($hasChildren) { - ${function:Get-ManagementGroupChildrenRecursive} = $using:funcDef - $managementGroupsToDelete = Get-ManagementGroupChildrenRecursive -managementGroups @($topLevelManagementGroup.children) - } else { - Write-Host "Management group has no children: $managementGroupId" - } + Write-ToConsoleLog "Finding management group: $managementGroupId ($managementGroupDisplayName)" -NoNewLine + $topLevelManagementGroup = (az account management-group show --name $managementGroupId --expand --recurse) | ConvertFrom-Json - $reverseKeys = $managementGroupsToDelete.Keys | Sort-Object -Descending + $hasChildren = $topLevelManagementGroup.children -and $topLevelManagementGroup.children.Count -gt 0 - $throttleLimit = $using:throttleLimit - $planMode = $using:planMode + $managementGroupsToDelete = @{} - foreach($depth in $reverseKeys) { - $managementGroups = $managementGroupsToDelete[$depth] - - Write-Host "Deleting management groups at depth: $depth" + if($hasChildren) { + ${function:Get-ManagementGroupChildrenRecursive} = $using:funcGetManagementGroupChildrenRecursive + $managementGroupsToDelete = Get-ManagementGroupChildrenRecursive -managementGroups @($topLevelManagementGroup.children) + } else { + Write-ToConsoleLog "Management group has no children: $managementGroupId ($managementGroupDisplayName)" -NoNewLine + } - $managementGroups | ForEach-Object -Parallel { - $subscriptionsFound = $using:subscriptionsFound - $subscriptions = (az account management-group subscription show-sub-under-mg --name $_) | ConvertFrom-Json - if ($subscriptions.Count -gt 0) { - Write-Host "Management group has subscriptions: $_" - foreach ($subscription in $subscriptions) { - Write-Host "Removing subscription from management group: $_, subscription: $($subscription.displayName)" - if(-not $subscriptionsProvided) { - $subscriptionsFound.Add( - @{ - Id = $subscription.name - Name = $subscription.displayName + $reverseKeys = $managementGroupsToDelete.Keys | Sort-Object -Descending + + $throttleLimit = $using:throttleLimit + $planMode = $using:planMode + + foreach($depth in $reverseKeys) { + $managementGroups = $managementGroupsToDelete[$depth] + + Write-ToConsoleLog "Deleting management groups at depth: $depth" -NoNewLine + + $managementGroups | ForEach-Object -Parallel { + $subscriptionsFound = $using:subscriptionsFound + $subscriptionTargetManagementGroup = $using:subscriptionTargetManagementGroup + $funcWriteToConsoleLog = $using:funcWriteToConsoleLog + ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog + + $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 + foreach ($subscription in $subscriptions) { + Write-ToConsoleLog "Removing subscription from management group: $_, subscription: $($subscription.displayName)" -NoNewLine + if(-not $subscriptionsProvided) { + $subscriptionsFound.Add( + @{ + Id = $subscription.name + Name = $subscription.displayName + } + ) + } + + if($subscriptionTargetManagementGroup) { + Write-ToConsoleLog "Moving subscription to target management group: $($subscriptionTargetManagementGroup), subscription: $($subscription.displayName)" -NoNewLine + if($using:planMode) { + Write-ToConsoleLog "(Plan Mode) Would run: az account management-group subscription add --name $($subscriptionTargetManagementGroup) --subscription $($subscription.name)" -NoNewLine -Color Gray + } else { + az account management-group subscription add --name $subscriptionTargetManagementGroup --subscription $subscription.name } - ) - } - if($using:planMode) { - Write-Host "(Plan Mode) Would run: az account management-group subscription remove --name $_ --subscription $($subscription.name)" - } else { - az account management-group subscription remove --name $_ --subscription $subscription.name + } else { + if($using:planMode) { + Write-ToConsoleLog "(Plan Mode) Would run: az account management-group subscription remove --name $_ --subscription $($subscription.name)" -NoNewLine -Color Gray + } else { + az account management-group subscription remove --name $_ --subscription $subscription.name + } + } } + } else { + Write-ToConsoleLog "Management group has no subscriptions: $_" -NoNewline } - } else { - Write-Host "Management group has no subscriptions: $_" - } - Write-Host "Deleting management group: $_" - if($using:planMode) { - Write-Host "(Plan Mode) Would run: az account management-group delete --name $_" - } else { - az account management-group delete --name $_ - } - } -ThrottleLimit $using:throttleLimit - } - } -ThrottleLimit $throttleLimit + Write-ToConsoleLog "Deleting management group: $_" -NoNewline + if($using:planMode) { + Write-ToConsoleLog "(Plan Mode) Would run: az account management-group delete --name $_" -NoNewline -Color Gray + } else { + az account management-group delete --name $_ + } + } -ThrottleLimit $using:throttleLimit + } + } -ThrottleLimit $throttleLimit + } + } + + if($subscriptionsProvided) { + Write-ToConsoleLog "Checking the provided subscriptions exist..." + } + + foreach($subscription in $subscriptions) { + $subscriptionObject = @{ + Id = Test-IsGuid -StringGuid $subscription ? $subscription : (az account list --all --query "[?name=='$subscription'].id" -o tsv) + Name = Test-IsGuid -StringGuid $subscription ? (az account list --all --query "[?id=='$subscription'].name" -o tsv) : $subscription + } + if(-not $subscriptionObject.Id -or -not $subscriptionObject.Name) { + Write-ToConsoleLog "Subscription not found, skipping: $($subscription.Name) (ID: $($subscription.Id))" -Color DarkYellow -Level "WARNING" + continue + } + $subscriptionsFound.Add($subscriptionObject) } $subscriptionsFinal = $subscriptionsFound.ToArray() | Sort-Object -Property name -Unique if($subscriptionsFinal.Count -eq 0) { - Write-Host "No subscriptions provided or found, skipping resource group deletion..." -ForegroundColor Yellow + Write-ToConsoleLog "No subscriptions provided or found, skipping resource group deletion..." return } else { if(-not $bypassConfirmation) { - Write-Host "" - Write-Host "The following Subscriptions were provided or discovered during management group cleanup:" -ForegroundColor DarkBlue - $subscriptionsFinal | ForEach-Object { Write-Host "Name: $($_.Name), ID: $($_.Id)" -ForegroundColor DarkBlue } - Write-Host "" + Write-ToConsoleLog "The following Subscriptions were provided or discovered during management group cleanup:" -Color DarkBlue + $subscriptionsFinal | ForEach-Object { Write-ToConsoleLog "Name: $($_.Name), ID: $($_.Id)" -Color DarkBlue -NoNewline } $continue = Invoke-PromptForConfirmation ` - -message "ALL RESOURCE GROUPS IN THE NAMED SUBSCRIPTIONS WILL BE PERMANENTLY DELETED UNLESS THEY MATCH RETENTION PATTERNS" ` + -message "ALL RESOURCE GROUPS IN THE LISTED SUBSCRIPTIONS WILL BE PERMANENTLY DELETED UNLESS THEY MATCH RETENTION PATTERNS" ` -initialConfirmationText "I CONFIRM I UNDERSTAND ALL SELECTED RESOURCE GROUPS IN THE NAMED SUBSCRIPTIONS WILL BE PERMANENTLY DELETED" if(-not $continue) { - Write-Host "Exiting..." + Write-ToConsoleLog "Exiting..." return } } } $subscriptionsFinal | ForEach-Object -Parallel { + $funcWriteToConsoleLog = $using:funcWriteToConsoleLog + ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog + $subscription = $_ - Write-Host "Finding resource groups for subscription: $($subscription.Name) (ID: $($subscription.Id))" + Write-ToConsoleLog "Finding resource groups for subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewline $resourceGroups = (az group list --subscription $subscription.Id) | ConvertFrom-Json if ($resourceGroups.Count -eq 0) { - Write-Host "No resource groups found for subscription: $($subscription.Name) (ID: $($subscription.Id)), skipping." + Write-ToConsoleLog "No resource groups found for subscription: $($subscription.Name) (ID: $($subscription.Id)), skipping." -NoNewline continue } - Write-Host "Found resource groups for subscription: $($subscription.Name) (ID: $($subscription.Id)), count: $($resourceGroups.Count)" + Write-ToConsoleLog "Found resource groups for subscription: $($subscription.Name) (ID: $($subscription.Id)), count: $($resourceGroups.Count)" -NoNewline $resourceGroupsToDelete = @() $resourceGroupsToRetainNamePatterns = $using:resourceGroupsToRetainNamePatterns @@ -347,7 +415,7 @@ function Remove-PlatformLandingZone { foreach ($pattern in $resourceGroupsToRetainNamePatterns) { if ($resourceGroup.name -match $pattern) { - Write-Host "Retaining resource group as it matches the pattern '$pattern': $($resourceGroup.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" -ForegroundColor Yellow + Write-ToConsoleLog "Retaining resource group as it matches the pattern '$pattern': $($resourceGroup.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewLine $foundMatch = $true break } @@ -372,66 +440,70 @@ function Remove-PlatformLandingZone { $shouldRetry = $false $resourceGroupsToRetry = [System.Collections.Concurrent.ConcurrentBag[hashtable]]::new() $resourceGroupsToDelete | ForEach-Object -Parallel { + $funcWriteToConsoleLog = $using:funcWriteToConsoleLog + ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog $resourceGroupName = $_.ResourceGroupName $subscription = $_.Subscription - Write-Host "Deleting resource group for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" + Write-ToConsoleLog "Deleting resource group for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" -NoNewLine $result = $null if($using:planMode) { - Write-Host "(Plan Mode) Would run: az group delete --name $ResourceGroupName --subscription $($subscription.Id) --yes" + Write-ToConsoleLog "(Plan Mode) Would run: az group delete --name $ResourceGroupName --subscription $($subscription.Id) --yes" -NoNewLine -Color Gray } else { $result = az group delete --name $ResourceGroupName --subscription $subscription.Id --yes 2>&1 } if (!$result) { - Write-Host "Deleted resource group for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" + Write-ToConsoleLog "Deleted resource group for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" -NoNewLine } else { - Write-Host "Delete resource group failed for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" - Write-Host "It will be retried once the other resource groups in the subscription have reported their status." - Write-Verbose "$result" + Write-ToConsoleLog "Delete resource group failed for subscription: $($subscription.Name) (ID: $($subscription.Id)), resource group: $($ResourceGroupName)" -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($_) } } -ThrottleLimit $using:throttleLimit if($resourceGroupsToRetry.Count -gt 0) { - Write-Host "Some resource groups failed to delete and will be retried in subscription: $($subscription.Name) (ID: $($subscription.Id))" + Write-ToConsoleLog "Some resource groups failed to delete and will be retried in subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewLine $shouldRetry = $true $resourceGroupsToDelete = $resourceGroupsToRetry.ToArray() } else { - Write-Host "All resource groups deleted successfully in subscription: $($subscription.Name) (ID: $($subscription.Id))." + Write-ToConsoleLog "All resource groups deleted successfully in subscription: $($subscription.Name) (ID: $($subscription.Id))." -NoNewLine } } - Write-Host "Checking for Microsoft Defender for Cloud Plans to reset in subscription: $($subscription.Name) (ID: $($subscription.Id))" + 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.value | Where-Object { -not $_.deprecated } | ForEach-Object -Parallel { $subscription = $using:subscription + $funcWriteToConsoleLog = $using:funcWriteToConsoleLog + ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog + if ($_.pricingTier -ne "Free") { - Write-Host "Resetting Microsoft Defender for Cloud Plan to Free for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" + Write-ToConsoleLog "Resetting Microsoft Defender for Cloud Plan to Free for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewLine $result = $null if($using:planMode) { - Write-Host "(Plan Mode) Would run: az security pricing create --name $($_.name) --tier `"Free`" --subscription $($subscription.Id)" + Write-ToConsoleLog "(Plan Mode) Would run: az security pricing create --name $($_.name) --tier `"Free`" --subscription $($subscription.Id)" -NoNewLine -Color Gray } else { $result = (az security pricing create --name $_.name --tier "Free" --subscription $subscription.Id 2>&1) } if ($result -like "*must be 'Standard'*") { - Write-Host "Resetting Microsoft Defender for Cloud Plan to Standard as Free is not supported for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" + 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 if($using:planMode) { - Write-Host "(Plan Mode) Would run: az security pricing create --name $($_.name) --tier `"Standard`" --subscription $($subscription.Id)" + Write-ToConsoleLog "(Plan Mode) Would run: az security pricing create --name $($_.name) --tier `"Standard`" --subscription $($subscription.Id)" -NoNewLine -Color Gray } else { $result = az security pricing create --name $_.name --tier "Standard" --subscription $subscription.Id } } - Write-Host "Microsoft Defender for Cloud Plan reset for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" + Write-ToConsoleLog "Microsoft Defender for Cloud Plan reset for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewLine } else { - Write-Host "Microsoft Defender for Cloud Plan is already set to Free for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id)), skipping." + Write-ToConsoleLog "Microsoft Defender for Cloud Plan is already set to Free for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id)), skipping."-NoNewLine } } -ThrottleLimit $using:throttleLimit } -ThrottleLimit $throttleLimit - Write-Host "Cleanup completed." + Write-ToConsoleLog "Cleanup completed." -Color Green } } \ No newline at end of file From 7178552baa9d134c57feacb119c44b5d0241f5d8 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 14 Nov 2025 17:56:03 +0000 Subject: [PATCH 5/8] complete testing and improvements --- src/ALZ/Public/Remove-PlatformLandingZone.ps1 | 296 +++++++++++++----- 1 file changed, 218 insertions(+), 78 deletions(-) diff --git a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 index 3bc1170..4564f9e 100644 --- a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -5,16 +5,16 @@ function Remove-PlatformLandingZone { .DESCRIPTION The Remove-PlatformLandingZone function performs a comprehensive cleanup of Azure Landing Zone platform resources. - It recursively deletes management groups, removes subscriptions from management groups, and deletes all resource + It can delete management group hierarchies, remove subscriptions from management groups, and delete all resource groups within the affected subscriptions. This function is primarily designed for testing and cleanup scenarios. The function operates in the following sequence: - 1. Validates provided subscriptions (if any) exist in Azure - 2. Processes each specified management group, recursively discovering child management groups - 3. Removes subscriptions from management groups (starting from the deepest level) - 4. Discovers subscriptions from management groups (if not explicitly provided) - 5. Deletes management groups in reverse depth order (children before parents) - 6. Requests confirmation before deleting resource groups (unless bypassed) + 1. Validates provided management groups and subscriptions (if any) exist in Azure + 2. Prompts for confirmation (unless bypassed or in plan mode) + 3. Processes each specified management group, recursively discovering child management groups + 4. Removes subscriptions from management groups and optionally moves them to a target management group + 5. Discovers subscriptions from management groups (if not explicitly provided) + 6. Deletes management groups in reverse depth order (children before parents) 7. Deletes all resource groups in the discovered/specified subscriptions (excluding retention patterns) 8. Resets Microsoft Defender for Cloud plans to Free tier @@ -23,10 +23,22 @@ 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. The function will delete these management groups and - all their child management groups recursively. Subscriptions under these management groups will also be - discovered (unless subscriptions are explicitly provided via the -subscriptions parameter). - This parameter is mandatory. + An array of management group IDs or names to process. 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. + + .PARAMETER deleteTargetManagementGroups + A switch parameter that causes the target management groups specified in -managementGroups to be deleted along + with all their children. By default, only management groups one level below the targets are deleted, preserving + the target management groups themselves. + Default: $false (preserve target management groups) + + .PARAMETER subscriptionsTargetManagementGroup + The management group ID or name where subscriptions should be moved after being removed from their current + management groups. If not specified, subscriptions are removed from management groups without being reassigned. + This is useful for maintaining subscription organization during cleanup operations. + Default: $null (subscriptions are not reassigned) .PARAMETER subscriptions An optional array of subscription IDs or names to process for resource group deletion. If provided, the @@ -42,48 +54,84 @@ function Remove-PlatformLandingZone { Default: @("VisualStudioOnline-") - Retains Azure DevOps billing resource groups .PARAMETER bypassConfirmation - A switch parameter that bypasses the interactive confirmation prompts before deleting resource groups. - When specified, the function will proceed with resource group deletion without asking for user confirmation. - WARNING: Use this parameter with extreme caution as it eliminates safety checks. + 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 management groups and resource groups. Higher values may improve performance but increase API throttling risk and resource consumption. 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-PlatformLandingZone -managementGroups @("alz-platform", "alz-landingzones") - Removes the specified management groups and all their children, discovers subscriptions from those management - groups, prompts for confirmation, then deletes all resource groups in the discovered subscriptions (except - those matching retention patterns). + Removes all child management groups one level below "alz-platform" and "alz-landingzones", discovers + subscriptions from those management groups, prompts for confirmation, then deletes all resource groups + in the discovered subscriptions (except those matching retention patterns). + + .EXAMPLE + Remove-PlatformLandingZone -managementGroups @("alz-test") -deleteTargetManagementGroups + + Deletes the "alz-test" management group itself along with all its children, rather than just deleting + one level below it. .EXAMPLE Remove-PlatformLandingZone -managementGroups @("mg-dev") -subscriptions @("Sub-Dev-001", "Sub-Dev-002") - Removes the "mg-dev" management group and deletes resource groups only from the two explicitly specified - subscriptions. No additional subscriptions will be discovered from the management group. + Processes the "mg-dev" management group hierarchy and deletes resource groups only from the two explicitly + specified subscriptions. No additional subscriptions will be discovered from the management group. .EXAMPLE - Remove-PlatformLandingZone -managementGroups @("alz-test") -bypassConfirmation + Remove-PlatformLandingZone -managementGroups @("alz-test") -subscriptionsTargetManagementGroup "mg-tenant-root" - Removes the management group and deletes all resource groups without prompting for confirmation. - USE WITH EXTREME CAUTION! + Removes child management groups and moves all discovered subscriptions to the "mg-tenant-root" management + group instead of leaving them orphaned. + + .EXAMPLE + Remove-PlatformLandingZone -managementGroups @("alz-dev") -planMode + + Runs in plan mode (dry run) to show what would be deleted without making any actual changes. Useful for + validating the scope before executing. + + .EXAMPLE + Remove-PlatformLandingZone -managementGroups @("alz-test") -bypassConfirmation -bypassConfirmationTimeoutSeconds 60 + + Bypasses interactive confirmation prompts but waits 60 seconds before proceeding, allowing time to cancel + by pressing any key. USE WITH EXTREME CAUTION! .EXAMPLE Remove-PlatformLandingZone -managementGroups @("alz-prod") -resourceGroupsToRetainNamePatterns @("VisualStudioOnline-", "RG-Critical-", "NetworkWatcherRG") - Removes the management group but retains resource groups matching any of the specified patterns. This example - preserves Azure DevOps billing resources, critical resource groups, and Network Watcher resource groups. + Removes management group hierarchy but retains resource groups matching any of the specified patterns. + This example preserves Azure DevOps billing resources, critical resource groups, and Network Watcher resource groups. .EXAMPLE $subs = @("12345678-1234-1234-1234-123456789012", "87654321-4321-4321-4321-210987654321") Remove-PlatformLandingZone -managementGroups @("alz-test") -subscriptions $subs -throttleLimit 5 - Removes the management group and processes only the specified subscriptions (by GUID) with reduced parallelism - to minimize API throttling. + Processes the management group hierarchy and only the specified subscriptions (by GUID) with reduced + parallelism to minimize API throttling. + + .EXAMPLE + Remove-PlatformLandingZone -subscriptions @("Sub-Test-001") + + Skips management group processing entirely and only deletes resource groups from the specified subscription. + This is useful when you want to clean subscriptions without touching the management group structure. .NOTES This function uses Azure CLI commands and requires: @@ -94,6 +142,8 @@ function Remove-PlatformLandingZone { * Contributor or Owner at the subscription scope for resource group deletions * Security Admin for resetting Microsoft Defender for Cloud plans + The function supports PowerShell's ShouldProcess pattern and respects -WhatIf and -Confirm parameters. + The function uses parallel processing with ForEach-Object -Parallel to improve performance when handling multiple management groups, subscriptions, and resource groups. The default throttle limit is 11. @@ -104,9 +154,19 @@ function Remove-PlatformLandingZone { The function automatically resets Microsoft Defender for Cloud plans to the Free tier for all processed subscriptions. Plans that don't support the Free tier will be set to Standard tier instead. + Management group deletion behavior: + - By default: Deletes management groups one level below the specified targets + - With -deleteTargetManagementGroups: Deletes the target management groups and all their children + Subscription discovery behavior: - If -subscriptions is provided: Only those subscriptions are processed; no discovery occurs - If -subscriptions is empty: Subscriptions are discovered from management groups during cleanup + - If -subscriptionsTargetManagementGroup is specified: Subscriptions are moved to that management group + + Plan mode behavior: + - All Azure CLI commands are displayed but not executed + - Useful for validating scope and understanding impact before actual execution + - Combine with -bypassConfirmation for fully automated dry runs .LINK https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/ @@ -120,12 +180,14 @@ function Remove-PlatformLandingZone { [CmdletBinding(SupportsShouldProcess = $true)] param ( [string[]]$managementGroups, - [string]$subscriptionTargetManagementGroup = $null, + [switch]$deleteTargetManagementGroups, + [string]$subscriptionsTargetManagementGroup = $null, [string[]]$subscriptions = @(), [string[]]$resourceGroupsToRetainNamePatterns = @( "VisualStudioOnline-" # By default retain Visual Studio Online resource groups created for Azure DevOps billing purposes ), [switch]$bypassConfirmation, + [int]$bypassConfirmationTimeoutSeconds = 30, [int]$throttleLimit = 11, [switch]$planMode ) @@ -134,16 +196,45 @@ function Remove-PlatformLandingZone { param ( [string]$Message, [string]$Level = "INFO", - [System.ConsoleColor]$Color = [System.ConsoleColor]::Yellow, - [switch]$NoNewLine + [System.ConsoleColor]$Color = [System.ConsoleColor]::Blue, + [switch]$NoNewLine, + [switch]$Overwrite, + [switch]$IsError, + [switch]$IsWarning, + [switch]$IsSuccess ) + $isDefaultColor = $Color -eq [System.ConsoleColor]::Blue + + if($IsError) { + $Level = "ERROR" + } elseif ($IsWarning) { + $Level = "WARNING" + } elseif ($IsSuccess) { + $Level = "SUCCESS" + } + + 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 + } + } + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $prefix = "" - if (-not $NoNewLine) { - $Prefix = [System.Environment]::NewLine + + if ($Overwrite) { + $prefix = "`r" + } else { + if (-not $NoNewLine) { + $prefix = [System.Environment]::NewLine + } } - Write-Host "$Prefix[$timestamp] [$Level] $Message" -ForegroundColor $Color + Write-Host "$prefix[$timestamp] [$Level] $Message" -ForegroundColor $Color -NoNewline:$Overwrite.IsPresent } function Get-ManagementGroupChildrenRecursive { @@ -198,65 +289,111 @@ function Remove-PlatformLandingZone { [string]$finalConfirmationText = "YES I CONFIRM" ) - Write-ToConsoleLog "$message" -Color DarkMagenta -Level "WARNING" - Write-ToConsoleLog "If you wish to proceed, type '$initialConfirmationText' to confirm." -Color DarkMagenta -Level "WARNING" + Write-ToConsoleLog "$message" -IsWarning + Write-ToConsoleLog "If you wish to proceed, type '$initialConfirmationText' to confirm." -IsWarning $confirmation = Read-Host "Enter the confirmation text" if ($confirmation -ne $initialConfirmationText) { - Write-ToConsoleLog "Confirmation not received. Exiting without making any changes." -Color Red -Level "ERROR" + Write-ToConsoleLog "Confirmation not received. Exiting without making any changes." -IsError return $false } - Write-ToConsoleLog "Initial confirmation received." - Write-ToConsoleLog "WARNING: This operation is permanent cannot be reversed!" -Color Magenta -Level "WARNING" - Write-ToConsoleLog "Are you sure you want to proceed? Type '$finalConfirmationText' to perform the highly destructive operation..." -Color Magenta -Level "WARNING" + Write-ToConsoleLog "Initial confirmation received." -IsSuccess + Write-ToConsoleLog "WARNING: This operation is permanent 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" if ($confirmation -ne $finalConfirmationText) { - Write-ToConsoleLog "Final confirmation not received. Exiting without making any changes." -Color Red -Level "ERROR" + Write-ToConsoleLog "Final confirmation not received. Exiting without making any changes." -IsError return $false } - Write-ToConsoleLog "Final confirmation received. Proceeding with destructive operation..." + Write-ToConsoleLog "Final confirmation received. Proceeding with destructive operation..." -IsSuccess return $true } + if ($PSCmdlet.ShouldProcess("Delete Management Groups and Clean Subscriptions", "delete")) { + if($bypassConfirmation) { + Write-ToConsoleLog "Bypass confirmation enabled, proceeding without prompts..." -IsWarning + Write-ToConsoleLog "This is a highly destructive operation that will permanently delete Azure resources!" -IsWarning + Write-ToConsoleLog "We are waiting $bypassConfirmationTimeoutSeconds seconds to allow for cancellation. Press any key to cancel..." -IsWarning - if ($PSCmdlet.ShouldProcess("Delete Management Groups and Clean Subscriptions", "delete")) { + $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 $managementGroupsProvided = $managementGroups.Count -gt 0 $subscriptionsProvided = $subscriptions.Count -gt 0 if(-not $subscriptionsProvided -and -not $managementGroupsProvided) { - Write-ToConsoleLog "No management groups or subscriptions provided, nothing to do. Exiting..." -Color Green + Write-ToConsoleLog "No management groups or subscriptions provided, nothing to do. Exiting..." -IsError return } if(-not $managementGroupsProvided) { - Write-ToConsoleLog "No management groups provided, skipping..." + Write-ToConsoleLog "No management groups provided, skipping..." -IsWarning } $subscriptionsFound = [System.Collections.Concurrent.ConcurrentBag[hashtable]]::new() if($managementGroupsProvided) { $managementGroupsFound = @() + + if($subscriptionsTargetManagementGroup) { + Write-ToConsoleLog "Validating target management group for subscriptions: $subscriptionsTargetManagementGroup" + + $managementGroupObject = (az account management-group show --name $subscriptionsTargetManagementGroup) | ConvertFrom-Json + if($null -eq $managementGroupObject) { + Write-ToConsoleLog "Target management group for subscriptions not found: $subscriptionsTargetManagementGroup" -IsError + return + } + + Write-ToConsoleLog "Subscriptions removed from management groups will be moved to target management group: $($managementGroupObject.name) ($($managementGroupObject.displayName))" + } + + Write-ToConsoleLog "Validating provided management groups..." foreach($managementGroup in $managementGroups) { - $managementGroup = (az account management-group show --name $managementGroup) | ConvertFrom-Json + $managementGroupObject = (az account management-group show --name $managementGroup) | ConvertFrom-Json - if($null -eq $managementGroup) { - Write-ToConsoleLog "Management group not found: $managementGroup" -Color DarkYellow -Level "WARNING" + if($null -eq $managementGroupObject) { + Write-ToConsoleLog "Management group not found: $managementGroup" -IsWarning continue } $managementGroupsFound += @{ - Name = $managementGroup.name - DisplayName = $managementGroup.displayName + Name = $managementGroupObject.name + DisplayName = $managementGroupObject.displayName } } + if($managementGroupsFound.Count -eq 0) { + Write-ToConsoleLog "No valid management groups found from the provided list, exiting..." -IsError + return + } + if(-not $bypassConfirmation) { - Write-ToConsoleLog "The following Management Groups will be processed for removal:" -Color DarkBlue - $managementGroupsFound | ForEach-Object { Write-ToConsoleLog "Management Group: $($_.Name) ($($_.DisplayName))" -Color Blue -NoNewLine } + Write-ToConsoleLog "The following Management Groups will be processed for removal:" + $managementGroupsFound | ForEach-Object { Write-ToConsoleLog "Management Group: $($_.Name) ($($_.DisplayName))" -NoNewLine } + $warningMessage = "ALL THE MANAGEMENT GROUP STRUCTURES ONE LEVEL BELOW THE LISTED MANAGEMENT GROUPS WILL BE PERMANENTLY DELETED" + $confirmationText = "I CONFIRM I UNDERSTAND ALL THE MANAGEMENT GROUP STRUCTURES ONE LEVEL BELOW THE LISTED MANAGEMENT GROUPS WILL BE PERMANENTLY DELETED" + if($deleteTargetManagementGroups) { + $warningMessage = "ALL THE LISTED MANAGEMENTS GROUPS AND THEIR CHILDREN WILL BE PERMANENTLY DELETED" + $confirmationText = "I CONFIRM I UNDERSTAND ALL THE MANAGEMENT GROUPS AND THEIR CHILDREN WILL BE PERMANENTLY DELETED" + } $continue = Invoke-PromptForConfirmation ` - -message "ALL THE CHILD MANAGEMENT GROUPS OF THE LISTED MANAGEMENTS GROUPS AND THEIR CHILDREN WILL BE PERMANENTLY DELETED" ` - -initialConfirmationText "I CONFIRM I UNDERSTAND ALL THE CHILD MANAGEMENT GROUPS AND THEIR CHILDREN WILL BE PERMANENTLY DELETED" + -message $warningMessage ` + -initialConfirmationText $confirmationText if(-not $continue) { Write-ToConsoleLog "Exiting..." return @@ -267,14 +404,15 @@ function Remove-PlatformLandingZone { $funcWriteToConsoleLog = ${function:Write-ToConsoleLog}.ToString() if(-not $subscriptionsProvided) { - Write-ToConsoleLog "No subscriptions provided, they will be discovered from the target management group hierarchy..." -Color Yellow + Write-ToConsoleLog "No subscriptions provided, they will be discovered from the target management group hierarchy..." } if($managementGroupsFound.Count -ne 0) { $managementGroupsFound | ForEach-Object -Parallel { $subscriptionsProvided = $using:subscriptionsProvided $subscriptionsFound = $using:subscriptionsFound - $subscriptionTargetManagementGroup = $using:subscriptionTargetManagementGroup + $subscriptionsTargetManagementGroup = $using:subscriptionsTargetManagementGroup + $deleteTargetManagementGroups = $using:deleteTargetManagementGroups $funcWriteToConsoleLog = $using:funcWriteToConsoleLog ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog @@ -288,9 +426,11 @@ function Remove-PlatformLandingZone { $managementGroupsToDelete = @{} - if($hasChildren) { + $targetManagementGroups = $deleteTargetManagementGroups ? @($topLevelManagementGroup) : @($topLevelManagementGroup.children) + + if($hasChildren -or $deleteTargetManagementGroups) { ${function:Get-ManagementGroupChildrenRecursive} = $using:funcGetManagementGroupChildrenRecursive - $managementGroupsToDelete = Get-ManagementGroupChildrenRecursive -managementGroups @($topLevelManagementGroup.children) + $managementGroupsToDelete = Get-ManagementGroupChildrenRecursive -managementGroups @($targetManagementGroups) } else { Write-ToConsoleLog "Management group has no children: $managementGroupId ($managementGroupDisplayName)" -NoNewLine } @@ -307,7 +447,7 @@ function Remove-PlatformLandingZone { $managementGroups | ForEach-Object -Parallel { $subscriptionsFound = $using:subscriptionsFound - $subscriptionTargetManagementGroup = $using:subscriptionTargetManagementGroup + $subscriptionsTargetManagementGroup = $using:subscriptionsTargetManagementGroup $funcWriteToConsoleLog = $using:funcWriteToConsoleLog ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog @@ -325,18 +465,18 @@ function Remove-PlatformLandingZone { ) } - if($subscriptionTargetManagementGroup) { - Write-ToConsoleLog "Moving subscription to target management group: $($subscriptionTargetManagementGroup), subscription: $($subscription.displayName)" -NoNewLine + if($subscriptionsTargetManagementGroup) { + Write-ToConsoleLog "Moving subscription to target management group: $($subscriptionsTargetManagementGroup), subscription: $($subscription.displayName)" -NoNewLine if($using:planMode) { - Write-ToConsoleLog "(Plan Mode) Would run: az account management-group subscription add --name $($subscriptionTargetManagementGroup) --subscription $($subscription.name)" -NoNewLine -Color Gray + Write-ToConsoleLog "(Plan Mode) Would run: az account management-group subscription add --name $($subscriptionsTargetManagementGroup) --subscription $($subscription.name)" -NoNewLine -Color Gray } else { - az account management-group subscription add --name $subscriptionTargetManagementGroup --subscription $subscription.name + az account management-group subscription add --name $subscriptionsTargetManagementGroup --subscription $subscription.name | Out-Null } } else { if($using:planMode) { Write-ToConsoleLog "(Plan Mode) Would run: az account management-group subscription remove --name $_ --subscription $($subscription.name)" -NoNewLine -Color Gray } else { - az account management-group subscription remove --name $_ --subscription $subscription.name + az account management-group subscription remove --name $_ --subscription $subscription.name | Out-Null } } } @@ -348,7 +488,7 @@ function Remove-PlatformLandingZone { if($using:planMode) { Write-ToConsoleLog "(Plan Mode) Would run: az account management-group delete --name $_" -NoNewline -Color Gray } else { - az account management-group delete --name $_ + az account management-group delete --name $_ | Out-Null } } -ThrottleLimit $using:throttleLimit } @@ -358,29 +498,29 @@ function Remove-PlatformLandingZone { if($subscriptionsProvided) { Write-ToConsoleLog "Checking the provided subscriptions exist..." - } - foreach($subscription in $subscriptions) { - $subscriptionObject = @{ - Id = Test-IsGuid -StringGuid $subscription ? $subscription : (az account list --all --query "[?name=='$subscription'].id" -o tsv) - Name = Test-IsGuid -StringGuid $subscription ? (az account list --all --query "[?id=='$subscription'].name" -o tsv) : $subscription - } - if(-not $subscriptionObject.Id -or -not $subscriptionObject.Name) { - Write-ToConsoleLog "Subscription not found, skipping: $($subscription.Name) (ID: $($subscription.Id))" -Color DarkYellow -Level "WARNING" - continue + foreach($subscription in $subscriptions) { + $subscriptionObject = @{ + Id = Test-IsGuid -StringGuid $subscription ? $subscription : (az account list --all --query "[?name=='$subscription'].id" -o tsv) + Name = Test-IsGuid -StringGuid $subscription ? (az account list --all --query "[?id=='$subscription'].name" -o tsv) : $subscription + } + if(-not $subscriptionObject.Id -or -not $subscriptionObject.Name) { + Write-ToConsoleLog "Subscription not found, skipping: $($subscription.Name) (ID: $($subscription.Id))" -IsWarning + continue + } + $subscriptionsFound.Add($subscriptionObject) } - $subscriptionsFound.Add($subscriptionObject) } $subscriptionsFinal = $subscriptionsFound.ToArray() | Sort-Object -Property name -Unique if($subscriptionsFinal.Count -eq 0) { - Write-ToConsoleLog "No subscriptions provided or found, skipping resource group deletion..." + Write-ToConsoleLog "No subscriptions provided or found, skipping resource group deletion..." -IsWarning return } else { if(-not $bypassConfirmation) { - Write-ToConsoleLog "The following Subscriptions were provided or discovered during management group cleanup:" -Color DarkBlue - $subscriptionsFinal | ForEach-Object { Write-ToConsoleLog "Name: $($_.Name), ID: $($_.Id)" -Color DarkBlue -NoNewline } + Write-ToConsoleLog "The following Subscriptions were provided or discovered during management group cleanup:" + $subscriptionsFinal | ForEach-Object { Write-ToConsoleLog "Name: $($_.Name), ID: $($_.Id)" -NoNewline } $continue = Invoke-PromptForConfirmation ` -message "ALL RESOURCE GROUPS IN THE LISTED SUBSCRIPTIONS WILL BE PERMANENTLY DELETED UNLESS THEY MATCH RETENTION PATTERNS" ` -initialConfirmationText "I CONFIRM I UNDERSTAND ALL SELECTED RESOURCE GROUPS IN THE NAMED SUBSCRIPTIONS WILL BE PERMANENTLY DELETED" @@ -498,12 +638,12 @@ function Remove-PlatformLandingZone { } Write-ToConsoleLog "Microsoft Defender for Cloud Plan reset for plan: $($_.name) in subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewLine } 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." -NoNewLine } } -ThrottleLimit $using:throttleLimit } -ThrottleLimit $throttleLimit - Write-ToConsoleLog "Cleanup completed." -Color Green + Write-ToConsoleLog "Cleanup completed." -IsSuccess } } \ No newline at end of file From dd3bcaf5ae9b92628ee5838c2bb7f699377b11f0 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 14 Nov 2025 17:59:29 +0000 Subject: [PATCH 6/8] docs 11 --- 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 4564f9e..1898eb6 100644 --- a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -69,7 +69,7 @@ function Remove-PlatformLandingZone { The maximum number of parallel operations to execute simultaneously. This controls the degree of parallelism when processing management groups and resource groups. Higher values may improve performance but increase API throttling risk and resource consumption. - Default: 11 + Default: 11 "These go to eleven." .PARAMETER planMode A switch parameter that enables "dry run" mode. When specified, the function displays what actions would be From 57b72b5e2795499bec65e2df4817276c49551f0d Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 14 Nov 2025 18:50:24 +0000 Subject: [PATCH 7/8] linting --- 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 1898eb6..1db8252 100644 --- a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -138,9 +138,9 @@ function Remove-PlatformLandingZone { - Azure CLI to be installed and available in the system path - User to be authenticated to Azure (az login) - Appropriate RBAC permissions: - * Management Group Contributor or Owner at the management group scope - * Contributor or Owner at the subscription scope for resource group deletions - * Security Admin for resetting Microsoft Defender for Cloud plans + * Management Group Contributor or Owner at the management group scope + * Contributor or Owner at the subscription scope for resource group deletions + * Security Admin for resetting Microsoft Defender for Cloud plans The function supports PowerShell's ShouldProcess pattern and respects -WhatIf and -Confirm parameters. From 070e66751f0f1ef3de10727e1338edab84962179 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 14 Nov 2025 18:53:06 +0000 Subject: [PATCH 8/8] linting --- 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 1db8252..1ff8228 100644 --- a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -646,4 +646,4 @@ function Remove-PlatformLandingZone { Write-ToConsoleLog "Cleanup completed." -IsSuccess } -} \ No newline at end of file +}