├── .github └── FUNDING.yml ├── Hyper-V-Backup-sa.ps1 ├── Hyper-V-Backup.ps1 ├── LICENSE ├── README.md ├── vms-example.txt └── webhook.txt /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [digressive] 4 | custom: ["https://www.paypal.me/digressive"] 5 | -------------------------------------------------------------------------------- /Hyper-V-Backup-sa.ps1: -------------------------------------------------------------------------------- 1 | <#PSScriptInfo 2 | 3 | .VERSION 24.05.11 4 | 5 | .GUID c7fb05cc-1e20-4277-9986-523020060668 6 | 7 | .AUTHOR Mike Galvin Contact: digressive@outlook.com 8 | 9 | .COMPANYNAME Mike Galvin 10 | 11 | .COPYRIGHT (C) Mike Galvin. All rights reserved. 12 | 13 | .TAGS Hyper-V Virtual Machines Full Backup Export Permissions Zip History 7-Zip 14 | 15 | .LICENSEURI https://github.com/Digressive/HyperV-Backup-Utility?tab=MIT-1-ov-file 16 | 17 | .PROJECTURI https://gal.vin/utils/hyperv-backup-utility/ 18 | 19 | .ICONURI 20 | 21 | .EXTERNALMODULEDEPENDENCIES 22 | 23 | .REQUIREDSCRIPTS 24 | 25 | .EXTERNALSCRIPTDEPENDENCIES 26 | 27 | .RELEASENOTES 28 | 29 | #> 30 | 31 | <# 32 | .SYNOPSIS 33 | Hyper-V Backup Utility - Flexible backup of Hyper-V Virtual Machines. 34 | 35 | .DESCRIPTION 36 | Creates a full backup of virtual machines. 37 | Run with -help or no arguments for usage. 38 | #> 39 | 40 | ## Set up command line switches. 41 | [CmdletBinding()] 42 | Param( 43 | [alias("BackupTo")] 44 | $BackupUsr, 45 | [alias("Keep")] 46 | $History, 47 | [alias("List")] 48 | [ValidateScript({Test-Path -Path $_ -PathType Leaf})] 49 | $VmList, 50 | [alias("Wd")] 51 | $WorkDirUsr, 52 | [alias("CaptureState")] 53 | $CaptureStateOpt, 54 | [alias("SzOptions")] 55 | $SzSwitches, 56 | [alias("L")] 57 | $LogPathUsr, 58 | [alias("LogRotate")] 59 | $LogHistory, 60 | [alias("Subject")] 61 | $MailSubject, 62 | [alias("SendTo")] 63 | $MailTo, 64 | [alias("From")] 65 | $MailFrom, 66 | [alias("Smtp")] 67 | $SmtpServer, 68 | [alias("Port")] 69 | $SmtpPort, 70 | [alias("User")] 71 | $SmtpUser, 72 | [alias("Pwd")] 73 | [ValidateScript({Test-Path -Path $_ -PathType Leaf})] 74 | $SmtpPwd, 75 | [Alias("Webhook")] 76 | [ValidateScript({Test-Path -Path $_ -PathType Leaf})] 77 | [string]$Webh, 78 | [switch]$UseSsl, 79 | [switch]$NoPerms, 80 | [switch]$Compress, 81 | [switch]$Sz, 82 | [switch]$ShortDate, 83 | [switch]$Help, 84 | [switch]$LowDisk, 85 | [switch]$ProgCheck, 86 | [switch]$OptimiseVHD, 87 | [switch]$NoBanner) 88 | 89 | If ($NoBanner -eq $False) 90 | { 91 | Write-Host -ForegroundColor Yellow -BackgroundColor Black -Object " 92 | _ _ __ __ ____ _ _ _ _ _ _ _ _ 93 | | | | | \ \ / / | _ \ | | | | | | | (_) (_) | 94 | | |__| |_ _ _ __ ___ _ _\ \ / / | |_) | __ _ ___| | ___ _ _ __ | | | | |_ _| |_| |_ _ _ 95 | | __ | | | | '_ \ / _ \ '__\ \/ / | _ < / _ |/ __| |/ / | | | '_ \ | | | | __| | | | __| | | | 96 | | | | | |_| | |_) | __/ | \ / | |_) | (_| | (__| <| |_| | |_) | | |__| | |_| | | | |_| |_| | 97 | |_| |_|\__, | .__/ \___|_| \/ |____/ \__,_|\___|_|\_\\__,_| .__/ \____/ \__|_|_|_|\__|\__, | 98 | __/ | | | | __/ | 99 | |___/|_| |_| |___/ 100 | Mike Galvin https://gal.vin Version 24.05.11 101 | Donate: https://www.paypal.me/digressive See -help for usage 102 | " 103 | } 104 | 105 | If ($PSBoundParameters.Values.Count -eq 0 -or $Help) 106 | { 107 | Write-Host -Object " Usage: 108 | From a terminal run: [path\]Hyper-V-Backup.ps1 -BackupTo [path\] 109 | This will backup all the VMs running to the backup location specified. 110 | 111 | Use -List [path\]vms.txt to specify a list of vm names to backup. 112 | Use -CaptureState to specify which method to use when exporting. 113 | Use -Wd [path\] to configure a working directory for the backup process. 114 | Use -Keep [number] to specify how many days worth of backup to keep. 115 | Use -ShortDate to use only the Year, Month and Day in backup filenames. 116 | Use -LowDisk to remove old backups before new ones are created. For low disk space situations. 117 | Use -ProgCheck to send notifications (email or webhook) after each VM is backed up. 118 | Use -OptimiseVHD to optimise the VHDs and make them smaller before copy. Must be used with -NoPerms option. 119 | 120 | -NoPerms should only be used when a regular backup cannot be performed. 121 | Please note: this will cause the VMs to shutdown during the backup process. 122 | 123 | Use -Compress to compress the VM backups in a zip file using Windows compression. 124 | Use -Sz to use 7-zip 125 | Use -SzOptions ""'-t7z,-v2g,-ppassword'"" to specify 7-zip options like file type, split files or password. 126 | 127 | To output a log: -L [path\]. 128 | To remove logs produced by the utility older than X days: -LogRotate [number]. 129 | Run with no ASCII banner: -NoBanner 130 | 131 | To send the log to a webhook on job completion: 132 | Specify a txt file containing the webhook URI with -Webhook [path\]webhook.txt 133 | 134 | To use the 'email log' function: 135 | Specify the subject line with -Subject ""'[subject line]'"" If you leave this blank a default subject will be used 136 | Make sure to encapsulate it with double & single quotes as per the example for Powershell to read it correctly. 137 | 138 | Specify the 'to' address with -SendTo [example@contoso.com] 139 | For multiple address, separate with a comma. 140 | 141 | Specify the 'from' address with -From [example@contoso.com] 142 | Specify the SMTP server with -Smtp [smtp server name] 143 | 144 | Specify the port to use with the SMTP server with -Port [port number]. 145 | If none is specified then the default of 25 will be used. 146 | 147 | Specify the user to access SMTP with -User [example@contoso.com] 148 | Specify the password file to use with -Pwd [path\]ps-script-pwd.txt. 149 | Use SSL for SMTP server connection with -UseSsl. 150 | 151 | To generate an encrypted password file run the following commands 152 | on the computer and the user that will run the script: 153 | " 154 | Write-Host -Object ' $creds = Get-Credential 155 | $creds.Password | ConvertFrom-SecureString | Set-Content [path\]ps-script-pwd.txt' 156 | } 157 | 158 | else { 159 | ## If logging is configured, start logging. 160 | ## If the log file already exists, clear it. 161 | If ($LogPathUsr) 162 | { 163 | ## Clean User entered string 164 | $LogPath = $LogPathUsr.trimend('\') 165 | 166 | ## Make sure the log directory exists. 167 | If ((Test-Path -Path $LogPath) -eq $False) 168 | { 169 | New-Item $LogPath -ItemType Directory -Force | Out-Null 170 | } 171 | 172 | $LogFile = ("Hyper-V-Backup_{0:yyyy-MM-dd_HH-mm-ss}.log" -f (Get-Date)) 173 | $Log = "$LogPath\$LogFile" 174 | 175 | If (Test-Path -Path $Log) 176 | { 177 | Clear-Content -Path $Log 178 | } 179 | } 180 | 181 | ## Function to get date in specific format. 182 | Function Get-DateFormat() 183 | { 184 | Get-Date -Format "yyyy-MM-dd HH:mm:ss" 185 | } 186 | 187 | Function Get-DateShort() 188 | { 189 | Get-Date -Format "yyyy-MM-dd" 190 | } 191 | 192 | Function Get-DateLong() 193 | { 194 | Get-Date -Format "yyyy-MM-dd_HH-mm-ss" 195 | } 196 | 197 | ## Function for logging. 198 | Function Write-Log($Type,$Evt) 199 | { 200 | If ($Type -eq "Info") 201 | { 202 | If ($LogPathUsr) 203 | { 204 | Add-Content -Path $Log -Encoding ASCII -Value "$(Get-DateFormat) [INFO] $Evt" 205 | } 206 | 207 | Write-Host -Object " $(Get-DateFormat) [INFO] $Evt" 208 | } 209 | 210 | If ($Type -eq "Succ") 211 | { 212 | If ($LogPathUsr) 213 | { 214 | Add-Content -Path $Log -Encoding ASCII -Value "$(Get-DateFormat) [SUCCESS] $Evt" 215 | } 216 | 217 | Write-Host -ForegroundColor Green -Object " $(Get-DateFormat) [SUCCESS] $Evt" 218 | } 219 | 220 | If ($Type -eq "Err") 221 | { 222 | If ($LogPathUsr) 223 | { 224 | Add-Content -Path $Log -Encoding ASCII -Value "$(Get-DateFormat) [ERROR] $Evt" 225 | } 226 | 227 | Write-Host -ForegroundColor Red -BackgroundColor Black -Object " $(Get-DateFormat) [ERROR] $Evt" 228 | } 229 | 230 | If ($Type -eq "Conf") 231 | { 232 | If ($LogPathUsr) 233 | { 234 | Add-Content -Path $Log -Encoding ASCII -Value "$Evt" 235 | } 236 | 237 | Write-Host -ForegroundColor Cyan -Object " $Evt" 238 | } 239 | } 240 | 241 | ## Function to optimise the VHD 242 | Function OptimVHD() 243 | { 244 | try { 245 | Write-Log -Type Info -Evt "(VM:$Vm) Optimising VHD(s)" 246 | $VmVhds = Get-VHD -Path $($Vm | Get-VMHardDiskDrive | Select-Object -ExpandProperty "Path") 247 | 248 | ## Loop through each VHD file and optimise 249 | ForEach ($Vhd in $VmVhds) { 250 | Write-Log -Type Info -Evt "(VM:$Vm) Used space before optimising VHD [$($Vhd.Path)] = $([math]::ceiling((Get-VHD -Path $Vhd.Path).FileSize / 1GB )) GB" 251 | Optimize-VHD -Path "$($Vhd.Path)" -Mode Full 252 | Write-Log -Type Info -Evt "(VM:$Vm) Used space after optimising VHD [$($Vhd.Path)] = $([math]::ceiling((Get-VHD -Path $Vhd.Path).FileSize / 1GB )) GB" 253 | $intTotalDisksSize += (Get-VHD -Path $Vhd.Path).FileSize 254 | } 255 | 256 | Write-Log -Type Info -Evt "(VM:$Vm) Done optimising VHD(s)" 257 | } 258 | 259 | catch { 260 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 261 | } 262 | } 263 | 264 | ## Function for Notifications 265 | Function Notify() 266 | { 267 | ## This whole block is for "simple auth" e-mail, if it is configured. 268 | If ($SmtpServer) 269 | { 270 | If (Test-Path -Path $Log) 271 | { 272 | ## Default e-mail subject if none is configured. 273 | If ($Null -eq $MailSubject) 274 | { 275 | $MailSubject = "Hyper-V Backup Utility Log" 276 | } 277 | 278 | ## Default Smtp Port if none is configured. 279 | If ($Null -eq $SmtpPort) 280 | { 281 | $SmtpPort = "25" 282 | } 283 | 284 | ## Setting the contents of the log to be the e-mail body. 285 | $MailBody = Get-Content -Path $Log | Out-String 286 | 287 | ForEach ($MailAddress in $MailTo) 288 | { 289 | ## If an smtp password is configured, get the username and password together for authentication. 290 | ## If an smtp password is not provided then send the e-mail without authentication and obviously no SSL. 291 | If ($SmtpPwd) 292 | { 293 | $SmtpCreds = New-Object System.Management.Automation.PSCredential -ArgumentList $SmtpUser, $($SmtpPwd | ConvertTo-SecureString -AsPlainText -Force) 294 | 295 | ## If -ssl switch is used, send the email with SSL. 296 | ## If it isn't then don't use SSL, but still authenticate with the credentials. 297 | If ($UseSsl) 298 | { 299 | Send-MailMessage -To $MailAddress -From $MailFrom -Subject "$MailSubject $Succi/$($Vms.count) VMs Successful" -Body $MailBody -SmtpServer $SmtpServer -Port $SmtpPort -UseSsl -Credential $SmtpCreds 300 | } 301 | 302 | else { 303 | Send-MailMessage -To $MailAddress -From $MailFrom -Subject "$MailSubject $Succi/$($Vms.count) VMs Successful" -Body $MailBody -SmtpServer $SmtpServer -Port $SmtpPort -Credential $SmtpCreds 304 | } 305 | } 306 | 307 | else { 308 | Send-MailMessage -To $MailAddress -From $MailFrom -Subject "$MailSubject $Succi/$($Vms.count) VMs Successful" -Body $MailBody -SmtpServer $SmtpServer -Port $SmtpPort 309 | } 310 | } 311 | } 312 | 313 | else { 314 | Write-Host -ForegroundColor Red -BackgroundColor Black -Object "There's no log file to email." 315 | } 316 | } 317 | ## End of Email block 318 | 319 | ## Webhook block 320 | If ($Webh) 321 | { 322 | $WebHookUri = Get-Content $Webh 323 | $WebHookArr = @() 324 | 325 | $title = "Hyper-V Backup Utility $Succi/$($Vms.count) VMs Successful" 326 | $description = Get-Content -Path $Log | Out-String 327 | 328 | $WebHookObj = [PSCustomObject]@{ 329 | title = $title 330 | description = $description 331 | } 332 | 333 | $WebHookArr += $WebHookObj 334 | $payload = [PSCustomObject]@{ 335 | embeds = $WebHookArr 336 | } 337 | 338 | Invoke-RestMethod -Uri $WebHookUri -Body ($payload | ConvertTo-Json -Depth 2) -Method Post -ContentType 'application/json' 339 | } 340 | } 341 | 342 | ## Function for Update Check 343 | Function UpdateCheck() 344 | { 345 | $ScriptVersion = "24.05.11" 346 | $RawSource = "https://raw.githubusercontent.com/Digressive/HyperV-Backup-Utility/master/Hyper-V-Backup.ps1" 347 | 348 | try { 349 | $SourceCheck = Invoke-RestMethod -uri "$RawSource" 350 | $VerCheck = $SourceCheck -split '\n' | Select-String -Pattern ".VERSION $ScriptVersion" -SimpleMatch -CaseSensitive -Quiet 351 | 352 | If ($VerCheck -ne $True) 353 | { 354 | Write-Log -Type Conf -Evt "-- There is an update available! --" 355 | } 356 | } 357 | 358 | catch { 359 | } 360 | } 361 | 362 | ## 363 | ## Start of backup Options functions 364 | ## 365 | 366 | Function CompressFiles7zip($CompressDateFormat,$CompressDir,$CompressFileName) 367 | { 368 | $7zipOutput = $null 369 | $7zipTestOutput = $null 370 | $CompressFileNameSet = $CompressFileName+$CompressDateFormat 371 | 372 | ## Makeshift error catch for 7zip in PowerShell 373 | $7zipOutput = & "$env:programfiles\7-Zip\7z.exe" $SzSwSplit -bso0 a ("$CompressDir\$CompressFileNameSet") "$CompressDir\$Vm\*" *>&1 374 | 375 | If ($7zipOutput -match "ERROR:") 376 | { 377 | Write-Log -Type Err -Evt "(VM:$Vm) 7zip encountered an error creating the archive" 378 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 2 379 | } 380 | 381 | else { 382 | Set-Variable -Name 'BackupSucc' -Value $true -Scope 2 383 | } 384 | 385 | $GetTheFile = Get-ChildItem -Path $CompressDir -File -Filter "$CompressFileNameSet.*" 386 | 387 | $archivePassword = if ($null -ne $SzSwitches) 388 | { 389 | $password = ($SzSwitches -split ',') | Where-Object { $_ -match '^-p(.*)' } | ForEach-Object { $matches[1] } 390 | if ($password -ne "" -and $null -ne $password) 391 | { 392 | "-p$password" 393 | } 394 | else {""} 395 | } 396 | else {""} 397 | 398 | $7zipTestOutput = & "$env:programfiles\7-Zip\7z.exe" $archivePassword -bso0 t $($GetTheFile.FullName) *>&1 399 | 400 | If ($7zipTestOutput -match "ERROR:") 401 | { 402 | Write-Log -Type Err -Evt "(VM:$Vm) 7zip encountered an error verifying the archive" 403 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 2 404 | } 405 | 406 | else { 407 | Set-Variable -Name 'BackupSucc' -Value $true -Scope 2 408 | } 409 | } 410 | 411 | Function CompressFilesWin($CompressDateFormat,$CompressDir,$CompressFileName) 412 | { 413 | Add-Type -AssemblyName "system.io.compression.filesystem" 414 | 415 | $CompressFileNameSet = $CompressFileName+$CompressDateFormat 416 | ## Windows compression with shortdate 417 | try { 418 | [io.compression.zipfile]::CreateFromDirectory("$CompressDir\$Vm", ("$CompressDir\$CompressFileNameSet.zip")) 419 | Set-Variable -Name 'BackupSucc' -Value $true -Scope 2 420 | } 421 | catch { 422 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 423 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 2 424 | } 425 | } 426 | 427 | Function ShortDateFileNo($ShortDateDir,$ShortDateFilePat) 428 | { 429 | Write-Log -Type Info -Evt "(VM:$Vm) Backup $VmFixed-$(Get-DateShort) already exists, appending number" 430 | $i = 1 431 | $ShortDateNN = ("$VmFixed-$(Get-DateShort)-{0:D3}" -f $i++)+$ShortDateFilePat 432 | $ShortDateExistT = Test-Path -Path $ShortDateDir\$ShortDateNN 433 | 434 | If ($ShortDateExistT) 435 | { 436 | do { 437 | $ShortDateNN = ("$VmFixed-$(Get-DateShort)-{0:D3}" -f $i++)+$ShortDateFilePat 438 | $ShortDateExistT = Test-Path -Path $ShortDateDir\$ShortDateNN 439 | } until ($ShortDateExistT -eq $false) 440 | } 441 | 442 | If ($Compress) 443 | { 444 | If ($Sz -eq $True -AND $7zT -eq $True) 445 | { 446 | If ($SzSwSplit -like "-v*") 447 | { 448 | ## 7-zip compression with shortdate configured and a number appended. 449 | $ShortDateNN7zFix = $ShortDateNN -replace '[.*]' 450 | CompressFiles7zip -CompressDir $ShortDateDir -CompressFileName $ShortDateNN7zFix 451 | } 452 | 453 | else { 454 | ## 7-zip compression with shortdate configured and a number appended. 455 | $ShortDateNN7zFix = $ShortDateNN -replace '[.*]' 456 | CompressFiles7zip -CompressDir $ShortDateDir -CompressFileName $ShortDateNN7zFix 457 | } 458 | } 459 | 460 | else { 461 | ## Windows compression with shortdate configured and a number appended. 462 | $ShortDateNNWinFix = $ShortDateNN.TrimEnd(".zip") 463 | CompressFilesWin -CompressDir $ShortDateDir -CompressFileName $ShortDateNNWinFix 464 | } 465 | } 466 | 467 | else { 468 | try { 469 | Get-ChildItem -Path $ShortDateDir -Filter $Vm -Directory | Rename-Item -NewName ("$ShortDateDir\$ShortDateNN") 470 | } 471 | catch { 472 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 473 | } 474 | 475 | If ($WorkDir -ne $Backup) 476 | { 477 | ## Moving backup folder with shortdate and renaming with number appended. 478 | try { 479 | Get-ChildItem -Path $WorkDir -Filter "$VmFixed-*-*-*" -Directory | Move-Item -Destination $ShortDateDir\$ShortDateNN -ErrorAction 'Stop' 480 | } 481 | catch { 482 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 483 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 484 | } 485 | } 486 | } 487 | } 488 | 489 | Function ReportRemove($RemoveDir,$RemoveFilePat,$RemoveDirOpt,$RemoveHistory) 490 | { 491 | If ($RemoveDirOpt) 492 | { 493 | $RemoveDirOptSet = @{Directory = $true} 494 | } 495 | 496 | else { 497 | $RemoveDirOptSet = @{Directory = $false} 498 | } 499 | 500 | $RemoveFullPath = $VmFixed+$RemoveFilePat 501 | 502 | ## report old files to remove 503 | If ($LogPathUsr) 504 | { 505 | If (Test-Path -Path $RemoveDir) 506 | { 507 | Get-ChildItem -Path $RemoveDir -Filter $RemoveFullPath @RemoveDirOptSet | Where-Object CreationTime -lt (Get-Date).AddDays(-$RemoveHistory) | Select-Object -Property Name, CreationTime | Format-Table -HideTableHeaders | Out-File -Append $Log -Encoding ASCII 508 | } 509 | } 510 | 511 | ## remove old files 512 | If (Test-Path -Path $RemoveDir) 513 | { 514 | Get-ChildItem -Path $RemoveDir -Filter $RemoveFullPath @RemoveDirOptSet | Where-Object CreationTime -lt (Get-Date).AddDays(-$RemoveHistory) | Remove-Item -Recurse -Force 515 | } 516 | } 517 | 518 | Function RemoveOld() 519 | { 520 | ## Remove previous backup folders. -Keep switch and -Compress switch are NOT configured. 521 | If ($Null -eq $History -And $Compress -eq $False) 522 | { 523 | Write-Log -Type Info -Evt "(VM:$Vm) Removing previous backups" 524 | ## Remove all previous backup folders 525 | If ($ShortDate) 526 | { 527 | ReportRemove -RemoveDir $WorkDir -RemoveFilePat "-*-*-*" -RemoveDirOpt $true -RemoveHistory $null 528 | } 529 | 530 | else { 531 | ReportRemove -RemoveDir $WorkDir -RemoveFilePat "-*-*-*_*-*-*" -RemoveDirOpt $true -RemoveHistory $null 532 | } 533 | 534 | ## If working directory is configured by user, remove all previous backup folders 535 | If ($WorkDir -ne $Backup) 536 | { 537 | ## Make sure the backup directory exists. 538 | If (Test-Path -Path $Backup) 539 | { 540 | If ($ShortDate) 541 | { 542 | ReportRemove -RemoveDir $Backup -RemoveFilePat "-*-*-*" -RemoveDirOpt $true -RemoveHistory $null 543 | } 544 | 545 | else { 546 | ReportRemove -RemoveDir $Backup -RemoveFilePat "-*-*-*_*-*-*" -RemoveDirOpt $true -RemoveHistory $null 547 | } 548 | } 549 | } 550 | } 551 | 552 | ## Remove previous backup folders older than X configured days. -Keep switch is configured and -Compress switch is NOT. 553 | else { 554 | If ($Compress -eq $False) 555 | { 556 | Write-Log -Type Info -Evt "(VM:$Vm) Removing backup folders older than: $History days" 557 | 558 | ## Remove previous backup folders older than the configured number of days. 559 | If ($ShortDate) 560 | { 561 | ReportRemove -RemoveDir $WorkDir -RemoveFilePat "-*-*-*" -RemoveDirOpt $true -RemoveHistory $History 562 | } 563 | 564 | else { 565 | ReportRemove -RemoveDir $WorkDir -RemoveFilePat "-*-*-*_*-*-*" -RemoveDirOpt $true -RemoveHistory $History 566 | } 567 | 568 | ## If working directory is configured by user, remove all previous backup folders older than X configured days. 569 | If ($WorkDir -ne $Backup) 570 | { 571 | ## Make sure the backup directory exists. 572 | If (Test-Path -Path $Backup) 573 | { 574 | If ($ShortDate) 575 | { 576 | ReportRemove -RemoveDir $Backup -RemoveFilePat "-*-*-*" -RemoveDirOpt $true -RemoveHistory $History 577 | } 578 | 579 | else { 580 | ReportRemove -RemoveDir $Backup -RemoveFilePat "-*-*-*_*-*-*" -RemoveDirOpt $true -RemoveHistory $History 581 | } 582 | } 583 | } 584 | } 585 | } 586 | 587 | ## Remove ALL previous backup files. -Keep switch is NOT configured and -Compress switch IS. 588 | If ($Compress) 589 | { 590 | If ($Null -eq $History) 591 | { 592 | Write-Log -Type Info -Evt "(VM:$Vm) Removing all previous compressed backups" 593 | 594 | ## Remove all previous compressed backups 595 | If ($ShortDate) 596 | { 597 | Remove-Item "$WorkDir\$VmFixed-*-*-*.*" -Force 598 | } 599 | 600 | else { 601 | Remove-Item "$WorkDir\$VmFixed-*-*-*_*-*-*.*" -Force 602 | } 603 | 604 | ## If working directory is configured by user, remove all previous backup files. 605 | If ($WorkDir -ne $Backup) 606 | { 607 | ## Make sure the backup directory exists. 608 | If (Test-Path -Path $Backup) 609 | { 610 | If ($ShortDate) 611 | { 612 | Remove-Item "$Backup\$VmFixed-*-*-*.*" -Force 613 | } 614 | 615 | else { 616 | Remove-Item "$Backup\$VmFixed-*-*-*_*-*-*.*" -Force 617 | } 618 | } 619 | } 620 | } 621 | 622 | ## Remove previous backup files older than X days. -Keep and -Compress switch are configured. 623 | else { 624 | Write-Log -Type Info -Evt "(VM:$Vm) Removing compressed backups older than: $History days" 625 | 626 | ## Remove previous compressed backups older than the configured number of days. 627 | If ($ShortDate) 628 | { 629 | ReportRemove -RemoveDir $WorkDir -RemoveFilePat "-*-*-*.*" -RemoveDirOpt $false -RemoveHistory $History 630 | } 631 | 632 | else { 633 | ReportRemove -RemoveDir $WorkDir -RemoveFilePat "-*-*-*_*-*-*.*" -RemoveDirOpt $false -RemoveHistory $History 634 | } 635 | 636 | ## If working directory is configured by user, remove previous backup files older than X days. 637 | If ($WorkDir -ne $Backup) 638 | { 639 | ## Make sure the backup directory exists. 640 | If (Test-Path -Path $Backup) 641 | { 642 | If ($ShortDate) 643 | { 644 | ReportRemove -RemoveDir $Backup -RemoveFilePat "-*-*-*.*" -RemoveDirOpt $false -RemoveHistory $History 645 | } 646 | 647 | else { 648 | ReportRemove -RemoveDir $Backup -RemoveFilePat "-*-*-*_*-*-*.*" -RemoveDirOpt $false -RemoveHistory $History 649 | } 650 | } 651 | } 652 | } 653 | } 654 | } 655 | 656 | Function OptionsRun() 657 | { 658 | If ($Compress) 659 | { 660 | ## If -Compress and -Sz are configured AND 7-zip is installed - compress the backup folder, if it isn't fallback to Windows compression. 661 | If ($Sz -eq $True -AND $7zT -eq $True) 662 | { 663 | Write-Log -Type Info -Evt "(VM:$Vm) Compressing backup using 7-Zip compression" 664 | 665 | ## If -Shortdate is configured, test for an old backup file, if true append a number (and increase the number if file still exists) before the file extension. 666 | If ($ShortDate) 667 | { 668 | ## If using 7zip's split file feature with short dates, we need to handle the files a little differently. 669 | If ($SzSwSplit -like "-v*") 670 | { 671 | $ShortDateT = Test-Path -Path ("$WorkDir\$VmFixed-$(Get-DateShort).*.*") 672 | 673 | If ($ShortDateT) 674 | { 675 | ShortDateFileNo -ShortDateDir $WorkDir -ShortDateFilePat ".*.*" 676 | } 677 | 678 | else { 679 | CompressFiles7zip(Get-DateShort) -CompressDir $WorkDir -CompressFileName "$VmFixed-$CompressDateFormat" 680 | } 681 | } 682 | 683 | else 684 | { 685 | $ShortDateT = Test-Path -Path ("$WorkDir\$VmFixed-$(Get-DateShort).*") 686 | 687 | If ($ShortDateT) 688 | { 689 | ShortDateFileNo -ShortDateDir $WorkDir -ShortDateFilePat ".*" 690 | } 691 | 692 | CompressFiles7zip(Get-DateShort) -CompressDir $WorkDir -CompressFileName "$VmFixed-$CompressDateFormat" 693 | } 694 | } 695 | 696 | else { 697 | CompressFiles7zip(Get-DateLong) -CompressDir $WorkDir -CompressFileName "$VmFixed-$CompressDateFormat" 698 | } 699 | } 700 | 701 | ## Compress the backup folder using Windows compression. -Compress is configured, -Sz switch is not, or it is and 7-zip isn't detected. 702 | ## This is also the "fallback" windows compression code. 703 | else { 704 | Write-Log -Type Info -Evt "(VM:$Vm) Compressing backup using Windows compression" 705 | 706 | If ($ShortDate) 707 | { 708 | $ShortDateT = Test-Path -Path ("$WorkDir\$VmFixed-$(Get-DateShort).zip") 709 | 710 | If ($ShortDateT) 711 | { 712 | ShortDateFileNo -ShortDateDir $WorkDir -ShortDateFilePat ".zip" 713 | } 714 | 715 | else { 716 | CompressFilesWin(Get-DateShort) -CompressDir $WorkDir -CompressFileName "$VmFixed-$CompressDateFormat" 717 | } 718 | } 719 | 720 | else { 721 | CompressFilesWin(Get-DateLong) -CompressDir $WorkDir -CompressFileName "$VmFixed-$CompressDateFormat" 722 | } 723 | } 724 | 725 | ## After being compressed, if success remove the VMs export folder. 726 | If ($BackupSucc) 727 | { 728 | Get-ChildItem -Path $WorkDir -Filter "$Vm" -Directory | Remove-Item -Recurse -Force 729 | } 730 | 731 | else { 732 | Write-Log -Type Err -Evt "(VM:$Vm) Compressing backup failed." 733 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 734 | } 735 | 736 | ## If working directory has been configured by the user, move the compressed backup to the backup folder and rename to include the date. 737 | If ($WorkDir -ne $Backup) 738 | { 739 | ## Make sure the backup directory exists. 740 | If ((Test-Path -Path $Backup) -eq $False) 741 | { 742 | Write-Log -Type Info -Evt "Backup directory $Backup doesn't exist. Creating it." 743 | New-Item $Backup -ItemType Directory -Force | Out-Null 744 | } 745 | 746 | ## Get the exact name of the backup file and append numbers onto the filename, keeping the extension intact. 747 | ## This contains special code to do the shortDate renaming with any 7-zip split files. 748 | If ($ShortDate) 749 | { 750 | If ($SzSwSplit -like "-v*") 751 | { 752 | $SzSplitFiles = Get-ChildItem -Path ("$WorkDir\$VmFixed-$(Get-DateShort).*.*") -File 753 | 754 | ForEach ($SplitFile in $SzSplitFiles) { 755 | $ShortDateT = Test-Path -Path "$Backup\$($SplitFile.name)" 756 | $split7zArray = $SplitFile.basename.Split(".") 757 | $archType = $split7zArray[1] 758 | 759 | If ($ShortDateT) 760 | { 761 | Write-Log -Type Info -Evt "(VM:$Vm) File: $($SplitFile.name) already exists, appending number" 762 | $FileExist = Get-ChildItem -Path "$Backup\$($SplitFile.name)" -File 763 | $i = 1 764 | 765 | $ShortDateNN = ("$VmFixed-$(Get-DateShort)-{0:D3}" -f $i++ + "." + $archType + $FileExist.Extension) 766 | $ShortDateExistT = Test-Path -Path $Backup\$ShortDateNN 767 | 768 | If ($ShortDateExistT) 769 | { 770 | do { 771 | $ShortDateNN = ("$VmFixed-$(Get-DateShort)-{0:D3}" -f $i++ + "." + $archType + $FileExist.Extension) 772 | $ShortDateExistT = Test-Path -Path $Backup\$ShortDateNN 773 | } until ($ShortDateExistT -eq $false) 774 | } 775 | 776 | try { 777 | Get-ChildItem -Path $SplitFile | Move-Item -Destination $Backup\$ShortDateNN -ErrorAction 'Stop' 778 | } 779 | catch { 780 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 781 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 782 | } 783 | } 784 | 785 | else { 786 | try { 787 | Get-ChildItem -Path $SplitFile | Move-Item -Destination $Backup\$ShortDateNN -ErrorAction 'Stop' 788 | } 789 | catch { 790 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 791 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 792 | } 793 | } 794 | } 795 | } 796 | 797 | else { 798 | $BackupFile = Get-ChildItem -Path ("$WorkDir\$VmFixed-$(Get-DateShort).*") -File 799 | $BackupFileN = $BackupFile.name 800 | $BackupFileNSplit = $BackupFileN.split(".") 801 | 802 | $ShortDateT = Test-Path -Path $Backup\$BackupFileN 803 | 804 | If ($ShortDateT) 805 | { 806 | Write-Log -Type Info -Evt "(VM:$Vm) File: $BackupFileN already exists, appending number" 807 | $FileExist = Get-ChildItem -Path $BackupFile -File 808 | $i = 1 809 | 810 | If ($Null -eq $BackupFileNSplit[2]) 811 | { 812 | $ShortDateNN = ("$VmFixed-$(Get-DateShort)-{0:D3}" -f $i++ + $FileExist.Extension) 813 | $ShortDateExistT = Test-Path -Path $Backup\$ShortDateNN 814 | } 815 | else { 816 | $ShortDateNN = ("$VmFixed-$(Get-DateShort)-{0:D3}" -f $i++ + "." + $BackupFileNSplit[1] + $FileExist.Extension) 817 | $ShortDateExistT = Test-Path -Path $Backup\$ShortDateNN 818 | } 819 | 820 | If ($ShortDateExistT) 821 | { 822 | If ($Null -eq $BackupFileNSplit[2]) 823 | { 824 | do { 825 | $ShortDateNN = ("$VmFixed-$(Get-DateShort)-{0:D3}" -f $i++ + $FileExist.Extension) 826 | $ShortDateExistT = Test-Path -Path $Backup\$ShortDateNN 827 | } until ($ShortDateExistT -eq $false) 828 | } 829 | else { 830 | do { 831 | $ShortDateNN = ("$VmFixed-$(Get-DateShort)-{0:D3}" -f $i++ + "." + $BackupFileNSplit[1] + $FileExist.Extension) 832 | $ShortDateExistT = Test-Path -Path $Backup\$ShortDateNN 833 | } until ($ShortDateExistT -eq $false) 834 | } 835 | } 836 | 837 | ## Move with shortdate and appended number 838 | try { 839 | Get-ChildItem -Path $BackupFile | Move-Item -Destination $Backup\$ShortDateNN -ErrorAction 'Stop' 840 | } 841 | catch { 842 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 843 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 844 | } 845 | } 846 | 847 | ## Move with shortdate 848 | try { 849 | Get-ChildItem -Path $WorkDir -Filter "$VmFixed-*-*-*.*" | Move-Item -Destination $Backup -ErrorAction 'Stop' 850 | } 851 | catch { 852 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 853 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 854 | } 855 | } 856 | } 857 | 858 | ## Move with long date 859 | else { 860 | try { 861 | Get-ChildItem -Path $WorkDir -Filter "$VmFixed-*-*-*_*-*-*.*" | Move-Item -Destination $Backup -ErrorAction 'Stop' 862 | } 863 | catch { 864 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 865 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 866 | } 867 | } 868 | } 869 | } 870 | 871 | ## -Compress switch is NOT configured and the -Keep switch is configured. 872 | ## Rename the export of each VM to include the date. 873 | else { 874 | If ($ShortDate) 875 | { 876 | $ShortDateT = Test-Path -Path ("$WorkDir\$VmFixed-$(Get-DateShort)") 877 | 878 | If ($ShortDateT) 879 | { 880 | ShortDateFileNo -ShortDateDir $WorkDir -ShortDateFilePat $null 881 | } 882 | 883 | try { 884 | Get-ChildItem -Path $WorkDir -Filter $Vm -Directory | Rename-Item -NewName ("$WorkDir\$VmFixed-$(Get-DateShort)") 885 | } 886 | catch { 887 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 888 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 889 | } 890 | } 891 | 892 | else { 893 | try { 894 | Get-ChildItem -Path $WorkDir -Filter $Vm -Directory | Rename-Item -NewName ("$WorkDir\$VmFixed-$(Get-DateLong)") 895 | } 896 | catch { 897 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 898 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 899 | } 900 | } 901 | 902 | ## If working directory has been configured by the user, move the backup to the backup folder and rename to include the date. 903 | If ($WorkDir -ne $Backup) 904 | { 905 | ## Make sure the backup directory exists. 906 | If ((Test-Path -Path $Backup) -eq $False) 907 | { 908 | Write-Log -Type Info -Evt "Backup directory $Backup doesn't exist. Creating it." 909 | New-Item $Backup -ItemType Directory -Force | Out-Null 910 | } 911 | 912 | If ($ShortDate) 913 | { 914 | $ShortDateT = Test-Path -Path ("$Backup\$VmFixed-$(Get-DateShort)") 915 | 916 | If ($ShortDateT) 917 | { 918 | ShortDateFileNo -ShortDateDir $Backup -ShortDateFilePat $null 919 | } 920 | 921 | ## Moving backup folder with shortdate 922 | try { 923 | Get-ChildItem -Path $WorkDir -Filter "$VmFixed-*-*-*" -Directory | Move-Item -Destination ("$Backup\$VmFixed-$(Get-DateShort)") -ErrorAction 'Stop' 924 | } 925 | catch { 926 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 927 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 928 | } 929 | } 930 | 931 | ## Moving backup folder with longdate 932 | else { 933 | try { 934 | Get-ChildItem -Path $WorkDir -Filter "$VmFixed-*-*-*_*-*-*" -Directory | Move-Item -Destination ("$Backup\$VmFixed-$(Get-DateLong)") -ErrorAction 'Stop' 935 | } 936 | catch { 937 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 938 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 939 | } 940 | } 941 | } 942 | } 943 | } 944 | ## 945 | ## End of backup Options functions 946 | ## 947 | 948 | ## getting Windows Version info 949 | $OSVMaj = [environment]::OSVersion.Version | Select-Object -expand major 950 | $OSVMin = [environment]::OSVersion.Version | Select-Object -expand minor 951 | $OSVBui = [environment]::OSVersion.Version | Select-Object -expand build 952 | $OSV = "$OSVMaj" + "." + "$OSVMin" + "." + "$OSVBui" 953 | 954 | If ($Null -eq $BackupUsr) 955 | { 956 | Write-Log -Type Err -Evt "You must specify -BackupTo [path\]." 957 | Exit 958 | } 959 | 960 | else { 961 | ## Test for Hyper-V feature installed on local machine. 962 | ## Old version of Win Serv have a different service name. 963 | try { 964 | If ($OSV -eq "6.3.9600") 965 | { 966 | Get-Service vmms -ErrorAction Stop | Out-Null 967 | } 968 | 969 | else { 970 | Get-Service vmcompute -ErrorAction Stop | Out-Null 971 | } 972 | } 973 | 974 | catch { 975 | Write-Log -Type Err -Evt "Hyper-V is not installed on this local machine." 976 | Exit 977 | } 978 | 979 | If ($Compress -eq $false -And $Sz -eq $true) 980 | { 981 | Write-Log -Type Err -Evt "You must specify -Compress to use -Sz." 982 | Exit 983 | } 984 | 985 | If ($Sz -eq $false -And $Null -ne $SzSwitches) 986 | { 987 | Write-Log -Type Err -Evt "You must specify -Sz to use -SzOptions." 988 | Exit 989 | } 990 | 991 | If ($Null -eq $LogPathUsr -And $Null -ne $LogHistory) 992 | { 993 | Write-Log -Type Err -Evt "You must specify -L [path\] to use -LogRotate [number]." 994 | Exit 995 | } 996 | 997 | If ($Null -eq $LogPathUsr -And $SmtpServer) 998 | { 999 | Write-Log -Type Err -Evt "You must specify -L [path\] to use the email log function." 1000 | Exit 1001 | } 1002 | 1003 | If ($Null -eq $LogPathUsr -And $Webh) 1004 | { 1005 | Write-Log -Type Err -Evt "You must specify -L [path\] to use send the log to a webhook." 1006 | Exit 1007 | } 1008 | 1009 | If ($NoPerms -eq $false -And $OptimiseVHD -eq $true) 1010 | { 1011 | Write-Log -Type Err -Evt "You must specify -NoPerms to use -OptimiseVHD." 1012 | Exit 1013 | } 1014 | 1015 | If ($NoPerms -eq $true -And $Null -ne $CaptureStateOpt) 1016 | { 1017 | Write-Log -Type Err -Evt "You cannot use -CaptureState Options with -NoPerms. They will have no effect." 1018 | Exit 1019 | } 1020 | 1021 | ## Clean User entered string 1022 | If ($BackupUsr) 1023 | { 1024 | $Backup = $BackupUsr.trimend('\') 1025 | } 1026 | 1027 | If ($WorkDirUsr) 1028 | { 1029 | $WorkDir = $WorkDirUsr.trimend('\') 1030 | } 1031 | } 1032 | 1033 | ## Setting an easier to use variable for computer name of the Hyper-V server. 1034 | $Vs = $Env:ComputerName 1035 | 1036 | ## If a VM list file is configured, get the content of the file, otherwise just get the running VMs. 1037 | ## Clean list if it has empty lines. 1038 | If ($VmList) 1039 | { 1040 | $Vms = Get-Content $VmList | Where-Object {$_.trim() -ne ""} 1041 | } 1042 | 1043 | else { 1044 | $Vms = Get-VM | Where-Object {$_.State -eq 'Running'} | Select-Object -ExpandProperty Name 1045 | } 1046 | 1047 | ## Check to see if there are any VMs to process. 1048 | If ($Vms.count -ne 0) 1049 | { 1050 | ## If the user has not configured the working directory, set it as the backup directory. 1051 | If ($Null -eq $WorkDir) 1052 | { 1053 | $WorkDir = "$Backup" 1054 | } 1055 | 1056 | If ($Null -eq $ShortDate) 1057 | { 1058 | $ShortDate = "$LongDate" 1059 | } 1060 | 1061 | If ($SzSwitches) 1062 | { 1063 | $SzSwSplit = $SzSwitches.split(",") 1064 | } 1065 | 1066 | If ($Sz -eq $True) 1067 | { 1068 | $7zT = Test-Path -Path "$env:programfiles\7-Zip\7z.exe" 1069 | } 1070 | 1071 | ## 1072 | ## Display the current config and log if configured. 1073 | ## 1074 | Write-Log -Type Conf -Evt "--- Running with the following config ---" 1075 | Write-Log -Type Conf -Evt "Utility Version: 24.05.11" 1076 | UpdateCheck ## Run Update checker function 1077 | Write-Log -Type Conf -Evt "Hostname: $Vs." 1078 | Write-Log -Type Conf -Evt "Windows Version: $OSV." 1079 | 1080 | If ($Vms) 1081 | { 1082 | Write-Log -Type Conf -Evt "No. of VMs: $($Vms.count)." 1083 | Write-Log -Type Conf -Evt "VMs to backup:" 1084 | ForEach ($Vm in $Vms) 1085 | { 1086 | Write-Log -Type Conf -Evt "$Vm" 1087 | } 1088 | } 1089 | 1090 | If ($BackupUsr) 1091 | { 1092 | Write-Log -Type Conf -Evt "Backup directory: $BackupUsr." 1093 | } 1094 | 1095 | If ($WorkDirUsr) 1096 | { 1097 | Write-Log -Type Conf -Evt "Working directory: $WorkDirUsr." 1098 | } 1099 | 1100 | If ($CaptureStateOpt) 1101 | { 1102 | Write-Log -Type Conf -Evt "Export-VM Options: $CaptureStateOpt." 1103 | } 1104 | 1105 | If ($NoPerms) 1106 | { 1107 | Write-Log -Type Conf -Evt "-NoPerms switch: $NoPerms." 1108 | } 1109 | 1110 | If ($ShortDate) 1111 | { 1112 | Write-Log -Type Conf -Evt "-ShortDate switch: $ShortDate." 1113 | } 1114 | 1115 | If ($LowDisk) 1116 | { 1117 | Write-Log -Type Conf -Evt "-LowDisk switch: $LowDisk." 1118 | } 1119 | 1120 | If ($Compress) 1121 | { 1122 | Write-Log -Type Conf -Evt "-Compress switch: $Compress." 1123 | } 1124 | 1125 | If ($Sz) 1126 | { 1127 | Write-Log -Type Conf -Evt "-Sz switch: $Sz." 1128 | } 1129 | 1130 | If ($Sz) 1131 | { 1132 | Write-Log -Type Conf -Evt "7-zip installed: $7zT." 1133 | } 1134 | 1135 | If ($SzSwitches) 1136 | { 1137 | Write-Log -Type Conf -Evt "7-zip Options: $SzSwitches." 1138 | } 1139 | 1140 | If ($Null -ne $History) 1141 | { 1142 | Write-Log -Type Conf -Evt "Backups to keep: $History days" 1143 | } 1144 | 1145 | If ($LogPathUsr) 1146 | { 1147 | Write-Log -Type Conf -Evt "Logs directory: $LogPathUsr." 1148 | } 1149 | 1150 | If ($Webh) 1151 | { 1152 | Write-Log -Type Conf -Evt "Webhook: Configured" 1153 | } 1154 | 1155 | If ($MailTo) 1156 | { 1157 | Write-Log -Type Conf -Evt "E-mail log to: $MailTo." 1158 | } 1159 | 1160 | If ($MailFrom) 1161 | { 1162 | Write-Log -Type Conf -Evt "E-mail log from: $MailFrom." 1163 | } 1164 | 1165 | If ($MailSubject) 1166 | { 1167 | Write-Log -Type Conf -Evt "E-mail subject: $MailSubject." 1168 | } 1169 | 1170 | If ($SmtpServer) 1171 | { 1172 | Write-Log -Type Conf -Evt "SMTP server: Configured" 1173 | } 1174 | 1175 | If ($SmtpUser) 1176 | { 1177 | Write-Log -Type Conf -Evt "SMTP auth: Configured" 1178 | } 1179 | Write-Log -Type Conf -Evt "---" 1180 | Write-Log -Type Info -Evt "Process started" 1181 | ## 1182 | ## Display current config ends here. 1183 | ## 1184 | 1185 | ## For Success/Fail stats 1186 | $Succi = 0 1187 | $Faili = 0 1188 | 1189 | ## 1190 | ## -NoPerms process starts here. 1191 | ## 1192 | ## If the -NoPerms switch is set, start a custom process to copy all the VM data. 1193 | If ($NoPerms) 1194 | { 1195 | ForEach ($Vm in $Vms) 1196 | { 1197 | ## Get VM info 1198 | try { 1199 | $VhdSize = Get-VHD -Path $($Vm | Get-VMHardDiskDrive | Select-Object -ExpandProperty "Path") | Select-Object @{Name = "FileSizeGB"; Expression = {[math]::ceiling($_.FileSize/1GB)}}, @{Name = "MaxSizeGB"; Expression = {[math]::ceiling($_.Size/1GB)}} 1200 | Write-Log -Type Info -Evt "(VM:$Vm) has [$((Get-VMProcessor $Vm).Count)] CPU cores, [$([math]::ceiling((Get-VMMemory $Vm).Startup / 1gb))GB] RAM, Storage: [Current Size = $($VhdSize.FileSizeGB)GB - Max Size = $($VhdSize.MaxSizeGB)GB]" 1201 | } 1202 | catch { 1203 | Write-Log -Type Err -Evt "(VM:$Vm) Error getting VM info: $($_.Exception.Message)" 1204 | } 1205 | 1206 | $VmFixed = $Vm.replace(".","-") 1207 | $VmInfo = Get-VM -Name $Vm 1208 | 1209 | ## Remove old backups if -LowDisk is configured 1210 | If ($LowDisk) 1211 | { 1212 | RemoveOld 1213 | } 1214 | 1215 | ## Test for the existence of a previous VM export. If it exists, delete it. 1216 | If (Test-Path -Path "$WorkDir\$Vm") 1217 | { 1218 | Remove-Item "$WorkDir\$Vm" -Recurse -Force 1219 | } 1220 | 1221 | ## Create directories for the VM export. 1222 | try { 1223 | New-Item "$WorkDir\$Vm" -ItemType Directory -Force | Out-Null 1224 | New-Item "$WorkDir\$Vm\Virtual Machines" -ItemType Directory -Force | Out-Null 1225 | New-Item "$WorkDir\$Vm\Virtual Hard Disks" -ItemType Directory -Force | Out-Null 1226 | New-Item "$WorkDir\$Vm\Snapshots" -ItemType Directory -Force | Out-Null 1227 | $BackupSucc = $true 1228 | } 1229 | catch { 1230 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 1231 | $BackupSucc = $false 1232 | } 1233 | 1234 | ## Check for VM running 1235 | If (Get-VM | Where-Object {$VmInfo.State -eq 'Running'}) 1236 | { 1237 | $VMwasRunning = $true 1238 | Write-Log -Type Info -Evt "(VM:$Vm) VM is running, saving state" 1239 | Stop-VM -Name $Vm -Save 1240 | } 1241 | 1242 | else { 1243 | $VMwasRunning = $false 1244 | Write-Log -Type Info -Evt "(VM:$Vm) VM not running" 1245 | } 1246 | 1247 | ## If -OptimiseVHD option is set attempt to optimise the VMs VHDs 1248 | If ($OptimiseVHD) 1249 | { 1250 | OptimVHD 1251 | } 1252 | 1253 | ## 1254 | ## Copy the VM config files and log if there is an error. 1255 | ## 1256 | 1257 | ## Check for VM being in the correct state before continuing 1258 | $VmState = Get-Vm -Name $Vm 1259 | 1260 | If ($VmState.State -ne 'Off' -OR $VmState.State -ne 'Saved' -AND $VmState.Status -ne 'Operating normally') 1261 | { 1262 | do { 1263 | Write-Log -Type Err -Evt "(VM:$Vm) VM not in the desired state. Waiting 60 seconds..." 1264 | Start-Sleep -S 60 1265 | } until ($VmState.State -eq 'Off' -OR $VmState.State -eq 'Saved' -AND $VmState.Status -eq 'Operating normally') 1266 | } 1267 | 1268 | $StartTime = $(get-date) 1269 | 1270 | try { 1271 | Write-Log -Type Info -Evt "(VM:$Vm) Copying config files" 1272 | Copy-Item "$($VmInfo.ConfigurationLocation)\Virtual Machines\$($VmInfo.id)" "$WorkDir\$Vm\Virtual Machines\" -Recurse -Force 1273 | Copy-Item "$($VmInfo.ConfigurationLocation)\Virtual Machines\$($VmInfo.id).*" "$WorkDir\$Vm\Virtual Machines\" -Recurse -Force 1274 | $BackupSucc = $true 1275 | } 1276 | catch { 1277 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 1278 | $BackupSucc = $false 1279 | } 1280 | ## 1281 | ## End of VM config files. 1282 | ## 1283 | 1284 | ## 1285 | ## Copy the VHDs and log if there is an error. 1286 | ## 1287 | try { 1288 | Write-Log -Type Info -Evt "(VM:$Vm) Copying VHD files" 1289 | Copy-Item $VmInfo.HardDrives.Path -Destination "$WorkDir\$Vm\Virtual Hard Disks\" -Recurse -Force 1290 | $BackupSucc = $true 1291 | } 1292 | catch { 1293 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 1294 | $BackupSucc = $false 1295 | } 1296 | ## 1297 | ## End of VHDs. 1298 | ## 1299 | 1300 | ## Get the VM snapshots/checkpoints. 1301 | $Snaps = Get-VMSnapshot $Vm 1302 | 1303 | ForEach ($Snap in $Snaps) 1304 | { 1305 | ## 1306 | ## Copy the snapshot config files and log if there is an error. 1307 | ## 1308 | try { 1309 | Write-Log -Type Info -Evt "(VM:$Vm) Copying Snapshot config files" 1310 | Copy-Item "$($VmInfo.ConfigurationLocation)\Snapshots\$($Snap.id)" "$WorkDir\$Vm\Snapshots\" -Recurse -Force 1311 | Copy-Item "$($VmInfo.ConfigurationLocation)\Snapshots\$($Snap.id).*" "$WorkDir\$Vm\Snapshots\" -Recurse -Force 1312 | $BackupSucc = $true 1313 | } 1314 | catch { 1315 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 1316 | $BackupSucc = $false 1317 | } 1318 | ## 1319 | ## End of snapshot config. 1320 | ## 1321 | 1322 | ## Copy the snapshot root VHD. 1323 | try { 1324 | Write-Log -Type Info -Evt "(VM:$Vm) Copying Snapshot root VHD files" 1325 | Copy-Item $Snap.HardDrives.Path -Destination "$WorkDir\$Vm\Virtual Hard Disks\" -Recurse -Force -ErrorAction 'Stop' 1326 | $BackupSucc = $true 1327 | } 1328 | catch { 1329 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 1330 | $BackupSucc = $false 1331 | } 1332 | } 1333 | 1334 | If ($VMwasRunning) 1335 | { 1336 | Write-Log -Type Info -Evt "(VM:$Vm) Starting VM" 1337 | Start-VM $Vm 1338 | Write-Log -Type Info -Evt "(VM:$Vm) Waiting 60 seconds..." 1339 | Start-Sleep -S 60 1340 | } 1341 | 1342 | ## Remove old backups if -LowDisk is NOT configured 1343 | If ($LowDisk -eq $false) 1344 | { 1345 | RemoveOld 1346 | } 1347 | 1348 | If ($BackupSucc) 1349 | { 1350 | OptionsRun 1351 | } 1352 | 1353 | If ($BackupSucc) 1354 | { 1355 | Write-Log -Type Succ -Evt "(VM:$Vm) Backup Successful" 1356 | $Succi = $Succi+1 1357 | } 1358 | else { 1359 | Write-Log -Type Err -Evt "(VM:$Vm) Backup failed" 1360 | $Faili = $Faili+1 1361 | } 1362 | 1363 | $elapsedTime = $(get-date) - $StartTime 1364 | $totalTime = "{0:HH:mm:ss}" -f ([datetime]$elapsedTime.Ticks) 1365 | Write-Log -Type Info -Evt "(VM:$Vm) Processed in $totalTime" 1366 | 1367 | If ($ProgCheck) 1368 | { 1369 | Notify 1370 | } 1371 | } 1372 | } 1373 | ## 1374 | ## -NoPerms process ends here. 1375 | ## 1376 | 1377 | ## 1378 | ## Standard export process starts here. 1379 | ## 1380 | ## If the -NoPerms switch is NOT set, for each VM check for the existence of a previous export. 1381 | ## If it exists then delete it, otherwise the export will fail. 1382 | else { 1383 | ForEach ($Vm in $Vms) 1384 | { 1385 | ## Get VM info 1386 | try { 1387 | $VhdSize = Get-VHD -Path $($Vm | Get-VMHardDiskDrive | Select-Object -ExpandProperty "Path") | Select-Object @{Name = "FileSizeGB"; Expression = {[math]::ceiling($_.FileSize/1GB)}}, @{Name = "MaxSizeGB"; Expression = {[math]::ceiling($_.Size/1GB)}} 1388 | Write-Log -Type Info -Evt "(VM:$Vm) has [$((Get-VMProcessor $Vm).Count)] CPU cores, [$([math]::ceiling((Get-VMMemory $Vm).Startup / 1gb))GB] RAM, Storage: [Current Size = $($VhdSize.FileSizeGB)GB - Max Size = $($VhdSize.MaxSizeGB)GB]" 1389 | } 1390 | catch { 1391 | Write-Log -Type Err -Evt "(VM:$Vm) Error getting VM info: $($_.Exception.Message)" 1392 | } 1393 | 1394 | If (Test-Path -Path "$WorkDir\$Vm") 1395 | { 1396 | Remove-Item "$WorkDir\$Vm" -Recurse -Force 1397 | } 1398 | 1399 | If ($WorkDir -ne $Backup) 1400 | { 1401 | If (Test-Path -Path "$Backup\$Vm") 1402 | { 1403 | Remove-Item "$Backup\$Vm" -Recurse -Force 1404 | } 1405 | } 1406 | } 1407 | 1408 | ## If default key is already null, then disable VSS Legacy Tracing on Windows Server 2016 to prevent possible BSOD on Hyper-V Host. 1409 | ## Don't want to mess up anyone's config. :) 1410 | If ($OSV -eq "10.0.14393") 1411 | { 1412 | If ($null -eq (get-ItemProperty -literalPath HKLM:\System\CurrentControlSet\Services\VSS\Diag\).'(Default)') 1413 | { 1414 | $RegVSSFix = $True 1415 | Set-ItemProperty -Path HKLM:\System\CurrentControlSet\Services\VSS\Diag -Name "(default)" -Value "Disabled" 1416 | Write-Log -Type Info -Evt "Disabling VSS Legacy Tracing on Windows Server 2016 to prevent possible BSOD on Hyper-V Host." 1417 | } 1418 | } 1419 | 1420 | ## Do a regular export of the VMs. 1421 | ForEach ($Vm in $Vms) 1422 | { 1423 | $VmFixed = $Vm.replace(".","-") 1424 | 1425 | ## Remove old backups if -LowDisk is configured 1426 | If ($LowDisk) 1427 | { 1428 | RemoveOld 1429 | } 1430 | 1431 | $StartTime = $(get-date) 1432 | 1433 | try { 1434 | Write-Log -Type Info -Evt "(VM:$Vm) Attempting to export VM" 1435 | If ($Null -ne $CaptureStateOpt) 1436 | { 1437 | $Vm | Export-VM -CaptureLiveState $CaptureStateOpt -Path "$WorkDir" -ErrorAction 'Stop' 1438 | } 1439 | else { 1440 | $Vm | Export-VM -Path "$WorkDir" -ErrorAction 'Stop' 1441 | } 1442 | $BackupSucc = $true 1443 | } 1444 | catch { 1445 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 1446 | $BackupSucc = $false 1447 | } 1448 | 1449 | ## Remove old backups if -LowDisk is NOT configured 1450 | If ($LowDisk -eq $false) 1451 | { 1452 | RemoveOld 1453 | } 1454 | 1455 | If ($BackupSucc) 1456 | { 1457 | OptionsRun 1458 | } 1459 | 1460 | If ($BackupSucc) 1461 | { 1462 | Write-Log -Type Succ -Evt "(VM:$Vm) Export Successful" 1463 | $Succi = $Succi+1 1464 | } 1465 | else { 1466 | Write-Log -Type Err -Evt "(VM:$Vm) Export failed" 1467 | $Faili = $Faili+1 1468 | } 1469 | 1470 | $elapsedTime = $(get-date) - $StartTime 1471 | $totalTime = "{0:HH:mm:ss}" -f ([datetime]$elapsedTime.Ticks) 1472 | Write-Log -Type Info -Evt "(VM:$Vm) Processed in $totalTime" 1473 | 1474 | If ($ProgCheck) 1475 | { 1476 | Notify 1477 | } 1478 | } 1479 | 1480 | ## If the VSS fix was run, return regkey back to original state. 1481 | If ($OSV -eq "10.0.14393") 1482 | { 1483 | If ($RegVSSFix) 1484 | { 1485 | REG DELETE "HKEY_LOCAL_MACHINE\System\CurrentControlSet\Services\VSS\Diag" /ve /f 1486 | Write-Log -Type Info -Evt "Returning VSS Legacy Tracing config to default." 1487 | } 1488 | } 1489 | } 1490 | ## 1491 | ## End of standard export block 1492 | ## 1493 | } 1494 | 1495 | ## If there are no VMs, then do nothing. 1496 | else { 1497 | Write-Log -Type Err -Evt "There are no VMs running to backup" 1498 | } 1499 | 1500 | Write-Log -Type Info -Evt "Process finished." 1501 | Write-Log -Type Info -Evt "Number of VMs to Backup:$($Vms.count)" 1502 | Write-Log -Type Info -Evt "Backups Successful:$Succi" 1503 | Write-Log -Type Info -Evt "Backups Failed:$Faili" 1504 | 1505 | If ($Null -ne $LogHistory) 1506 | { 1507 | ## Clean up logs. 1508 | Write-Log -Type Info -Evt "Deleting logs older than: $LogHistory days" 1509 | Get-ChildItem -Path "$LogPath\Hyper-V-Backup_*" -File | Where-Object CreationTime -lt (Get-Date).AddDays(-$LogHistory) | Remove-Item -Recurse 1510 | } 1511 | 1512 | If ($ProgCheck -eq $false) 1513 | { 1514 | Notify 1515 | } 1516 | } 1517 | ## End -------------------------------------------------------------------------------- /Hyper-V-Backup.ps1: -------------------------------------------------------------------------------- 1 | <#PSScriptInfo 2 | 3 | .VERSION 24.08.29 4 | 5 | .GUID c7fb05cc-1e20-4277-9986-523020060668 6 | 7 | .AUTHOR Mike Galvin Contact: digressive@outlook.com 8 | 9 | .COMPANYNAME Mike Galvin 10 | 11 | .COPYRIGHT (C) Mike Galvin. All rights reserved. 12 | 13 | .TAGS Hyper-V Virtual Machines Full Backup Export Permissions Zip History 7-Zip 14 | 15 | .LICENSEURI https://github.com/Digressive/HyperV-Backup-Utility?tab=MIT-1-ov-file 16 | 17 | .PROJECTURI https://gal.vin/utils/hyperv-backup-utility/ 18 | 19 | .ICONURI 20 | 21 | .EXTERNALMODULEDEPENDENCIES 22 | 23 | .REQUIREDSCRIPTS 24 | 25 | .EXTERNALSCRIPTDEPENDENCIES 26 | 27 | .RELEASENOTES 28 | 29 | #> 30 | 31 | <# 32 | .SYNOPSIS 33 | Hyper-V Backup Utility - Flexible backup of Hyper-V Virtual Machines. 34 | 35 | .DESCRIPTION 36 | Creates a full backup of virtual machines. 37 | Run with -help or no arguments for usage. 38 | #> 39 | 40 | ## Set up command line switches. 41 | [CmdletBinding()] 42 | Param( 43 | [alias("BackupTo")] 44 | $BackupUsr, 45 | $SMBUsr, 46 | $SMBPwd, 47 | [alias("Keep")] 48 | $History, 49 | [alias("List")] 50 | [ValidateScript({Test-Path -Path $_ -PathType Leaf})] 51 | $VmList, 52 | [alias("Wd")] 53 | $WorkDirUsr, 54 | [alias("CaptureState")] 55 | $CaptureStateOpt, 56 | [alias("SzOptions")] 57 | $SzSwitches, 58 | [alias("L")] 59 | $LogPathUsr, 60 | [alias("LogRotate")] 61 | $LogHistory, 62 | [alias("Subject")] 63 | $MailSubject, 64 | [alias("SendTo")] 65 | $MailTo, 66 | [alias("From")] 67 | $MailFrom, 68 | [alias("Smtp")] 69 | $SmtpServer, 70 | [alias("Port")] 71 | $SmtpPort, 72 | [alias("User")] 73 | $SmtpUser, 74 | [alias("Pwd")] 75 | [ValidateScript({Test-Path -Path $_ -PathType Leaf})] 76 | $SmtpPwd, 77 | [alias("MakeCreds")] 78 | $MkCr, 79 | [Alias("Webhook")] 80 | [ValidateScript({Test-Path -Path $_ -PathType Leaf})] 81 | [string]$Webh, 82 | [string]$Prefix, 83 | [switch]$AllVms, 84 | [switch]$UseSsl, 85 | [switch]$NoPerms, 86 | [switch]$Compress, 87 | [switch]$Sz, 88 | [switch]$ShortDate, 89 | [switch]$Help, 90 | [switch]$LowDisk, 91 | [switch]$ProgCheck, 92 | [switch]$OptimiseVHD, 93 | [switch]$NoBanner) 94 | 95 | If ($NoBanner -eq $False) 96 | { 97 | Write-Host -ForegroundColor Yellow -BackgroundColor Black -Object " 98 | _ _ __ __ ____ _ _ _ _ _ _ _ _ 99 | | | | | \ \ / / | _ \ | | | | | | | (_) (_) | 100 | | |__| |_ _ _ __ ___ _ _\ \ / / | |_) | __ _ ___| | ___ _ _ __ | | | | |_ _| |_| |_ _ _ 101 | | __ | | | | '_ \ / _ \ '__\ \/ / | _ < / _ |/ __| |/ / | | | '_ \ | | | | __| | | | __| | | | 102 | | | | | |_| | |_) | __/ | \ / | |_) | (_| | (__| <| |_| | |_) | | |__| | |_| | | | |_| |_| | 103 | |_| |_|\__, | .__/ \___|_| \/ |____/ \__,_|\___|_|\_\\__,_| .__/ \____/ \__|_|_|_|\__|\__, | 104 | __/ | | | | __/ | 105 | |___/|_| |_| |___/ 106 | Mike Galvin https://gal.vin Version 24.08.29 107 | Donate: https://www.paypal.me/digressive See -help for usage 108 | " 109 | } 110 | 111 | If ($PSBoundParameters.Values.Count -eq 0 -or $Help) 112 | { 113 | Write-Host -Object " Usage: 114 | From a terminal run: [path\Hyper-V-Backup.ps1] -BackupTo [path] 115 | This will backup all the VMs running to the backup location specified. 116 | 117 | Use -SMBUsr [username] and -SMBPwd [password] to provide authentication to the backup location, such as an SMB share. 118 | 119 | ---- Virtual Machine Selection Options ---- 120 | Use -List [path\vms.txt] to specify a list of vm names to backup. 121 | Use -Prefix [prefix] to specify a list of vm names with a prefix to backup. 122 | Use -AllVMs to specify all VMs to backup. 123 | 124 | Use -CaptureState to specify which method to use when exporting. 125 | Use -Wd [path] to configure a working directory for the backup process. 126 | Use -Keep [number] to specify how many days worth of backup to keep. 127 | Use -ShortDate to use only the Year, Month and Day in backup filenames. 128 | Use -LowDisk to remove old backups before new ones are created. For low disk space situations. 129 | Use -ProgCheck to send notifications (email or webhook) after each VM is backed up. 130 | Use -OptimiseVHD to optimise the VHDs and make them smaller before copy. Must be used with -NoPerms option. 131 | 132 | -NoPerms should only be used when a regular backup cannot be performed. 133 | Please note: this will cause the VMs to shutdown during the backup process. 134 | 135 | ---- Compression Options ---- 136 | Use -Compress to compress the VM backups in a zip file using Windows compression. 137 | Use -Sz to use 7-zip 138 | Use -SzOptions ""'-t7z,-v2g,-ppassword'"" to specify 7-zip options like file type, split files or password. 139 | 140 | ---- Logging Options ---- 141 | To output a log: -L [path]. 142 | To remove logs produced by the utility older than X days: -LogRotate [number]. 143 | Run with no ASCII banner: -NoBanner 144 | 145 | ---- Webhook Options ---- 146 | To send the log to a webhook on job completion: 147 | Specify a txt file containing the webhook URI with -Webhook [path\webhook.txt] 148 | 149 | ---- Email Options ---- 150 | To use the 'email log' function: 151 | Specify the subject line with -Subject ""'[subject line]'"" If you leave this blank a default subject will be used 152 | Make sure to encapsulate it with double & single quotes as per the example for Powershell to read it correctly. 153 | 154 | Specify the 'to' address with -SendTo [example@contoso.com] 155 | For multiple addresses, separate with a comma. 156 | 157 | Specify the 'from' address with -From [example@contoso.com] 158 | Specify the SMTP server with -Smtp [smtp server name] 159 | 160 | Specify the port to use with the SMTP server with -Port [port number]. 161 | If none is specified then the default of 25 will be used. 162 | 163 | Specify the user to access SMTP with -User [example@contoso.com] 164 | Specify the password file to use with -Pwd [path\filename.txt]. 165 | Use SSL for SMTP server connection with -UseSsl. 166 | 167 | ---- How to generate a credentials file for SMTP authentication ---- 168 | To generate an encrypted password file run this script with -MakeCreds filename.txt 169 | on the computer and running as the user that will run the backup." 170 | } 171 | 172 | else { 173 | ## If logging is configured, start logging. 174 | ## If the log file already exists, clear it. 175 | If ($LogPathUsr) 176 | { 177 | ## Clean User entered string 178 | $LogPath = $LogPathUsr.trimend('\') 179 | 180 | ## Make sure the log directory exists. 181 | If ((Test-Path -Path $LogPath) -eq $False) 182 | { 183 | New-Item $LogPath -ItemType Directory -Force | Out-Null 184 | } 185 | 186 | $LogFile = ("Hyper-V-Backup_{0:yyyy-MM-dd_HH-mm-ss}.log" -f (Get-Date)) 187 | $Log = "$LogPath\$LogFile" 188 | 189 | If (Test-Path -Path $Log) 190 | { 191 | Clear-Content -Path $Log 192 | } 193 | } 194 | 195 | ## Function to get date in specific format. 196 | Function Get-DateFormat() 197 | { 198 | Get-Date -Format "yyyy-MM-dd HH:mm:ss" 199 | } 200 | 201 | Function Get-DateShort() 202 | { 203 | Get-Date -Format "yyyy-MM-dd" 204 | } 205 | 206 | Function Get-DateLong() 207 | { 208 | Get-Date -Format "yyyy-MM-dd_HH-mm-ss" 209 | } 210 | 211 | ## Function for logging. 212 | Function Write-Log($Type,$Evt) 213 | { 214 | If ($Type -eq "Info") 215 | { 216 | If ($LogPathUsr) 217 | { 218 | Add-Content -Path $Log -Encoding ASCII -Value "$(Get-DateFormat) [INFO] $Evt" 219 | } 220 | 221 | Write-Host -Object " $(Get-DateFormat) [INFO] $Evt" 222 | } 223 | 224 | If ($Type -eq "Succ") 225 | { 226 | If ($LogPathUsr) 227 | { 228 | Add-Content -Path $Log -Encoding ASCII -Value "$(Get-DateFormat) [SUCCESS] $Evt" 229 | } 230 | 231 | Write-Host -ForegroundColor Green -Object " $(Get-DateFormat) [SUCCESS] $Evt" 232 | } 233 | 234 | If ($Type -eq "Err") 235 | { 236 | If ($LogPathUsr) 237 | { 238 | Add-Content -Path $Log -Encoding ASCII -Value "$(Get-DateFormat) [ERROR] $Evt" 239 | } 240 | 241 | Write-Host -ForegroundColor Red -BackgroundColor Black -Object " $(Get-DateFormat) [ERROR] $Evt" 242 | } 243 | 244 | If ($Type -eq "Conf") 245 | { 246 | If ($LogPathUsr) 247 | { 248 | Add-Content -Path $Log -Encoding ASCII -Value "$Evt" 249 | } 250 | 251 | Write-Host -ForegroundColor Cyan -Object " $Evt" 252 | } 253 | } 254 | 255 | ## Function to optimise the VHD 256 | Function OptimVHD() 257 | { 258 | try { 259 | Write-Log -Type Info -Evt "(VM:$Vm) Optimising VHD(s)" 260 | $VmVhds = Get-VHD -Path $($Vm | Get-VMHardDiskDrive | Select-Object -ExpandProperty "Path") 261 | 262 | ## Loop through each VHD file and optimise 263 | ForEach ($Vhd in $VmVhds) { 264 | Write-Log -Type Info -Evt "(VM:$Vm) Used space before optimising VHD [$($Vhd.Path)] = $([math]::ceiling((Get-VHD -Path $Vhd.Path).FileSize / 1GB )) GB" 265 | Optimize-VHD -Path "$($Vhd.Path)" -Mode Full 266 | Write-Log -Type Info -Evt "(VM:$Vm) Used space after optimising VHD [$($Vhd.Path)] = $([math]::ceiling((Get-VHD -Path $Vhd.Path).FileSize / 1GB )) GB" 267 | $intTotalDisksSize += (Get-VHD -Path $Vhd.Path).FileSize 268 | } 269 | 270 | Write-Log -Type Info -Evt "(VM:$Vm) Done optimising VHD(s)" 271 | } 272 | 273 | catch { 274 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 275 | } 276 | } 277 | 278 | ## Function for Notifications 279 | Function Notify() 280 | { 281 | ## This whole block is for e-mail, if it is configured. 282 | If ($SmtpServer) 283 | { 284 | If (Test-Path -Path $Log) 285 | { 286 | ## Default e-mail subject if none is configured. 287 | If ($Null -eq $MailSubject) 288 | { 289 | $MailSubject = "Hyper-V Backup Utility Log" 290 | } 291 | 292 | ## Default Smtp Port if none is configured. 293 | If ($Null -eq $SmtpPort) 294 | { 295 | $SmtpPort = "25" 296 | } 297 | 298 | ## Setting the contents of the log to be the e-mail body. 299 | $MailBody = Get-Content -Path $Log | Out-String 300 | 301 | ForEach ($MailAddress in $MailTo) 302 | { 303 | ## If an smtp password is configured, get the username and password together for authentication. 304 | ## If an smtp password is not provided then send the e-mail without authentication and obviously no SSL. 305 | If ($SmtpPwd) 306 | { 307 | $SmtpPwdEncrypt = Get-Content $SmtpPwd | ConvertTo-SecureString 308 | $SmtpCreds = New-Object System.Management.Automation.PSCredential -ArgumentList ($SmtpUser, $SmtpPwdEncrypt) 309 | 310 | ## If -ssl switch is used, send the email with SSL. 311 | ## If it isn't then don't use SSL, but still authenticate with the credentials. 312 | If ($UseSsl) 313 | { 314 | Send-MailMessage -To $MailAddress -From $MailFrom -Subject "$MailSubject $Succi/$($Vms.count) VMs Successful" -Body $MailBody -SmtpServer $SmtpServer -Port $SmtpPort -UseSsl -Credential $SmtpCreds 315 | } 316 | 317 | else { 318 | Send-MailMessage -To $MailAddress -From $MailFrom -Subject "$MailSubject $Succi/$($Vms.count) VMs Successful" -Body $MailBody -SmtpServer $SmtpServer -Port $SmtpPort -Credential $SmtpCreds 319 | } 320 | } 321 | 322 | else { 323 | Send-MailMessage -To $MailAddress -From $MailFrom -Subject "$MailSubject $Succi/$($Vms.count) VMs Successful" -Body $MailBody -SmtpServer $SmtpServer -Port $SmtpPort 324 | } 325 | } 326 | } 327 | 328 | else { 329 | Write-Host -ForegroundColor Red -BackgroundColor Black -Object " There's no log file to email." 330 | } 331 | } 332 | ## End of Email block 333 | 334 | ## Webhook block 335 | If ($Webh) 336 | { 337 | $WebHookUri = Get-Content $Webh 338 | $WebHookArr = @() 339 | 340 | $title = "Hyper-V Backup Utility $Succi/$($Vms.count) VMs Successful" 341 | $description = Get-Content -Path $Log | Out-String 342 | 343 | $WebHookObj = [PSCustomObject]@{ 344 | title = $title 345 | description = $description 346 | } 347 | 348 | $WebHookArr += $WebHookObj 349 | $payload = [PSCustomObject]@{ 350 | embeds = $WebHookArr 351 | } 352 | 353 | Invoke-RestMethod -Uri $WebHookUri -Body ($payload | ConvertTo-Json -Depth 2) -Method Post -ContentType 'application/json' 354 | } 355 | } 356 | 357 | ## Function for Update Check 358 | Function UpdateCheck() 359 | { 360 | $ScriptVersion = "24.08.29" 361 | $RawSource = "https://raw.githubusercontent.com/Digressive/HyperV-Backup-Utility/master/Hyper-V-Backup.ps1" 362 | 363 | try { 364 | $SourceCheck = Invoke-RestMethod -uri "$RawSource" 365 | $VerCheck = $SourceCheck -split '\n' | Select-String -Pattern ".VERSION $ScriptVersion" -SimpleMatch -CaseSensitive -Quiet 366 | 367 | If ($VerCheck -ne $True) 368 | { 369 | Write-Log -Type Conf -Evt "-- There is an update available! --" 370 | } 371 | } 372 | 373 | catch { 374 | } 375 | } 376 | 377 | ## 378 | ## Start of backup Options functions 379 | ## 380 | 381 | Function CompressFiles7zip($CompressDateFormat,$CompressDir,$CompressFileName) 382 | { 383 | $7zipOutput = $null 384 | $7zipTestOutput = $null 385 | $CompressFileNameSet = $CompressFileName+$CompressDateFormat 386 | 387 | ## Makeshift error catch for 7zip in PowerShell 388 | $7zipOutput = & "$env:programfiles\7-Zip\7z.exe" $SzSwSplit -bso0 a ("$CompressDir\$CompressFileNameSet") "$CompressDir\$Vm\*" *>&1 389 | 390 | If ($7zipOutput -match "ERROR:") 391 | { 392 | Write-Log -Type Err -Evt "(VM:$Vm) 7zip encountered an error creating the archive" 393 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 2 394 | } 395 | 396 | else { 397 | Set-Variable -Name 'BackupSucc' -Value $true -Scope 2 398 | } 399 | 400 | $GetTheFile = Get-ChildItem -Path $CompressDir -File -Filter "$CompressFileNameSet.*" 401 | 402 | $archivePassword = if ($null -ne $SzSwitches) 403 | { 404 | $password = ($SzSwitches -split ',') | Where-Object { $_ -match '^-p(.*)' } | ForEach-Object { $matches[1] } 405 | if ($password -ne "" -and $null -ne $password) 406 | { 407 | "-p$password" 408 | } 409 | else {""} 410 | } 411 | else {""} 412 | 413 | $7zipTestOutput = & "$env:programfiles\7-Zip\7z.exe" $archivePassword -bso0 t $($GetTheFile.FullName) *>&1 414 | 415 | If ($7zipTestOutput -match "ERROR:") 416 | { 417 | Write-Log -Type Err -Evt "(VM:$Vm) 7zip encountered an error verifying the archive" 418 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 2 419 | } 420 | 421 | else { 422 | Set-Variable -Name 'BackupSucc' -Value $true -Scope 2 423 | } 424 | } 425 | 426 | Function CompressFilesWin($CompressDateFormat,$CompressDir,$CompressFileName) 427 | { 428 | Add-Type -AssemblyName "system.io.compression.filesystem" 429 | 430 | $CompressFileNameSet = $CompressFileName+$CompressDateFormat 431 | ## Windows compression with shortdate 432 | try { 433 | [io.compression.zipfile]::CreateFromDirectory("$CompressDir\$Vm", ("$CompressDir\$CompressFileNameSet.zip")) 434 | Set-Variable -Name 'BackupSucc' -Value $true -Scope 2 435 | } 436 | catch { 437 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 438 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 2 439 | } 440 | } 441 | 442 | Function ShortDateFileNo($ShortDateDir,$ShortDateFilePat) 443 | { 444 | Write-Log -Type Info -Evt "(VM:$Vm) Backup $VmFixed-$(Get-DateShort) already exists, appending number" 445 | $i = 1 446 | $ShortDateNN = ("$VmFixed-$(Get-DateShort)-{0:D3}" -f $i++)+$ShortDateFilePat 447 | $ShortDateExistT = Test-Path -Path $ShortDateDir\$ShortDateNN 448 | 449 | If ($ShortDateExistT) 450 | { 451 | do { 452 | $ShortDateNN = ("$VmFixed-$(Get-DateShort)-{0:D3}" -f $i++)+$ShortDateFilePat 453 | $ShortDateExistT = Test-Path -Path $ShortDateDir\$ShortDateNN 454 | } until ($ShortDateExistT -eq $false) 455 | } 456 | 457 | If ($Compress) 458 | { 459 | If ($Sz -eq $True -AND $7zT -eq $True) 460 | { 461 | If ($SzSwSplit -like "-v*") 462 | { 463 | ## 7-zip compression with shortdate configured and a number appended. 464 | $ShortDateNN7zFix = $ShortDateNN -replace '[.*]' 465 | CompressFiles7zip -CompressDir $ShortDateDir -CompressFileName $ShortDateNN7zFix 466 | } 467 | 468 | else { 469 | ## 7-zip compression with shortdate configured and a number appended. 470 | $ShortDateNN7zFix = $ShortDateNN -replace '[.*]' 471 | CompressFiles7zip -CompressDir $ShortDateDir -CompressFileName $ShortDateNN7zFix 472 | } 473 | } 474 | 475 | else { 476 | ## Windows compression with shortdate configured and a number appended. 477 | $ShortDateNNWinFix = $ShortDateNN.TrimEnd(".zip") 478 | CompressFilesWin -CompressDir $ShortDateDir -CompressFileName $ShortDateNNWinFix 479 | } 480 | } 481 | 482 | else { 483 | try { 484 | Get-ChildItem -Path $ShortDateDir -Filter $Vm -Directory | Rename-Item -NewName ("$ShortDateDir\$ShortDateNN") 485 | } 486 | catch { 487 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 488 | } 489 | 490 | If ($WorkDir -ne $Backup) 491 | { 492 | ## Moving backup folder with shortdate and renaming with number appended. 493 | try { 494 | Get-ChildItem -Path $WorkDir -Filter "$VmFixed-*-*-*" -Directory | Move-Item -Destination $ShortDateDir\$ShortDateNN -ErrorAction 'Stop' 495 | } 496 | catch { 497 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 498 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 499 | } 500 | } 501 | } 502 | } 503 | 504 | Function ReportRemove($RemoveDir,$RemoveFilePat,$RemoveDirOpt,$RemoveHistory) 505 | { 506 | If ($RemoveDirOpt) 507 | { 508 | $RemoveDirOptSet = @{Directory = $true} 509 | } 510 | 511 | else { 512 | $RemoveDirOptSet = @{Directory = $false} 513 | } 514 | 515 | $RemoveFullPath = $VmFixed+$RemoveFilePat 516 | 517 | ## report old files to remove 518 | If ($LogPathUsr) 519 | { 520 | If (Test-Path -Path $RemoveDir) 521 | { 522 | Get-ChildItem -Path $RemoveDir -Filter $RemoveFullPath @RemoveDirOptSet | Where-Object CreationTime -lt (Get-Date).AddDays(-$RemoveHistory) | Select-Object -Property Name, CreationTime | Format-Table -HideTableHeaders | Out-File -Append $Log -Encoding ASCII 523 | } 524 | } 525 | 526 | ## remove old files 527 | If (Test-Path -Path $RemoveDir) 528 | { 529 | Get-ChildItem -Path $RemoveDir -Filter $RemoveFullPath @RemoveDirOptSet | Where-Object CreationTime -lt (Get-Date).AddDays(-$RemoveHistory) | Remove-Item -Recurse -Force 530 | } 531 | } 532 | 533 | Function RemoveOld() 534 | { 535 | ## Remove previous backup folders. -Keep switch and -Compress switch are NOT configured. 536 | If ($Null -eq $History -And $Compress -eq $False) 537 | { 538 | Write-Log -Type Info -Evt "(VM:$Vm) Removing previous backups" 539 | ## Remove all previous backup folders 540 | If ($ShortDate) 541 | { 542 | ReportRemove -RemoveDir $WorkDir -RemoveFilePat "-*-*-*" -RemoveDirOpt $true -RemoveHistory $null 543 | } 544 | 545 | else { 546 | ReportRemove -RemoveDir $WorkDir -RemoveFilePat "-*-*-*_*-*-*" -RemoveDirOpt $true -RemoveHistory $null 547 | } 548 | 549 | ## If working directory is configured by user, remove all previous backup folders 550 | If ($WorkDir -ne $Backup) 551 | { 552 | ## Make sure the backup directory exists. 553 | If (Test-Path -Path $Backup) 554 | { 555 | If ($ShortDate) 556 | { 557 | ReportRemove -RemoveDir $Backup -RemoveFilePat "-*-*-*" -RemoveDirOpt $true -RemoveHistory $null 558 | } 559 | 560 | else { 561 | ReportRemove -RemoveDir $Backup -RemoveFilePat "-*-*-*_*-*-*" -RemoveDirOpt $true -RemoveHistory $null 562 | } 563 | } 564 | } 565 | } 566 | 567 | ## Remove previous backup folders older than X configured days. -Keep switch is configured and -Compress switch is NOT. 568 | else { 569 | If ($Compress -eq $False) 570 | { 571 | Write-Log -Type Info -Evt "(VM:$Vm) Removing backup folders older than: $History days" 572 | 573 | ## Remove previous backup folders older than the configured number of days. 574 | If ($ShortDate) 575 | { 576 | ReportRemove -RemoveDir $WorkDir -RemoveFilePat "-*-*-*" -RemoveDirOpt $true -RemoveHistory $History 577 | } 578 | 579 | else { 580 | ReportRemove -RemoveDir $WorkDir -RemoveFilePat "-*-*-*_*-*-*" -RemoveDirOpt $true -RemoveHistory $History 581 | } 582 | 583 | ## If working directory is configured by user, remove all previous backup folders older than X configured days. 584 | If ($WorkDir -ne $Backup) 585 | { 586 | ## Make sure the backup directory exists. 587 | If (Test-Path -Path $Backup) 588 | { 589 | If ($ShortDate) 590 | { 591 | ReportRemove -RemoveDir $Backup -RemoveFilePat "-*-*-*" -RemoveDirOpt $true -RemoveHistory $History 592 | } 593 | 594 | else { 595 | ReportRemove -RemoveDir $Backup -RemoveFilePat "-*-*-*_*-*-*" -RemoveDirOpt $true -RemoveHistory $History 596 | } 597 | } 598 | } 599 | } 600 | } 601 | 602 | ## Remove ALL previous backup files. -Keep switch is NOT configured and -Compress switch IS. 603 | If ($Compress) 604 | { 605 | If ($Null -eq $History) 606 | { 607 | Write-Log -Type Info -Evt "(VM:$Vm) Removing all previous compressed backups" 608 | 609 | ## Remove all previous compressed backups 610 | If ($ShortDate) 611 | { 612 | Remove-Item "$WorkDir\$VmFixed-*-*-*.*" -Force 613 | } 614 | 615 | else { 616 | Remove-Item "$WorkDir\$VmFixed-*-*-*_*-*-*.*" -Force 617 | } 618 | 619 | ## If working directory is configured by user, remove all previous backup files. 620 | If ($WorkDir -ne $Backup) 621 | { 622 | ## Make sure the backup directory exists. 623 | If (Test-Path -Path $Backup) 624 | { 625 | If ($ShortDate) 626 | { 627 | Remove-Item "$Backup\$VmFixed-*-*-*.*" -Force 628 | } 629 | 630 | else { 631 | Remove-Item "$Backup\$VmFixed-*-*-*_*-*-*.*" -Force 632 | } 633 | } 634 | } 635 | } 636 | 637 | ## Remove previous backup files older than X days. -Keep and -Compress switch are configured. 638 | else { 639 | Write-Log -Type Info -Evt "(VM:$Vm) Removing compressed backups older than: $History days" 640 | 641 | ## Remove previous compressed backups older than the configured number of days. 642 | If ($ShortDate) 643 | { 644 | ReportRemove -RemoveDir $WorkDir -RemoveFilePat "-*-*-*.*" -RemoveDirOpt $false -RemoveHistory $History 645 | } 646 | 647 | else { 648 | ReportRemove -RemoveDir $WorkDir -RemoveFilePat "-*-*-*_*-*-*.*" -RemoveDirOpt $false -RemoveHistory $History 649 | } 650 | 651 | ## If working directory is configured by user, remove previous backup files older than X days. 652 | If ($WorkDir -ne $Backup) 653 | { 654 | ## Make sure the backup directory exists. 655 | If (Test-Path -Path $Backup) 656 | { 657 | If ($ShortDate) 658 | { 659 | ReportRemove -RemoveDir $Backup -RemoveFilePat "-*-*-*.*" -RemoveDirOpt $false -RemoveHistory $History 660 | } 661 | 662 | else { 663 | ReportRemove -RemoveDir $Backup -RemoveFilePat "-*-*-*_*-*-*.*" -RemoveDirOpt $false -RemoveHistory $History 664 | } 665 | } 666 | } 667 | } 668 | } 669 | } 670 | 671 | Function OptionsRun() 672 | { 673 | If ($Compress) 674 | { 675 | ## If -Compress and -Sz are configured AND 7-zip is installed - compress the backup folder, if it isn't fallback to Windows compression. 676 | If ($Sz -eq $True -AND $7zT -eq $True) 677 | { 678 | Write-Log -Type Info -Evt "(VM:$Vm) Compressing backup using 7-Zip compression" 679 | 680 | ## If -Shortdate is configured, test for an old backup file, if true append a number (and increase the number if file still exists) before the file extension. 681 | If ($ShortDate) 682 | { 683 | ## If using 7zip's split file feature with short dates, we need to handle the files a little differently. 684 | If ($SzSwSplit -like "-v*") 685 | { 686 | $ShortDateT = Test-Path -Path ("$WorkDir\$VmFixed-$(Get-DateShort).*.*") 687 | 688 | If ($ShortDateT) 689 | { 690 | ShortDateFileNo -ShortDateDir $WorkDir -ShortDateFilePat ".*.*" 691 | } 692 | 693 | else { 694 | CompressFiles7zip(Get-DateShort) -CompressDir $WorkDir -CompressFileName "$VmFixed-$CompressDateFormat" 695 | } 696 | } 697 | 698 | else 699 | { 700 | $ShortDateT = Test-Path -Path ("$WorkDir\$VmFixed-$(Get-DateShort).*") 701 | 702 | If ($ShortDateT) 703 | { 704 | ShortDateFileNo -ShortDateDir $WorkDir -ShortDateFilePat ".*" 705 | } 706 | 707 | CompressFiles7zip(Get-DateShort) -CompressDir $WorkDir -CompressFileName "$VmFixed-$CompressDateFormat" 708 | } 709 | } 710 | 711 | else { 712 | CompressFiles7zip(Get-DateLong) -CompressDir $WorkDir -CompressFileName "$VmFixed-$CompressDateFormat" 713 | } 714 | } 715 | 716 | ## Compress the backup folder using Windows compression. -Compress is configured, -Sz switch is not, or it is and 7-zip isn't detected. 717 | ## This is also the "fallback" windows compression code. 718 | else { 719 | Write-Log -Type Info -Evt "(VM:$Vm) Compressing backup using Windows compression" 720 | 721 | If ($ShortDate) 722 | { 723 | $ShortDateT = Test-Path -Path ("$WorkDir\$VmFixed-$(Get-DateShort).zip") 724 | 725 | If ($ShortDateT) 726 | { 727 | ShortDateFileNo -ShortDateDir $WorkDir -ShortDateFilePat ".zip" 728 | } 729 | 730 | else { 731 | CompressFilesWin(Get-DateShort) -CompressDir $WorkDir -CompressFileName "$VmFixed-$CompressDateFormat" 732 | } 733 | } 734 | 735 | else { 736 | CompressFilesWin(Get-DateLong) -CompressDir $WorkDir -CompressFileName "$VmFixed-$CompressDateFormat" 737 | } 738 | } 739 | 740 | ## After being compressed, if success remove the VMs export folder. 741 | If ($BackupSucc) 742 | { 743 | Get-ChildItem -Path $WorkDir -Filter "$Vm" -Directory | Remove-Item -Recurse -Force 744 | } 745 | 746 | else { 747 | Write-Log -Type Err -Evt "(VM:$Vm) Compressing backup failed." 748 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 749 | } 750 | 751 | ## If working directory has been configured by the user, move the compressed backup to the backup folder and rename to include the date. 752 | If ($WorkDir -ne $Backup) 753 | { 754 | ## Make sure the backup directory exists. 755 | If ((Test-Path -Path $Backup) -eq $False) 756 | { 757 | Write-Log -Type Info -Evt "Backup directory $Backup doesn't exist. Creating it." 758 | New-Item $Backup -ItemType Directory -Force | Out-Null 759 | } 760 | 761 | ## Get the exact name of the backup file and append numbers onto the filename, keeping the extension intact. 762 | ## This contains special code to do the shortDate renaming with any 7-zip split files. 763 | If ($ShortDate) 764 | { 765 | If ($SzSwSplit -like "-v*") 766 | { 767 | $SzSplitFiles = Get-ChildItem -Path ("$WorkDir\$VmFixed-$(Get-DateShort).*.*") -File 768 | 769 | ForEach ($SplitFile in $SzSplitFiles) { 770 | $ShortDateT = Test-Path -Path "$Backup\$($SplitFile.name)" 771 | $split7zArray = $SplitFile.basename.Split(".") 772 | $archType = $split7zArray[1] 773 | 774 | If ($ShortDateT) 775 | { 776 | Write-Log -Type Info -Evt "(VM:$Vm) File: $($SplitFile.name) already exists, appending number" 777 | $FileExist = Get-ChildItem -Path "$Backup\$($SplitFile.name)" -File 778 | $i = 1 779 | 780 | $ShortDateNN = ("$VmFixed-$(Get-DateShort)-{0:D3}" -f $i++ + "." + $archType + $FileExist.Extension) 781 | $ShortDateExistT = Test-Path -Path $Backup\$ShortDateNN 782 | 783 | If ($ShortDateExistT) 784 | { 785 | do { 786 | $ShortDateNN = ("$VmFixed-$(Get-DateShort)-{0:D3}" -f $i++ + "." + $archType + $FileExist.Extension) 787 | $ShortDateExistT = Test-Path -Path $Backup\$ShortDateNN 788 | } until ($ShortDateExistT -eq $false) 789 | } 790 | 791 | try { 792 | Get-ChildItem -Path $SplitFile | Move-Item -Destination $Backup\$ShortDateNN -ErrorAction 'Stop' 793 | } 794 | catch { 795 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 796 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 797 | } 798 | } 799 | 800 | else { 801 | try { 802 | Get-ChildItem -Path $SplitFile | Move-Item -Destination $Backup\$ShortDateNN -ErrorAction 'Stop' 803 | } 804 | catch { 805 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 806 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 807 | } 808 | } 809 | } 810 | } 811 | 812 | else { 813 | $BackupFile = Get-ChildItem -Path ("$WorkDir\$VmFixed-$(Get-DateShort).*") -File 814 | $BackupFileN = $BackupFile.name 815 | $BackupFileNSplit = $BackupFileN.split(".") 816 | 817 | $ShortDateT = Test-Path -Path $Backup\$BackupFileN 818 | 819 | If ($ShortDateT) 820 | { 821 | Write-Log -Type Info -Evt "(VM:$Vm) File: $BackupFileN already exists, appending number" 822 | $FileExist = Get-ChildItem -Path $BackupFile -File 823 | $i = 1 824 | 825 | If ($Null -eq $BackupFileNSplit[2]) 826 | { 827 | $ShortDateNN = ("$VmFixed-$(Get-DateShort)-{0:D3}" -f $i++ + $FileExist.Extension) 828 | $ShortDateExistT = Test-Path -Path $Backup\$ShortDateNN 829 | } 830 | else { 831 | $ShortDateNN = ("$VmFixed-$(Get-DateShort)-{0:D3}" -f $i++ + "." + $BackupFileNSplit[1] + $FileExist.Extension) 832 | $ShortDateExistT = Test-Path -Path $Backup\$ShortDateNN 833 | } 834 | 835 | If ($ShortDateExistT) 836 | { 837 | If ($Null -eq $BackupFileNSplit[2]) 838 | { 839 | do { 840 | $ShortDateNN = ("$VmFixed-$(Get-DateShort)-{0:D3}" -f $i++ + $FileExist.Extension) 841 | $ShortDateExistT = Test-Path -Path $Backup\$ShortDateNN 842 | } until ($ShortDateExistT -eq $false) 843 | } 844 | else { 845 | do { 846 | $ShortDateNN = ("$VmFixed-$(Get-DateShort)-{0:D3}" -f $i++ + "." + $BackupFileNSplit[1] + $FileExist.Extension) 847 | $ShortDateExistT = Test-Path -Path $Backup\$ShortDateNN 848 | } until ($ShortDateExistT -eq $false) 849 | } 850 | } 851 | 852 | ## Move with shortdate and appended number 853 | try { 854 | Get-ChildItem -Path $BackupFile | Move-Item -Destination $Backup\$ShortDateNN -ErrorAction 'Stop' 855 | } 856 | catch { 857 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 858 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 859 | } 860 | } 861 | 862 | ## Move with shortdate 863 | try { 864 | Get-ChildItem -Path $WorkDir -Filter "$VmFixed-*-*-*.*" | Move-Item -Destination $Backup -ErrorAction 'Stop' 865 | } 866 | catch { 867 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 868 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 869 | } 870 | } 871 | } 872 | 873 | ## Move with long date 874 | else { 875 | try { 876 | Get-ChildItem -Path $WorkDir -Filter "$VmFixed-*-*-*_*-*-*.*" | Move-Item -Destination $Backup -ErrorAction 'Stop' 877 | } 878 | catch { 879 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 880 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 881 | } 882 | } 883 | } 884 | } 885 | 886 | ## -Compress switch is NOT configured and the -Keep switch is configured. 887 | ## Rename the export of each VM to include the date. 888 | else { 889 | If ($ShortDate) 890 | { 891 | $ShortDateT = Test-Path -Path ("$WorkDir\$VmFixed-$(Get-DateShort)") 892 | 893 | If ($ShortDateT) 894 | { 895 | ShortDateFileNo -ShortDateDir $WorkDir -ShortDateFilePat $null 896 | } 897 | 898 | try { 899 | Get-ChildItem -Path $WorkDir -Filter $Vm -Directory | Rename-Item -NewName ("$WorkDir\$VmFixed-$(Get-DateShort)") 900 | } 901 | catch { 902 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 903 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 904 | } 905 | } 906 | 907 | else { 908 | try { 909 | Get-ChildItem -Path $WorkDir -Filter $Vm -Directory | Rename-Item -NewName ("$WorkDir\$VmFixed-$(Get-DateLong)") 910 | } 911 | catch { 912 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 913 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 914 | } 915 | } 916 | 917 | ## If working directory has been configured by the user, move the backup to the backup folder and rename to include the date. 918 | If ($WorkDir -ne $Backup) 919 | { 920 | ## Make sure the backup directory exists. 921 | If ((Test-Path -Path $Backup) -eq $False) 922 | { 923 | Write-Log -Type Info -Evt "Backup directory $Backup doesn't exist. Creating it." 924 | New-Item $Backup -ItemType Directory -Force | Out-Null 925 | } 926 | 927 | If ($ShortDate) 928 | { 929 | $ShortDateT = Test-Path -Path ("$Backup\$VmFixed-$(Get-DateShort)") 930 | 931 | If ($ShortDateT) 932 | { 933 | ShortDateFileNo -ShortDateDir $Backup -ShortDateFilePat $null 934 | } 935 | 936 | ## Moving backup folder with shortdate 937 | try { 938 | Get-ChildItem -Path $WorkDir -Filter "$VmFixed-*-*-*" -Directory | Move-Item -Destination ("$Backup\$VmFixed-$(Get-DateShort)") -ErrorAction 'Stop' 939 | } 940 | catch { 941 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 942 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 943 | } 944 | } 945 | 946 | ## Moving backup folder with longdate 947 | else { 948 | try { 949 | Get-ChildItem -Path $WorkDir -Filter "$VmFixed-*-*-*_*-*-*" -Directory | Move-Item -Destination ("$Backup\$VmFixed-$(Get-DateLong)") -ErrorAction 'Stop' 950 | } 951 | catch { 952 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 953 | Set-Variable -Name 'BackupSucc' -Value $false -Scope 1 954 | } 955 | } 956 | } 957 | } 958 | } 959 | 960 | Function CredsGen() 961 | { 962 | $credsGen = Get-Credential 963 | $credsGen.Password | ConvertFrom-SecureString | Set-Content $PSScriptRoot\$MkCr 964 | 965 | If ($null -eq $credsGen) 966 | { 967 | Write-Log -Type Err -Evt "No credentials were specified." 968 | } 969 | 970 | else { 971 | Write-Log -Type Succ -Evt "Credentials file created: $PSScriptRoot\$MkCr" 972 | } 973 | } 974 | 975 | ## 976 | ## End of backup Options functions 977 | ## 978 | 979 | ## getting Windows Version info 980 | $OSVMaj = [environment]::OSVersion.Version | Select-Object -expand major 981 | $OSVMin = [environment]::OSVersion.Version | Select-Object -expand minor 982 | $OSVBui = [environment]::OSVersion.Version | Select-Object -expand build 983 | $OSV = "$OSVMaj" + "." + "$OSVMin" + "." + "$OSVBui" 984 | 985 | ## Run make creds function to generate an encrypted password file 986 | If ($MkCr) 987 | { 988 | CredsGen 989 | Exit 990 | } 991 | 992 | If ($null -eq $BackupUsr) 993 | { 994 | Write-Log -Type Err -Evt "You must specify -BackupTo [path]." 995 | Exit 996 | } 997 | 998 | else { 999 | ## Test for Hyper-V feature installed on local machine. 1000 | ## Old version of Win Serv have a different service name. 1001 | try { 1002 | If ($OSV -eq "6.3.9600") 1003 | { 1004 | Get-Service vmms -ErrorAction Stop | Out-Null 1005 | } 1006 | 1007 | else { 1008 | Get-Service vmcompute -ErrorAction Stop | Out-Null 1009 | } 1010 | } 1011 | 1012 | catch { 1013 | Write-Log -Type Err -Evt "Hyper-V is not installed on this local machine." 1014 | Exit 1015 | } 1016 | 1017 | If ($Compress -eq $false -And $Sz -eq $true) 1018 | { 1019 | Write-Log -Type Err -Evt "You must specify -Compress to use -Sz." 1020 | Exit 1021 | } 1022 | 1023 | If ($Sz -eq $false -And $Null -ne $SzSwitches) 1024 | { 1025 | Write-Log -Type Err -Evt "You must specify -Sz to use -SzOptions." 1026 | Exit 1027 | } 1028 | 1029 | If ($Null -eq $LogPathUsr -And $Null -ne $LogHistory) 1030 | { 1031 | Write-Log -Type Err -Evt "You must specify -L [path] to use -LogRotate [number]." 1032 | Exit 1033 | } 1034 | 1035 | If ($Null -eq $LogPathUsr -And $SmtpServer) 1036 | { 1037 | Write-Log -Type Err -Evt "You must specify -L [path] to use the email log function." 1038 | Exit 1039 | } 1040 | 1041 | If ($Null -eq $LogPathUsr -And $Webh) 1042 | { 1043 | Write-Log -Type Err -Evt "You must specify -L [path] to use send the log to a webhook." 1044 | Exit 1045 | } 1046 | 1047 | If ($NoPerms -eq $false -And $OptimiseVHD -eq $true) 1048 | { 1049 | Write-Log -Type Err -Evt "You must specify -NoPerms to use -OptimiseVHD." 1050 | Exit 1051 | } 1052 | 1053 | If ($NoPerms -eq $true -And $Null -ne $CaptureStateOpt) 1054 | { 1055 | Write-Log -Type Err -Evt "You cannot use -CaptureState Options with -NoPerms. They will have no effect." 1056 | Exit 1057 | } 1058 | 1059 | ## Smb Mapping with credentials 1060 | If ($SMBPwd -And $SMBUsr) 1061 | { 1062 | New-SmbMapping -RemotePath "$BackupUsr" -UserName $SMBUsr -Password $SMBPwd | Out-Null 1063 | } 1064 | 1065 | ## Clean User entered string 1066 | If ($BackupUsr) 1067 | { 1068 | $Backup = $BackupUsr.trimend('\') 1069 | } 1070 | 1071 | If ($WorkDirUsr) 1072 | { 1073 | $WorkDir = $WorkDirUsr.trimend('\') 1074 | } 1075 | } 1076 | 1077 | ## Setting an easier to use variable for computer name of the Hyper-V server. 1078 | $Vs = $Env:ComputerName 1079 | 1080 | ## VM selection section 1081 | ## If a VM list file is configured, get the content of the file, otherwise move on to the next option. 1082 | ## Clean list if it has empty lines. 1083 | If ($VmList -And $AllVms -Or $Prefix) 1084 | { 1085 | Write-Log -Type Err -Evt "With a VM list file configured, -AllVMs and -Prefix will have no effect." 1086 | } 1087 | 1088 | If ($VmList) 1089 | { 1090 | $Vms = Get-Content $VmList | Where-Object {$_.trim() -ne ""} 1091 | } 1092 | 1093 | else { 1094 | If ($Prefix) 1095 | { 1096 | $Vms = Get-VM | Where-Object {$_.Name.StartsWith($Prefix)} | Select-Object -ExpandProperty Name 1097 | } 1098 | 1099 | else { 1100 | If ($AllVms) 1101 | { 1102 | $Vms = Get-VM | Select-Object -ExpandProperty Name 1103 | } 1104 | 1105 | else { 1106 | $Vms = Get-VM | Where-Object {$_.State -eq 'Running'} | Select-Object -ExpandProperty Name 1107 | } 1108 | } 1109 | } 1110 | 1111 | ## Check to see if there are any VMs to process. 1112 | If ($Vms.count -ne 0) 1113 | { 1114 | ## If the user has not configured the working directory, set it as the backup directory. 1115 | If ($Null -eq $WorkDir) 1116 | { 1117 | $WorkDir = "$Backup" 1118 | } 1119 | 1120 | If ($Null -eq $ShortDate) 1121 | { 1122 | $ShortDate = "$LongDate" 1123 | } 1124 | 1125 | If ($SzSwitches) 1126 | { 1127 | $SzSwSplit = $SzSwitches.split(",") 1128 | } 1129 | 1130 | If ($Sz -eq $True) 1131 | { 1132 | $7zT = Test-Path -Path "$env:programfiles\7-Zip\7z.exe" 1133 | } 1134 | 1135 | ## 1136 | ## Display the current config and log if configured. 1137 | ## 1138 | Write-Log -Type Conf -Evt "--- Running with the following config ---" 1139 | Write-Log -Type Conf -Evt "Utility Version: 24.08.29" 1140 | UpdateCheck ## Run Update checker function 1141 | Write-Log -Type Conf -Evt "Hostname: $Vs." 1142 | Write-Log -Type Conf -Evt "Windows Version: $OSV." 1143 | 1144 | If ($Prefix) 1145 | { 1146 | Write-Log -Type Conf -Evt "Prefix config: $Prefix." 1147 | } 1148 | 1149 | If ($AllVms) 1150 | { 1151 | Write-Log -Type Conf -Evt "All VMs will be backed up." 1152 | } 1153 | 1154 | If ($Vms) 1155 | { 1156 | Write-Log -Type Conf -Evt "No. of VMs: $($Vms.count)." 1157 | Write-Log -Type Conf -Evt "VMs to backup:" 1158 | ForEach ($Vm in $Vms) 1159 | { 1160 | Write-Log -Type Conf -Evt "$Vm" 1161 | } 1162 | } 1163 | 1164 | If ($BackupUsr) 1165 | { 1166 | Write-Log -Type Conf -Evt "Backup directory: $BackupUsr." 1167 | } 1168 | 1169 | If ($SMBUsr -And $SMBPwd) 1170 | { 1171 | Write-Log -Type Conf -Evt "SMB Auth: Configured" 1172 | } 1173 | 1174 | If ($WorkDirUsr) 1175 | { 1176 | Write-Log -Type Conf -Evt "Working directory: $WorkDirUsr." 1177 | } 1178 | 1179 | If ($CaptureStateOpt) 1180 | { 1181 | Write-Log -Type Conf -Evt "Export-VM Options: $CaptureStateOpt." 1182 | } 1183 | 1184 | If ($NoPerms) 1185 | { 1186 | Write-Log -Type Conf -Evt "-NoPerms switch: $NoPerms." 1187 | } 1188 | 1189 | If ($ShortDate) 1190 | { 1191 | Write-Log -Type Conf -Evt "-ShortDate switch: $ShortDate." 1192 | } 1193 | 1194 | If ($LowDisk) 1195 | { 1196 | Write-Log -Type Conf -Evt "-LowDisk switch: $LowDisk." 1197 | } 1198 | 1199 | If ($Compress) 1200 | { 1201 | Write-Log -Type Conf -Evt "-Compress switch: $Compress." 1202 | } 1203 | 1204 | If ($Sz) 1205 | { 1206 | Write-Log -Type Conf -Evt "-Sz switch: $Sz." 1207 | } 1208 | 1209 | If ($Sz) 1210 | { 1211 | Write-Log -Type Conf -Evt "7-zip installed: $7zT." 1212 | } 1213 | 1214 | If ($SzSwitches) 1215 | { 1216 | Write-Log -Type Conf -Evt "7-zip Options: $SzSwitches." 1217 | } 1218 | 1219 | If ($Null -ne $History) 1220 | { 1221 | Write-Log -Type Conf -Evt "Backups to keep: $History days" 1222 | } 1223 | 1224 | If ($LogPathUsr) 1225 | { 1226 | Write-Log -Type Conf -Evt "Logs directory: $LogPathUsr." 1227 | } 1228 | 1229 | If ($Webh) 1230 | { 1231 | Write-Log -Type Conf -Evt "Webhook: Configured" 1232 | } 1233 | 1234 | If ($MailTo) 1235 | { 1236 | Write-Log -Type Conf -Evt "E-mail log to: $MailTo." 1237 | } 1238 | 1239 | If ($MailFrom) 1240 | { 1241 | Write-Log -Type Conf -Evt "E-mail log from: $MailFrom." 1242 | } 1243 | 1244 | If ($MailSubject) 1245 | { 1246 | Write-Log -Type Conf -Evt "E-mail subject: $MailSubject." 1247 | } 1248 | 1249 | If ($SmtpServer) 1250 | { 1251 | Write-Log -Type Conf -Evt "SMTP server: Configured" 1252 | } 1253 | 1254 | If ($SmtpUser) 1255 | { 1256 | Write-Log -Type Conf -Evt "SMTP auth: Configured" 1257 | } 1258 | Write-Log -Type Conf -Evt "---" 1259 | Write-Log -Type Info -Evt "Process started" 1260 | ## 1261 | ## Display current config ends here. 1262 | ## 1263 | 1264 | ## For Success/Fail stats. 1265 | $Succi = 0 1266 | $Faili = 0 1267 | 1268 | ## 1269 | ## -NoPerms process starts here. 1270 | ## 1271 | ## If the -NoPerms switch is set, start a custom process to copy all the VM data. 1272 | If ($NoPerms) 1273 | { 1274 | ForEach ($Vm in $Vms) 1275 | { 1276 | $VmTested = Get-VM -Name $Vm -ErrorAction SilentlyContinue 1277 | If ($null -eq $VmTested) 1278 | { 1279 | Write-Log -Type Err -Evt "VM name: $Vm was not found. Skipping backup." 1280 | $Faili = $Faili+1 1281 | } 1282 | 1283 | else { 1284 | ## Get VM info. 1285 | $VhdSize = Get-VHD -Path $($Vm | Get-VMHardDiskDrive | Select-Object -ExpandProperty "Path") | Select-Object @{Name = "FileSizeGB"; Expression = {[math]::ceiling($_.FileSize/1GB)}}, @{Name = "MaxSizeGB"; Expression = {[math]::ceiling($_.Size/1GB)}} 1286 | Write-Log -Type Info -Evt "(VM:$Vm) has [$((Get-VMProcessor $Vm).Count)] CPU cores, [$([math]::ceiling((Get-VMMemory $Vm).Startup / 1gb))GB] RAM, Storage: [Current Size = $($VhdSize.FileSizeGB)GB - Max Size = $($VhdSize.MaxSizeGB)GB]" 1287 | 1288 | $VmFixed = $Vm.replace(".","-") 1289 | $VmInfo = Get-VM -Name $Vm 1290 | 1291 | ## Remove old backups first if -LowDisk is configured. 1292 | If ($LowDisk) 1293 | { 1294 | RemoveOld 1295 | } 1296 | 1297 | ## Test for the existence of a previous VM export. If it exists, delete it. 1298 | If (Test-Path -Path "$WorkDir\$Vm") 1299 | { 1300 | Remove-Item "$WorkDir\$Vm" -Recurse -Force 1301 | } 1302 | 1303 | ## Create directories for the VM export. 1304 | try { 1305 | New-Item "$WorkDir\$Vm" -ItemType Directory -Force | Out-Null 1306 | New-Item "$WorkDir\$Vm\Virtual Machines" -ItemType Directory -Force | Out-Null 1307 | New-Item "$WorkDir\$Vm\Virtual Hard Disks" -ItemType Directory -Force | Out-Null 1308 | New-Item "$WorkDir\$Vm\Snapshots" -ItemType Directory -Force | Out-Null 1309 | $BackupSucc = $true 1310 | } 1311 | catch { 1312 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 1313 | $BackupSucc = $false 1314 | } 1315 | 1316 | ## Check for VM running 1317 | If (Get-VM | Where-Object {$VmInfo.State -eq 'Running'}) 1318 | { 1319 | $VMwasRunning = $true 1320 | Write-Log -Type Info -Evt "(VM:$Vm) VM is running, saving state" 1321 | Stop-VM -Name $Vm -Save 1322 | } 1323 | 1324 | else { 1325 | $VMwasRunning = $false 1326 | Write-Log -Type Info -Evt "(VM:$Vm) VM not running" 1327 | } 1328 | 1329 | ## If -OptimiseVHD option is set attempt to optimise the VMs VHDs 1330 | If ($OptimiseVHD) 1331 | { 1332 | OptimVHD 1333 | } 1334 | 1335 | ## 1336 | ## Copy the VM config files and log if there is an error. 1337 | ## 1338 | 1339 | ## Check for VM being in the correct state before continuing 1340 | $VmState = Get-Vm -Name $Vm 1341 | 1342 | If ($VmState.State -ne 'Off' -OR $VmState.State -ne 'Saved' -AND $VmState.Status -ne 'Operating normally') 1343 | { 1344 | do { 1345 | Write-Log -Type Err -Evt "(VM:$Vm) VM not in the desired state. Waiting 60 seconds..." 1346 | Start-Sleep -S 60 1347 | } until ($VmState.State -eq 'Off' -OR $VmState.State -eq 'Saved' -AND $VmState.Status -eq 'Operating normally') 1348 | } 1349 | 1350 | $StartTime = $(get-date) 1351 | 1352 | try { 1353 | Write-Log -Type Info -Evt "(VM:$Vm) Copying config files" 1354 | Copy-Item "$($VmInfo.ConfigurationLocation)\Virtual Machines\$($VmInfo.id)" "$WorkDir\$Vm\Virtual Machines\" -Recurse -Force 1355 | Copy-Item "$($VmInfo.ConfigurationLocation)\Virtual Machines\$($VmInfo.id).*" "$WorkDir\$Vm\Virtual Machines\" -Recurse -Force 1356 | $BackupSucc = $true 1357 | } 1358 | catch { 1359 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 1360 | $BackupSucc = $false 1361 | } 1362 | ## 1363 | ## End of VM config files. 1364 | ## 1365 | 1366 | ## 1367 | ## Copy the VHDs and log if there is an error. 1368 | ## 1369 | try { 1370 | Write-Log -Type Info -Evt "(VM:$Vm) Copying VHD files" 1371 | Copy-Item $VmInfo.HardDrives.Path -Destination "$WorkDir\$Vm\Virtual Hard Disks\" -Recurse -Force 1372 | $BackupSucc = $true 1373 | } 1374 | catch { 1375 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 1376 | $BackupSucc = $false 1377 | } 1378 | ## 1379 | ## End of VHDs. 1380 | ## 1381 | 1382 | ## Get the VM snapshots/checkpoints. 1383 | $Snaps = Get-VMSnapshot $Vm 1384 | 1385 | ForEach ($Snap in $Snaps) 1386 | { 1387 | ## 1388 | ## Copy the snapshot config files and log if there is an error. 1389 | ## 1390 | try { 1391 | Write-Log -Type Info -Evt "(VM:$Vm) Copying Snapshot config files" 1392 | Copy-Item "$($VmInfo.ConfigurationLocation)\Snapshots\$($Snap.id)" "$WorkDir\$Vm\Snapshots\" -Recurse -Force 1393 | Copy-Item "$($VmInfo.ConfigurationLocation)\Snapshots\$($Snap.id).*" "$WorkDir\$Vm\Snapshots\" -Recurse -Force 1394 | $BackupSucc = $true 1395 | } 1396 | catch { 1397 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 1398 | $BackupSucc = $false 1399 | } 1400 | ## 1401 | ## End of snapshot config. 1402 | ## 1403 | 1404 | ## Copy the snapshot root VHD. 1405 | try { 1406 | Write-Log -Type Info -Evt "(VM:$Vm) Copying Snapshot root VHD files" 1407 | Copy-Item $Snap.HardDrives.Path -Destination "$WorkDir\$Vm\Virtual Hard Disks\" -Recurse -Force -ErrorAction 'Stop' 1408 | $BackupSucc = $true 1409 | } 1410 | catch { 1411 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 1412 | $BackupSucc = $false 1413 | } 1414 | } 1415 | 1416 | If ($VMwasRunning) 1417 | { 1418 | Write-Log -Type Info -Evt "(VM:$Vm) Starting VM" 1419 | Start-VM $Vm 1420 | Write-Log -Type Info -Evt "(VM:$Vm) Waiting 60 seconds..." 1421 | Start-Sleep -S 60 1422 | } 1423 | 1424 | ## Remove old backups if -LowDisk is NOT configured 1425 | If ($LowDisk -eq $false) 1426 | { 1427 | RemoveOld 1428 | } 1429 | 1430 | If ($BackupSucc) 1431 | { 1432 | OptionsRun 1433 | } 1434 | 1435 | If ($BackupSucc) 1436 | { 1437 | Write-Log -Type Succ -Evt "(VM:$Vm) Backup Successful" 1438 | $Succi = $Succi+1 1439 | } 1440 | else { 1441 | Write-Log -Type Err -Evt "(VM:$Vm) Backup failed" 1442 | $Faili = $Faili+1 1443 | } 1444 | 1445 | $elapsedTime = $(get-date) - $StartTime 1446 | $totalTime = "{0:HH:mm:ss}" -f ([datetime]$elapsedTime.Ticks) 1447 | Write-Log -Type Info -Evt "(VM:$Vm) Processed in $totalTime" 1448 | 1449 | If ($ProgCheck) 1450 | { 1451 | Notify 1452 | } 1453 | } 1454 | } 1455 | } 1456 | ## 1457 | ## -NoPerms process ends here. 1458 | ## 1459 | 1460 | ## 1461 | ## Standard export process starts here. 1462 | ## 1463 | ## If the -NoPerms switch is NOT set, for each VM check for the existence of a previous export. 1464 | ## If it exists then delete it, otherwise the export will fail. 1465 | else { 1466 | ForEach ($Vm in $Vms) 1467 | { 1468 | $VmTested = Get-VM -Name $Vm -ErrorAction SilentlyContinue 1469 | If ($null -eq $VmTested) 1470 | { 1471 | Write-Log -Type Err -Evt "VM name: $Vm was not found. Could not get any info." 1472 | } 1473 | 1474 | else { 1475 | ## Get VM info 1476 | $VhdSize = Get-VHD -Path $($Vm | Get-VMHardDiskDrive | Select-Object -ExpandProperty "Path") | Select-Object @{Name = "FileSizeGB"; Expression = {[math]::ceiling($_.FileSize/1GB)}}, @{Name = "MaxSizeGB"; Expression = {[math]::ceiling($_.Size/1GB)}} 1477 | Write-Log -Type Info -Evt "(VM:$Vm) has [$((Get-VMProcessor $Vm).Count)] CPU cores, [$([math]::ceiling((Get-VMMemory $Vm).Startup / 1gb))GB] RAM, Storage: [Current Size = $($VhdSize.FileSizeGB)GB - Max Size = $($VhdSize.MaxSizeGB)GB]" 1478 | 1479 | If (Test-Path -Path "$WorkDir\$Vm") 1480 | { 1481 | Remove-Item "$WorkDir\$Vm" -Recurse -Force 1482 | } 1483 | 1484 | If ($WorkDir -ne $Backup) 1485 | { 1486 | If (Test-Path -Path "$Backup\$Vm") 1487 | { 1488 | Remove-Item "$Backup\$Vm" -Recurse -Force 1489 | } 1490 | } 1491 | } 1492 | } 1493 | 1494 | ## If default key is already null, then disable VSS Legacy Tracing on Windows Server 2016 to prevent possible BSOD on Hyper-V Host. 1495 | ## Don't want to mess up anyone's config. :) 1496 | If ($OSV -eq "10.0.14393") 1497 | { 1498 | If ($null -eq (Get-ItemProperty -LiteralPath HKLM:\System\CurrentControlSet\Services\VSS\Diag\).'(Default)') 1499 | { 1500 | $RegVSSFix = $True 1501 | Set-ItemProperty -Path HKLM:\System\CurrentControlSet\Services\VSS\Diag -Name "(default)" -Value "Disabled" 1502 | Write-Log -Type Info -Evt "Disabling VSS Legacy Tracing on Windows Server 2016 to prevent possible BSOD on Hyper-V Host." 1503 | } 1504 | } 1505 | 1506 | ## Do a regular export of the VMs. 1507 | ForEach ($Vm in $Vms) 1508 | { 1509 | $VmTested = Get-VM -Name $Vm -ErrorAction SilentlyContinue 1510 | If ($null -eq $VmTested) 1511 | { 1512 | Write-Log -Type Err -Evt "VM name: $Vm was not found. Skipping backup." 1513 | $Faili = $Faili+1 1514 | } 1515 | 1516 | else { 1517 | $VmFixed = $Vm.replace(".","-") 1518 | 1519 | ## Remove old backups if -LowDisk is configured 1520 | If ($LowDisk) 1521 | { 1522 | RemoveOld 1523 | } 1524 | 1525 | $StartTime = $(get-date) 1526 | 1527 | try { 1528 | Write-Log -Type Info -Evt "(VM:$Vm) Attempting to export VM" 1529 | If ($Null -ne $CaptureStateOpt) 1530 | { 1531 | $Vm | Export-VM -CaptureLiveState $CaptureStateOpt -Path "$WorkDir" -ErrorAction 'Stop' 1532 | } 1533 | else { 1534 | $Vm | Export-VM -Path "$WorkDir" -ErrorAction 'Stop' 1535 | } 1536 | $BackupSucc = $true 1537 | } 1538 | catch { 1539 | $_.Exception.Message | Write-Log -Type Err -Evt "(VM:$Vm) $_" 1540 | $BackupSucc = $false 1541 | } 1542 | 1543 | ## Remove old backups if -LowDisk is NOT configured 1544 | If ($LowDisk -eq $false) 1545 | { 1546 | RemoveOld 1547 | } 1548 | 1549 | If ($BackupSucc) 1550 | { 1551 | OptionsRun 1552 | } 1553 | 1554 | If ($BackupSucc) 1555 | { 1556 | Write-Log -Type Succ -Evt "(VM:$Vm) Export Successful" 1557 | $Succi = $Succi+1 1558 | } 1559 | else { 1560 | Write-Log -Type Err -Evt "(VM:$Vm) Export failed" 1561 | $Faili = $Faili+1 1562 | } 1563 | 1564 | $elapsedTime = $(get-date) - $StartTime 1565 | $totalTime = "{0:HH:mm:ss}" -f ([datetime]$elapsedTime.Ticks) 1566 | Write-Log -Type Info -Evt "(VM:$Vm) Processed in $totalTime" 1567 | 1568 | If ($ProgCheck) 1569 | { 1570 | Notify 1571 | } 1572 | } 1573 | } 1574 | 1575 | ## If the VSS fix was run, return regkey back to original state. 1576 | If ($OSV -eq "10.0.14393") 1577 | { 1578 | If ($RegVSSFix) 1579 | { 1580 | REG DELETE "HKEY_LOCAL_MACHINE\System\CurrentControlSet\Services\VSS\Diag" /ve /f 1581 | Write-Log -Type Info -Evt "Returning VSS Legacy Tracing config to default." 1582 | } 1583 | } 1584 | } 1585 | ## 1586 | ## End of standard export block 1587 | ## 1588 | } 1589 | 1590 | ## If there are no VMs, then do nothing. 1591 | else { 1592 | Write-Log -Type Err -Evt "There are no VMs running to backup" 1593 | } 1594 | 1595 | Write-Log -Type Info -Evt "Process finished." 1596 | Write-Log -Type Info -Evt "Number of VMs to Backup:$($Vms.count)" 1597 | If ($($Vms.count) -ne 0) 1598 | { 1599 | Write-Log -Type Info -Evt "Backups Successful:$Succi" 1600 | Write-Log -Type Info -Evt "Backups Failed:$Faili" 1601 | } 1602 | 1603 | If ($Null -ne $LogHistory) 1604 | { 1605 | ## Clean up logs. 1606 | Write-Log -Type Info -Evt "Deleting logs older than: $LogHistory days" 1607 | Get-ChildItem -Path "$LogPath\Hyper-V-Backup_*" -File | Where-Object CreationTime -lt (Get-Date).AddDays(-$LogHistory) | Remove-Item -Recurse 1608 | } 1609 | 1610 | If ($ProgCheck -eq $false) 1611 | { 1612 | Notify 1613 | } 1614 | } 1615 | ## End -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2024 Mike Galvin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hyper-V Backup Utility 2 | 3 | ## Flexible Hyper-V Backup Utility 4 | 5 | For full change log and more information, [visit my site.](https://gal.vin/utils/hyperv-backup-utility/) 6 | 7 | Hyper-V Backup Utility is available from: 8 | 9 | * [GitHub](https://github.com/Digressive/HyperV-Backup-Utility) 10 | * [The Microsoft PowerShell Gallery](https://www.powershellgallery.com/packages/Hyper-V-Backup) 11 | 12 | Please consider supporting my work: 13 | 14 | * Support with [Github Sponsors](https://github.com/sponsors/Digressive). 15 | * Support with a one-time donation using [PayPal](https://www.paypal.me/digressive). 16 | 17 | Please report any problems via the ‘issues’ tab on GitHub. 18 | 19 | -Mike 20 | 21 | ## Features and Requirements 22 | 23 | * Designed to be run on a Hyper-V host. 24 | * The Hyper-V host must have the Hyper-V management PowerShell modules installed. 25 | * Can be used to backup VMs to a device which the Hyper-V host does not have permission to run a regular export to. 26 | * Supports Hyper-V hosts in a clustered configuration. 27 | * The utility requires at least Windows PowerShell 5.0. 28 | * Tested on Windows 11, Windows 10, Windows Server 2022, Windows Server 2019 and Windows Server 2016. 29 | * The backup log can be sent via email and/or webhook. 30 | 31 | ## SMB share support 32 | 33 | An SMB share can be used as a backup location by specifying the UNC path (\\\server\share) with the -BackupTo option. Please note that you must use either a local working directory (-Wd path) or the -NoPerms option if your SMB share host and Hyper-V host are not members of an Active Directory domain. Hyper-V cannot perform export VMs to a network share without the Hyper-V host having permissions to the share which are possible when using Windows servers and Active Directory and not available with a NAS appliance such as TrueNAS, QNAP or Synology. 34 | 35 | ## When to use the -NoPerms option 36 | 37 | The -NoPerms switch is intended as a workaround when used in an environment where the Hyper-V host cannot be given the required permissions to run a regular export to a remote device such as a NAS device. To copy all the files necessary for a complete backup, the VM must be in an offline state for the operation to be completed. Therefore the script will put the VM in a 'Saved' state (if it is running) so the files can be copied. In previous versions the VM would be shutdown but this is a faster and safer method as the VM does not require any integrations to be put in a saved state. 38 | 39 | Hyper-V’s export operation requires that the computer account in Active Directory have access to the location where the exports are being stored. When a NAS is intended to be used as an export location, Hyper-V will not be able to complete the operation as the computer account cannot be given access to the share on the NAS. 40 | 41 | ## 7-Zip support 42 | 43 | I've implemented support for 7-Zip into the script. You should be able to use any option that 7-zip supports, although currently the only options I've tested fully are '-t' archive type, '-p' password and '-v' split files. 44 | 45 | ## Generating A Credentials File For SMTP Authentication 46 | 47 | The password used for SMTP server authentication must be in an encrypted text file. To generate the password file, run the command below in PowerShell on the same computer and logged in with the user that will be running the utility. When you run the command, you will be prompted for a username and password. Enter the username and password you want to use to authenticate to your SMTP server. 48 | 49 | Please note: This is only required if you need to authenticate to the SMTP server when sending the log via e-mail. 50 | 51 | ``` powershell 52 | [path\]Hyper-V-Backup.ps1 -MakeCreds [filename.txt] 53 | ``` 54 | 55 | After running the commands, you will have a text file containing the encrypted password. When configuring the -Pwd switch enter the path and file name of this file. 56 | 57 | ## Restoring a virtual machine from backup 58 | 59 | The easiest and quickest way to restore a Virtual Machine that has been backed up using this script is to use Hyper-V's native import function. 60 | 61 | 1. Copy the backup of the VM you want to restore to a location on the VM host server that the VM should run from. If the backup is compressed, uncompress the file. 62 | 2. In the Hyper-V Manager, right-click on the VM host and select 'Import Virtual Machine'. 63 | 3. Browse to the location of the VM backup folder and click Next. 64 | 4. Select the VM you want to restore. 65 | 5. Select 'Register the virtual machine in-place' option. 66 | 6. The VM will be registered in Hyper-V and available for use. 67 | 68 | ## Configuration 69 | 70 | Here’s a list of all the command line switches and example configurations. 71 | 72 | | Command Line Switch | Description | Example | 73 | | ------------------- | ----------- | ------- | 74 | | -BackupTo | The path the virtual machines should be backed up to. Each VM will have its own folder inside this location. This can be an SMB share but you must also use either -Wd or -NoPerms. | [path] or [\\\server\path] | 75 | | -SMBUsr | Enter the domain and user required to access the SMB share. Use only if required. | domain\user | 76 | | -SMBPwd | Enter the password for the above account required to access the SMB share. Use only if required. | password | 77 | | -AllVms | If this option is configured all VMs will be backed up. | N/A | 78 | | -Prefix | Use this option to specify VM names beginning with the string specified to backup. | [string] | 79 | | -List | Enter the path to a txt file with a list of Hyper-V VM names to backup. If this option, -Prefix and -AllVms are not configured, all running VMs will be backed up. | [path\vms.txt] | 80 | | -CaptureState | Enter a method to use when exporting the VM. If this option is not configured, the default method will be used. | CaptureCrashConsistentState, CaptureSavedState, CaptureDataConsistentState | 81 | | -Wd | The path to the working directory to use for the backup before copying it to the final backup directory. Use a directory on local fast media to improve performance. | [path] | 82 | | -NoPerms | Configures the utility to shut down running VMs to do the file-copy based backup instead of using the Hyper-V export function. | N/A | 83 | | -Keep | Instructs the utility to keep a specified number of days worth of backups. VM backups older than the number of days specified will be deleted. | [number] | 84 | | -Compress | This option will create a zip file of each Hyper-V VM backup. | N/A | 85 | | -Sz | Configure the utility to use 7-Zip to compress the VM backups. 7-Zip must be installed in the default location ```$env:ProgramFiles``` if it is not found, Windows compression will be used. | N/A | 86 | | -SzOptions | Use this switch to configure options for 7-Zip. The switches must be comma separated. | "'-t7z,-v2G,-ppassword'" | 87 | | -ShortDate | Configure the script to use only the Year, Month and Day in backup filenames. | N/A | 88 | | -LowDisk | Remove old backups before new ones are created. For low disk space situations. | N/A | 89 | | -L | The path to output the log file to. | [path] | 90 | | -LogRotate | Remove logs produced by the utility older than X days | [number] | 91 | | -NoBanner | Use this option to hide the ASCII art title in the console. | N/A | 92 | | -Help | Display usage information. No arguments also displays help. | N/A | 93 | | -ProgCheck | Send notifications (email or webhook) after each VM is backed up. | N/A | 94 | | -OptimiseVHD | Optimise the VHDs and make them smaller before copy. Must be used with -NoPerms option. | N/A | 95 | | -Webhook | The txt file containing the URI for a webhook to send the log file to. | [path\webhook.txt] | 96 | | -Subject | Specify a subject line. If you leave this blank the default subject will be used | "'[Server: Notification]'" | 97 | | -SendTo | The e-mail address the log should be sent to. For multiple address, separate with a comma. | [example@contoso.com] | 98 | | -From | The e-mail address the log should be sent from. | [example@contoso.com] | 99 | | -Smtp | The DNS name or IP address of the SMTP server. | [smtp server address] | 100 | | -Port | The Port that should be used for the SMTP server. If none is specified then the default of 25 will be used. | [port number] | 101 | | -User | The user account to authenticate to the SMTP server. | [example@contoso.com] | 102 | | -Pwd | The txt file containing the encrypted password for SMTP authentication. | [path\ps-script-pwd.txt] | 103 | | -UseSsl | Configures the utility to connect to the SMTP server using SSL. | N/A | 104 | | -MakeCreds | Use this option to create a credentials file for SMTP authentication. The file will be created in the same directory as the script. | [filename.txt] | 105 | 106 | ## How to use 107 | 108 | ``` txt 109 | [path\]Hyper-V-Backup.ps1 -BackupTo [path] 110 | ``` 111 | 112 | This will backup all the VMs running to the backup location specified. 113 | 114 | ## Change Log 115 | 116 | ### 2024-08-29: Version 24.08.29 117 | 118 | * Added Sponsorship information to the projects Github. 119 | * Added SMB Authentication, based on work from [PR 37](https://github.com/Digressive/HyperV-Backup-Utility/pull/37) 120 | * Added -MakeCreds option to help in creation of SMTP authentication file. 121 | * Added -AllVms option to specify the backing up of all VMs on a Hyper-V server without the need for a list file. 122 | * Added -Prefix option to backup all VMs beginning with the string specified. 123 | * Added more information about Hyper-V's export limitations to the documentation and in app help. 124 | 125 | ### 2024-05-11: Version 24.05.11 126 | 127 | * Fixed an issue where backup success would be reported when using a working directory and the final destination directory for the backup didn't have enough disk space. From [Issue 27](https://github.com/Digressive/HyperV-Backup-Utility/issues/27) 128 | 129 | ### 2024-03-21: Version 24.03.21 130 | 131 | * Added -CaptureState option for the user to specify the method that Export-VM uses to capture the state of the VM whilst running. From [Issue 34](https://github.com/Digressive/HyperV-Backup-Utility/issues/34) 132 | 133 | ### 2024-03-18: Version 24.03.18 134 | 135 | * Added fix for verifying password protected 7-Zip archives from [Issue 33](https://github.com/Digressive/HyperV-Backup-Utility/issues/33) 136 | 137 | ### 2024-03-08: Version 24.03.08 138 | 139 | * Fixed 7-Zip split files getting renamed and not keeping file extensions when short dates are used. 140 | * Added a verify operation for 7-Zip created archives as per [Issue 33](https://github.com/Digressive/HyperV-Backup-Utility/issues/33) 141 | * Fixed an issue where failed backups where also listed as successful. 142 | * Overhauled the backup success/fail checks. They now work a lot more reliably. 143 | * Added check for the work dir/backup dir to exist before trying to remove as this caused a script error. 144 | * Cleaned up console and log file output. 145 | 146 | ### 2023-09-05: Version 23.09.05 147 | 148 | * Added new features from [Issue 28](https://github.com/Digressive/HyperV-Backup-Utility/issues/28) 149 | * Added -ProgCheck option. With this option set, notifications will be sent after each VM is backup is finished. 150 | * Added backup time duration to the script output. 151 | * Added -OptimiseVHD option to shrink the size of the VHDs. Can only be used the the -NoPerms option as the VM must be offline to optimise the VHDs. 152 | 153 | ### 2023-04-28: Version 23.04.28 154 | 155 | * Minor improvement to update checker. If the internet is not reachable it silently errors out. 156 | 157 | ### 2023-02-18: Version 23.02.18 158 | 159 | * Removed specific SMTP config info from config report. [Issue 24](https://github.com/Digressive/HyperV-Backup-Utility/issues/24) 160 | * Added a "simple auth edition" version of the script. [Issue 25](https://github.com/Digressive/HyperV-Backup-Utility/issues/25) 161 | 162 | ### 2023-02-07: Version 23.02.07 163 | 164 | * Removed SMTP authentication details from the 'Config' report. Now it just shows as 'configured' if SMTP user is configured. To be clear: no passwords were ever shown or stored in plain text. 165 | 166 | ### 2023-01-09: Version 23.01.09 167 | 168 | * Added script update checker - shows if an update is available in the log and console. 169 | * Added VM restore instructions to readme.md. 170 | * Added "low disk space" mode. -LowDisk switch deletes previous backup files and folders before backup for systems with low disk space. 171 | * Added webhook option to send log file to. 172 | * Lot's of refactored code using functions. Simpler, easier to manage. Long overdue. 173 | * Fixed bug that started VMs that were shutdown. 174 | * Changed "VM not running" from an error state to an informational state. 175 | * Changed "Backup Success" to a success state (green text in console). 176 | * Changed -NoPerms so that VMs are now saved instead of shutdown (safer, faster, does not require Hyper-V integrations) 177 | 178 | ### 2022-06-22: Version 22.06.22 179 | 180 | * Fixed an issue with the code checking for OS version too late. 181 | 182 | ### 2022-06-18: Version 22.06.18 183 | 184 | * Fixed Get-Service check outputting to console. 185 | * Fixed backup success/fail counter not working with -NoPerms switch. 186 | 187 | ### 2022-06-17: Version 22.06.17 188 | 189 | * Fixed an issue with Windows Server 2012 R2 when checking for the Hyper-V service to be installed and running. 190 | 191 | ### 2022-06-14: Version 22.06.11 192 | 193 | * Fixed [Issue 19 on GitHub](https://github.com/Digressive/HyperV-Backup-Utility/issues/19) - All Virtual Hard Disk folders should now be called "Virtual Hard Disks" and not some with the name "VHD". 194 | * Fixed [Issue 20 on GitHub](https://github.com/Digressive/HyperV-Backup-Utility/issues/20) - If -L [path\] not configured then a non fatal error would occur as no log path was specified for the log to be output to. 195 | * Fixed an issue where a VM would not be backed up if it were in the state "saved" and was present in the user configured VM list text file. 196 | * Added user feedback - make backup success or fail clear in the log and console. 197 | * Added user feedback - add "VMs backed up x/x" to email subject for clear success/fail visibility. 198 | * Added user feedback - Log can now be emailed to multiple addresses. 199 | * Added checks and balances to help with configuration as I'm very aware that the initial configuration can be troublesome. Running the utility manually is a lot more friendly and step-by-step now. 200 | * Added -Help to give usage instructions in the terminal. Running the script with no options will also trigger the -help switch. 201 | * Cleaned user entered paths so that trailing slashes no longer break things or have otherwise unintended results. 202 | * Added -LogRotate [days] to removed old logs created by the utility. 203 | * Streamlined config report so non configured options are not shown. 204 | * Added donation link to the ASCII banner. 205 | * Cleaned up code, removed unneeded log noise. 206 | 207 | ### 2022-03-27: Version 22.03.26 208 | 209 | * Made a small fix to the 'NoPerms' function: The VM will be left in the state it was found. For example, when a VM is found in an offline state, the script will not start the VM once the backup is complete. In the previous version the VM would be started regardless of what state it was in previously. 210 | 211 | ### 2022-02-08: Version 22.02.08 212 | 213 | * Added fix for potential BSOD on Windows Server 2016 Hyper-V host when exporting VMs using VSS. The change to the registry will only happen if Windows Server 2016 is detected as the Hyper-V host and only if the registry value is in the default state. If it has been configured previously no change will be made. [Issue 17 on GitHub](https://github.com/Digressive/HyperV-Backup-Utility/issues/17) 214 | 215 | ### 2022-01-20: Version 22.01.19 216 | 217 | * When using -NoPerms the utility now waits for disk merging to complete before backing up. 218 | * Utility now ignores blanks lines in VM list file. 219 | * Added checks for success or failure in the backup, copy/compression process. If it fails none of the previous backups should be removed. 220 | 221 | ### 2021-12-28: Version 21.12.28 222 | 223 | * Put checks in place so if a VM fails to backup the old backup for that VM is not removed and the error is logged. 224 | 225 | ### 2021-11-12: Version 21.11.09 226 | 227 | * Added more logging info, clearer formatting. 228 | 229 | ### 2021-11-05: Version 21.11.05 230 | 231 | * Fixed an error when moving compressed backup files from a working directory. 232 | * Configured logs path now is created, if it does not exist. 233 | * Added OS version info. 234 | * Improved log output, added more information for each stage of the backup. 235 | 236 | ### 2021-08-10: Version 21.08.10 237 | 238 | * Added an option to specify the Port for SMTP communication. 239 | 240 | ### 2021-07-02: Version 21.07.02 241 | 242 | * Fixed many bugs introduced with implementing more 7-zip options. 7-zip options I've tested fully are '-t' archive type, '-p' password and '-v' split files. 243 | * Implemented and automated a formal testing process. 244 | 245 | ### 2021-06-14: Version 21.06.14 246 | 247 | * Replaced -Sz* specific options with -SzOptions which will support any option that 7-zip supports. 248 | 249 | ### 2021-06-02: Version 21.06.02 250 | 251 | * Fixed an error where file types which are not .zip were not being moved from the working directory to the final backup location. 252 | 253 | ### 2021-05-30: Version 21.05.30 254 | 255 | * Added additional 7-Zip options. -SzSplit to split archives into configuration volumes. 256 | * Changed existing switches for 7-Zip options. Users must now add an additional hyphen '-' for 7-Zip options. This has been done to better support features that 7-Zip supports. 257 | * Changed how old files are removed. Users should take extra care if they are storing non back-up files in the backup location. This has been done so that 7-Zip's split function can be supported. 258 | 259 | ### 2020-07-13: Version 20.07.13 260 | 261 | * Added -ShortDate option. This will create backups with only the Year, Month, Day as the file name. 262 | * Added pass through for 7-Zip options - CPU threads to use and compression level. 263 | * Added proper error handling so errors are properly reported in the console, log and email. 264 | * Bug fixes to create folders when paths are configured without the folders existing. 265 | 266 | ### 2020-02-28: Version 20.02.28 ‘Artifact’ 267 | 268 | * Fixed e-mail report extra line breaks in Outlook 365, Version 2001. 269 | * Config report matches design of Image Factory Utility. 270 | * Improved and simplified code. 271 | 272 | ### 2020-02-18: Version 2020.02.14 ‘Valentine’ 273 | 274 | Current known issues: 275 | 276 | * E-mail report has extra line breaks in Outlook 365, Version 2001. 277 | 278 | New features: 279 | 280 | * Refactored code. 281 | * Fully backwards compatible. 282 | * Added option to use a working directory to stage backups before moving them to final backup location. 283 | * Added option to use 7-Zip for backup compression. 284 | * Added ASCII banner art when run in the console. 285 | * Added option to disable the ASCII banner art. 286 | 287 | ### 2019-09-04 v4.5 288 | 289 | * Added custom subject line for e-mail. 290 | 291 | ### 2019-05-26 v4.4 292 | 293 | * Added more feedback when the script is used interactively. 294 | 295 | ### 2018-06-21 v4.3 296 | 297 | * Added the ability to specify the VMs to be backed up using a txt file. 298 | 299 | ### 2018-03-04 v4.2 300 | 301 | * Improved logging slightly to be clearer about which VM's previous backups are being deleted. 302 | 303 | ### 2018-03-03 v4.1 304 | 305 | * Added option to compress the VM backups to a zip file. This option will remove the original VM backup. 306 | * Added option to keep a configurable number of days’ worth of backups, so you can keep a history/archive of previous backups. Every effort has been taken to only remove backup files or folders generated by this utility. 307 | * Changed the script so that when backup is complete, the VM backup folders/zip files will be have the time and date append to them. 308 | 309 | ### 2018-01-15 v4.0 310 | 311 | * The backup script no longer creates a folder named after the Host server. The VM backups are placed in the root of the specified backup location. 312 | * Fixed a small issue with logging where the script completes the backup process, then states incorrectly "there are no VMs to backup". 313 | 314 | ### 2018-01-12 v3.9 315 | 316 | * Fixed a small bug that occurred when there were no VMs to backup, the script incorrectly logged an error in exporting the VMs. It now states that that are no VMs to backup. 317 | 318 | ### 2018-01-12 v3.8 319 | 320 | * The script has been tested performing backups of Virtual Machines running on a Hyper-V cluster. 321 | * Minor update to documentation. 322 | 323 | ### 2017-10-16 v3.7 324 | 325 | * Changed SMTP authentication to require an encrypted password file. 326 | * Added instructions on how to generate an encrypted password file. 327 | 328 | ### 2017-10-07 v3.6 329 | 330 | * Added necessary information to add the script to the PowerShell Gallery. 331 | 332 | ### 2017-09-18 v3.5 333 | 334 | * Improved the log output to be easier to read. 335 | 336 | ### 2017-07-22 v3.4 337 | 338 | * Improved commenting on the code for documentation purposes. 339 | * Added authentication and SSL options for e-mail notification. 340 | 341 | ### 2017-05-20 v3.3 342 | 343 | * Added configuration via command line switches. 344 | * Added option to perform regular online export if destination allows it. 345 | 346 | ### 2017-04-24 Minor Update 347 | 348 | * Cleaned up the formatting and commented sections of the script. 349 | 350 | ### 2017-04-21 Minor Update 351 | 352 | * Added the ability to email the log file when the script completes. 353 | -------------------------------------------------------------------------------- /vms-example.txt: -------------------------------------------------------------------------------- 1 | VMName1 2 | Web01 3 | WSUS01 4 | File-Server.Domain.com 5 | -------------------------------------------------------------------------------- /webhook.txt: -------------------------------------------------------------------------------- 1 | https://discord.gg/webhook/a-lot-of-letters-and-numbers --------------------------------------------------------------------------------