# 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