diff --git a/doc/engsys_resource_management.md b/doc/engsys_resource_management.md index 235c5c2fb11..68a4efe7914 100644 --- a/doc/engsys_resource_management.md +++ b/doc/engsys_resource_management.md @@ -13,6 +13,8 @@ improve our overall security risks and manage costs. ## Managing Development and Testing Resources +The cleanup script implementing these guidelines can be found [here](https://github.com/Azure/azure-sdk-tools/blob/main/eng/scripts/live-test-resource-cleanup.ps1). + This section applies to resource groups located in any of the dev/test subscriptions managed by the Azure SDK Engineering System team, such as: diff --git a/eng/common/scripts/Helpers/Metadata-Helpers.ps1 b/eng/common/scripts/Helpers/Metadata-Helpers.ps1 index 8891837a9f8..a8daf0d8374 100644 --- a/eng/common/scripts/Helpers/Metadata-Helpers.ps1 +++ b/eng/common/scripts/Helpers/Metadata-Helpers.ps1 @@ -1,11 +1,11 @@ -function Generate-AadToken ($TenantId, $ClientId, $ClientSecret) +function Generate-AadToken ($TenantId, $ClientId, $ClientSecret) { $LoginAPIBaseURI = "https://login.microsoftonline.com/$TenantId/oauth2/token" $headers = @{ "content-type" = "application/x-www-form-urlencoded" } - + $body = @{ "grant_type" = "client_credentials" "client_id" = $ClientId @@ -17,8 +17,9 @@ function Generate-AadToken ($TenantId, $ClientId, $ClientSecret) return $resp.access_token } -function GetMsAliasFromGithub ($TenantId, $ClientId, $ClientSecret, $GithubUser) +function GetMsAliasFromGithub ([string]$TenantId, [string]$ClientId, [string]$ClientSecret, [string]$GithubUser) { + # API documentation (out of date): https://github.com/microsoft/opensource-management-portal/blob/main/docs/api.md $OpensourceAPIBaseURI = "https://repos.opensource.microsoft.com/api/people/links/github/$GithubUser" $Headers = @{ @@ -31,8 +32,7 @@ function GetMsAliasFromGithub ($TenantId, $ClientId, $ClientSecret, $GithubUser) $Headers["Authorization"] = "Bearer $opsAuthToken" Write-Host "Fetching aad identity for github user: $GithubUser" $resp = Invoke-RestMethod $OpensourceAPIBaseURI -Method 'GET' -Headers $Headers -MaximumRetryCount 3 - } - catch { + } catch { Write-Warning $_ return $null } @@ -47,7 +47,30 @@ function GetMsAliasFromGithub ($TenantId, $ClientId, $ClientSecret, $GithubUser) return $null } -function GetPrimaryCodeOwner ($TargetDirectory) +function GetAllGithubUsers ([string]$TenantId, [string]$ClientId, [string]$ClientSecret) +{ + # API documentation (out of date): https://github.com/microsoft/opensource-management-portal/blob/main/docs/api.md + $OpensourceAPIBaseURI = "https://repos.opensource.microsoft.com/api/people/links" + + $Headers = @{ + "Content-Type" = "application/json" + "api-version" = "2019-10-01" + } + + try { + $opsAuthToken = Generate-AadToken -TenantId $TenantId -ClientId $ClientId -ClientSecret $ClientSecret + $Headers["Authorization"] = "Bearer $opsAuthToken" + Write-Host "Fetching all github alias links" + $resp = Invoke-RestMethod $OpensourceAPIBaseURI -Method 'GET' -Headers $Headers -MaximumRetryCount 3 + } catch { + Write-Warning $_ + return $null + } + + return $resp +} + +function GetPrimaryCodeOwner ([string]$TargetDirectory) { $codeOwnerArray = &"$PSScriptRoot/../get-codeowners.ps1" -TargetDirectory $TargetDirectory if ($codeOwnerArray) { diff --git a/eng/common/scripts/Import-AzModules.ps1 b/eng/common/scripts/Import-AzModules.ps1 index 6e97ba97028..4eec04b85e4 100644 --- a/eng/common/scripts/Import-AzModules.ps1 +++ b/eng/common/scripts/Import-AzModules.ps1 @@ -5,4 +5,4 @@ param ( . (Join-Path $PSScriptRoot Helpers PSModule-Helpers.ps1) -Install-ModuleIfNotInstalled "Az" $AzModuleVersion | Import-Module \ No newline at end of file +Install-ModuleIfNotInstalled "Az" $AzModuleVersion | Import-Module diff --git a/eng/common/scripts/Update-DocsMsMetadata.ps1 b/eng/common/scripts/Update-DocsMsMetadata.ps1 index 93893098c5f..540d4da4dd3 100644 --- a/eng/common/scripts/Update-DocsMsMetadata.ps1 +++ b/eng/common/scripts/Update-DocsMsMetadata.ps1 @@ -4,10 +4,10 @@ Updates package README.md for publishing to docs.microsoft.com .DESCRIPTION Given a PackageInfo .json file, format the package README.md file with metadata -and other information needed to release reference docs: +and other information needed to release reference docs: * Adjust README.md content to include metadata -* Insert the package verison number in the README.md title +* Insert the package verison number in the README.md title * Copy file to the appropriate location in the documentation repository * Copy PackageInfo .json file to the metadata location in the reference docs repository. This enables the Docs CI build to onboard packages which have not @@ -18,7 +18,7 @@ List of locations of the artifact information .json file. This is usually stored in build artifacts under packages/PackageInfo/.json. Can also be a single item. -.PARAMETER DocRepoLocation +.PARAMETER DocRepoLocation Location of the root of the docs.microsoft.com reference doc location. Further path information is provided by $GetDocsMsMetadataForPackageFn @@ -47,7 +47,7 @@ param( [array]$PackageInfoJsonLocations, [Parameter(Mandatory = $true)] - [string]$DocRepoLocation, + [string]$DocRepoLocation, [Parameter(Mandatory = $true)] [string]$Language, @@ -104,12 +104,12 @@ function GetAdjustedReadmeContent($ReadmeContent, $PackageInfo, $PackageMetadata $replacementPattern = "`${1}$tag" $ReadmeContent = $ReadmeContent -replace $releaseReplaceRegex, $replacementPattern } - + # Get the first code owners of the package. Write-Host "Retrieve the code owner from $($PackageInfo.DirectoryPath)." - $author = GetPrimaryCodeOwner -TargetDirectory $PackageInfo.DirectoryPath + $author = GetPrimaryCodeOwner -TargetDirectory $PackageInfo.DirectoryPath if (!$author) { - $author = "ramya-rao-a" + $author = "ramya-rao-a" $msauthor = "ramyar" } else { @@ -148,10 +148,10 @@ function GetPackageInfoJson ($packageInfoJsonLocation) { $packageInfoJson = Get-Content $packageInfoJsonLocation -Raw $packageInfo = ConvertFrom-Json $packageInfoJson if ($packageInfo.DevVersion) { - # If the package is of a dev version there may be language-specific needs to - # specify the appropriate version. For example, in the case of JS, the dev + # If the package is of a dev version there may be language-specific needs to + # specify the appropriate version. For example, in the case of JS, the dev # version is always 'dev' when interacting with NPM. - if ($GetDocsMsDevLanguageSpecificPackageInfoFn -and (Test-Path "Function:$GetDocsMsDevLanguageSpecificPackageInfoFn")) { + if ($GetDocsMsDevLanguageSpecificPackageInfoFn -and (Test-Path "Function:$GetDocsMsDevLanguageSpecificPackageInfoFn")) { $packageInfo = &$GetDocsMsDevLanguageSpecificPackageInfoFn $packageInfo } else { # Default: use the dev version from package info as the version for @@ -162,16 +162,16 @@ function GetPackageInfoJson ($packageInfoJsonLocation) { return $packageInfo } -function UpdateDocsMsMetadataForPackage($packageInfoJsonLocation, $packageInfo) { +function UpdateDocsMsMetadataForPackage($packageInfoJsonLocation, $packageInfo) { $originalVersion = [AzureEngSemanticVersion]::ParseVersionString($packageInfo.Version) $packageMetadataArray = (Get-CSVMetadata).Where({ $_.Package -eq $packageInfo.Name -and $_.Hide -ne 'true' -and $_.New -eq 'true' }) if ($packageInfo.Group) { $packageMetadataArray = ($packageMetadataArray).Where({$_.GroupId -eq $packageInfo.Group}) } - if ($packageMetadataArray.Count -eq 0) { + if ($packageMetadataArray.Count -eq 0) { LogWarning "Could not retrieve metadata for $($packageInfo.Name) from metadata CSV. Using best effort defaults." $packageMetadata = $null - } elseif ($packageMetadataArray.Count -gt 1) { + } elseif ($packageMetadataArray.Count -gt 1) { LogWarning "Multiple metadata entries for $($packageInfo.Name) in metadata CSV. Using first entry." $packageMetadata = $packageMetadataArray[0] } else { @@ -199,10 +199,10 @@ function UpdateDocsMsMetadataForPackage($packageInfoJsonLocation, $packageInfo) Write-Warning "$($packageInfo.Name) does not have Readme file. Skipping update readme." return } - + $readmeContent = Get-Content $packageInfo.ReadMePath -Raw - $outputReadmeContent = "" - if ($readmeContent) { + $outputReadmeContent = "" + if ($readmeContent) { $outputReadmeContent = GetAdjustedReadmeContent $readmeContent $packageInfo $packageMetadata } diff --git a/eng/pipelines/live-test-cleanup-template.yml b/eng/pipelines/live-test-cleanup-template.yml new file mode 100644 index 00000000000..4b0da11301b --- /dev/null +++ b/eng/pipelines/live-test-cleanup-template.yml @@ -0,0 +1,36 @@ +parameters: + - name: DisplayName + type: string + - name: SubscriptionConfigurations + type: object + default: + - $(sub-config-azure-cloud-test-resources) + - name: GithubAliasCachePath + type: string + - name: AdditionalParameters + type: string + default: "" + +steps: + - template: /eng/common/TestResources/build-test-resource-config.yml + parameters: + SubscriptionConfigurations: ${{ parameters.SubscriptionConfigurations }} + + - pwsh: | + eng/common/scripts/Import-AzModules.ps1 + Import-Module Az.Accounts + + $subscriptionConfiguration = @' + $(SubscriptionConfiguration) + '@ | ConvertFrom-Json -AsHashtable + + ./eng/scripts/live-test-resource-cleanup.ps1 ` + -OpensourceApiApplicationId $(opensource-aad-app-id) ` + -OpensourceApiApplicationSecret $(opensource-aad-secret) ` + -OpensourceApiApplicationTenant $(opensource-aad-tenant-id) ` + -GithubAliasCachePath ${{ parameters.GithubAliasCachePath }} ` + @subscriptionConfiguration ` + -Verbose ${{ parameters.AdditionalParameters }} + displayName: ${{ parameters.DisplayName }} + continueOnError: true + diff --git a/eng/pipelines/live-test-cleanup.yml b/eng/pipelines/live-test-cleanup.yml index c8dcc12ceb3..ed696b8c510 100644 --- a/eng/pipelines/live-test-cleanup.yml +++ b/eng/pipelines/live-test-cleanup.yml @@ -1,11 +1,63 @@ pr: none trigger: none +parameters: + - name: Subscriptions + type: object + default: + - DisplayName: AzureCloud - Resource Cleanup + SubscriptionConfigurations: + - $(sub-config-azure-cloud-test-resources) + # TODO: Enable strict resource cleanup after pre-existing static groups have been handled + # AdditionalParameters: "-DeleteNonCompliantGroups" + - DisplayName: AzureCloud-Preview - Resource Cleanup + SubscriptionConfigurations: + - $(sub-config-azure-cloud-test-resources-preview) + # TODO: Enable strict resource cleanup after pre-existing static groups have been handled + # AdditionalParameters: "-DeleteNonCompliantGroups" + - DisplayName: AzureUSGovernment - Resource Cleanup + SubscriptionConfigurations: + - $(sub-config-cn-test-resources) + # TODO: Enable strict resource cleanup after pre-existing static groups have been handled + # AdditionalParameters: "-DeleteNonCompliantGroups" + - DisplayName: AzureChinaCloud - Resource Cleanup + SubscriptionConfigurations: + - $(sub-config-cn-test-resources) + # TODO: Enable strict resource cleanup after pre-existing static groups have been handled + # AdditionalParameters: "-DeleteNonCompliantGroups" + - DisplayName: AzureCloud Playground - Resource Cleanup + SubscriptionConfigurations: + - $(sub-config-azure-cloud-playground) + # TODO: Enable strict resource cleanup after pre-existing static groups have been handled + # AdditionalParameters: "-DeleteNonCompliantGroups" + - DisplayName: Dogfood Translation - Resource Cleanup + SubscriptionConfigurations: + - $(sub-config-translation-int-test-resources) + - DisplayName: Dogfood ACS - Resource Cleanup + SubscriptionConfigurations: + - $(sub-config-communication-int-test-resources-common) + - DisplayName: AzureCloud ACS - Resource Cleanup + SubscriptionConfigurations: + - $(sub-config-azure-cloud-test-resources) + - $(sub-config-communication-services-cloud-test-resources-common) + - DisplayName: AzureCloud Cosmos - Resource Cleanup + SubscriptionConfigurations: + - $(sub-config-azure-cloud-test-resources) + - $(sub-config-cosmos-azure-cloud-test-resources) + - DisplayName: AzureCloud Storage NET - Resource Cleanup + SubscriptionConfigurations: + - $(sub-config-azure-cloud-test-resources) + - $(sub-config-storage-test-resources-net) + stages: - stage: Run variables: - template: ./templates/variables/globals.yml + - name: DailyCacheKey + value: $[format('{0:ddMMyyyy}', pipeline.startTime)] + - name: CachePath + value: CleanupCache jobs: - job: Run @@ -14,131 +66,24 @@ stages: vmImage: ubuntu-20.04 steps: - # Register the dogfood environment to clean up ACS custom subscription - - template: /eng/common/TestResources/setup-environments.yml - - - pwsh: | - $subscriptionConfiguration = @' - $(sub-config-azure-cloud-test-resources) - '@ | ConvertFrom-Json -AsHashtable - - ./eng/scripts/live-test-resource-cleanup.ps1 ` - @subscriptionConfiguration ` - -Verbose - displayName: AzureCloud - Resource Cleanup - continueOnError: true - - - pwsh: | - $subscriptionConfiguration = @' - $(sub-config-azure-cloud-test-resources-preview) - '@ | ConvertFrom-Json -AsHashtable - - ./eng/scripts/live-test-resource-cleanup.ps1 ` - @subscriptionConfiguration ` - -Verbose - displayName: AzureCloud-Preview - Resource Cleanup - continueOnError: true - - - pwsh: | - $subscriptionConfiguration = @' - $(sub-config-gov-test-resources) - '@ | ConvertFrom-Json -AsHashtable - - ./eng/scripts/live-test-resource-cleanup.ps1 ` - @subscriptionConfiguration ` - -Verbose - displayName: AzureUSGovernment - Resource Cleanup - continueOnError: true - - - pwsh: | - $subscriptionConfiguration = @' - $(sub-config-cn-test-resources) - '@ | ConvertFrom-Json -AsHashtable - - ./eng/scripts/live-test-resource-cleanup.ps1 ` - @subscriptionConfiguration ` - -Verbose - displayName: AzureChinaCloud - Resource Cleanup - continueOnError: true - - - pwsh: | - $subscriptionConfiguration = @' - $(sub-config-translation-int-test-resources) - '@ | ConvertFrom-Json -AsHashtable - - ./eng/scripts/live-test-resource-cleanup.ps1 ` - @subscriptionConfiguration ` - -Verbose - displayName: Dogfood Translation - Resource Cleanup - continueOnError: true - - - pwsh: | - $subscriptionConfiguration = @' - $(sub-config-communication-int-test-resources-common) - '@ | ConvertFrom-Json -AsHashtable - - ./eng/scripts/live-test-resource-cleanup.ps1 ` - @subscriptionConfiguration ` - -Verbose - displayName: Dogfood ACS - Resource Cleanup - continueOnError: true - - - template: /eng/common/TestResources/build-test-resource-config.yml - parameters: - SubscriptionConfigurations: - - $(sub-config-azure-cloud-test-resources) - - $(sub-config-communication-services-cloud-test-resources-common) - - pwsh: | - $subscriptionConfiguration = @' - $(SubscriptionConfiguration) - '@ | ConvertFrom-Json -AsHashtable - - ./eng/scripts/live-test-resource-cleanup.ps1 ` - @subscriptionConfiguration ` - -Verbose - displayName: AzureCloud ACS - Resource Cleanup - continueOnError: true - - - template: /eng/common/TestResources/build-test-resource-config.yml - parameters: - SubscriptionConfigurations: - - $(sub-config-azure-cloud-test-resources) - - $(sub-config-cosmos-azure-cloud-test-resources) - - pwsh: | - $subscriptionConfiguration = @' - $(SubscriptionConfiguration) - '@ | ConvertFrom-Json -AsHashtable - - ./eng/scripts/live-test-resource-cleanup.ps1 ` - @subscriptionConfiguration ` - -Verbose - displayName: AzureCloud Cosmos - Resource Cleanup - continueOnError: true - - - template: /eng/common/TestResources/build-test-resource-config.yml + - template: /eng/common/pipelines/templates/steps/cache-ps-modules.yml + + - task: Cache@2 + inputs: + # CacheSalt is an optional variable that can be overridden at pipeline runtime to + # force invalidate the cache. + # DailyCacheKey will trigger a new cache entry to refresh once per day. + key: '"$(CacheSalt)" | "$(DailyCacheKey)" | $(Build.SourcesDirectory)/eng/scripts/live-test-resource-cleanup.ps1' + path: $(CachePath) + displayName: Cache Github Alias Mappings + + # Register the dogfood environment to clean up any custom subscriptions in it + - template: /eng/common/TestResources/setup-environments.yml + + - ${{ each subscription in parameters.Subscriptions }}: + - template: ./live-test-cleanup-template.yml parameters: - SubscriptionConfigurations: - - $(sub-config-azure-cloud-test-resources) - - $(sub-config-storage-test-resources-net) - - pwsh: | - $subscriptionConfiguration = @' - $(SubscriptionConfiguration) - '@ | ConvertFrom-Json -AsHashtable - - ./eng/scripts/live-test-resource-cleanup.ps1 ` - @subscriptionConfiguration ` - -Verbose - displayName: AzureCloud Storage NET - Resource Cleanup - continueOnError: true - - - - pwsh: | - $subscriptionConfiguration = @' - $(sub-config-azure-cloud-playground) - '@ | ConvertFrom-Json -AsHashtable - - ./eng/scripts/live-test-resource-cleanup.ps1 ` - @subscriptionConfiguration ` - -Verbose - displayName: AzureCloud Playground - Resource Cleanup - continueOnError: true + DisplayName: ${{ subscription.DisplayName }} + SubscriptionConfigurations: ${{ subscription.SubscriptionConfigurations }} + GithubAliasCachePath: $(CachePath)/github-alias-mappings.txt + AdditionalParameters: ${{ subscription.AdditionalParameters }} diff --git a/eng/scripts/cleanup-allowlist.txt b/eng/scripts/cleanup-allowlist.txt new file mode 100644 index 00000000000..b7746595fa4 --- /dev/null +++ b/eng/scripts/cleanup-allowlist.txt @@ -0,0 +1,12 @@ +# This allowlist contains static resource groups automatically added to +# Microsoft owned subscriptions by policy + +cleanupservice +NetworkWatcherRG +AzSecPackAutoConfigRG + +# Exclude static groups used for testing. These groups should already be +# excluded via owners tags, but add a second exclusion just in case + +static-test-resources +LiveTestSecrets diff --git a/eng/scripts/live-test-resource-cleanup.ps1 b/eng/scripts/live-test-resource-cleanup.ps1 index 2c7e4654c82..bd7001a444d 100644 --- a/eng/scripts/live-test-resource-cleanup.ps1 +++ b/eng/scripts/live-test-resource-cleanup.ps1 @@ -3,93 +3,381 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +# This script implements the resource management guidelines documented at https://github.com/Azure/azure-sdk-tools/blob/main/doc/engsys_resource_management.md + #Requires -Version 6.0 #Requires -PSEdition Core +#Requires -Modules @{ModuleName='Az.Accounts'; ModuleVersion='1.6.4'} +#Requires -Modules @{ModuleName='Az.Resources'; ModuleVersion='1.8.0'} -[CmdletBinding(DefaultParameterSetName = 'Default', SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] +[CmdletBinding(DefaultParameterSetName = 'Interactive', SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param ( - [Parameter(Mandatory = $true)] - [ValidatePattern('^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$')] - [string] $ProvisionerApplicationId, + [Parameter(ParameterSetName = 'Provisioner', Mandatory = $true)] + [ValidatePattern('^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$')] + [string] $ProvisionerApplicationId, - [Parameter(Mandatory = $true)] - [string] $ProvisionerApplicationSecret, + [Parameter(ParameterSetName = 'Provisioner', Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $ProvisionerApplicationSecret, - [Parameter(Mandatory = $true)] - [ValidatePattern('^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$')] - [ValidateNotNullOrEmpty()] - [string] $TenantId, + [Parameter(ParameterSetName = 'Provisioner', Mandatory = $true)] + [ValidatePattern('^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$')] + [string] $OpensourceApiApplicationId, - [Parameter(Mandatory = $true)] - [ValidatePattern('^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$')] - [string] $SubscriptionId, + [Parameter(ParameterSetName = 'Provisioner', Mandatory = $true)] + [ValidatePattern('^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$')] + [string] $OpensourceApiApplicationTenantId, + + [Parameter(ParameterSetName = 'Provisioner', Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $OpensourceApiApplicationSecret, + + [Parameter(ParameterSetName = 'Provisioner', Mandatory = $true)] + [Parameter(ParameterSetName = 'Interactive')] + [ValidatePattern('^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$')] + [string] $TenantId, + + [Parameter(ParameterSetName = 'Provisioner', Mandatory = $true)] + [Parameter(ParameterSetName = 'Interactive')] + [ValidatePattern('^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$')] + [string] $SubscriptionId, + + [Parameter(ParameterSetName = 'Provisioner')] + [string] $GithubAliasCachePath, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $Environment = "AzureCloud", + + [Parameter()] + [switch] $DeleteNonCompliantGroups, + + [Parameter()] + [int] $DeleteAfterHours = 24, - [Parameter()] - [ValidateNotNullOrEmpty()] - $Environment = "AzureCloud", + [Parameter()] + [string] $AllowListPath = "$PSScriptRoot/cleanup-allowlist.txt", - [Parameter()] - [switch] $Force, + [Parameter()] + [switch] $Force, - [Parameter(ValueFromRemainingArguments = $true)] - $IgnoreUnusedArguments + [Parameter(ParameterSetName = 'Interactive')] + [switch] $Login, + + [Parameter(ValueFromRemainingArguments = $true)] + $IgnoreUnusedArguments ) -&"$PSScriptRoot/../common/scripts/Import-AzModules.ps1" +Set-StrictMode -Version 3 # Import resource management helpers and override its Log function. -. "$PSScriptRoot\..\common\scripts\Helpers\Resource-Helpers.ps1" +. (Join-Path $PSScriptRoot .. common scripts Helpers Resource-Helpers.ps1) +# Import helpers for querying repos.opensource.microsoft.com API +. (Join-Path $PSScriptRoot .. common scripts Helpers Metadata-Helpers.ps1) + +$OwnerAliasCache = @{} +$IsProvisionerApp = $PSCmdlet.ParameterSetName -eq "Provisioner" +$Exceptions = [System.Collections.Generic.HashSet[String]]@() + +function LoadAllowList() { + if (!(Test-Path $AllowListPath)) { + return + } + $lines = Get-Content $AllowListPath + foreach ($line in $lines) { + if ($line -and !$line.StartsWith("#")) { + $_ = $Exceptions.Add($line.Trim()) + } + } +} function Log($Message) { Write-Host $Message } +function IsValidAlias +{ + param( + [Parameter(Mandatory = $true)] + [string]$Alias + ) -Write-Verbose "Logging in" -$provisionerSecret = ConvertTo-SecureString -String $ProvisionerApplicationSecret -AsPlainText -Force -$provisionerCredential = [System.Management.Automation.PSCredential]::new($ProvisionerApplicationId, $provisionerSecret) -Connect-AzAccount -Force -Tenant $TenantId -Credential $provisionerCredential -ServicePrincipal -Environment $Environment -Select-AzSubscription -Subscription $SubscriptionId + if ($OwnerAliasCache.ContainsKey($Alias)) { + return $OwnerAliasCache[$Alias] + } -Write-Verbose "Fetching groups" -$allGroups = @(Get-AzResourceGroup) + # AAD apps require a higher level of permission requiring admin consent to query the MS Graph list API + # https://docs.microsoft.com/en-us/graph/api/user-list?view=graph-rest-1.0&tabs=http#permissions + # The Get-AzAdUser call uses the list API under the hood (`/users/$filter=`) + # and for some reason the Get API (`/user/`) also returns 401 + # with User.Read and User.ReadBasic.All permissions when called with an AAD app. + # For this reason, skip trying to query MS Graph directly in provisioner mode. + # The owner alias cache should already be pre-populated with all user records from the + # github -> ms alias mapping retrieved via the repos.opensource.microsoft.com API, however + # this will not include any security groups, in the case an owner tag does not contain + # individual user aliases. + if ($IsProvisionerApp) { + Write-Host "Skipping MS Graph alias lookup for '$Alias' due to permissions. Owner aliases not registered with github will be treated as invalid." + $OwnerAliasCache[$Alias] = $false + return $false + } -Write-Host "Total Resource Groups: $($allGroups.Count)" + $domains = @("microsoft.com", "ntdev.microsoft.com") -$now = [DateTime]::UtcNow + foreach ($domain in $domains) { + if (Get-AzAdUser -UserPrincipalName "$Alias@$domain") { + $OwnerAliasCache[$Alias] = $true + return $true; + } + } -$noDeleteAfter = $allGroups.Where({ $_.Tags.Keys -notcontains "DeleteAfter" }) -Write-Host "Subscription contains $($noDeleteAfter.Count) resource groups with no DeleteAfter tags" -$noDeleteAfter | ForEach-Object { Write-Verbose $_.ResourceGroupName } + $OwnerAliasCache[$Alias] = $false -$hasDeleteAfter = $allGroups.Where({ $_.Tags.Keys -contains "DeleteAfter" }) -Write-Host "Count $($hasDeleteAfter.Count)" -$toDelete = $hasDeleteAfter.Where({ $deleteDate = ($_.Tags.DeleteAfter -as [DateTime]); (!$deleteDate -or $now -gt $deleteDate) }) -Write-Host "Groups to delete: $($toDelete.Count)" + return $false; +} -# Get purgeable resources already in a deleted state. -$purgeableResources = @(Get-PurgeableResources) +function AddGithubUsersToAliasCache() { + if ($GithubAliasCachePath -and (Test-Path $GithubAliasCachePath)) { + Write-Host "Loading github -> microsoft alias mappings from filesystem cache '$GithubAliasCachePath'." + $users = Get-Content $GithubAliasCachePath | ConvertFrom-Json -AsHashtable + } else { + Write-Host "Retrieving github -> microsoft alias mappings from opensource API." + $users = GetAllGithubUsers $OpensourceApiApplicationTenantId $OpensourceApiApplicationId $OpensourceApiApplicationSecret + } + if (!$users) { + Write-Error "Failed to retrieve github -> microsoft alias mappings from opensource api." + exit 1 + } + foreach ($user in $users) { + if ($user.aad.alias) { + $OwnerAliasCache[$user.aad.alias] = $true + } + if ($user.aad.userPrincipalName) { + $OwnerAliasCache[$user.aad.userPrincipalName] = $true + } + if ($user.github.login) { + $OwnerAliasCache[$user.github.login] = $true + } + } + Write-Host "Found $($OwnerAliasCache.Count) valid github or microsoft aliases." + if ($GithubAliasCachePath -and !(Test-Path $GithubAliasCachePath)) { + $cacheDir = Split-Path $GithubAliasCachePath + if ($cacheDir -and $cacheDir -ne '.') { + New-Item -Type Directory -Force $cacheDir -WhatIf:$false + } + Write-Host "Caching github -> microsoft alias mappings to '$GithubAliasCachePath'" + $users | ConvertTo-Json -Depth 4 | Out-File $GithubAliasCachePath -WhatIf:$false + } +} -foreach ($rg in $toDelete) -{ - if ($Force -or $PSCmdlet.ShouldProcess("$($rg.ResourceGroupName) (UTC: $($rg.Tags.DeleteAfter))", "Delete Group")) { - # Add purgeable resources that will be deleted with the resource group to the collection. - $purgeableResourcesFromRG = Get-PurgeableGroupResources $rg.ResourceGroupName +function GetTag([object]$ResourceGroup, [string]$Key) { + if (!$ResourceGroup.Tags) { + return $null + } + + foreach ($tagKey in $ResourceGroup.Tags.Keys) { + # Compare case-insensitive + if ($tagKey -ieq $Key) { + return $ResourceGroup.Tags[$tagKey] + } + } + + return $null +} + +function HasValidOwnerTag([object]$ResourceGroup) { + $ownerTag = GetTag $ResourceGroup "Owners" + if (!$ownerTag) { + return $false + } + $owners = $ownerTag -split "[;, ]" + $hasValidOwner = $false + $invalidOwners = @() + foreach ($owner in $owners) { + if (IsValidAlias -Alias $owner) { + $hasValidOwner = $true + } else { + $invalidOwners += $owner + } + } + if ($invalidOwners) { + Write-Warning " Resource group '$($ResourceGroup.ResourceGroupName)' has invalid owner tags: $($invalidOwners -join ',')" + } + if ($hasValidOwner) { + Write-Host " Skipping tagged resource group '$($ResourceGroup.ResourceGroupName)' with owners '$owners'" + return $true + } + return $false +} + +function HasValidAliasInName([object]$ResourceGroup) { + # check compliance (formatting first, then validate alias) and skip if compliant + if ($ResourceGroup.ResourceGroupName ` + -match '^(rg-)?(?(t-|a-|v-)?[a-z,A-Z]+)([-_].*)?$' ` + -and (IsValidAlias -Alias $matches['alias'])) + { + Write-Host " Skipping resource group '$($ResourceGroup.ResourceGroupName)' starting with valid alias '$($matches['alias'])'" + return $true + } + return $false +} + +function GetDeleteAfterTag([object]$ResourceGroup) { + return GetTag $ResourceGroup "DeleteAfter" +} + +function HasExpiredDeleteAfterTag([string]$DeleteAfter) { + if ($DeleteAfter) { + $deleteDate = $deleteAfter -as [DateTime] + return $deleteDate -and [datetime]::UtcNow -gt $deleteDate + } + return $false +} + +function HasException([object]$ResourceGroup) { + if ($Exceptions.Count -and $Exceptions.Contains($ResourceGroup.ResourceGroupName)) { + Write-Host " Skipping allowed resource group '$($ResourceGroup.ResourceGroupName)' because it is in the allow list '$AllowListPath'" + return $true + } + return $false +} - if ($purgeableResourcesFromRG) { - $purgeableResources += $purgeableResourcesFromRG - Write-Verbose "Found $($purgeableResourcesFromRG.Count) potentially purgeable resources in resource group $($rg.ResourceGroupName)" +function FindOrCreateDeleteAfterTag { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + param( + [object]$ResourceGroup + ) + + $deleteAfter = GetTag $ResourceGroup "DeleteAfter" + if (!$deleteAfter -or !($deleteAfter -as [datetime])) { + $deleteAfter = [datetime]::UtcNow.AddHours($DeleteAfterHours) + if ($Force -or $PSCmdlet.ShouldProcess("$($ResourceGroup.ResourceGroupName) [DeleteAfter (UTC): $deleteAfter]", "Adding DeleteAfter Tag to Group")) { + Write-Host "Adding DeleteAfer tag with value '$deleteAfter' to group '$($ResourceGroup.ResourceGroupName)'" + $ResourceGroup | Update-AzTag -Operation Merge -Tag @{ DeleteAfter = $deleteAfter } } - Write-Verbose "Deleting group: $($rg.ResourceGroupName)" - Write-Verbose " tags $($rg.Tags | ConvertTo-Json -Compress)" - Write-Host ($rg | Remove-AzResourceGroup -Force -AsJob).Name } } -# Purge all the purgeable resources and get a list of resources (as a collection) we need to follow-up on. -Write-Host "Attempting to purge $($purgeableResources.Count) resources." -$failedResources = @(Remove-PurgeableResources $purgeableResources -PassThru) -if ($failedResources) { - Write-Warning "Timed out deleting the following $($failedResources.Count) resources. Please file an IcM ticket per resource type." - $failedResources | Sort-Object AzsdkResourceType, AzsdkName | Format-Table -Property @{l='Type'; e={$_.AzsdkResourceType}}, @{l='Name'; e={$_.AzsdkName}} +function HasDeleteLock() { + return $false +} + +function DeleteOrUpdateResourceGroups() { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + param() + + if ($IsProvisionerApp) { + AddGithubUsersToAliasCache + } + + Write-Verbose "Fetching groups" + $allGroups = @(Get-AzResourceGroup) + $toDelete = @() + $toUpdate = @() + Write-Host "Total Resource Groups: $($allGroups.Count)" + + foreach ($rg in $allGroups) { + if (HasException $rg) { + continue + } + $deleteAfter = GetDeleteAfterTag $rg + if ($deleteAfter) { + if (HasExpiredDeleteAfterTag $deleteAfter) { + $toDelete += $rg + } + continue + } + # TODO: Remove $true and follow non-compliant group deletion + # Currently this is disabled in order to roll out features of the script slowly. + # See https://gitub.com/Azure/azure-sdk-tools/issues/2714h + if ($true -or !$DeleteNonCompliantGroups) { + continue + } + if (HasValidAliasInName $rg) { + continue + } + if (HasValidOwnerTag $rg -or HasDeleteLock $rg) { + continue + } + $toUpdate += $rg + } + + foreach ($rg in $toUpdate) { + FindOrCreateDeleteAfterTag $rg + } + + # Get purgeable resources already in a deleted state. + $purgeableResources = @(Get-PurgeableResources) + + Write-Host "Total Resource Groups To Delete: $($toDelete.Count)" + foreach ($rg in $toDelete) + { + $deleteAfter = GetTag $rg "DeleteAfter" + if ($Force -or $PSCmdlet.ShouldProcess("$($rg.ResourceGroupName) [DeleteAfter (UTC): $deleteAfter]", "Delete Group")) { + # Add purgeable resources that will be deleted with the resource group to the collection. + $purgeableResourcesFromRG = Get-PurgeableGroupResources $rg.ResourceGroupName + + if ($purgeableResourcesFromRG) { + $purgeableResources += $purgeableResourcesFromRG + Write-Verbose "Found $($purgeableResourcesFromRG.Count) potentially purgeable resources in resource group $($rg.ResourceGroupName)" + } + Write-Verbose "Deleting group: $($rg.ResourceGroupName)" + Write-Verbose " tags $($rg.Tags | ConvertTo-Json -Compress)" + Write-Host ($rg | Remove-AzResourceGroup -Force -AsJob).Name + } + } + + if (!$purgeableResources.Count) { + return + } + if ($Force -or $PSCmdlet.ShouldProcess("$($purgeableResources.VaultName)", "Delete Purgeable Resources")) { + # Purge all the purgeable resources and get a list of resources (as a collection) we need to follow-up on. + Write-Host "Attempting to purge $($purgeableResources.Count) resources." + $failedResources = @(Remove-PurgeableResources $purgeableResources -PassThru) + if ($failedResources) { + Write-Warning "Timed out deleting the following $($failedResources.Count) resources. Please file an IcM ticket per resource type." + $failedResources | Sort-Object AzsdkResourceType, AzsdkName | Format-Table -Property @{l='Type'; e={$_.AzsdkResourceType}}, @{l='Name'; e={$_.AzsdkName}} + } + } +} + +function Login() { + if ($PSCmdlet.ParameterSetName -eq "Provisioner") { + Write-Verbose "Logging in with provisioner" + $provisionerSecret = ConvertTo-SecureString -String $ProvisionerApplicationSecret -AsPlainText -Force + $provisionerCredential = [System.Management.Automation.PSCredential]::new($ProvisionerApplicationId, $provisionerSecret) + Connect-AzAccount -Force -Tenant $TenantId -Credential $provisionerCredential -ServicePrincipal -Environment $Environment -WhatIf:$false + Select-AzSubscription -Subscription $SubscriptionId -Confirm:$false -WhatIf:$false + } elseif ($Login) { + Write-Verbose "Logging in with interactive user" + $cmd = "Connect-AzAccount" + if ($TenantId) { + $cmd += " -TenantId $TenantId" + } + if ($SubscriptionId) { + $cmd += " -SubscriptionId $SubscriptionId" + } + Invoke-Expression $cmd + } elseif (Get-AzContext) { + Write-Verbose "Using existing account" + } else { + $errMsg = 'User context not found. Please re-run script with "-Login" to login, ' + + 'or run "Connect-AzAccount -UseDeviceAuthentication" if interactive login is not available.' + Write-Error $errMsg + exit 1 + } +} + +LoadAllowList +Login + +$originalSubscription = (Get-AzContext).Subscription.Id +if ($SubscriptionId -and ($originalSubscription -ne $SubscriptionId)) { + Select-AzSubscription -Subscription $SubscriptionId -Confirm:$false -WhatIf:$false +} + +DeleteOrUpdateResourceGroups + +if ($SubscriptionId -and ($originalSubscription -ne $SubscriptionId)) { + Select-AzSubscription -Subscription $originalSubscription -Confirm:$false -WhatIf:$false } diff --git a/scripts/powershell/sub-cleanup/sub-cleanup.ps1 b/scripts/powershell/sub-cleanup/sub-cleanup.ps1 deleted file mode 100644 index 8a982dd7278..00000000000 --- a/scripts/powershell/sub-cleanup/sub-cleanup.ps1 +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env pwsh -c - -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -#Requires -Modules @{ModuleName='Az.Accounts'; ModuleVersion='1.6.4'} -#Requires -Modules @{ModuleName='Az.Resources'; ModuleVersion='1.8.0'} - -<# -.SYNOPSIS -Subscription cleanup script - -.DESCRIPTION -Cleans up non-compliant resource groups in a specified Azure -subscription. Complient resource groups are named with a valid -Microsoft alias or using the format: alias- - -.NOTES -You can add the tag CleanupException=true to the resource group -to have it skipped as an exception. This can be done with CLI -or in the portal. - -Requires PowerShell module Az and RemoteSigned excecution policy. -Commands to make this happen: - PS> Install-Module -Name Az -AllowClobber - PS> Set-ExecutionPolicy RemoteSigned - -.EXAMPLE -PS> sub-cleanup.ps1 -Subscription {guid} -Audit false - -.PARAMETER Subscription -Subscription ID (guid) for the subscription to be cleaned. - -.PARAMETER Audit -When specified, deletion operations are just logged, but not performed. -#> - -param( - [Parameter(Mandatory = $true)] - [string]$Subscription, - - [Parameter(Mandatory = $false)] - [boolean]$Audit=$true, - - [Parameter(Mandatory = $false)] - [boolean]$DeleteAsync=$false, - - [Parameter(Mandatory = $false)] - [boolean]$Login=$true -) - -function IsValidAlias -{ - param( - [Parameter(Mandatory = $true)] - [string]$Alias - ) - - $domains = @("microsoft.com", "ntdev.microsoft.com") - - foreach ($domain in $domains) - { - if (Get-AzAdUser -UserPrincipalName "$Alias@$domain") - { - return $true; - } - } - return $false; -} - -# Check if we're in audit mode (no actual deletions) -if ($Audit) -{ - Write-Host -ForegroundColor Green "Running in audit mode. Nothing will be deleted." -} -else -{ - Write-Warning "!!!NOT IN AUDIT MODE. RESOURCE GROUPS WILL BE DELETED!!!" -} - -# Connect and select active subscription -if ($Login) -{ - Connect-AzAccount - Select-AzSubscription -Subscription $Subscription -} - -#initialize lists -$deleted = @() -$skipped = @() - -# Loop through the resource groups and delete any with non-compliant names/tags -foreach ($resourceGroup in Get-AzResourceGroup | Sort-Object ResourceGroupName) -{ - # skip exceptions - if ($resourceGroup.Tags -and $resourceGroup.Tags["CleanupException"] -eq "true") - { - Write-Host " Skipping tagged exception resource group: $($resourceGroup.ResourceGroupName)" - $skipped += $($resourceGroup.ResourceGroupName) - continue - } - - # Exclude groups with a valid owners tag list - if ($resourceGroup.Tags -and $resourceGroup.Tags["owners"]) - { - $hasValidOwner = $false - $owners = $resourceGroup.Tags["owners"] - foreach ($owner in $owners -Split "[;, ]") { - if (IsValidAlias -Alias $owner) { - $hasValidOwner = $true - break - } - } - if ($hasValidOwner) - { - Write-Host " Skipping tagged exception resource group '$($resourceGroup.ResourceGroupName)' with owners '$owners'" - $skipped += $($resourceGroup.ResourceGroupName) - continue - } - } - - # Exclude groups already marked for cleanup within a week - if ($resourceGroup.Tags -and $resourceGroup.Tags["DeleteAfter"]) - { - $now = [DateTime]::UtcNow - $parsedTime = [DateTime]::MaxValue - $canParse = [DateTime]::TryParse($resourceGroup.Tags["DeleteAfter"], [ref]$parsedTime) - if ($canParse -and ($parsedTime -lt $now.AddDays(7))) { - Write-Host " Skipping compliant resource group '$($resourceGroup.ResourceGroupName)' marked DeleteAfter '$parsedTime'" - $skipped += $($resourceGroup.ResourceGroupName) - continue - } - } - - # check compliance (formatting first, then validate alias) and skip if compliant - if ($resourceGroup.ResourceGroupName -match "^(rg-)?((t-|a-|v-)?[a-z,A-Z]{3,15})([-_]{1}.*)?$" -and (IsValidAlias -Alias $matches[2])) - { - Write-Host " Skipping compliant resource group: $($resourceGroup.ResourceGroupName)" - $skipped += $($resourceGroup.ResourceGroupName) - continue - } - - Write-Host -ForegroundColor Red -NoNewline "Deleting non-compliant resource group: $($resourceGroup.ResourceGroupName)" - - # delete - $deleted += $($resourceGroup.ResourceGroupName) - if ($Audit) - { - Write-Host " Audit:Skipped." - } - else - { - if ($DeleteAsync) - { - Remove-AzResourceGroup -Name $resourceGroup.ResourceGroupName -Force -AsJob - } - elseif (Remove-AzResourceGroup -Name $resourceGroup.ResourceGroupName -Force) - { - Write-Host " Succeeded." - } - } -} - -Write-Host -ForegroundColor Cyan "##################################################" -Write-Host -ForegroundColor Cyan "Summary:" -Write-Host -ForegroundColor Cyan " Deleted: $($deleted.Count)" -Write-Host -ForegroundColor Cyan " Skipped: $($skipped.Count)" -Write-Host -ForegroundColor Cyan "##################################################" - -Write-Host -ForegroundColor Cyan "Resource Groups Deleted:" -foreach ($rg in $deleted) -{ - Write-Host " $rg" -} - -Write-Host -ForegroundColor Cyan "##################################################" -Write-Host -ForegroundColor Cyan "Compliant Resource Groups Skipped:" -foreach ($rg in $skipped) -{ - Write-Host " $rg" -}