internal/functions/Remove-AzOpsDeployment.ps1
function Remove-AzOpsDeployment { <# .SYNOPSIS Deletion of supported resource types AzOps.Core.DeletionSupportedResourceType and custom templates. .DESCRIPTION Deletion of supported resource types AzOps.Core.DeletionSupportedResourceType and custom templates. .PARAMETER CustomTemplateResourceDeletion Enable or disable, deletion of resources in custom templates. .PARAMETER DeploymentName Dummy name used to run Azure WhatIf deployment. .PARAMETER TemplateFilePath Path where the ARM templates can be found. .PARAMETER TemplateParameterFilePath Path where the ARM parameters templates can be found. .PARAMETER StatePath The root folder under which to find the resource json. .PARAMETER DeletionSupportedResourceType Supported resource types for deletion of AzOps generated file. .PARAMETER DeleteSet String of file names to validate deletion. .PARAMETER WhatIf If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. .EXAMPLE > $AzOpsRemovalList | Select-Object $uniqueProperties -Unique | Remove-AzOpsDeployment Remove all unique deployments provided from $AzOpsRemovalList #> [CmdletBinding(SupportsShouldProcess = $true)] param ( [bool] $CustomTemplateResourceDeletion = (Get-PSFConfigValue -FullName 'AzOps.Core.CustomTemplateResourceDeletion'), [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $DeploymentName = "azops-template-deployment", [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $TemplateFilePath = (Get-PSFConfigValue -FullName 'AzOps.Core.MainTemplate'), [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $TemplateParameterFilePath, [string] $StatePath = (Get-PSFConfigValue -FullName 'AzOps.Core.State'), [object[]] $DeletionSupportedResourceType = (Get-PSFConfigValue -FullName 'AzOps.Core.DeletionSupportedResourceType'), [string[]] $DeleteSet ) process { function Get-AzLocksDeletionDependency { param ( $resourceToDelete ) $dependency = @() if ($resourceToDelete.Type -in $DeletionSupportedResourceType) { $subPattern = '^/subscriptions/([0-9a-fA-F-]{36})' $rgPattern = '^/subscriptions/([0-9a-fA-F-]{36})/resourceGroups/([^/]+)' if ($resourceToDelete.Id -match $subPattern) { $deletionScope = $matches[0] $depLock = Get-AzResourceLock -Scope $deletionScope if ($depLock) { foreach ($lock in $depLock) { #Filter through each return and validate if resource has rg and is not at child resource scope if ($lock.ResourceId -match $rgPattern) { if ($resourceToDelete.Id.StartsWith($matches[0]) -and $lock.ResourceId -notlike '*/resourcegroups/*/providers/*/providers/*') { $dependency += [PSCustomObject]@{ Type = 'locks' Id = $lock.ResourceId } } } elseif ($lock.ResourceId -notlike '*/resourcegroups/*') { $dependency += [PSCustomObject]@{ Type = 'locks' Id = $lock.ResourceId } } } if ($dependency) { $dependency = $dependency | Sort-Object Id -Unique | Where-Object {$_.Id -ne $resourceToDelete.Id} return $dependency } } } } } function Get-AzPolicyAssignmentDeletionDependency { param ( $resourceToDelete ) $dependency = @() if ($resourceToDelete.Type -in $DeletionSupportedResourceType) { switch ($resourceToDelete.Type) { 'Microsoft.Authorization/policyAssignments' { $depPolicyAssignment = $resourceToDelete } 'Microsoft.Authorization/policyDefinitions' { $depPolicyAssignment = Get-AzPolicyAssignment -PolicyDefinitionId $resourceToDelete.Id -ErrorAction SilentlyContinue } 'Microsoft.Authorization/policySetDefinitions' { $query = "PolicyResources | where type == 'microsoft.authorization/policyassignments' and properties.policyDefinitionId == '$($resourceToDelete.Id)' | order by id asc" $depPolicyAssignment = Search-AzGraphDeletionDependency -query $query if ($depPolicyAssignment) { #Loop through each return from graph cache and validate resource is still present in Azure $depPolicyAssignment = foreach ($policyAssignment in $depPolicyAssignment) {Get-AzPolicyAssignment -Id $policyAssignment.Id -ErrorAction SilentlyContinue} } } } } if ($depPolicyAssignment) { foreach ($policyAssignment in $depPolicyAssignment) { $dependency += [PSCustomObject]@{ Type = $policyAssignment.Type Id = $policyAssignment.Id } if ($policyAssignment.IdentityType -eq 'SystemAssigned') { $depSystemAssignedRoleAssignment = $null $depSystemAssignedRoleAssignment = Get-AzRoleAssignment -ObjectId $policyAssignment.IdentityPrincipalId -Scope $policyAssignment.Scope if ($depSystemAssignedRoleAssignment) { foreach ($roleAssignmentId in $depSystemAssignedRoleAssignment.RoleAssignmentId) { #Filter through each return and validate resource is not at child resource scope if ($roleAssignmentId -notlike '*/resourcegroups/*/providers/*/providers/*') { $dependency += [PSCustomObject]@{ Type = 'roleAssignments' Id = $roleAssignmentId } } else { Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.ResourceDependencyNested' -LogStringValues $roleAssignmentId, $policyAssignment.Id } } } } } } if ($dependency) { $dependency = $dependency | Sort-Object Id -Unique | Where-Object {$_.Id -ne $resourceToDelete.Id} return $dependency } } function Get-AzPolicyDefinitionDeletionDependency { param ( $resourceToDelete ) if ($resourceToDelete.Type -eq 'Microsoft.Authorization/policyDefinitions') { $dependency = @() $query = "PolicyResources | where type == 'microsoft.authorization/policysetdefinitions' and properties.policyType == 'Custom' | project id, type, policyDefinitions = (properties.policyDefinitions) | mv-expand policyDefinitions | project id, type, policyDefinitionId = tostring(policyDefinitions.policyDefinitionId) | where policyDefinitionId == '$($resourceToDelete.Id)' | order by policyDefinitionId asc | order by id asc" $depPolicySetDefinition = Search-AzGraphDeletionDependency -query $query if ($depPolicySetDefinition) { $depPolicySetDefinition = foreach ($policySetDefinition in $depPolicySetDefinition) { #Loop through each return from graph cache and validate resource is still present in Azure $policy = Get-AzPolicySetDefinition -Id $policySetDefinition.Id -ErrorAction SilentlyContinue if ($policy) { $dependency += [PSCustomObject]@{ Type = $policy.Type Id = $policy.Id } $dependency += Get-AzPolicyAssignmentDeletionDependency -resourceToDelete $policy } } } if ($dependency) { $dependency = $dependency | Sort-Object Id -Unique | Where-Object {$_.Id -ne $resourceToDelete.Id} return $dependency } } } function Search-AzGraphDeletionDependency { param ( $query, $PartialMgDiscoveryRoot = (Get-PSFConfigValue -FullName 'AzOps.Core.PartialMgDiscoveryRoot') ) $results = @() if ($PartialMgDiscoveryRoot) { foreach ($managementRoot in $PartialMgDiscoveryRoot) { $subscriptions = Get-AzOpsNestedSubscription -Scope $managementRoot $results += Search-AzOpsAzGraph -ManagementGroupName $managementRoot -Query $query -ErrorAction Stop if ($subscriptions) { $results += Search-AzOpsAzGraph -Subscription $subscriptions -Query $query -ErrorAction Stop } } } else { $results = Search-AzOpsAzGraph -Query $query -UseTenantScope -ErrorAction Stop } if ($results) { $results = $results | Sort-Object Id -Unique return $results } } $dependencyMissing = $null #Adjust TemplateParameterFilePath to compensate for policyDefinitions and policySetDefinitions usage of parameters.json if ($TemplateParameterFilePath -and $TemplateFilePath -eq (Resolve-Path (Get-PSFConfigValue -FullName 'AzOps.Core.MainTemplate')).Path) { $TemplateFilePath = $TemplateParameterFilePath } #Deployment Name $fileItem = Get-Item -Path $TemplateFilePath $removeJobName = $fileItem.BaseName -replace '\.json$' -replace ' ', '_' $removeJobName = "AzOps-RemoveResource-$removeJobName" Write-AzOpsMessage -LogLevel Important -LogString 'Remove-AzOpsDeployment.Processing' -LogStringValues $removeJobName, $TemplateFilePath #region Parse Content $templateContent = Get-Content $TemplateFilePath | ConvertFrom-Json -AsHashtable #endregion Parse Content #region Validate template type AzOps generated or not $schemavalue = '$schema' $customDeletion = $false if ($templateContent.metadata._generator.name -eq "AzOps" -or $templateContent.$schemavalue -like "*deploymentParameters.json#") { Write-AzOpsMessage -LogLevel Verbose -LogString 'Remove-AzOpsDeployment.Metadata.AzOps' -LogStringValues $TemplateFilePath } elseif ($true -eq $CustomTemplateResourceDeletion) { Write-AzOpsMessage -LogLevel Verbose -LogString 'Remove-AzOpsDeployment.Metadata.Custom' -LogStringValues $TemplateFilePath $customDeletion = $true } else { Write-AzOpsMessage -LogLevel Error -LogString 'Remove-AzOpsDeployment.Metadata.Failed' -LogStringValues $TemplateFilePath return } #endregion Validate template type AzOps generated or not #region Resolve Scope try { $scopeObject = New-AzOpsScope -Path $TemplateFilePath -StatePath $StatePath -ErrorAction Stop -WhatIf:$false } catch { Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.Scope.Failed' -LogStringValues $TemplateFilePath -ErrorRecord $_ return } if (-not $scopeObject) { Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.Scope.Empty' -LogStringValues $TemplateFilePath return } #endregion Resolve Scope #region SetContext Set-AzOpsContext -ScopeObject $scopeObject #endregion SetContext #region remove resources if ($customDeletion -eq $false -and $scopeObject.Resource -in $DeletionSupportedResourceType) { $dependency = @() switch ($scopeObject.Resource) { # Check resource existance through optimal path 'locks' { $resourceToDelete = Get-AzResourceLock -Scope "/subscriptions/$($ScopeObject.Subscription)" -ErrorAction SilentlyContinue | Where-Object { $_.ResourceID -eq $ScopeObject.Scope } } 'policyAssignments' { $resourceToDelete = Get-AzPolicyAssignment -Id $scopeObject.Scope -ErrorAction SilentlyContinue if ($resourceToDelete) { $dependency += Get-AzPolicyAssignmentDeletionDependency -resourceToDelete $resourceToDelete $dependency += Get-AzLocksDeletionDependency -resourceToDelete $resourceToDelete } } 'policyDefinitions' { $resourceToDelete = Get-AzPolicyDefinition -Id $scopeObject.Scope -ErrorAction SilentlyContinue if ($resourceToDelete) { $dependency += Get-AzPolicyAssignmentDeletionDependency -resourceToDelete $resourceToDelete $dependency += Get-AzPolicyDefinitionDeletionDependency -resourceToDelete $resourceToDelete $dependency += Get-AzLocksDeletionDependency -resourceToDelete $resourceToDelete } } 'policyExemptions' { $resourceToDelete = Get-AzPolicyExemption -Id $scopeObject.Scope -ErrorAction SilentlyContinue if ($resourceToDelete) { $dependency += Get-AzLocksDeletionDependency -resourceToDelete $resourceToDelete } } 'policySetDefinitions' { $resourceToDelete = Get-AzPolicySetDefinition -Id $scopeObject.Scope -ErrorAction SilentlyContinue if ($resourceToDelete) { $dependency += Get-AzPolicyAssignmentDeletionDependency -resourceToDelete $resourceToDelete $dependency += Get-AzLocksDeletionDependency -resourceToDelete $resourceToDelete } } 'roleAssignments' { $resourceToDelete = (Invoke-AzRestMethod -Path "$($scopeObject.Scope)?api-version=2022-04-01" | Where-Object { $_.StatusCode -eq 200 }).Content | ConvertFrom-Json -Depth 100 if ($resourceToDelete) { $dependency += Get-AzLocksDeletionDependency -resourceToDelete $resourceToDelete } } 'resourceGroups' { $resourceToDelete = Get-AzResourceGroup -Id $scopeObject.Scope -ErrorAction SilentlyContinue if ($resourceToDelete) { $resourceToDelete | Add-Member -MemberType NoteProperty -Name "Type" -Value "$($scopeObject.Type)" $resourceToDelete | Add-Member -MemberType NoteProperty -Name "SubscriptionId" -Value "$($scopeObject.Subscription)" $resourceToDelete | Add-Member -MemberType NoteProperty -Name "Id" -Value "$($resourceToDelete.ResourceId)" $dependency += Get-AzLocksDeletionDependency -resourceToDelete $resourceToDelete } } } # If no resource to delete was found return if (-not $resourceToDelete) { Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.ResourceNotFound' -LogStringValues $scopeObject.Resource, $scopeObject.Scope $results = 'What if operation failed:{1}Deletion of target resource {0}.{1}Resource could not be found' -f $scopeObject.scope, [environment]::NewLine Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -Results $results -RemoveAzOpsFlag $true return } if ($dependency) { foreach ($resource in $dependency) { if ($resource.Id -notin $deletionList.ScopeObject.Scope) { Write-AzOpsMessage -LogLevel Critical -LogString 'Remove-AzOpsDeployment.ResourceDependencyNotFound' -LogStringValues $resource.Id, $scopeObject.Scope $results = 'Missing resource dependency:{2}{0} for successful deletion of {1}.{2}{2}Please add dependent resource to pull request and retry.' -f $resource.Id, $scopeObject.scope, [environment]::NewLine Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -Results $results -RemoveAzOpsFlag $true $dependencyMissing = [PSCustomObject]@{ dependencyMissing = $true } } } } else { $results = 'What if successful:{1}Performing the operation:{1}Deletion of target resource {0}.' -f $scopeObject.scope, [environment]::NewLine Write-AzOpsMessage -LogLevel Verbose -LogString 'Set-AzOpsWhatIfOutput.WhatIfResults' -LogStringValues $results Write-AzOpsMessage -LogLevel InternalComment -LogString 'Set-AzOpsWhatIfOutput.WhatIfFile' Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -Results $results -RemoveAzOpsFlag $true } if ($dependencyMissing) { return $dependencyMissing } elseif ($dependency) { $results = 'What if successful:{1}Performing the operation:{1}Deletion of target resource {0}.' -f $scopeObject.scope, [environment]::NewLine Write-AzOpsMessage -LogLevel Verbose -LogString 'Set-AzOpsWhatIfOutput.WhatIfResults' -LogStringValues $results Write-AzOpsMessage -LogLevel InternalComment -LogString 'Set-AzOpsWhatIfOutput.WhatIfFile' Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -Results $results -RemoveAzOpsFlag $true } if ($PSCmdlet.ShouldProcess("Remove $($scopeObject.Scope)?")) { $null = Remove-AzResourceRaw -ScopeObject $scopeObject -TemplateFilePath $TemplateFilePath -TemplateParameterFilePath $TemplateParameterFilePath } else { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzOpsDeployment.SkipDueToWhatIf' } } elseif ($customDeletion -eq $false -and $scopeObject.Resource -notin $DeletionSupportedResourceType) { Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.SkipUnsupportedResource' -LogStringValues $TemplateFilePath -Target $scopeObject return } elseif ($customDeletion -eq $true) { # Perform a New-AzOpsDeployment using WhatIf with ResourceIdOnly to extrapolate resources inside template $removalJob = New-AzOpsDeployment -DeploymentName $DeploymentName -TemplateFilePath $TemplateFilePath -TemplateParameterFilePath $TemplateParameterFilePath -WhatIfResultFormat 'ResourceIdOnly' -WhatIf:$true if ($removalJob.results.Changes.Count -gt 0) { # Initialize array to store items that need retry $retry = @() $removalJobChanges = Set-AzOpsRemoveOrder -DeletionList $removalJob.results.Changes -Index { (New-AzOpsScope -Scope $_.FullyQualifiedResourceId -WhatIf:$false).Resource } $allResults = @() foreach ($change in $removalJobChanges) { $resource = $null $resourceScopeObject = $null $removeAction = $null # Check if the resource exists $resourceScopeObject = New-AzOpsScope -Scope $change.FullyQualifiedResourceId -WhatIf:$false $resource = Get-AzOpsResource -ScopeObject $resourceScopeObject -ErrorAction SilentlyContinue if ($resource) { $results = 'What if successful:{1}Performing the operation:{1}Deletion of target resource {0}.' -f $resourceScopeObject.Scope, [environment]::NewLine $allResults += $results Write-AzOpsMessage -LogLevel Verbose -LogString 'Set-AzOpsWhatIfOutput.WhatIfResults' -LogStringValues $results Write-AzOpsMessage -LogLevel InternalComment -LogString 'Set-AzOpsWhatIfOutput.WhatIfFile' # Check if the removal should be performed if ($PSCmdlet.ShouldProcess("Remove $($resourceScopeObject.Scope)?")) { $removeAction = Remove-AzResourceRaw -ScopeObject $resourceScopeObject -TemplateFilePath $TemplateFilePath -TemplateParameterFilePath $TemplateParameterFilePath # If removal failed, add to retry if ($removeAction.Status -eq 'failed') { $retry += $removeAction } } else { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzOpsDeployment.SkipDueToWhatIf' } } else { # Log warning if resource not found Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.ResourceNotFound' -LogStringValues $ScopeObject.Resource, $change.FullyQualifiedResourceId $results = 'What if operation failed:{1}Deletion of target resource {0}.{1}Resource could not be found' -f $change.FullyQualifiedResourceId, [environment]::NewLine $allResults += $results } } $baseTemplateCheck = $TemplateFilePath -replace '\.bicep$', '.json' if ($TemplateParameterFilePath) { $baseParameterCheck = $TemplateParameterFilePath -replace '\.bicepparam$', 'parameters.json' } if ($DeleteSet) { $deleteSetCheck = $DeleteSet -replace '\.bicep$', '.json' $deleteSetCheck = $deleteSetCheck -replace '\.bicepparam$', '.parameters.json' # Check if template and parameter file exist in $DeleteSet, example AzOps has been instructed to remove template.json but not the associated parameter.json $resultsFileAssociation = switch ($null) { { $baseTemplateCheck -notin $deleteSetCheck -and $baseParameterCheck -notin $deleteSetCheck } { 'Missing template and parameter file association:{2}{0} and {1} for deletion.{2}{2}Ensure that you have reviewed and confirmed the necessity of each deletion.{2}If you are deleting files with extension .bicep or .bicepparam, keep in mind that AzOps converts them to .json or .parameters.json for deletion processing and outputs the results from the converted files here.{2}' -f $TemplateFilePath, $TemplateParameterFilePath, [environment]::NewLine } { $baseTemplateCheck -notin $deleteSetCheck } { 'Missing template file association:{1}{0} for deletion.{1}{1}Ensure that you have reviewed and confirmed the necessity of each deletion.{1}If you are deleting files with extension .bicep or .bicepparam, keep in mind that AzOps converts them to .json or .parameters.json for deletion processing and outputs the results from the converted files here.{1}' -f $TemplateFilePath, [environment]::NewLine } { $baseParameterCheck -notin $deleteSetCheck } { 'Missing parameter file association:{1}{0} for deletion.{1}{1}Ensure that you have reviewed and confirmed the necessity of each deletion.{1}If you are deleting files with extension .bicep or .bicepparam, keep in mind that AzOps converts them to .json or .parameters.json for deletion processing and outputs the results from the converted files here.{1}' -f $TemplateParameterFilePath, [environment]::NewLine } } # If there are $resultsFileAssociation, combine them with existing results and log a warning if ($resultsFileAssociation) { $finalResults = @() $finalResults += $resultsFileAssociation $finalResults += $allResults $allResults = $finalResults Write-AzOpsMessage -LogLevel Warning -LogString 'Set-AzOpsWhatIfOutput.WhatIfResults' -LogStringValues $allResults } } Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -ParameterFilePath $TemplateParameterFilePath -Results $allResults -RemoveAzOpsFlag $true if ($retry.Count -gt 0) { # Retry failed removals recursively Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzOpsDeployment.Resource.RetryCount' -LogStringValues $retry.Count foreach ($try in $retry) { $try.Status = $null } $removeActionRecursive = Remove-AzResourceRaw -InputObject $retry -Recursive $removeActionRecursiveRemaining = $removeActionRecursive | Where-Object { $_.Status -eq 'failed' } return $removeActionRecursiveRemaining } } else { # No resource to remove was found Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.ResourceNotFound' -LogStringValues $scopeObject.Resource, $scopeObject.Scope $results = 'What if operation failed:{1}Deletion of target resource {0}.{1}Resource could not be found' -f $scopeObject.Scope, [environment]::NewLine Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -ParameterFilePath $TemplateParameterFilePath -Results $results -RemoveAzOpsFlag $true return } } #endregion remove resources } } |