Files
hyperv-backup-scripts/monthly_vmbackup.ps1

621 lines
22 KiB
PowerShell

# Monthly VM Backup Script - Full Shutdown Export with 7-Zip Compression
# Run Monthly on Saturday nights at 11pm
# Location: Hyper-V Server
param(
[Parameter(ParameterSetName='SingleVM', Mandatory=$true)]
[string]$VMName,
[Parameter(ParameterSetName='AllVMs', Mandatory=$true)]
[switch]$AllVMs,
[Parameter(ParameterSetName='AllVMs')]
[string[]]$ExcludeVMs = @(),
[int]$MaxBackups = 3
)
# Configuration - Modify these paths as needed
$NASBackupPath = "\\nas5362\Backups"
$TempBackupPath = "F:\TempBackup"
$LocalBackupPath = "F:\Backups"
$SevenZipPath = "C:\Program Files\7-Zip\7z.exe"
# Configure logging
$LogPath = "C:\Scripts\Logs\Monthly_Backup_$(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 Test-Prerequisites {
# Test 7-Zip installation
if (-not (Test-Path $SevenZipPath)) {
Write-Log "7-Zip not found at $SevenZipPath" "ERROR"
Write-Log "Please install 7-Zip or update the SevenZipPath variable" "ERROR"
return $false
}
# Test temp backup directory
try {
$null = New-Item -ItemType Directory -Path $TempBackupPath -Force -ErrorAction Stop
Write-Log "Temp backup directory ready: $TempBackupPath"
}
catch {
Write-Log "Cannot create temp backup directory: $TempBackupPath - $_" "ERROR"
return $false
}
# Test local backup directory
try {
$null = New-Item -ItemType Directory -Path $LocalBackupPath -Force -ErrorAction Stop
Write-Log "Local backup directory ready: $LocalBackupPath"
}
catch {
Write-Log "Cannot create local backup directory: $LocalBackupPath - $_" "ERROR"
return $false
}
# Test NAS connectivity (but don't fail if it's temporarily unavailable)
if (-not (Test-NetworkPath $NASBackupPath)) {
Write-Log "NAS connectivity test failed - will attempt copy later" "WARNING"
}
return $true
}
function Compress-BackupWithSevenZip {
param(
[string]$SourcePath,
[string]$ArchivePath,
[string]$VMName
)
try {
Write-Log "Starting 7-Zip compression for $VMName..."
Write-Log "Source: $SourcePath"
Write-Log "Archive: $ArchivePath"
# Ensure destination directory exists
$ArchiveDir = Split-Path $ArchivePath -Parent
$null = New-Item -ItemType Directory -Path $ArchiveDir -Force -ErrorAction SilentlyContinue
# 7-Zip command: a = add, -t7z = 7z format, -mx=9 = maximum compression, -mmt = multithreaded
$Arguments = @(
'a',
'-t7z',
'-mx=9',
'-mmt',
"`"$ArchivePath`"",
"`"$SourcePath\*`""
)
Write-Log "7-Zip command: `"$SevenZipPath`" $($Arguments -join ' ')"
$StartTime = Get-Date
$Process = Start-Process -FilePath $SevenZipPath -ArgumentList $Arguments -Wait -PassThru -NoNewWindow
$EndTime = Get-Date
$CompressionTime = ($EndTime - $StartTime).TotalMinutes
Write-Log "7-Zip compression completed in $([math]::Round($CompressionTime, 1)) minutes"
if ($Process.ExitCode -eq 0) {
# Get compression statistics
if (Test-Path $ArchivePath) {
$OriginalSize = (Get-ChildItem -Path $SourcePath -Recurse -File | Measure-Object -Property Length -Sum).Sum
$CompressedSize = (Get-Item $ArchivePath).Length
$CompressionRatio = [math]::Round((1 - ($CompressedSize / $OriginalSize)) * 100, 1)
$OriginalSizeGB = [math]::Round($OriginalSize / 1GB, 2)
$CompressedSizeGB = [math]::Round($CompressedSize / 1GB, 2)
Write-Log "Compression successful for $VMName"
Write-Log "Original size: $OriginalSizeGB GB"
Write-Log "Compressed size: $CompressedSizeGB GB"
Write-Log "Compression ratio: $CompressionRatio%"
return $true
}
else {
Write-Log "7-Zip reported success but archive file not found" "ERROR"
return $false
}
}
else {
Write-Log "7-Zip compression failed with exit code: $($Process.ExitCode)" "ERROR"
return $false
}
}
catch {
Write-Log "Error during 7-Zip compression: $_" "ERROR"
return $false
}
}
function Move-ToLocalBackup {
param(
[string]$SourceFile,
[string]$DestinationPath,
[string]$VMName
)
try {
Write-Log "Moving compressed backup to local backup folder for $VMName..."
# Ensure destination directory exists
$null = New-Item -ItemType Directory -Path $DestinationPath -Force -ErrorAction SilentlyContinue
$DestinationFile = Join-Path $DestinationPath (Split-Path $SourceFile -Leaf)
# Move the file (much faster than copy since it's on the same drive)
Move-Item -Path $SourceFile -Destination $DestinationFile -Force
Write-Log "Successfully moved backup to local folder for $VMName"
return $DestinationFile
}
catch {
Write-Log "Error moving to local backup folder: $_" "ERROR"
return $null
}
}
function Copy-AllBackupsToNAS {
param(
[string]$LocalPath,
[string]$NASPath,
[array]$BackupFiles
)
try {
Write-Log "=== Starting bulk copy of all backups to NAS ==="
Write-Log "Copying $($BackupFiles.Count) backup file(s) to NAS..."
# Test NAS connectivity before attempting bulk copy
if (-not (Test-NetworkPath $NASPath)) {
throw "NAS is not accessible for bulk copy"
}
$SuccessfulCopies = 0
$FailedCopies = 0
$TotalSize = 0
foreach ($BackupFile in $BackupFiles) {
try {
Write-Log "Copying: $(Split-Path $BackupFile -Leaf)"
# Use robocopy for reliable network transfer
$RobocopyArgs = @(
"`"$(Split-Path $BackupFile -Parent)`"",
"`"$NASPath`"",
"`"$(Split-Path $BackupFile -Leaf)`"",
"/Z", # Copy files in restartable mode
"/R:3", # Retry 3 times on failed copies
"/W:30", # Wait 30 seconds between retries
"/TBD", # Wait for share names to be defined
"/NP" # No progress display
)
$StartTime = Get-Date
$Process = Start-Process -FilePath "robocopy.exe" -ArgumentList $RobocopyArgs -Wait -PassThru -NoNewWindow
$EndTime = Get-Date
$CopyTime = ($EndTime - $StartTime).TotalSeconds
# Robocopy exit codes: 0-3 are success
if ($Process.ExitCode -le 3) {
$FileSize = (Get-Item $BackupFile).Length
$TotalSize += $FileSize
$FileSizeMB = [math]::Round($FileSize / 1MB, 1)
Write-Log "Successfully copied $(Split-Path $BackupFile -Leaf) ($FileSizeMB MB) in $CopyTime seconds"
$SuccessfulCopies++
}
else {
Write-Log "Failed to copy $(Split-Path $BackupFile -Leaf). Robocopy exit code: $($Process.ExitCode)" "ERROR"
$FailedCopies++
}
}
catch {
Write-Log "Error copying $(Split-Path $BackupFile -Leaf): $_" "ERROR"
$FailedCopies++
}
}
$TotalSizeGB = [math]::Round($TotalSize / 1GB, 2)
Write-Log "=== Bulk copy completed ==="
Write-Log "Successful copies: $SuccessfulCopies"
Write-Log "Failed copies: $FailedCopies"
Write-Log "Total data copied: $TotalSizeGB GB"
return $FailedCopies -eq 0
}
catch {
Write-Log "Error during bulk copy to NAS: $_" "ERROR"
return $false
}
}
function Cleanup-TempFiles {
param(
[string]$TempVMPath,
[string]$TempArchive,
[string]$VMName
)
try {
Write-Log "Cleaning up temporary files for $VMName..."
# Remove temp VM export folder
if ($TempVMPath -and (Test-Path $TempVMPath)) {
Remove-Item -Path $TempVMPath -Recurse -Force
Write-Log "Removed temp VM folder: $TempVMPath"
}
# Remove temp archive
if ($TempArchive -and (Test-Path $TempArchive)) {
Remove-Item -Path $TempArchive -Force
Write-Log "Removed temp archive: $TempArchive"
}
}
catch {
Write-Log "Warning: Could not cleanup some temp files for $VMName : $_" "WARNING"
}
}
function Cleanup-OldBackups {
param([string]$Path, [string]$Pattern, [int]$Keep)
try {
# Look for 7z archives instead of folders
$BackupFiles = Get-ChildItem -Path $Path -File -Filter "*.7z" |
Where-Object { $_.Name -match $Pattern } |
Sort-Object CreationTime -Descending
if ($BackupFiles.Count -gt $Keep) {
$ToDelete = $BackupFiles | Select-Object -Skip $Keep
foreach ($File in $ToDelete) {
Write-Log "Deleting old backup: $($File.FullName)"
Remove-Item -Path $File.FullName -Force
}
}
$KeptCount = if ($BackupFiles.Count -lt $Keep) { $BackupFiles.Count } else { $Keep }
Write-Log "Cleanup completed. Kept $KeptCount backups"
}
catch {
Write-Log "Error during cleanup: $_" "ERROR"
throw
}
}
function Backup-SingleVM {
param(
[string]$VMName,
[int]$MaxBackups
)
$VMBackupSuccess = $false
$OriginalState = $null
$TempVMFolder = $null
$TempArchive = $null
$LocalBackupFile = $null
try {
Write-Log "=== Starting backup for VM: $VMName ==="
# Verify VM exists
$VM = Get-VM -Name $VMName -ErrorAction Stop
Write-Log "VM found: $($VM.Name) - State: $($VM.State)"
# Create temp folders for this VM
$VMTempFolder = Join-Path $TempBackupPath $VMName
$TempVMFolder = Join-Path $VMTempFolder "Export"
$TempArchive = Join-Path $VMTempFolder "Monthly_$($VMName)_$(Get-Date -Format 'yyyyMMdd_HHmm').7z"
# Ensure temp directories exist
$null = New-Item -ItemType Directory -Path $TempVMFolder -Force
Write-Log "Created temp export folder: $TempVMFolder"
# Store original VM state
$OriginalState = $VM.State
Write-Log "Original VM state: $OriginalState"
# Shutdown VM if running
if ($VM.State -eq 'Running') {
Write-Log "Shutting down VM..."
Stop-VM -Name $VMName -Force
# Wait for shutdown with timeout
$TimeoutSeconds = 300
$Timer = 0
do {
Start-Sleep -Seconds 5
$Timer += 5
$VM = Get-VM -Name $VMName
} while ($VM.State -ne 'Off' -and $Timer -lt $TimeoutSeconds)
if ($VM.State -ne 'Off') {
throw "VM failed to shutdown within $TimeoutSeconds seconds"
}
Write-Log "VM successfully shut down"
}
# Export VM to temp folder
Write-Log "Starting VM export to temp folder..."
$ExportJob = Export-VM -Name $VMName -Path $TempVMFolder -AsJob
# Monitor export progress
do {
Start-Sleep -Seconds 30
$Progress = Get-Job -Id $ExportJob.Id
Write-Log "Export status: $($Progress.State)"
} while ($Progress.State -eq 'Running')
# Check export result
$ExportResult = Receive-Job -Job $ExportJob
Remove-Job -Job $ExportJob
if ($Progress.State -eq 'Completed') {
# Verify the export actually succeeded by checking for VM files
$VMExportFolder = Get-ChildItem -Path $TempVMFolder -Directory -ErrorAction SilentlyContinue | Select-Object -First 1
if ($VMExportFolder) {
$VHDXFiles = Get-ChildItem -Path $VMExportFolder.FullName -Recurse -Filter "*.vhdx" -ErrorAction SilentlyContinue
$VMCXFiles = Get-ChildItem -Path $VMExportFolder.FullName -Recurse -Filter "*.vmcx" -ErrorAction SilentlyContinue
if ($VHDXFiles.Count -eq 0 -and $VMCXFiles.Count -eq 0) {
throw "Export job completed but no VM files were found in temp folder"
}
Write-Log "VM export to temp folder completed successfully"
Write-Log "Found $($VHDXFiles.Count) VHDX file(s) and $($VMCXFiles.Count) VMCX file(s)"
} else {
throw "Export job completed but no VM folder was created in temp directory"
}
}
else {
throw "VM export failed with job state: $($Progress.State)"
}
# Restart VM if it was originally running
if ($OriginalState -eq 'Running') {
Write-Log "Restarting VM..."
Start-VM -Name $VMName
Write-Log "VM restarted successfully"
}
# Compress the backup with 7-Zip
if (-not (Compress-BackupWithSevenZip -SourcePath $TempVMFolder -ArchivePath $TempArchive -VMName $VMName)) {
throw "7-Zip compression failed"
}
# Move compressed backup to local backup folder
$LocalBackupFile = Move-ToLocalBackup -SourceFile $TempArchive -DestinationPath $LocalBackupPath -VMName $VMName
if (-not $LocalBackupFile) {
throw "Failed to move backup to local backup folder"
}
# Immediately cleanup temp files for this VM after successful compression and move
Write-Log "Cleaning up temp files for $VMName after successful compression..."
Cleanup-TempFiles -TempVMPath $TempVMFolder -TempArchive $TempArchive -VMName $VMName
$VMBackupSuccess = $true
Write-Log "=== Monthly backup completed successfully for $VMName ===" "SUCCESS"
}
catch {
Write-Log "=== Backup failed for $VMName : $_ ===" "ERROR"
# Attempt to restart VM if it was originally running
if ($OriginalState -eq 'Running') {
try {
$CurrentVM = Get-VM -Name $VMName -ErrorAction SilentlyContinue
if ($CurrentVM -and $CurrentVM.State -eq 'Off') {
Write-Log "Attempting to restart VM after failure..."
Start-VM -Name $VMName
Write-Log "VM restarted after failure"
}
}
catch {
Write-Log "Failed to restart VM after backup failure: $_" "ERROR"
}
}
}
finally {
# Always cleanup temp files for this VM (backup in case not already done)
if ((Test-Path $TempVMFolder -ErrorAction SilentlyContinue) -or (Test-Path $TempArchive -ErrorAction SilentlyContinue)) {
Write-Log "Final cleanup of any remaining temp files for $VMName..."
Cleanup-TempFiles -TempVMPath $TempVMFolder -TempArchive $TempArchive -VMName $VMName
}
}
# Return both success status and local backup file path
return @{
Success = $VMBackupSuccess
BackupFile = $LocalBackupFile
}
}
# Main execution
try {
Write-Log "Starting monthly backup process"
# Test prerequisites
if (-not (Test-Prerequisites)) {
throw "Prerequisites check failed"
}
# Determine which VMs to backup
$VMsToBackup = @()
if ($AllVMs) {
Write-Log "Backing up all VMs (excluding specified VMs)"
$AllVirtualMachines = Get-VM | Where-Object { $_.Name -notin $ExcludeVMs }
$VMsToBackup = $AllVirtualMachines.Name
if ($ExcludeVMs.Count -gt 0) {
Write-Log "Excluded VMs: $($ExcludeVMs -join ', ')"
}
Write-Log "VMs to backup: $($VMsToBackup -join ', ')"
}
else {
$VMsToBackup = @($VMName)
Write-Log "Backing up single VM: $VMName"
}
if ($VMsToBackup.Count -eq 0) {
throw "No VMs found to backup"
}
# Backup each VM
$SuccessCount = 0
$FailureCount = 0
$Results = @()
$SuccessfulBackupFiles = @()
foreach ($VM in $VMsToBackup) {
$StartTime = Get-Date
$BackupResult = Backup-SingleVM -VMName $VM -MaxBackups $MaxBackups
$EndTime = Get-Date
$Duration = ($EndTime - $StartTime).TotalMinutes
$Results += [PSCustomObject]@{
VMName = $VM
Success = $BackupResult.Success
Duration = [math]::Round($Duration, 1)
BackupFile = $BackupResult.BackupFile
}
if ($BackupResult.Success) {
$SuccessCount++
if ($BackupResult.BackupFile) {
$SuccessfulBackupFiles += $BackupResult.BackupFile
}
} else {
$FailureCount++
}
}
# Copy all successful backups to NAS in one batch
$NASCopySuccess = $false
if ($SuccessfulBackupFiles.Count -gt 0) {
Write-Log "All VM backups completed. Starting bulk copy to NAS..."
$NASCopySuccess = Copy-AllBackupsToNAS -LocalPath $LocalBackupPath -NASPath $NASBackupPath -BackupFiles $SuccessfulBackupFiles
if ($NASCopySuccess) {
Write-Log "Successfully copied all backups to NAS"
# Now cleanup old backups on NAS for each VM
Write-Log "Cleaning up old backups on NAS..."
foreach ($VM in $VMsToBackup) {
try {
Cleanup-OldBackups -Path $NASBackupPath -Pattern "^Monthly_$($VM)_\d{8}_\d{4}\.7z$" -Keep $MaxBackups
}
catch {
Write-Log "Warning: Could not cleanup old backups for $VM on NAS: $_" "WARNING"
}
}
}
else {
Write-Log "Failed to copy some or all backups to NAS" "ERROR"
}
}
# Cleanup old local backups regardless of NAS copy success
Write-Log "Cleaning up old local backups..."
foreach ($VM in $VMsToBackup) {
try {
Cleanup-OldBackups -Path $LocalBackupPath -Pattern "^Monthly_$($VM)_\d{8}_\d{4}\.7z$" -Keep $MaxBackups
}
catch {
Write-Log "Warning: Could not cleanup old local backups for $VM : $_" "WARNING"
}
}
# Final summary
Write-Log "=== BACKUP SUMMARY ==="
Write-Log "Total VMs processed: $($VMsToBackup.Count)"
Write-Log "Successful backups: $SuccessCount"
Write-Log "Failed backups: $FailureCount"
Write-Log "NAS copy success: $(if ($NASCopySuccess) { 'YES' } else { 'NO' })"
foreach ($Result in $Results) {
$Status = if ($Result.Success) { "SUCCESS" } else { "FAILED" }
Write-Log "$($Result.VMName): $Status ($($Result.Duration) minutes)"
}
# Display local backup status
Write-Log "=== LOCAL BACKUP STATUS ==="
if (Test-Path $LocalBackupPath) {
$LocalBackups = Get-ChildItem -Path $LocalBackupPath -File -Filter "*.7z" | Sort-Object CreationTime -Descending
$TotalLocalSize = 0
foreach ($Backup in $LocalBackups) {
$Size = $Backup.Length
$SizeGB = [math]::Round($Size / 1GB, 2)
$TotalLocalSize += $Size
Write-Log "$($Backup.Name): $SizeGB GB (Created: $(Get-Date $Backup.CreationTime -Format 'yyyy-MM-dd HH:mm'))"
}
$TotalLocalSizeGB = [math]::Round($TotalLocalSize / 1GB, 2)
Write-Log "Total local backup storage used: $TotalLocalSizeGB GB"
}
if ($FailureCount -gt 0) {
Write-Log "Monthly backup process completed with $FailureCount failure(s)" "WARNING"
exit 1
} elseif (-not $NASCopySuccess -and $SuccessfulBackupFiles.Count -gt 0) {
Write-Log "Monthly backup process completed but NAS copy failed - backups available locally" "WARNING"
exit 1
} else {
Write-Log "Monthly backup process completed successfully" "SUCCESS"
}
}
catch {
Write-Log "Monthly backup process failed: $_" "ERROR"
exit 1
}
# Example usage:
# Single VM:
# .\Monthly-VMBackup.ps1 -VMName "MyVM" -MaxBackups 3
# All VMs:
# .\Monthly-VMBackup.ps1 -AllVMs -MaxBackups 3
# All VMs except specific ones:
# .\Monthly-VMBackup.ps1 -AllVMs -ExcludeVMs @("TestVM", "TempVM") -MaxBackups 3
# New workflow:
# 1. Export VM to F:\TempBackup\{VMName}\Export\
# 2. Compress with 7-Zip to F:\TempBackup\{VMName}\{Archive}.7z
# 3. Move compressed archive to F:\Backups\{Archive}.7z
# 4. Clean up temp files for this VM
# 5. Repeat for all VMs
# 6. Once ALL VMs are done, bulk copy F:\Backups\*.7z to NAS
# 7. Clean up old backups on both local F: drive and NAS