<# .SYNOPSIS This script checks each Azure resource (group) across all subscriptions and eventually tags it as subject for deletion or (in some cases) deletes it automatically (after confirmation, configurable). Based on the tag's value suspect resources can be confirmed or rejected as subject for deletion and will be considered accordingly in subsequent runs. .DESCRIPTION ___ __ ███████╗ █████╗ ██╗ ██╗███████╗ ██╔════╝██╔══██╗██║ ██║██╔════╝ ███████╗███████║██║ ██║█████╗ ╚════██║██╔══██║╚██╗ ██╔╝██╔══╝ ███████║██║ ██║ ╚████╔╝ ███████╗ ╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚══════╝ ███╗ ███╗ ██████╗ ███╗ ██╗███████╗██╗ ██╗ ████╗ ████║██╔═══██╗████╗ ██║██╔════╝╚██╗ ██╔╝ ██╔████╔██║██║ ██║██╔██╗ ██║█████╗ ╚████╔╝ ██║╚██╔╝██║██║ ██║██║╚██╗██║██╔══╝ ╚██╔╝ __ ██║ ╚═╝ ██║╚██████╔╝██║ ╚████║███████╗ ██║ ____ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚══════╝ ╚═╝ ___ and clean-up... This script was primarily written to clean-up large Azure environments and potentially save money along the way. It was inspired by the project 'itoleck/AzureSaveMoney'. This script was deliberately written in a single file to ensure ease of use. The log output is written to the host with colors to improve human readability. The default values for some parameters can be specified in a config file named 'Defaults.json'. The script implements function hooks named for each supported resource type/kind. Function hooks determine for a specific resource which action shall be taken. The naming convention for hooks is "Test-ResourceActionHook-[-]". New hooks can easily be added by implementing a new function and will be discovered and called automatically. New hooks should be inserted after the marker [ADD NEW HOOKS HERE]. There are multiple tags which are set when a resource is marked as subject for deletion (tag names are configurable): - "SubjectForDeletion", - "SubjectForDeletion-FindingDate", - "SubjectForDeletion-Reason" and - "SubjectForDeletion-Hint" (optional). The "SubjectForDeletion" tag has one of the following values after the script ran and the tag was created: - "suspected": resource marked as subject for deletion - "suspectedSubResources": at least one sub resource is subject for deletion As long as the tag `SubjectForDeletion` has a value starting with `suspected...` the resource is reevaluated in every run and the tag value is updated (overwritten). You can update the tag value to one of the following values in order to influence the script behavior in subsequent runs (see below). The following example process is suggested to for large organizations: 1. RUN script regularly 2. ALERT `suspected` or `suspectedSubResources` resources to owners 3. MANUAL RESOLUTION by owners by reviewing and changing the tag value of `SubjectForDeletion` to one of the following values (case-sensitive!): - `rejected`: Resource is needed and shall NOT be deleted (this status will not be overwritten in subsequent runs for 6 months after `SubjectForDeletion-FindingDate`). - `confirmed`: Resource shall be deleted (will be automatically deleted in the next run). 4. AUTO-DELETION/REEVALUATION: Subsequent script runs will check all resources again with the following special handling for status: - `confirmed`: resource will be deleted. - `suspected`: if `SubjectForDeletion-FindingDate` is older that 30 days (e.g. resource was not reviewed in time), the resource will be automatically deleted. Project Link: https://github.com/thgossler/AzSaveMoney Copyright (c) 2022 Thomas Gossler License: MIT Tags: Azure, cost, optimization, PowerShell .PARAMETER DirectoryId The ID of the Azure AD tenant. Can be set in defaults config file. .PARAMETER AzEnvironment The Azure environment name (for options call "(Get-AzEnvironment).Name"). Can be set in defaults config file. .PARAMETER SubscriptionIdsToProcess The list of Azure subscription IDs to process. If empty all subscriptions will be processed. Can be set in defaults config file. .PARAMETER DontDeleteEmptyResourceGroups Prevents that empty resource groups are processed. .PARAMETER AlwaysOnlyMarkForDeletion Prevent any automatic deletions, only tag as subject for deletion. .PARAMETER TryMakingUserContributorTemporarily Add a Contributor role assignment temporarily for each subscription. .PARAMETER CentralAuditLogAnalyticsWorkspaceId Also use this LogAnalytics workspace for querying LogAnalytics diagnostic 'Audit' logs. .PARAMETER CheckForUnusedResourceGroups Checks for old resources groups with no deployments for a long time and no write/action activities in last 90 days. .PARAMETER MinimumResourceAgeInDaysForChecking Minimum number of days resources must exist to be considered (default: 4, lower or equal 0 will always check). Can be set in defaults config file. .PARAMETER DisableTimeoutForDeleteConfirmationPrompt Disable the timeout for all delete confirmation prompts (wait forever) .PARAMETER DeleteSuspectedResourcesAndGroupsAfterDays Delete resources and groups which have been and are still marked 'suspected' for longer than the defined period. (default: -1, i.e. don't delete). Can be set in defaults config file. .PARAMETER EnableRegularResetOfRejectedState Specifies that a 'rejected' status shall be reset to 'suspected' after the specified period of time to avoid that unused resources are remaining undetected forever. .PARAMETER ResetOfRejectedStatePeriodInDays Specifies the period of time in days after which a 'rejected' status is reset to 'suspected'. (default: 6 months) .PARAMETER DocumentationUrl An optional URL pointing to documentation about the context-specific use of this script. Can be set in defaults config file. .PARAMETER UseDeviceAuthentication Use device authentication. .PARAMETER AutomationAccountResourceId Use the system-assigned managed identity of this Azure Automation account for authentication (full resource ID). .PARAMETER ServicePrincipalCredential Use these service principal credentials for authentication. .PARAMETER EnforceStdout Redirect all displayed text (Write-HostOrOutput) to standard output. .INPUTS Azure resources/groups across all (or specified) subscriptions. .OUTPUTS Resource/group tags "SubjectForDeletion", "SubjectForDeletion-Reason", "SubjectForDeletion-FindingDate", "SubjectForDeletion-Hint", deleted empty resource groups eventually. .NOTES Warnings are suppressed by $WarningPreference='SilentlyContinue'. #> #Requires -Version 7 #Requires -Modules Az.Accounts #Requires -Modules Az.Batch #Requires -Modules Az.DataProtection #Requires -Modules Az.Monitor #Requires -Modules Az.ResourceGraph #Requires -Modules Az.Resources #Requires -Modules Az.ServiceBus #Requires -Modules PowerShellGet ###################################################################### # Configuration Settings ###################################################################### [CmdletBinding(SupportsShouldProcess)] param ( # The ID of the Azure AD tenant. Can be set in defaults config file. [string]$DirectoryId, # The Azure environment name (for options call "(Get-AzEnvironment).Name"). # Can be set in defaults config file. [string]$AzEnvironment, # The list of Azure subscription IDs to process. If empty all subscriptions # will be processed. Can be set in defaults config file. [string[]]$SubscriptionIdsToProcess = @(), # Prevents that empty resource groups are processed. [switch]$DontDeleteEmptyResourceGroups = $false, # Prevent any automatic deletions, only tag as subject for deletion. [switch]$AlwaysOnlyMarkForDeletion = $false, # Add a Contributor role assignment temporarily for each subscription. [switch]$TryMakingUserContributorTemporarily = $false, # Also use this LogAnalytics workspace for querying LogAnalytics diagnostic # 'Audit' logs. [string]$CentralAuditLogAnalyticsWorkspaceId = $null, # Checks for old resources groups with no deployments for a long time and # no write/action activities in last 90 days. [switch]$CheckForUnusedResourceGroups = $false, # Minimum number of days resources must exist to be considered (default: 4, # lower or equal 0 will always check). Can be set in defaults config file. [int]$MinimumResourceAgeInDaysForChecking = 1, # Disable the timeout for all delete confirmation prompts (wait forever) [switch]$DisableTimeoutForDeleteConfirmationPrompt = $false, # Delete resources and groups which have been and are still marked # 'suspected' for longer than the defined period. (default: -1, i.e. don't # delete). Can be set in defaults config file. [int]$DeleteSuspectedResourcesAndGroupsAfterDays = -1, # Specifies that a 'rejected' status shall be reset to 'suspected' after the specified period of time to avoid that unused resources are remaining undetected forever. [switch]$EnableRegularResetOfRejectedState = $false, # Specifies the duration in days after which a 'rejected' status is reset to 'suspected'. (default: 6 months) [int]$ResetOfRejectedStatePeriodInDays = -1, # An optional URL pointing to documentation about the context-specific # use of this script. Can be set in defaults config file. [string]$DocumentationUrl = $null, # Use device authentication. [switch]$UseDeviceAuthentication, # Use the system-assigned managed identity of this Azure Automation account for authentication (full resource ID). [string]$AutomationAccountResourceId = $null, # Use these service principal credentials for authentication. [PSCredential]$ServicePrincipalCredential = $null, # Redirect all displayed text (Write-HostOrOutput) to standard output. [switch]$EnforceStdout ) function Write-HostOrOutput { param ( [Parameter(Mandatory = $false, Position = 0)] [string]$Message = "", [Parameter(Mandatory = $false, Position = 1)] [System.ConsoleColor]$ForegroundColor = [System.ConsoleColor]::Gray, [Parameter(Mandatory = $false, Position = 2)] [System.ConsoleColor]$BackgroundColor = [System.ConsoleColor]::Black, [Parameter(Mandatory = $false, Position = 3)] [switch]$NoNewline = $false ) if ($EnforceStdout.IsPresent) { Write-Output $Message -NoNewline:$NoNewline } else { Write-Host $Message -ForegroundColor $ForegroundColor -BackgroundColor $BackgroundColor -NoNewline:$NoNewline } } # Get configured defaults from config file $defaultsConfig = (Test-Path -Path $PSScriptRoot/Defaults.json -PathType Leaf) ? (Get-Content -Path $PSScriptRoot/Defaults.json -Raw | ConvertFrom-Json) : @{} if ([string]::IsNullOrWhiteSpace($DirectoryId) -and ![string]::IsNullOrWhiteSpace($defaultsConfig.DirectoryId)) { $DirectoryId = $defaultsConfig.DirectoryId } if ([string]::IsNullOrWhiteSpace($AzEnvironment)) { if (![string]::IsNullOrWhiteSpace($defaultsConfig.AzEnvironment)) { $AzEnvironment = $defaultsConfig.AzEnvironment } else { $AzEnvironment = 'AzureCloud' } } if ($defaultsConfig.PSobject.Properties.name -match "DontDeleteEmptyResourceGroups") { try { $DontDeleteEmptyResourceGroups = [System.Convert]::ToBoolean($defaultsConfig.DontDeleteEmptyResourceGroups) } catch {} } if ($defaultsConfig.PSobject.Properties.name -match "AlwaysOnlyMarkForDeletion") { try { $AlwaysOnlyMarkForDeletion = [System.Convert]::ToBoolean($defaultsConfig.AlwaysOnlyMarkForDeletion) } catch {} } if ($defaultsConfig.PSobject.Properties.name -match "EnableRegularResetOfRejectedState") { try { $EnableRegularResetOfRejectedState = [System.Convert]::ToBoolean($defaultsConfig.EnableRegularResetOfRejectedState) } catch {} } if ($EnableRegularResetOfRejectedState -and $ResetOfRejectedStatePeriodInDays -lt 1) { if ($defaultsConfig.ResetOfRejectedStatePeriodInDays -ge 1) { $ResetOfRejectedStatePeriodInDays = $defaultsConfig.ResetOfRejectedStatePeriodInDays } else { $ResetOfRejectedStatePeriodInDays = 180 } } if ($defaultsConfig.PSobject.Properties.name -match "TryMakingUserContributorTemporarily") { try { $TryMakingUserContributorTemporarily = [System.Convert]::ToBoolean($defaultsConfig.TryMakingUserContributorTemporarily) } catch {} } if ($defaultsConfig.PSobject.Properties.name -match "CheckForUnusedResourceGroups") { try { $CheckForUnusedResourceGroups = [System.Convert]::ToBoolean($defaultsConfig.CheckForUnusedResourceGroups) } catch {} } if ($defaultsConfig.PSobject.Properties.name -match "EnforceStdout") { try { $EnforceStdout = [System.Convert]::ToBoolean($defaultsConfig.EnforceStdout) } catch {} } if ([string]::IsNullOrWhiteSpace($CentralAuditLogAnalyticsWorkspaceId) -and ![string]::IsNullOrWhiteSpace($defaultsConfig.CentralAuditLogAnalyticsWorkspaceId)) { $CentralAuditLogAnalyticsWorkspaceId = $defaultsConfig.CentralAuditLogAnalyticsWorkspaceId } if ($MinimumResourceAgeInDaysForChecking -lt 1 -and $defaultsConfig.MinimumResourceAgeInDaysForChecking -ge 1) { $MinimumResourceAgeInDaysForChecking = $defaultsConfig.MinimumResourceAgeInDaysForChecking } if ($DeleteSuspectedResourcesAndGroupsAfterDays -lt 0 -and $defaultsConfig.DeleteSuspectedResourcesAndGroupsAfterDays -ge 0) { $DeleteSuspectedResourcesAndGroupsAfterDays = $defaultsConfig.DeleteSuspectedResourcesAndGroupsAfterDays } if ($SubscriptionIdsToProcess.Count -lt 1 -and $defaultsConfig.SubscriptionIdsToProcess -and ($defaultsConfig.SubscriptionIdsToProcess -is [System.Array]) -and $defaultsConfig.SubscriptionIdsToProcess.Count -gt 0) { $SubscriptionIdsToProcess = $defaultsConfig.SubscriptionIdsToProcess } if ([string]::IsNullOrWhiteSpace($AutomationAccountResourceId) -and ![string]::IsNullOrWhiteSpace($defaultsConfig.AutomationAccountResourceId)) { $AutomationAccountResourceId = $defaultsConfig.AutomationAccountResourceId } # Alert invalid parameter combinations if ($null -ne $ServicePrincipalCredential -and $UseSystemAssignedIdentity.IsPresent) { throw [System.ApplicationException]::new("Parameters 'ServicePrincipalCredential' and 'UseSystemAssignedIdentity' cannot be used together") return } if ($null -ne $ServicePrincipalCredential -and $UseDeviceAuthentication.IsPresent) { throw [System.ApplicationException]::new("Parameters 'ServicePrincipalCredential' and 'UseDeviceAuthentication' cannot be used together") return } if ($UseDeviceAuthentication.IsPresent -and $UseSystemAssignedIdentity.IsPresent) { throw [System.ApplicationException]::new("Parameters 'UseDeviceAuthentication' and 'UseSystemAssignedIdentity' cannot be used together") return } if ($null -ne $ServicePrincipalCredential -and [string]::IsNullOrEmpty($DirectoryId)) { throw [System.ApplicationException]::new("Parameter 'ServicePrincipalCredential' requires 'DirectoryId' to be specified") return } # Initialize static settings (non-parameterized) $performDeletionWithoutConfirmation = $false # defensive default of $false (intentionally not made available as param) $enableOperationalInsightsWorkspaceHook = $false # enable only when at least 30 days of Audit logs are available $subjectForDeletionTagName = "SubjectForDeletion" $subjectForDeletionFindingDateTagName = "SubjectForDeletion-FindingDate" $subjectForDeletionReasonTagName = "SubjectForDeletion-Reason" $resourceGroupOldAfterDays = 365 # resource groups with no deployments for that long and no write/action activities for 90 days enum SubjectForDeletionStatus { # Supported values for the SubjectForDeletion tag suspected suspectedSubResources rejected confirmed } # General explanatory and constant tag/value applied to all tagged resource if value is not empty (e.g. URL to docs for the approach) $subjectForDeletionHintTagName = "SubjectForDeletion-Hint" $subjectForDeletionHintTagValue = "Update the '$subjectForDeletionTagName' tag value to '$([SubjectForDeletionStatus]::rejected.ToString())' if still needed!" if ([string]::IsNullOrWhiteSpace($DocumentationUrl) -and ![string]::IsNullOrWhiteSpace($defaultsConfig.DocumentationUrl)) { $DocumentationUrl = $defaultsConfig.DocumentationUrl } if (![string]::IsNullOrWhiteSpace($DocumentationUrl)) { $subjectForDeletionHintTagValue += " See also: $DocumentationUrl" } $tab = ' ' ###################################################################### # Resource Type Hooks ###################################################################### # Actions decided upon by hooks enum ResourceAction { none markForDeletion markForSuspectSubResourceCheck delete } # Resource type-specific hooks for determining the action to perform function Test-ResourceActionHook-microsoft-batch-batchaccounts($Resource) { $apps = Get-AzBatchApplication -ResourceGroupName $Resource.resourceGroup -AccountName $Resource.name -WarningAction Ignore if ($apps.Id.Length -lt 1) { return [ResourceAction]::markForDeletion, "The batch account has no apps." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-cache-redis($Resource) { $periodInDays = 35 $totalGetCount = Get-Metric -ResourceId $Resource.id -MetricName 'allgetcommands' -AggregationType Total -PeriodInDays $periodInDays if ($null -ne $totalGetCount -and $totalGetCount.Sum -lt 1) { return [ResourceAction]::markForDeletion, "The Redis cache had no read access for $periodInDays days." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-compute-disks($Resource) { if ($Resource.properties.diskState -ieq "Unattached" -or $Resource.managedBy.Length -lt 1) { return [ResourceAction]::markForDeletion, "The disk is not attached to any virtual machine." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-compute-images($Resource) { try { $sourceVm = Get-AzResource -ResourceId $Resource.properties.sourceVirtualMachine.Id -ErrorAction Ignore -WarningAction Ignore if ($sourceVm) { Write-HostOrOutput "$($tab)$($tab)Source VM of a usually generalized image is still existing" -ForegroundColor DarkGray return [ResourceAction]::markForDeletion, "The source VM (usually generalized) of the image still exists." } } catch {} return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-compute-snapshots($Resource) { $periodInDays = 180 if ($Resource.properties.timeCreated -lt (Get-Date -AsUTC).AddDays(-$periodInDays)) { return [ResourceAction]::markForDeletion, "The snapshot is older than $periodInDays days." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-containerregistry-registries($Resource) { $periodInDays = 90 $totalPullCount = Get-Metric -ResourceId $Resource.id -MetricName 'TotalPullCount' -AggregationType Average -PeriodInDays $periodInDays if ($null -ne $totalPullCount -and $totalPullCount.Sum -lt 1) { return [ResourceAction]::markForDeletion, "The container registry had no pull requests for $periodInDays days." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-datafactory-factories($Resource) { $periodInDays = 35 $totalSucceededActivityRuns = Get-Metric -ResourceId $Resource.id -MetricName 'ActivitySucceededRuns' -AggregationType Total -PeriodInDays $periodInDays if ($null -ne $totalSucceededActivityRuns -and $totalSucceededActivityRuns.Sum -lt 1) { return [ResourceAction]::markForDeletion, "The data factory has no successful activity runs for $periodInDays days." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-dataprotection-backupvaults($Resource) { $backupInstances = Get-AzDataProtectionBackupInstance -ResourceGroupName $Resource.resourceGroup -VaultName $Resource.name if ($backupInstances.Count -lt 1) { return [ResourceAction]::markForDeletion, "The backup vault has no backup instances." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-dbformysql-servers($Resource) { $periodInDays = 35 $totalNetworkBytesEgress = Get-Metric -ResourceId $Resource.id -MetricName 'network_bytes_egress' -AggregationType Total -PeriodInDays $periodInDays if ($null -ne $totalNetworkBytesEgress -and $totalNetworkBytesEgress.Sum -lt 1) { return [ResourceAction]::markForDeletion, "The MySql database had no egress for $periodInDays days." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-documentdb-databaseaccounts($Resource) { $periodInDays = 35 $totalRequestCount = Get-Metric -ResourceId $Resource.id -MetricName 'TotalRequests' -AggregationType Count -PeriodInDays $periodInDays if ($null -ne $totalRequestCount -and $totalRequestCount.Sum -lt 1) { return [ResourceAction]::markForDeletion, "The Document DB had no requests account for $periodInDays days." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-eventgrid-topics($Resource) { $periodInDays = 35 $totalSuccessfulDeliveredEvents = Get-Metric -ResourceId $Resource.id -MetricName 'DeliverySuccessCount' -AggregationType Total -PeriodInDays $periodInDays if ($null -ne $totalSuccessfulDeliveredEvents -and $totalSuccessfulDeliveredEvents.Sum -lt 1) { return [ResourceAction]::markForDeletion, "The event grid topic had no successfully delivered events for $periodInDays days." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-insights-activitylogalerts($Resource) { if ($Resource.properties.enabled -eq $false) { return [ResourceAction]::markForDeletion, "The activity log alert is disabled." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-insights-components($Resource) { $access = Get-AzAccessToken -ResourceTypeName "OperationalInsights" $headers = @{"Authorization" = "Bearer " + $access.Token} $body = @{ "timespan" = "P30D"; "query" = "requests | summarize totalCount=sum(itemCount)"} | ConvertTo-Json $result = Invoke-RestMethod "https://api.applicationinsights.io/v1/apps/$($Resource.properties.AppId)/query" -Method 'POST' -Headers $headers -Body $body -ContentType "application/json" $totalRequestCount = $result.tables[0].rows[0][0] if ($totalRequestCount -lt 1) { return [ResourceAction]::markForDeletion, "The application insights resource had no read requests for 30 days." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-insights-metricalerts($Resource) { if ($Resource.properties.enabled -eq $false) { return [ResourceAction]::markForDeletion, "The metric alert is disabled." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-insights-scheduledqueryrules($Resource) { if ($Resource.properties.enabled -eq $false) { return [ResourceAction]::markForDeletion, "The scheduled query rule is disabled." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-keyvault-vaults($Resource) { $periodInDays = 35 $totalApiHits = Get-Metric -ResourceId $Resource.id -MetricName 'ServiceApiHit' -AggregationType Count -PeriodInDays $periodInDays if ($null -ne $totalApiHits -and $totalApiHits.Sum -lt 1) { return [ResourceAction]::markForDeletion, "The key vault had no API hits for $periodInDays days." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-kusto-clusters($Resource) { $periodInDays = 35 $totalReceivedBytesAverage = Get-Metric -ResourceId $Resource.id -MetricName 'ReceivedDataSizeBytes' -AggregationType Average -PeriodInDays $periodInDays if ($null -ne $totalReceivedBytesAverage -and $totalReceivedBytesAverage.Sum -lt 1) { return [ResourceAction]::markForDeletion, "The Kusto cluster had no egress for $periodInDays days." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-logic-workflows($Resource) { $periodInDays = 35 if ($Resource.properties.state -ine 'Enabled') { return [ResourceAction]::markForDeletion, "The logic apps workflow disabled." } $totalRunsSucceeded = Get-Metric -ResourceId $Resource.id -MetricName 'RunsSucceeded' -AggregationType 'Total' -PeriodInDays $periodInDays if ($null -ne $totalRunsSucceeded -and $totalRunsSucceeded.Sum -lt 1) { return [ResourceAction]::markForDeletion, "The logic apps workflow had no successful runs for $periodInDays days." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-network-bastionhosts($Resource) { $periodInDays = 35 $totalNumberOfSessions = Get-Metric -ResourceId $Resource.id -MetricName 'sessions' -AggregationType Total -PeriodInDays $periodInDays if ($null -ne $totalNumberOfSessions -and $totalNumberOfSessions.Sum -lt 1) { return [ResourceAction]::markForDeletion, "The Bastion host had no sessions for $periodInDays days." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-network-connections($Resource) { if ($Resource.properties.connectionStatus -ine 'Connected') { return [ResourceAction]::markForDeletion, "The network connection is disconnected." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-network-loadbalancers($Resource) { $periodInDays = 35 if ($Resource.properties.loadBalancingRules.Count -lt 1) { return [ResourceAction]::markForDeletion, "The load balancer has no load blanancing rules." } if ($Resource.sku.name -ine 'Basic') { # metrics not available in Basic SKU $totalByteCount = Get-Metric -ResourceId $Resource.id -MetricName 'ByteCount' -AggregationType Total -PeriodInDays $periodInDays if ($null -ne $totalByteCount -and $totalByteCount.Sum -lt 1) { return [ResourceAction]::markForDeletion, "The load balancer had no transmitted bytes for $periodInDays days." } } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-network-networkinterfaces($Resource) { if (!$Resource.properties.virtualMachine -and !$Resource.properties.privateEndpoint) { return [ResourceAction]::markForDeletion, "The network interface is unassigned." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-network-networksecuritygroups($Resource) { if (!$Resource.properties.networkInterfaces -and !$Resource.properties.subnets) { return [ResourceAction]::markForDeletion, "The network security group is unassigned." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-network-publicipaddresses($Resource) { if ($null -eq $Resource.properties.ipConfiguration.id) { return [ResourceAction]::markForDeletion, "The public IP address is unassigned." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-network-routetables($Resource) { $atLeastOneUsedRoute = $false foreach ($route in $Resource) { if ($route.properties.subnets.Count -gt 0) { $atLeastOneUsedRoute = $true } } if (!$atLeastOneUsedRoute) { return [ResourceAction]::markForDeletion, "The network route table has no used routed." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-network-trafficmanagerprofiles($Resource) { if ($Resource.properties.profileStatus -ine 'Enabled') { return [ResourceAction]::markForDeletion, "The traffic manager profile is disabled." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-network-virtualnetworks($Resource) { if ($Resource.properties.subnets.Count -lt 1) { return [ResourceAction]::markForDeletion, "The virtual network has no subnets." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-notificationhubs-namespaces($Resource) { $parameters = @{ ResourceType = 'Microsoft.NotificationHubs/namespaces/notificationHubs' ResourceGroupName = $Resource.resourceGroup ResourceName = $Resource.name ApiVersion = '2017-04-01' ErrorAction = 'SilentlyContinue' } $notificationHub = Get-AzResource @parameters if (!$notificationHub) { return [ResourceAction]::markForDeletion, "The notification hub has no hubs." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-operationalinsights-workspaces($Resource) { # # NOTE: This hook is only working for LogAnalytics workspaces which have diagnostic # 'Audit' logs enabled and written into either the workspace itself or another single # workspace specified in $CentralAuditLogAnalyticsWorkspaceId. Therefore, to enable # this hook, the following setting must be made: # $enableOperationalInsightsWorkspaceHook = $true # $periodInDays = 35 # data retention in the LogAnalytics workspaces needs to be configured correspondingly if ($enableOperationalInsightsWorkspaceHook -eq $true) { $query = "LAQueryLogs | where TimeGenerated >= now() - $($periodInDays)d | where RequestTarget == '$($Resource.id)' | count" $numberOfUserOrClientRequests = 0 if (![string]::IsNullOrWhiteSpace($CentralAuditLogAnalyticsWorkspaceId)) { $results = Invoke-AzOperationalInsightsQuery -Query $query -WorkspaceId $CentralAuditLogAnalyticsWorkspaceId | Select-Object -ExpandProperty Results $numberOfUserOrClientRequests = [int]$results[0].Count } if ($numberOfUserOrClientRequests -lt 1) { $workspace = Get-AzOperationalInsightsWorkspace -ResourceGroupName $Resource.resourceGroup -Name $Resource.name $results = Invoke-AzOperationalInsightsQuery -Query $query -WorkspaceId $workspace.CustomerId | Select-Object -ExpandProperty Results $numberOfUserOrClientRequests = [int]$results[0].Count } if ($numberOfUserOrClientRequests -lt 1) { return [ResourceAction]::markForDeletion, "The log analytics workspace had no read requests for $periodInDays days." } } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-servicebus-namespaces($Resource) { $queues = Get-AzServiceBusQueue -ResourceGroupName $Resource.resourceGroup -NamespaceName $Resource.name $result = [ResourceAction]::none foreach ($queue in $queues) { if ($queue.Status -ine "Active") { Write-HostOrOutput "$($tab)$($tab)Queue '$($queue.name)' is in status '$($queue.Status)'" -ForegroundColor DarkGray $result = [ResourceAction]::markForSuspectSubResourceCheck, "The service bus namespace has at least one inactive queue." } } return $result, "" } function Test-ResourceActionHook-microsoft-web-serverfarms($Resource) { if ($Resource.properties.numberOfSites -lt 1) { return [ResourceAction]::markForDeletion, "The app service plan has no apps." } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-web-sites-functionapp($Resource) { if ($Resource.properties.state -ieq 'Running') { # we can't see functions in stopped apps, so we ignore them $GetAzResourceParameters = @{ ResourceType = 'Microsoft.Web/sites/functions' ResourceGroupName = $Resource.resourceGroup ResourceName = $Resource.name ApiVersion = '2022-03-01' ErrorAction = 'SilentlyContinue' } $functions = Get-AzResource @GetAzResourceParameters if (!$functions) { Write-HostOrOutput "$($tab)$($tab)Function app has no functions" -ForegroundColor DarkGray return [ResourceAction]::markForDeletion, "The function app has no functions." } } return [ResourceAction]::none, "" } function Test-ResourceActionHook-microsoft-storage-storageaccounts($Resource) { $periodInDays = 35 $totalNumOfTransactions = Get-Metric -ResourceId $Resource.id -MetricName "Transactions" -AggregationType "Total" -PeriodInDays $periodInDays if ($null -ne $totalNumOfTransactions -and $totalNumOfTransactions.Sum -lt 1) { return [ResourceAction]::markForDeletion, "The storage account had no transactions for $periodInDays days." } $usedCapacity = Get-Metric -ResourceId $Resource.id -MetricName "UsedCapacity" -AggregationType "Average" -PeriodInDays $periodInDays -TimeGrainInHours 1 if ($null -ne $usedCapacity -and $usedCapacity.Maximum -lt 1) { return [ResourceAction]::markForDeletion, "The storage account had no data for $periodInDays days." } return [ResourceAction]::none, "" } # [ADD NEW HOOKS HERE], ideally insert them above in alphanumeric order ###################################################################### # Helper Functions ###################################################################### function Get-Metric([string]$ResourceId, [string]$MetricName, [string]$AggregationType, [int]$PeriodInDays = 35, [int]$TimeGrainInHours = 24) { if ([string]::IsNullOrWhiteSpace($ResourceId)) { throw [System.ApplicationException]::new("ResourceId not specified")} if ([string]::IsNullOrWhiteSpace($MetricName)) { throw [System.ApplicationException]::new("MetricName not specified")} if ([string]::IsNullOrWhiteSpace($AggregationType)) { throw [System.ApplicationException]::new("AggregationType not specified")} $metric = $null $retries = 3 $delaySeconds = 3 do { if ($retries -ne 3) { Start-Sleep -Seconds $delaySeconds } $retries -= 1 try { $metric = Get-AzMetric -ResourceId $ResourceId -MetricName $MetricName -AggregationType $AggregationType ` -StartTime (Get-Date -AsUTC).AddDays(-$PeriodInDays) -EndTime (Get-Date -AsUTC) ` -TimeGrain ([timespan]::FromHours($TimeGrainInHours).ToString()) ` -ErrorAction Continue } catch { $metric = $null Write-HostOrOutput "Retrying in $delaySeconds seconds..." if ($retries -eq 1) { # Workaround: Get-AzMetric doesn't work sometimes with TimeGrain specified (https://github.com/Azure/azure-powershell/issues/22750) $metric = Get-AzMetric -ResourceId $ResourceId -MetricName $MetricName -AggregationType $AggregationType ` -StartTime (Get-Date -AsUTC).AddDays(-$PeriodInDays) -EndTime (Get-Date -AsUTC) ` -ErrorAction Continue } else { $metric = Get-AzMetric -ResourceId $ResourceId -MetricName $MetricName -AggregationType $AggregationType ` -StartTime (Get-Date -AsUTC).AddDays(-$PeriodInDays) -EndTime (Get-Date -AsUTC) ` -TimeGrain ([timespan]::FromHours($TimeGrainInHours).ToString()) ` -ErrorAction SilentlyContinue } } $retries -= 1 if ($null -eq $metric -and $retries -gt 0) { Write-HostOrOutput "$($tab)$($tab)Metric could not be retrieved, retrying in $delaySeconds seconds..." -ForegroundColor DarkGray } } while ($null -eq $metric -and $retries -gt 0) if ($null -eq $metric) { Write-HostOrOutput "$($tab)$($tab)Failed to get metric '$MetricName' for resource '$ResourceId'" -ForegroundColor Red return $null } $metricData = $metric.Data $measuredMetricData = $metricData | Measure-Object -Property $AggregationType -AllStats return $measuredMetricData } function Add-SubjectForDeletionTags { [CmdletBinding(SupportsShouldProcess)] param ( $ResourceOrGroup, [SubjectForDeletionStatus]$Status = [SubjectForDeletionStatus]::suspected, [string]$Reason = $null, [string]$Hint = $null, [switch]$SuppressHostOutput = $false, [switch]$AllowResetOfRejectedToSuspected = $false ) $tags = $ResourceOrGroup.Tags $subjectForDeletionTagValue = ($tags.$subjectForDeletionTagName ?? '').Trim() $subjectForDeletionFindingDateTagValue = ($tags.$subjectForDeletionFindingDateTagName ?? '').Trim() # only update tag if not existing yet or still in suspected status $targetTagValue = $Status.ToString() if ([string]::IsNullOrWhiteSpace($subjectForDeletionTagValue) -or ` ($subjectForDeletionTagValue -ine [SubjectForDeletionStatus]::confirmed.ToString() -and ` $subjectForDeletionTagValue -ine [SubjectForDeletionStatus]::suspectedSubResources.ToString()) -or ` ($subjectForDeletionTagValue -ieq [SubjectForDeletionStatus]::rejected.ToString() -and ` $AllowResetOfRejectedToSuspected) ) { $dateString = Get-Date -AsUTC -Format "dd.MM.yyyy" $tagsToBeRemoved = @{} $newTags = @{ $subjectForDeletionTagName = $targetTagValue } # Don't overwrite FindingDate tag if value is existing if ([string]::IsNullOrWhiteSpace($subjectForDeletionFindingDateTagValue)) { $newTags.Add($subjectForDeletionFindingDateTagName, $dateString) } if (![String]::IsNullOrWhiteSpace($Reason)) { $text = $Reason.Trim() if ($text.Length -gt 256) { $text = $text.Substring(0, 256) } $newTags.Add($subjectForDeletionReasonTagName, $text) } elseif ($null -ne $tags.$subjectForDeletionReasonTagName) { $tagsToBeRemoved.Add($subjectForDeletionReasonTagName, $tags.$subjectForDeletionReasonTagName) } if ([String]::IsNullOrWhiteSpace($Hint) -and ![String]::IsNullOrWhiteSpace($subjectForDeletionHintTagValue)) { $Hint = $subjectForDeletionHintTagValue } if (![String]::IsNullOrWhiteSpace($Hint)) { $text = $Hint.Trim() if ($text.Length -gt 256) { $text = $text.Substring(0, 256) } $newTags.Add($subjectForDeletionHintTagName, $text) } elseif ($null -ne $tags.$subjectForDeletionHintTagName) { $tagsToBeRemoved.Add($subjectForDeletionHintTagName, $tags.$subjectForDeletionHintTagName) } $result = Update-AzTag -ResourceId $ResourceOrGroup.ResourceId -tag $newTags -Operation Merge -WhatIf:$WhatIfPreference if (!$SuppressHostOutput -and $result) { Write-HostOrOutput "$($tab)$($tab)$($WhatIfHint)Added tags " -NoNewline Write-HostOrOutput ($newTags | ConvertTo-Json -Compress) -ForegroundColor White } } # Remove existing tags which are not specified if ($tagsToBeRemoved.Keys.Count -gt 0) { Update-AzTag -ResourceId $ResourceOrGroup.ResourceId -tag $tagsToBeRemoved -Operation Delete -WhatIf:$WhatIfPreference | Out-Null } } function Remove-SubjectForDeletionTags { [CmdletBinding(SupportsShouldProcess)] param ( $ResourceOrGroup ) $tags = $ResourceOrGroup.Tags if (!$tags) { return } $resourceOrGroupId = $ResourceOrGroup.ResourceId $subjectForDeletionTagValue = ($tags.$subjectForDeletionTagName ?? '').Trim() $status = $null if (![string]::IsNullOrWhiteSpace($subjectForDeletionTagValue)) { $status = [SubjectForDeletionStatus]$subjectForDeletionTagValue } $subjectForDeletionFindingDateTagValue = $tags.$subjectForDeletionFindingDateTagName $subjectForDeletionReasonTagValue = $tags.$subjectForDeletionReasonTagName $subjectForDeletionHintTagValue = $tags.$subjectForDeletionHintTagName $tagsToRemove = @{} $removeNecessary = $false # Certain tags shall not be removed in 'rejected' status! if ($status -ine [SubjectForDeletionStatus]::rejected.ToString()) { $tagsToRemove.Add($subjectForDeletionTagName, $subjectForDeletionTagValue) $removeNecessary = $true if ($null -ne $subjectForDeletionFindingDateTagValue) { $tagsToRemove.Add($subjectForDeletionFindingDateTagName, $subjectForDeletionFindingDateTagValue) } if ($null -ne $subjectForDeletionHintTagValue) { $tagsToRemove.Add($subjectForDeletionHintTagName, $subjectForDeletionHintTagValue) } } if ($null -ne $subjectForDeletionReasonTagValue) { $tagsToRemove.Add($subjectForDeletionReasonTagName, $subjectForDeletionReasonTagValue) $removeNecessary = $true } if ($removeNecessary) { Update-AzTag -ResourceId $resourceOrGroupId -tag $tagsToRemove -Operation Delete -WhatIf:$WhatIfPreference | Out-Null } } enum UserConfirmationResult { Unknown No Yes YesForAll } function Get-UserConfirmationWithTimeout( # Continue automatically after this number of seconds assuming 'No' [int]$TimeoutSeconds = 30, # Wait for user input forever [switch]$DisableTimeout = $false, # Consider last user input, i.e. YesToAll suppresses confirmation prompt [UserConfirmationResult]$LastConfirmationResult = [UserConfirmationResult]::Unknown) { $choice = "cancel" $questionTimeStamp = (Get-Date -AsUTC) if ($LastConfirmationResult -eq [UserConfirmationResult]::YesForAll) { $choice = "a" } else { try { Write-HostOrOutput "$([Environment]::NewLine)Continue? 'y' = yes, 'a' = yes to all, = no : " -ForegroundColor Red -NoNewline # Read key input from host $timer = [System.Diagnostics.Stopwatch]::StartNew() $choice = $null # Clear console key input queue while ([Console]::KeyAvailable) { [Console]::ReadKey($true) | Out-Null } # Wait for user input if (!$DisableTimeout) { while (-not [Console]::KeyAvailable -and $null -eq $choice) { if ($timer.ElapsedMilliseconds -gt ($TimeoutSeconds*1000)) { $choice = 'n' break } else { Start-Sleep -Milliseconds 250 } } } if ($null -eq $choice) { $choice = ([Console]::ReadKey()).KeyChar } $timer.Stop() $timer = $null Write-HostOrOutput "" $answerTimeStamp = (Get-Date -AsUTC) if (!$DisableTimeout -and ` $answerTimeStamp.Subtract($questionTimeStamp) -gt [timespan]::FromSeconds($TimeoutSeconds)) { Write-HostOrOutput "No response within $($TimeoutSeconds)s (the situation may have changed), assuming 'no'..." $choice = 'n' } } catch { Write-HostOrOutput "Asking user for confirmation failed, assuming 'no'..." $choice = 'n' } } if ($choice -ieq "y") { return [UserConfirmationResult]::Yes } elseif ($choice -ieq "a") { return [UserConfirmationResult]::YesForAll } return [UserConfirmationResult]::No } ###################################################################### # Execution ###################################################################### $WarningPreference = 'SilentlyContinue' # to suppress upcoming breaking changes warnings $IsWhatIfMode = !$PSCmdlet.ShouldProcess("mode is enabled (no changes will be made)", $null, $null) $WhatIfHint = $IsWhatIfMode ? "What if: " : "" # Override $performDeletionWithoutConfirmation settings when -Confirm is used if ($performDeletionWithoutConfirmation -eq $true -and $ConfirmPreference -eq $true) { $performDeletionWithoutConfirmation = $false } $lastUserConfirmationResult = $performDeletionWithoutConfirmation -eq $true ? [UserConfirmationResult]::YesForAll : [UserConfirmationResult]::Unknown if (!((Get-AzEnvironment).Name -contains $AzEnvironment)) { throw [System.ApplicationException]::new("Invalid Azure environment name '$AzEnvironment'") return } Write-HostOrOutput "Signing-in to Azure..." $loggedIn = $false $null = Disable-AzContextAutosave -Scope Process # ensures that an AzContext is not inherited $useSystemIdentity = ![string]::IsNullOrWhiteSpace($AutomationAccountResourceId) $useDeviceAuth = $UseDeviceAuthentication.IsPresent $warnAction = $useDeviceAuth ? 'Continue' : 'SilentlyContinue' if ($useSystemIdentity -eq $true) { # Use system-assigned identity Write-HostOrOutput "Using system-assigned identity..." $loggedIn = Connect-AzAccount -Identity -WarningAction $warnAction -WhatIf:$false } elseif ($null -eq $ServicePrincipalCredential) { # Use user authentication (interactive or device) Write-HostOrOutput "Using user authentication..." if (![string]::IsNullOrWhiteSpace($DirectoryId)) { $loggedIn = Connect-AzAccount -Environment $AzEnvironment -UseDeviceAuthentication:$useDeviceAuth -TenantId $DirectoryId -WarningAction $warnAction -WhatIf:$false } else { $loggedIn = Connect-AzAccount -Environment $AzEnvironment -UseDeviceAuthentication:$useDeviceAuth -WarningAction $warnAction -WhatIf:$false $DirectoryId = (Get-AzContext).Tenant.Id } } else { # Use service principal authentication Write-HostOrOutput "Using service principal authentication..." $loggedIn = Connect-AzAccount -Environment $AzEnvironment -TenantId $DirectoryId -ServicePrincipal -Credential $ServicePrincipalCredential -WhatIf:$false } if (!$loggedIn) { throw [System.ApplicationException]::new("Sign-in failed") return } Write-HostOrOutput "Signed in successfully." Write-HostOrOutput "$([Environment]::NewLine)Getting Azure subscriptions..." $allSubscriptions = @(Get-AzSubscription -TenantId $DirectoryId | Where-Object -Property State -NE Disabled | Sort-Object -Property Name) if ($allSubscriptions.Count -lt 1) { throw [System.ApplicationException]::new("No Azure subscriptions found") return } if ($null -ne $SubscriptionIdsToProcess -and $SubscriptionIdsToProcess.Count -gt 0) { Write-HostOrOutput "Only the following $($SubscriptionIdsToProcess.Count) of all $($allSubscriptions.Count) Azure subscriptions will be processed (according to the specified filter):" foreach ($s in $SubscriptionIdsToProcess) { Write-HostOrOutput "$($tab)$s" } } else { Write-HostOrOutput "All Azure subscriptions will be processed" } # Filled during processing and reported at the end $usedResourceTypesWithoutHook = [System.Collections.ArrayList]@() $signedInIdentity = $null if ($useSystemIdentity) { Write-HostOrOutput "Getting system-managed identity of the automation account..." $signedInIdentity = Get-AzSystemAssignedIdentity -Scope $AutomationAccountResourceId } elseif ($null -ne $ServicePrincipalCredential) { Write-HostOrOutput "Getting signed-in service principal..." $signedInIdentity = Get-AzADServicePrincipal -ApplicationId (Get-AzContext).Account.Id } else { Write-HostOrOutput "Getting signed-in user identity..." $signedInIdentity = Get-AzADUser -SignedIn } Write-HostOrOutput "Identity Object ID: $($signedInIdentity.Id)" foreach ($sub in $allSubscriptions) { if ($null -ne $SubscriptionIdsToProcess -and $SubscriptionIdsToProcess.Count -gt 0 -and !$SubscriptionIdsToProcess.Contains($sub.Id)) { continue } Write-HostOrOutput "$([Environment]::NewLine)vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv" -ForegroundColor Cyan Write-HostOrOutput "Processing subscription '$($sub.Name)' ($($sub.Id))..." -ForegroundColor Cyan # get all resources in current subscription Select-AzSubscription -SubscriptionName $sub.Name -TenantId $DirectoryId -WhatIf:$false | Out-Null $tempRoleAssignment = $null if ($TryMakingUserContributorTemporarily) { if ($null -ne $signedInIdentity) { $subscriptionResourceId = "/subscriptions/$($sub.Id)" $roleAssignmentExists = @((Get-AzRoleAssignment -ObjectId $signedInIdentity.Id -Scope $subscriptionResourceId -RoleDefinitionName Contributor)).Count -gt 0 if (!$roleAssignmentExists -and $PSCmdlet.ShouldProcess($subscriptionResourceId, "Assign Contributor role")) { $tempRoleAssignment = New-AzRoleAssignment -ObjectId $signedInIdentity.Id -Scope $subscriptionResourceId -RoleDefinitionName Contributor ` -Description "Temporary permission to create tags on resources and delete empty resource groups" -ErrorAction SilentlyContinue if ($tempRoleAssignment) { Write-HostOrOutput "$($tab)$($WhatIfHint)Contributor role was temporarily assigned to the signed-in identity '$($ServicePrincipalCredential ? $signedInIdentity.ApplicationId : $signedInIdentity.UserPrincipalName)'" -ForegroundColor DarkGray } } } } $resources = [System.Collections.ArrayList]@() $query = "Resources" $skipToken = $null; $queryResult = $null; do { if ($null -eq $skipToken) { $queryResult = Search-AzGraph -Subscription $sub.Id -Query $query } else { $queryResult = Search-AzGraph -Subscription $sub.Id -Query $query -SkipToken $skipToken } $skipToken = $queryResult.SkipToken; $resources.AddRange($queryResult.Data) } while ($null -ne $skipToken) Write-HostOrOutput "$($tab)Number of resources to process: $($resources.Count)" if ($resources.Count -lt 1) { continue } $processedResourceGroups = [System.Collections.ArrayList]@() foreach ($resource in $resources) { Write-HostOrOutput "$($tab)Processing resource '" -NoNewline Write-HostOrOutput $($resource.name) -ForegroundColor White -NoNewline Write-HostOrOutput "' (type: $($resource.type), resource group: $($resource.resourceGroup))..." $resourceTypeName = $resource.type $resourceKindName = $resource.kind # process resource group $resourceGroupName = $resource.resourceGroup if (!$processedResourceGroups.Contains($resourceGroupName)) { $rg = Get-AzResourceGroup -Name $resourceGroupName $rgJustTagged = $false # Check for unused resource group was requested if ($CheckForUnusedResourceGroups) { # reset 'rejected' status to 'suspected' after specified time if specified, otherwise skip 'rejected' resource group $subjectForDeletionTagValue = '' $subjectForDeletionTagValue = ($rg.Tags.$subjectForDeletionTagName ?? '').Trim() $findingDateString = '' $findingDateString = ($rg.Tags.$subjectForDeletionFindingDateTagName ?? '').Trim() if ($subjectForDeletionTagValue -ieq [SubjectForDeletionStatus]::rejected.ToString()) { if ($EnableRegularResetOfRejectedState -and $findingDateString) { $findingDateTime = (Get-Date -AsUTC) if ([datetime]::TryParse($findingDateString, [ref]$findingDateTime)) { if ((Get-Date -AsUTC).Subtract($findingDateTime) -gt $ResetOfRejectedStatePeriodInDays) { Write-HostOrOutput "$($tab)$($tab)$($WhatIfHint)Resetting status from 'rejected' to 'suspected' after $ResetOfRejectedStatePeriodInDays days for resource group: $resourceGroupName..." Add-SubjectForDeletionTags -ResourceOrGroup $rg -Status suspected ` -AllowResetOfRejectedToSuspected -SuppressHostOutput -WhatIf:$WhatIfPreference } } } continue } # check for deployments in the specified number of last days and mark resource group for deletion if no deployments found $deployments = $rg | Get-AzResourceGroupDeployment | Sort-Object -Property Timestamp -Descending if ($deployments) { # determine whether newest deployment is too old $noRecentDeployments = $deployments[0].Timestamp -lt (Get-Date -AsUTC).AddDays(-$resourceGroupOldAfterDays) if ($noRecentDeployments) { # check activity log for relevant activity over the last 3 months (max.) $activityLogs = Get-AzActivityLog -ResourceGroupName $resourceGroupName -StartTime (Get-Date -AsUTC).AddDays(-90) -EndTime (Get-Date -AsUTC) $activelyUsed = $activityLogs | Where-Object { $_.Authorization.Action -imatch '^(?:(?!tags|roleAssignments).)*\/(write|action)$' } if (!$activelyUsed) { Write-HostOrOutput "$($tab)$($tab)$($WhatIfHint)Marking potentially unused resource group '$resourceGroupName' for deletion..." -ForegroundColor Yellow Add-SubjectForDeletionTags -ResourceOrGroup $rg -SuppressHostOutput -WhatIf:$WhatIfPreference ` -Reason "no deployments for $resourceGroupOldAfterDays days and no write/action activities for 3 months" $rgJustTagged = $true } } } } $processedResourceGroups.Add($resourceGroupName) | Out-Null if (!$rgJustTagged) { # Check whether existing tags from past runs shall be removed again from the resource group $tags = $rg.Tags $subjectForDeletionTagValue = ($tags.$subjectForDeletionTagName ?? '').Trim() if (![string]::IsNullOrWhiteSpace($subjectForDeletionTagValue)) { $taggedResources = Search-AzGraph -Query "resources | where subscriptionId =~ '$($sub.SubscriptionId)' and resourceGroup =~ '$($rg.ResourceGroupName)' and tags contains '$($subjectForDeletionTagName)'" if ($taggedResources.Count -eq 0) { Remove-SubjectForDeletionTags -ResourceOrGroup $rg -WhatIf:$WhatIfPreference } } } } # reset 'rejected' status to 'suspected' after specified time if specified, otherwise skip 'rejected' resource $subjectForDeletionTagValue = '' $subjectForDeletionTagValue = ($resource.Tags.$subjectForDeletionTagName ?? '').Trim() $findingDateString = '' $findingDateString = ($resource.Tags.$subjectForDeletionFindingDateTagName ?? '').Trim() if ($subjectForDeletionTagValue -ieq [SubjectForDeletionStatus]::rejected.ToString()) { if ($EnableRegularResetOfRejectedState -and $findingDateString) { $findingDateTime = (Get-Date -AsUTC) if ([datetime]::TryParse($findingDateString, [ref]$findingDateTime)) { if ((Get-Date -AsUTC).Subtract($findingDateTime) -gt $ResetOfRejectedStatePeriodInDays) { Write-HostOrOutput "$($tab)$($tab)$($WhatIfHint)Resetting status from 'rejected' to 'suspected' after $ResetOfRejectedStatePeriodInDays days for resource: $($resource.Name)..." Add-SubjectForDeletionTags -ResourceOrGroup $resource -Status suspected ` -AllowResetOfRejectedToSuspected -SuppressHostOutput -WhatIf:$WhatIfPreference } } } continue } # call resource type specific hook for testing unused characteristics of this resource $normalizedResourceTypeName = $resourceTypeName.Replace("/", "-").Replace(".", "-").ToLower() $normalizedResourceKindName = $normalizedResourceTypeName if (![String]::IsNullOrWhiteSpace($resourceKindName)) { $normalizedResourceKindName += "-$($resourceKindName.Replace("/", "-").Replace(".", "-").ToLower())" } $action = [ResourceAction]::none $reason = $null $hook = $null $hookFunctionName = "Test-ResourceActionHook-" + $normalizedResourceKindName $hook = (Get-Command $hookFunctionName -CommandType Function -ErrorAction SilentlyContinue).ScriptBlock if ($null -eq $hook) { if (![String]::IsNullOrWhiteSpace($resourceKindName)) { $usedResourceTypesWithoutHook.Add("Type: '$resourceTypeName', Kind: '$resourceKindName' (hook name: 'Test-ResourceActionHook-$normalizedResourceKindName')") | Out-Null } $hookFunctionName = "Test-ResourceActionHook-" + $normalizedResourceTypeName $hook = (Get-Command $hookFunctionName -CommandType Function -ErrorAction SilentlyContinue).ScriptBlock } if ($null -ne $hook) { # Delete timed-out suspected resources if ($DeleteSuspectedResourcesAndGroupsAfterDays -ge 0) { $status = ($resource.Tags.$subjectForDeletionTagName ?? '').Trim() $isResourceSuspected = $status -ieq [SubjectForDeletionStatus]::suspected.ToString() $isResourceDeletionRejected = $status -ieq [SubjectForDeletionStatus]::rejected.ToString() $lastEvalDateString = ($resource.Tags.$subjectForDeletionFindingDateTagName ?? '').Trim() if ($isResourceSuspected -and $lastEvalDateString) { $lastEvalDateTime = (Get-Date -AsUTC) if ([datetime]::TryParse($lastEvalDateString, [ref]$lastEvalDateTime)) { if ((Get-Date -AsUTC).Subtract($lastEvalDateTime) -gt [timespan]::FromDays($DeleteSuspectedResourcesAndGroupsAfterDays)) { Write-HostOrOutput "$($tab)$($tab)--> review deadline reached for this suspected resource" $lastUserConfirmationResult = Get-UserConfirmationWithTimeout ` -DisableTimeout:$DisableTimeoutForDeleteConfirmationPrompt ` -LastConfirmationResult $lastUserConfirmationResult if ($lastUserConfirmationResult -ne [UserConfirmationResult]::No) { Write-HostOrOutput "$($tab)$($tab)$($WhatIfHint)Resource is being deleted..." -ForegroundColor Red Remove-AzResource -ResourceId $resource.ResourceId -Force -AsJob -WhatIf:$WhatIfPreference | Out-Null continue # skip to next resource } else { Write-HostOrOutput "$($tab)$($tab)Deletion cancelled by user" } } } } elseif ($isResourceDeletionRejected) { # Remove reason and finding date tags Remove-SubjectForDeletionTags -ResourceOrGroup $resource -WhatIf:$WhatIfPreference | Out-Null } } # Only test resources which are existing long enough $hasMinimumAge = $true # if creation time cannot be determined we assume age to be older than 30 days if ($MinimumResourceAgeInDaysForChecking -gt 0) { $r = Get-AzResource -ResourceId $resource.id -ExpandProperties $createdTime = $r.Properties.CreationTime if ($null -ne $createdTime -and (Get-Date -AsUTC).Subtract($createdTime) -lt [timespan]::FromDays($MinimumResourceAgeInDaysForChecking)) { $hasMinimumAge = $false } } if ($hasMinimumAge) { # Execute test hook for current resource type $action, $reason = Invoke-Command -ScriptBlock $hook -ArgumentList $resource if ($action -eq [ResourceAction]::delete -and $AlwaysOnlyMarkForDeletion) { $action = [ResourceAction]::markForDeletion } } else { # Resource doesn't have the specified minimum age for checking Write-HostOrOutput "$($tab)$($tab)Resource has not reached the minimum age and is ignored" -ForegroundColor DarkGray $action = [ResourceAction]::none } Write-HostOrOutput "$($tab)$($tab)--> action: " -NoNewline $color = [ConsoleColor]::Gray switch ($action) { none { $color = [ConsoleColor]::Green } suspected { $color = [ConsoleColor]::DarkYellow } markForDeletion { $color = [ConsoleColor]::Yellow } markForSuspectSubResourceCheck { $color = [ConsoleColor]::Yellow } delete { $color = [ConsoleColor]::Red } Default {} } Write-HostOrOutput $action.ToString() -ForegroundColor $color -NoNewline if (![string]::IsNullOrWhiteSpace($reason)) { Write-HostOrOutput " (reason: '$reason')" } else { Write-HostOrOutput "" } } else { Write-HostOrOutput "$($tab)$($tab)--> no matching test hook for this type of resource" -ForegroundColor DarkGray $usedResourceTypesWithoutHook.Add("Type: '$resourceTypeName' (hook name: 'Test-ResourceActionHook-$normalizedResourceTypeName')") | Out-Null } # delete or mark resource accordingly switch ($action) { "markForDeletion" { Write-HostOrOutput "$($tab)$($tab)$($WhatIfHint)Marking resource for deletion..." Add-SubjectForDeletionTags -ResourceOrGroup $resource -Reason $reason -WhatIf:$WhatIfPreference } "markForSuspectSubResourceCheck" { Write-HostOrOutput "$($tab)$($tab)$($WhatIfHint)Marking resource for check of suspect sub resources..." Add-SubjectForDeletionTags -ResourceOrGroup $resource -Status suspectedSubResources -Reason $reason -WhatIf:$WhatIfPreference } "delete" { Write-HostOrOutput "$($tab)$($tab)$($WhatIfHint)Deleting resource..." $lastUserConfirmationResult = Get-UserConfirmationWithTimeout -DisableTimeout:$DisableTimeoutForDeleteConfirmationPrompt ` -LastConfirmationResult $lastUserConfirmationResult if ($lastUserConfirmationResult -ne [UserConfirmationResult]::No) { Remove-AzResource -ResourceId $resource.ResourceId -Force -AsJob -WhatIf:$WhatIfPreference | Out-Null } else { Write-HostOrOutput "$($tab)$($tab)Deletion rejected by user" } } default { $tags = $resource.Tags if ($tags.$subjectForDeletionTagName) { # previously tagged resource changed and is no subject for deletion anymore Remove-SubjectForDeletionTags -ResourceOrGroup $resource -WhatIf:$WhatIfPreference } } } } # Process resource groups Write-HostOrOutput "$($tab)Processing resource groups..." $resourceGroups = Get-AzResourceGroup foreach ($resourceGroup in $resourceGroups) { $rgname = $resourceGroup.ResourceGroupName # Delete suspected and timed-out resource groups if ($DeleteSuspectedResourcesAndGroupsAfterDays -ge 0) { $status = ($resourceGroup.Tags.$subjectForDeletionTagName ?? '').Trim() $isResourceGroupSuspected = $status -ieq [SubjectForDeletionStatus]::suspected.ToString() $isResourceGroupDeletionRejected = $status -ieq [SubjectForDeletionStatus]::rejected.ToString() $lastEvalDateString = ($resourceGroup.Tags.$subjectForDeletionFindingDateTagName ?? '').Trim() if ($isResourceGroupSuspected -and $lastEvalDateString) { $lastEvalDateTime = (Get-Date -AsUTC) if ([datetime]::TryParse($lastEvalDateString, [ref]$lastEvalDateTime)) { if ((Get-Date -AsUTC).Subtract($lastEvalDateTime) -gt [timespan]::FromDays($DeleteSuspectedResourcesAndGroupsAfterDays)) { Write-HostOrOutput "$($tab)$($tab)--> review deadline reached for this suspected resource group '$rgname'" $lastUserConfirmationResult = Get-UserConfirmationWithTimeout -DisableTimeout:$DisableTimeoutForDeleteConfirmationPrompt ` -LastConfirmationResult $lastUserConfirmationResult if ($lastUserConfirmationResult -ne [UserConfirmationResult]::No) { Write-HostOrOutput "$($tab)$($tab)$($WhatIfHint)Resource group is being deleted..." -ForegroundColor Red Remove-AzResourceGroup -Name $resourceGroup.ResourceGroupName -Force -AsJob -WhatIf:$WhatIfPreference | Out-Null continue # skip to next resource group } else { Write-HostOrOutput "$($tab)$($tab)Deletion cancelled by user" } } } } elseif ($isResourceGroupDeletionRejected) { # Remove reason and finding date tags Remove-SubjectForDeletionTags -ResourceOrGroup $resourceGroup -WhatIf:$WhatIfPreference | Out-Null } } # Process empty resource groups if (!$processedResourceGroups.Contains($rgname)) { # confirm that this resource group is really empty $resourceCount = (Get-AzResource -ResourceGroupName $rgname).Count if ($resourceCount -eq 0) { if ($AlwaysOnlyMarkForDeletion -or $DontDeleteEmptyResourceGroups) { Write-HostOrOutput "$($tab)$($tab)--> action: " -NoNewline Write-HostOrOutput ([ResourceAction]::markForDeletion).toString() -ForegroundColor Yellow Write-HostOrOutput "$($tab)$($tab)$($WhatIfHint)Marking empty resource group '$rgname' for deletion..." Add-SubjectForDeletionTags -ResourceOrGroup $resourceGroup -Reason "group is empty" -WhatIf:$WhatIfPreference } else { Write-HostOrOutput "$($tab)$($tab)--> action: " -NoNewline Write-HostOrOutput ([ResourceAction]::delete).ToString() -ForegroundColor Red Write-HostOrOutput "$($tab)$($tab)$($WhatIfHint)Deleting empty resource group '$rgname'..." $lastUserConfirmationResult = Get-UserConfirmationWithTimeout -DisableTimeout:$DisableTimeoutForDeleteConfirmationPrompt ` -LastConfirmationResult $lastUserConfirmationResult if ($lastUserConfirmationResult -ne [UserConfirmationResult]::No) { Remove-AzResourceGroup -Id $resourceGroup.ResourceId -Force -AsJob -WhatIf:$WhatIfPreference | Out-Null } else { Write-HostOrOutput "$($tab)$($tab)Deletion rejected by user" } } } } } Write-HostOrOutput "$($tab)$($tab)Done" if ($TryMakingUserContributorTemporarily -and $null -ne $tempRoleAssignment -and $PSCmdlet.ShouldProcess($tempRoleAssignment.Scope, "Remove Contributor role assignment")) { Remove-AzRoleAssignment -InputObject $tempRoleAssignment -ErrorAction SilentlyContinue | Out-Null Write-HostOrOutput "$($tab)$($WhatIfHint)Contributor role was removed again from the signed-in identity '$($ServicePrincipalCredential ? $signedInIdentity.ApplicationId : $signedInIdentity.UserPrincipalName)'" -ForegroundColor DarkGray } } # Wait for still running resource deletion jobs $runningJobs = Get-Job -State Running if ($runningJobs.Count -gt 0) { Write-HostOrOutput "$([Environment]::NewLine)Waiting for all background jobs to complete..." while ($runningJobs.Count -gt 0) { Write-HostOrOutput "$($runningJobs.Count) jobs still running..." -ForegroundColor DarkGray $jobs = Get-Job -State Completed $jobs | Receive-Job | Out-null $jobs | Remove-Job | Out-null $runningJobs = Get-Job -State Running Start-Sleep -Seconds 5 } } if ($usedResourceTypesWithoutHook.Count -gt 0) { Write-HostOrOutput "$([System.Environment]::NewLine)Discovered resource types without matching hook:" foreach ($resourceType in ($usedResourceTypesWithoutHook | Sort-Object -Unique)) { Write-HostOrOutput "$($tab)$resourceType" } } Write-HostOrOutput "$([System.Environment]::NewLine)Finished." -ForegroundColor Green