diff --git a/.github/actions/templates/getModuleTestFiles/action.yml b/.github/actions/templates/getModuleTestFiles/action.yml index 1cce2fb82e..b342b09d34 100644 --- a/.github/actions/templates/getModuleTestFiles/action.yml +++ b/.github/actions/templates/getModuleTestFiles/action.yml @@ -21,7 +21,7 @@ runs: # Grouping task logs Write-Output '::group::Get parameter files' # Load used functions - . (Join-Path $env:GITHUB_WORKSPACE 'utilities' 'pipelines' 'sharedScripts' 'Get-ModuleTestFileList.ps1') + . (Join-Path $env:GITHUB_WORKSPACE 'avm' 'utilities' 'pipelines' 'sharedScripts' 'Get-ModuleTestFileList.ps1') $functionInput = @{ ModulePath = Join-Path $env:GITHUB_WORKSPACE '${{ inputs.modulePath }}' diff --git a/.github/actions/templates/getWorkflowInput/action.yml b/.github/actions/templates/getWorkflowInput/action.yml index b9a7a25eb3..a6e92dd858 100644 --- a/.github/actions/templates/getWorkflowInput/action.yml +++ b/.github/actions/templates/getWorkflowInput/action.yml @@ -74,7 +74,7 @@ runs: # Otherwise retrieve default values else { # Load used functions - . (Join-Path $env:GITHUB_WORKSPACE 'utilities' 'pipelines' 'sharedScripts' 'Get-GitHubWorkflowDefaultInput.ps1') + . (Join-Path $env:GITHUB_WORKSPACE 'avm' 'utilities' 'pipelines' 'sharedScripts' 'Get-GitHubWorkflowDefaultInput.ps1') $functionInput = @{ workflowPath = '${{ inputs.workflowPath }}' diff --git a/.github/actions/templates/setEnvironment/action.yml b/.github/actions/templates/setEnvironment/action.yml index d85985c32c..a640470d7e 100644 --- a/.github/actions/templates/setEnvironment/action.yml +++ b/.github/actions/templates/setEnvironment/action.yml @@ -41,7 +41,7 @@ runs: Write-Verbose "Caller job id: ${{ github.job }}" -Verbose # Load used functions - . (Join-Path $env:GITHUB_WORKSPACE 'utilities' 'pipelines' 'sharedScripts' 'Set-EnvironmentOnAgent.ps1') + . (Join-Path $env:GITHUB_WORKSPACE 'avm' 'utilities' 'pipelines' 'sharedScripts' 'Set-EnvironmentOnAgent.ps1') # Define PS modules to install on the runner $modules = @( diff --git a/.github/actions/templates/validateModuleDeployment/action.yml b/.github/actions/templates/validateModuleDeployment/action.yml index 43adf76e6f..0e73947ba3 100644 --- a/.github/actions/templates/validateModuleDeployment/action.yml +++ b/.github/actions/templates/validateModuleDeployment/action.yml @@ -68,7 +68,7 @@ runs: Write-Output '::group::Replace tokens in template file' # Load used functions - . (Join-Path $env:GITHUB_WORKSPACE 'utilities' 'pipelines' 'tokensReplacement' 'Convert-TokensInFileList.ps1') + . (Join-Path $env:GITHUB_WORKSPACE 'avm' 'utilities' 'pipelines' 'tokensReplacement' 'Convert-TokensInFileList.ps1') # Get target files $targetFileList = @('${{ inputs.templateFilePath }}') @@ -127,7 +127,7 @@ runs: Write-Output '::group::Validate template file' # Load used functions - . (Join-Path $env:GITHUB_WORKSPACE 'utilities' 'pipelines' 'resourceDeployment' 'Test-TemplateDeployment.ps1') + . (Join-Path $env:GITHUB_WORKSPACE 'avm' 'utilities' 'pipelines' 'resourceDeployment' 'Test-TemplateDeployment.ps1') # Prepare general parameters # -------------------------- @@ -177,7 +177,7 @@ runs: Write-Output '::group::Deploy template file' # Load used functions - . (Join-Path $env:GITHUB_WORKSPACE 'utilities' 'pipelines' 'resourceDeployment' 'New-TemplateDeployment.ps1') + . (Join-Path $env:GITHUB_WORKSPACE 'avm' 'utilities' 'pipelines' 'resourceDeployment' 'New-TemplateDeployment.ps1') # Prepare general parameters # -------------------------- @@ -248,7 +248,7 @@ runs: Write-Output '::group::Remove deployed resources' # Load used function - . (Join-Path $env:GITHUB_WORKSPACE 'utilities' 'pipelines' 'resourceRemoval' 'Initialize-DeploymentRemoval.ps1') + . (Join-Path $env:GITHUB_WORKSPACE 'avm' 'utilities' 'pipelines' 'resourceRemoval' 'Initialize-DeploymentRemoval.ps1') $functionInput = @{ TemplateFilePath = Join-Path $env:GITHUB_WORKSPACE '${{ inputs.templateFilePath }}' diff --git a/avm/utilities/pipelines/resourceDeployment/New-TemplateDeployment.ps1 b/avm/utilities/pipelines/resourceDeployment/New-TemplateDeployment.ps1 new file mode 100644 index 0000000000..f2f8a9ff68 --- /dev/null +++ b/avm/utilities/pipelines/resourceDeployment/New-TemplateDeployment.ps1 @@ -0,0 +1,476 @@ +#region helper + +<# +.SYNOPSIS +If a deployment failed, get its error message + +.DESCRIPTION +If a deployment failed, get its error message based on the deployment name in the given scope + +.PARAMETER DeploymentScope +Mandatory. The scope to fetch the deployment from (e.g. resourcegroup, tenant,...) + +.PARAMETER DeploymentName +Mandatory. The name of the deployment to search for (e.g. 'storageAccounts-20220105T0701282538Z') + +.PARAMETER ResourceGroupName +Optional. The resource group to search the deployment in, if the scope is 'resourcegroup' + +.EXAMPLE +Get-ErrorMessageForScope -DeploymentScope 'resourcegroup' -DeploymentName 'storageAccounts-20220105T0701282538Z' -ResourceGroupName 'validation-rg' + +Get the error message of any failed deployment into resource group 'validation-rg' that has the name 'storageAccounts-20220105T0701282538Z' + +.EXAMPLE +Get-ErrorMessageForScope -DeploymentScope 'subscription' -DeploymentName 'resourcegroups-20220106T0401282538Z' + +Get the error message of any failed deployment into the current subscription that has the name 'storageAccounts-20220105T0701282538Z' +#> +function Get-ErrorMessageForScope { + + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] $DeploymentScope, + + [Parameter(Mandatory)] + [string] $DeploymentName, + + [Parameter(Mandatory = $false)] + [string] $ResourceGroupName = '' + ) + + switch ($deploymentScope) { + 'resourcegroup' { + $deployments = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $resourceGroupName + break + } + 'subscription' { + $deployments = Get-AzDeploymentOperation -DeploymentName $deploymentName + break + } + 'managementgroup' { + $deployments = Get-AzManagementGroupDeploymentOperation -DeploymentName $deploymentName + break + } + 'tenant' { + $deployments = Get-AzTenantDeploymentOperation -DeploymentName $deploymentName + break + } + } + if ($deployments) { + return ($deployments | Where-Object { $_.ProvisioningState -ne 'Succeeded' }).StatusMessage + } +} + +<# +.SYNOPSIS +Run a template deployment using a given parameter file + +.DESCRIPTION +Run a template deployment using a given parameter file. +Works on a resource group, subscription, managementgroup and tenant level + +.PARAMETER templateFilePath +Mandatory. The path to the deployment file + +.PARAMETER parameterFilePath +Optional. Path to the parameter file from root. Can be a single file, multiple files, or directory that contains (.json) files. + +.PARAMETER location +Mandatory. Location to test in. E.g. WestEurope + +.PARAMETER resourceGroupName +Optional. Name of the resource group to deploy into. Mandatory if deploying into a resource group (resource group level) + +.PARAMETER subscriptionId +Optional. ID of the subscription to deploy into. Mandatory if deploying into a subscription (subscription level) using a Management groups service connection + +.PARAMETER managementGroupId +Optional. Name of the management group to deploy into. Mandatory if deploying into a management group (management group level) + +.PARAMETER additionalTags +Optional. Provde a Key Value Pair (Object) that will be appended to the Parameter file tags. Example: @{myKey = 'myValue',myKey2 = 'myValue2'}. + +.PARAMETER additionalParameters +Optional. Additional parameters you can provide with the deployment. E.g. @{ resourceGroupName = 'myResourceGroup' } + +.PARAMETER retryLimit +Optional. Maximum retry limit if the deployment fails. Default is 3. + +.PARAMETER doNotThrow +Optional. Do not throw an exception if it failed. Still returns the error message though + +.EXAMPLE +New-TemplateDeploymentInner -templateFilePath 'C:/key-vault/vault/main.json' -parameterFilePath 'C:/key-vault/vault/.test/parameters.json' -location 'WestEurope' -resourceGroupName 'aLegendaryRg' + +Deploy the main.json of the KeyVault module with the parameter file 'parameters.json' using the resource group 'aLegendaryRg' in location 'WestEurope' + +.EXAMPLE +New-TemplateDeploymentInner -templateFilePath 'C:/key-vault/vault/main.bicep' -location 'WestEurope' -resourceGroupName 'aLegendaryRg' + +Deploy the main.bicep of the KeyVault module using the resource group 'aLegendaryRg' in location 'WestEurope' + +.EXAMPLE +New-TemplateDeploymentInner -templateFilePath 'C:/resources/resource-group/main.json' -location 'WestEurope' + +Deploy the main.json of the ResourceGroup module without a parameter file in location 'WestEurope' +#> +function New-TemplateDeploymentInner { + + [CmdletBinding(SupportsShouldProcess = $true)] + param ( + [Parameter(Mandatory)] + [string] $templateFilePath, + + [Parameter(Mandatory = $false)] + [string] $parameterFilePath, + + [Parameter(Mandatory = $false)] + [string] $resourceGroupName = '', + + [Parameter(Mandatory)] + [string] $location, + + [Parameter(Mandatory = $false)] + [string] $subscriptionId, + + [Parameter(Mandatory = $false)] + [string] $managementGroupId, + + [Parameter(Mandatory = $false)] + [PSCustomObject] $additionalTags, + + [Parameter(Mandatory = $false)] + [Hashtable] $additionalParameters, + + [Parameter(Mandatory = $false)] + [switch] $doNotThrow, + + [Parameter(Mandatory = $false)] + [int]$retryLimit = 3 + ) + + begin { + Write-Debug ('{0} entered' -f $MyInvocation.MyCommand) + + # Load helper + . (Join-Path (Get-Item -Path $PSScriptRoot).parent.FullName 'sharedScripts' 'Get-ScopeOfTemplateFile.ps1') + } + + process { + $deploymentNamePrefix = Split-Path -Path (Split-Path $templateFilePath -Parent) -LeafBase + if ([String]::IsNullOrEmpty($deploymentNamePrefix)) { + $deploymentNamePrefix = 'templateDeployment-{0}' -f (Split-Path $templateFilePath -LeafBase) + } + + $modulesRegex = '.+[\\|\/]modules[\\|\/]' + if ($templateFilePath -match $modulesRegex) { + # If we can assume we're operating in a module structure, we can further fetch the provider namespace & resource type + $shortPathElem = (($templateFilePath -split $modulesRegex)[1] -replace '\\', '/') -split '/' # e.g., app-configuration, configuration-store, .test, common, main.test.bicep + $providerNamespace = $shortPathElem[0] # e.g., app-configuration + $providerNamespaceShort = ($providerNamespace -split '-' | ForEach-Object { $_[0] }) -join '' # e.g., ac + + $resourceType = $shortPathElem[1] # e.g., configuration-store + $resourceTypeShort = ($resourceType -split '-' | ForEach-Object { $_[0] }) -join '' # e.g. cs + + $testFolderShort = Split-Path (Split-Path $templateFilePath -Parent) -Leaf # e.g., common + + $deploymentNamePrefix = "$providerNamespaceShort-$resourceTypeShort-$testFolderShort" # e.g., ac-cs-common + } + + $DeploymentInputs = @{ + TemplateFile = $templateFilePath + Verbose = $true + ErrorAction = 'Stop' + } + + # Parameter file provided yes/no + if (-not [String]::IsNullOrEmpty($parameterFilePath)) { + $DeploymentInputs['TemplateParameterFile'] = $parameterFilePath + } + + # Additional parameter object provided yes/no + if ($additionalParameters) { + $DeploymentInputs += $additionalParameters + } + + # Additional tags provides yes/no + # Append tags to parameters if resource supports them (all tags must be in one object) + if ($additionalTags) { + + # Parameter tags + if (-not [String]::IsNullOrEmpty($parameterFilePath)) { + $parameterFileTags = (ConvertFrom-Json (Get-Content -Raw -Path $parameterFilePath) -AsHashtable).parameters.tags.value + } + if (-not $parameterFileTags) { $parameterFileTags = @{} } + + # Pipeline tags + if ($additionalTags) { $parameterFileTags += $additionalTags } # If additionalTags object is provided, append tag to the resource + + # Overwrites parameter file tags parameter + Write-Verbose ("additionalTags: $(($additionalTags) ? ($additionalTags | ConvertTo-Json) : '[]')") + $DeploymentInputs += @{Tags = $parameterFileTags } + } + + ####################### + ## INVOKE DEPLOYMENT ## + ####################### + $deploymentScope = Get-ScopeOfTemplateFile -TemplateFilePath $templateFilePath + [bool]$Stoploop = $false + [int]$retryCount = 1 + $usedDeploymentNames = @() + + do { + # Generate a valid deployment name. Must match ^[-\w\._\(\)]+$ + do { + $deploymentName = ('{0}-t{1}-{2}' -f $deploymentNamePrefix, $retryCount, (Get-Date -Format 'yyyyMMddTHHMMssffffZ'))[0..63] -join '' + } while ($deploymentName -notmatch '^[-\w\._\(\)]+$') + + Write-Verbose "Deploying with deployment name [$deploymentName]" -Verbose + $usedDeploymentNames += $deploymentName + $DeploymentInputs['DeploymentName'] = $deploymentName + + try { + switch ($deploymentScope) { + 'resourcegroup' { + if (-not [String]::IsNullOrEmpty($subscriptionId)) { + Write-Verbose ('Setting context to subscription [{0}]' -f $subscriptionId) + $null = Set-AzContext -Subscription $subscriptionId + } + if (-not (Get-AzResourceGroup -Name $resourceGroupName -ErrorAction 'SilentlyContinue')) { + if ($PSCmdlet.ShouldProcess("Resource group [$resourceGroupName] in location [$location]", 'Create')) { + $null = New-AzResourceGroup -Name $resourceGroupName -Location $location + } + } + if ($PSCmdlet.ShouldProcess('Resource group level deployment', 'Create')) { + $res = New-AzResourceGroupDeployment @DeploymentInputs -ResourceGroupName $resourceGroupName + } + break + } + 'subscription' { + if (-not [String]::IsNullOrEmpty($subscriptionId)) { + Write-Verbose ('Setting context to subscription [{0}]' -f $subscriptionId) + $null = Set-AzContext -Subscription $subscriptionId + } + if ($PSCmdlet.ShouldProcess('Subscription level deployment', 'Create')) { + $res = New-AzSubscriptionDeployment @DeploymentInputs -Location $location + } + break + } + 'managementgroup' { + if ($PSCmdlet.ShouldProcess('Management group level deployment', 'Create')) { + $res = New-AzManagementGroupDeployment @DeploymentInputs -Location $location -ManagementGroupId $managementGroupId + } + break + } + 'tenant' { + if ($PSCmdlet.ShouldProcess('Tenant level deployment', 'Create')) { + $res = New-AzTenantDeployment @DeploymentInputs -Location $location + } + break + } + default { + throw "[$deploymentScope] is a non-supported template scope" + $Stoploop = $true + } + } + if ($res.ProvisioningState -eq 'Failed') { + # Deployment failed but no exception was thrown. Hence we must do it for the command. + + $errorInputObject = @{ + DeploymentScope = $deploymentScope + DeploymentName = $deploymentName + ResourceGroupName = $resourceGroupName + } + $exceptionMessage = Get-ErrorMessageForScope @errorInputObject + + throw "Deployed failed with provisioning state [Failed]. Error Message: [$exceptionMessage]. Please review the Azure logs of deployment [$deploymentName] in scope [$deploymentScope] for further details." + } + $Stoploop = $true + } catch { + if ($retryCount -ge $retryLimit) { + if ($doNotThrow) { + + # In case a deployment failes but not throws an exception (i.e. the exception message is empty) we try to fetch it via the deployment name + if ([String]::IsNullOrEmpty($PSitem.Exception.Message)) { + $errorInputObject = @{ + DeploymentScope = $deploymentScope + DeploymentName = $deploymentName + ResourceGroupName = $resourceGroupName + } + $exceptionMessage = Get-ErrorMessageForScope @errorInputObject + } else { + $exceptionMessage = $PSitem.Exception.Message + } + + return @{ + DeploymentNames = $usedDeploymentNames + Exception = $exceptionMessage + } + } else { + throw $PSitem.Exception.Message + } + $Stoploop = $true + } else { + Write-Verbose "Resource deployment Failed.. ($retryCount/$retryLimit) Retrying in 5 Seconds.. `n" + Write-Verbose ($PSitem.Exception.Message | Out-String) -Verbose + Start-Sleep -Seconds 5 + $retryCount++ + } + } + } + until ($Stoploop -eq $true -or $retryCount -gt $retryLimit) + + Write-Verbose 'Result' -Verbose + Write-Verbose '------' -Verbose + Write-Verbose ($res | Out-String) -Verbose + return @{ + DeploymentNames = $usedDeploymentNames + DeploymentOutput = $res.Outputs + } + } + + end { + Write-Debug ('{0} exited' -f $MyInvocation.MyCommand) + } +} +#endregion + +<# +.SYNOPSIS +Run a template deployment using a given parameter file + +.DESCRIPTION +Run a template deployment using a given parameter file. +Works on a resource group, subscription, managementgroup and tenant level + +.PARAMETER templateFilePath +Mandatory. The path to the deployment file + +.PARAMETER parameterFilePath +Optional. Path to the parameter file from root. Can be a single file, multiple files, or directory that contains (.json) files. + +.PARAMETER location +Mandatory. Location to test in. E.g. WestEurope + +.PARAMETER resourceGroupName +Optional. Name of the resource group to deploy into. Mandatory if deploying into a resource group (resource group level) + +.PARAMETER subscriptionId +Optional. ID of the subscription to deploy into. Mandatory if deploying into a subscription (subscription level) using a Management groups service connection + +.PARAMETER managementGroupId +Optional. Name of the management group to deploy into. Mandatory if deploying into a management group (management group level) + +.PARAMETER additionalTags +Optional. Provide a Key Value Pair (Object) that will be appended to the Parameter file tags. Example: @{myKey = 'myValue', myKey2 = 'myValue2'}. + +.PARAMETER additionalParameters +Optional. Additional parameters you can provide with the deployment. E.g. @{ resourceGroupName = 'myResourceGroup' } + +.PARAMETER retryLimit +Optional. Maximum retry limit if the deployment fails. Default is 3. + +.PARAMETER doNotThrow +Optional. Do not throw an exception if it failed. Still returns the error message though + +.EXAMPLE +New-TemplateDeployment -templateFilePath 'C:/key-vault/vault/main.bicep' -parameterFilePath 'C:/key-vault/vault/.test/parameters.json' -location 'WestEurope' -resourceGroupName 'aLegendaryRg' + +Deploy the main.bicep of the 'key-vault/vault' module with the parameter file 'parameters.json' using the resource group 'aLegendaryRg' in location 'WestEurope' + +.EXAMPLE +New-TemplateDeployment -templateFilePath 'C:/resources/resource-group/main.bicep' -location 'WestEurope' + +Deploy the main.bicep of the 'resources/resource-group' module in location 'WestEurope' without a parameter file + +.EXAMPLE +New-TemplateDeployment -templateFilePath 'C:/resources/resource-group/main.json' -parameterFilePath 'C:/resources/resource-group/.test/parameters.json' -location 'WestEurope' + +Deploy the main.json of the 'resources/resource-group' module with the parameter file 'parameters.json' in location 'WestEurope' +#> +function New-TemplateDeployment { + + [CmdletBinding(SupportsShouldProcess)] + param ( + [Parameter(Mandatory)] + [string] $templateFilePath, + + [Parameter(Mandatory = $false)] + [string[]] $parameterFilePath, + + [Parameter(Mandatory)] + [string] $location, + + [Parameter(Mandatory = $false)] + [string] $resourceGroupName = '', + + [Parameter(Mandatory = $false)] + [string] $subscriptionId, + + [Parameter(Mandatory = $false)] + [string] $managementGroupId, + + [Parameter(Mandatory = $false)] + [Hashtable] $additionalParameters, + + [Parameter(Mandatory = $false)] + [PSCustomObject] $additionalTags, + + [Parameter(Mandatory = $false)] + [switch] $doNotThrow, + + [Parameter(Mandatory = $false)] + [int]$retryLimit = 3 + ) + + begin { + Write-Debug ('{0} entered' -f $MyInvocation.MyCommand) + } + + process { + ## Assess Provided Parameter Path + if ((-not [String]::IsNullOrEmpty($parameterFilePath)) -and (Test-Path -Path $parameterFilePath -PathType 'Container') -and $parameterFilePath.Length -eq 1) { + ## Transform Path to Files + $parameterFilePath = Get-ChildItem $parameterFilePath -Recurse -Filter *.json | Select-Object -ExpandProperty FullName + Write-Verbose "Detected Parameter File(s)/Directory - Count: `n $($parameterFilePath.Count)" + } + + ## Iterate through each file + $deploymentInputObject = @{ + templateFilePath = $templateFilePath + additionalTags = $additionalTags + additionalParameters = $additionalParameters + location = $location + resourceGroupName = $resourceGroupName + subscriptionId = $subscriptionId + managementGroupId = $managementGroupId + doNotThrow = $doNotThrow + retryLimit = $retryLimit + } + if ($parameterFilePath) { + if ($parameterFilePath -is [array]) { + $deploymentResult = [System.Collections.ArrayList]@() + foreach ($path in $parameterFilePath) { + if ($PSCmdlet.ShouldProcess("Deployment for parameter file [$parameterFilePath]", 'Trigger')) { + $deploymentResult += New-TemplateDeploymentInner @deploymentInputObject -parameterFilePath $path + } + } + return $deploymentResult + } else { + if ($PSCmdlet.ShouldProcess("Deployment for single parameter file [$parameterFilePath]", 'Trigger')) { + return New-TemplateDeploymentInner @deploymentInputObject -parameterFilePath $parameterFilePath + } + } + } else { + if ($PSCmdlet.ShouldProcess('Deployment without parameter file', 'Trigger')) { + return New-TemplateDeploymentInner @deploymentInputObject + } + } + } + + end { + Write-Debug ('{0} exited' -f $MyInvocation.MyCommand) + } +} diff --git a/avm/utilities/pipelines/resourceDeployment/Test-TemplateDeployment.ps1 b/avm/utilities/pipelines/resourceDeployment/Test-TemplateDeployment.ps1 new file mode 100644 index 0000000000..2593de5df6 --- /dev/null +++ b/avm/utilities/pipelines/resourceDeployment/Test-TemplateDeployment.ps1 @@ -0,0 +1,185 @@ +<# +.SYNOPSIS +Run a template validation using a given parameter file + +.DESCRIPTION +Run a template validation using a given parameter file +Works on a resource group, subscription, managementgroup and tenant level + +.PARAMETER parametersBasePath +Mandatory. The path to the root of the parameters folder to test with + +.PARAMETER templateFilePath +Mandatory. Path to the template file from root. + +.PARAMETER parameterFilePath +Optional. Path to the parameter file from root. + +.PARAMETER location +Mandatory. Location to test in. E.g. WestEurope + +.PARAMETER resourceGroupName +Optional. Name of the resource group to deploy into. Mandatory if deploying into a resource group (resource group level) + +.PARAMETER subscriptionId +Optional. ID of the subscription to deploy into. Mandatory if deploying into a subscription (subscription level) using a Management groups service connection + +.PARAMETER managementGroupId +Optional. Name of the management group to deploy into. Mandatory if deploying into a management group (management group level) + +.PARAMETER additionalParameters +Optional. Additional parameters you can provide with the deployment. E.g. @{ resourceGroupName = 'myResourceGroup' } + +.EXAMPLE +Test-TemplateDeployment -templateFilePath 'C:/key-vault/vault/main.bicep' -parameterFilePath 'C:/key-vault/vault/.test/parameters.json' -location 'WestEurope' -resourceGroupName 'aLegendaryRg' + +Test the main.bicep of the KeyVault module with the parameter file 'parameters.json' using the resource group 'aLegendaryRg' in location 'WestEurope' + +.EXAMPLE +Test-TemplateDeployment -templateFilePath 'C:/key-vault/vault/main.bicep' -location 'WestEurope' -resourceGroupName 'aLegendaryRg' + +Test the main.bicep of the KeyVault module using the resource group 'aLegendaryRg' in location 'WestEurope' + +.EXAMPLE +Test-TemplateDeployment -templateFilePath 'C:/resources/resource-group/main.json' -parameterFilePath 'C:/resources/resource-group/.test/parameters.json' -location 'WestEurope' + +Test the main.json of the ResourceGroup module with the parameter file 'parameters.json' in location 'WestEurope' +#> +function Test-TemplateDeployment { + + [CmdletBinding(SupportsShouldProcess)] + param ( + [Parameter(Mandatory)] + [string] $templateFilePath, + + [Parameter(Mandatory)] + [string] $location, + + [Parameter(Mandatory = $false)] + [string] $parameterFilePath, + + [Parameter(Mandatory = $false)] + [string] $resourceGroupName, + + [Parameter(Mandatory = $false)] + [string] $subscriptionId, + + [Parameter(Mandatory = $false)] + [string] $managementGroupId, + + [Parameter(Mandatory = $false)] + [Hashtable] $additionalParameters + ) + + begin { + Write-Debug ('{0} entered' -f $MyInvocation.MyCommand) + + # Load helper + . (Join-Path (Get-Item -Path $PSScriptRoot).parent.FullName 'sharedScripts' 'Get-ScopeOfTemplateFile.ps1') + } + + process { + $DeploymentInputs = @{ + TemplateFile = $templateFilePath + Verbose = $true + OutVariable = 'ValidationErrors' + } + if (-not [String]::IsNullOrEmpty($parameterFilePath)) { + $DeploymentInputs['TemplateParameterFile'] = $parameterFilePath + } + $ValidationErrors = $null + + # Additional parameter object provided yes/no + if ($additionalParameters) { + $DeploymentInputs += $additionalParameters + } + + $deploymentScope = Get-ScopeOfTemplateFile -TemplateFilePath $templateFilePath -Verbose + + $deploymentNamePrefix = Split-Path -Path (Split-Path $templateFilePath -Parent) -LeafBase + if ([String]::IsNullOrEmpty($deploymentNamePrefix)) { + $deploymentNamePrefix = 'templateDeployment-{0}' -f (Split-Path $templateFilePath -LeafBase) + } + if ($templateFilePath -match '.*(\\|\/)Microsoft.+') { + # If we can assume we're operating in a module structure, we can further fetch the provider namespace & resource type + $shortPathElem = (($templateFilePath -split 'Microsoft\.')[1] -replace '\\', '/') -split '/' # e.g., AppConfiguration, configurationStores, .test, common, main.test.bicep + $providerNamespace = $shortPathElem[0] # e.g., AppConfiguration + $providerNamespaceShort = ($providerNamespace -creplace '[^A-Z]').ToLower() # e.g., ac + + $resourceType = $shortPathElem[1] # e.g., configurationStores + $resourceTypeShort = ('{0}{1}' -f ($resourceType.ToLower())[0], ($resourceType -creplace '[^A-Z]')).ToLower() # e.g. cs + + $testFolderShort = Split-Path (Split-Path $templateFilePath -Parent) -Leaf # e.g., common + + $deploymentNamePrefix = "$providerNamespaceShort-$resourceTypeShort-$testFolderShort" # e.g., ac-cs-common + } + + # Generate a valid deployment name. Must match ^[-\w\._\(\)]+$ + do { + $deploymentName = ('{0}-{1}' -f $deploymentNamePrefix, (Get-Date -Format 'yyyyMMddTHHMMssffffZ'))[0..63] -join '' + } while ($deploymentName -notmatch '^[-\w\._\(\)]+$') + + if ($deploymentScope -ne 'resourceGroup') { + Write-Verbose "Testing with deployment name [$deploymentName]" -Verbose + $DeploymentInputs['DeploymentName'] = $deploymentName + } + + ################# + ## INVOKE TEST ## + ################# + switch ($deploymentScope) { + 'resourceGroup' { + if (-not [String]::IsNullOrEmpty($subscriptionId)) { + Write-Verbose ('Setting context to subscription [{0}]' -f $subscriptionId) + $null = Set-AzContext -Subscription $subscriptionId + } + if (-not (Get-AzResourceGroup -Name $resourceGroupName -ErrorAction 'SilentlyContinue')) { + if ($PSCmdlet.ShouldProcess("Resource group [$resourceGroupName] in location [$location]", 'Create')) { + $null = New-AzResourceGroup -Name $resourceGroupName -Location $location + } + } + if ($PSCmdlet.ShouldProcess('Resource group level deployment', 'Test')) { + $res = Test-AzResourceGroupDeployment @DeploymentInputs -ResourceGroupName $resourceGroupName + } + break + } + 'subscription' { + if (-not [String]::IsNullOrEmpty($subscriptionId)) { + Write-Verbose ('Setting context to subscription [{0}]' -f $subscriptionId) + $null = Set-AzContext -Subscription $subscriptionId + } + if ($PSCmdlet.ShouldProcess('Subscription level deployment', 'Test')) { + $res = Test-AzSubscriptionDeployment @DeploymentInputs -Location $Location + } + break + } + 'managementGroup' { + if ($PSCmdlet.ShouldProcess('Management group level deployment', 'Test')) { + $res = Test-AzManagementGroupDeployment @DeploymentInputs -Location $Location -ManagementGroupId $ManagementGroupId + } + break + } + 'tenant' { + Write-Verbose 'Handling tenant level validation' + if ($PSCmdlet.ShouldProcess('Tenant level deployment', 'Test')) { + $res = Test-AzTenantDeployment @DeploymentInputs -Location $location + } + break + } + default { + throw "[$deploymentScope] is a non-supported template scope" + } + } + if ($ValidationErrors) { + if ($res.Details) { Write-Warning ($res.Details | ConvertTo-Json -Depth 10 | Out-String) } + if ($res.Message) { Write-Warning $res.Message } + Write-Error 'Template is not valid.' + } else { + Write-Verbose 'Template is valid' -Verbose + } + } + + end { + Write-Debug ('{0} exited' -f $MyInvocation.MyCommand) + } +} diff --git a/avm/utilities/pipelines/resourceRemoval/Initialize-DeploymentRemoval.ps1 b/avm/utilities/pipelines/resourceRemoval/Initialize-DeploymentRemoval.ps1 new file mode 100644 index 0000000000..3c2b11eeec --- /dev/null +++ b/avm/utilities/pipelines/resourceRemoval/Initialize-DeploymentRemoval.ps1 @@ -0,0 +1,117 @@ +<# +.SYNOPSIS +Remove deployed resources based on their deploymentName(s) + +.DESCRIPTION +Remove deployed resources based on their deploymentName(s) + +.PARAMETER DeploymentName(s) +Mandatory. The name(s) of the deployment(s) + +.PARAMETER TemplateFilePath +Mandatory. The path to the template used for the deployment. Used to determine the level/scope (e.g. subscription) + +.PARAMETER ResourceGroupName +Optional. The name of the resource group the deployment was happening in. Relevant for resource-group level deployments. + +.PARAMETER ManagementGroupId +Optional. The ID of the management group to fetch deployments from. Relevant for management-group level deployments. + +.EXAMPLE +Initialize-DeploymentRemoval -DeploymentName 'n-vw-t1-20211204T1812029146Z' -TemplateFilePath "$home/ResourceModules/modules/network/virtual-wan/main.bicep" -resourceGroupName 'test-virtualWan-rg' + +Remove the deployment 'n-vw-t1-20211204T1812029146Z' from resource group 'test-virtualWan-rg' that was executed using template in path "$home/ResourceModules/modules/network/virtual-wan/main.bicep" +#> +function Initialize-DeploymentRemoval { + + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [Alias('DeploymentName')] + [string[]] $DeploymentNames, + + [Parameter(Mandatory = $true)] + [string] $TemplateFilePath, + + [Parameter(Mandatory = $false)] + [string] $ResourceGroupName, + + [Parameter(Mandatory = $false)] + [string] $subscriptionId, + + [Parameter(Mandatory = $false)] + [string] $ManagementGroupId + ) + + begin { + Write-Debug ('{0} entered' -f $MyInvocation.MyCommand) + # Load functions + . (Join-Path $PSScriptRoot 'helper' 'Remove-Deployment.ps1') + } + + process { + + if (-not [String]::IsNullOrEmpty($subscriptionId)) { + Write-Verbose ('Setting context to subscription [{0}]' -f $subscriptionId) + $null = Set-AzContext -Subscription $subscriptionId + } + + # The initial sequence is a general order-recommendation + $removalSequence = @( + 'Microsoft.Authorization/locks', + 'Microsoft.Authorization/roleAssignments', + 'Microsoft.Insights/diagnosticSettings', + 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups', + 'Microsoft.Network/privateEndpoints', + 'Microsoft.Network/azureFirewalls', + 'Microsoft.Network/virtualHubs', + 'Microsoft.Network/virtualWans', + 'Microsoft.OperationsManagement/solutions', + 'Microsoft.OperationalInsights/workspaces/linkedServices', + 'Microsoft.OperationalInsights/workspaces', + 'Microsoft.KeyVault/vaults', + 'Microsoft.Authorization/policyExemptions', + 'Microsoft.Authorization/policyAssignments', + 'Microsoft.Authorization/policySetDefinitions', + 'Microsoft.Authorization/policyDefinitions' + 'Microsoft.Sql/managedInstances', + 'Microsoft.MachineLearningServices/workspaces', + 'Microsoft.Resources/resourceGroups', + 'Microsoft.Compute/virtualMachines' + ) + + Write-Verbose ('Handling resource removal with deployment names [{0}]' -f ($deploymentNames -join ', ')) -Verbose + + ### CODE LOCATION: Add custom removal sequence here + ## Add custom module-specific removal sequence following the example below + # $moduleName = Split-Path (Split-Path (Split-Path $templateFilePath -Parent) -Parent) -LeafBase + # switch ($moduleName) { + # '' { # For example: 'virtualWans', 'automationAccounts' + # $removalSequence += @( + # '', # For example: 'Microsoft.Network/vpnSites', 'Microsoft.OperationalInsights/workspaces/linkedServices' + # '', + # '' + # ) + # break + # } + # } + + # Invoke removal + $inputObject = @{ + DeploymentNames = $DeploymentNames + TemplateFilePath = $templateFilePath + RemovalSequence = $removalSequence + } + if (-not [String]::IsNullOrEmpty($resourceGroupName)) { + $inputObject['resourceGroupName'] = $resourceGroupName + } + if (-not [String]::IsNullOrEmpty($ManagementGroupId)) { + $inputObject['ManagementGroupId'] = $ManagementGroupId + } + Remove-Deployment @inputObject + } + + end { + Write-Debug ('{0} exited' -f $MyInvocation.MyCommand) + } +} diff --git a/avm/utilities/pipelines/resourceRemoval/helper/Get-DeploymentTargetResourceList.ps1 b/avm/utilities/pipelines/resourceRemoval/helper/Get-DeploymentTargetResourceList.ps1 new file mode 100644 index 0000000000..42f521b9ce --- /dev/null +++ b/avm/utilities/pipelines/resourceRemoval/helper/Get-DeploymentTargetResourceList.ps1 @@ -0,0 +1,224 @@ +#region helper +<# +.SYNOPSIS +Get all deployments that match a given deployment name in a given scope + +.DESCRIPTION +Get all deployments that match a given deployment name in a given scope. Works recursively through the deployment tree. + +.PARAMETER Name +Mandatory. The deployment name to search for + +.PARAMETER ResourceGroupName +Optional. The name of the resource group for scope 'resourcegroup' + +.PARAMETER ManagementGroupId +Optional. The ID of the management group to fetch deployments from. Relevant for management-group level deployments. + +.PARAMETER Scope +Mandatory. The scope to search in + +.EXAMPLE +Get-DeploymentTargetResourceListInner -Name 'keyvault-12356' -Scope 'resourcegroup' + +Get all deployments that match name 'keyvault-12356' in scope 'resourcegroup' + +.EXAMPLE +Get-ResourceIdsOfDeploymentInner -Name 'mgmtGroup-12356' -Scope 'managementGroup' -ManagementGroupId 'af760cf5-3c9e-4804-a59a-a51741daa350' + +Get all deployments that match name 'mgmtGroup-12356' in scope 'managementGroup' + +.NOTES +Works after the principal: +- Find all deployments for the given deployment name +- If any of them are not a deployments, add their target resource to the result set (as they are e.g. a resource) +- If any of them is are deployments, recursively invoke this function for them to get their contained target resources +#> +function Get-DeploymentTargetResourceListInner { + + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] $Name, + + [Parameter(Mandatory = $false)] + [string] $ResourceGroupName, + + [Parameter(Mandatory = $false)] + [string] $ManagementGroupId, + + [Parameter(Mandatory)] + [ValidateSet( + 'resourcegroup', + 'subscription', + 'managementgroup', + 'tenant' + )] + [string] $Scope + ) + + $resultSet = [System.Collections.ArrayList]@() + + ############################################## + # Get all deployment children based on scope # + ############################################## + switch ($Scope) { + 'resourcegroup' { + if (Get-AzResourceGroup -Name $resourceGroupName -ErrorAction 'SilentlyContinue') { + [array]$deploymentTargets = (Get-AzResourceGroupDeploymentOperation -DeploymentName $name -ResourceGroupName $resourceGroupName).TargetResource | Where-Object { $_ -ne $null } + } else { + # In case the resource group itself was already deleted, there is no need to try and fetch deployments from it + # In case we already have any such resources in the list, we should remove them + [array]$resultSet = $resultSet | Where-Object { $_ -notmatch "/resourceGroups/$resourceGroupName/" } + } + break + } + 'subscription' { + [array]$deploymentTargets = (Get-AzDeploymentOperation -DeploymentName $name).TargetResource | Where-Object { $_ -ne $null } + break + } + 'managementgroup' { + [array]$deploymentTargets = (Get-AzManagementGroupDeploymentOperation -DeploymentName $name -ManagementGroupId $ManagementGroupId).TargetResource | Where-Object { $_ -ne $null } + break + } + 'tenant' { + [array]$deploymentTargets = (Get-AzTenantDeploymentOperation -DeploymentName $name).TargetResource | Where-Object { $_ -ne $null } + break + } + } + + ########################### + # Manage nested resources # + ########################### + foreach ($deployment in ($deploymentTargets | Where-Object { $_ -notmatch '/deployments/' } )) { + Write-Verbose ('Found deployed resource [{0}]' -f $deployment) + [array]$resultSet += $deployment + } + + ############################# + # Manage nested deployments # + ############################# + foreach ($deployment in ($deploymentTargets | Where-Object { $_ -match '/deployments/' } )) { + $name = Split-Path $deployment -Leaf + if ($deployment -match '/resourceGroups/') { + # Resource Group Level Child Deployments # + ########################################## + Write-Verbose ('Found [resource group] deployment [{0}]' -f $deployment) + $resourceGroupName = $deployment.split('/resourceGroups/')[1].Split('/')[0] + [array]$resultSet += Get-DeploymentTargetResourceListInner -Name $name -Scope 'resourcegroup' -ResourceGroupName $ResourceGroupName + } elseif ($deployment -match '/subscriptions/') { + # Subscription Level Child Deployments # + ######################################## + Write-Verbose ('Found [subscription] deployment [{0}]' -f $deployment) + [array]$resultSet += Get-DeploymentTargetResourceListInner -Name $name -Scope 'subscription' + } elseif ($deployment -match '/managementgroups/') { + # Management Group Level Child Deployments # + ############################################ + Write-Verbose ('Found [management group] deployment [{0}]' -f $deployment) + [array]$resultSet += Get-DeploymentTargetResourceListInner -Name $name -Scope 'managementgroup' -ManagementGroupId $ManagementGroupId + } else { + # Tenant Level Child Deployments # + ################################## + Write-Verbose ('Found [tenant] deployment [{0}]' -f $deployment) + [array]$resultSet += Get-DeploymentTargetResourceListInner -Name $name -Scope 'tenant' + } + } + + return $resultSet +} +#endregion + +<# +.SYNOPSIS +Get all deployments that match a given deployment name in a given scope using a retry mechanic + +.DESCRIPTION +Get all deployments that match a given deployment name in a given scope using a retry mechanic. + +.PARAMETER ResourceGroupName +Optional. The name of the resource group for scope 'resourcegroup' + +.PARAMETER ManagementGroupId +Optional. The ID of the management group to fetch deployments from. Relevant for management-group level deployments. + +.PARAMETER Name +Optional. The deployment name to use for the removal + +.PARAMETER Scope +Mandatory. The scope to search in + +.PARAMETER SearchRetryLimit +Optional. The maximum times to retry the search for resources via their removal tag + +.PARAMETER SearchRetryInterval +Optional. The time to wait in between the search for resources via their remove tags + +.EXAMPLE +Get-DeploymentTargetResourceList -name 'KeyVault' -ResourceGroupName 'validation-rg' -scope 'resourcegroup' + +Get all deployments that match name 'KeyVault' in scope 'resourcegroup' of resource group 'validation-rg' + +.EXAMPLE +Get-ResourceIdsOfDeployment -Name 'mgmtGroup-12356' -Scope 'managementGroup' -ManagementGroupId 'af760cf5-3c9e-4804-a59a-a51741daa350' + +Get all deployments that match name 'mgmtGroup-12356' in scope 'managementGroup' + +#> +function Get-DeploymentTargetResourceList { + + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [string] $ResourceGroupName, + + [Parameter(Mandatory = $false)] + [string] $ManagementGroupId, + + [Parameter(Mandatory = $true)] + [string] $Name, + + [Parameter(Mandatory = $true)] + [ValidateSet( + 'resourcegroup', + 'subscription', + 'managementgroup', + 'tenant' + )] + [string] $Scope, + + [Parameter(Mandatory = $false)] + [int] $SearchRetryLimit = 40, + + [Parameter(Mandatory = $false)] + [int] $SearchRetryInterval = 60 + ) + + $searchRetryCount = 1 + do { + $innerInputObject = @{ + Name = $name + Scope = $scope + ErrorAction = 'SilentlyContinue' + } + if (-not [String]::IsNullOrEmpty($resourceGroupName)) { + $innerInputObject['resourceGroupName'] = $resourceGroupName + } + if (-not [String]::IsNullOrEmpty($ManagementGroupId)) { + $innerInputObject['ManagementGroupId'] = $ManagementGroupId + } + [array]$targetResources = Get-DeploymentTargetResourceListInner @innerInputObject + if ($targetResources) { + break + } + Write-Verbose ('No deployment found by name [{0}] in scope [{1}]. Retrying in [{2}] seconds [{3}/{4}]' -f $name, $scope, $searchRetryInterval, $searchRetryCount, $searchRetryLimit) -Verbose + Start-Sleep $searchRetryInterval + $searchRetryCount++ + } while ($searchRetryCount -le $searchRetryLimit) + + if (-not $targetResources) { + Write-Warning "No deployment target resources found for [$name]" + return @() + } + + return $targetResources +} diff --git a/avm/utilities/pipelines/resourceRemoval/helper/Get-OrderedResourcesList.ps1 b/avm/utilities/pipelines/resourceRemoval/helper/Get-OrderedResourcesList.ps1 new file mode 100644 index 0000000000..0c94365267 --- /dev/null +++ b/avm/utilities/pipelines/resourceRemoval/helper/Get-OrderedResourcesList.ps1 @@ -0,0 +1,46 @@ +<# +.SYNOPSIS +Order the given resources as per the provided ordered resource type list + +.DESCRIPTION +Order the given resources as per the provided ordered resource type list. +Any resources not in that list will be appended after. + +.PARAMETER ResourcesToOrder +Mandatory. The resources to order. Items are stacked as per their order in the list (i.e. the first items is put on top, then the next, etc.) +Each item should be in format: +@{ + name = '...' + resourceID = '...' + type = '...' +} + +.PARAMETER Order +Optional. The order of resource types to apply for deletion. If order is provided, the list is returned as is + +.EXAMPLE +Get-OrderedResourcesList -ResourcesToOrder @(@{ name = 'myAccount'; resourceId '(..)/Microsoft.Automation/automationAccounts/myAccount'; type = 'Microsoft.Automation/automationAccounts'}) -Order @('Microsoft.Insights/diagnosticSettings','Microsoft.Automation/automationAccounts') + +Order the given list of resources which would put the diagnostic settings to the front of the list, then the automation account, then the rest. As only one item exists, the list is returned as is. +#> +function Get-OrderedResourcesList { + + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [hashtable[]] $ResourcesToOrder, + + [Parameter(Mandatory = $false)] + [string[]] $Order = @() + ) + + # Going from back to front of the list to stack in the correct order + for ($orderIndex = ($order.Count - 1); $orderIndex -ge 0; $orderIndex--) { + $searchItem = $order[$orderIndex] + if ($elementsContained = $resourcesToOrder | Where-Object { $_.type -eq $searchItem }) { + $resourcesToOrder = @() + $elementsContained + ($resourcesToOrder | Where-Object { $_.type -ne $searchItem }) + } + } + + return $resourcesToOrder +} diff --git a/avm/utilities/pipelines/resourceRemoval/helper/Get-ResourceIdsAsFormattedObjectList.ps1 b/avm/utilities/pipelines/resourceRemoval/helper/Get-ResourceIdsAsFormattedObjectList.ps1 new file mode 100644 index 0000000000..2ac9fbe0ea --- /dev/null +++ b/avm/utilities/pipelines/resourceRemoval/helper/Get-ResourceIdsAsFormattedObjectList.ps1 @@ -0,0 +1,112 @@ +<# +.SYNOPSIS +Format the provide resource IDs into objects of resourceID, name & type + +.DESCRIPTION +Format the provide resource IDs into objects of resourceID, name & type + +.PARAMETER ResourceIds +Optional. The resource IDs to process + +.EXAMPLE +Get-ResourceIdsAsFormattedObjectList -ResourceIds @('/subscriptions//resourceGroups/test-analysisServices-rg/providers/Microsoft.Storage/storageAccounts/adpsxxazsaaspar01') + +Returns an object @{ + resourceId = '/subscriptions//resourceGroups/test-analysisServices-rg/providers/Microsoft.Storage/storageAccounts/adpsxxazsaaspar01' + type = 'Microsoft.Storage/storageAccounts' + name = 'adpsxxazsaaspar01' +} +#> +function Get-ResourceIdsAsFormattedObjectList { + + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [string[]] $ResourceIds = @() + ) + + $formattedResources = [System.Collections.ArrayList]@() + + # If any resource is deployed at a resource group level, we store all resources in this resource group in this array. Essentially it's a cache. + $allResourceGroupResources = @() + + foreach ($resourceId in $resourceIds) { + + $idElements = $resourceId.Split('/') + + switch ($idElements.Count) { + { $PSItem -eq 5 } { + if ($idElements[3] -eq 'managementGroups') { + # management-group level management group (e.g. '/providers/Microsoft.Management/managementGroups/testMG') + $formattedResources += @{ + resourceId = $resourceId + type = $idElements[2, 3] -join '/' + } + } else { + # subscription level resource group (e.g. '/subscriptions//resourceGroups/myRG') + $formattedResources += @{ + resourceId = $resourceId + type = 'Microsoft.Resources/resourceGroups' + } + } + break + } + { $PSItem -eq 6 } { + # subscription-level resource group + $formattedResources += @{ + resourceId = $resourceId + type = $idElements[4, 5] -join '/' + } + break + } + { $PSItem -eq 7 } { + if (($resourceId.Split('/'))[3] -ne 'resourceGroups') { + # subscription-level resource + $formattedResources += @{ + resourceId = $resourceId + type = $idElements[4, 5] -join '/' + } + } else { + # resource group-level + if ($allResourceGroupResources.Count -eq 0) { + $allResourceGroupResources = Get-AzResource -ResourceGroupName $resourceGroupName -Name '*' + } + $expandedResources = $allResourceGroupResources | Where-Object { $_.ResourceId.startswith($resourceId) } + $expandedResources = $expandedResources | Sort-Object -Descending -Property { $_.ResourceId.Split('/').Count } + foreach ($resource in $expandedResources) { + $formattedResources += @{ + resourceId = $resource.ResourceId + type = $resource.Type + } + } + } + break + } + { $PSItem -ge 8 } { + # child-resource level + # Find the last resource type reference in the resourceId. + # E.g. Microsoft.Automation/automationAccounts/provider/Microsoft.Authorization/roleAssignments/... returns the index of 'Microsoft.Authorization' + $indexOfResourceType = $idElements.IndexOf(($idElements -like 'Microsoft.**')[-1]) + $type = $idElements[$indexOfResourceType, ($indexOfResourceType + 1)] -join '/' + + # Concat rest of resource type along the ID + $partCounter = $indexOfResourceType + 1 + while (-not ($partCounter + 2 -gt $idElements.Count - 1)) { + $type += ('/{0}' -f $idElements[($partCounter + 2)]) + $partCounter = $partCounter + 2 + } + + $formattedResources += @{ + resourceId = $resourceId + type = $type + } + break + } + Default { + throw "Failed to process resource ID [$resourceId]" + } + } + } + + return $formattedResources +} diff --git a/avm/utilities/pipelines/resourceRemoval/helper/Invoke-ResourceLockRemoval.ps1 b/avm/utilities/pipelines/resourceRemoval/helper/Invoke-ResourceLockRemoval.ps1 new file mode 100644 index 0000000000..ccd41cbceb --- /dev/null +++ b/avm/utilities/pipelines/resourceRemoval/helper/Invoke-ResourceLockRemoval.ps1 @@ -0,0 +1,73 @@ +<# +.SYNOPSIS +Remove resource locks from a resource or a specific resource lock. + +.DESCRIPTION +Remove resource locks from a resource or a specific resource lock. + +.PARAMETER ResourceId +Mandatory. The resourceID of the resource to check, and remove a resource lock from. + +.PARAMETER Type +Optional. The type of the resource. If the resource is a lock, the lock itself will be removed. If the resource is a resource, all locks on the resource will be removed. If not specified, the resource will be checked for locks, and if any are found, all locks will be removed. + +.PARAMETER RetryLimit +Optional. The number of times to retry checking if the lock is removed. + +.PARAMETER RetryInterval +Optional. The number of seconds to wait between each retry. + +.EXAMPLE +Invoke-ResourceLockRemoval -ResourceId '/subscriptions/.../resourceGroups/validation-rg/.../resource-name' + +Check if the resource 'resource-name' is locked. If it is, remove the lock. +#> +function Invoke-ResourceLockRemoval { + [CmdletBinding(SupportsShouldProcess)] + param ( + [Parameter(Mandatory = $true)] + [string] $ResourceId, + + [Parameter(Mandatory = $false)] + [string] $Type, + + [Parameter(Mandatory = $false)] + [int] $RetryLimit = 10, + + [Parameter(Mandatory = $false)] + [int] $RetryInterval = 10 + ) + # Load functions + . (Join-Path $PSScriptRoot 'Invoke-ResourceLockRetrieval.ps1') + + $resourceLock = Invoke-ResourceLockRetrieval -ResourceId $ResourceId -Type $Type + + $isLocked = $resourceLock.count -gt 0 + if (-not $isLocked) { + return + } + + $resourceLock | ForEach-Object { + Write-Warning (' [-] Removing lock [{0}] on [{1}] of type [{2}].' -f $_.Name, $_.ResourceName, $_.ResourceType) + if ($PSCmdlet.ShouldProcess(('Lock [{0}] on resource [{1}] of type [{2}].' -f $_.Name, $_.ResourceName, $_.ResourceType ), 'Remove')) { + $null = $_ | Remove-AzResourceLock -Force + } + } + + $retryCount = 0 + do { + $retryCount++ + if ($retryCount -ge $RetryLimit) { + Write-Warning (' [!] Lock was not removed after {1} seconds. Continuing with resource removal.' -f ($retryCount * $RetryInterval)) + break + } + Write-Verbose ' [⏱️] Waiting for lock to be removed.' -Verbose + Start-Sleep -Seconds $RetryInterval + + # Rechecking the resource locks to see if they have been removed. + $resourceLock = Invoke-ResourceLockRetrieval -ResourceId $ResourceId -Type $Type + $isLocked = $resourceLock.count -gt 0 + } while ($isLocked) + + Write-Verbose (' [-] [{0}] resource lock(s) removed.' -f $resourceLock.count) -Verbose +} diff --git a/avm/utilities/pipelines/resourceRemoval/helper/Invoke-ResourceLockRetrieval.ps1 b/avm/utilities/pipelines/resourceRemoval/helper/Invoke-ResourceLockRetrieval.ps1 new file mode 100644 index 0000000000..90c35dd598 --- /dev/null +++ b/avm/utilities/pipelines/resourceRemoval/helper/Invoke-ResourceLockRetrieval.ps1 @@ -0,0 +1,45 @@ +<# +.SYNOPSIS +Gets resource locks on a resource or a specific resource lock. + +.DESCRIPTION +Gets resource locks on a resource or a specific resource lock. + +.PARAMETER ResourceId +Mandatory. The resourceID of the resource to check or the resource lock to check. + +.PARAMETER Type +Optional. The type of the resource. +If the resource is a lock, the lock itself will be returned. +If the resource is not a lock, all locks on the resource will be returned. + +.EXAMPLE +Invoke-ResourceLockRetrieval -ResourceId '/subscriptions/.../resourceGroups/validation-rg/.../resource-name' + +Check if the resource 'resource-name' is locked. If it is, return the lock. + +.EXAMPLE +Invoke-ResourceLockRetrieval -ResourceId '/subscriptions/.../resourceGroups/validation-rg/.../resource-name/providers/Microsoft.Authorization/locks/lock-name' -Type 'Microsoft.Authorization/locks' + +Return the lock 'lock-name' on the resource 'resource-name'. + +.NOTES +Needed as the AzPwsh cmdlet Get-AzResourceLock does not support getting a specific lock by LockId. +#> +function Invoke-ResourceLockRetrieval { + [OutputType([System.Management.Automation.PSCustomObject])] + param ( + [Parameter(Mandatory = $true)] + [string] $ResourceId, + + [Parameter(Mandatory = $false)] + [string] $Type = '' + ) + if ($Type -eq 'Microsoft.Authorization/locks') { + $lockName = ($ResourceId -split '/')[-1] + $lockScope = ($ResourceId -split '/providers/Microsoft.Authorization/locks')[0] + return Get-AzResourceLock -LockName $lockName -Scope $lockScope -ErrorAction SilentlyContinue + } else { + return Get-AzResourceLock -Scope $ResourceId -ErrorAction SilentlyContinue + } +} diff --git a/avm/utilities/pipelines/resourceRemoval/helper/Invoke-ResourcePostRemoval.ps1 b/avm/utilities/pipelines/resourceRemoval/helper/Invoke-ResourcePostRemoval.ps1 new file mode 100644 index 0000000000..7a4178a3e0 --- /dev/null +++ b/avm/utilities/pipelines/resourceRemoval/helper/Invoke-ResourcePostRemoval.ps1 @@ -0,0 +1,157 @@ +<# +.SYNOPSIS +Remove any artifacts that remain of the given resource + +.DESCRIPTION +Remove any artifacts that remain of the given resource. For example, some resources such as key vaults usually go into a soft-delete state from which we want to purge them from. + +.PARAMETER ResourceId +Mandatory. The resourceID of the resource to remove + +.PARAMETER Type +Mandatory. The type of the resource to remove + +.EXAMPLE +Invoke-ResourcePostRemoval -Type 'Microsoft.KeyVault/vaults' -ResourceId '/subscriptions/.../resourceGroups/validation-rg/providers/Microsoft.KeyVault/vaults/myVault' + +Purge the resource 'myVault' of type 'Microsoft.KeyVault/vaults' with ID '/subscriptions/.../resourceGroups/validation-rg/providers/Microsoft.KeyVault/vaults/myVault' if no purge protection is enabled +#> +function Invoke-ResourcePostRemoval { + + [CmdletBinding(SupportsShouldProcess)] + param ( + [Parameter(Mandatory = $true)] + [string] $ResourceId, + + [Parameter(Mandatory = $true)] + [string] $Type + ) + + switch ($Type) { + 'Microsoft.AppConfiguration/configurationStores' { + $subscriptionId = $ResourceId.Split('/')[2] + $resourceName = Split-Path $ResourceId -Leaf + + # Fetch service in soft-delete + $getPath = '/subscriptions/{0}/providers/Microsoft.AppConfiguration/deletedConfigurationStores?api-version=2021-10-01-preview' -f $subscriptionId + $getRequestInputObject = @{ + Method = 'GET' + Path = $getPath + } + $softDeletedConfigurationStore = ((Invoke-AzRestMethod @getRequestInputObject).Content | ConvertFrom-Json).value | Where-Object { $_.properties.configurationStoreId -eq $ResourceId } + + if ($softDeletedConfigurationStore) { + # Purge service + $purgePath = '/subscriptions/{0}/providers/Microsoft.AppConfiguration/locations/{1}/deletedConfigurationStores/{2}/purge?api-version=2021-10-01-preview' -f $subscriptionId, $softDeletedConfigurationStore.properties.location, $resourceName + $purgeRequestInputObject = @{ + Method = 'POST' + Path = $purgePath + } + Write-Verbose ('[*] Purging resource [{0}] of type [{1}]' -f $resourceName, $Type) -Verbose + if ($PSCmdlet.ShouldProcess(('App Configuration Store with ID [{0}]' -f $softDeletedConfigurationStore.properties.configurationStoreId), 'Purge')) { + $response = Invoke-AzRestMethod @purgeRequestInputObject + if ($response.StatusCode -ne 200) { + throw ('Purge of resource [{0}] failed with error code [{1}]' -f $ResourceId, $response.StatusCode) + } + } + } + break + } + 'Microsoft.KeyVault/vaults' { + $resourceName = Split-Path $ResourceId -Leaf + + $matchingKeyVault = Get-AzKeyVault -InRemovedState | Where-Object { $_.resourceId -eq $ResourceId } + if ($matchingKeyVault -and -not $matchingKeyVault.EnablePurgeProtection) { + Write-Verbose ('[*] Purging resource [{0}] of type [{1}]' -f $resourceName, $Type) -Verbose + if ($PSCmdlet.ShouldProcess(('Key Vault with ID [{0}]' -f $matchingKeyVault.Id), 'Purge')) { + try { + $null = Remove-AzKeyVault -ResourceId $matchingKeyVault.Id -InRemovedState -Force -Location $matchingKeyVault.Location -ErrorAction 'Stop' + } catch { + if ($_.Exception.Message -like '*DeletedVaultPurge*') { + Write-Warning ('Purge protection for key vault [{0}] enabled. Skipping. Scheduled purge date is [{1}]' -f $resourceName, $matchingKeyVault.ScheduledPurgeDate) + } else { + throw $_ + } + } + } + } + break + } + 'Microsoft.CognitiveServices/accounts' { + $resourceGroupName = $ResourceId.Split('/')[4] + $resourceName = Split-Path $ResourceId -Leaf + + $matchingAccount = Get-AzCognitiveServicesAccount -InRemovedState | Where-Object { $_.AccountName -eq $resourceName } + if ($matchingAccount) { + Write-Verbose ('[*] Purging resource [{0}] of type [{1}]' -f $resourceName, $Type) -Verbose + if ($PSCmdlet.ShouldProcess(('Cognitive services account with ID [{0}]' -f $matchingAccount.Id), 'Purge')) { + $null = Remove-AzCognitiveServicesAccount -InRemovedState -Force -Location $matchingAccount.Location -ResourceGroupName $resourceGroupName -Name $matchingAccount.AccountName + } + } + break + } + 'Microsoft.ApiManagement/service' { + $subscriptionId = $ResourceId.Split('/')[2] + $resourceName = Split-Path $ResourceId -Leaf + + # Fetch service in soft-delete + $getPath = '/subscriptions/{0}/providers/Microsoft.ApiManagement/deletedservices?api-version=2021-08-01' -f $subscriptionId + $getRequestInputObject = @{ + Method = 'GET' + Path = $getPath + } + $softDeletedService = ((Invoke-AzRestMethod @getRequestInputObject).Content | ConvertFrom-Json).value | Where-Object { $_.properties.serviceId -eq $ResourceId } + + if ($softDeletedService) { + # Purge service + $purgePath = '/subscriptions/{0}/providers/Microsoft.ApiManagement/locations/{1}/deletedservices/{2}?api-version=2020-06-01-preview' -f $subscriptionId, $softDeletedService.location, $resourceName + $purgeRequestInputObject = @{ + Method = 'DELETE' + Path = $purgePath + } + Write-Verbose ('[*] Purging resource [{0}] of type [{1}]' -f $resourceName, $Type) -Verbose + if ($PSCmdlet.ShouldProcess(('API management service with ID [{0}]' -f $softDeletedService.properties.serviceId), 'Purge')) { + $null = Invoke-AzRestMethod @purgeRequestInputObject + } + } + break + } + 'Microsoft.RecoveryServices/vaults/backupFabrics/protectionContainers/protectedItems' { + # Remove protected VM + # Required if e.g. a VM was listed in an RSV and only that VM is removed + $vaultId = $ResourceId.split('/backupFabrics/')[0] + $resourceName = Split-Path $ResourceId -Leaf + $softDeleteStatus = (Get-AzRecoveryServicesVaultProperty -VaultId $vaultId).SoftDeleteFeatureState + if ($softDeleteStatus -ne 'Disabled') { + if ($PSCmdlet.ShouldProcess(('Soft-delete on RSV [{0}]' -f $vaultId), 'Set')) { + $null = Set-AzRecoveryServicesVaultProperty -VaultId $vaultId -SoftDeleteFeatureState 'Disable' + } + } + + $backupItemInputObject = @{ + BackupManagementType = 'AzureVM' + WorkloadType = 'AzureVM' + VaultId = $vaultId + Name = $resourceName + } + if ($backupItem = Get-AzRecoveryServicesBackupItem @backupItemInputObject -ErrorAction 'SilentlyContinue') { + Write-Verbose (' [-] Removing Backup item [{0}] from RSV [{1}]' -f $backupItem.Name, $vaultId) -Verbose + + if ($backupItem.DeleteState -eq 'ToBeDeleted') { + if ($PSCmdlet.ShouldProcess('Soft-deleted backup data removal', 'Undo')) { + $null = Undo-AzRecoveryServicesBackupItemDeletion -Item $backupItem -VaultId $vaultId -Force + } + } + + if ($PSCmdlet.ShouldProcess(('Backup item [{0}] from RSV [{1}]' -f $backupItem.Name, $vaultId), 'Remove')) { + $null = Disable-AzRecoveryServicesBackupProtection -Item $backupItem -VaultId $vaultId -RemoveRecoveryPoints -Force + } + } + + # Undo a potential soft delete state change + $null = Set-AzRecoveryServicesVaultProperty -VaultId $vaultId -SoftDeleteFeatureState $softDeleteStatus.TrimEnd('d') + break + } + ### CODE LOCATION: Add custom post-removal operation here + } +} diff --git a/avm/utilities/pipelines/resourceRemoval/helper/Invoke-ResourceRemoval.ps1 b/avm/utilities/pipelines/resourceRemoval/helper/Invoke-ResourceRemoval.ps1 new file mode 100644 index 0000000000..b0065c67a4 --- /dev/null +++ b/avm/utilities/pipelines/resourceRemoval/helper/Invoke-ResourceRemoval.ps1 @@ -0,0 +1,195 @@ +<# +.SYNOPSIS +Remove a specific resource + +.DESCRIPTION +Remove a specific resource. Tries to handle different resource types accordingly + +.PARAMETER ResourceId +Mandatory. The resourceID of the resource to remove + +.PARAMETER Type +Mandatory. The type of the resource to remove + +.EXAMPLE +Invoke-ResourceRemoval -Type 'Microsoft.Insights/diagnosticSettings' -ResourceId '/subscriptions/.../resourceGroups/validation-rg/providers/Microsoft.Network/networkInterfaces/sxx-vm-linux-001-nic-01/providers/Microsoft.Insights/diagnosticSettings/sxx-vm-linux-001-nic-01-diagnosticSettings' + +Remove the resource 'sxx-vm-linux-001-nic-01-diagnosticSettings' of type 'Microsoft.Insights/diagnosticSettings' from resource '/subscriptions/.../resourceGroups/validation-rg/providers/Microsoft.Network/networkInterfaces/sxx-vm-linux-001-nic-01' +#> +function Invoke-ResourceRemoval { + + [CmdletBinding(SupportsShouldProcess)] + param ( + [Parameter(Mandatory = $true)] + [string] $ResourceId, + + [Parameter(Mandatory = $true)] + [string] $Type + ) + # Load functions + . (Join-Path $PSScriptRoot 'Invoke-ResourceLockRemoval.ps1') + + # Remove unhandled resource locks, for cases when the resource + # collection is incomplete, usually due to previous removal failing. + if ($PSCmdlet.ShouldProcess("Possible locks on resource with ID [$ResourceId]", 'Handle')) { + Invoke-ResourceLockRemoval -ResourceId $ResourceId -Type $Type + } + + switch ($Type) { + 'Microsoft.Insights/diagnosticSettings' { + $parentResourceId = $ResourceId.Split('/providers/{0}' -f $Type)[0] + $resourceName = Split-Path $ResourceId -Leaf + if ($PSCmdlet.ShouldProcess("Diagnostic setting [$resourceName]", 'Remove')) { + $null = Remove-AzDiagnosticSetting -ResourceId $parentResourceId -Name $resourceName + } + break + } + 'Microsoft.Authorization/locks' { + if ($PSCmdlet.ShouldProcess("Lock with ID [$ResourceId]", 'Remove')) { + Invoke-ResourceLockRemoval -ResourceId $ResourceId -Type $Type + } + break + } + 'Microsoft.KeyVault/vaults/keys' { + $resourceName = Split-Path $ResourceId -Leaf + Write-Verbose ('[/] Skipping resource [{0}] of type [{1}]. Reason: It is handled by different logic.' -f $resourceName, $Type) -Verbose + # Also, we don't want to accidently remove keys of the dependency key vault + break + } + 'Microsoft.KeyVault/vaults/accessPolicies' { + $resourceName = Split-Path $ResourceId -Leaf + Write-Verbose ('[/] Skipping resource [{0}] of type [{1}]. Reason: It is handled by different logic.' -f $resourceName, $Type) -Verbose + break + } + 'Microsoft.ServiceBus/namespaces/authorizationRules' { + if ((Split-Path $ResourceId '/')[-1] -eq 'RootManageSharedAccessKey') { + Write-Verbose ('[/] Skipping resource [RootManageSharedAccessKey] of type [{0}]. Reason: The Service Bus''s default authorization key cannot be removed' -f $Type) -Verbose + } else { + if ($PSCmdlet.ShouldProcess("Resource with ID [$ResourceId]", 'Remove')) { + $null = Remove-AzResource -ResourceId $ResourceId -Force -ErrorAction 'Stop' + } + } + break + } + 'Microsoft.Compute/diskEncryptionSets' { + # Pre-Removal + # ----------- + # Remove access policies on key vault + $resourceGroupName = $ResourceId.Split('/')[4] + $resourceName = Split-Path $ResourceId -Leaf + + $diskEncryptionSet = Get-AzDiskEncryptionSet -Name $resourceName -ResourceGroupName $resourceGroupName + $keyVaultResourceId = $diskEncryptionSet.ActiveKey.SourceVault.Id + $keyVaultName = Split-Path $keyVaultResourceId -Leaf + $objectId = $diskEncryptionSet.Identity.PrincipalId + + if ($PSCmdlet.ShouldProcess(('Access policy [{0}] from key vault [{1}]' -f $objectId, $keyVaultName), 'Remove')) { + $null = Remove-AzKeyVaultAccessPolicy -VaultName $keyVaultName -ObjectId $objectId + } + + # Actual removal + # -------------- + if ($PSCmdlet.ShouldProcess("Resource with ID [$ResourceId]", 'Remove')) { + $null = Remove-AzResource -ResourceId $ResourceId -Force -ErrorAction 'Stop' + } + break + } + 'Microsoft.RecoveryServices/vaults/backupstorageconfig' { + # Not a 'resource' that can be removed, but represents settings on the RSV. The config is deleted with the RSV + break + } + 'Microsoft.Authorization/roleAssignments' { + $idElem = $ResourceId.Split('/') + $scope = $idElem[0..($idElem.Count - 5)] -join '/' + $roleAssignmentsOnScope = Get-AzRoleAssignment -Scope $scope + $null = $roleAssignmentsOnScope | Where-Object { $_.RoleAssignmentId -eq $ResourceId } | Remove-AzRoleAssignment + break + } + 'Microsoft.RecoveryServices/vaults' { + # Pre-Removal + # ----------- + # Remove protected VMs + if ((Get-AzRecoveryServicesVaultProperty -VaultId $ResourceId).SoftDeleteFeatureState -ne 'Disabled') { + if ($PSCmdlet.ShouldProcess(('Soft-delete on RSV [{0}]' -f $ResourceId), 'Set')) { + $null = Set-AzRecoveryServicesVaultProperty -VaultId $ResourceId -SoftDeleteFeatureState 'Disable' + } + } + + $backupItems = Get-AzRecoveryServicesBackupItem -BackupManagementType 'AzureVM' -WorkloadType 'AzureVM' -VaultId $ResourceId + foreach ($backupItem in $backupItems) { + Write-Verbose ('Removing Backup item [{0}] from RSV [{1}]' -f $backupItem.Name, $ResourceId) -Verbose + + if ($backupItem.DeleteState -eq 'ToBeDeleted') { + if ($PSCmdlet.ShouldProcess('Soft-deleted backup data removal', 'Undo')) { + $null = Undo-AzRecoveryServicesBackupItemDeletion -Item $backupItem -VaultId $ResourceId -Force + } + } + + if ($PSCmdlet.ShouldProcess(('Backup item [{0}] from RSV [{1}]' -f $backupItem.Name, $ResourceId), 'Remove')) { + $null = Disable-AzRecoveryServicesBackupProtection -Item $backupItem -VaultId $ResourceId -RemoveRecoveryPoints -Force + } + } + + # Actual removal + # -------------- + if ($PSCmdlet.ShouldProcess("Resource with ID [$ResourceId]", 'Remove')) { + $null = Remove-AzResource -ResourceId $ResourceId -Force -ErrorAction 'Stop' + } + break + } + 'Microsoft.OperationalInsights/workspaces' { + $resourceGroupName = $ResourceId.Split('/')[4] + $resourceName = Split-Path $ResourceId -Leaf + # Force delete workspace (cannot be recovered) + if ($PSCmdlet.ShouldProcess("Log Analytics Workspace [$resourceName]", 'Remove')) { + Write-Verbose ('[*] Purging resource [{0}] of type [{1}]' -f $resourceName, $Type) -Verbose + $null = Remove-AzOperationalInsightsWorkspace -ResourceGroupName $resourceGroupName -Name $resourceName -Force -ForceDelete + } + break + } + 'Microsoft.MachineLearningServices/workspaces' { + $subscriptionId = $ResourceId.Split('/')[2] + $resourceGroupName = $ResourceId.Split('/')[4] + $resourceName = Split-Path $ResourceId -Leaf + + # Purge service + $purgePath = '/subscriptions/{0}/resourceGroups/{1}/providers/Microsoft.MachineLearningServices/workspaces/{2}?api-version=2023-06-01-preview&forceToPurge=true' -f $subscriptionId, $resourceGroupName, $resourceName + $purgeRequestInputObject = @{ + Method = 'DELETE' + Path = $purgePath + } + Write-Verbose ('[*] Purging resource [{0}] of type [{1}]' -f $resourceName, $Type) -Verbose + if ($PSCmdlet.ShouldProcess("Machine Learning Workspace [$resourceName]", 'Purge')) { + $purgeResource = Invoke-AzRestMethod @purgeRequestInputObject + if ($purgeResource.StatusCode -notlike '2*') { + $responseContent = $purgeResource.Content | ConvertFrom-Json + throw ('{0} : {1}' -f $responseContent.error.code, $responseContent.error.message) + } + + # Wait for workspace to be purged. If it is not purged it has a chance of being soft-deleted via RG deletion (not purged) + # The consecutive deployments will fail because it is not purged. + $retryCount = 0 + $retryLimit = 240 + $retryInterval = 15 + do { + $retryCount++ + if ($retryCount -ge $retryLimit) { + Write-Warning (' [!] Workspace [{0}] was not purged after {1} seconds. Continuing with resource removal.' -f $resourceName, ($retryCount * $retryInterval)) + break + } + Write-Verbose (' [⏱️] Waiting {0} seconds for workspace to be purged.' -f $retryInterval) -Verbose + Start-Sleep -Seconds $retryInterval + $workspace = Get-AzMLWorkspace -Name $resourceName -ResourceGroupName $resourceGroupName -SubscriptionId $subscriptionId -ErrorAction SilentlyContinue + $workspaceExists = $workspace.count -gt 0 + } while ($workspaceExists) + } + break + } + ### CODE LOCATION: Add custom removal action here + Default { + if ($PSCmdlet.ShouldProcess("Resource with ID [$ResourceId]", 'Remove')) { + $null = Remove-AzResource -ResourceId $ResourceId -Force -ErrorAction 'Stop' + } + } + } +} diff --git a/avm/utilities/pipelines/resourceRemoval/helper/Remove-Deployment.ps1 b/avm/utilities/pipelines/resourceRemoval/helper/Remove-Deployment.ps1 new file mode 100644 index 0000000000..a964a30298 --- /dev/null +++ b/avm/utilities/pipelines/resourceRemoval/helper/Remove-Deployment.ps1 @@ -0,0 +1,163 @@ +<# +.SYNOPSIS +Invoke the removal of a deployed module + +.DESCRIPTION +Invoke the removal of a deployed module. +Requires the resource in question to be tagged with 'removeModule = ' + +.PARAMETER ModuleName +Mandatory. The name of the module to remove + +.PARAMETER ResourceGroupName +Optional. The resource group of the resource to remove + +.PARAMETER ManagementGroupId +Optional. The ID of the management group to fetch deployments from. Relevant for management-group level deployments. + +.PARAMETER DeploymentNames +Optional. The deployment names to use for the removal + +.PARAMETER TemplateFilePath +Mandatory. The path to the deployment file + +.PARAMETER RemovalSequence +Optional. The order of resource types to apply for deletion + +.EXAMPLE +Remove-Deployment -DeploymentNames @('KeyVault-t1','KeyVault-t2') -TemplateFilePath 'C:/main.json' + +Remove all resources deployed via the with deployment names 'KeyVault-t1' & 'KeyVault-t2' +#> +function Remove-Deployment { + + [CmdletBinding(SupportsShouldProcess)] + param ( + [Parameter(Mandatory = $false)] + [string] $ResourceGroupName, + + [Parameter(Mandatory = $false)] + [string] $ManagementGroupId, + + [Parameter(Mandatory = $true)] + [string[]] $DeploymentNames, + + [Parameter(Mandatory = $true)] + [string] $TemplateFilePath, + + [Parameter(Mandatory = $false)] + [string[]] $RemovalSequence = @() + ) + + begin { + Write-Debug ('{0} entered' -f $MyInvocation.MyCommand) + + # Load helper + . (Join-Path (Get-Item -Path $PSScriptRoot).parent.parent.FullName 'sharedScripts' 'Get-ScopeOfTemplateFile.ps1') + . (Join-Path (Split-Path $PSScriptRoot -Parent) 'helper' 'Get-DeploymentTargetResourceList.ps1') + . (Join-Path (Split-Path $PSScriptRoot -Parent) 'helper' 'Get-ResourceIdsAsFormattedObjectList.ps1') + . (Join-Path (Split-Path $PSScriptRoot -Parent) 'helper' 'Get-OrderedResourcesList.ps1') + . (Join-Path (Split-Path $PSScriptRoot -Parent) 'helper' 'Remove-ResourceList.ps1') + } + + process { + $azContext = Get-AzContext + + # Prepare data + # ============ + $deploymentScope = Get-ScopeOfTemplateFile -TemplateFilePath $TemplateFilePath + + # Fundamental checks + if ($deploymentScope -eq 'resourcegroup' -and -not (Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction 'SilentlyContinue')) { + Write-Verbose "Resource group [$ResourceGroupName] does not exist (anymore). Skipping removal of its contained resources" -Verbose + return + } + + # Fetch deployments + # ================= + $deployedTargetResources = @() + + foreach ($deploymentName in $DeploymentNames) { + $deploymentsInputObject = @{ + Name = $deploymentName + Scope = $deploymentScope + } + if (-not [String]::IsNullOrEmpty($ResourceGroupName)) { + $deploymentsInputObject['resourceGroupName'] = $ResourceGroupName + } + if (-not [String]::IsNullOrEmpty($ManagementGroupId)) { + $deploymentsInputObject['ManagementGroupId'] = $ManagementGroupId + } + $deployedTargetResources += Get-DeploymentTargetResourceList @deploymentsInputObject + } + + if ($deployedTargetResources.Count -eq 0) { + throw 'No deployment target resources found.' + } + + [array] $deployedTargetResources = $deployedTargetResources | Select-Object -Unique + + Write-Verbose ('Total number of deployment target resources after fetching deployments [{0}]' -f $deployedTargetResources.Count) -Verbose + + # Pre-Filter & order items + # ======================== + $rawTargetResourceIdsToRemove = $deployedTargetResources | Sort-Object -Property { $_.Split('/').Count } -Descending | Select-Object -Unique + Write-Verbose ('Total number of deployment target resources after pre-filtering (duplicates) & ordering items [{0}]' -f $rawTargetResourceIdsToRemove.Count) -Verbose + + # Format items + # ============ + [array] $resourcesToRemove = Get-ResourceIdsAsFormattedObjectList -ResourceIds $rawTargetResourceIdsToRemove + Write-Verbose ('Total number of deployment target resources after formatting items [{0}]' -f $resourcesToRemove.Count) -Verbose + + if ($resourcesToRemove.Count -eq 0) { + return + } + + # Filter resources + # ================ + + # Resource IDs in the below list are ignored by the removal + $resourceIdsToIgnore = @( + '/subscriptions/{0}/resourceGroups/NetworkWatcherRG' -f $azContext.Subscription.Id + ) + + # Resource IDs starting with a prefix in the below list are ignored by the removal + $resourceIdPrefixesToIgnore = @( + '/subscriptions/{0}/providers/Microsoft.Security/autoProvisioningSettings/' -f $azContext.Subscription.Id + '/subscriptions/{0}/providers/Microsoft.Security/deviceSecurityGroups/' -f $azContext.Subscription.Id + '/subscriptions/{0}/providers/Microsoft.Security/iotSecuritySolutions/' -f $azContext.Subscription.Id + '/subscriptions/{0}/providers/Microsoft.Security/pricings/' -f $azContext.Subscription.Id + '/subscriptions/{0}/providers/Microsoft.Security/securityContacts/' -f $azContext.Subscription.Id + '/subscriptions/{0}/providers/Microsoft.Security/workspaceSettings/' -f $azContext.Subscription.Id + ) + [regex] $ignorePrefix_regex = '(?i)^(' + (($resourceIdPrefixesToIgnore | ForEach-Object { [regex]::escape($_) }) -join '|') + ')' + + + if ($resourcesToIgnore = $resourcesToRemove | Where-Object { $_.resourceId -in $resourceIdsToIgnore -or $_.resourceId -match $ignorePrefix_regex }) { + Write-Verbose 'Resources excluded from removal:' -Verbose + $resourcesToIgnore | ForEach-Object { Write-Verbose ('- Ignore [{0}]' -f $_.resourceId) -Verbose } + } + + [array] $resourcesToRemove = $resourcesToRemove | Where-Object { $_.resourceId -notin $resourceIdsToIgnore -and $_.resourceId -notmatch $ignorePrefix_regex } + Write-Verbose ('Total number of deployments after filtering all dependency resources [{0}]' -f $resourcesToRemove.Count) -Verbose + + # Order resources + # =============== + [array] $resourcesToRemove = Get-OrderedResourcesList -ResourcesToOrder $resourcesToRemove -Order $RemovalSequence + Write-Verbose ('Total number of deployments after final ordering of resources [{0}]' -f $resourcesToRemove.Count) -Verbose + + # Remove resources + # ================ + if ($resourcesToRemove.Count -gt 0) { + if ($PSCmdlet.ShouldProcess(('[{0}] resources' -f (($resourcesToRemove -is [array]) ? $resourcesToRemove.Count : 1)), 'Remove')) { + Remove-ResourceList -ResourcesToRemove $resourcesToRemove + } + } else { + Write-Verbose 'Found [0] resources to remove' + } + } + + end { + Write-Debug ('{0} exited' -f $MyInvocation.MyCommand) + } +} diff --git a/avm/utilities/pipelines/resourceRemoval/helper/Remove-ResourceList.ps1 b/avm/utilities/pipelines/resourceRemoval/helper/Remove-ResourceList.ps1 new file mode 100644 index 0000000000..cc35460c8a --- /dev/null +++ b/avm/utilities/pipelines/resourceRemoval/helper/Remove-ResourceList.ps1 @@ -0,0 +1,128 @@ +#region helperScripts +<# +.SYNOPSIS +Remove the given resource(s) + +.DESCRIPTION +Remove the given resource(s). Resources that the script fails to removed are returned in an array. + +.PARAMETER ResourcesToRemove +Mandatory. The resource(s) to remove. Each resource must have a type & resourceId property. + +.EXAMPLE +Remove-ResourceListInner -ResourcesToRemove @( @{ Type = 'Microsoft.Storage/storageAccounts'; ResourceId = 'subscriptions/.../storageAccounts/resourceName' } ) +#> +function Remove-ResourceListInner { + + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $false)] + [Hashtable[]] $ResourcesToRemove = @() + ) + + begin { + Write-Debug ('{0} entered' -f $MyInvocation.MyCommand) + + # Load functions + . (Join-Path $PSScriptRoot 'Invoke-ResourceRemoval.ps1') + . (Join-Path $PSScriptRoot 'Invoke-ResourcePostRemoval.ps1') + } + + process { + $resourcesToRemove | ForEach-Object { Write-Verbose ('- Remove [{0}]' -f $_.resourceId) -Verbose } + $resourcesToRetry = @() + $processedResources = @() + Write-Verbose '----------------------------------' -Verbose + + foreach ($resource in $resourcesToRemove) { + $resourceName = Split-Path $resource.resourceId -Leaf + $alreadyProcessed = $processedResources.count -gt 0 ? (($processedResources | Where-Object { $resource.resourceId -like ('{0}*' -f $_) }).Count -gt 0) : $false + + if ($alreadyProcessed) { + # Skipping + Write-Verbose ('[/] Skipping resource [{0}] of type [{1}]. Reason: Its parent resource was already processed' -f $resourceName, $resource.type) -Verbose + [array]$processedResources += $resource.resourceId + [array]$resourcesToRetry = $resourcesToRetry | Where-Object { $_.resourceId -notmatch $resource.resourceId } + } else { + Write-Verbose ('[-] Removing resource [{0}] of type [{1}]' -f $resourceName, $resource.type) -Verbose + try { + if ($PSCmdlet.ShouldProcess(('Resource [{0}]' -f $resource.resourceId), 'Remove')) { + Invoke-ResourceRemoval -Type $resource.type -ResourceId $resource.resourceId + } + + # If we removed a parent remove its children + [array]$processedResources += $resource.resourceId + [array]$resourcesToRetry = $resourcesToRetry | Where-Object { $_.resourceId -notmatch $resource.resourceId } + } catch { + Write-Warning ('[!] Removal moved back for retry. Reason: [{0}]' -f $_.Exception.Message) + [array]$resourcesToRetry += $resource + } + } + + # We want to purge resources even if they were not explicitly removed because they were 'alreadyProcessed' + if ($PSCmdlet.ShouldProcess(('Post-resource-removal for [{0}]' -f $resource.resourceId), 'Execute')) { + Invoke-ResourcePostRemoval -Type $resource.type -ResourceId $resource.resourceId + } + } + Write-Verbose '----------------------------------' -Verbose + return $resourcesToRetry + } + end { + Write-Debug ('{0} exited' -f $MyInvocation.MyCommand) + } +} +#endregion + +<# +.SYNOPSIS +Remove all resources in the provided array from Azure + +.DESCRIPTION +Remove all resources in the provided array from Azure. Resources are removed with a retry mechanism. + +.PARAMETER ResourcesToRemove +Optional. The array of resources to remove. Has to contain objects with at least a 'resourceId' & 'type' property + +.EXAMPLE +Remove-ResourceList @( @{ Type = 'Microsoft.Storage/storageAccounts'; ResourceId = 'subscriptions/.../storageAccounts/resourceName' } ) + +Remove resource with ID 'subscriptions/.../storageAccounts/resourceName'. +#> +function Remove-ResourceList { + + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $false)] + [PSObject[]] $ResourcesToRemove = @(), + + [Parameter(Mandatory = $false)] + [int] $RemovalRetryLimit = 3, + + [Parameter(Mandatory = $false)] + [int] $RemovalRetryInterval = 15 + ) + + $removalRetryCount = 1 + $resourcesToRetry = $resourcesToRemove + + do { + if ($PSCmdlet.ShouldProcess(("[{0}] Resource(s) with a maximum of [$removalRetryLimit] attempts." -f (($resourcesToRetry -is [array]) ? $resourcesToRetry.Count : 1)), 'Remove')) { + $resourcesToRetry = Remove-ResourceListInner -ResourcesToRemove $resourcesToRetry + } else { + Remove-ResourceListInner -ResourcesToRemove $resourcesToRemove -WhatIf + } + + if (-not $resourcesToRetry) { + break + } + Write-Verbose ('Retry removal of remaining [{0}] resources. Waiting [{1}] seconds. Round [{2}|{3}]' -f (($resourcesToRetry -is [array]) ? $resourcesToRetry.Count : 1), $removalRetryInterval, $removalRetryCount, $removalRetryLimit) + $removalRetryCount++ + Start-Sleep $removalRetryInterval + } while ($removalRetryCount -le $removalRetryLimit) + + if ($resourcesToRetry.Count -gt 0) { + throw ('The removal failed for resources [{0}]' -f ((Split-Path $resourcesToRetry.resourceId -Leaf) -join ', ')) + } else { + Write-Verbose 'The removal completed successfully' + } +} diff --git a/avm/utilities/pipelines/sharedScripts/Get-ModuleTestFileList.ps1 b/avm/utilities/pipelines/sharedScripts/Get-ModuleTestFileList.ps1 index e0cbac542b..e157bec766 100644 --- a/avm/utilities/pipelines/sharedScripts/Get-ModuleTestFileList.ps1 +++ b/avm/utilities/pipelines/sharedScripts/Get-ModuleTestFileList.ps1 @@ -27,35 +27,35 @@ Returns the relative file paths of all test files of the virtual-machine module #> function Get-ModuleTestFileList { - [CmdletBinding()] - param ( - [Parameter(Mandatory)] - [string] $ModulePath, - - [Parameter(Mandatory = $false)] - [string] $SearchFolder = '.test', - - [Parameter(Mandatory = $false)] - [string[]] $TestFilePattern = @('*.json', 'main.test.bicep') - ) - - $deploymentTests = @() - if (Test-Path (Join-Path $ModulePath $SearchFolder)) { - $deploymentTests += (Get-ChildItem -Path (Join-Path $ModulePath $SearchFolder) -Recurse -Include $TestFilePattern -File).FullName | Where-Object { - $_ -ne (Join-Path (Join-Path $ModulePath $SearchFolder) 'main.test.bicep') # Excluding PBR test file - } + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] $ModulePath, + + [Parameter(Mandatory = $false)] + [string] $SearchFolder = 'tests/e2e', + + [Parameter(Mandatory = $false)] + [string[]] $TestFilePattern = @('*.json', 'main.test.bicep') + ) + + $deploymentTests = @() + if (Test-Path (Join-Path $ModulePath $SearchFolder)) { + $deploymentTests += (Get-ChildItem -Path (Join-Path $ModulePath $SearchFolder) -Recurse -Include $TestFilePattern -File).FullName | Where-Object { + $_ -ne (Join-Path (Join-Path $ModulePath $SearchFolder) 'main.test.bicep') # Excluding PBR test file } + } - if (-not $deploymentTests) { - throw "No deployment test files found for module [$ModulePath]" - } + if (-not $deploymentTests) { + throw "No deployment test files found for module [$ModulePath]" + } - $deploymentTests = $deploymentTests | ForEach-Object { - $_.Replace($ModulePath, '').Trim('\').Trim('/') - } + $deploymentTests = $deploymentTests | ForEach-Object { + $_.Replace($ModulePath, '').Trim('\').Trim('/') + } - Write-Verbose 'Found parameter files' - $deploymentTests | ForEach-Object { Write-Verbose "- $_" } + Write-Verbose 'Found parameter files' + $deploymentTests | ForEach-Object { Write-Verbose "- $_" } - return $deploymentTests + return $deploymentTests }