eng/common/scripts/Helpers/Resource-Helpers.ps1 (359 lines of code) (raw):

# Add 'AzsdkResourceType' member to outputs since actual output types have changed over the years. #Requires -Modules @{ModuleName='Az.KeyVault'; ModuleVersion='3.4.1'} function Get-PurgeableGroupResources { param ( [Parameter(Mandatory = $true, Position = 0)] [string] $ResourceGroupName ) $purgeableResources = @() # Discover Managed HSMs first since they are a premium resource. Write-Verbose "Retrieving deleted Managed HSMs from resource group $ResourceGroupName" # Get any Managed HSMs in the resource group, for which soft delete cannot be disabled. $deletedHsms = @(Get-AzKeyVaultManagedHsm -ResourceGroupName $ResourceGroupName -ErrorAction Ignore ` | Add-Member -MemberType NoteProperty -Name AzsdkResourceType -Value 'Managed HSM' -PassThru ` | Add-Member -MemberType AliasProperty -Name AzsdkName -Value VaultName -PassThru) if ($deletedHsms) { Write-Verbose "Found $($deletedHsms.Count) deleted Managed HSMs to potentially purge." $purgeableResources += $deletedHsms } Write-Verbose "Retrieving deleted Key Vaults from resource group $ResourceGroupName" # Get any Key Vaults that will be deleted so they can be purged later if soft delete is enabled. $deletedKeyVaults = @(Get-AzKeyVault -ResourceGroupName $ResourceGroupName -ErrorAction Ignore | ForEach-Object { # Enumerating vaults from a resource group does not return all properties we required. Get-AzKeyVault -VaultName $_.VaultName -ErrorAction Ignore | Where-Object { $_.EnableSoftDelete } ` | Add-Member -MemberType NoteProperty -Name AzsdkResourceType -Value 'Key Vault' -PassThru ` | Add-Member -MemberType AliasProperty -Name AzsdkName -Value VaultName -PassThru }) if ($deletedKeyVaults) { Write-Verbose "Found $($deletedKeyVaults.Count) deleted Key Vaults to potentially purge." $purgeableResources += $deletedKeyVaults } return $purgeableResources } function Get-PurgeableResources { $purgeableResources = @() $subscriptionId = (Get-AzContext).Subscription.Id # Discover Managed HSMs first since they are a premium resource. Write-Verbose "Retrieving deleted Managed HSMs from subscription $subscriptionId" # Get deleted Managed HSMs for the current subscription. $response = Invoke-AzRestMethod -Method GET -Path "/subscriptions/$subscriptionId/providers/Microsoft.KeyVault/deletedManagedHSMs?api-version=2023-02-01" -ErrorAction Ignore if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 300 -and $response.Content) { $content = $response.Content | ConvertFrom-Json $deletedHsms = @() foreach ($r in $content.value) { $deletedHsms += [pscustomobject] @{ AzsdkResourceType = 'Managed HSM' AzsdkName = $r.name Id = $r.id Name = $r.name Location = $r.properties.location DeletionDate = $r.properties.deletionDate -as [DateTime] ScheduledPurgeDate = $r.properties.scheduledPurgeDate -as [DateTime] EnablePurgeProtection = $r.properties.purgeProtectionEnabled } } if ($deletedHsms) { Write-Verbose "Found $($deletedHsms.Count) deleted Managed HSMs to potentially purge." $purgeableResources += $deletedHsms } } Write-Verbose "Retrieving deleted Key Vaults from subscription $subscriptionId" # TODO: Remove try/catch handler for Get-AzKeyVault - https://github.com/Azure/azure-sdk-tools/issues/5315 # This is a temporary workaround since Az module >= 9.2.0 uses a more recent API # version than is supported in the dogfood cloud environment: # # | The resource type 'deletedVaults' could not be found in the namespace 'Microsoft.KeyVault' for api version '2022-07-01'. The supported api-versions are # | '2016-10-01,2018-02-14-preview,2018-02-14,2019-09-01,2021-04-01-preview,2021-06-01-preview,2021-10-01,2021-11-01-preview'. try { # Get deleted Key Vaults for the current subscription. $deletedKeyVaults = @(Get-AzKeyVault -InRemovedState ` | Add-Member -MemberType NoteProperty -Name AzsdkResourceType -Value 'Key Vault' -PassThru ` | Add-Member -MemberType AliasProperty -Name AzsdkName -Value VaultName -PassThru) if ($deletedKeyVaults) { Write-Verbose "Found $($deletedKeyVaults.Count) deleted Key Vaults to potentially purge." $purgeableResources += $deletedKeyVaults } } catch { } return $purgeableResources } # A filter differs from a function by teating body as -process {} instead of -end {}. # This allows you to pipe a collection and process each item in the collection. filter Remove-PurgeableResources { param ( [Parameter(Position = 0, ValueFromPipeline = $true)] [object[]] $Resource, [Parameter()] [ValidateRange(1, [int]::MaxValue)] [int] $Timeout = 30, [Parameter()] [switch] $PassThru ) if (!$Resource) { return } $subscriptionId = (Get-AzContext).Subscription.Id foreach ($r in $Resource) { Log "Attempting to purge $($r.AzsdkResourceType) '$($r.AzsdkName)'" switch ($r.AzsdkResourceType) { 'Key Vault' { if ($r.EnablePurgeProtection) { # We will try anyway but will ignore errors. Write-Warning "Key Vault '$($r.VaultName)' has purge protection enabled and may not be purged until $($r.ScheduledPurgeDate)" } # Use `-AsJob` to start a lightweight, cancellable job and pass to `Wait-PurgeableResoruceJob` for consistent behavior. Remove-AzKeyVault -VaultName $r.VaultName -Location $r.Location -InRemovedState -Force -ErrorAction Continue -AsJob ` | Wait-PurgeableResourceJob -Resource $r -Timeout $Timeout -PassThru:$PassThru } 'Managed HSM' { if ($r.EnablePurgeProtection) { # We will try anyway but will ignore errors. Write-Warning "Managed HSM '$($r.Name)' has purge protection enabled and may not be purged until $($r.ScheduledPurgeDate)" } # Use `GetNewClosure()` on the `-Action` ScriptBlock to make sure variables are captured. Invoke-AzRestMethod -Method POST -Path "/subscriptions/$subscriptionId/providers/Microsoft.KeyVault/locations/$($r.Location)/deletedManagedHSMs/$($r.Name)/purge?api-version=2023-02-01" -ErrorAction Ignore -AsJob ` | Wait-PurgeableResourceJob -Resource $r -Timeout $Timeout -PassThru:$PassThru -Action { param ( $response ) if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 300) { Write-Warning "Successfully requested that Managed HSM '$($r.Name)' be purged, but may take a few minutes before it is actually purged." } elseif ($response.Content) { $content = $response.Content | ConvertFrom-Json if ($content.error) { $err = $content.error Write-Warning "Failed to deleted Managed HSM '$($r.Name)': ($($err.code)) $($err.message)" } } }.GetNewClosure() } default { Write-Warning "Cannot purge $($r.AzsdkResourceType) '$($r.AzsdkName)'. Add support to https://github.com/Azure/azure-sdk-tools/blob/main/eng/common/scripts/Helpers/Resource-Helpers.ps1." } } } } # The Log function can be overridden by the sourcing script. function Log($Message) { Write-Host ('{0} - {1}' -f [DateTime]::Now.ToLongTimeString(), $Message) } function Wait-PurgeableResourceJob { param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] $Job, # The resource is used for logging and to return if `-PassThru` is specified # so we can easily see all resources that may be in a bad state when the script has completed. [Parameter(Mandatory = $true)] $Resource, # Optional ScriptBlock should define params corresponding to the associated job's `Output` property. [Parameter()] [scriptblock] $Action, [Parameter()] [ValidateRange(1, [int]::MaxValue)] [int] $Timeout = 30, [Parameter()] [switch] $PassThru ) $null = Wait-Job -Job $Job -Timeout $Timeout if ($Job.State -eq 'Completed' -or $Job.State -eq 'Failed') { $result = Receive-Job -Job $Job -ErrorAction Continue if ($Action) { $null = $Action.Invoke($result) } } else { Write-Warning "Timed out waiting to purge $($Resource.AzsdkResourceType) '$($Resource.AzsdkName)'. Cancelling job." $Job.Cancel() if ($PassThru) { $Resource } } } # Helper function for removing storage accounts with WORM that sometimes get leaked from live tests not set up to clean # up their resource policies function Remove-WormStorageAccounts() { [CmdletBinding(SupportsShouldProcess = $True)] param( [string]$GroupPrefix, [switch]$CI ) $ErrorActionPreference = 'Stop' # Be a little defensive so we don't delete non-live test groups via naming convention # DO NOT REMOVE THIS # We call this script from live test pipelines as well, and a string mismatch/error could blow away # some static storage accounts we rely on if (!$groupPrefix -or ($CI -and !$GroupPrefix.StartsWith('rg-'))) { throw "The -GroupPrefix parameter must not be empty, or must start with 'rg-' in CI contexts" } $groups = Get-AzResourceGroup | Where-Object { $_.ResourceGroupName.StartsWith($GroupPrefix) } | Where-Object { $_.ProvisioningState -ne 'Deleting' } foreach ($group in $groups) { Write-Host "=========================================" $accounts = Get-AzStorageAccount -ResourceGroupName $group.ResourceGroupName if ($accounts) { foreach ($account in $accounts) { if ($WhatIfPreference) { Write-Host "What if: Removing $($account.StorageAccountName) in $($account.ResourceGroupName)" } else { Write-Host "Removing $($account.StorageAccountName) in $($account.ResourceGroupName)" } $hasContainers = ($account.Kind -ne "FileStorage") # If it doesn't have containers then we can skip the explicit clean-up of this storage account if (!$hasContainers) { continue } $ctx = New-AzStorageContext -StorageAccountName $account.StorageAccountName $containers = $ctx | Get-AzStorageContainer $blobs = $containers | Get-AzStorageBlob $immutableBlobs = $containers ` | Where-Object { $_.BlobContainerProperties.HasImmutableStorageWithVersioning } ` | Get-AzStorageBlob try { foreach ($blob in $immutableBlobs) { # We can't edit blobs with customer encryption without using that key # so just try to delete them fully instead. It is unlikely they # will also have a legal hold enabled. if (($blob | Get-Member 'ListBlobProperties') ` -and $blob.ListBlobProperties.Properties.CustomerProvidedKeySha256) { Write-Host "Removing customer encrypted blob: $($blob.Name), account: $($account.StorageAccountName), group: $($group.ResourceGroupName)" $blob | Remove-AzStorageBlob -Force continue } if (!($blob | Get-Member 'BlobProperties')) { continue } if ($blob.BlobProperties.LeaseState -eq 'Leased') { Write-Host "Breaking blob lease: $($blob.Name), account: $($account.StorageAccountName), group: $($group.ResourceGroupName)" $blob.ICloudBlob.BreakLease() } if ($blob.BlobProperties.HasLegalHold) { Write-Host "Removing legal hold - blob: $($blob.Name), account: $($account.StorageAccountName), group: $($group.ResourceGroupName)" $blob | Set-AzStorageBlobLegalHold -DisableLegalHold | Out-Null } } } catch { Write-Warning "Ensure user has 'Storage Blob Data Owner' RBAC permission on subscription or resource group" Write-Error $_ throw } # Sometimes we get a 404 blob not found but can still delete containers, # and sometimes we must delete the blob if there's a legal hold. # Try to remove the blob, but keep running regardless. $succeeded = $false for ($attempt = 0; $attempt -lt 2; $attempt++) { if ($succeeded) { break } try { foreach ($blob in $blobs) { if ($blob.BlobProperties.ImmutabilityPolicy.PolicyMode) { Write-Host "Removing immutability policy - blob: $($blob.Name), account: $($ctx.StorageAccountName), group: $($group.ResourceGroupName)" $null = $blob | Remove-AzStorageBlobImmutabilityPolicy } } } catch {} try { foreach ($blob in $blobs) { $blob | Remove-AzStorageBlob -Force } $succeeded = $true } catch { Write-Warning "Failed to remove blobs - account: $($ctx.StorageAccountName), group: $($group.ResourceGroupName)" Write-Warning $_ } } try { # Use AzRm cmdlet as deletion will only work through ARM with the immutability policies defined on the blobs $containers | ForEach-Object { Remove-AzRmStorageContainer -Name $_.Name -StorageAccountName $ctx.StorageAccountName -ResourceGroupName $group.ResourceGroupName -Force } } catch { Write-Warning "Container removal failed. Ignoring the error and trying to delete the storage account." Write-Warning $_ } Remove-AzStorageAccount -StorageAccountName $account.StorageAccountName -ResourceGroupName $account.ResourceGroupName -Force } } if ($WhatIfPreference) { Write-Host "What if: Removing resource group $($group.ResourceGroupName)" } else { Remove-AzResourceGroup -ResourceGroupName $group.ResourceGroupName -Force -AsJob } } } function SetResourceNetworkAccessRules([string]$ResourceGroupName, [array]$AllowIpRanges, [switch]$CI, [switch]$SetFirewall) { SetStorageNetworkAccessRules -ResourceGroupName $ResourceGroupName -AllowIpRanges $AllowIpRanges -CI:$CI -SetFirewall:$SetFirewall } function SetStorageNetworkAccessRules([string]$ResourceGroupName, [array]$AllowIpRanges, [switch]$CI, [switch]$SetFirewall) { $clientIp = $null $storageAccounts = Retry { Get-AzResource -ResourceGroupName $ResourceGroupName -ResourceType "Microsoft.Storage/storageAccounts" } # Add client IP to storage account when running as local user. Pipeline's have their own vnet with access if ($storageAccounts) { $appliedRule = $false foreach ($account in $storageAccounts) { $properties = Get-AzStorageAccount -ResourceGroupName $ResourceGroupName -AccountName $account.Name $rules = Get-AzStorageAccountNetworkRuleSet -ResourceGroupName $ResourceGroupName -AccountName $account.Name if ($properties.AllowBlobPublicAccess) { Write-Host "Restricting public blob access in storage account '$($account.Name)'" Set-AzStorageAccount -ResourceGroupName $ResourceGroupName -StorageAccountName $account.Name -AllowBlobPublicAccess $false } # In override mode, we only want to capture storage accounts that have had incomplete network rules applied, # otherwise it's not worth updating due to timing and throttling issues. # If the network rules are deny only without any vnet/ip allowances, then we can't ever purge the storage account # when immutable blobs need to be removed. if (!$rules -or !$SetFirewall -or $rules.DefaultAction -eq "Allow") { return } # Add firewall rules in cases where existing rules added were incomplete to enable blob removal Write-Host "Restricting network rules in storage account '$($account.Name)' to deny access by default" Retry { Update-AzStorageAccountNetworkRuleSet -ResourceGroupName $ResourceGroupName -Name $account.Name -DefaultAction Deny } if ($CI -and $env:PoolSubnet) { Write-Host "Enabling access to '$($account.Name)' from pipeline subnet $($env:PoolSubnet)" Retry { Add-AzStorageAccountNetworkRule -ResourceGroupName $ResourceGroupName -Name $account.Name -VirtualNetworkResourceId $env:PoolSubnet } $appliedRule = $true } elseif ($AllowIpRanges) { Write-Host "Enabling access to '$($account.Name)' to $($AllowIpRanges.Length) IP ranges" $ipRanges = $AllowIpRanges | ForEach-Object { @{ Action = 'allow'; IPAddressOrRange = $_ } } Retry { Update-AzStorageAccountNetworkRuleSet -ResourceGroupName $ResourceGroupName -Name $account.Name -IPRule $ipRanges | Out-Null } $appliedRule = $true } elseif (!$CI) { Write-Host "Enabling access to '$($account.Name)' from client IP" $clientIp ??= Retry { Invoke-RestMethod -Uri 'https://icanhazip.com/' } # cloudflare owned ip site $clientIp = $clientIp.Trim() $ipRanges = Get-AzStorageAccountNetworkRuleSet -ResourceGroupName $ResourceGroupName -Name $account.Name if ($ipRanges) { foreach ($range in $ipRanges.IpRules) { if (DoesSubnetOverlap $range.IPAddressOrRange $clientIp) { return } } } Retry { Add-AzStorageAccountNetworkRule -ResourceGroupName $ResourceGroupName -Name $account.Name -IPAddressOrRange $clientIp | Out-Null } $appliedRule = $true } } if ($appliedRule) { Write-Host "Sleeping for 15 seconds to allow network rules to take effect" Start-Sleep 15 } } } function DoesSubnetOverlap([string]$ipOrCidr, [string]$overlapIp) { [System.Net.IPAddress]$overlapIpAddress = $overlapIp $parsed = $ipOrCidr -split '/' [System.Net.IPAddress]$baseIp = $parsed[0] if ($parsed.Length -eq 1) { return $baseIp -eq $overlapIpAddress } $subnet = $parsed[1] $subnetNum = [int]$subnet $baseMask = [math]::pow(2, 31) $mask = 0 for ($i = 0; $i -lt $subnetNum; $i++) { $mask = $mask + $baseMask; $baseMask = $baseMask / 2 } return $baseIp.Address -eq ($overlapIpAddress.Address -band ([System.Net.IPAddress]$mask).Address) }