diff --git a/Daily-SnapshotBackup.ps1 b/Daily-SnapshotBackup.ps1 new file mode 100644 index 0000000..b8ce8cb --- /dev/null +++ b/Daily-SnapshotBackup.ps1 @@ -0,0 +1,488 @@ +# Daily VM Snapshot Backup Script - Checkpoint-based Incremental Backup +# Run Daily at 3am +# Location: Hyper-V Server +# Creates daily checkpoints and backs up only the differencing data + +param( + [Parameter(ParameterSetName='SingleVM', Mandatory=$true)] + [string]$VMName, + + [Parameter(ParameterSetName='AllVMs', Mandatory=$true)] + [switch]$AllVMs, + + [Parameter(ParameterSetName='AllVMs')] + [string[]]$ExcludeVMs = @(), + + [int]$MaxSnapshots = 7, # Keep 7 days of snapshots + + [switch]$BackupSnapshotFiles, # Whether to also backup snapshot files to NAS + + [string]$SnapshotPrefix = "Daily" +) + +# Configuration - Modify these paths as needed +$NASBackupPath = "\\nas5362\Backups\Daily" +$LocalSnapshotBackupPath = "F:\Backups\Daily" # For snapshot file backups +$SevenZipPath = "C:\Program Files\7-Zip\7z.exe" + +# Configure logging +$LogPath = "C:\Scripts\Logs\Daily_Snapshot_$(Get-Date -Format 'yyyyMM').log" +$null = New-Item -ItemType Directory -Path (Split-Path $LogPath -Parent) -Force -ErrorAction SilentlyContinue + +function Write-Log { + param([string]$Message, [string]$Level = "INFO") + $TimeStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $LogEntry = "[$TimeStamp] [$Level] $Message" + Write-Host $LogEntry + Add-Content -Path $LogPath -Value $LogEntry +} + +function Test-NetworkPath { + param([string]$Path) + try { + if (-not (Test-Path $Path -ErrorAction Stop)) { + Write-Log "Network path does not exist: $Path" "ERROR" + return $false + } + + $TestFile = Join-Path $Path "test_write_$(Get-Date -Format 'yyyyMMddHHmmss').tmp" + try { + $null = New-Item -ItemType File -Path $TestFile -Force -ErrorAction Stop + Remove-Item -Path $TestFile -Force -ErrorAction SilentlyContinue + Write-Log "Network path is accessible and writable: $Path" + return $true + } + catch { + Write-Log "Network path exists but is not writable: $Path - $_" "ERROR" + return $false + } + } + catch { + Write-Log "Network path test failed: $Path - $_" "ERROR" + return $false + } +} + +function Get-VMSnapshotFiles { + param([string]$VMName) + + try { + $VM = Get-VM -Name $VMName + $CheckpointFiles = @() + + # Get all checkpoints for this VM + $Checkpoints = Get-VMCheckpoint -VMName $VMName | Sort-Object CreationTime -Descending + + foreach ($Checkpoint in $Checkpoints) { + # Get AVHDX files (differencing disks) for this checkpoint + $CheckpointVHDs = Get-VMCheckpoint -VMName $VMName -Name $Checkpoint.Name | Get-VMHardDiskDrive + + foreach ($VHD in $CheckpointVHDs) { + if ($VHD.Path -match "\.avhdx$") { + $CheckpointFiles += [PSCustomObject]@{ + CheckpointName = $Checkpoint.Name + CheckpointId = $Checkpoint.Id + CreationTime = $Checkpoint.CreationTime + FilePath = $VHD.Path + FileType = "AVHDX" + } + } + } + + # Also get the checkpoint configuration files + $VMPath = Split-Path $VM.ConfigurationLocation -Parent + $CheckpointConfigPath = Join-Path $VMPath "Snapshots\$($Checkpoint.Id.Guid).vmrs" + if (Test-Path $CheckpointConfigPath) { + $CheckpointFiles += [PSCustomObject]@{ + CheckpointName = $Checkpoint.Name + CheckpointId = $Checkpoint.Id + CreationTime = $Checkpoint.CreationTime + FilePath = $CheckpointConfigPath + FileType = "VMRS" + } + } + } + + return $CheckpointFiles + } + catch { + Write-Log "Error getting snapshot files for $VMName : $_" "ERROR" + return @() + } +} + +function Backup-SnapshotFiles { + param( + [string]$VMName, + [array]$SnapshotFiles, + [string]$BackupPath + ) + + try { + Write-Log "Backing up snapshot files for $VMName..." + + # Create backup directory + $VMBackupPath = Join-Path $BackupPath $VMName + $DateBackupPath = Join-Path $VMBackupPath (Get-Date -Format 'yyyyMMdd') + $null = New-Item -ItemType Directory -Path $DateBackupPath -Force + + $TotalSize = 0 + $BackedUpFiles = 0 + + foreach ($File in $SnapshotFiles) { + try { + if (Test-Path $File.FilePath) { + $FileName = Split-Path $File.FilePath -Leaf + $BackupFilePath = Join-Path $DateBackupPath $FileName + + # Only backup if file doesn't exist or is newer + $ShouldCopy = $true + if (Test-Path $BackupFilePath) { + $SourceDate = (Get-Item $File.FilePath).LastWriteTime + $BackupDate = (Get-Item $BackupFilePath).LastWriteTime + $ShouldCopy = $SourceDate -gt $BackupDate + } + + if ($ShouldCopy) { + Copy-Item -Path $File.FilePath -Destination $BackupFilePath -Force + $FileSize = (Get-Item $File.FilePath).Length + $TotalSize += $FileSize + $BackedUpFiles++ + + $FileSizeMB = [math]::Round($FileSize / 1MB, 2) + Write-Log "Backed up $FileName ($FileSizeMB MB)" + } + } + } + catch { + Write-Log "Failed to backup $($File.FilePath): $_" "ERROR" + } + } + + $TotalSizeGB = [math]::Round($TotalSize / 1GB, 2) + Write-Log "Snapshot file backup completed for $VMName. Files: $BackedUpFiles, Size: $TotalSizeGB GB" + + return $true + } + catch { + Write-Log "Error backing up snapshot files for $VMName : $_" "ERROR" + return $false + } +} + +function Cleanup-OldSnapshots { + param( + [string]$VMName, + [int]$MaxSnapshots, + [string]$SnapshotPrefix + ) + + try { + Write-Log "Cleaning up old snapshots for $VMName (keeping $MaxSnapshots)..." + + # Get all snapshots with our prefix, sorted by creation time + $Snapshots = Get-VMCheckpoint -VMName $VMName | + Where-Object { $_.Name -like "$SnapshotPrefix*" } | + Sort-Object CreationTime -Descending + + if ($Snapshots.Count -gt $MaxSnapshots) { + $ToDelete = $Snapshots | Select-Object -Skip $MaxSnapshots + + foreach ($Snapshot in $ToDelete) { + Write-Log "Removing old snapshot: $($Snapshot.Name) (Created: $($Snapshot.CreationTime))" + Remove-VMCheckpoint -VMName $VMName -Name $Snapshot.Name -IncludeAllChildCheckpoints + } + + Write-Log "Removed $($ToDelete.Count) old snapshots for $VMName" + } + else { + Write-Log "No cleanup needed for $VMName (has $($Snapshots.Count) snapshots)" + } + } + catch { + Write-Log "Error cleaning up snapshots for $VMName : $_" "ERROR" + } +} + +function Cleanup-OldSnapshotBackups { + param( + [string]$BackupPath, + [string]$VMName, + [int]$MaxBackups + ) + + try { + $VMBackupPath = Join-Path $BackupPath $VMName + if (-not (Test-Path $VMBackupPath)) { + return + } + + # Get backup folders (by date) + $BackupFolders = Get-ChildItem -Path $VMBackupPath -Directory | + Where-Object { $_.Name -match '^\d{8}$' } | + Sort-Object Name -Descending + + if ($BackupFolders.Count -gt $MaxBackups) { + $ToDelete = $BackupFolders | Select-Object -Skip $MaxBackups + + foreach ($Folder in $ToDelete) { + Write-Log "Removing old snapshot backup folder: $($Folder.FullName)" + Remove-Item -Path $Folder.FullName -Recurse -Force + } + + Write-Log "Removed $($ToDelete.Count) old snapshot backup folders for $VMName" + } + } + catch { + Write-Log "Error cleaning up old snapshot backups for $VMName : $_" "ERROR" + } +} + +function Create-DailySnapshot { + param( + [string]$VMName, + [int]$MaxSnapshots, + [string]$SnapshotPrefix + ) + + try { + Write-Log "=== Creating daily snapshot for VM: $VMName ===" + + # Verify VM exists and is running + $VM = Get-VM -Name $VMName -ErrorAction Stop + Write-Log "VM found: $($VM.Name) - State: $($VM.State)" + + if ($VM.State -ne 'Running') { + Write-Log "VM is not running, skipping snapshot creation" "WARNING" + return $false + } + + # Create snapshot name with timestamp + $SnapshotName = "$SnapshotPrefix_$(Get-Date -Format 'yyyyMMdd_HHmm')" + + Write-Log "Creating snapshot: $SnapshotName" + $StartTime = Get-Date + + # Create the checkpoint + Checkpoint-VM -Name $VMName -SnapshotName $SnapshotName + + $EndTime = Get-Date + $Duration = ($EndTime - $StartTime).TotalSeconds + Write-Log "Snapshot created successfully in $Duration seconds" + + # Get the new snapshot info + $NewSnapshot = Get-VMCheckpoint -VMName $VMName -Name $SnapshotName + if ($NewSnapshot) { + Write-Log "Snapshot ID: $($NewSnapshot.Id)" + Write-Log "Snapshot Path: $($NewSnapshot.Path)" + + # Get size of new differencing files + $SnapshotFiles = Get-VMSnapshotFiles -VMName $VMName | Where-Object { $_.CheckpointName -eq $SnapshotName } + $TotalSnapshotSize = 0 + + foreach ($File in $SnapshotFiles) { + if (Test-Path $File.FilePath) { + $FileSize = (Get-Item $File.FilePath).Length + $TotalSnapshotSize += $FileSize + } + } + + $SnapshotSizeMB = [math]::Round($TotalSnapshotSize / 1MB, 2) + Write-Log "New snapshot differencing files size: $SnapshotSizeMB MB" + } + + # Clean up old snapshots + Cleanup-OldSnapshots -VMName $VMName -MaxSnapshots $MaxSnapshots -SnapshotPrefix $SnapshotPrefix + + Write-Log "=== Daily snapshot completed successfully for $VMName ===" + return $true + } + catch { + Write-Log "=== Snapshot creation failed for $VMName : $_ ===" "ERROR" + return $false + } +} + +function Process-SingleVM { + param( + [string]$VMName, + [int]$MaxSnapshots, + [string]$SnapshotPrefix, + [bool]$BackupFiles + ) + + $Success = $false + + try { + # Create daily snapshot + $SnapshotSuccess = Create-DailySnapshot -VMName $VMName -MaxSnapshots $MaxSnapshots -SnapshotPrefix $SnapshotPrefix + + if ($SnapshotSuccess -and $BackupFiles) { + # Backup snapshot files to NAS/Local storage + $SnapshotFiles = Get-VMSnapshotFiles -VMName $VMName + + if ($SnapshotFiles.Count -gt 0) { + # Backup to local storage + if (Test-Path $LocalSnapshotBackupPath) { + $LocalBackupSuccess = Backup-SnapshotFiles -VMName $VMName -SnapshotFiles $SnapshotFiles -BackupPath $LocalSnapshotBackupPath + } + + # Backup to NAS if accessible + if (Test-NetworkPath $NASBackupPath) { + $NASBackupSuccess = Backup-SnapshotFiles -VMName $VMName -SnapshotFiles $SnapshotFiles -BackupPath $NASBackupPath + + # Cleanup old NAS backups + Cleanup-OldSnapshotBackups -BackupPath $NASBackupPath -VMName $VMName -MaxBackups $MaxSnapshots + } + + # Cleanup old local backups + if (Test-Path $LocalSnapshotBackupPath) { + Cleanup-OldSnapshotBackups -BackupPath $LocalSnapshotBackupPath -VMName $VMName -MaxBackups $MaxSnapshots + } + } + } + + $Success = $SnapshotSuccess + } + catch { + Write-Log "Error processing VM $VMName : $_" "ERROR" + } + + return $Success +} + +# Main execution +try { + Write-Log "Starting daily snapshot backup process" + + # Create directories if they don't exist + if ($BackupSnapshotFiles) { + $null = New-Item -ItemType Directory -Path $LocalSnapshotBackupPath -Force -ErrorAction SilentlyContinue + + if (-not (Test-NetworkPath $NASBackupPath)) { + Write-Log "NAS path not accessible - will only backup locally" "WARNING" + } + } + + # Determine which VMs to process + $VMsToProcess = @() + + if ($AllVMs) { + Write-Log "Processing snapshots for all VMs (excluding specified VMs)" + $AllVirtualMachines = Get-VM | Where-Object { $_.Name -notin $ExcludeVMs } + $VMsToProcess = $AllVirtualMachines.Name + + if ($ExcludeVMs.Count -gt 0) { + Write-Log "Excluded VMs: $($ExcludeVMs -join ', ')" + } + Write-Log "VMs to process: $($VMsToProcess -join ', ')" + } + else { + $VMsToProcess = @($VMName) + Write-Log "Processing single VM: $VMName" + } + + if ($VMsToProcess.Count -eq 0) { + throw "No VMs found to process" + } + + # Process each VM + $SuccessCount = 0 + $FailureCount = 0 + $Results = @() + + foreach ($VM in $VMsToProcess) { + $StartTime = Get-Date + $Success = Process-SingleVM -VMName $VM -MaxSnapshots $MaxSnapshots -SnapshotPrefix $SnapshotPrefix -BackupFiles $BackupSnapshotFiles.IsPresent + $EndTime = Get-Date + $Duration = ($EndTime - $StartTime).TotalMinutes + + $Results += [PSCustomObject]@{ + VMName = $VM + Success = $Success + Duration = [math]::Round($Duration, 1) + } + + if ($Success) { + $SuccessCount++ + } else { + $FailureCount++ + } + } + + # Final summary + Write-Log "=== DAILY SNAPSHOT SUMMARY ===" + Write-Log "Total VMs processed: $($VMsToProcess.Count)" + Write-Log "Successful snapshots: $SuccessCount" + Write-Log "Failed snapshots: $FailureCount" + Write-Log "Snapshot file backup: $(if ($BackupSnapshotFiles) { 'ENABLED' } else { 'DISABLED' })" + + foreach ($Result in $Results) { + $Status = if ($Result.Success) { "SUCCESS" } else { "FAILED" } + Write-Log "$($Result.VMName): $Status ($($Result.Duration) minutes)" + } + + # Show current snapshot status for all VMs + Write-Log "=== CURRENT SNAPSHOT STATUS ===" + foreach ($VM in $VMsToProcess) { + try { + $Snapshots = Get-VMCheckpoint -VMName $VM | Where-Object { $_.Name -like "$SnapshotPrefix*" } | Sort-Object CreationTime -Descending + Write-Log "$VM : $($Snapshots.Count) $SnapshotPrefix snapshots" + + # Show total size of snapshot files + $SnapshotFiles = Get-VMSnapshotFiles -VMName $VM + $TotalSize = 0 + foreach ($File in $SnapshotFiles) { + if (Test-Path $File.FilePath) { + $TotalSize += (Get-Item $File.FilePath).Length + } + } + $TotalSizeGB = [math]::Round($TotalSize / 1GB, 2) + Write-Log "$VM : Total snapshot storage: $TotalSizeGB GB" + } + catch { + Write-Log "$VM : Could not get snapshot status" "WARNING" + } + } + + if ($FailureCount -gt 0) { + Write-Log "Daily snapshot process completed with $FailureCount failure(s)" "WARNING" + exit 1 + } else { + Write-Log "Daily snapshot process completed successfully" "SUCCESS" + } +} +catch { + Write-Log "Daily snapshot process failed: $_" "ERROR" + exit 1 +} + +# Example usage: +# Single VM with snapshots only: +# .\Daily-SnapshotBackup.ps1 -VMName "MyVM" -MaxSnapshots 7 +# +# Single VM with snapshot file backup: +# .\Daily-SnapshotBackup.ps1 -VMName "MyVM" -MaxSnapshots 7 -BackupSnapshotFiles +# +# All VMs with snapshots only: +# .\Daily-SnapshotBackup.ps1 -AllVMs -MaxSnapshots 7 +# +# All VMs with snapshot file backup: +# .\Daily-SnapshotBackup.ps1 -AllVMs -MaxSnapshots 7 -BackupSnapshotFiles +# +# All VMs except specific ones: +# .\Daily-SnapshotBackup.ps1 -AllVMs -ExcludeVMs @("TestVM", "TempVM") -MaxSnapshots 7 -BackupSnapshotFiles + +# Daily snapshot workflow: +# 1. Create daily checkpoint/snapshot for each VM +# 2. Optionally backup the differencing files (.avhdx) and config files (.vmrs) to local/NAS storage +# 3. Automatically cleanup old snapshots (keeps specified number) +# 4. Cleanup old snapshot file backups +# 5. Report on current snapshot storage usage +# +# Benefits: +# - Near-instant snapshot creation (seconds, not minutes/hours) +# - Minimal storage overhead (only stores changes since last snapshot) +# - No VM downtime at all +# - Can restore to any point in the last N days quickly +# - Differencing files are much smaller than full backups \ No newline at end of file