[CmdletBinding()] Param ( [Parameter()] [String]$ManageAlchemyPath = "[redacted]", [Parameter()] [String]$VshadowPath = "[redacted]", [Parameter()] [String]$SourceDrive = "[redacted]", [Parameter()] [String]$ShadowDrive = "[redacted]", [Parameter()] [String]$ShadowPath = "[redacted]" ) ############################################################################### # # BackupVMs.ps1 - alchemy.nodomain.net VM backup script # # Copyright 2017-2020 - Josh Moyer , all rights reserved. # ############################################################################### # To do: # Support automated restartability - i.e. without having to manually destroy the shadow and offline the drive # Known Issues: # - If VM config references files in a different directory than the config then the files might get skipped # - No verbose logging # - C style error handling is not as modern as PowerShell can be # - If the backup drive is full the script will never complete, as robocopy keeps retrying the failed copy. # Test cases: # - Destination path does/does not exist # - Disk failure/removal # - Disk full # - Disk offline # - Disk online # - Disk read only # - Disk subsystem configuration changes # - Multiple backup disks connected # - Multiple script instances started Begin { Function Send-Alert($Subject, $Body) { $Message = [System.Web.Mail.MailMessage]::new() $Message.To = "[redacted]" $Message.To = "[redacted]" $Message.From = "$($Env:COMPUTERNAME).NODOMAIN.NET" $Message.Subject = $Subject $Message.Body = $Body [System.Web.Mail.SmtpMail]::SmtpServer = '[redacted]' [System.Web.Mail.SmtpMail]::Send($Message) } Function ConcatStringCollection ($Object) { For ($I = 0 ; $I -LT $Object.Length ; $I++) { $Output = -Join ($Output, $Object[$I], "`n") } Return $Output } # -5: failed to copy at least one file Function CopyVMs ($BackupPath) { <# some of the things we'll need to do: check disk size $disk = Get-WmiObject Win32_LogicalDisk -ComputerName remotecomputer -Filter "DeviceID='C:'" | Select-Object Size,FreeSpace $disk.Size $disk.FreeSpace check free space: $disk = Get-WmiObject Win32_LogicalDisk -ComputerName remotecomputer -Filter "DeviceID='C:'" | Select-Object Size,FreeSpace; $disk.FreeSpace check the size of files and/or directories: gci -Recurse | measure-object -sum length have a priority ranked list of vms to backup, according to storage class and an alert if a certain class of machine is not backedup #> If (!(Test-Path $BackupPath)) {Return -5} $BackupItems = Get-ChildItem $BackupPath ForEach ($BackupItem In $BackupItems) { If (Test-Path $ShadowPath$BackupItem) { #If vm.length -LT (freespace + vm.backup.length) #Requires -RunAsAdministrator RoboCopy $ShadowPath$BackupItem $BackupPath$BackupItem /COPYALL /B /J /MIR /SL /DCOPY:DAT /NP #| Out-Null #If ($Notify -EQ $True) { Notify vm.specification } } } } # -6: failed to create or expose shadow Function CreateShadow { #Requires -RunAsAdministrator $Output = & $VshadowPath "-p" "-nw" "$($SourceDrive)" $Output = ConcatStringCollection($Output) #If ($Output -EQ $Null) #{ # Write-Output "VC Redist installed?" # Return -61 #} Write-Debug $Output $ShadowCopySet = ($Output | Select-String "Shadow copy Set: ({[a-f0-9-]*})").Matches.Groups[1].Value $SnapshotId = ($Output | Select-String "SNAPSHOT ID = ({[a-f0-9-]*})").Matches.Groups[1].Value If (($ShadowCopySet -EQ $Null -OR $SnapshotId -EQ $Null)) { Return -6 } If (Test-Path $ShadowDrive) { Return -6 } #Requires -RunAsAdministrator $Output = & $VshadowPath "-el=`"$($SnapshotId),$($ShadowDrive)`"" $Output = ConcatStringCollection($Output) Write-Debug $Output If (!(Test-Path $ShadowDrive)) { Return -6 } Return $ShadowCopySet } # -7: unable to destroy shadow Function DestroyShadow($ShadowCopySet) { #Requires -RunAsAdministrator $Output = & $VshadowPath "-dx=`"$ShadowCopySet`"" If (Test-Path $ShadowDrive) { Return -7 } } # -4: offline disks was not equal to 1 Function OnlineDrive { $Drives = "list disk" | DISKPART | Where {$_ -Match "Disk ([0-9]+)\s+Offline"} If ($Matches.Count -NE 2) {Return -4} $Drive = $Matches[1] "select disk $Drive`nattributes disk clear readonly`nonline disk" | DISKPART | Out-Null $Drive } # -3: failed to offline backup drive Function OfflineDrive ($Drive) { "select disk $Drive`noffline disk" | DISKPART | Out-Null } # -2: backup drive signature not found Function ReturnDrive { $IDFile = "vmbackupdrive.id" $Drives = Get-WmiObject -Query "SELECT * from win32_logicaldisk WHERE DriveType = 3” $Drives | ForEach-Object -Process { $Drive = $_.DeviceID $Candidate = Get-ChildItem "$Drive\$IDFile" -ErrorAction Ignore If ($Candidate) {Return $Drive} } If ($Drive -LT 0) {Return -2} } # -1: failure in main Function Main { $DebugPreference = "Continue" $ErrorActionPreference = "Stop" Date Echo "Suspending VMs." & $ManageAlchemyPath -Command Suspend-VMs Date Echo "Creating shadow copy." $ShadowSet = CreateShadow If ($ShadowSet.Length -NE 38) {Return $ShadowSet} Date Echo "Resuming VMs." & $ManageAlchemyPath -Command Start-VMs Date Echo "Bringing backup drive online." $Drive = OnlineDrive If ($Drive -LT 0) {Return $Drive} Date Echo "Identifying backup drive." $BackupDrive = ReturnDrive If ($BackupDrive -LT 0) {Return $BackupDrive} Date Echo "Copying items." CopyVMs $BackupDrive Pause Date Echo "Destroying shadow copy." DestroyShadow $ShadowSet Date Echo "Taking backup drive offline." OfflineDrive $Drive Date } } Process { Main } End {}