Add Remove-OldDiskSnapshot script for managing Azure managed disk snapshots
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user