257 lines
10 KiB
PowerShell
257 lines
10 KiB
PowerShell
<#
|
|
.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
|
|
}
|