(bugfix): hyper-v backup and file sync scripts improvements

This commit is contained in:
Maksym Sadovnychyy 2026-01-26 18:44:27 +01:00
parent 20c7419b75
commit de1add2172
11 changed files with 271 additions and 310 deletions

183
README.md
View File

@ -28,13 +28,11 @@ Designed for system administrators — and also for those who *feel like* system
- [Logging](#logging) - [Logging](#logging)
- [Contact](#contact) - [Contact](#contact)
- [License](#license) - [License](#license)
- [Appendix](#appendix)
- [SchedulerTemplate.psm1 (Full Source)](#schedulertemplatepsm1-full-source)
## Scripts Examples ## Scripts Examples
- [Scheduler Template Module](./examples/SchedulerTemplate.psm1)
- [Hyper-V Backup](./examples/HyperV-Backup/README.md) - Production-ready Hyper-V VM backup solution with scheduling and retention management - [Hyper-V Backup](./examples/HyperV-Backup/README.md) - Production-ready Hyper-V VM backup solution with scheduling and retention management
- [File-Sync](./examples//File-Sync/README.md) - [FreeFileSync](https://freefilesync.org/) batch job execution - [File-Sync](./examples/File-Sync/README.md) - [FreeFileSync](https://freefilesync.org/) batch job execution
--- ---
@ -235,180 +233,3 @@ Maksym Sadovnychyy MAKS-IT
maksym.sadovnychyy@gmail.com maksym.sadovnychyy@gmail.com
--- ---
# Appendix
## SchedulerTemplate.psm1 (Full Source)
```powershell
# ======================================================================
# SchedulerTemplate.psm1 - Scheduling + Lock + Interval + Callback Runner
# ======================================================================
function Write-Log {
param(
[string]$Message,
[switch]$Automated,
[string]$Color = 'White'
)
if ($Automated) {
Write-Output $Message
}
else {
Write-Host $Message -ForegroundColor $Color
}
}
function Get-CurrentUtcDateTime {
param([string]$ExternalDateTime, [switch]$Automated)
if ($ExternalDateTime) {
try {
return [datetime]::Parse($ExternalDateTime).ToUniversalTime()
}
catch {
try {
return [datetime]::ParseExact($ExternalDateTime, 'dd/MM/yyyy HH:mm:ss', $null).ToUniversalTime()
}
catch {
Write-Log "Failed to parse CurrentDateTimeUtc ('$ExternalDateTime'). Using system time (UTC)." -Automated:$Automated -Color 'Red'
return (Get-Date).ToUniversalTime()
}
}
}
return (Get-Date).ToUniversalTime()
}
function Test-ScheduleMonth { param([datetime]$DateTime, [array]$Months)
$name = $DateTime.ToString('MMMM')
return ($Months.Count -eq 0) -or ($Months -contains $name)
}
function Test-ScheduleWeekday { param([datetime]$DateTime, [array]$Weekdays)
$name = $DateTime.DayOfWeek.ToString()
return ($Weekdays.Count -eq 0) -or ($Weekdays -contains $name)
}
function Test-ScheduleTime { param([datetime]$DateTime, [array]$Times)
$t = $DateTime.ToString('HH:mm')
return ($Times.Count -eq 0) -or ($Times -contains $t)
}
function Test-Schedule {
param(
[datetime]$DateTime,
[array]$RunMonth,
[array]$RunWeekday,
[array]$RunTime
)
return (Test-ScheduleMonth -DateTime $DateTime -Months $RunMonth) -and
(Test-ScheduleWeekday -DateTime $DateTime -Weekdays $RunWeekday) -and
(Test-ScheduleTime -DateTime $DateTime -Times $RunTime)
}
function Test-Interval {
param([datetime]$LastRun,[datetime]$Now,[int]$MinIntervalMinutes)
return $Now -ge $LastRun.AddMinutes($MinIntervalMinutes)
}
function Test-ScheduledExecution {
param(
[switch]$Automated,
[string]$CurrentDateTimeUtc,
[hashtable]$Config,
[string]$LastRunFilePath
)
$now = Get-CurrentUtcDateTime -ExternalDateTime $CurrentDateTimeUtc -Automated:$Automated
$shouldRun = $true
if ($Automated) {
Write-Log "Automated: $Automated" -Automated:$Automated -Color 'Green'
Write-Log "Current UTC Time: $now" -Automated:$Automated -Color 'Green'
if (-not (Test-Schedule -DateTime $now -RunMonth $Config.RunMonth -RunWeekday $Config.RunWeekday -RunTime $Config.RunTime)) {
Write-Log "Execution skipped due to schedule." -Automated:$Automated -Color 'Yellow'
$shouldRun = $false
}
}
if ($shouldRun -and $LastRunFilePath -and (Test-Path $LastRunFilePath)) {
$lastRun = Get-Content $LastRunFilePath | Select-Object -First 1
if ($lastRun) {
[datetime]$lr = $lastRun
if (-not (Test-Interval -LastRun $lr -Now $now -MinIntervalMinutes $Config.MinIntervalMinutes)) {
Write-Log "Last run at $lr. Interval not reached." -Automated:$Automated -Color 'Yellow'
$shouldRun = $false
}
}
}
return @{
ShouldExecute = $shouldRun
Now = $now
}
}
function New-LockGuard {
param([string]$LockFile,[switch]$Automated)
if (Test-Path $LockFile) {
Write-Log "Guard: Lock file exists ($LockFile). Skipping." -Automated:$Automated -Color 'Red'
return $false
}
try {
New-Item -Path $LockFile -ItemType File -Force | Out-Null
return $true
}
catch {
Write-Log "Guard: Cannot create lock file ($LockFile)." -Automated:$Automated -Color 'Red'
return $false
}
}
function Remove-LockGuard {
param([string]$LockFile,[switch]$Automated)
if (Test-Path $LockFile) {
Remove-Item $LockFile -Force
Write-Log "Lock removed: $LockFile" -Automated:$Automated -Color 'Cyan'
}
}
# ======================================================================
# Main unified executor (callback-based)
# ======================================================================
function Invoke-ScheduledExecution {
param(
[scriptblock]$ScriptBlock,
[hashtable]$Config,
[switch]$Automated,
[string]$CurrentDateTimeUtc
)
$scriptPath = $MyInvocation.ScriptName
$lastRunFile = [IO.Path]::ChangeExtension($scriptPath, ".lastRun")
$lockFile = [IO.Path]::ChangeExtension($scriptPath, ".lock")
# Check schedule
$schedule = Test-ScheduledExecution -Automated:$Automated -CurrentDateTimeUtc $CurrentDateTimeUtc -Config $Config -LastRunFilePath $lastRunFile
if (-not $schedule.ShouldExecute) {
Write-Log "Execution skipped." -Automated:$Automated -Color 'Yellow'
return
}
# Lock
if (-not (New-LockGuard -LockFile $lockFile -Automated:$Automated)) {
return
}
try {
$schedule.Now.ToString("o") | Set-Content $lastRunFile
& $ScriptBlock
}
finally {
Remove-LockGuard -LockFile $lockFile -Automated:$Automated
}
}
Export-ModuleMember -Function * -Alias *
```

View File

@ -1,7 +1,7 @@
# File Sync Script # File Sync Script
**Version:** 1.0.0 **Version:** 1.0.1
**Last Updated:** 2026-01-24 **Last Updated:** 2026-01-26
## Overview ## Overview
@ -361,6 +361,11 @@ Run with verbose output:
- Dynamic batch file path updates - Dynamic batch file path updates
- Prerequisite validation - Prerequisite validation
### 1.0.1 (2026-01-26)
- Improve UNC path validation for network share connections
- Code formatting improvements for better readability
- Refactored parameter splatting for Invoke-ScheduledExecution
## Support ## Support
For issues or questions: For issues or questions:

View File

@ -3,8 +3,8 @@ setlocal EnableDelayedExpansion
REM ============================================================================ REM ============================================================================
REM File Sync Launcher REM File Sync Launcher
REM VERSION: 1.0.0 REM VERSION: 1.0.1
REM DATE: 2026-01-24 REM DATE: 2026-01-26
REM DESCRIPTION: Batch file launcher for file-sync.ps1 with admin check REM DESCRIPTION: Batch file launcher for file-sync.ps1 with admin check
REM ============================================================================ REM ============================================================================

View File

@ -12,9 +12,9 @@ param (
.DESCRIPTION .DESCRIPTION
Production-ready file synchronization solution with scheduling and secure credential management. Production-ready file synchronization solution with scheduling and secure credential management.
.VERSION .VERSION
1.0.0 1.0.1
.DATE .DATE
2026-01-24 2026-01-26
.NOTES .NOTES
- Requires FreeFileSync installed - Requires FreeFileSync installed
- Requires SchedulerTemplate.psm1 module - Requires SchedulerTemplate.psm1 module
@ -22,8 +22,8 @@ param (
#> #>
# Script Version # Script Version
$ScriptVersion = "1.0.0" $ScriptVersion = "1.0.1"
$ScriptDate = "2026-01-24" $ScriptDate = "2026-01-26"
try { try {
Import-Module "$PSScriptRoot\..\SchedulerTemplate.psm1" -Force -ErrorAction Stop Import-Module "$PSScriptRoot\..\SchedulerTemplate.psm1" -Force -ErrorAction Stop
@ -135,6 +135,12 @@ function Connect-NasShare {
return $true return $true
} }
# Validate UNC path format
if (-not (Test-UNCPath -Path $SharePath)) {
Write-Log "Invalid UNC path format: $SharePath (expected \\server\share)" -Level Error -Automated:$Automated
return $false
}
Write-Log "Authenticating to NAS share: $SharePath" -Level Info -Automated:$Automated Write-Log "Authenticating to NAS share: $SharePath" -Level Info -Automated:$Automated
# Validate credential environment variable name is configured # Validate credential environment variable name is configured
@ -211,14 +217,14 @@ function Start-FreeFileSyncProcess {
try { try {
# Use ProcessStartInfo to ensure that no window is shown # Use ProcessStartInfo to ensure that no window is shown
$psi = New-Object System.Diagnostics.ProcessStartInfo $psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $FreeFileSyncExe $psi.FileName = $FreeFileSyncExe
$psi.Arguments = "`"$FfsBatchFile`"" $psi.Arguments = "`"$FfsBatchFile`""
$psi.WorkingDirectory = [System.IO.Path]::GetDirectoryName($FreeFileSyncExe) $psi.WorkingDirectory = [System.IO.Path]::GetDirectoryName($FreeFileSyncExe)
$psi.UseShellExecute = $false $psi.UseShellExecute = $false
$psi.CreateNoWindow = $true $psi.CreateNoWindow = $true
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden $psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
$psi.RedirectStandardOutput = $true $psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true $psi.RedirectStandardError = $true
$proc = [System.Diagnostics.Process]::Start($psi) $proc = [System.Diagnostics.Process]::Start($psi)
$global:FFS_Process = $proc $global:FFS_Process = $proc
@ -356,11 +362,13 @@ function Start-BusinessLogic {
if ($Automated) { if ($Automated) {
if (Get-Command Invoke-ScheduledExecution -ErrorAction SilentlyContinue) { if (Get-Command Invoke-ScheduledExecution -ErrorAction SilentlyContinue) {
Invoke-ScheduledExecution ` $params = @{
-Config $Config ` Config = $Config
-Automated:$Automated ` Automated = $Automated
-CurrentDateTimeUtc $CurrentDateTimeUtc ` CurrentDateTimeUtc = $CurrentDateTimeUtc
-ScriptBlock { Start-BusinessLogic -Automated:$Automated } ScriptBlock = { Start-BusinessLogic -Automated:$Automated }
}
Invoke-ScheduledExecution @params
} }
else { else {
Write-Log "Invoke-ScheduledExecution not available. Execution aborted." -Level Error -Automated:$Automated Write-Log "Invoke-ScheduledExecution not available. Execution aborted." -Level Error -Automated:$Automated

View File

@ -2,8 +2,8 @@
"$schema": "https://json-schema.org/draft-07/schema", "$schema": "https://json-schema.org/draft-07/schema",
"title": "File Sync Script Settings", "title": "File Sync Script Settings",
"description": "Configuration file for file-sync.ps1 script using FreeFileSync", "description": "Configuration file for file-sync.ps1 script using FreeFileSync",
"version": "1.0.0", "version": "1.0.1",
"lastModified": "2026-01-24", "lastModified": "2026-01-26",
"schedule": { "schedule": {
"runMonth": [], "runMonth": [],
"runWeekday": ["Monday"], "runWeekday": ["Monday"],

View File

@ -1,7 +1,7 @@
# Hyper-V Backup Script # Hyper-V Backup Script
**Version:** 1.0.0 **Version:** 1.0.1
**Last Updated:** 2026-01-24 **Last Updated:** 2026-01-26
## Overview ## Overview
@ -9,12 +9,12 @@ Production-ready automated backup solution for Hyper-V virtual machines with sch
## Features ## Features
- ✅ **Automated VM Backup** - Exports all VMs on the host using Hyper-V checkpoints - ✅ **Automated VM Backup** - Exports all VMs on the host (Export-VM handles checkpoints internally)
- ✅ **Flexible Scheduling** - Schedule backups by month, weekday, and time with interval control - ✅ **Flexible Scheduling** - Schedule backups by month, weekday, and time with interval control
- ✅ **Remote Storage Support** - Backup to UNC shares with secure credential management - ✅ **Remote Storage Support** - Backup to UNC shares with secure credential management
- ✅ **Retention Management** - Automatically cleanup old backups based on retention count - ✅ **Retention Management** - Automatically cleanup old backups based on retention count
- ✅ **Checkpoint Management** - Automatic cleanup of backup checkpoints - ✅ **Checkpoint Management** - Automatic cleanup of backup checkpoints (keeps last 2 for rollback)
- ✅ **Space Validation** - Pre-flight checks for available disk space - ✅ **Space Validation** - Dynamic space checks for temp (per VM) and destination before copy
- ✅ **VM Exclusion** - Exclude specific VMs from backup - ✅ **VM Exclusion** - Exclude specific VMs from backup
- ✅ **Detailed Logging** - Comprehensive logging with timestamps and severity levels - ✅ **Detailed Logging** - Comprehensive logging with timestamps and severity levels
- ✅ **Lock Files** - Prevents concurrent execution - ✅ **Lock Files** - Prevents concurrent execution
@ -65,7 +65,6 @@ HyperV-Backup/
"credentialEnvVar": "YOUR_ENV_VAR_NAME", "credentialEnvVar": "YOUR_ENV_VAR_NAME",
"tempExportRoot": "D:\\Temp\\HyperVExport", "tempExportRoot": "D:\\Temp\\HyperVExport",
"retentionCount": 3, "retentionCount": 3,
"minFreeSpaceGB": 100,
"excludeVMs": ["vm-to-exclude"] "excludeVMs": ["vm-to-exclude"]
} }
``` ```
@ -109,9 +108,8 @@ HyperV-Backup/
|----------|------|----------|-------------| |----------|------|----------|-------------|
| `backupRoot` | string | Yes | UNC or local path for backups. Hostname is appended automatically. | | `backupRoot` | string | Yes | UNC or local path for backups. Hostname is appended automatically. |
| `credentialEnvVar` | string | No* | Name of Machine-level environment variable with credentials (*Required for UNC paths) | | `credentialEnvVar` | string | No* | Name of Machine-level environment variable with credentials (*Required for UNC paths) |
| `tempExportRoot` | string | Yes | Local directory for temporary VM exports | | `tempExportRoot` | string | Yes | Local directory for temporary VM exports. Space checked dynamically per VM (1.5x VM size). |
| `retentionCount` | number | Yes | Number of backup generations to keep (1-365) | | `retentionCount` | number | Yes | Number of backup generations to keep (1-365) |
| `minFreeSpaceGB` | number | No | Minimum required free space in GB (0 = disable check) |
| `excludeVMs` | array | No | VM names to exclude from backup | | `excludeVMs` | array | No | VM names to exclude from backup |
### Version Tracking ### Version Tracking
@ -177,13 +175,14 @@ When `-Automated` is specified:
- Retrieve all VMs on the host - Retrieve all VMs on the host
- Filter excluded VMs - Filter excluded VMs
- For each VM: - For each VM:
- Create checkpoint with timestamp - Check temp space (requires 1.5x VM size)
- Export VM to temp location - Export VM to temp location (Export-VM handles checkpoints internally)
- Check destination space before copy
- Copy to final backup location - Copy to final backup location
- Cleanup temp export - Cleanup temp export
4. **Cleanup** 4. **Cleanup**
- Remove all backup checkpoints - Remove old backup checkpoints (keeps last 2 for rollback)
- Delete old backup folders beyond retention count - Delete old backup folders beyond retention count
5. **Summary** 5. **Summary**
@ -265,12 +264,13 @@ Error: Failed to connect to \\server\share
**3. Insufficient Space** **3. Insufficient Space**
``` ```
Error: Insufficient free space on drive D: Error: Insufficient temp space for VM 'xxx' (need ~150 GB, have 100 GB)
Error: Insufficient space on destination for VM 'xxx'
``` ```
**Solution:** **Solution:**
- Free up space on temp drive - Free up space on temp drive or destination
- Reduce `minFreeSpaceGB` setting (not recommended) - Use different temp location with more space
- Use different temp location - Check destination share quota/capacity
**4. Lock File Exists** **4. Lock File Exists**
``` ```
@ -281,14 +281,15 @@ Guard: Lock file exists. Skipping.
- Manually delete `.lock` file if stuck - Manually delete `.lock` file if stuck
- Check for hung PowerShell processes - Check for hung PowerShell processes
**5. Checkpoint Creation Failed** **5. Export Failed**
``` ```
Error: Failed to create checkpoint for VM Error: Failed to export VM 'xxx'
``` ```
**Solution:** **Solution:**
- Verify VM is in a valid state - Verify VM is in a valid state
- Check Hyper-V event logs - Check Hyper-V event logs
- Ensure sufficient disk space for checkpoints - Ensure sufficient disk space for export
- Verify no other export/checkpoint operations in progress
### Debug Mode ### Debug Mode
@ -329,12 +330,22 @@ Run with verbose output:
### 1.0.0 (2026-01-24) ### 1.0.0 (2026-01-24)
- Initial production release - Initial production release
- Automated backup with scheduling - Automated backup with scheduling
- Checkpoint-based export - Export-VM based backup (handles checkpoints internally)
- Retention management - Retention management
- UNC share support with credential management - UNC share support with credential management
- Lock file and interval control - Lock file and interval control
- Comprehensive error handling and logging - Comprehensive error handling and logging
### 1.0.1 (2026-01-26)
- Improved disk space checking: Dynamic per-VM validation (1.5x VM size) for temp and destination
- Removed static `minFreeSpaceGB` setting in favor of smart per-VM space checks
- Enhanced checkpoint retention: Keep last 2 backup checkpoints for rollback capability
- Removed manual checkpoint creation (Export-VM handles checkpoints internally)
- Improved UNC path validation
- Better error messages for space-related failures
- Performance improvement: Skip unnecessary space checks
- Refactored parameter splatting for Invoke-ScheduledExecution
## Support ## Support
For issues or questions: For issues or questions:

View File

@ -3,8 +3,8 @@ setlocal EnableDelayedExpansion
REM ============================================================================ REM ============================================================================
REM Hyper-V Backup Launcher REM Hyper-V Backup Launcher
REM VERSION: 1.0.0 REM VERSION: 1.0.1
REM DATE: 2026-01-24 REM DATE: 2026-01-26
REM DESCRIPTION: Batch file launcher for hyper-v-backup.ps1 with admin check REM DESCRIPTION: Batch file launcher for hyper-v-backup.ps1 with admin check
REM ============================================================================ REM ============================================================================

View File

@ -13,9 +13,9 @@ param (
.DESCRIPTION .DESCRIPTION
Production-ready Hyper-V backup solution with scheduling, checkpoints, and retention management. Production-ready Hyper-V backup solution with scheduling, checkpoints, and retention management.
.VERSION .VERSION
1.0.0 1.0.1
.DATE .DATE
2026-01-24 2026-01-26
.NOTES .NOTES
- Requires Administrator privileges - Requires Administrator privileges
- Requires Hyper-V PowerShell module - Requires Hyper-V PowerShell module
@ -23,8 +23,8 @@ param (
#> #>
# Script Version # Script Version
$ScriptVersion = "1.0.0" $ScriptVersion = "1.0.1"
$ScriptDate = "2026-01-24" $ScriptDate = "2026-01-26"
try { try {
Import-Module "$PSScriptRoot\..\SchedulerTemplate.psm1" -Force -ErrorAction Stop Import-Module "$PSScriptRoot\..\SchedulerTemplate.psm1" -Force -ErrorAction Stop
@ -67,7 +67,6 @@ $BackupRoot = $settings.backupRoot
$CredentialEnvVar = $settings.credentialEnvVar $CredentialEnvVar = $settings.credentialEnvVar
$TempExportRoot = $settings.tempExportRoot $TempExportRoot = $settings.tempExportRoot
$RetentionCount = $settings.retentionCount $RetentionCount = $settings.retentionCount
$MinFreeSpaceGB = $settings.minFreeSpaceGB
$BlacklistedVMs = $settings.excludeVMs $BlacklistedVMs = $settings.excludeVMs
# Schedule Configuration # Schedule Configuration
@ -144,19 +143,6 @@ function Test-Prerequisites {
} }
} }
# Check free space on temp drive
if ($MinFreeSpaceGB -gt 0) {
$tempDrive = (Get-Item $TempExportRoot).PSDrive.Name
$freeSpace = (Get-PSDrive $tempDrive).Free / 1GB
if ($freeSpace -lt $MinFreeSpaceGB) {
Write-Log "Insufficient free space on drive ${tempDrive}: ($([math]::Round($freeSpace, 2)) GB available, $MinFreeSpaceGB GB required)" -Level Error -Automated:$Automated
return $false
}
Write-Log "Free space on drive ${tempDrive}: $([math]::Round($freeSpace, 2)) GB" -Level Info -Automated:$Automated
}
Write-Log "All prerequisites passed" -Level Success -Automated:$Automated Write-Log "All prerequisites passed" -Level Success -Automated:$Automated
return $true return $true
} }
@ -173,6 +159,12 @@ function Connect-BackupShare {
return $true return $true
} }
# Validate UNC path format
if (-not (Test-UNCPath -Path $SharePath)) {
Write-Log "Invalid UNC path format: $SharePath (expected \\server\share)" -Level Error -Automated:$Automated
return $false
}
Write-Log "Authenticating to UNC share: $SharePath" -Level Info -Automated:$Automated Write-Log "Authenticating to UNC share: $SharePath" -Level Info -Automated:$Automated
# Validate credential environment variable name is configured # Validate credential environment variable name is configured
@ -236,6 +228,64 @@ function Get-VMDiskSize {
} }
} }
function Get-PathFreeSpace {
param([string]$Path)
try {
$uri = [System.Uri]$Path
if ($uri.IsUnc) {
$server = $uri.Host
$share = $uri.Segments[1].TrimEnd('/')
# Query remote share info via WMI
$shareInfo = Get-WmiObject -Class Win32_LogicalDisk -ComputerName $server -ErrorAction Stop |
Where-Object { $_.DeviceID -or $_.ProviderName -like "*$share*" }
if ($shareInfo) {
return $shareInfo.FreeSpace
}
# Fallback: try to get info from mapped drive or direct query
$driveInfo = [System.IO.DriveInfo]::GetDrives() |
Where-Object { $_.DriveType -eq 'Network' -and $_.Name -and (Test-Path $Path) }
if ($driveInfo) {
return $driveInfo.AvailableFreeSpace
}
# Last resort: create a temp file and check available space
if (Test-Path $Path) {
$testFile = Join-Path $Path ".space_check_$(Get-Random)"
try {
[System.IO.File]::WriteAllText($testFile, "")
$drive = [System.IO.Path]::GetPathRoot((Resolve-Path $Path).Path)
$info = Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Root -eq $drive }
if ($info) {
return $info.Free
}
}
finally {
if (Test-Path $testFile) {
Remove-Item $testFile -Force -ErrorAction SilentlyContinue
}
}
}
return $null
}
else {
# Local path - use PSDrive
$driveLetter = (Get-Item $Path -ErrorAction Stop).PSDrive.Name
$freeSpace = (Get-PSDrive $driveLetter -ErrorAction Stop).Free
return $freeSpace
}
}
catch {
return $null
}
}
function Backup-VM { function Backup-VM {
param( param(
[string]$VMName, [string]$VMName,
@ -262,52 +312,27 @@ function Backup-VM {
return $false return $false
} }
# Estimate required space # Estimate required space and check temp drive
$vmDiskSize = Get-VMDiskSize -VMName $VMName -Automated:$Automated $vmDiskSize = Get-VMDiskSize -VMName $VMName -Automated:$Automated
if ($vmDiskSize -gt 0) { if ($vmDiskSize -gt 0) {
$vmDiskSizeGB = [math]::Round($vmDiskSize / 1GB, 2) $vmDiskSizeGB = [math]::Round($vmDiskSize / 1GB, 2)
Write-Log "VM '$VMName' estimated size: $vmDiskSizeGB GB" -Level Info -Automated:$Automated Write-Log "VM '$VMName' estimated size: $vmDiskSizeGB GB" -Level Info -Automated:$Automated
# Check if enough temp space # Check if enough temp space for export (need ~1.5x VM size)
if ($MinFreeSpaceGB -gt 0) { $tempDrive = (Get-Item $TempExportRoot).PSDrive.Name
$tempDrive = (Get-Item $TempExportRoot).PSDrive.Name $freeSpace = (Get-PSDrive $tempDrive).Free
$freeSpace = (Get-PSDrive $tempDrive).Free
if ($freeSpace -lt ($vmDiskSize * 1.5)) { if ($freeSpace -lt ($vmDiskSize * 1.5)) {
Write-Log "Insufficient temp space for VM '$VMName' (need ~$([math]::Round($vmDiskSize * 1.5 / 1GB, 2)) GB, have $([math]::Round($freeSpace / 1GB, 2)) GB)" -Level Error -Automated:$Automated Write-Log "Insufficient temp space for VM '$VMName' (need ~$([math]::Round($vmDiskSize * 1.5 / 1GB, 2)) GB, have $([math]::Round($freeSpace / 1GB, 2)) GB)" -Level Error -Automated:$Automated
$script:BackupStats.FailedVMs++ $script:BackupStats.FailedVMs++
$script:BackupStats.FailureMessages += "Insufficient space for $VMName" $script:BackupStats.FailureMessages += "Insufficient temp space for $VMName"
return $false return $false
}
} }
Write-Log "Temp drive ${tempDrive}: has $([math]::Round($freeSpace / 1GB, 2)) GB free" -Level Info -Automated:$Automated
} }
# Create checkpoint # Export VM to temp location (Export-VM creates its own checkpoint internally)
Write-Log "Creating checkpoint for VM '$VMName'..." -Level Info -Automated:$Automated
$checkpointName = "Backup-$DateSuffix"
try {
Checkpoint-VM -Name $VMName -SnapshotName $checkpointName -ErrorAction Stop
}
catch {
Write-Log "Failed to create checkpoint for VM '$VMName': $_" -Level Error -Automated:$Automated
$script:BackupStats.FailedVMs++
$script:BackupStats.FailureMessages += "Checkpoint failed for $VMName"
return $false
}
# Verify checkpoint
$checkpoint = Get-VMSnapshot -VMName $VMName -Name $checkpointName -ErrorAction SilentlyContinue
if (-not $checkpoint) {
Write-Log "Checkpoint verification failed for VM '$VMName'" -Level Error -Automated:$Automated
$script:BackupStats.FailedVMs++
$script:BackupStats.FailureMessages += "Checkpoint verification failed for $VMName"
return $false
}
Write-Log "Checkpoint created successfully: $checkpointName" -Level Success -Automated:$Automated
# Export VM to temp location
$tempExportPath = Join-Path -Path $TempExportRoot -ChildPath "$VMName-$DateSuffix" $tempExportPath = Join-Path -Path $TempExportRoot -ChildPath "$VMName-$DateSuffix"
Write-Log "Exporting VM '$VMName' to temp location: $tempExportPath" -Level Info -Automated:$Automated Write-Log "Exporting VM '$VMName' to temp location: $tempExportPath" -Level Info -Automated:$Automated
@ -329,6 +354,34 @@ function Backup-VM {
Write-Log "Export completed successfully" -Level Success -Automated:$Automated Write-Log "Export completed successfully" -Level Success -Automated:$Automated
# Get actual export size for destination space check
$exportSize = (Get-ChildItem -Path $tempExportPath -Recurse -File | Measure-Object -Property Length -Sum).Sum
if (-not $exportSize) { $exportSize = 0 }
$exportSizeGB = [math]::Round($exportSize / 1GB, 2)
Write-Log "Export size for VM '$VMName': $exportSizeGB GB" -Level Info -Automated:$Automated
# Check destination space before copying
$destFreeSpace = Get-PathFreeSpace -Path $BackupFolder
if ($null -ne $destFreeSpace) {
$requiredSpace = $exportSize * 1.1 # 10% buffer
if ($destFreeSpace -lt $requiredSpace) {
Write-Log "Insufficient space on destination for VM '$VMName' (need ~$([math]::Round($requiredSpace / 1GB, 2)) GB, have $([math]::Round($destFreeSpace / 1GB, 2)) GB)" -Level Error -Automated:$Automated
# Cleanup temp export
if (Test-Path $tempExportPath) {
Remove-Item -Path $tempExportPath -Recurse -Force -ErrorAction SilentlyContinue
}
$script:BackupStats.FailedVMs++
$script:BackupStats.FailureMessages += "Insufficient destination space for $VMName"
return $false
}
Write-Log "Destination has $([math]::Round($destFreeSpace / 1GB, 2)) GB free space" -Level Info -Automated:$Automated
}
else {
Write-Log "Warning: Could not determine free space on destination, proceeding with copy" -Level Warning -Automated:$Automated
}
# Copy to NAS # Copy to NAS
Write-Log "Copying VM '$VMName' export to backup location: $vmBackupPath" -Level Info -Automated:$Automated Write-Log "Copying VM '$VMName' export to backup location: $vmBackupPath" -Level Info -Automated:$Automated
@ -382,10 +435,11 @@ function Backup-VM {
function Remove-OldCheckpoints { function Remove-OldCheckpoints {
param( param(
[array]$VMs, [array]$VMs,
[int]$RetentionCount = 2,
[switch]$Automated [switch]$Automated
) )
Write-Log "Starting checkpoint cleanup for all VMs..." -Level Info -Automated:$Automated Write-Log "Starting checkpoint cleanup for all VMs (keeping $RetentionCount most recent)..." -Level Info -Automated:$Automated
$totalCheckpoints = 0 $totalCheckpoints = 0
@ -394,10 +448,13 @@ function Remove-OldCheckpoints {
try { try {
$checkpoints = Get-VMSnapshot -VMName $vmName -ErrorAction SilentlyContinue | $checkpoints = Get-VMSnapshot -VMName $vmName -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like "Backup-*" } Where-Object { $_.Name -like "Backup-*" } |
Sort-Object CreationTime -Descending
if ($checkpoints) { if ($checkpoints -and $checkpoints.Count -gt $RetentionCount) {
foreach ($checkpoint in $checkpoints) { $checkpointsToRemove = $checkpoints | Select-Object -Skip $RetentionCount
foreach ($checkpoint in $checkpointsToRemove) {
Write-Log "Removing checkpoint '$($checkpoint.Name)' from VM '$vmName'..." -Level Info -Automated:$Automated Write-Log "Removing checkpoint '$($checkpoint.Name)' from VM '$vmName'..." -Level Info -Automated:$Automated
try { try {
@ -585,11 +642,13 @@ function Start-BusinessLogic {
if ($Automated) { if ($Automated) {
if (Get-Command Invoke-ScheduledExecution -ErrorAction SilentlyContinue) { if (Get-Command Invoke-ScheduledExecution -ErrorAction SilentlyContinue) {
Invoke-ScheduledExecution ` $params = @{
-Config $Config ` Config = $Config
-Automated:$Automated ` Automated = $Automated
-CurrentDateTimeUtc $CurrentDateTimeUtc ` CurrentDateTimeUtc = $CurrentDateTimeUtc
-ScriptBlock { Start-BusinessLogic -Automated:$Automated } ScriptBlock = { Start-BusinessLogic -Automated:$Automated }
}
Invoke-ScheduledExecution @params
} }
else { else {
Write-Log "Invoke-ScheduledExecution not available. Execution aborted." -Level Error -Automated:$Automated Write-Log "Invoke-ScheduledExecution not available. Execution aborted." -Level Error -Automated:$Automated

View File

@ -2,8 +2,8 @@
"$schema": "https://json-schema.org/draft-07/schema", "$schema": "https://json-schema.org/draft-07/schema",
"title": "Hyper-V Backup Script Settings", "title": "Hyper-V Backup Script Settings",
"description": "Configuration file for hyper-v-backup.ps1 script", "description": "Configuration file for hyper-v-backup.ps1 script",
"version": "1.0.0", "version": "1.0.1",
"lastModified": "2026-01-24", "lastModified": "2026-01-26",
"schedule": { "schedule": {
"runMonth": [], "runMonth": [],
"runWeekday": ["Monday"], "runWeekday": ["Monday"],
@ -14,7 +14,6 @@
"credentialEnvVar": "nassrv0001", "credentialEnvVar": "nassrv0001",
"tempExportRoot": "D:\\Temp\\HyperVExport", "tempExportRoot": "D:\\Temp\\HyperVExport",
"retentionCount": 3, "retentionCount": 3,
"minFreeSpaceGB": 100,
"excludeVMs": ["nassrv0002"], "excludeVMs": ["nassrv0002"],
"_comments": { "_comments": {
"version": "Configuration schema version", "version": "Configuration schema version",
@ -27,9 +26,8 @@
}, },
"backupRoot": "UNC path or local path to backup root directory. Hostname will be appended automatically.", "backupRoot": "UNC path or local path to backup root directory. Hostname will be appended automatically.",
"credentialEnvVar": "Name of Machine-level environment variable containing Base64-encoded 'username:password'", "credentialEnvVar": "Name of Machine-level environment variable containing Base64-encoded 'username:password'",
"tempExportRoot": "Local directory for temporary VM exports. Must have sufficient free space.", "tempExportRoot": "Local directory for temporary VM exports. Space is checked dynamically per VM (1.5x VM size).",
"retentionCount": "Number of backup generations to keep (1-365). Older backups are automatically deleted.", "retentionCount": "Number of backup generations to keep (1-365). Older backups are automatically deleted.",
"minFreeSpaceGB": "Minimum required free space in GB before starting backup. Set to 0 to disable check.",
"excludeVMs": "Array of VM names to exclude from backup process" "excludeVMs": "Array of VM names to exclude from backup process"
} }
} }

View File

@ -0,0 +1,46 @@
@{
RootModule = 'SchedulerTemplate.psm1'
ModuleVersion = '1.0.1'
GUID = 'a3b2c1d0-e4f5-6a7b-8c9d-0e1f2a3b4c5d'
Author = 'MaksIT'
CompanyName = 'MaksIT'
Copyright = '(c) 2026 MaksIT. All rights reserved.'
Description = 'Reusable PowerShell module for scheduled script execution with lock files, interval control, and credential management.'
PowerShellVersion = '5.1'
FunctionsToExport = @(
'Write-Log',
'Get-CredentialFromEnvVar',
'Test-UNCPath',
'Get-CurrentUtcDateTime',
'Test-ScheduleMonth',
'Test-ScheduleWeekday',
'Test-ScheduleTime',
'Test-Schedule',
'Test-Interval',
'Test-ScheduledExecution',
'New-LockGuard',
'Remove-LockGuard',
'Invoke-ScheduledExecution'
)
CmdletsToExport = @()
VariablesToExport = @('ModuleVersion', 'ModuleDate')
AliasesToExport = @()
PrivateData = @{
PSData = @{
Tags = @('Scheduler', 'Automation', 'Lock', 'Logging', 'Credentials')
LicenseUri = ''
ProjectUri = 'https://github.com/MaksIT/uscheduler'
ReleaseNotes = @'
## 1.0.1 (2026-01-26)
- Improved UNC path validation (Test-UNCPath function)
- Enhanced credential management
- Comprehensive logging with timestamp support
- Scheduled execution with lock files and interval control
- Schedule validation (month, weekday, time)
- Write-Log function with severity levels and color support
- Get-CredentialFromEnvVar for secure Base64-encoded credential retrieval
- Invoke-ScheduledExecution for automated scheduled task management
'@
}
}
}

View File

@ -5,18 +5,19 @@
Reusable PowerShell module for scheduled script execution with lock files, Reusable PowerShell module for scheduled script execution with lock files,
interval control, and credential management. interval control, and credential management.
.VERSION .VERSION
1.0.0 1.0.1
.DATE .DATE
2026-01-24 2026-01-26
.NOTES .NOTES
- Provides Write-Log function with timestamp and level support - Provides Write-Log function with timestamp and level support
- Provides Get-CredentialFromEnvVar for secure credential retrieval - Provides Get-CredentialFromEnvVar for secure credential retrieval
- Provides Test-UNCPath for UNC path validation
- Provides Invoke-ScheduledExecution for scheduled task management - Provides Invoke-ScheduledExecution for scheduled task management
#> #>
# Module Version (exported for external scripts to check version) # Module Version (exported for external scripts to check version)
$script:ModuleVersion = "1.0.0" $script:ModuleVersion = "1.0.1"
$script:ModuleDate = "2026-01-24" $script:ModuleDate = "2026-01-26"
# Module load confirmation # Module load confirmation
Write-Verbose "SchedulerTemplate.psm1 v$ModuleVersion loaded ($ModuleDate)" Write-Verbose "SchedulerTemplate.psm1 v$ModuleVersion loaded ($ModuleDate)"
@ -90,6 +91,18 @@ function Get-CredentialFromEnvVar {
} }
} }
function Test-UNCPath {
param([string]$Path)
try {
$uri = [System.Uri]$Path
return $uri.IsUnc
}
catch {
return $false
}
}
function Get-CurrentUtcDateTime { function Get-CurrentUtcDateTime {
param([string]$ExternalDateTime, [switch]$Automated) param([string]$ExternalDateTime, [switch]$Automated)