diff --git a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 index d083d97..ea4b417 100644 --- a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -94,6 +94,12 @@ function Remove-PlatformLandingZone { subscriptions. This is useful when you want to preserve deployment records for audit or compliance purposes. Default: $false (delete deployments) + .PARAMETER SkipDeploymentStackDeletion + A switch parameter that skips deployment stack deletion operations at both the management group and subscription + levels. When specified, the function will not delete deployment stacks from management groups or subscriptions. + This is useful when you want to preserve deployment stacks or lack the necessary permissions to delete them. + Default: $false (delete deployment stacks) + .PARAMETER SkipOrphanedRoleAssignmentDeletion A switch parameter that skips orphaned role assignment deletion operations at both the management group and subscription levels. When specified, the function will not delete role assignments where the principal no @@ -122,6 +128,14 @@ function Remove-PlatformLandingZone { containing "Custom" anywhere in their name). Default: Empty array (delete all custom role definitions) + .PARAMETER DeploymentStacksToDeleteNamePatterns + An array of wildcard patterns for deployment stack names that should be deleted. Only deployment stacks + matching any of these patterns will be deleted during the deployment stack cleanup process. If the array + is empty, all deployment stacks will be deleted (default behavior). Each pattern is evaluated using a + -like expression with wildcards at the start and end (e.g., a pattern of "alz" will match deployment stacks + containing "alz" anywhere in their name). + Default: Empty array (delete all deployment stacks) + .EXAMPLE Remove-PlatformLandingZone -ManagementGroups @("alz-platform", "alz-landingzones") @@ -184,6 +198,12 @@ function Remove-PlatformLandingZone { Removes management groups and resource groups but skips resetting Microsoft Defender plans and deleting deployment history. Useful for faster cleanup when Defender configuration and audit trails should be preserved. + .EXAMPLE + Remove-PlatformLandingZone -ManagementGroups @("alz-test") -SkipDeploymentStackDeletion + + Removes management groups and resource groups but skips deleting deployment stacks. Useful when you want to + preserve deployment stacks for managed resource cleanup or lack the necessary permissions to delete them. + .EXAMPLE Remove-PlatformLandingZone -Subscriptions @("Sub-Test-001") -SkipOrphanedRoleAssignmentDeletion @@ -208,6 +228,12 @@ function Remove-PlatformLandingZone { Removes management groups and resource groups but only deletes custom role definitions with names containing "Test-Role" or "Temporary". Useful when you want to clean up specific custom roles while preserving others. + .EXAMPLE + Remove-PlatformLandingZone -ManagementGroups @("alz-test") -DeploymentStacksToDeleteNamePatterns @("alz-", "test-") + + Removes management groups and resource groups but only deletes deployment stacks with names containing + "alz-" or "test-". Useful when you want to clean up specific deployment stacks while preserving others. + .NOTES This function uses Azure CLI commands and requires: - Azure CLI to be installed and available in the system path @@ -267,10 +293,12 @@ function Remove-PlatformLandingZone { [switch]$PlanMode, [switch]$SkipDefenderPlanReset, [switch]$SkipDeploymentDeletion, + [switch]$SkipDeploymentStackDeletion, [switch]$SkipOrphanedRoleAssignmentDeletion, [switch]$SkipCustomRoleDefinitionDeletion, [string[]]$ManagementGroupsToDeleteNamePatterns = @(), - [string[]]$RoleDefinitionsToDeleteNamePatterns = @() + [string[]]$RoleDefinitionsToDeleteNamePatterns = @(), + [string[]]$DeploymentStacksToDeleteNamePatterns = @() ) function Write-ToConsoleLog { @@ -558,7 +586,10 @@ function Remove-PlatformLandingZone { [string]$ScopeId, [int]$ThrottleLimit, [switch]$PlanMode, - [string]$TempLogFileForPlan + [string]$TempLogFileForPlan, + [switch]$SkipDeploymentStackDeletion, + [switch]$SkipDeploymentDeletion, + [string[]]$DeploymentStacksToDeleteNamePatterns = @() ) if(-not $PSCmdlet.ShouldProcess("Delete Deployments", "delete")) { @@ -568,57 +599,143 @@ function Remove-PlatformLandingZone { $funcWriteToConsoleLog = ${function:Write-ToConsoleLog}.ToString() $isSubscriptionScope = $ScopeType -eq "subscription" - Write-ToConsoleLog "Checking for deployments to delete in $($ScopeType): $ScopeNameForLogs" -NoNewLine + # Delete deployment stacks first (before regular deployments) + if(-not $SkipDeploymentStackDeletion) { + Write-ToConsoleLog "Checking for deployment stacks to delete in $($ScopeType): $ScopeNameForLogs" -NoNewLine + + $deploymentStacks = @() + if ($isSubscriptionScope) { + $deploymentStacks = (az stack sub list --subscription $ScopeId --query "[].{name:name,id:id}" -o json 2>$null) | ConvertFrom-Json + } else { + $deploymentStacks = (az stack mg list --management-group-id $ScopeId --query "[].{name:name,id:id}" -o json 2>$null) | ConvertFrom-Json + } + + # Filter deployment stacks to only include those matching deletion patterns + if ($DeploymentStacksToDeleteNamePatterns -and $DeploymentStacksToDeleteNamePatterns.Count -gt 0) { + $filteredDeploymentStacks = @() + foreach($stack in $deploymentStacks) { + $shouldDelete = $false + foreach($pattern in $DeploymentStacksToDeleteNamePatterns) { + if($stack.name -like "*$pattern*") { + Write-ToConsoleLog "Including deployment stack for deletion due to pattern match '$pattern': $($stack.name)" -NoNewLine + $shouldDelete = $true + break + } + } + if($shouldDelete) { + $filteredDeploymentStacks += $stack + } else { + Write-ToConsoleLog "Skipping deployment stack (no pattern match): $($stack.name)" -NoNewLine + } + } + $deploymentStacks = $filteredDeploymentStacks + } + + if ($deploymentStacks -and $deploymentStacks.Count -gt 0) { + Write-ToConsoleLog "Found $($deploymentStacks.Count) deployment stack(s) in $($ScopeType): $ScopeNameForLogs" -NoNewLine + + $deploymentStacks | ForEach-Object -Parallel { + $deploymentStack = $_ + $scopeId = $using:ScopeId + $scopeNameForLogs = $using:ScopeNameForLogs + $scopeType = $using:ScopeType + $funcWriteToConsoleLog = $using:funcWriteToConsoleLog + ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog + $isSubscriptionScope = $using:isSubscriptionScope + + Write-ToConsoleLog "Deleting deployment stack: $($deploymentStack.name) from $($scopeType): $scopeNameForLogs" -NoNewLine + $result = $null + if($isSubscriptionScope) { + if($using:PlanMode) { + Write-ToConsoleLog ` + "Deleting deployment stack: $($deploymentStack.name) from $($scopeType): $scopeNameForLogs", ` + "Would run: az stack sub delete --subscription $scopeId --name $($deploymentStack.name) --aou detachAll --yes" ` + -IsPlan -LogFilePath $using:TempLogFileForPlan + } else { + $result = az stack sub delete --subscription $scopeId --name $deploymentStack.name --aou detachAll --yes 2>&1 + } + } else { + if($using:PlanMode) { + Write-ToConsoleLog ` + "Deleting deployment stack: $($deploymentStack.name) from $($scopeType): $scopeNameForLogs", ` + "Would run: az stack mg delete --management-group-id $scopeId --name $($deploymentStack.name) --aou detachAll --yes" ` + -IsPlan -LogFilePath $using:TempLogFileForPlan + } else { + $result = az stack mg delete --management-group-id $scopeId --name $deploymentStack.name --aou detachAll --yes 2>&1 + } + } + + if (!$result) { + Write-ToConsoleLog "Deleted deployment stack: $($deploymentStack.name) from $($scopeType): $scopeNameForLogs" -NoNewLine + } else { + Write-ToConsoleLog "Failed to delete deployment stack: $($deploymentStack.name) from $($scopeType): $scopeNameForLogs" -IsWarning -NoNewLine + } + } -ThrottleLimit $ThrottleLimit - $deployments = @() - if ($isSubscriptionScope) { - $deployments = (az deployment sub list --subscription $ScopeId --query "[].name" -o json) | ConvertFrom-Json + Write-ToConsoleLog "All deployment stacks processed in $($ScopeType): $ScopeNameForLogs" -NoNewLine + } else { + Write-ToConsoleLog "No deployment stacks found in $($ScopeType): $ScopeNameForLogs, skipping." -NoNewLine + } } else { - $deployments = (az deployment mg list --management-group-id $ScopeId --query "[].name" -o json) | ConvertFrom-Json + Write-ToConsoleLog "Skipping deployment stack deletion in $($ScopeType): $ScopeNameForLogs" -NoNewLine } - if ($deployments -and $deployments.Count -gt 0) { - Write-ToConsoleLog "Found $($deployments.Count) deployment(s) in $($ScopeType): $scopeNameForLogs" -NoNewLine + if(-not $SkipDeploymentDeletion) { + Write-ToConsoleLog "Checking for deployments to delete in $($ScopeType): $ScopeNameForLogs" -NoNewLine - $deployments | ForEach-Object -Parallel { - $deploymentName = $_ - $scopeId = $using:ScopeId - $scopeNameForLogs = $using:ScopeNameForLogs - $funcWriteToConsoleLog = $using:funcWriteToConsoleLog - ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog + $deployments = @() + if ($isSubscriptionScope) { + $deployments = (az deployment sub list --subscription $ScopeId --query "[].name" -o json) | ConvertFrom-Json + } else { + $deployments = (az deployment mg list --management-group-id $ScopeId --query "[].name" -o json) | ConvertFrom-Json + } - Write-ToConsoleLog "Deleting deployment: $deploymentName from $($scopeType): $scopeNameForLogs" -NoNewLine - $result = $null - if($isSubscriptionScope) { - if($using:PlanMode) { - Write-ToConsoleLog ` - "Deleting deployment: $deploymentName from $($scopeType): $scopeNameForLogs", ` - "Would run: az deployment sub delete --subscription $scopeId --name $deploymentName" ` - -IsPlan -LogFilePath $using:TempLogFileForPlan + if ($deployments -and $deployments.Count -gt 0) { + Write-ToConsoleLog "Found $($deployments.Count) deployment(s) in $($ScopeType): $scopeNameForLogs" -NoNewLine + + $deployments | ForEach-Object -Parallel { + $deploymentName = $_ + $scopeId = $using:ScopeId + $scopeNameForLogs = $using:ScopeNameForLogs + $funcWriteToConsoleLog = $using:funcWriteToConsoleLog + ${function:Write-ToConsoleLog} = $funcWriteToConsoleLog + $isSubscriptionScope = $using:isSubscriptionScope + + Write-ToConsoleLog "Deleting deployment: $deploymentName from $($scopeType): $scopeNameForLogs" -NoNewLine + $result = $null + if($isSubscriptionScope) { + if($using:PlanMode) { + Write-ToConsoleLog ` + "Deleting deployment: $deploymentName from $($scopeType): $scopeNameForLogs", ` + "Would run: az deployment sub delete --subscription $scopeId --name $deploymentName" ` + -IsPlan -LogFilePath $using:TempLogFileForPlan + } else { + $result = az deployment sub delete --subscription $scopeId --name $deploymentName 2>&1 + } } else { - $result = az deployment sub delete --subscription $scopeId --name $deploymentName 2>&1 + if($using:PlanMode) { + Write-ToConsoleLog ` + "Deleting deployment: $deploymentName from $($scopeType): $scopeNameForLogs", ` + "Would run: az deployment mg delete --management-group-id $scopeId --name $deploymentName" ` + -IsPlan -LogFilePath $using:TempLogFileForPlan + } else { + $result = az deployment mg delete --management-group-id $scopeId --name $deploymentName 2>&1 + } } - } else { - if($using:PlanMode) { - Write-ToConsoleLog ` - "Deleting deployment: $deploymentName from $($scopeType): $scopeNameForLogs", ` - "Would run: az deployment mg delete --management-group-id $scopeId --name $deploymentName" ` - -IsPlan -LogFilePath $using:TempLogFileForPlan + + if (!$result) { + Write-ToConsoleLog "Deleted deployment: $deploymentName from $($scopeType): $scopeNameForLogs" -NoNewLine } else { - $result = az deployment mg delete --management-group-id $scopeId --name $deploymentName 2>&1 + Write-ToConsoleLog "Failed to delete deployment: $deploymentName from $($scopeType): $scopeNameForLogs" -IsWarning -NoNewLine } - } - - if (!$result) { - Write-ToConsoleLog "Deleted deployment: $deploymentName from $($scopeType): $scopeNameForLogs" -NoNewLine - } else { - Write-ToConsoleLog "Failed to delete deployment: $deploymentName from $($scopeType): $scopeNameForLogs" -IsWarning -NoNewLine - } - } -ThrottleLimit $using:ThrottleLimit + } -ThrottleLimit $ThrottleLimit - Write-ToConsoleLog "All deployments processed in $($scopeType): $scopeNameForLogs" -NoNewLine + Write-ToConsoleLog "All deployments processed in $($scopeType): $scopeNameForLogs" -NoNewLine + } else { + Write-ToConsoleLog "No deployments found in $($scopeType): $scopeNameForLogs, skipping." -NoNewLine + } } else { - Write-ToConsoleLog "No deployments found in $($scopeType): $scopeNameForLogs, skipping." -NoNewLine + Write-ToConsoleLog "Skipping deployment deletion in $($ScopeType): $ScopeNameForLogs" -NoNewLine } } @@ -961,8 +1078,8 @@ function Remove-PlatformLandingZone { } -ThrottleLimit $ThrottleLimit } - # Delete deployments from target management groups that are not being deleted - if($managementGroupsFound.Count -ne 0 -and -not $SkipDeploymentDeletion -and -not $DeleteTargetManagementGroups) { + # Delete deployments and deployment stacks from target management groups that are not being deleted + if($managementGroupsFound.Count -ne 0 -and (-not $SkipDeploymentDeletion -or -not $SkipDeploymentStackDeletion) -and -not $DeleteTargetManagementGroups) { $managementGroupsFound | ForEach-Object -Parallel { $managementGroupId = $_.Name $managementGroupDisplayName = $_.DisplayName @@ -978,11 +1095,14 @@ function Remove-PlatformLandingZone { -ScopeId $managementGroupId ` -ThrottleLimit $using:ThrottleLimit ` -PlanMode:$using:PlanMode ` - -TempLogFileForPlan $using:TempLogFileForPlan + -TempLogFileForPlan $using:TempLogFileForPlan ` + -SkipDeploymentStackDeletion:$using:SkipDeploymentStackDeletion ` + -SkipDeploymentDeletion:$using:SkipDeploymentDeletion ` + -DeploymentStacksToDeleteNamePatterns $using:DeploymentStacksToDeleteNamePatterns } -ThrottleLimit $ThrottleLimit } else { - Write-ToConsoleLog "Skipping deployment deletion for management groups" -NoNewLine + Write-ToConsoleLog "Skipping deployment and deployment stack deletion for management groups" -NoNewLine } # Delete orphaned role assignments from target management groups that are not being deleted @@ -1172,16 +1292,19 @@ function Remove-PlatformLandingZone { Write-ToConsoleLog "Skipping Microsoft Defender for Cloud Plans reset in subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewLine } - if(-not $using:SkipDeploymentDeletion) { + if(-not $using:SkipDeploymentDeletion -or -not $using:SkipDeploymentStackDeletion) { Remove-DeploymentsForScope ` -ScopeType "subscription" ` -ScopeNameForLogs "$($subscription.Name) (ID: $($subscription.Id))" ` -ScopeId $subscription.Id ` -ThrottleLimit $using:ThrottleLimit ` -PlanMode:$using:PlanMode ` - -TempLogFileForPlan $using:TempLogFileForPlan + -TempLogFileForPlan $using:TempLogFileForPlan ` + -SkipDeploymentStackDeletion:$using:SkipDeploymentStackDeletion ` + -SkipDeploymentDeletion:$using:SkipDeploymentDeletion ` + -DeploymentStacksToDeleteNamePatterns $using:DeploymentStacksToDeleteNamePatterns } else { - Write-ToConsoleLog "Skipping subscription level deployment deletion in subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewLine + Write-ToConsoleLog "Skipping subscription level deployment and deployment stack deletion in subscription: $($subscription.Name) (ID: $($subscription.Id))" -NoNewLine } if(-not $using:SkipOrphanedRoleAssignmentDeletion) {