testscripts/cleanup.ps1 (357 lines of code) (raw):

<# .SYNOPSIS Cleans up the Azure resources for the Modern Web App pattern for a given azd environment. .DESCRIPTION There are times that azd down doesn't work well. At time of writing, this includes complex environments with multiple resource groups and networking. To remedy this, this script removes the Azure resources in the correct order. If you do not provide any parameters, this script will clean up the most current azd environment. .PARAMETER Prefix The prefix of the Azure environment to clean up. Provide this OR the ResourceGroup parameter to clean up a specific environment. .PARAMETER ResourceGroup The name of the application resource group to clean up. Provide this OR the Prefix parameter to clean up a specific environment. .PARAMETER SpokeResourceGroup If you provide the ResourceGroup parameter and are using network isolation, then you must also provide the SpokeResourceGroup if it is a different resource group. If you don't, then the spoke network will not be cleaned up. .PARAMETER HubResourceGroup If you provide the ResourceGroup parameter and have deployed a hub network, then you must also provide the HubResourceGroup if it is a different resource group. If you don't, then the hub network will not be cleaned up. .PARAMETER DeleteGroups Defaults to true, but if you set this to false, then the resource groups will not be deleted. This is expected behavior when combined with the `azd down` command which will take responsibility for deleting the resource groups. .NOTES This command requires that Az modules are installed and imported. It also requires that you have an active Azure session. If you are not authenticated with Azure, you will be prompted to authenticate. #> Param( [Parameter(Mandatory = $false)][string]$Prefix, [Parameter(Mandatory = $false)][string]$ResourceGroup, [Parameter(Mandatory = $false)][string]$SecondaryResourceGroup, [Parameter(Mandatory = $false)][string]$SpokeResourceGroup, [Parameter(Mandatory = $false)][string]$SecondarySpokeResourceGroup, [Parameter(Mandatory = $false)][string]$HubResourceGroup, [Parameter(Mandatory = $false)][switch]$SkipResourceGroupDeletion, [Parameter(Mandatory = $false)][switch]$Purge, [Parameter(Mandatory = $false)][switch]$NoPrompt ) if ((Get-Module -ListAvailable -Name Az) -and (Get-Module -Name Az.Resources -ErrorAction SilentlyContinue)) { Write-Debug "The 'Az.Resources' module is installed and imported." if (Get-AzContext -ErrorAction SilentlyContinue) { Write-Debug "The user is authenticated with Azure." } else { Write-Error "You are not authenticated with Azure. Please run 'Connect-AzAccount' to authenticate before running this script." exit 10 } } else { try { Write-Host "Importing 'Az.Resources' module" Import-Module -Name Az.Resources -ErrorAction Stop Write-Debug "The 'Az.Resources' module is imported successfully." if (Get-AzContext -ErrorAction SilentlyContinue) { Write-Debug "The user is authenticated with Azure." } else { Write-Error "You are not authenticated with Azure. Please run 'Connect-AzAccount' to authenticate before running this script." exit 11 } } catch { Write-Error "Failed to import the 'Az' module. Please install and import the 'Az' module before running this script." exit 12 } } function Test-ResourceGroupExists($resourceGroupName) { $resourceGroup = Get-AzResourceGroup -Name $resourceGroupName -ErrorAction SilentlyContinue return $null -ne $resourceGroup } # Default Settings $rgPrefix = "" $rgApplication = "" $rgSpoke = "" $rgHub = "" $rgSecondaryApplication = "" $rgSecondarySpoke = "" $rgContainerAppEnvironment = "" $rgSecondaryContainerAppEnvironment = "" #$CleanupAzureDirectory = $false $azdConfig = azd env get-values -o json | ConvertFrom-Json -Depth 9 -AsHashtable if ($Prefix) { $rgPrefix = $Prefix $rgApplication = "$rgPrefix-application" $rgSpoke = "$rgPrefix-spoke" $rgSecondaryApplication = "$rgPrefix-2-application" $rgSecondarySpoke = "$rgPrefix-2-spoke" $rgHub = "$rgPrefix-hub" } else { if (!$ResourceGroup) { if (!(Test-Path -Path ./.azure -PathType Container)) { "No .azure directory found and no resource group information provided - cannot clean up" exit 8 } $environmentName = $azdConfig['AZURE_ENV_NAME'] $environmentType = $azdConfig['AZURE_ENV_TYPE'] ?? 'dev' $location = $azdConfig['AZURE_LOCATION'] $locationSecondary = $azdConfig['AZURE_LOCATION'] ?? $azdConfig['AZURE_LOCATION'] $rgPrefix = "rg-$environmentName-$environmentType" $rgApplication = "$rgPrefix-$location-application" $rgSpoke = "$rgPrefix-$location-spoke" $rgSecondaryApplication = "$rgPrefix-$locationSecondary-2-application" Write-Host "Secondary Application Resource Group: $rgSecondaryApplication" $rgSecondarySpoke = "$rgPrefix-$locationSecondary-2-spoke" Write-Host "Secondary Spoke Resource Group: $rgSecondarySpoke" $rgHub = "$rgPrefix-hub" #$CleanupAzureDirectory = $true } else { $rgApplication = $ResourceGroup if (Test-ResourceGroupExists -ResourceGroupName $rgApplication) { # Tags on the group describe the environment $rgResource = Get-AzResourceGroup -Name $rgApplication -ErrorAction SilentlyContinue $rgPrefix = $ResourceGroup.Substring(0, $ResourceGroup.IndexOf('-application') - $rgResource.Location.Length - 1) $location = $rgResource.Location $locationSecondary = $rgResource.Tags['SecondaryLocation'] ?? $rgResource.Location } } } if ($SecondaryResourceGroup) { $rgSecondaryApplication = $SecondaryResourceGroup } elseif ($rgSecondaryApplication -eq '') { $rgSecondaryApplication = "$rgPrefix-$locationSecondary-2-application" } if ($SpokeResourceGroup) { $rgSpoke = $SpokeResourceGroup } elseif ($rgSpoke -eq '') { $rgSpoke = "$rgPrefix-$location-spoke" } if ($SecondarySpokeResourceGroup) { $rgSecondarySpoke = $SecondarySpokeResourceGroup } elseif ($rgSecondarySpoke -eq '') { $rgSecondarySpoke = "$rgPrefix-$locationSecondary-2-spoke" } if ($HubResourceGroup) { $rgHub = $HubResourceGroup } elseif ($rgHub -eq '') { $rgHub = "$rgPrefix-$location-hub" } # Gets an access token for accessing Azure Resource Manager APIs function Get-AzAccessToken { $azContext = Get-AzContext $azProfile = [Microsoft.Azure.Commands.Common.Authentication.Abstractions.AzureRmProfileProvider]::Instance.Profile $profileClient = New-Object -TypeName Microsoft.Azure.Commands.ResourceManager.Common.RMProfileClient -ArgumentList ($azProfile) $token = $profileClient.AcquireAccessToken($azContext.Subscription.TenantId) return $token } # Get-AzConsumptionBudget doesn't seem to return the list of budgets, # so we use the REST API instead. function Get-AzBudget($resourceGroupName) { $azContext = Get-AzContext $token = Get-AzAccessToken $authHeader = @{ 'Content-Type'='application/json' 'Authorization'='Bearer ' + $token.AccessToken } $baseUri = "https://management.azure.com/subscriptions/$($azContext.Subscription)/resourceGroups/$($resourceGroupName)/providers/Microsoft.Consumption/budgets" $apiVersion = "?api-version=2023-05-01" $restUri = "$($baseUri)$($apiVersion)" $result = Invoke-RestMethod -Uri $restUri -Method GET -Header $authHeader return $result.value } function Remove-ConsumptionBudgetForResourceGroup($resourceGroupName) { Get-AzBudget -ResourceGroupName $resourceGroupName | Foreach-Object { "`tRemoving $resourceGroupName::$($_.name)" | Write-Output Remove-AzConsumptionBudget -Name $_.name -ResourceGroupName $resourceGroupName } } function Remove-DiagnosticSettingsForResourceGroup($resourceGroupName) { Get-AzResource -ResourceGroupName $resourceGroupName | Foreach-Object { $resourceName = $_.Name $resourceId = $_.ResourceId Get-AzDiagnosticSetting -ResourceId $resourceId -ErrorAction SilentlyContinue | Foreach-Object { "`tRemoving $resourceGroupName::$resourceName::$($_.Name)" | Write-Output Remove-AzDiagnosticSetting -ResourceId $resourceId -Name $_.Name } } } function Remove-PrivateEndpointsForResourceGroup($resourceGroupName) { Get-AzPrivateEndpoint -ResourceGroupName $resourceGroupName | Foreach-Object { "`tRemoving $resourceGroupName::$($_.Name)" | Write-Output Remove-AzPrivateEndpoint -Name $_.Name -ResourceGroupName $_.ResourceGroupName -Force } } function Remove-ResourceGroupFromAzure($resourceGroupName) { if (Test-ResourceGroupExists -ResourceGroupName $resourceGroupName) { "`tRemoving $resourceGroupName" | Write-Output Remove-AzResourceGroup -Name $resourceGroupName -Force } } function Test-EntraAppRegistrationExists($name) { $appRegistration = Get-AzADApplication -DisplayName $name -ErrorAction SilentlyContinue return $null -ne $appRegistration } function Remove-AzADApplicationByName($name) { $appRegistration = Get-AzADApplication -DisplayName $name -ErrorAction SilentlyContinue if ($appRegistration) { "`tRemoving $name" | Write-Output Remove-AzADApplication -ObjectId $appRegistration.Id } } function Get-ResourceToken($resourceGroupName) { $defaultRedisNamePrefix = 'redis-' $redisInstances = Get-AzRedisCache -ResourceGroupName $resourceGroupName -ErrorAction SilentlyContinue if ($redisInstances.Count -eq 0) { return "notfound" } return ($redisInstances | Select-Object -First 1).Name.Substring($defaultRedisNamePrefix.Length) } <# .SYNOPSIS Reads input from the user, but taking care of default value and request to not prompt the user. .PARAMETER Prompt The prompt to display to the user. .PARAMETER DefaultValue The default value to use if the user just hits Enter. .PARAMETER NoPrompt If specified, don't prompt - just use the default value. #> function Read-ApplicationPrompt { param( [Parameter(Mandatory = $true)] [string] $Prompt, [Parameter(Mandatory = $true)] [string] $DefaultValue, [Parameter(Mandatory = $false)] [switch] $NoPrompt = $false ) $returnValue = "" if (-not $NoPrompt) { $returnValue = Read-Host -Prompt "`n$($Prompt) [default: $(Get-HighlightedText($DefaultValue))] " } if ([string]::IsNullOrWhiteSpace($returnValue)) { $returnValue = $DefaultValue } return $returnValue } "`nCleaning up environment for application '$rgApplication'" | Write-Output # Get the list of resource groups to deal with $resourceGroups = [System.Collections.ArrayList]@() if (Test-ResourceGroupExists -ResourceGroupName $rgApplication) { "`tFound application resource group: $rgApplication" | Write-Output $resourceGroups.Add($rgApplication) | Out-Null $resourceToken=(Get-ResourceToken -resourceGroupName $rgApplication) # expecting to be something like 'fjmjdbizcdxt4' $possibleContainerAppEnvironmentGroup = "ME_acae-common-$resourceToken" if (Test-ResourceGroupExists -ResourceGroupName $possibleContainerAppEnvironmentGroup) { "`tFound container app environment resource group: $possibleContainerAppEnvironmentGroup" | Write-Output $resourceGroups.Add($possibleContainerAppEnvironmentGroup) | Out-Null } } else { "`tConfirm the correct subscription was selected and check the spelling of the group to be deleted" | Write-Warning "`tCould not find resource group: $rgApplication" | Write-Error exit 9 } if (Test-ResourceGroupExists -ResourceGroupName $rgSecondaryApplication) { "`tFound secondary application resource group: $rgSecondaryApplication" | Write-Output $resourceGroups.Add($rgSecondaryApplication) | Out-Null $resourceToken=(Get-ResourceToken -resourceGroupName $rgSecondaryApplication) # expecting to be something like 'fjmjdbizcdxt4' $possibleContainerAppEnvironmentGroup = "ME_acae-common-$resourceToken" if (Test-ResourceGroupExists -ResourceGroupName $possibleContainerAppEnvironmentGroup) { "`tFound secondary container app environment resource group: $possibleContainerAppEnvironmentGroup" | Write-Output $resourceGroups.Add($possibleContainerAppEnvironmentGroup) | Out-Null } } if (Test-ResourceGroupExists -ResourceGroupName $rgSpoke) { "`tFound spoke resource group: $rgSpoke" | Write-Output $resourceGroups.Add($rgSpoke) | Out-Null } if (Test-ResourceGroupExists -ResourceGroupName $rgSecondarySpoke) { "`tFound secondary spoke resource group: $rgSecondarySpoke" | Write-Output $resourceGroups.Add($rgSecondarySpoke) | Out-Null } if (Test-ResourceGroupExists -ResourceGroupName $rgHub) { "`tFound hub resource group: $rgHub" | Write-Output $resourceGroups.Add($rgHub) | Out-Null } $resourceToken=(Get-ResourceToken -resourceGroupName $rgApplication) # expecting to be something like 'fjmjdbizcdxt4' $appRegistrations = [System.Collections.ArrayList]@() $calculatedAppRegistrationNameForApi = "$rgPrefix-api-webapp-$resourceToken".Substring(3) $calculatedAppRegistrationNameForFrontend = "$rgPrefix-front-webapp-$resourceToken".Substring(3) if (Test-EntraAppRegistrationExists -Name $calculatedAppRegistrationNameForApi) { "`tFound Entra ID App Registration: $calculatedAppRegistrationNameForApi" | Write-Output $appRegistrations.Add($calculatedAppRegistrationNameForApi) | Out-Null } if (Test-EntraAppRegistrationExists -Name $calculatedAppRegistrationNameForFrontend) { "`tFound Entra ID App Registration: $calculatedAppRegistrationNameForFrontend" | Write-Output $appRegistrations.Add($calculatedAppRegistrationNameForFrontend) | Out-Null } # Determine if we need to purge the App Configuration and Key Vault. $defaultPurgeResources = if ($Purge) { "y" } else { "n" } $purgeResources = Read-ApplicationPrompt -Prompt "Do you wish to puge resources that cannot be reassigned immediately (such as Key Vault)? [y/n]" -DefaultValue $defaultPurgeResources -NoPrompt:$NoPrompt # press enter to proceed if (-not $NoPrompt) { "`nPress enter to proceed with cleanup or CTRL+C to cancel" | Write-Output $null = Read-Host } # we don't want to delete the app registrations because we reuse them when running in pipeline # when running in pipeline, the AZURE_PRINCIPAL_TYPE is set to 'ServicePrincipal' if ($azdConfig['AZURE_PRINCIPAL_TYPE'] -eq 'User') { "`nRemoving Entra ID App Registration..." | Write-Output foreach($appRegistration in $appRegistrations) { Remove-AzADApplicationByName -Name $appRegistration } } if ($purgeResources -eq "y") { "> Remove and purge purgeable resources:" | Write-Output foreach ($resourceGroupName in $resourceGroups) { Get-AzKeyVault -ResourceGroupName $resourceGroupName | Foreach-Object { "`tRemoving $($_.VaultName)" | Write-Output Remove-AzKeyVault -VaultName $_.VaultName -ResourceGroupName $resourceGroupName -Force "`tPurging $($_.VaultName)" | Write-Output Remove-AzKeyVault -VaultName $_.VaultName -Location $_.Location -InRemovedState -Force -ErrorAction SilentlyContinue } Get-AzAppConfigurationStore -ResourceGroupName $resourceGroupName | Foreach-Object { "`tRemoving $($_.Name)" | Write-Output Remove-AzAppConfigurationStore -Name $_.Name -ResourceGroupName $resourceGroupName "`tPurging $($_.Name)" | Write-Output Clear-AzAppConfigurationDeletedStore -Location $_.Location -Name $_.Name -ErrorAction SilentlyContinue } } } "`nRemoving resources from resource groups..." | Write-Output "> Private Endpoints:" | Write-Output foreach ($resourceGroupName in $resourceGroups) { Remove-PrivateEndpointsForResourceGroup -ResourceGroupName $resourceGroupName } "> Budgets:" | Write-Output foreach ($resourceGroupName in $resourceGroups) { Remove-ConsumptionBudgetForResourceGroup -ResourceGroupName $resourceGroupName } "> Diagnostic Settings:" | Write-Output foreach ($resourceGroupName in $resourceGroups) { Remove-DiagnosticSettingsForResourceGroup -ResourceGroupName $resourceGroupName } if ($azdConfig['ENVIRONMENT'] ?? 'dev' -eq "dev") { # when performing dev cleanup there are no dependencies between resource groups # existing at this point allows AZD to handle the tear down responsibilities "`nCleanup complete." | Write-Output exit 0 } # if $SkipResourceGroupDeletion is false, then we skip the resource group deletion # flag is expected to be set to false when combined with the `azd down` command if (-not $SkipResourceGroupDeletion) { "`nRemoving resource groups in order..." | Write-Output Remove-ResourceGroupFromAzure -ResourceGroupName $rgApplication Remove-ResourceGroupFromAzure -ResourceGroupName $rgSecondaryApplication Remove-ResourceGroupFromAzure -ResourceGroupName $rgSpoke Remove-ResourceGroupFromAzure -ResourceGroupName $rgSecondarySpoke Remove-ResourceGroupFromAzure -ResourceGroupName $rgHub "`nCleanup complete." | Write-Output }