diff --git a/src/AzOps.psd1 b/src/AzOps.psd1 index a5067439..556986a8 100644 --- a/src/AzOps.psd1 +++ b/src/AzOps.psd1 @@ -3,7 +3,7 @@ # # Generated by: Customer Architecture Team (CAT) # -# Generated on: 05/23/2025 +# Generated on: 6/10/2025 # @{ @@ -52,7 +52,7 @@ PowerShellVersion = '7.2' # Modules that must be imported into the global environment prior to importing this module RequiredModules = @(@{ModuleName = 'PSFramework'; RequiredVersion = '1.12.346'; }, - @{ModuleName = 'Az.Accounts'; RequiredVersion = '5.0.0'; }, + @{ModuleName = 'Az.Accounts'; RequiredVersion = '5.1.0'; }, @{ModuleName = 'Az.Billing'; RequiredVersion = '2.2.0'; }, @{ModuleName = 'Az.ResourceGraph'; RequiredVersion = '1.2.1'; }, @{ModuleName = 'Az.Resources'; RequiredVersion = '8.0.0'; }) diff --git a/src/internal/functions/New-AzOpsDeployment.ps1 b/src/internal/functions/New-AzOpsDeployment.ps1 index 3219d198..6dad0a0f 100644 --- a/src/internal/functions/New-AzOpsDeployment.ps1 +++ b/src/internal/functions/New-AzOpsDeployment.ps1 @@ -152,7 +152,7 @@ $parameters.ResultFormat = $WhatIfResultFormat } # Resource Groups excluding Microsoft.Resources/resourceGroups that needs to be submitted at subscription scope - if ($scopeObject.resourcegroup -and $TemplateObject.resources[0].type -ne 'Microsoft.Resources/resourceGroups') { + if ($scopeObject.Resourcegroup -and $TemplateObject.resources[0].type -ne 'Microsoft.Resources/resourceGroups') { Set-AzOpsContext -ScopeObject $scopeObject $whatIfCommand = 'Get-AzResourceGroupDeploymentWhatIfResult' if ($null -ne $DeploymentStackSettings) { @@ -166,7 +166,7 @@ $parameters.Remove('Location') } # Subscriptions - elseif ($scopeObject.subscription) { + elseif ($scopeObject.Subscription) { Set-AzOpsContext -ScopeObject $scopeObject $whatIfCommand = 'Get-AzSubscriptionDeploymentWhatIfResult' if ($null -ne $DeploymentStackSettings) { @@ -178,7 +178,7 @@ } } # Management Groups - elseif ($scopeObject.managementGroup -and (-not ($scopeObject.StatePath).StartsWith('azopsscope-assume-new-resource_'))) { + elseif ($scopeObject.ManagementGroup -and (-not ($scopeObject.StatePath).StartsWith('azopsscope-assume-new-resource_'))) { $parameters.ManagementGroupId = $scopeObject.managementgroup $whatIfCommand = 'Get-AzManagementGroupDeploymentWhatIfResult' if ($null -ne $DeploymentStackSettings) { @@ -196,7 +196,7 @@ $deploymentCommand = 'New-AzTenantDeployment' } # If Management Group resource was not found, validate and prepare for first time deployment of resource - elseif ($scopeObject.managementGroup -and (($scopeObject.StatePath).StartsWith('azopsscope-assume-new-resource_'))) { + elseif ($scopeObject.ManagementGroup -and (($scopeObject.StatePath).StartsWith('azopsscope-assume-new-resource_'))) { $resourceScopeFileContent = Get-Content -Path $addition | ConvertFrom-Json -Depth 100 $resource = ($resourceScopeFileContent.resources | Where-Object {$_.type -eq 'Microsoft.Management/managementGroups'} | Select-Object -First 1) $pathDir = (Get-Item -Path $addition -Force).Directory | Resolve-Path -Relative diff --git a/src/internal/functions/Remove-AzResourceRaw.ps1 b/src/internal/functions/Remove-AzResourceRaw.ps1 index e47e42c9..e4c26322 100644 --- a/src/internal/functions/Remove-AzResourceRaw.ps1 +++ b/src/internal/functions/Remove-AzResourceRaw.ps1 @@ -124,6 +124,40 @@ } } } + function Get-AzOpsDeploymentStackActionOnUnmanage { + param ( + [string] + $ResourcesCleanupAction, + [string] + $ResourceGroupsCleanupAction, + [string] + $ManagementGroupsCleanupAction + ) + + # Check for deleteAll pattern + if ($ResourcesCleanupAction -eq 'delete' -and + $ResourceGroupsCleanupAction -eq 'delete' -and + $ManagementGroupsCleanupAction -eq 'delete') { + return 'deleteAll' + } + + # Check for deleteResources pattern + if ($ResourcesCleanupAction -eq 'delete' -and + $ResourceGroupsCleanupAction -eq 'detach' -and + $ManagementGroupsCleanupAction -eq 'detach') { + return 'deleteResources' + } + + # Check for detachAll pattern + if ($ResourcesCleanupAction -eq 'detach' -and + $ResourceGroupsCleanupAction -eq 'detach' -and + $ManagementGroupsCleanupAction -eq 'detach') { + return 'detachAll' + } + + # Default fallback + return 'detachAll' + } if ($null -ne $InputObject -and $Recursive) { # Perform recursive resource deletion $result = Remove-AzResourceRawRecursive -InputObject $InputObject @@ -159,18 +193,36 @@ try { # Set Azure context for removal operation Set-AzOpsContext -ScopeObject $ScopeObject - $null = Remove-AzResource -ResourceId $ScopeObject.Scope -Force -ErrorAction Stop - $maxAttempts = 4 - $attempt = 1 - $gone = $false - while ($gone -eq $false -and $attempt -le $maxAttempts) { - Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzResourceRaw.Resource.CheckExistence' -LogStringValues $ScopeObject.Scope - Start-Sleep -Seconds 10 - $tryResource = Get-AzOpsResource -ScopeObject $ScopeObject -ErrorAction SilentlyContinue - if (-not $tryResource) { - $gone = $true + # Evaluate the resource type and perform the appropriate removal action + if ($ScopeObject.Resource -eq 'deploymentStacks') { + $actionOnUnmanage = Get-AzOpsDeploymentStackActionOnUnmanage -ResourcesCleanupAction $resource.resourcesCleanupAction -ResourceGroupsCleanupAction $resource.resourceGroupsCleanupAction -ManagementGroupsCleanupAction $resource.managementGroupsCleanupAction + if ($ScopeObject.ResourceGroup) { + $removeCommand = 'Remove-AzResourceGroupDeploymentStack' + } + elseif ($ScopeObject.Subscription) { + $removeCommand = 'Remove-AzSubscriptionDeploymentStack' + } + elseif ($scopeObject.ManagementGroup) { + $removeCommand = 'Remove-AzManagementGroupDeploymentStack' + } + Write-AzOpsMessage -LogLevel Verbose -LogString 'Remove-AzResourceRaw.Resource.StackCommand' -LogStringValues $removeCommand, $ScopeObject.Scope, $actionOnUnmanage + $null = & $removeCommand -ResourceId $ScopeObject.Scope -ActionOnUnmanage $actionOnUnmanage -Force -ErrorAction Stop + } + else { + Write-AzOpsMessage -LogLevel Verbose -LogString 'Remove-AzResourceRaw.Resource.Command' -LogStringValues 'Remove-AzResource', $ScopeObject.Scope + $null = Remove-AzResource -ResourceId $ScopeObject.Scope -Force -ErrorAction Stop + $maxAttempts = 4 + $attempt = 1 + $gone = $false + while ($gone -eq $false -and $attempt -le $maxAttempts) { + Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzResourceRaw.Resource.CheckExistence' -LogStringValues $ScopeObject.Scope + Start-Sleep -Seconds 10 + $tryResource = Get-AzOpsResource -ScopeObject $ScopeObject -ErrorAction SilentlyContinue + if (-not $tryResource) { + $gone = $true + } + $attempt++ } - $attempt++ } } catch { diff --git a/src/localized/en-us/Strings.psd1 b/src/localized/en-us/Strings.psd1 index c7dd52f8..bf2b7d34 100644 --- a/src/localized/en-us/Strings.psd1 +++ b/src/localized/en-us/Strings.psd1 @@ -321,6 +321,8 @@ 'Remove-AzResourceRaw.Resource.Recursive.Missing' = 'Missing required parameter InputObject, when running Recursive'# 'Remove-AzResourceRaw.Resource.Missing' = 'Missing required parameter ScopeObject'# 'Remove-AzResourceRaw.Resource.CheckExistence' = 'Checking existence after deletion of: [{0}]'# $FullyQualifiedResourceId + 'Remove-AzResourceRaw.Resource.StackCommand' = 'Running deletion command: [{0}] for resource: [{1}] with ActionOnUnmanage: [{2}]' # $removeCommand, $ScopeObject.Scope, $actionOnUnmanage + 'Remove-AzResourceRaw.Resource.Command' = 'Running deletion command: [{0}] for resource: [{1}]' # 'Remove-AzResource', $ScopeObject.Scope 'Remove-AzResourceRaw.Resource.Failed' = 'Unable to delete resource of type {0} with id {1}'# $ScopeObject.Resource, $ScopeObject.Scope 'Remove-AzResourceRawRecursive.Processing' = 'Recursive retry processing to delete resource of type {0} with id {1}'# $item.ScopeObject.Resource, $item.ScopeObject.Scope diff --git a/src/tests/integration/Repository.Tests.ps1 b/src/tests/integration/Repository.Tests.ps1 index 83685fff..6fe3e247 100644 --- a/src/tests/integration/Repository.Tests.ps1 +++ b/src/tests/integration/Repository.Tests.ps1 @@ -1372,7 +1372,7 @@ Describe "Repository" { "A`t$($script:deployStacksAtRgScope.FullName[1])" ) {Invoke-AzOpsPush -ChangeSet $changeSet} | Should -Not -Throw - Get-AzResourceGroupDeploymentStack -ResourceId "/subscriptions/$script:subscriptionId/resourceGroups/$($script:resourceGroupStacksDeploy.ResourceGroupName)/providers/Microsoft.Resources/deploymentStacks/AzOps-deploystacksatrg-$deploymentLocationId" -ErrorAction SilentlyContinue | Should -Not -Be $null + $resources = Get-AzResourceGroupDeploymentStack -ResourceId "/subscriptions/$script:subscriptionId/resourceGroups/$($script:resourceGroupStacksDeploy.ResourceGroupName)/providers/Microsoft.Resources/deploymentStacks/AzOps-deploystacksatrg-$deploymentLocationId" -ErrorAction SilentlyContinue | Should -Not -Be $null Start-Sleep -Seconds 10 $changeSet = @( "D`t$($script:deployStacksAtRgScope.FullName[0])", @@ -1388,6 +1388,9 @@ Describe "Repository" { Set-PSFConfig -FullName AzOps.Core.CustomTemplateResourceDeletion -Value $false Start-Sleep -Seconds 30 Get-AzResourceGroupDeploymentStack -ResourceId "/subscriptions/$script:subscriptionId/resourceGroups/$($script:resourceGroupStacksDeploy.ResourceGroupName)/providers/Microsoft.Resources/deploymentStacks/AzOps-deploystacksatrg-$deploymentLocationId" -ErrorAction SilentlyContinue | Should -Be $null + foreach ($resource in $resources.Resources.Id) { + Get-AzResource -ResourceId $resource -ErrorAction SilentlyContinue | Should -Be $null + } } It "Deploy and Delete AzOps Managed DeploymentStacks at Subscription Scope with parameter file and exclusion of direct stack, expecting usage of parent stack" { Set-PSFConfig -FullName AzOps.Core.CustomTemplateResourceDeletion -Value $true @@ -1422,6 +1425,9 @@ Describe "Repository" { Set-PSFConfig -FullName AzOps.Core.CustomTemplateResourceDeletion -Value $false Start-Sleep -Seconds 30 Get-AzSubscriptionDeploymentStack -ResourceId "/subscriptions/$script:subscriptionId/providers/Microsoft.Resources/deploymentStacks/AzOps-deploystacksatsub-$deploymentLocationId" -ErrorAction SilentlyContinue | Should -Be $null + foreach ($resource in $stackConfig.Resources.Id) { + Get-AzResource -ResourceId $resource -ErrorAction SilentlyContinue | Should -Be $null + } } It "Deploy and Delete AzOps Managed DeploymentStacks at ManagementGroup Scope with multiparameter filename and parameter specific stackfile discovered with reverse lookup" { Set-PSFConfig -FullName AzOps.Core.CustomTemplateResourceDeletion -Value $true @@ -1432,7 +1438,7 @@ Describe "Repository" { "A`t$($script:deployStacksAtMgScope.FullName[2])" ) {Invoke-AzOpsPush -ChangeSet $changeSet} | Should -Not -Throw - Get-AzManagementGroupDeploymentStack -ResourceId "/providers/Microsoft.Management/managementGroups/$($script:managementManagementGroup.Name)/providers/Microsoft.Resources/deploymentStacks/AzOps-deploystacksatmg.x1-$deploymentLocationId" -ErrorAction SilentlyContinue | Should -Not -Be $null + $resources = Get-AzManagementGroupDeploymentStack -ResourceId "/providers/Microsoft.Management/managementGroups/$($script:managementManagementGroup.Name)/providers/Microsoft.Resources/deploymentStacks/AzOps-deploystacksatmg.x1-$deploymentLocationId" -ErrorAction SilentlyContinue | Should -Not -Be $null Start-Sleep -Seconds 10 $changeSet = @( "D`t$($script:deployStacksAtMgScope.FullName[2])" @@ -1446,6 +1452,9 @@ Describe "Repository" { Set-PSFConfig -FullName AzOps.Core.DeployAllMultipleTemplateParameterFiles -Value $false Start-Sleep -Seconds 30 Get-AzManagementGroupDeploymentStack -ResourceId "/providers/Microsoft.Management/managementGroups/$($script:managementManagementGroup.Name)/providers/Microsoft.Resources/deploymentStacks/AzOps-deploystacksatmg.x1-$deploymentLocationId" -ErrorAction SilentlyContinue | Should -Be $null + foreach ($resource in $resources.Resources.Id) { + Get-AzResource -ResourceId $resource -ErrorAction SilentlyContinue | Should -Be $null + } } #endregion } diff --git a/src/tests/templates/deploystacksatmg.x1.deploymentStacks.json b/src/tests/templates/deploystacksatmg.x1.deploymentStacks.json index ce06d2c1..c3edf68f 100644 --- a/src/tests/templates/deploystacksatmg.x1.deploymentStacks.json +++ b/src/tests/templates/deploystacksatmg.x1.deploymentStacks.json @@ -1,6 +1,6 @@ { - "actionOnUnmanage": "DeleteAll", + "actionOnUnmanage": "deleteAll", "bypassStackOutOfSyncError": true, - "denySettingsMode": "None", + "denySettingsMode": "none", "excludedAzOpsFiles": [] } \ No newline at end of file diff --git a/src/tests/templates/deploystacksatrg.deploymentStacks.json b/src/tests/templates/deploystacksatrg.deploymentStacks.json index 916d300d..9dbdaefd 100644 --- a/src/tests/templates/deploystacksatrg.deploymentStacks.json +++ b/src/tests/templates/deploystacksatrg.deploymentStacks.json @@ -1,6 +1,6 @@ { "actionOnUnmanage": "deleteResources", "bypassStackOutOfSyncError": true, - "denySettingsMode": "DenyDelete", + "denySettingsMode": "denyDelete", "excludedAzOpsFiles": [] } \ No newline at end of file diff --git a/src/tests/templates/deploystacksatsub.deploymentStacks.json b/src/tests/templates/deploystacksatsub.deploymentStacks.json index 196333f2..ed7b2c5e 100644 --- a/src/tests/templates/deploystacksatsub.deploymentStacks.json +++ b/src/tests/templates/deploystacksatsub.deploymentStacks.json @@ -1,7 +1,7 @@ { - "actionOnUnmanage": "DetachAll", + "actionOnUnmanage": "detachAll", "bypassStackOutOfSyncError": true, - "denySettingsMode": "DenyDelete", + "denySettingsMode": "denyDelete", "excludedAzOpsFiles": [ "deploystacksatsub.bicep" ] diff --git a/src/tests/templates/deploystacksatsubrename.deploymentStacks.json b/src/tests/templates/deploystacksatsubrename.deploymentStacks.json index 7f46eeaa..b420a8a7 100644 --- a/src/tests/templates/deploystacksatsubrename.deploymentStacks.json +++ b/src/tests/templates/deploystacksatsubrename.deploymentStacks.json @@ -1,6 +1,6 @@ { - "actionOnUnmanage": "DeleteAll", + "actionOnUnmanage": "deleteAll", "bypassStackOutOfSyncError": true, - "denySettingsMode": "DenyDelete", + "denySettingsMode": "denyDelete", "excludedAzOpsFiles": [] } \ No newline at end of file