mirror of
https://dev.azure.com/effectory/Survey%20Software/_git/Cloud%20Engineering
synced 2026-02-27 18:52:18 +01:00
updated keyvaults rbac column added scripts to detected unused resources Related work items: #103196
1344 lines
68 KiB
PowerShell
1344 lines
68 KiB
PowerShell
<#
|
|
.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-<resourceType>[-<resourceKind>]". 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, <Any> = 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
|