diff --git a/.azuredevops/pipelineTemplates/module.jobs.deploy.yml b/.azuredevops/pipelineTemplates/module.jobs.deploy.yml index 275b2c7400..df626e90fc 100644 --- a/.azuredevops/pipelineTemplates/module.jobs.deploy.yml +++ b/.azuredevops/pipelineTemplates/module.jobs.deploy.yml @@ -278,7 +278,7 @@ jobs: pwsh: true inline: | # Load used function - . (Join-Path '$(moduleRepoRoot)' '$(pipelineFunctionsPath)' 'resourceRemoval' 'Remove-DeployedModule.ps1') + . (Join-Path '$(moduleRepoRoot)' '$(pipelineFunctionsPath)' 'resourceRemoval' 'Initialize-DeploymentRemoval.ps1') if(-not [String]::IsNullOrEmpty('${{ deploymentBlock.templateFilePath }}')) { $templateFilePath = Join-Path '$(moduleRepoRoot)' '${{ deploymentBlock.templateFilePath }}' @@ -289,14 +289,16 @@ jobs: (Join-Path '$(ModuleRepoRoot)' '$(modulePath)' 'deploy.json') } - $functionInput = @{ - deploymentName = '$(deploymentName)' - templateFilePath = $templateFilePath - resourceGroupName = '${{ parameters.resourceGroupName }}' - verbose = $true - } + if (-not [String]::IsNullOrEmpty('$(deploymentName)')) { + $functionInput = @{ + DeploymentName = '$(deploymentName)' + TemplateFilePath = $templateFilePath + ResourceGroupName = '${{ parameters.resourceGroupName }}' + Verbose = $true + } - Write-Verbose 'Invoke task with' -Verbose - Write-Verbose ($functionInput | ConvertTo-Json | Out-String) -Verbose + Write-Verbose 'Invoke task with' -Verbose + Write-Verbose ($functionInput | ConvertTo-Json | Out-String) -Verbose - Remove-DeployedModule @functionInput + Initialize-DeploymentRemoval @functionInput + } diff --git a/.github/actions/templates/validateModuleDeployment/action.yml b/.github/actions/templates/validateModuleDeployment/action.yml index d781fe54f7..6711eb84c8 100644 --- a/.github/actions/templates/validateModuleDeployment/action.yml +++ b/.github/actions/templates/validateModuleDeployment/action.yml @@ -143,23 +143,21 @@ runs: # ----------------- - name: 'Remove [${{ inputs.templateFilePath }}] from parameters [${{ inputs.parameterFilePath }}]' shell: pwsh - if: ${{ always() && steps.deploy_step.outputs.deploymentName != '' }} + if: ${{ always() && inputs.removeDeployment == 'true' && steps.deploy_step.outputs.deploymentName != '' }} run: | - if('${{ inputs.removeDeployment }}' -eq 'true') { - # Load used function - . (Join-Path $env:GITHUB_WORKSPACE 'utilities' 'pipelines' 'resourceRemoval' 'Remove-DeployedModule.ps1') - - if(-not [String]::IsNullOrEmpty('${{ steps.deploy_step.outputs.deploymentName }}')) { - $functionInput = @{ - deploymentName = '${{ steps.deploy_step.outputs.deploymentName }}' - templateFilePath = Join-Path $env:GITHUB_WORKSPACE '${{ inputs.templateFilePath }}' - resourceGroupName = '${{ inputs.resourceGroupName }}' - verbose = $true - } - - Write-Verbose 'Invoke task with' -Verbose - Write-Verbose ($functionInput | ConvertTo-Json | Out-String) -Verbose - - Remove-DeployedModule @functionInput + # Load used function + . (Join-Path $env:GITHUB_WORKSPACE 'utilities' 'pipelines' 'resourceRemoval' 'Initialize-DeploymentRemoval.ps1') + + if (-not [String]::IsNullOrEmpty('${{ steps.deploy_step.outputs.deploymentName }}')) { + $functionInput = @{ + DeploymentName = '${{ steps.deploy_step.outputs.deploymentName }}' + TemplateFilePath = Join-Path $env:GITHUB_WORKSPACE '${{ inputs.templateFilePath }}' + ResourceGroupName = '${{ inputs.resourceGroupName }}' + Verbose = $true } + + Write-Verbose 'Invoke task with' -Verbose + Write-Verbose ($functionInput | ConvertTo-Json | Out-String) -Verbose + + Initialize-DeploymentRemoval @functionInput } diff --git a/.github/workflows/ms.insights.diagnosticsettings.yml b/.github/workflows/ms.insights.diagnosticsettings.yml index 477321678a..f82cf56669 100644 --- a/.github/workflows/ms.insights.diagnosticsettings.yml +++ b/.github/workflows/ms.insights.diagnosticsettings.yml @@ -7,7 +7,7 @@ on: type: boolean description: 'Remove deployed module' required: false - default: 'false' # Needs custom removals script + default: 'true' versioningOption: type: choice description: 'The mode to handle the version increments [major|minor|patch]' diff --git a/.github/workflows/ms.network.networkwatchers.yml b/.github/workflows/ms.network.networkwatchers.yml index 58513b4913..ff9b9a66ea 100644 --- a/.github/workflows/ms.network.networkwatchers.yml +++ b/.github/workflows/ms.network.networkwatchers.yml @@ -7,7 +7,7 @@ on: type: boolean description: 'Remove deployed module' required: false - default: 'false' # Required as a dependency + Only one Network Watcher can exist in the same location. If removed, a default would be created in a dedicated RG + default: 'false' # Only one Network Watcher can exist in the same location. If removed, a default would be created in a dedicated RG versioningOption: type: choice description: 'The mode to handle the version increments [major|minor|patch]' diff --git a/docs/wiki/PipelinesDesign.md b/docs/wiki/PipelinesDesign.md index 617d437f5a..2d1af45627 100644 --- a/docs/wiki/PipelinesDesign.md +++ b/docs/wiki/PipelinesDesign.md @@ -95,7 +95,7 @@ Note that, for the deployments we have to account for certain [prerequisites](#p #### Removal -The removal phase is strongly coupled with the previous deployment phase. Fundamentally, we want to remove any test-deployed resource after its test concluded. If we would not, we would generate unnecessary costs and may temper with any subsequent test. Some resources may require a dedicated logic to be removed. This logic should be stored alongside the generally utilized removal script in the `.utilities/pipelines/resourceRemoval` folder and be referenced by the `Remove-DeployedModule.ps1` script that orchestrates the removal. +The removal phase is strongly coupled with the previous deployment phase. Fundamentally, we want to remove any test-deployed resource after its test concluded. If we would not, we would generate unnecessary costs and may temper with any subsequent test. Some resources may require a dedicated logic to be removed. This logic should be stored alongside the generally utilized removal script in the `.utilities/pipelines/resourceRemoval` folder and be referenced by the `Initialize-DeploymentRemoval.ps1` script that orchestrates the removal. Most of the removal scripts rely on the deployment name used during the preceding deployment step. Based on this name in combination with the template file path, the removal script find the corresponding deployment and removes all contained resources. diff --git a/utilities/pipelines/resourceDeployment/New-ModuleDeployment.ps1 b/utilities/pipelines/resourceDeployment/New-ModuleDeployment.ps1 index da899f50df..0085b7ba1b 100644 --- a/utilities/pipelines/resourceDeployment/New-ModuleDeployment.ps1 +++ b/utilities/pipelines/resourceDeployment/New-ModuleDeployment.ps1 @@ -28,6 +28,9 @@ Optional. Name of the management group to deploy into. Mandatory if deploying in .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. @@ -67,10 +70,10 @@ function New-DeploymentWithParameterFile { [string] $managementGroupId, [Parameter(Mandatory = $false)] - [PSCustomObject]$additionalTags, + [PSCustomObject] $additionalTags, [Parameter(Mandatory = $false)] - [Hashtable]$additionalParameters, + [Hashtable] $additionalParameters, [Parameter(Mandatory = $false)] [switch] $doNotThrow, @@ -79,153 +82,147 @@ function New-DeploymentWithParameterFile { [int]$retryLimit = 3 ) - $moduleName = Split-Path -Path (Split-Path $templateFilePath -Parent) -LeafBase - - [bool]$Stoploop = $false - [int]$retryCount = 1 - - # Generate a valid deployment name. Must match ^[-\w\._\(\)]+$ - do { - $deploymentName = "$moduleName-$(-join (Get-Date -Format yyyyMMddTHHMMssffffZ)[0..63])" - } while ($deploymentName -notmatch '^[-\w\._\(\)]+$') - - Write-Verbose "Deploying with deployment name [$deploymentName]" -Verbose + begin { + Write-Debug ('{0} entered' -f $MyInvocation.MyCommand) - $DeploymentInputs = @{ - DeploymentName = $deploymentName - TemplateFile = $templateFilePath - Verbose = $true - ErrorAction = 'Stop' + # Load helper + . (Join-Path (Get-Item -Path $PSScriptRoot).parent.FullName 'sharedScripts' 'Get-ScopeOfTemplateFile.ps1') } - if ($additionalParameters) { - $DeploymentInputs += $additionalParameters - } + process { + $moduleName = Split-Path -Path (Split-Path $templateFilePath -Parent) -LeafBase - if (-not [String]::IsNullOrEmpty($parameterFilePath)) { - $DeploymentInputs['TemplateParameterFile'] = $parameterFile - $fileProperties = Get-Item -Path $parameterFile - Write-Verbose "Deploying: $($fileProperties.Name)" - } + # Generate a valid deployment name. Must match ^[-\w\._\(\)]+$ + do { + $deploymentName = "$moduleName-$(-join (Get-Date -Format yyyyMMddTHHMMssffffZ)[0..63])" + } while ($deploymentName -notmatch '^[-\w\._\(\)]+$') + + Write-Verbose "Deploying with deployment name [$deploymentName]" -Verbose - ## Append Tags to Parameters if Resource supports them (all tags must be in one object) - if ($additionalTags) { + $DeploymentInputs = @{ + DeploymentName = $deploymentName + TemplateFile = $templateFilePath + Verbose = $true + ErrorAction = 'Stop' + } - # Parameter tags + # Parameter file provided yes/no if (-not [String]::IsNullOrEmpty($parameterFilePath)) { - $parameterFileTags = (ConvertFrom-Json (Get-Content -Raw -Path $parameterFile) -AsHashtable).parameters.tags.value + $DeploymentInputs['TemplateParameterFile'] = $parameterFilePath } - if (-not $parameterFileTags) { $parameterFileTags = @{} } - # Pipeline tags - if ($additionalTags) { $parameterFileTags += $additionalTags } # If additionalTags object is provided, append tag to the resource + # Additional parameter object provided yes/no + if ($additionalParameters) { + $DeploymentInputs += $additionalParameters + } - # Overwrites parameter file tags parameter - Write-Verbose ("additionalTags: $(($additionalTags) ? ($additionalTags | ConvertTo-Json) : '[]')") - $DeploymentInputs += @{Tags = $parameterFileTags } - } + # Additional tags provides yes/no + # Append tags to parameters if resource supports them (all tags must be in one object) + if ($additionalTags) { - if ((Split-Path $templateFilePath -Extension) -eq '.bicep') { - # Bicep - $bicepContent = Get-Content $templateFilePath - $bicepScope = $bicepContent | Where-Object { $_ -like '*targetscope =*' } - if (-not $bicepScope) { - $deploymentScope = 'resourceGroup' - } else { - $deploymentScope = $bicepScope.ToLower().Replace('targetscope = ', '').Replace("'", '').Trim() - } - } else { - # ARM - $armSchema = (ConvertFrom-Json (Get-Content -Raw -Path $templateFilePath)).'$schema' - switch -regex ($armSchema) { - '\/deploymentTemplate.json#$' { $deploymentScope = 'resourceGroup' } - '\/subscriptionDeploymentTemplate.json#$' { $deploymentScope = 'subscription' } - '\/managementGroupDeploymentTemplate.json#$' { $deploymentScope = 'managementGroup' } - '\/tenantDeploymentTemplate.json#$' { $deploymentScope = 'tenant' } - Default { throw "[$armSchema] is a non-supported ARM template schema" } + # 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 ## - ####################### - do { - try { - switch ($deploymentScope) { - 'resourceGroup' { - if ($subscriptionId) { - $Context = Get-AzContext -ListAvailable | Where-Object Subscription -Match $subscriptionId - if ($Context) { - $null = $Context | Set-AzContext + ####################### + ## INVOKE DEPLOYMENT ## + ####################### + $deploymentScope = Get-ScopeOfTemplateFile -TemplateFilePath $templateFilePath + [bool]$Stoploop = $false + [int]$retryCount = 1 + + do { + try { + switch ($deploymentScope) { + 'resourcegroup' { + if ($subscriptionId) { + $Context = Get-AzContext -ListAvailable | Where-Object Subscription -Match $subscriptionId + if ($Context) { + $null = $Context | Set-AzContext + } } - } - if (-not (Get-AzResourceGroup -Name $resourceGroupName -ErrorAction 'SilentlyContinue')) { - if ($PSCmdlet.ShouldProcess("Resource group [$resourceGroupName] in location [$location]", 'Create')) { - New-AzResourceGroup -Name $resourceGroupName -Location $location + if (-not (Get-AzResourceGroup -Name $resourceGroupName -ErrorAction 'SilentlyContinue')) { + if ($PSCmdlet.ShouldProcess("Resource group [$resourceGroupName] in location [$location]", 'Create')) { + New-AzResourceGroup -Name $resourceGroupName -Location $location + } } + if ($PSCmdlet.ShouldProcess('Resource group level deployment', 'Create')) { + $res = New-AzResourceGroupDeployment @DeploymentInputs -ResourceGroupName $resourceGroupName + } + break } - if ($PSCmdlet.ShouldProcess('Resource group level deployment', 'Create')) { - $res = New-AzResourceGroupDeployment @DeploymentInputs -ResourceGroupName $resourceGroupName + 'subscription' { + if ($subscriptionId) { + $Context = Get-AzContext -ListAvailable | Where-Object Subscription -Match $subscriptionId + if ($Context) { + $null = $Context | Set-AzContext + } + } + if ($PSCmdlet.ShouldProcess('Subscription level deployment', 'Create')) { + $res = New-AzSubscriptionDeployment @DeploymentInputs -Location $location + } + break } - break - } - 'subscription' { - if ($subscriptionId) { - $Context = Get-AzContext -ListAvailable | Where-Object Subscription -Match $subscriptionId - if ($Context) { - $null = $Context | Set-AzContext + 'managementgroup' { + if ($PSCmdlet.ShouldProcess('Management group level deployment', 'Create')) { + $res = New-AzManagementGroupDeployment @DeploymentInputs -Location $location -ManagementGroupId $managementGroupId } + break } - if ($PSCmdlet.ShouldProcess('Subscription level deployment', 'Create')) { - $res = New-AzSubscriptionDeployment @DeploymentInputs -Location $location + 'tenant' { + if ($PSCmdlet.ShouldProcess('Tenant level deployment', 'Create')) { + $res = New-AzTenantDeployment @DeploymentInputs -Location $location + } + break } - break - } - 'managementGroup' { - if ($PSCmdlet.ShouldProcess('Management group level deployment', 'Create')) { - $res = New-AzManagementGroupDeployment @DeploymentInputs -Location $location -ManagementGroupId $managementGroupId + default { + throw "[$deploymentScope] is a non-supported template scope" + $Stoploop = $true } - break } - 'tenant' { - if ($PSCmdlet.ShouldProcess('Tenant level deployment', 'Create')) { - $res = New-AzTenantDeployment @DeploymentInputs -Location $location + $Stoploop = $true + } catch { + if ($retryCount -ge $retryLimit) { + if ($doNotThrow) { + return @{ + DeploymentName = $deploymentName + Exception = $PSitem.Exception.Message + } + } else { + throw $PSitem.Exception.Message } - break - } - default { - throw "[$deploymentScope] is a non-supported template scope" $Stoploop = $true - } - } - $Stoploop = $true - } catch { - if ($retryCount -ge $retryLimit) { - if ($doNotThrow) { - return @{ - DeploymentName = $deploymentName - Exception = $PSitem.Exception.Message - } } else { - throw $PSitem.Exception.Message + 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++ } - $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 @{ + deploymentName = $deploymentName + deploymentOutput = $res.Outputs + } } - until ($Stoploop -eq $true -or $retryCount -gt $retryLimit) - - Write-Verbose 'Result' -Verbose - Write-Verbose '------' -Verbose - Write-Verbose ($res | Out-String) -Verbose - return @{ - deploymentName = $deploymentName - deploymentOutput = $res.Outputs + + end { + Write-Debug ('{0} exited' -f $MyInvocation.MyCommand) } } #endregion @@ -259,6 +256,9 @@ Optional. Name of the management group to deploy into. Mandatory if deploying in .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. @@ -298,10 +298,10 @@ function New-ModuleDeployment { [string] $managementGroupId, [Parameter(Mandatory = $false)] - [Hashtable]$additionalParameters, + [Hashtable] $additionalParameters, [Parameter(Mandatory = $false)] - [PSCustomObject]$additionalTags, + [PSCustomObject] $additionalTags, [Parameter(Mandatory = $false)] [switch] $doNotThrow, @@ -337,9 +337,9 @@ function New-ModuleDeployment { if ($parameterFilePath) { if ($parameterFilePath -is [array]) { $deploymentResult = [System.Collections.ArrayList]@() - foreach ($parameterFile in $parameterFilePath) { + foreach ($path in $parameterFilePath) { if ($PSCmdlet.ShouldProcess("Deployment for parameter file [$parameterFilePath]", 'Trigger')) { - $deploymentResult += New-DeploymentWithParameterFile @deploymentInputObject -parameterFilePath $parameterFile + $deploymentResult += New-DeploymentWithParameterFile @deploymentInputObject -parameterFilePath $path } } return $deploymentResult diff --git a/utilities/pipelines/resourceRemoval/Initialize-DeploymentRemoval.ps1 b/utilities/pipelines/resourceRemoval/Initialize-DeploymentRemoval.ps1 new file mode 100644 index 0000000000..54f8ad6d05 --- /dev/null +++ b/utilities/pipelines/resourceRemoval/Initialize-DeploymentRemoval.ps1 @@ -0,0 +1,89 @@ +<# +.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. + +.EXAMPLE +Initialize-DeploymentRemoval -DeploymentName 'virtualWans-20211204T1812029146Z' -TemplateFilePath "$home/ResourceModules/arm/Microsoft.Network/virtualWans/deploy.bicep" -resourceGroupName 'test-virtualWan-parameters.json-rg' + +Remove the deployment 'virtualWans-20211204T1812029146Z' from resource group 'test-virtualWan-parameters.json-rg' that was executed using template in path "$home/ResourceModules/arm/Microsoft.Network/virtualWans/deploy.bicep" +#> +function Initialize-DeploymentRemoval { + + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [Alias('DeploymentName')] + [string[]] $DeploymentNames, + + [Parameter(Mandatory = $true)] + [string] $TemplateFilePath, + + [Parameter(Mandatory = $false)] + [string] $ResourceGroupName = 'validation-rg' + ) + + begin { + Write-Debug ('{0} entered' -f $MyInvocation.MyCommand) + # Load functions + . (Join-Path $PSScriptRoot 'helper' 'Remove-Deployment.ps1') + } + + process { + $moduleName = Split-Path (Split-Path $templateFilePath -Parent) -LeafBase + + # The intial sequence is a general order-recommendation + $removalSequence = @( + 'Microsoft.Insights/diagnosticSettings', + 'Microsoft.Resources/resourceGroups', + 'Microsoft.Compute/virtualMachines' + ) + + foreach ($deploymentName in $deploymentNames) { + Write-Verbose ('Handling resource removal with deployment name [{0}]' -f $deploymentName) -Verbose + switch ($moduleName) { + 'virtualWans' { + $removalSequence += @( + 'Microsoft.Network/vpnGateways', + 'Microsoft.Network/virtualHubs', + 'Microsoft.Network/vpnSites' + ) + break + } + 'automationAccounts' { + $removalSequence += @( + 'Microsoft.OperationsManagement/solutions', + 'Microsoft.OperationalInsights/workspaces/linkedServices', + 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups', + 'Microsoft.Network/privateEndpoints' + ) + break + } + } + + # Invoke removal + $inputObject = @{ + DeploymentName = $deploymentName + ResourceGroupName = $resourceGroupName + TemplateFilePath = $templateFilePath + RemovalSequence = $removalSequence + } + Remove-Deployment @inputObject -Verbose + } + } + + end { + Write-Debug ('{0} exited' -f $MyInvocation.MyCommand) + } +} diff --git a/utilities/pipelines/resourceRemoval/Remove-DeployedModule.ps1 b/utilities/pipelines/resourceRemoval/Remove-DeployedModule.ps1 deleted file mode 100644 index 8398641a2d..0000000000 --- a/utilities/pipelines/resourceRemoval/Remove-DeployedModule.ps1 +++ /dev/null @@ -1,68 +0,0 @@ -function Remove-DeployedModule { - - [CmdletBinding()] - param ( - [Parameter(Mandatory = $true)] - [string] $deploymentName, - - [Parameter(Mandatory = $true)] - [string] $templateFilePath, - - [Parameter(Mandatory = $false)] - [string] $ResourceGroupName = 'validation-rg' - ) - - $moduleName = Split-Path (Split-Path $templateFilePath -Parent) -LeafBase - - switch ($moduleName) { - 'virtualWans' { - Write-Verbose 'Run vWAN removal script' -Verbose - # Load function - . (Join-Path $PSScriptRoot 'helper' 'Remove-vWan.ps1') - - # Invoke removal - $inputObject = @{ - deploymentName = $deploymentName - ResourceGroupName = $ResourceGroupName - } - Remove-vWan @inputObject -Verbose - } - 'virtualMachines' { - Write-Verbose 'Run virtual machine removal script' -Verbose - # Load function - . (Join-Path $PSScriptRoot 'helper' 'Remove-VirtualMachine.ps1') - - # Invoke removal - $inputObject = @{ - deploymentName = $deploymentName - ResourceGroupName = $ResourceGroupName - } - Remove-VirtualMachine @inputObject -Verbose - } - 'automationAccounts' { - Write-Verbose 'Run automation account removal script' -Verbose - # Load function - . (Join-Path $PSScriptRoot 'helper' 'Remove-AutomationAccount.ps1') - - # Invoke removal - $inputObject = @{ - deploymentName = $deploymentName - ResourceGroupName = $ResourceGroupName - } - Remove-AutomationAccount @inputObject -Verbose - } - default { - Write-Verbose 'Run default removal script' -Verbose - # Load function - . (Join-Path $PSScriptRoot 'helper' 'Remove-GeneralModule.ps1') - - # Invoke removal - $inputObject = @{ - deploymentName = $deploymentName - ResourceGroupName = $ResourceGroupName - templateFilePath = $templateFilePath - } - Remove-GeneralModule @inputObject -Verbose - } - } -} diff --git a/utilities/pipelines/resourceRemoval/helper/Get-DependencyResourceNames.ps1 b/utilities/pipelines/resourceRemoval/helper/Get-DependencyResourceNameList.ps1 similarity index 88% rename from utilities/pipelines/resourceRemoval/helper/Get-DependencyResourceNames.ps1 rename to utilities/pipelines/resourceRemoval/helper/Get-DependencyResourceNameList.ps1 index 7e69bcf093..c3748e2dad 100644 --- a/utilities/pipelines/resourceRemoval/helper/Get-DependencyResourceNames.ps1 +++ b/utilities/pipelines/resourceRemoval/helper/Get-DependencyResourceNameList.ps1 @@ -6,20 +6,20 @@ Get a list of all dependency resources specified in the dependencies parameter f Get a list of all dependency resources specified in the dependencies parameter files Note: It only considers resources that use the 'name' parameter -.PARAMETER dependencyParameterPath +.PARAMETER DependencyParameterPath Optional. The path the the dependency parameters parent folder. Defaults to 'utilities/pipelines/dependencies' .EXAMPLE -Get-DependencyResourceNames +Get-DependencyResourceNameList Get the list of all dependency names from the current set of parameter files #> -function Get-DependencyResourceNames { +function Get-DependencyResourceNameList { [CmdletBinding()] param ( [Parameter(Mandatory = $false)] - [string] $dependencyParameterPath = (Join-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) 'dependencies') + [string] $DependencyParameterPath = (Join-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) 'dependencies') ) $parameterFolders = Get-ChildItem -Path $dependencyParameterPath -Recurse -Filter 'parameters' -Directory diff --git a/utilities/pipelines/resourceRemoval/helper/Get-OrderedResourcesList.ps1 b/utilities/pipelines/resourceRemoval/helper/Get-OrderedResourcesList.ps1 new file mode 100644 index 0000000000..0c94365267 --- /dev/null +++ b/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/utilities/pipelines/resourceRemoval/helper/Get-ResourceIdsAsFormattedObjectList.ps1 b/utilities/pipelines/resourceRemoval/helper/Get-ResourceIdsAsFormattedObjectList.ps1 new file mode 100644 index 0000000000..2136afbe0a --- /dev/null +++ b/utilities/pipelines/resourceRemoval/helper/Get-ResourceIdsAsFormattedObjectList.ps1 @@ -0,0 +1,104 @@ +<# +.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-parameters.json-rg/providers/Microsoft.Storage/storageAccounts/adpsxxazsaaspar01') + +Returns an object @{ + resourceId = '/subscriptions//resourceGroups/test-analysisServices-parameters.json-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 } { + # subscription level resource group + $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/utilities/pipelines/resourceRemoval/helper/Get-ResourceIdsOfDeployment.ps1 b/utilities/pipelines/resourceRemoval/helper/Get-ResourceIdsOfDeployment.ps1 new file mode 100644 index 0000000000..076aa7ad06 --- /dev/null +++ b/utilities/pipelines/resourceRemoval/helper/Get-ResourceIdsOfDeployment.ps1 @@ -0,0 +1,192 @@ +#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 Scope +Mandatory. The scope to search in + +.EXAMPLE +Get-ResourceIdsOfDeploymentInner -Name 'keyvault-12356' -Scope 'resourcegroup' + +Get all deployments that match name 'keyvault-12356' in scope 'resourcegroup' + +.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-ResourceIdsOfDeploymentInner { + + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] $Name, + + [Parameter(Mandatory = $false)] + [string] $ResourceGroupName, + + [Parameter(Mandatory)] + [ValidateSet( + 'resourcegroup', + 'subscription', + 'managementgroup', + 'tenant' + )] + [string] $Scope + ) + + $resultSet = [System.Collections.ArrayList]@() + switch ($Scope) { + 'resourcegroup' { + if (Get-AzResourceGroup -Name $resourceGroupName -ErrorAction 'SilentlyContinue') { + [array]$deploymentTargets = (Get-AzResourceGroupDeploymentOperation -DeploymentName $name -ResourceGroupName $resourceGroupName).TargetResource | Where-Object { $_ -ne $null } + foreach ($deployment in ($deploymentTargets | Where-Object { $_ -notmatch '/deployments/' } )) { + [array]$resultSet += $deployment + } + foreach ($deployment in ($deploymentTargets | Where-Object { $_ -match '/deployments/' } )) { + $name = Split-Path $deployment -Leaf + $resourceGroupName = $deployment.split('/resourceGroups/')[1].Split('/')[0] + [array]$resultSet += Get-ResourceIdsOfDeploymentInner -Name $name -ResourceGroupName $ResourceGroupName -Scope 'resourcegroup' + } + } 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/" } + } + } + 'subscription' { + [array]$deploymentTargets = (Get-AzDeploymentOperation -DeploymentName $name).TargetResource | Where-Object { $_ -ne $null } + foreach ($deployment in ($deploymentTargets | Where-Object { $_ -notmatch '/deployments/' } )) { + [array]$resultSet += $deployment + } + foreach ($deployment in ($deploymentTargets | Where-Object { $_ -match '/deployments/' } )) { + [array]$resultSet = $resultSet | Where-Object { $_ -ne $deployment } + if ($deployment -match '/resourceGroups/') { + # Resource Group Level Child Deployments + $name = Split-Path $deployment -Leaf + $resourceGroupName = $deployment.split('/resourceGroups/')[1].Split('/')[0] + [array]$resultSet += Get-ResourceIdsOfDeploymentInner -Name $name -ResourceGroupName $ResourceGroupName -Scope 'resourcegroup' + } else { + # Subscription Level Deployments + [array]$resultSet += Get-ResourceIdsOfDeploymentInner -name (Split-Path $deployment -Leaf) -Scope 'subscription' + } + } + } + 'managementgroup' { + [array]$deploymentTargets = (Get-AzManagementGroupDeploymentOperation -DeploymentName $name).TargetResource | Where-Object { $_ -ne $null } + foreach ($deployment in ($deploymentTargets | Where-Object { $_ -notmatch '/deployments/' } )) { + [array]$resultSet += $deployment + } + foreach ($deployment in ($deploymentTargets | Where-Object { $_ -match '/deployments/' } )) { + [array]$resultSet = $resultSet | Where-Object { $_ -ne $deployment } + if ($deployment -match '/managementGroup/') { + # Subscription Level Child Deployments + [array]$resultSet += Get-ResourceIdsOfDeploymentInner -Name (Split-Path $deployment -Leaf) -Scope 'subscription' + } else { + # Management Group Level Deployments + [array]$resultSet += Get-ResourceIdsOfDeploymentInner -name (Split-Path $deployment -Leaf) -scope 'managementgroup' + } + } + } + 'tenant' { + [array]$deploymentTargets = (Get-AzTenantDeploymentOperation -DeploymentName $name).TargetResource | Where-Object { $_ -ne $null } + foreach ($deployment in ($deploymentTargets | Where-Object { $_ -notmatch '/deployments/' } )) { + [array]$resultSet += $deployment + } + foreach ($deployment in ($deploymentTargets | Where-Object { $_ -match '/deployments/' } )) { + [array]$resultSet = $resultSet | Where-Object { $_ -ne $deployment } + if ($deployment -match '/tenant/') { + # Management Group Level Child Deployments + [array]$resultSet += Get-ResourceIdsOfDeploymentInner -Name (Split-Path $deployment -Leaf) -scope 'managementgroup' + } else { + # Tenant Level Deployments + [array]$resultSet += Get-ResourceIdsOfDeploymentInner -name (Split-Path $deployment -Leaf) + } + } + } + } + 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 +Mandatory. The resource group of the resource to remove + +.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-ResourceIdsOfDeployment -name 'KeyVault' -ResourceGroupName 'validation-rg' -scope 'resourcegroup' + +Get all deployments that match name 'KeyVault' in scope 'resourcegroup' of resource group 'validation-rg' +#> +function Get-ResourceIdsOfDeployment { + + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [string] $ResourceGroupName, + + [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 { + [array]$deployments = Get-ResourceIdsOfDeploymentInner -Name $name -Scope $scope -ResourceGroupName $resourceGroupName -ErrorAction 'SilentlyContinue' + if ($deployments) { + break + } + Write-Verbose ('Did not to find deployments 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 $deployments) { + throw "No deployment found for [$name]" + } + + return $deployments +} diff --git a/utilities/pipelines/resourceRemoval/helper/Invoke-ResourcePostRemoval.ps1 b/utilities/pipelines/resourceRemoval/helper/Invoke-ResourcePostRemoval.ps1 new file mode 100644 index 0000000000..a1757e152d --- /dev/null +++ b/utilities/pipelines/resourceRemoval/helper/Invoke-ResourcePostRemoval.ps1 @@ -0,0 +1,113 @@ +<# +.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.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 key vault [$resourceName]") -Verbose + if ($PSCmdlet.ShouldProcess(('Key Vault with ID [{0}]' -f $matchingKeyVault.Id), 'Purge')) { + $null = Remove-AzKeyVault -ResourceId $matchingKeyVault.Id -InRemovedState -Force -Location $matchingKeyVault.Location + } + } + } + 'Microsoft.CognitiveServices/accounts' { + $resourceGroupName = $resourceId.Split('/')[4] + $resourceName = Split-Path $ResourceId -Leaf + + $matchingAccount = Get-AzCognitiveServicesAccount -InRemovedState | Where-Object { $_.AccountName -eq $resourceName } + if ($matchingAccount) { + 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 + } + } + } + '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 + } + if ($PSCmdlet.ShouldProcess(('API management service with ID [{0}]' -f $softDeletedService.properties.serviceId), 'Purge')) { + $null = Invoke-AzRestMethod @purgeRequestInputObject + } + } + } + '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') + } + } +} diff --git a/utilities/pipelines/resourceRemoval/helper/Invoke-ResourceRemoval.ps1 b/utilities/pipelines/resourceRemoval/helper/Invoke-ResourceRemoval.ps1 new file mode 100644 index 0000000000..14f9891773 --- /dev/null +++ b/utilities/pipelines/resourceRemoval/helper/Invoke-ResourceRemoval.ps1 @@ -0,0 +1,72 @@ +<# +.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 + ) + + 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.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 + # -------------- + $null = Remove-AzResource -ResourceId $resourceId -Force -ErrorAction 'Stop' + } + Default { + $null = Remove-AzResource -ResourceId $resourceId -Force -ErrorAction 'Stop' + } + } +} diff --git a/utilities/pipelines/resourceRemoval/helper/Remove-AutomationAccount.ps1 b/utilities/pipelines/resourceRemoval/helper/Remove-AutomationAccount.ps1 deleted file mode 100644 index 8867a18695..0000000000 --- a/utilities/pipelines/resourceRemoval/helper/Remove-AutomationAccount.ps1 +++ /dev/null @@ -1,124 +0,0 @@ -<# -.SYNOPSIS -Remove Automation account, Log analytics link and Update solution deployed with a given deployment name. Resources will be removed even if Log Analytics is in a different resource group than the Automation Account - -.DESCRIPTION -Remove Automation account, Log analytics link and Update solution deployed with a given deployment name. Resources will be removed even if Log Analytics is in a different resource group than the Automation Account - -.PARAMETER deploymentName -Mandatory. The deployment name to use and find resources to remove - -.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 -Remove-AutomationAccount -deploymentname 'aa-12345' - -Remove Automation account, Log analytics link and Update solution deployed starting with the deployment name 'aa-12345'. -#> -function Remove-AutomationAccount { - - [Cmdletbinding(SupportsShouldProcess)] - param( - [Parameter(Mandatory = $true)] - [string] $deploymentName, - - [Parameter(Mandatory = $false)] - [string] $ResourceGroupName = 'validation-rg', - - [Parameter(Mandatory = $false)] - [int] $searchRetryLimit = 40, - - [Parameter(Mandatory = $false)] - [int] $searchRetryInterval = 60 - ) - - begin { - Write-Debug ('{0} entered' -f $MyInvocation.MyCommand) - - # Load helper - . (Join-Path $PSScriptRoot 'Remove-Resource.ps1') - } - - process { - - # Identify resources - # ------------------ - $searchRetryCount = 1 - do { - $deployments = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $resourceGroupName -ErrorAction 'SilentlyContinue' - if ($deployments) { - break - } - Write-Verbose ('[Failure] not to find Automation Account deployment resources by name [{0}] in scope [{1}]. Retrying in [{2}] seconds [{3}/{4}]' -f $deploymentName, $deploymentScope, $searchRetryInterval, $searchRetryCount, $searchRetryLimit) -Verbose - Start-Sleep $searchRetryInterval - $searchRetryCount++ - } while ($searchRetryCount -le $searchRetryLimit) - - if (-not $deployments) { - throw "No deployment found for [$deploymentName]" - } - - $resourcesToRemove = @() - $unorderedResourceIds = $deployments.TargetResource | Where-Object { $_ -and $_ -notmatch '/deployments/' } - $childDeploymentsIds = $deployments.TargetResource | Where-Object { $_ -and $_ -match '/deployments/' } - - foreach ($childDeploymentId in $childDeploymentsIds) { - $searchRetryCount = 1 - $childDeploymentTokens = $childDeploymentId.Split('/') - $childDeploymentName = $childDeploymentTokens[8] - $childDeploymentResourceGroup = $childDeploymentTokens[4] - do { - Write-Verbose ('Searching child deployment named [{0}] in resource group [{1}]. Attempt [{2}/{3}]' -f $childDeploymentName, $childDeploymentResourceGroup, $searchRetryCount, $searchRetryLimit) -Verbose - $childDeployment = Get-AzResourceGroupDeploymentOperation -DeploymentName $childDeploymentName -ResourceGroupName $childDeploymentResourceGroup -ErrorAction 'SilentlyContinue' - if ($childDeployment) { - Write-Verbose ('[Success] Child deployment named [{0}] in resource group [{1}] found' -f $childDeploymentName, $childDeploymentResourceGroup) -Verbose - $unorderedResourceIds += $childDeployment.TargetResource - break - } - Write-Verbose ('[Failure] Did not to find child deployment named [{0}] in resource group [{1}]. Retrying in [{2}] seconds [{3}/{4}]' -f $childDeploymentName, $childDeploymentResourceGroup, $searchRetryInterval, $searchRetryCount, $searchRetryLimit) -Verbose - Start-Sleep $searchRetryInterval - $searchRetryCount++ - } while ($searchRetryCount -le $searchRetryLimit) - } - - $unorderedResourceIds = $unorderedResourceIds | Where-Object { $_ ` - -and ($_ -notmatch '/Microsoft.Insights/diagnosticSettings/') ` - -and ($_ -notmatch '/variables/') ` - -and ($_ -notmatch '/softwareUpdateConfigurations/') ` - -and ($_ -notmatch '/jobSchedules/') ` - -and ($_ -notmatch '/schedules/') ` - -and ($_ -notmatch '/runbooks/') ` - -and ($_ -notmatch '/modules/') ` - -and ($_ -notmatch '/Microsoft.Authorization/roleAssignments/') ` - } | Select-Object -Unique - - $orderedResourceIds = @( - $unorderedResourceIds | Where-Object { $_ -match 'Microsoft.OperationsManagement/solutions/Updates' } - $unorderedResourceIds | Where-Object { $_ -match 'linkedServices/automation' } - $unorderedResourceIds | Where-Object { $_ -match 'Microsoft.Insights/diagnosticSettings' } - $unorderedResourceIds | Where-Object { $_ -match 'Microsoft.Automation/automationAccounts' } - ) - - $resourcesToRemove = $orderedResourceIds | ForEach-Object { - @{ - resourceId = $_ - name = $_.Split('/')[-1] - type = $_.Split('/')[6..7] -join '/' - } - } - - # Remove resources - # ---------------- - if ($PSCmdlet.ShouldProcess(('[{0}] resources' -f $resourcesToRemove.Count), 'Remove')) { - Remove-Resource -resourceToRemove $resourcesToRemove -Verbose - } - } - - end { - Write-Debug ('{0} exited' -f $MyInvocation.MyCommand) - } -} diff --git a/utilities/pipelines/resourceRemoval/helper/Remove-Deployment.ps1 b/utilities/pipelines/resourceRemoval/helper/Remove-Deployment.ps1 new file mode 100644 index 0000000000..a680938a6d --- /dev/null +++ b/utilities/pipelines/resourceRemoval/helper/Remove-Deployment.ps1 @@ -0,0 +1,121 @@ +<# +.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 +Mandatory. The resource group of the resource to remove + +.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 + +.PARAMETER DeploymentName +Optional. The deployment name to use for the removal + +.PARAMETER TemplateFilePath +Optional. The path to the deployment file + +.PARAMETER RemovalSequence +Optional. The order of resource types to apply for deletion + +.EXAMPLE +Remove-Deployment -DeploymentName 'KeyVault' -ResourceGroupName 'validation-rg' -TemplateFilePath 'C:/deploy.json' + +Remove a virtual WAN with deployment name 'keyvault-12345' from resource group 'validation-rg' +#> +function Remove-Deployment { + + [CmdletBinding(SupportsShouldProcess)] + param ( + [Parameter(Mandatory = $false)] + [string] $ResourceGroupName, + + [Parameter(Mandatory = $true)] + [string] $DeploymentName, + + [Parameter(Mandatory = $true)] + [string] $TemplateFilePath, + + [Parameter(Mandatory = $false)] + [string[]] $RemovalSequence = @(), + + [Parameter(Mandatory = $false)] + [int] $SearchRetryLimit = 40, + + [Parameter(Mandatory = $false)] + [int] $SearchRetryInterval = 60 + ) + + 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-ResourceIdsOfDeployment.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' 'Get-DependencyResourceNameList.ps1') + . (Join-Path (Split-Path $PSScriptRoot -Parent) 'helper' 'Remove-ResourceList.ps1') + } + + process { + # 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 + # ================= + $deploymentsInputObject = @{ + Name = $deploymentName + Scope = $deploymentScope + ResourceGroupName = $resourceGroupName + } + $deploymentResourceIds = Get-ResourceIdsOfDeployment @deploymentsInputObject -Verbose + + # Pre-Filter & order items + # ======================== + $rawResourceIdsToRemove = $deploymentResourceIds | Sort-Object -Property { $_.Split('/').Count } -Descending | Select-Object -Unique + + # Format items + # ============ + $resourcesToRemove = Get-ResourceIdsAsFormattedObjectList -ResourceIds $rawResourceIdsToRemove + + # Filter all dependency resources + # =============================== + $dependencyResourceNames = Get-DependencyResourceNameList + $resourcesToRemove = $resourcesToRemove | Where-Object { $_.Name -notin $dependencyResourceNames } + + # Order resources + # =============== + $resourcesToRemove = Get-OrderedResourcesList -ResourcesToOrder $resourcesToRemove -Order $RemovalSequence + + # Remove resources + # ================ + if ($resourcesToRemove.Count -gt 0) { + if ($PSCmdlet.ShouldProcess(('[{0}] resources' -f (($resourcesToRemove -is [array]) ? $resourcesToRemove.Count : 1)), 'Remove')) { + Remove-ResourceList -ResourcesToRemove $resourcesToRemove -Verbose + } + } else { + Write-Verbose 'Found [0] resources to remove' + } + } + + end { + Write-Debug ('{0} exited' -f $MyInvocation.MyCommand) + } +} diff --git a/utilities/pipelines/resourceRemoval/helper/Remove-GeneralModule.ps1 b/utilities/pipelines/resourceRemoval/helper/Remove-GeneralModule.ps1 deleted file mode 100644 index 7753f69722..0000000000 --- a/utilities/pipelines/resourceRemoval/helper/Remove-GeneralModule.ps1 +++ /dev/null @@ -1,244 +0,0 @@ -#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 - -.PARAMETER Name -Mandatory. The deployment name to search for - -.PARAMETER ResourceGroupName -Optional. The name of the resource group for scope 'resourceGroup' - -.PARAMETER Scope -Mandatory. The scope to search in - -.EXAMPLE -Get-DeploymentByName -Name 'keyvault-12356' -Scope 'resourceGroup' - -Get all deployments that match name 'keyvault-12356' in scope 'resourceGroup' -#> -function Get-DeploymentByName { - - [CmdletBinding()] - param ( - [Parameter(Mandatory)] - [string] $Name, - - [Parameter(Mandatory = $false)] - [string] $ResourceGroupName, - - [Parameter(Mandatory)] - [ValidateSet( - 'resourceGroup', - 'subscription', - 'managementGroup', - 'tenant' - )] - [string] $Scope - ) - - switch ($Scope) { - 'resourceGroup' { - return Get-AzResourceGroupDeploymentOperation -DeploymentName $name -ResourceGroupName $resourceGroupName - } - 'subscription' { - return Get-AzDeploymentOperation -DeploymentName $name - } - 'managementGroup' { - return Get-AzManagementGroupDeploymentOperation` -DeploymentName $name - } - 'tenant' { - return Get-AzTenantDeploymentOperation -DeploymentName $name - } - } -} - -#endregion - -<# -.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 -Mandatory. The resource group of the resource to remove - -.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 - -.PARAMETER DeploymentName -Optional. The deployment name to use for the removal - -.PARAMETER TemplateFilePath -Optional. The path to the deployment file - -.EXAMPLE -Remove-GeneralModule -DeploymentName 'KeyVault' -ResourceGroupName 'validation-rg' - -Remove a virtual WAN with deployment name 'keyvault-12345' from resource group 'validation-rg' -#> -function Remove-GeneralModule { - - [CmdletBinding(SupportsShouldProcess)] - param ( - [Parameter(Mandatory = $false)] - [string] $ResourceGroupName, - - [Parameter(Mandatory = $true)] - [string] $DeploymentName, - - [Parameter(Mandatory = $true)] - [string] $TemplateFilePath, - - [Parameter(Mandatory = $false)] - [int] $SearchRetryLimit = 40, - - [Parameter(Mandatory = $false)] - [int] $SearchRetryInterval = 60 - ) - - begin { - Write-Debug ('{0} entered' -f $MyInvocation.MyCommand) - - # Load helper - . (Join-Path $PSScriptRoot 'Remove-Resource.ps1') - . (Join-Path $PSScriptRoot 'Get-DependencyResourceNames.ps1') - } - - process { - - ##################### - ## Process Removal ## - ##################### - Write-Verbose ('Handling resource removal with deployment name [{0}]' -f $deploymentName) -Verbose - - # Gather deployments - # ================== - if ((Split-Path $templateFilePath -Extension) -eq '.bicep') { - # Bicep - $bicepContent = Get-Content $templateFilePath - $bicepScope = $bicepContent | Where-Object { $_ -like '*targetscope =*' } - if (-not $bicepScope) { - $deploymentScope = 'resourceGroup' - } else { - $deploymentScope = $bicepScope.ToLower().Split('=')[-1].Replace("'", '').Trim() - } - } else { - # ARM - $armSchema = (ConvertFrom-Json (Get-Content -Raw -Path $templateFilePath)).'$schema' - switch -regex ($armSchema) { - '\/deploymentTemplate.json#$' { $deploymentScope = 'resourceGroup' } - '\/subscriptionDeploymentTemplate.json#$' { $deploymentScope = 'subscription' } - '\/managementGroupDeploymentTemplate.json#$' { $deploymentScope = 'managementGroup' } - '\/tenantDeploymentTemplate.json#$' { $deploymentScope = 'tenant' } - Default { throw "[$armSchema] is a non-supported ARM template schema" } - } - } - - # Identify resources - # ------------------ - $searchRetryCount = 1 - do { - $deployments = Get-DeploymentByName -name $deploymentName -scope $deploymentScope -resourceGroupName $resourceGroupName -ErrorAction 'SilentlyContinue' - if ($deployments) { - break - } - Write-Verbose ('Did not to find deployments by name [{0}] in scope [{1}]. Retrying in [{2}] seconds [{3}/{4}]' -f $deploymentName, $deploymentScope, $searchRetryInterval, $searchRetryCount, $searchRetryLimit) -Verbose - Start-Sleep $searchRetryInterval - $searchRetryCount++ - } while ($searchRetryCount -le $searchRetryLimit) - - if (-not $deployments) { - throw "No deployment found for [$deploymentName]" - } - - $resourcesToRemove = @() - $rawResourceIdsToRemove = $deployments.TargetResource | Where-Object { $_ -and $_ -notmatch '/deployments/' } - $rawResourceIdsToRemove = $rawResourceIdsToRemove | Sort-Object -Descending -Unique - - # Process removal - # =============== - if ($deploymentScope -eq 'subscription') { - Write-Verbose 'Handle subscription level removal' - - foreach ($rawResourceIdsToRemove in $rawResourceIdsToRemove) { - if ($rawResourceIdsToRemove.Split('/').count -lt 7) { - # resource group - $resourcesToRemove += @{ - resourceId = $rawResourceIdsToRemove - name = $rawResourceIdsToRemove.Split('/')[-1] - type = 'Microsoft.Resources/resourceGroups' - } - } else { - $resourcesToRemove += @{ - resourceId = $rawResourceIdsToRemove - name = $rawResourceIdsToRemove.Split('/')[-1] - type = $rawResourceIdsToRemove.Split('/')[4, 5] -join '/' - } - } - } - } else { - $allResources = Get-AzResource -ResourceGroupName $resourceGroupName -Name '*' - # Get all child resources and sort from child to parent - foreach ($topLevelResource in $rawResourceIdsToRemove) { - $expandedResources = $allResources | Where-Object { $_.ResourceId.startswith($topLevelResource) } - $expandedResources = $expandedResources | Sort-Object -Descending -Property { $_.ResourceId.Split('/').Count } - foreach ($resource in $expandedResources) { - $resourcesToRemove += @{ - resourceId = $resource.ResourceId - name = $resource.Name - type = $resource.Type - } - } - } - if ($resourcesToRemove.Count -gt 1) { - $resourcesToRemove = $resourcesToRemove | Sort-Object -Descending -Property 'ResourceId' -Unique - } - - # If VMs are available, delete those first - if ($vmsContained = $resourcesToRemove | Where-Object { $_.type -eq 'Microsoft.Compute/virtualMachines' }) { - - $intermediateResources = @() - foreach ($vmInstance in $vmsContained) { - $intermediateResources += @{ - resourceId = $vmInstance.ResourceId - name = $vmInstance.Name - type = $vmInstance.Type - } - } - if ($PSCmdlet.ShouldProcess(('[{0}] VM resources' -f $intermediateResources.Count), 'Remove')) { - Remove-Resource -resourceToRemove $intermediateResources -Verbose - } - # refresh - $resourcesToRemove = $resourcesToRemove | Where-Object { $_.ResourceId -notin $intermediateResources.resourceId } - } - } - - # Filter all dependency resources - $dependencyResourceNames = Get-DependencyResourceNames - $resourcesToRemove = $resourcesToRemove | Where-Object { $_.Name -notin $dependencyResourceNames } - - # Remove resources - # ---------------- - if ($PSCmdlet.ShouldProcess(('[{0}] resources' -f (($resourcesToRemove -is [array]) ? $resourcesToRemove.Count : 1)), 'Remove')) { - Remove-Resource -resourceToRemove $resourcesToRemove -Verbose - } - } - - end { - Write-Debug ('{0} exited' -f $MyInvocation.MyCommand) - } -} diff --git a/utilities/pipelines/resourceRemoval/helper/Remove-Resource.ps1 b/utilities/pipelines/resourceRemoval/helper/Remove-Resource.ps1 deleted file mode 100644 index 54429a6032..0000000000 --- a/utilities/pipelines/resourceRemoval/helper/Remove-Resource.ps1 +++ /dev/null @@ -1,101 +0,0 @@ -#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 resourceToRemove -Mandatory. The resource(s) to remove. Each resource must have a name (optional), type (optional) & resourceId property. - -.EXAMPLE -Remove-ResourceInner -resourceToRemove @( @{ 'Name' = 'resourceName'; Type = 'Microsoft.Storage/storageAccounts'; ResourceId = 'subscriptions/.../storageAccounts/resourceName' } ) -#> -function Remove-ResourceInner { - - - [CmdletBinding(SupportsShouldProcess)] - param( - [Parameter(Mandatory = $false)] - [PSObject[]] $resourceToRemove = @() - ) - - $resourceToRemove | ForEach-Object { Write-Verbose ('- Remove [{0}]' -f $_.resourceId) -Verbose } - $resourcesToRetry = @() - Write-Verbose '----------------------------------' -Verbose - - foreach ($resource in $resourceToRemove) { - - Write-Verbose ('Trying to remove resource [{0}] of type [{1}]' -f $resource.name, $resource.type) -Verbose - try { - if ($PSCmdlet.ShouldProcess(('Resource [{0}]' -f $resource.resourceId), 'Remove')) { - $null = Remove-AzResource -ResourceId $resource.resourceId -Force -ErrorAction 'Stop' - } - - # If we removed a parent remove its children - $resourceToRemove = $resourceToRemove | Where-Object { $_.resourceId -notmatch $resource.resourceId } - $resourcesToRetry = $resourcesToRetry | Where-Object { $_.resourceId -notmatch $resource.resourceId } - } catch { - Write-Warning ('Removal moved back for re-try. Reason: [{0}]' -f $_.Exception.Message) - $resourcesToRetry += $resource - } - } - Write-Verbose '----------------------------------' -Verbose - return $resourcesToRetry -} -#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 resourceToRemove -Optional. The array of resources to remove. Has to contain objects with at least a 'resourceId' property - -.EXAMPLE -Remove-Resource @( @{ 'Name' = 'resourceName'; Type = 'Microsoft.Storage/storageAccounts'; ResourceId = 'subscriptions/.../storageAccounts/resourceName' } ) - -Remove resource with ID 'subscriptions/.../storageAccounts/resourceName'. -#> -function Remove-Resource { - - [CmdletBinding(SupportsShouldProcess)] - param( - [Parameter(Mandatory = $false)] - [PSObject[]] $resourceToRemove = @(), - - [Parameter(Mandatory = $false)] - [int] $removalRetryLimit = 3, - - [Parameter(Mandatory = $false)] - [int] $removalRetryInterval = 15 - ) - - $removalRetryCount = 1 - $resourcesToRetry = $resourceToRemove - - do { - if ($PSCmdlet.ShouldProcess(("[{0}] Resource(s) with a maximum of [$removalRetryLimit] attempts." -f (($resourcesToRetry -is [array]) ? $resourcesToRetry.Count : 1)), 'Remove')) { - $resourcesToRetry = Remove-ResourceInner -resourceToRemove $resourcesToRetry -Verbose - } else { - Remove-ResourceInner -resourceToRemove $resourceToRemove -WhatIf - } - - if (-not $resourcesToRetry) { - break - } - Write-Verbose ('Re-try 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 ($resourcesToRetry.Name -join ', ')) - } else { - Write-Verbose 'The removal completed successfully' - } -} diff --git a/utilities/pipelines/resourceRemoval/helper/Remove-ResourceList.ps1 b/utilities/pipelines/resourceRemoval/helper/Remove-ResourceList.ps1 new file mode 100644 index 0000000000..ff7dce093c --- /dev/null +++ b/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}] as a 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 re-try. 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 -Verbose + } else { + Remove-ResourceListInner -ResourcesToRemove $resourcesToRemove -WhatIf + } + + if (-not $resourcesToRetry) { + break + } + Write-Verbose ('Re-try 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/utilities/pipelines/resourceRemoval/helper/Remove-VirtualMachine.ps1 b/utilities/pipelines/resourceRemoval/helper/Remove-VirtualMachine.ps1 deleted file mode 100644 index 054f875671..0000000000 --- a/utilities/pipelines/resourceRemoval/helper/Remove-VirtualMachine.ps1 +++ /dev/null @@ -1,115 +0,0 @@ -<# -.SYNOPSIS -Remove a Virtual Machine resource with a given deployment name - -.DESCRIPTION -Remove a Virtual Machine resource with a given deployment name - -.PARAMETER deploymentName -Mandatory. The deployment name to use and find resources to remove - -.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 -Remove-VirtualMachine -deploymentname 'keyvault-12345' - -Remove a virtual machine with deployment name 'keyvault-12345' from resource group 'validation-rg' -#> -function Remove-VirtualMachine { - - [Cmdletbinding(SupportsShouldProcess)] - param( - [Parameter(Mandatory = $true)] - [string] $deploymentName, - - [Parameter(Mandatory = $false)] - [string] $ResourceGroupName = 'validation-rg', - - [Parameter(Mandatory = $false)] - [int] $searchRetryLimit = 40, - - [Parameter(Mandatory = $false)] - [int] $searchRetryInterval = 60 - ) - - begin { - Write-Debug ('{0} entered' -f $MyInvocation.MyCommand) - - # Load helper - . (Join-Path $PSScriptRoot 'Remove-Resource.ps1') - . (Join-Path $PSScriptRoot 'Get-DependencyResourceNames.ps1') - } - - process { - - # Identify resources - # ------------------ - $searchRetryCount = 1 - do { - $deployments = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $resourceGroupName -ErrorAction 'SilentlyContinue' - if ($deployments) { - break - } - Write-Verbose ('Did not to find VirtualMachine deployment resources by name [{0}] in scope [{1}]. Retrying in [{2}] seconds [{3}/{4}]' -f $deploymentName, $deploymentScope, $searchRetryInterval, $searchRetryCount, $searchRetryLimit) -Verbose - Start-Sleep $searchRetryInterval - $searchRetryCount++ - } while ($searchRetryCount -le $searchRetryLimit) - - if (-not $deployments) { - throw "No deployment found for [$deploymentName]" - } - - $unorderedResourceIds = $deployments.TargetResource | Where-Object { $_ -and $_ -notmatch '/deployments/' } - $resourcesToRemove = [System.Collections.ArrayList] @() - - - # Handle VM resources - $vmIds = $unorderedResourceIds | Where-Object { $_ -match '/virtualMachines/' } - if ($vmIds.Count -gt 0) { - $vmName = ($unorderedResourceIds | Where-Object { $_ -match '/virtualMachines/' }).Split('/')[-1] - - # Fetch all resources that match the VM - $allResources = Get-AzResource -ResourceGroupName $resourceGroupName -Name "$vmName*" - - # Sort ascending as we need to remove the VM first - $orderedResourceIds = $allResources | Sort-Object -Property { $_.ResourceId.Split('/').Count } - $resourcesToRemove += $orderedResourceIds | ForEach-Object { - @{ - resourceId = $_.ResourceId - name = $_.Name - type = $_.Type - } - } - } - - # Handle non-vm resources that are deployed with the VM - $otherIds = $unorderedResourceIds | Where-Object { $_ -notmatch '/virtualMachines/' } - foreach ($otherResourceId in $otherIds) { - $resourcesToRemove += @{ - resourceId = $otherResourceId - name = $otherResourceId.Split('/')[-1] - type = $otherResourceId.Split('/')[6..7] -join '/' - } - } - - # Filter all dependency resources - $dependencyResourceNames = Get-DependencyResourceNames - $resourcesToRemove = $resourcesToRemove | Where-Object { $_.Name -notin $dependencyResourceNames } - - # Remove resources - # ---------------- - if ($resourcesToRemove.count -gt 0) { - if ($PSCmdlet.ShouldProcess(('[{0}] resources' -f $resourcesToRemove.Count), 'Remove')) { - Remove-Resource -resourceToRemove $resourcesToRemove -Verbose - } - } - } - - end { - Write-Debug ('{0} exited' -f $MyInvocation.MyCommand) - } -} diff --git a/utilities/pipelines/resourceRemoval/helper/Remove-vWan.ps1 b/utilities/pipelines/resourceRemoval/helper/Remove-vWan.ps1 deleted file mode 100644 index d5090d3c2e..0000000000 --- a/utilities/pipelines/resourceRemoval/helper/Remove-vWan.ps1 +++ /dev/null @@ -1,92 +0,0 @@ -<# -.SYNOPSIS -Remove a vWAN resource with a given deployment name - -.DESCRIPTION -Remove a vWAN resource with a given deployment name - -.PARAMETER deploymentName -Mandatory. The deployment name to use and find resources to remove - -.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 -Remove-vWan -deploymentname 'keyvault-12345' - -Remove a virtual WAN with deployment name 'keyvault-12345' from resource group 'validation-rg' -#> -function Remove-vWan { - - [Cmdletbinding(SupportsShouldProcess)] - param( - [Parameter(Mandatory = $true)] - [string] $deploymentName, - - [Parameter(Mandatory = $false)] - [string] $ResourceGroupName = 'validation-rg', - - [Parameter(Mandatory = $false)] - [int] $searchRetryLimit = 40, - - [Parameter(Mandatory = $false)] - [int] $searchRetryInterval = 60 - ) - - begin { - Write-Debug ('{0} entered' -f $MyInvocation.MyCommand) - - # Load helper - . (Join-Path $PSScriptRoot 'Remove-Resource.ps1') - } - - process { - - # Identify resources - # ------------------ - $searchRetryCount = 1 - do { - $deployments = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $resourceGroupName -ErrorAction 'SilentlyContinue' - if ($deployments) { - break - } - Write-Verbose ('Did not to find vWAN deployment resources by name [{0}] in scope [{1}]. Retrying in [{2}] seconds [{3}/{4}]' -f $deploymentName, $deploymentScope, $searchRetryInterval, $searchRetryCount, $searchRetryLimit) -Verbose - Start-Sleep $searchRetryInterval - $searchRetryCount++ - } while ($searchRetryCount -le $searchRetryLimit) - - if (-not $deployments) { - throw "No deployment found for [$deploymentName]" - } - - $resourcesToRemove = @() - $unorderedResourceIds = $deployments.TargetResource | Where-Object { $_ -and $_ -notmatch '/deployments/' } - - $orderedResourceIds = @( - $unorderedResourceIds | Where-Object { $_ -match 'Microsoft.Network/vpnGateways' } - $unorderedResourceIds | Where-Object { $_ -match 'Microsoft.Network/virtualHubs' } - $unorderedResourceIds | Where-Object { $_ -match 'Microsoft.Network/vpnSites' } - $unorderedResourceIds | Where-Object { $_ -match 'Microsoft.Network/virtualWans' } - ) - $resourcesToRemove = $orderedResourceIds | ForEach-Object { - @{ - resourceId = $_ - name = $_.Split('/')[-1] - type = $_.Split('/')[6..7] -join '/' - } - } - - # Remove resources - # ---------------- - if ($PSCmdlet.ShouldProcess(('[{0}] resources' -f $resourcesToRemove.Count), 'Remove')) { - Remove-Resource -resourceToRemove $resourcesToRemove -Verbose - } - } - - end { - Write-Debug ('{0} exited' -f $MyInvocation.MyCommand) - } -} diff --git a/utilities/pipelines/resourceValidation/Test-TemplateWithParameterFile.ps1 b/utilities/pipelines/resourceValidation/Test-TemplateWithParameterFile.ps1 index 9fcc6ffe99..d8f6ff9452 100644 --- a/utilities/pipelines/resourceValidation/Test-TemplateWithParameterFile.ps1 +++ b/utilities/pipelines/resourceValidation/Test-TemplateWithParameterFile.ps1 @@ -62,6 +62,9 @@ function Test-TemplateWithParameterFile { begin { Write-Debug ('{0} entered' -f $MyInvocation.MyCommand) + + # Load helper + . (Join-Path (Get-Item -Path $PSScriptRoot).parent.FullName 'sharedScripts' 'Get-ScopeOfTemplateFile.ps1') } process { @@ -74,29 +77,7 @@ function Test-TemplateWithParameterFile { } $ValidationErrors = $null - ################################ - ## Determine deployment scope ## - ################################ - if ((Split-Path $templateFilePath -Extension) -eq '.bicep') { - # Bicep - $bicepContent = Get-Content $templateFilePath - $bicepScope = $bicepContent | Where-Object { $_ -like '*targetscope =*' } - if (-not $bicepScope) { - $deploymentScope = 'resourceGroup' - } else { - $deploymentScope = $bicepScope.ToLower().Replace('targetscope = ', '').Replace("'", '').Trim() - } - } else { - # ARM - $armSchema = (ConvertFrom-Json (Get-Content -Raw -Path $templateFilePath)).'$schema' - switch -regex ($armSchema) { - '\/deploymentTemplate.json#$' { $deploymentScope = 'resourceGroup' } - '\/subscriptionDeploymentTemplate.json#$' { $deploymentScope = 'subscription' } - '\/managementGroupDeploymentTemplate.json#$' { $deploymentScope = 'managementGroup' } - '\/tenantDeploymentTemplate.json#$' { $deploymentScope = 'tenant' } - Default { throw "[$armSchema] is a non-supported ARM template schema" } - } - } + $deploymentScope = Get-ScopeOfTemplateFile -TemplateFilePath $templateFilePath ####################### ## INVOKE DEPLOYMENT ## diff --git a/utilities/pipelines/sharedScripts/Get-ScopeOfTemplateFile.ps1 b/utilities/pipelines/sharedScripts/Get-ScopeOfTemplateFile.ps1 new file mode 100644 index 0000000000..d2b13414cb --- /dev/null +++ b/utilities/pipelines/sharedScripts/Get-ScopeOfTemplateFile.ps1 @@ -0,0 +1,53 @@ +<# +.SYNOPSIS +Get the scope of the given template file + +.DESCRIPTION +Get the scope of the given template file (supports ARM & Bicep) +Will return either +- resourcegroup +- subscription +- managementgroup +- tenant + +.PARAMETER TemplateFilePath +Mandatory. The path of the template file + +.EXAMPLE +Get-ScopeOfTemplateFile -TemplateFilePath 'C:/deploy.json' + +Get the scope of the given deploy.json template. +#> +function Get-ScopeOfTemplateFile { + + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [Alias('Path')] + [string] $TemplateFilePath + ) + + if ((Split-Path $templateFilePath -Extension) -eq '.bicep') { + # Bicep + $bicepContent = Get-Content $templateFilePath + $bicepScope = $bicepContent | Where-Object { $_ -like '*targetscope =*' } + if (-not $bicepScope) { + $deploymentScope = 'resourcegroup' + } else { + $deploymentScope = $bicepScope.ToLower().Split('=')[-1].Replace("'", '').Trim() + } + } else { + # ARM + $armSchema = (ConvertFrom-Json (Get-Content -Raw -Path $templateFilePath)).'$schema' + switch -regex ($armSchema) { + '\/deploymentTemplate.json#$' { $deploymentScope = 'resourcegroup' } + '\/subscriptionDeploymentTemplate.json#$' { $deploymentScope = 'subscription' } + '\/managementGroupDeploymentTemplate.json#$' { $deploymentScope = 'managementgroup' } + '\/tenantDeploymentTemplate.json#$' { $deploymentScope = 'tenant' } + Default { throw "[$armSchema] is a non-supported ARM template schema" } + } + } + Write-Verbose "Determined deployment scope [$deploymentScope]" -Verbose + + return $deploymentScope +} diff --git a/utilities/pipelines/sharedScripts/Set-EnvironmentOnAgent.ps1 b/utilities/pipelines/sharedScripts/Set-EnvironmentOnAgent.ps1 index 590d5181a8..72149ff01a 100644 --- a/utilities/pipelines/sharedScripts/Set-EnvironmentOnAgent.ps1 +++ b/utilities/pipelines/sharedScripts/Set-EnvironmentOnAgent.ps1 @@ -102,6 +102,9 @@ function Set-EnvironmentOnAgent { @{ Name = 'Az.Network' }, @{ Name = 'Az.ContainerRegistry' }, @{ Name = 'Az.KeyVault' }, + @{ Name = 'Az.RecoveryServices' }, + @{ Name = 'Az.Monitor' }, + @{ Name = 'Az.CognitiveServices' }, @{ Name = 'Pester'; Version = '5.3.0' } ) )