<# .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 }