From 6641f30cc630fd2bc9d1872893963a14b2e14c59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0t=C4=9Bp=C3=A1n?= Date: Mon, 10 Aug 2026 16:09:57 +0200 Subject: [PATCH] Add Remove-OldDiskSnapshot script for managing Azure managed disk snapshots --- VirtualMachine/Remove-OldDiskSnapshot.ps1 | 256 ++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 VirtualMachine/Remove-OldDiskSnapshot.ps1 diff --git a/VirtualMachine/Remove-OldDiskSnapshot.ps1 b/VirtualMachine/Remove-OldDiskSnapshot.ps1 new file mode 100644 index 0000000..6790cd7 --- /dev/null +++ b/VirtualMachine/Remove-OldDiskSnapshot.ps1 @@ -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 +}