Add Remove-OldDiskSnapshot script for managing Azure managed disk snapshots

This commit is contained in:
Petr Štěpán
2026-08-10 16:09:57 +02:00
parent 486f9d9d94
commit 6641f30cc6
+256
View File
@@ -0,0 +1,256 @@
<#
.SYNOPSIS
Finds and removes Azure managed disk snapshots older than a specified number of days.
.DESCRIPTION
Connects to the current Azure context (via Az.Compute), enumerates managed disk
snapshots - either across one or more given subscriptions, or across every
subscription the signed-in account can see, optionally scoped to a single
resource group - and deletes any snapshot whose TimeCreated is older than the
given threshold. Each deletion is gated behind -WhatIf/-Confirm via
ShouldProcess. A single snapshot (or subscription) failing does not stop the
run; it is logged and the script continues with the rest.
.PARAMETER DaysOld
Snapshots with a TimeCreated older than this many days are deleted. Default: 30.
.PARAMETER ResourceGroupName
Optional. Limits the scan to snapshots in this resource group. If omitted, all
snapshots in each processed subscription are scanned.
.PARAMETER SubscriptionId
Optional. One or more Azure subscription IDs to operate against. If omitted,
every subscription visible to the current Az account (Get-AzSubscription) is
processed.
.PARAMETER LogDir
Directory for log output. Created if it does not exist. Defaults to the
directory this script lives in ($PSScriptRoot), so logs land next to the
script regardless of the caller's current working directory.
.PARAMETER LogEnabled
Enable or disable file logging. Default: $true.
.EXAMPLE
PS> .\Remove-OldDiskSnapshot.ps1 -DaysOld 60 -WhatIf
Shows which snapshots older than 60 days would be deleted across all visible
subscriptions, without deleting them.
.EXAMPLE
PS> .\Remove-OldDiskSnapshot.ps1 -DaysOld 30 -ResourceGroupName 'rg-vm-prod'
Deletes snapshots older than 30 days in the 'rg-vm-prod' resource group, in every
visible subscription that contains it.
.EXAMPLE
PS> .\Remove-OldDiskSnapshot.ps1 -DaysOld 30 -SubscriptionId '00000000-0000-0000-0000-000000000000','11111111-1111-1111-1111-111111111111'
Deletes snapshots older than 30 days across the two specified subscriptions.
.EXAMPLE
PS> .\Remove-OldDiskSnapshot.ps1 -DaysOld 90 -Confirm:$false
Use -Confirm:$false to run unattended and skip all prompts.
.NOTES
Author: Petr Stepan
Created: 2026-08-10
Version: 1.3.0
Requires: Az.Accounts, Az.Compute modules; an active Connect-AzAccount session.
Changelog:
1.3.0 - Default LogDir moved from the OS temp directory to the script's own
directory ($PSScriptRoot), so logs are easy to find regardless of the
caller's working directory.
1.2.0 - Key progress (current subscription, per-snapshot name/resource group/age,
deletion results, summary) is now printed to the console by default,
without requiring -Verbose. Per-snapshot deletion prompt now also shows
the snapshot's age in days. Interactive per-snapshot confirmation was
already provided by ShouldProcess/ConfirmImpact='High' and needs no
-Confirm:$false override to opt out of; documented for clarity.
1.1.0 - SubscriptionId is now an array (defaults to all visible subscriptions
when omitted); fixed -WhatIf leaking into log-file writes via
$WhatIfPreference inheritance on New-Item/Out-File.
1.0.0 - Initial version
#>
#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
[Parameter(Mandatory = $false, HelpMessage = 'Delete snapshots older than this many days.')]
[ValidateRange(1, 3650)]
[int]
$DaysOld = 30,
[Parameter(Mandatory = $false, HelpMessage = 'Resource group to scope the scan to. Omit to scan the whole subscription.')]
[ValidateNotNullOrEmpty()]
[string]
$ResourceGroupName,
[Parameter(Mandatory = $false, HelpMessage = 'Azure subscription ID(s) to operate against. Omit to process every visible subscription.')]
[ValidateNotNullOrEmpty()]
[string[]]
$SubscriptionId,
[Parameter(Mandatory = $false)]
[string]
$LogDir = $PSScriptRoot,
[Parameter(Mandatory = $false)]
[bool]
$LogEnabled = $true
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# --- Prepare log file -------------------------------------------------------
# -WhatIf:$false below is deliberate: New-Item/Out-File support ShouldProcess
# themselves, so they would otherwise silently inherit $WhatIfPreference from the
# script's own -WhatIf and skip writing the log - even though logging what would
# happen is exactly what -WhatIf is for.
if ($LogEnabled) {
if (-not (Test-Path -LiteralPath $LogDir)) {
New-Item -ItemType Directory -Path $LogDir -Force -WhatIf:$false | Out-Null
}
$LogFile = Join-Path $LogDir ("Remove-OldDiskSnapshot_{0}.log" -f (Get-Date -Format 'yyyyMMdd_HHmmss'))
}
function Write-Log {
<#
.SYNOPSIS
Writes a timestamped message to the log file and to Write-Verbose, optionally
also echoing it to the console (Write-Host) so key progress is visible even
without -Verbose.
.PARAMETER Message
The text to log.
.PARAMETER Level
Severity label prefixed to the log line (e.g. INFO, WARN, ERROR). Defaults to INFO.
.PARAMETER Console
When set, also writes the message to the console via Write-Host, regardless of
-Verbose. Used for the handful of lines a user running without -Verbose still
needs to see (current subscription, per-snapshot details, results).
#>
param(
[string]$Message,
[string]$Level = "INFO",
[switch]$Console
)
$Line = "{0} [{1}] {2}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
if ($LogEnabled) { $Line | Out-File -FilePath $LogFile -Append -Encoding UTF8 -WhatIf:$false }
Write-Verbose $Line
if ($Console) {
$Color = switch ($Level) {
'ERROR' { 'Red' }
'WARN' { 'Yellow' }
default { 'Gray' }
}
Write-Host $Message -ForegroundColor $Color
}
}
try {
# --- Verify prerequisites ------------------------------------------------
if (-not (Get-Module -ListAvailable -Name Az.Compute)) {
throw "Az.Compute module is not installed. Install it with: Install-Module Az.Compute -Scope CurrentUser -Force"
}
Import-Module Az.Compute -ErrorAction Stop
if (-not (Get-AzContext)) {
throw "No active Azure context. Run Connect-AzAccount first."
}
# Resolve the list of subscriptions to process: explicit -SubscriptionId, or
# every subscription the signed-in account can see.
if ($SubscriptionId) {
$TargetSubscriptions = $SubscriptionId
}
else {
$TargetSubscriptions = @(Get-AzSubscription -ErrorAction Stop | Select-Object -ExpandProperty Id)
if ($TargetSubscriptions.Count -eq 0) {
throw "No subscriptions visible to the current account."
}
}
Write-Log "=== Remove-OldDiskSnapshot started ==="
if ($LogEnabled) { Write-Host "Log file: $LogFile" -ForegroundColor Gray }
Write-Log "Subscriptions: $($TargetSubscriptions.Count) ($($TargetSubscriptions -join ', '))" -Console
Write-Log "DaysOld : $DaysOld" -Console
Write-Log "ResourceGroup: $(if ($ResourceGroupName) { $ResourceGroupName } else { '(all)' })" -Console
$Cutoff = (Get-Date).ToUniversalTime().AddDays(-$DaysOld)
$TotalScanned = 0
$TotalDeleted = 0
$TotalFailed = 0
foreach ($SubId in $TargetSubscriptions) {
try {
# -WhatIf:$false: switching context is read-only setup needed to query the
# right subscription, not the mutating action -WhatIf is meant to preview.
# Without it, -WhatIf on the script would stop Set-AzContext from actually
# switching, and every subscription would be scanned against the first one.
$Context = Set-AzContext -SubscriptionId $SubId -ErrorAction Stop -WhatIf:$false
}
catch {
$TotalFailed++
Write-Log "FAILED : could not set context to subscription '$SubId' - $($_.Exception.Message)" -Level "WARN" -Console
continue
}
Write-Log "--- Subscription: $($Context.Subscription.Name) ($SubId) ---" -Console
# --- Collect candidate snapshots --------------------------------------
$GetParams = @{ ErrorAction = 'Stop' }
if ($ResourceGroupName) { $GetParams['ResourceGroupName'] = $ResourceGroupName }
try {
$Snapshots = @(Get-AzSnapshot @GetParams | Where-Object { $_.TimeCreated -lt $Cutoff })
}
catch {
$TotalFailed++
Write-Log "FAILED : could not list snapshots in subscription '$SubId' - $($_.Exception.Message)" -Level "WARN" -Console
continue
}
if ($Snapshots.Count -eq 0) {
Write-Log "No snapshots older than $DaysOld days in this subscription." -Console
continue
}
Write-Log "Snapshots to delete: $($Snapshots.Count)" -Console
$TotalScanned += $Snapshots.Count
# --- Delete matching snapshots, one failure does not abort the run ----
foreach ($Snapshot in $Snapshots) {
$Target = "$SubId/$($Snapshot.ResourceGroupName)/$($Snapshot.Name)"
$AgeDays = [math]::Floor(((Get-Date).ToUniversalTime() - $Snapshot.TimeCreated).TotalDays)
Write-Log " - $($Snapshot.Name) | RG: $($Snapshot.ResourceGroupName) | Age: $AgeDays days (created $($Snapshot.TimeCreated))" -Console
if ($PSCmdlet.ShouldProcess($Target, "Delete snapshot (created $($Snapshot.TimeCreated), $AgeDays days old)")) {
try {
Remove-AzSnapshot -ResourceGroupName $Snapshot.ResourceGroupName -SnapshotName $Snapshot.Name -Force -ErrorAction Stop | Out-Null
$TotalDeleted++
Write-Log "DELETED: $Target (created $($Snapshot.TimeCreated))" -Console
}
catch {
$TotalFailed++
Write-Log "FAILED : $Target - $($_.Exception.Message)" -Level "WARN" -Console
}
}
}
}
$Summary = "Completed - Deleted: $TotalDeleted | Failed: $TotalFailed | Scanned: $TotalScanned"
Write-Log $Summary -Console
Write-Log "=== Remove-OldDiskSnapshot finished ==="
if ($LogEnabled) { Write-Host "Log: $LogFile" -ForegroundColor Gray }
if ($TotalFailed -gt 0) { exit 1 }
exit 0
}
catch {
Write-Log "Script failed: $($_.Exception.Message)" -Level "ERROR"
exit 1
}