diff --git a/README.md b/README.md index 9933f66..6f210a3 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Designed for system administrators — and also for those who *feel like* system - [MaksIT Unified Scheduler Service](#maksit-unified-scheduler-service) - [Table of Contents](#table-of-contents) + - [Scripts Examples](#scripts-examples) - [Features at a Glance](#features-at-a-glance) - [Installation](#installation) - [Recommended (using bundled scripts)](#recommended-using-bundled-scripts) @@ -30,6 +31,10 @@ Designed for system administrators — and also for those who *feel like* system - [Appendix](#appendix) - [SchedulerTemplate.psm1 (Full Source)](#schedulertemplatepsm1-full-source) +## Scripts Examples + +- [Hyper-V Backup](./examples/HyperV-Backup/README.md) - Production-ready Hyper-V VM backup solution with scheduling and retention management + --- ## Features at a Glance diff --git a/examples/HyperV-Backup/README.md b/examples/HyperV-Backup/README.md new file mode 100644 index 0000000..c73a1c8 --- /dev/null +++ b/examples/HyperV-Backup/README.md @@ -0,0 +1,355 @@ +# Hyper-V Backup Script + +**Version:** 1.0.0 +**Last Updated:** 2026-01-24 + +## Overview + +Production-ready automated backup solution for Hyper-V virtual machines with scheduling, checkpoints, retention management, and remote storage support. + +## Features + +- ✅ **Automated VM Backup** - Exports all VMs on the host using Hyper-V checkpoints +- ✅ **Flexible Scheduling** - Schedule backups by month, weekday, and time with interval control +- ✅ **Remote Storage Support** - Backup to UNC shares with secure credential management +- ✅ **Retention Management** - Automatically cleanup old backups based on retention count +- ✅ **Checkpoint Management** - Automatic cleanup of backup checkpoints +- ✅ **Space Validation** - Pre-flight checks for available disk space +- ✅ **VM Exclusion** - Exclude specific VMs from backup +- ✅ **Detailed Logging** - Comprehensive logging with timestamps and severity levels +- ✅ **Lock Files** - Prevents concurrent execution +- ✅ **Error Handling** - Proper exit codes and error reporting + +## Requirements + +### System Requirements +- Windows Server with Hyper-V role installed +- PowerShell 5.1 or later +- Administrator privileges +- Hyper-V PowerShell module + +### Dependencies +- `SchedulerTemplate.psm1` module (located in parent directory) +- `scriptsettings.json` configuration file + +## File Structure + +``` +HyperV-Backup/ +├── hyper-v-backup.bat # Batch launcher with admin check +├── hyper-v-backup.ps1 # Main PowerShell script +├── scriptsettings.json # Configuration file +└── README.md # This file +``` + +## Installation + +1. **Copy Files** + ```powershell + # Copy the entire HyperV-Backup folder to your desired location + # Ensure SchedulerTemplate.psm1 is in the parent directory + ``` + +2. **Configure Settings** + + Edit `scriptsettings.json` with your environment settings: + ```json + { + "schedule": { + "runMonth": [], + "runWeekday": ["Monday"], + "runTime": ["00:00"], + "minIntervalMinutes": 10 + }, + "backupRoot": "\\\\your-nas\\backups", + "credentialEnvVar": "YOUR_ENV_VAR_NAME", + "tempExportRoot": "D:\\Temp\\HyperVExport", + "retentionCount": 3, + "minFreeSpaceGB": 100, + "excludeVMs": ["vm-to-exclude"] + } + ``` + +3. **Setup Credentials (for UNC paths)** + + If backing up to a network share, create a Machine-level environment variable: + ```powershell + # Create Base64-encoded credentials + $username = "DOMAIN\user" + $password = "your-password" + $creds = "$username:$password" + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($creds)) + + # Set Machine-level environment variable + [System.Environment]::SetEnvironmentVariable("YOUR_ENV_VAR_NAME", $encoded, "Machine") + ``` + +4. **Test Manual Execution** + ```powershell + # Run as Administrator + .\hyper-v-backup.bat + # or + .\hyper-v-backup.ps1 + ``` + +## Configuration Reference + +### Schedule Settings + +| Property | Type | Description | Example | +|----------|------|-------------|---------| +| `runMonth` | array | Month names to run. Empty = every month | `["January", "June", "December"]` or `[]` | +| `runWeekday` | array | Weekday names to run. Empty = every day | `["Monday", "Friday"]` | +| `runTime` | array | UTC times to run (HH:mm format) | `["00:00", "12:00"]` | +| `minIntervalMinutes` | number | Minimum minutes between runs | `10` | + +### Backup Settings + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `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) | +| `tempExportRoot` | string | Yes | Local directory for temporary VM exports | +| `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 | + +### Version Tracking + +| Property | Type | Description | +|----------|------|-------------| +| `version` | string | Configuration schema version | +| `lastModified` | string | Last modification date (YYYY-MM-DD) | + +## Usage + +### Manual Execution + +**Using Batch File (Recommended):** +```batch +REM Right-click and select "Run as administrator" +hyper-v-backup.bat +``` + +**Using PowerShell:** +```powershell +# Run as Administrator +.\hyper-v-backup.ps1 + +# With verbose output +.\hyper-v-backup.ps1 -Verbose +``` + +### Automated Execution + +The script supports automated execution through the UScheduler service: + +```powershell +# Called by scheduler with -Automated flag +.\hyper-v-backup.ps1 -Automated -CurrentDateTimeUtc "2026-01-24 00:00:00" +``` + +When `-Automated` is specified: +- Schedule is enforced (month, weekday, time) +- Lock files prevent concurrent execution +- Interval checking prevents duplicate runs +- Logs are formatted for service logger (no timestamps) + +## How It Works + +### Backup Process Flow + +1. **Initialization** + - Load SchedulerTemplate.psm1 module + - Load and validate scriptsettings.json + - Validate required settings + - Set up backup paths + +2. **Pre-flight Checks** + - Verify Administrator privileges + - Check Hyper-V module availability + - Verify Hyper-V service is running + - Check temp directory and free space + - Authenticate to backup share (if UNC) + - Create backup destination directory + +3. **Backup Execution** + - Retrieve all VMs on the host + - Filter excluded VMs + - For each VM: + - Create checkpoint with timestamp + - Export VM to temp location + - Copy to final backup location + - Cleanup temp export + +4. **Cleanup** + - Remove all backup checkpoints + - Delete old backup folders beyond retention count + +5. **Summary** + - Display backup statistics + - Report success/failure counts + - List any errors encountered + +### Directory Structure Created + +``` +\\backupRoot\Hyper-V\Backups\hostname\ +├── 20260124000000\ # Backup folder (timestamp) +│ ├── VM-Name-1\ +│ ├── VM-Name-2\ +│ └── VM-Name-3\ +├── 20260117000000\ # Previous backup +└── 20260110000000\ # Older backup (will be deleted if retentionCount=2) +``` + +### Lock and State Files + +When running in automated mode, the script creates: +- `hyper-v-backup.lock` - Prevents concurrent execution +- `hyper-v-backup.lastRun` - Tracks last execution time for interval control + +## Logging + +### Log Levels + +| Level | Description | Color (Manual) | +|-------|-------------|----------------| +| `Info` | Informational messages | White | +| `Success` | Successful operations | Green | +| `Warning` | Non-critical issues | Yellow | +| `Error` | Critical errors | Red | + +### Log Format + +**Manual Execution:** +``` +[2026-01-24 00:00:00] [Info] Hyper-V Backup Process Started +[2026-01-24 00:00:01] [Success] All prerequisites passed +[2026-01-24 00:05:30] [Success] Backup completed successfully for VM: server01 +``` + +**Automated Execution:** +``` +[Info] Hyper-V Backup Process Started +[Success] All prerequisites passed +[Success] Backup completed successfully for VM: server01 +``` + +## Exit Codes + +| Code | Description | +|------|-------------| +| `0` | Success or no VMs found | +| `1` | Error occurred (module load, config, prerequisites, backup failure) | + +## Troubleshooting + +### Common Issues + +**1. Module Not Found** +``` +Error: Failed to load SchedulerTemplate.psm1 +``` +**Solution:** Ensure SchedulerTemplate.psm1 is in the parent directory (`../SchedulerTemplate.psm1`) + +**2. UNC Path Authentication Failed** +``` +Error: Failed to connect to \\server\share +``` +**Solution:** +- Verify `credentialEnvVar` is set in scriptsettings.json +- Verify environment variable exists at Machine level +- Verify credentials are Base64-encoded in format: `username:password` +- Test with: `net use \\server\share` manually + +**3. Insufficient Space** +``` +Error: Insufficient free space on drive D: +``` +**Solution:** +- Free up space on temp drive +- Reduce `minFreeSpaceGB` setting (not recommended) +- Use different temp location + +**4. Lock File Exists** +``` +Guard: Lock file exists. Skipping. +``` +**Solution:** +- Another instance is running, or previous run didn't complete +- Manually delete `.lock` file if stuck +- Check for hung PowerShell processes + +**5. Checkpoint Creation Failed** +``` +Error: Failed to create checkpoint for VM +``` +**Solution:** +- Verify VM is in a valid state +- Check Hyper-V event logs +- Ensure sufficient disk space for checkpoints + +### Debug Mode + +Run with verbose output: +```powershell +.\hyper-v-backup.ps1 -Verbose +``` + +## Best Practices + +1. **Test First** - Always test backups manually before scheduling +2. **Monitor Space** - Ensure adequate space on both temp and backup locations +3. **Verify Backups** - Periodically test restore from backups +4. **Secure Credentials** - Use Machine-level environment variables, never store passwords in scripts +5. **Schedule Wisely** - Run backups during low-usage periods +6. **Review Logs** - Check backup summaries regularly +7. **Update Retention** - Adjust `retentionCount` based on storage capacity +8. **Exclude Carefully** - Only exclude VMs that don't need backup + +## Security Considerations + +- **Credentials** are stored Base64-encoded in Machine-level environment variables (not encryption, just encoding) +- Script requires **Administrator privileges** +- **Network credentials** are passed to `net use` command +- Consider using **dedicated backup account** with minimal required permissions +- **Backup data** should be stored on secured network shares with appropriate ACLs + +## Performance Considerations + +- **Export time** depends on VM size and disk I/O performance +- **Temp location** should be on fast local storage (SSD recommended) +- **Network speed** affects copy time to remote shares +- **Checkpoints** consume disk space temporarily +- Multiple VMs are processed **sequentially** (not parallel) + +## Version History + +### 1.0.0 (2026-01-24) +- Initial production release +- Automated backup with scheduling +- Checkpoint-based export +- Retention management +- UNC share support with credential management +- Lock file and interval control +- Comprehensive error handling and logging + +## Support + +For issues or questions: +1. Check the [Troubleshooting](#troubleshooting) section +2. Review script logs for error details +3. Verify all [Requirements](#requirements) are met +4. Check Hyper-V event logs for VM-related issues + +## License + +See [LICENSE](../../LICENSE.md) in the root directory. + +## Related Files + +- `../SchedulerTemplate.psm1` - Shared scheduling and logging module +- `scriptsettings.json` - Configuration file +- `hyper-v-backup.bat` - Batch launcher +- `hyper-v-backup.ps1` - Main script diff --git a/examples/HyperV-Backup/hyper-v-backup.bat b/examples/HyperV-Backup/hyper-v-backup.bat new file mode 100644 index 0000000..f5bdad9 --- /dev/null +++ b/examples/HyperV-Backup/hyper-v-backup.bat @@ -0,0 +1,74 @@ +@echo off +setlocal EnableDelayedExpansion + +REM ============================================================================ +REM Hyper-V Backup Launcher +REM VERSION: 1.0.0 +REM DATE: 2026-01-24 +REM DESCRIPTION: Batch file launcher for hyper-v-backup.ps1 with admin check +REM ============================================================================ + +echo. +echo ============================================ +echo Hyper-V Automated Backup Launcher +echo ============================================ +echo. + +REM Check for Administrator privileges +net session >nul 2>&1 +if %errorLevel% NEQ 0 ( + echo [ERROR] This script must be run as Administrator! + echo. + echo Please right-click and select "Run as administrator" + echo. + pause + exit /b 1 +) + +echo [OK] Running with Administrator privileges +echo. + +REM Get script directory +set "SCRIPT_DIR=%~dp0" +set "PS_SCRIPT=%SCRIPT_DIR%hyper-v-backup.ps1" + +REM Check if PowerShell script exists +if not exist "%PS_SCRIPT%" ( + echo [ERROR] PowerShell script not found: %PS_SCRIPT% + echo. + pause + exit /b 1 +) + +echo [OK] Found PowerShell script: %PS_SCRIPT% +echo. +echo ============================================ +echo Starting backup process... +echo ============================================ +echo. + +REM Execute PowerShell script +REM Note: Logging is handled by UScheduler service +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%PS_SCRIPT%" + +REM Capture exit code +set "EXIT_CODE=%ERRORLEVEL%" + +echo. +echo ============================================ +echo Backup process completed +echo Exit Code: %EXIT_CODE% +echo ============================================ +echo. + +if %EXIT_CODE% EQU 0 ( + echo [SUCCESS] Backup completed successfully +) else ( + echo [ERROR] Backup completed with errors +) + +echo. +pause + +endlocal +exit /b %EXIT_CODE% \ No newline at end of file diff --git a/examples/HyperV-Backup/hyper-v-backup.ps1 b/examples/HyperV-Backup/hyper-v-backup.ps1 new file mode 100644 index 0000000..ca79375 --- /dev/null +++ b/examples/HyperV-Backup/hyper-v-backup.ps1 @@ -0,0 +1,602 @@ +[CmdletBinding()] +param ( + [switch]$Automated, + [string]$CurrentDateTimeUtc +) + +#Requires -RunAsAdministrator +#Requires -Modules Hyper-V + +<# +.SYNOPSIS + Hyper-V Automated Backup Script +.DESCRIPTION + Production-ready Hyper-V backup solution with scheduling, checkpoints, and retention management. +.VERSION + 1.0.0 +.DATE + 2026-01-24 +.NOTES + - Requires Administrator privileges + - Requires Hyper-V PowerShell module + - Requires SchedulerTemplate.psm1 module +#> + +# Script Version +$ScriptVersion = "1.0.0" +$ScriptDate = "2026-01-24" + +try { + Import-Module "$PSScriptRoot\..\SchedulerTemplate.psm1" -Force -ErrorAction Stop +} +catch { + Write-Error "Failed to load SchedulerTemplate.psm1: $_" + exit 1 +} + +# Load Settings ============================================================ + +$settingsFile = Join-Path $PSScriptRoot "scriptsettings.json" + +if (-not (Test-Path $settingsFile)) { + Write-Error "Settings file not found: $settingsFile" + exit 1 +} + +try { + $settings = Get-Content $settingsFile -Raw | ConvertFrom-Json + Write-Verbose "Loaded settings from $settingsFile" +} +catch { + Write-Error "Failed to load settings from $settingsFile : $_" + exit 1 +} + +# Process Settings ========================================================= + +# Validate required settings +$requiredSettings = @('backupRoot', 'tempExportRoot', 'retentionCount') +foreach ($setting in $requiredSettings) { + if (-not $settings.$setting) { + Write-Error "Required setting '$setting' is missing or empty in $settingsFile" + exit 1 + } +} + +$BackupRoot = $settings.backupRoot +$CredentialEnvVar = $settings.credentialEnvVar +$TempExportRoot = $settings.tempExportRoot +$RetentionCount = $settings.retentionCount +$MinFreeSpaceGB = $settings.minFreeSpaceGB +$BlacklistedVMs = $settings.excludeVMs + +# Schedule Configuration +$Config = @{ + RunMonth = $settings.schedule.runMonth + RunWeekday = $settings.schedule.runWeekday + RunTime = $settings.schedule.runTime + MinIntervalMinutes = $settings.schedule.minIntervalMinutes +} + +# Set hostname and backup path +$Hostname = ($env:COMPUTERNAME).ToLower() +$BackupPath = "$BackupRoot\Hyper-V\Backups\$Hostname" + +# Validate and set temp directory +if (-not (Test-Path $TempExportRoot)) { + try { + $TempExportRoot = [System.IO.Path]::GetTempPath() + } + catch { + $TempExportRoot = $env:TEMP + } +} + +# End Settings ============================================================= + +# Global variables +$script:BackupStats = @{ + TotalVMs = 0 + SuccessfulVMs = 0 + FailedVMs = 0 + SkippedVMs = 0 + StartTime = Get-Date + EndTime = $null + FailureMessages = @() +} + +# Helper Functions ========================================================= + +function Test-Prerequisites { + param([switch]$Automated) + + Write-Log "Checking prerequisites..." -Level Info -Automated:$Automated + + # Check if running as Administrator + $currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) + if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Write-Log "Script must be run as Administrator!" -Level Error -Automated:$Automated + return $false + } + + # Check Hyper-V module + if (-not (Get-Module -ListAvailable -Name Hyper-V)) { + Write-Log "Hyper-V PowerShell module is not installed!" -Level Error -Automated:$Automated + return $false + } + + # Check Hyper-V service + $hvService = Get-Service -Name vmms -ErrorAction SilentlyContinue + if (-not $hvService -or $hvService.Status -ne 'Running') { + Write-Log "Hyper-V Virtual Machine Management service is not running!" -Level Error -Automated:$Automated + return $false + } + + # Check temp directory + if (-not (Test-Path $TempExportRoot)) { + try { + New-Item -Path $TempExportRoot -ItemType Directory -Force | Out-Null + Write-Log "Created temp directory: $TempExportRoot" -Level Success -Automated:$Automated + } + catch { + Write-Log "Failed to create temp directory: $_" -Level Error -Automated:$Automated + return $false + } + } + + # 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 + return $true +} + +function Connect-BackupShare { + param( + [string]$SharePath, + [switch]$Automated + ) + + # Check if path is UNC + if (-not $SharePath.StartsWith("\\")) { + Write-Log "Backup path is local, no authentication needed" -Level Info -Automated:$Automated + return $true + } + + Write-Log "Authenticating to UNC share: $SharePath" -Level Info -Automated:$Automated + + # Validate credential environment variable name is configured + if (-not $CredentialEnvVar) { + Write-Log "credentialEnvVar is not configured in settings for UNC path authentication" -Level Error -Automated:$Automated + return $false + } + + try { + # Retrieve credentials from environment variable + $creds = Get-CredentialFromEnvVar -EnvVarName $CredentialEnvVar -Automated:$Automated + + if (-not $creds) { + return $false + } + + # Check if already connected + $existingConnection = net use | Select-String $SharePath + if ($existingConnection) { + Write-Log "Already connected to $SharePath" -Level Info -Automated:$Automated + return $true + } + + # Connect to share + $netUseResult = cmd /c "net use $SharePath $($creds.Password) /user:$($creds.Username) 2>&1" + + if ($LASTEXITCODE -ne 0) { + Write-Log "Failed to connect to $SharePath : $netUseResult" -Level Error -Automated:$Automated + return $false + } + + Write-Log "Successfully authenticated to $SharePath" -Level Success -Automated:$Automated + return $true + } + catch { + Write-Log "Error connecting to share: $_" -Level Error -Automated:$Automated + return $false + } +} + +function Get-VMDiskSize { + param( + [string]$VMName, + [switch]$Automated + ) + + try { + $vm = Get-VM -Name $VMName -ErrorAction Stop + $vhds = $vm | Get-VMHardDiskDrive | Get-VHD -ErrorAction SilentlyContinue + + if ($vhds) { + $totalSize = ($vhds | Measure-Object -Property FileSize -Sum).Sum + return $totalSize + } + + return 0 + } + catch { + Write-Log "Warning: Could not determine disk size for VM '$VMName': $_" -Level Warning -Automated:$Automated + return 0 + } +} + +function Backup-VM { + param( + [string]$VMName, + [string]$BackupFolder, + [string]$DateSuffix, + [switch]$Automated + ) + + Write-Log "=== Processing VM: $VMName ===" -Level Info -Automated:$Automated + + try { + # Check if VM exists + $vm = Get-VM -Name $VMName -ErrorAction Stop + + # Check VM state + $vmState = $vm.State + Write-Log "VM '$VMName' state: $vmState" -Level Info -Automated:$Automated + + # Check if backup already exists + $vmBackupPath = Join-Path -Path $BackupFolder -ChildPath $VMName + if (Test-Path $vmBackupPath) { + Write-Log "Backup for VM '$VMName' already exists at '$vmBackupPath'. Skipping..." -Level Warning -Automated:$Automated + $script:BackupStats.SkippedVMs++ + return $false + } + + # Estimate required space + $vmDiskSize = Get-VMDiskSize -VMName $VMName -Automated:$Automated + if ($vmDiskSize -gt 0) { + $vmDiskSizeGB = [math]::Round($vmDiskSize / 1GB, 2) + Write-Log "VM '$VMName' estimated size: $vmDiskSizeGB GB" -Level Info -Automated:$Automated + + # Check if enough temp space + if ($MinFreeSpaceGB -gt 0) { + $tempDrive = (Get-Item $TempExportRoot).PSDrive.Name + $freeSpace = (Get-PSDrive $tempDrive).Free + + 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 + $script:BackupStats.FailedVMs++ + $script:BackupStats.FailureMessages += "Insufficient space for $VMName" + return $false + } + } + } + + # Create checkpoint + 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" + Write-Log "Exporting VM '$VMName' to temp location: $tempExportPath" -Level Info -Automated:$Automated + + try { + Export-VM -Name $VMName -Path $tempExportPath -ErrorAction Stop + } + catch { + Write-Log "Failed to export VM '$VMName': $_" -Level Error -Automated:$Automated + + # Cleanup temp if export failed + if (Test-Path $tempExportPath) { + Remove-Item -Path $tempExportPath -Recurse -Force -ErrorAction SilentlyContinue + } + + $script:BackupStats.FailedVMs++ + $script:BackupStats.FailureMessages += "Export failed for $VMName" + return $false + } + + Write-Log "Export completed successfully" -Level Success -Automated:$Automated + + # Copy to NAS + Write-Log "Copying VM '$VMName' export to backup location: $vmBackupPath" -Level Info -Automated:$Automated + + try { + if (-not (Test-Path $vmBackupPath)) { + New-Item -Path $vmBackupPath -ItemType Directory -Force | Out-Null + } + + Copy-Item -Path "$tempExportPath\*" -Destination $vmBackupPath -Recurse -Force -ErrorAction Stop + } + catch { + Write-Log "Failed to copy VM '$VMName' to backup location: $_" -Level Error -Automated:$Automated + + # Cleanup partial backup + if (Test-Path $vmBackupPath) { + Remove-Item -Path $vmBackupPath -Recurse -Force -ErrorAction SilentlyContinue + } + + $script:BackupStats.FailedVMs++ + $script:BackupStats.FailureMessages += "Copy to NAS failed for $VMName" + return $false + } + + Write-Log "Copy to backup location completed successfully" -Level Success -Automated:$Automated + + # Cleanup temp export + Write-Log "Cleaning up temp export for VM '$VMName'..." -Level Info -Automated:$Automated + + try { + if (Test-Path $tempExportPath) { + Remove-Item -Path $tempExportPath -Recurse -Force -ErrorAction Stop + } + Write-Log "Temp cleanup completed" -Level Success -Automated:$Automated + } + catch { + Write-Log "Warning: Failed to cleanup temp export: $_" -Level Warning -Automated:$Automated + } + + Write-Log "=== Backup completed successfully for VM: $VMName ===" -Level Success -Automated:$Automated + $script:BackupStats.SuccessfulVMs++ + return $true + } + catch { + Write-Log "Unexpected error processing VM '$VMName': $_" -Level Error -Automated:$Automated + $script:BackupStats.FailedVMs++ + $script:BackupStats.FailureMessages += "Unexpected error for $VMName : $_" + return $false + } +} + +function Remove-OldCheckpoints { + param( + [array]$VMs, + [switch]$Automated + ) + + Write-Log "Starting checkpoint cleanup for all VMs..." -Level Info -Automated:$Automated + + $totalCheckpoints = 0 + + foreach ($vm in $VMs) { + $vmName = $vm.Name + + try { + $checkpoints = Get-VMSnapshot -VMName $vmName -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like "Backup-*" } + + if ($checkpoints) { + foreach ($checkpoint in $checkpoints) { + Write-Log "Removing checkpoint '$($checkpoint.Name)' from VM '$vmName'..." -Level Info -Automated:$Automated + + try { + Remove-VMSnapshot -VMName $vmName -Name $checkpoint.Name -Confirm:$false -ErrorAction Stop + $totalCheckpoints++ + } + catch { + Write-Log "Warning: Failed to remove checkpoint '$($checkpoint.Name)' from VM '$vmName': $_" -Level Warning -Automated:$Automated + } + } + } + } + catch { + Write-Log "Warning: Error accessing checkpoints for VM '$vmName': $_" -Level Warning -Automated:$Automated + } + } + + Write-Log "Checkpoint cleanup completed. Removed $totalCheckpoints checkpoint(s)" -Level Success -Automated:$Automated +} + +function Remove-OldBackups { + param( + [string]$BackupPath, + [int]$RetentionCount, + [switch]$Automated + ) + + Write-Log "Starting old backup cleanup (retention: $RetentionCount most recent)..." -Level Info -Automated:$Automated + + try { + if (-not (Test-Path $BackupPath)) { + Write-Log "Backup path does not exist, skipping cleanup" -Level Warning -Automated:$Automated + return + } + + $backupDirs = Get-ChildItem -Path $BackupPath -Directory -ErrorAction Stop | + Sort-Object Name -Descending + + if ($backupDirs.Count -le $RetentionCount) { + Write-Log "Only $($backupDirs.Count) backup(s) found, no cleanup needed" -Level Info -Automated:$Automated + return + } + + $dirsToRemove = $backupDirs | Select-Object -Skip $RetentionCount + $removedCount = 0 + + foreach ($dir in $dirsToRemove) { + Write-Log "Removing old backup folder: $($dir.Name)" -Level Info -Automated:$Automated + + try { + Remove-Item -Path $dir.FullName -Recurse -Force -ErrorAction Stop + $removedCount++ + } + catch { + Write-Log "Warning: Failed to remove backup folder '$($dir.Name)': $_" -Level Warning -Automated:$Automated + } + } + + Write-Log "Old backup cleanup completed. Removed $removedCount backup folder(s)" -Level Success -Automated:$Automated + } + catch { + Write-Log "Error during backup cleanup: $_" -Level Error -Automated:$Automated + } +} + +function Write-BackupSummary { + param([switch]$Automated) + + $script:BackupStats.EndTime = Get-Date + $duration = $script:BackupStats.EndTime - $script:BackupStats.StartTime + + Write-Log "" -Level Info -Automated:$Automated + Write-Log "========================================" -Level Info -Automated:$Automated + Write-Log "BACKUP SUMMARY" -Level Info -Automated:$Automated + Write-Log "========================================" -Level Info -Automated:$Automated + Write-Log "Start Time : $($script:BackupStats.StartTime.ToString('yyyy-MM-dd HH:mm:ss'))" -Level Info -Automated:$Automated + Write-Log "End Time : $($script:BackupStats.EndTime.ToString('yyyy-MM-dd HH:mm:ss'))" -Level Info -Automated:$Automated + Write-Log "Duration : $($duration.Hours)h $($duration.Minutes)m $($duration.Seconds)s" -Level Info -Automated:$Automated + Write-Log "Total VMs : $($script:BackupStats.TotalVMs)" -Level Info -Automated:$Automated + Write-Log "Successful : $($script:BackupStats.SuccessfulVMs)" -Level Success -Automated:$Automated + Write-Log "Failed : $($script:BackupStats.FailedVMs)" -Level $(if ($script:BackupStats.FailedVMs -gt 0) { 'Error' } else { 'Info' }) -Automated:$Automated + Write-Log "Skipped : $($script:BackupStats.SkippedVMs)" -Level Info -Automated:$Automated + + if ($script:BackupStats.FailureMessages.Count -gt 0) { + Write-Log "" -Level Info -Automated:$Automated + Write-Log "ERRORS:" -Level Error -Automated:$Automated + foreach ($failureMsg in $script:BackupStats.FailureMessages) { + Write-Log " - $failureMsg" -Level Error -Automated:$Automated + } + } + + Write-Log "========================================" -Level Info -Automated:$Automated +} + +# Main Business Logic ====================================================== + +function Start-BusinessLogic { + param([switch]$Automated) + + Write-Log "========================================" -Level Info -Automated:$Automated + Write-Log "Hyper-V Backup Process Started" -Level Info -Automated:$Automated + Write-Log "Script Version: $ScriptVersion ($ScriptDate)" -Level Info -Automated:$Automated + Write-Log "Host: $Hostname" -Level Info -Automated:$Automated + Write-Log "========================================" -Level Info -Automated:$Automated + + # Check prerequisites + if (-not (Test-Prerequisites -Automated:$Automated)) { + Write-Log "Prerequisites check failed. Aborting backup." -Level Error -Automated:$Automated + exit 1 + } + + # Connect to backup share + if (-not (Connect-BackupShare -SharePath $BackupRoot -Automated:$Automated)) { + Write-Log "Failed to connect to backup share. Aborting backup." -Level Error -Automated:$Automated + exit 1 + } + + # Create backup destination if it doesn't exist + if (-not (Test-Path $BackupPath)) { + Write-Log "Backup path does not exist. Creating: $BackupPath" -Level Info -Automated:$Automated + try { + New-Item -Path $BackupPath -ItemType Directory -Force -ErrorAction Stop | Out-Null + Write-Log "Created backup path: $BackupPath" -Level Success -Automated:$Automated + } + catch { + Write-Log "Failed to create backup path '$BackupPath': $_" -Level Error -Automated:$Automated + exit 1 + } + } + + # Get all VMs + try { + $vms = Get-VM -ErrorAction Stop + } + catch { + Write-Log "Failed to retrieve VMs: $_" -Level Error -Automated:$Automated + exit 1 + } + + if (-not $vms -or $vms.Count -eq 0) { + Write-Log "No VMs found on this host. Aborting backup." -Level Warning -Automated:$Automated + exit 0 + } + + $script:BackupStats.TotalVMs = $vms.Count + Write-Log "Found $($vms.Count) VM(s) on this host" -Level Info -Automated:$Automated + + # Create backup folder with timestamp + $dateSuffix = Get-Date -Format "yyyyMMddHHmmss" + $backupFolder = Join-Path -Path $BackupPath -ChildPath $dateSuffix + + try { + New-Item -Path $backupFolder -ItemType Directory -Force -ErrorAction Stop | Out-Null + Write-Log "Created backup folder: $backupFolder" -Level Success -Automated:$Automated + } + catch { + Write-Log "Failed to create backup folder '$backupFolder': $_" -Level Error -Automated:$Automated + exit 1 + } + + # Process each VM + foreach ($vm in $vms) { + $vmName = $vm.Name + + if ($BlacklistedVMs -contains $vmName) { + Write-Log "Skipping blacklisted VM: $vmName" -Level Warning -Automated:$Automated + $script:BackupStats.SkippedVMs++ + continue + } + + Backup-VM -VMName $vmName -BackupFolder $backupFolder -DateSuffix $dateSuffix -Automated:$Automated + } + + # Cleanup old checkpoints + Remove-OldCheckpoints -VMs $vms -Automated:$Automated + + # Cleanup old backups + Remove-OldBackups -BackupPath $BackupPath -RetentionCount $RetentionCount -Automated:$Automated + + # Print summary + Write-BackupSummary -Automated:$Automated +} + +# Entry Point ============================================================== + +if ($Automated) { + if (Get-Command Invoke-ScheduledExecution -ErrorAction SilentlyContinue) { + Invoke-ScheduledExecution ` + -Config $Config ` + -Automated:$Automated ` + -CurrentDateTimeUtc $CurrentDateTimeUtc ` + -ScriptBlock { Start-BusinessLogic -Automated:$Automated } + } + else { + Write-Log "Invoke-ScheduledExecution not available. Execution aborted." -Level Error -Automated:$Automated + exit 1 + } +} +else { + Write-Log "Manual execution started" -Level Info -Automated:$Automated + Start-BusinessLogic -Automated:$Automated +} \ No newline at end of file diff --git a/examples/HyperV-Backup/scriptsettings.json b/examples/HyperV-Backup/scriptsettings.json new file mode 100644 index 0000000..2738eaf --- /dev/null +++ b/examples/HyperV-Backup/scriptsettings.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "title": "Hyper-V Backup Script Settings", + "description": "Configuration file for hyper-v-backup.ps1 script", + "version": "1.0.0", + "lastModified": "2026-01-24", + "schedule": { + "runMonth": [], + "runWeekday": ["Monday"], + "runTime": ["00:00"], + "minIntervalMinutes": 10 + }, + "backupRoot": "\\\\nassrv0001.corp.maks-it.com\\data-1", + "credentialEnvVar": "nassrv0001", + "tempExportRoot": "D:\\Temp\\HyperVExport", + "retentionCount": 3, + "minFreeSpaceGB": 100, + "excludeVMs": ["nassrv0002"], + "_comments": { + "version": "Configuration schema version", + "lastModified": "Last modification date (YYYY-MM-DD)", + "schedule": { + "runMonth": "Array of month names (e.g. 'January', 'June', 'December') to run backup. Empty array = every month.", + "runWeekday": "Array of weekday names (e.g. 'Monday', 'Friday') to run backup. Empty array = every day.", + "runTime": "Array of UTC times in HH:mm format when backup should run.", + "minIntervalMinutes": "Minimum minutes between backup runs to prevent duplicate executions." + }, + "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'", + "tempExportRoot": "Local directory for temporary VM exports. Must have sufficient free space.", + "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" + } +} diff --git a/examples/SchedulerTemplate.psm1 b/examples/SchedulerTemplate.psm1 new file mode 100644 index 0000000..b24e853 --- /dev/null +++ b/examples/SchedulerTemplate.psm1 @@ -0,0 +1,249 @@ +<# +.SYNOPSIS + SchedulerTemplate.psm1 - Scheduling + Lock + Interval + Callback Runner +.DESCRIPTION + Reusable PowerShell module for scheduled script execution with lock files, + interval control, and credential management. +.VERSION + 1.0.0 +.DATE + 2026-01-24 +.NOTES + - Provides Write-Log function with timestamp and level support + - Provides Get-CredentialFromEnvVar for secure credential retrieval + - Provides Invoke-ScheduledExecution for scheduled task management +#> + +# Module Version (exported for external scripts to check version) +$script:ModuleVersion = "1.0.0" +$script:ModuleDate = "2026-01-24" + +# Module load confirmation +Write-Verbose "SchedulerTemplate.psm1 v$ModuleVersion loaded ($ModuleDate)" + +function Write-Log { + param( + [string]$Message, + [ValidateSet('Info', 'Success', 'Warning', 'Error')] + [string]$Level = 'Info', + [switch]$Automated + ) + + $colors = @{ + 'Info' = 'White' + 'Success' = 'Green' + 'Warning' = 'Yellow' + 'Error' = 'Red' + } + + if ($Automated) { + # Service logger adds timestamps, so only include level and message + Write-Output "[$Level] $Message" + } + else { + # Manual execution: include timestamp for better tracking + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + Write-Host "[$timestamp] [$Level] $Message" -ForegroundColor $colors[$Level] + } +} + +function Get-CredentialFromEnvVar { + param( + [Parameter(Mandatory = $true)] + [string]$EnvVarName, + [switch]$Automated + ) + + try { + # Retrieve environment variable from Machine level + $envVar = [System.Environment]::GetEnvironmentVariable($EnvVarName, "Machine") + + if (-not $envVar) { + Write-Log "Environment variable '$EnvVarName' not found at Machine level!" -Level Error -Automated:$Automated + return $null + } + + # Decode Base64 + try { + $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($envVar)) + } + catch { + Write-Log "Failed to decode '$EnvVarName' as Base64. Expected format: Base64('username:password')" -Level Error -Automated:$Automated + return $null + } + + # Split credentials + $creds = $decoded -split ':', 2 + if ($creds.Count -ne 2) { + Write-Log "Invalid credential format in '$EnvVarName'. Expected 'username:password'" -Level Error -Automated:$Automated + return $null + } + + return @{ + Username = $creds[0] + Password = $creds[1] + } + } + catch { + Write-Log "Error retrieving credentials from '$EnvVarName': $_" -Level Error -Automated:$Automated + return $null + } +} + +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 -Level Error + 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 -Level Success + Write-Log "Current UTC Time: $now" -Automated:$Automated -Level Success + + if (-not (Test-Schedule -DateTime $now -RunMonth $Config.RunMonth -RunWeekday $Config.RunWeekday -RunTime $Config.RunTime)) { + Write-Log "Execution skipped due to schedule." -Automated:$Automated -Level Warning + $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 -Level Warning + $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 -Level Error + 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 -Level Error + 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 -Level Info + } +} + +# ====================================================================== +# Main unified executor (callback-based) +# ====================================================================== +function Invoke-ScheduledExecution { + param( + [scriptblock]$ScriptBlock, + [hashtable]$Config, + [switch]$Automated, + [string]$CurrentDateTimeUtc + ) + + # Get the calling script's path (not the module path) + $scriptPath = (Get-PSCallStack)[1].ScriptName + if (-not $scriptPath) { + Write-Log "Unable to determine calling script path" -Level Error -Automated:$Automated + return + } + + $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 -Level Warning + 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 * -Variable ModuleVersion, ModuleDate