############################################################################### #### windows-backup-smoke-tests.ps1 — Verify Windows backup health #### #### Checks WBAdmin status, backup age, VSS writers, shadow copies, #### #### system state, event log errors, SQL backup age, BMR readiness. #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version: 1.0 #### #### #### #### Usage: .\windows-backup-smoke-tests.ps1 #### #### .\windows-backup-smoke-tests.ps1 -BackupTarget E:\Backups #### #### .\windows-backup-smoke-tests.ps1 -OutputFormat tap #### #### #### #### See -Help for all options. #### ############################################################################### [CmdletBinding()] param( [string]$BackupTarget = "", [int]$MaxBackupAgeHours = 25, [ValidateSet("text","tap")] [string]$OutputFormat = "text", [switch]$NoColor, [switch]$Help ) $ErrorActionPreference = "Continue" # ============================================================================ # HELP # ============================================================================ if ($Help) { @" Usage: .\windows-backup-smoke-tests.ps1 [OPTIONS] Smoke-test Windows backup infrastructure. PowerShell 5.1+. Designed for Windows Server with Windows Server Backup. Parameters: -BackupTarget PATH Backup destination path (default: auto-detect from WBAdmin) -MaxBackupAgeHours HOURS Maximum backup age in hours (default: 25) -OutputFormat FORMAT Output: text (default), tap -NoColor Disable coloured output -Verbose Show debug output -Help Show this help Examples: .\windows-backup-smoke-tests.ps1 .\windows-backup-smoke-tests.ps1 -BackupTarget "E:\WindowsImageBackup" .\windows-backup-smoke-tests.ps1 -MaxBackupAgeHours 48 -OutputFormat tap .\windows-backup-smoke-tests.ps1 -NoColor -Verbose "@ exit 0 } # ============================================================================ # STATE # ============================================================================ $script:Pass = 0 $script:Fail = 0 $script:Skip = 0 $script:Total = 0 $script:Results = @() $script:StartTime = Get-Date # ============================================================================ # COLORS # ============================================================================ function Write-Color { param([string]$Text, [string]$Color = "White") if ($NoColor) { Write-Host $Text } else { Write-Host $Text -ForegroundColor $Color } } function Write-Log { param([string]$Msg) Write-Color "[INFO] $Msg" "Cyan" } function Write-Warn { param([string]$Msg) Write-Color "[WARN] $Msg" "Yellow" } function Write-Err { param([string]$Msg) Write-Color "[ERROR] $Msg" "Red" } # ============================================================================ # TEST RESULT RECORDING # ============================================================================ function Record-Pass { param([string]$Name, [string]$Detail = "") $script:Pass++ $script:Total++ $script:Results += [PSCustomObject]@{ Status="PASS"; Name=$Name; Detail=$Detail } if ($OutputFormat -eq "tap") { Write-Host "ok $($script:Total) - $Name$(if($Detail){" ($Detail)"})" } else { $mark = if ($NoColor) { "[PASS]" } else { [char]0x2713 } $msg = " $mark $Name" if ($Detail) { $msg += " - $Detail" } Write-Color $msg "Green" } } function Record-Fail { param([string]$Name, [string]$Detail = "") $script:Fail++ $script:Total++ $script:Results += [PSCustomObject]@{ Status="FAIL"; Name=$Name; Detail=$Detail } if ($OutputFormat -eq "tap") { Write-Host "not ok $($script:Total) - $Name" if ($Detail) { Write-Host " # $Detail" } } else { $mark = if ($NoColor) { "[FAIL]" } else { [char]0x2717 } $msg = " $mark $Name" if ($Detail) { $msg += " - $Detail" } Write-Color $msg "Red" } } function Record-Skip { param([string]$Name, [string]$Reason = "") $script:Skip++ $script:Total++ $script:Results += [PSCustomObject]@{ Status="SKIP"; Name=$Name; Detail=$Reason } if ($OutputFormat -eq "tap") { Write-Host "ok $($script:Total) - $Name # SKIP $Reason" } else { $mark = if ($NoColor) { "[SKIP]" } else { [char]0x2298 } $msg = " $mark $Name" if ($Reason) { $msg += " - $Reason" } Write-Color $msg "Yellow" } } # ============================================================================ # HELPERS # ============================================================================ function Test-CommandExists { param([string]$Command) $null -ne (Get-Command $Command -ErrorAction SilentlyContinue) } function Write-Section { param([string]$Name) if ($OutputFormat -eq "text") { Write-Host "" Write-Color $Name "White" } } # ============================================================================ # TESTS # ============================================================================ # -- 1. Windows Server Backup Feature --------------------------------------- function Test-BackupFeature { Write-Section "Backup Feature" if (Test-CommandExists "Get-WindowsFeature") { try { $feature = Get-WindowsFeature -Name Windows-Server-Backup -ErrorAction Stop if ($feature.Installed) { Record-Pass "Windows Server Backup feature installed" } else { Record-Fail "Windows Server Backup feature installed" "feature not installed" } } catch { Record-Fail "Windows Server Backup feature installed" $_.Exception.Message } } else { # Workstation or older OS — check for wbadmin directly if (Test-CommandExists "wbadmin") { Record-Pass "Windows Server Backup feature installed" "wbadmin available" } else { Record-Skip "Windows Server Backup feature installed" "Get-WindowsFeature not available" } } } # -- 2. Backup Scheduled Task ----------------------------------------------- function Test-BackupScheduledTask { Write-Section "Scheduled Task" try { $task = Get-ScheduledTask -TaskName "Microsoft-Windows-WindowsBackup" -TaskPath "\Microsoft\Windows\WindowsBackup\" -ErrorAction SilentlyContinue if (-not $task) { # Try alternative task name $task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskPath -match "WindowsBackup" -or $_.TaskName -match "WindowsBackup" } | Select-Object -First 1 } if ($task) { if ($task.State -eq "Ready" -or $task.State -eq "Running") { Record-Pass "Backup scheduled task enabled" "state: $($task.State)" } else { Record-Fail "Backup scheduled task enabled" "state: $($task.State)" } } else { Record-Fail "Backup scheduled task enabled" "no backup scheduled task found" } } catch { Record-Fail "Backup scheduled task enabled" $_.Exception.Message } } # -- 3. Last Backup Status -------------------------------------------------- function Test-LastBackupStatus { Write-Section "Backup Status" if (-not (Test-CommandExists "wbadmin")) { Record-Skip "Last backup status" "wbadmin not available" return } try { $output = wbadmin get versions 2>&1 | Out-String if ($output -match "Backup time:\s*(.+)" -or $output -match "Version identifier:\s*(.+)") { Record-Pass "Last backup status" "completed successfully" } elseif ($output -match "no previous backups" -or $output -match "There are no versions") { Record-Fail "Last backup status" "no backups found" } else { Record-Fail "Last backup status" "could not parse wbadmin output" } } catch { Record-Fail "Last backup status" "wbadmin error - $($_.Exception.Message)" } } # -- 4. Backup Age ---------------------------------------------------------- function Test-BackupAge { if (-not (Test-CommandExists "wbadmin")) { Record-Skip "Backup age" "wbadmin not available" return } try { $output = wbadmin get versions 2>&1 | Out-String $timestamps = [regex]::Matches($output, "Backup time:\s*(.+)") if ($timestamps.Count -eq 0) { Record-Fail "Backup age" "no backup timestamps found" return } # Get the most recent backup time $latestStr = $timestamps[$timestamps.Count - 1].Groups[1].Value.Trim() $latestTime = [datetime]::Parse($latestStr) $ageHours = [math]::Floor(((Get-Date) - $latestTime).TotalHours) if ($ageHours -le $MaxBackupAgeHours) { Record-Pass "Backup age" "${ageHours}h < ${MaxBackupAgeHours}h threshold" } else { Record-Fail "Backup age" "${ageHours}h exceeds ${MaxBackupAgeHours}h threshold" } } catch { Record-Fail "Backup age" "could not determine backup age - $($_.Exception.Message)" } } # -- 5. Backup Target Accessible -------------------------------------------- function Test-BackupTargetAccessible { Write-Section "Backup Target" $target = $BackupTarget # Auto-detect if not specified if (-not $target -and (Test-CommandExists "wbadmin")) { try { $policyOutput = wbadmin get policy 2>&1 | Out-String if ($policyOutput -match "Backup Target:\s*(.+)") { $target = $Matches[1].Trim() Write-Verbose "Auto-detected backup target: $target" } } catch { Write-Verbose "Could not auto-detect backup target from policy" } } if (-not $target) { Record-Skip "Backup target accessible" "no backup target specified or detected" return } try { if (Test-Path $target -ErrorAction Stop) { Record-Pass "Backup target accessible" $target } else { Record-Fail "Backup target accessible" "$target - not accessible" } } catch { Record-Fail "Backup target accessible" "$target - $($_.Exception.Message)" } } # -- 6. VSS Writers Healthy ------------------------------------------------- function Test-VSSWriters { Write-Section "VSS" if (-not (Test-CommandExists "vssadmin")) { Record-Skip "VSS writers healthy" "vssadmin not available" return } try { $output = vssadmin list writers 2>&1 | Out-String $writerBlocks = [regex]::Matches($output, "Writer name:\s*'([^']+)'.+?State:\s*\[(\d+)\]\s*(\w+)", [System.Text.RegularExpressions.RegexOptions]::Singleline) if ($writerBlocks.Count -eq 0) { Record-Fail "VSS writers healthy" "could not parse writer state" return } $failedWriters = @() foreach ($match in $writerBlocks) { $writerName = $match.Groups[1].Value $stateNum = $match.Groups[2].Value $stateText = $match.Groups[3].Value if ($stateText -ne "Stable" -and $stateText -ne "Waiting") { $failedWriters += "$writerName ($stateText)" } } if ($failedWriters.Count -eq 0) { Record-Pass "VSS writers healthy" "$($writerBlocks.Count) writers, 0 failed" } else { Record-Fail "VSS writers healthy" "$($failedWriters.Count) failed: $($failedWriters -join ', ')" } } catch { Record-Fail "VSS writers healthy" "vssadmin error - $($_.Exception.Message)" } } # -- 7. VSS Shadow Copies Exist --------------------------------------------- function Test-VSSShadowCopies { if (-not (Test-CommandExists "vssadmin")) { Record-Skip "VSS shadow copies exist" "vssadmin not available" return } try { $output = vssadmin list shadows 2>&1 | Out-String $shadowCount = ([regex]::Matches($output, "Shadow Copy ID:")).Count if ($shadowCount -gt 0) { Record-Pass "VSS shadow copies exist" "$shadowCount shadow copies" } elseif ($output -match "No items found") { Record-Fail "VSS shadow copies exist" "no shadow copies found" } else { Record-Fail "VSS shadow copies exist" "no shadow copies detected" } } catch { Record-Fail "VSS shadow copies exist" "vssadmin error - $($_.Exception.Message)" } } # -- 8. System State Backup ------------------------------------------------- function Test-SystemStateBackup { Write-Section "System State" if (-not (Test-CommandExists "wbadmin")) { Record-Skip "System state backup present" "wbadmin not available" return } try { $output = wbadmin get versions 2>&1 | Out-String if ($output -match "Can recover:\s*.*System State" -or $output -match "systemStateBackup" -or $output -match "System State") { Record-Pass "System state backup present" } elseif ($output -match "no previous backups" -or $output -match "There are no versions") { Record-Fail "System state backup present" "no backups found" } else { Record-Fail "System state backup present" "system state not included in backups" } } catch { Record-Fail "System state backup present" "wbadmin error - $($_.Exception.Message)" } } # -- 9. Backup Disk Space --------------------------------------------------- function Test-BackupDiskSpace { Write-Section "Disk Space" $target = $BackupTarget if (-not $target -and (Test-CommandExists "wbadmin")) { try { $policyOutput = wbadmin get policy 2>&1 | Out-String if ($policyOutput -match "Backup Target:\s*(.+)") { $target = $Matches[1].Trim() } } catch {} } if (-not $target) { Record-Skip "Backup disk space" "no backup target specified or detected"; return } try { if ($target -match "^([A-Za-z]):\\") { $drive = Get-PSDrive -Name $Matches[1] -ErrorAction Stop $freeGB = [math]::Round($drive.Free / 1GB, 0) if ($freeGB -ge 10) { Record-Pass "Backup disk space" "$freeGB GB free on $($Matches[1]):" } elseif ($freeGB -ge 1) { Record-Pass "Backup disk space" "$freeGB GB free on $($Matches[1]): (low)" } else { Record-Fail "Backup disk space" "$freeGB GB free on $($Matches[1]): — critically low" } } elseif ($target -match "^\\\\") { if (Test-Path $target -ErrorAction Stop) { Record-Pass "Backup disk space" "UNC target accessible (space check not available)" } else { Record-Fail "Backup disk space" "UNC target not accessible" } } else { Record-Skip "Backup disk space" "cannot determine volume from target: $target" } } catch { Record-Fail "Backup disk space" $_.Exception.Message } } # -- 10. Backup Event Log --------------------------------------------------- function Test-BackupEventLog { Write-Section "Event Log" try { $logName = "Microsoft-Windows-Backup" $cutoff = (Get-Date).AddHours(-24) $errors = Get-WinEvent -FilterHashtable @{ LogName = "$logName/Operational" Level = 2 # Error StartTime = $cutoff } -ErrorAction SilentlyContinue if (-not $errors) { # Try alternative log name $errors = Get-WinEvent -FilterHashtable @{ ProviderName = "Microsoft-Windows-Backup" Level = 2 StartTime = $cutoff } -ErrorAction SilentlyContinue } $errorCount = if ($errors) { ($errors | Measure-Object).Count } else { 0 } if ($errorCount -eq 0) { Record-Pass "Backup event log clean" "0 errors in 24h" } else { $latestMsg = $errors[0].Message if ($latestMsg.Length -gt 80) { $latestMsg = $latestMsg.Substring(0, 80) + "..." } Record-Fail "Backup event log clean" "$errorCount error(s) in 24h — latest: $latestMsg" } } catch { if ($_.Exception.Message -match "No events were found" -or $_.Exception.Message -match "could not be found") { Record-Pass "Backup event log clean" "0 errors in 24h" } else { Record-Skip "Backup event log clean" "cannot access backup event log" } } } # -- 11. SQL Server Backup Age ---------------------------------------------- function Test-SQLBackupAge { Write-Section "SQL Server" # Check if SQL Server is installed $sqlService = Get-Service -Name "MSSQLSERVER" -ErrorAction SilentlyContinue if (-not $sqlService) { $sqlService = Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -match "^MSSQL\$" -or $_.Name -eq "MSSQLSERVER" } | Select-Object -First 1 } if (-not $sqlService) { Record-Skip "SQL Server backup age" "SQL Server not installed" return } $query = @" SELECT TOP 1 bs.database_name, bs.backup_finish_date, DATEDIFF(HOUR, bs.backup_finish_date, GETDATE()) AS age_hours FROM msdb.dbo.backupset bs ORDER BY bs.backup_finish_date DESC "@ # Try Invoke-Sqlcmd, then fall back to sqlcmd $ageH = $null $dbName = "" if (Test-CommandExists "Invoke-Sqlcmd") { try { $result = Invoke-Sqlcmd -Query $query -ServerInstance "localhost" -ErrorAction Stop if ($result) { $ageH = $result.age_hours; $dbName = $result.database_name } else { Record-Fail "SQL Server backup age" "no SQL backups found in msdb"; return } } catch { Write-Verbose "Invoke-Sqlcmd failed: $($_.Exception.Message)" } } if ($null -eq $ageH -and (Test-CommandExists "sqlcmd")) { try { $output = sqlcmd -S localhost -Q $query -h -1 -W 2>&1 | Out-String if ($output -match "(\d+)\s*$") { $ageH = [int]$Matches[1] } else { Record-Fail "SQL Server backup age" "could not parse sqlcmd output"; return } } catch { Write-Verbose "sqlcmd failed: $($_.Exception.Message)" } } if ($null -ne $ageH) { $detail = "${ageH}h$(if($dbName){" ($dbName)"})" if ($ageH -le $MaxBackupAgeHours) { Record-Pass "SQL Server backup age" $detail } else { Record-Fail "SQL Server backup age" "$detail exceeds ${MaxBackupAgeHours}h threshold" } } else { Record-Skip "SQL Server backup age" "SQL Server present but query tools unavailable" } } # -- 12. Backup Schedule Configured ------------------------------------------ function Test-BackupSchedule { Write-Section "Schedule" if (-not (Test-CommandExists "wbadmin")) { Record-Skip "Backup schedule configured" "wbadmin not available" return } try { $output = wbadmin get policy 2>&1 | Out-String if ($output -match "Schedule:" -or $output -match "Times of day:") { Record-Pass "Backup schedule configured" } elseif ($output -match "no current backup policy" -or $output -match "There is no currently-set") { Record-Fail "Backup schedule configured" "no backup policy configured" } else { Record-Fail "Backup schedule configured" "could not confirm schedule in policy output" } } catch { Record-Fail "Backup schedule configured" "wbadmin error - $($_.Exception.Message)" } } # -- 13. Bare Metal Recovery ------------------------------------------------ function Test-BareMetalRecovery { Write-Section "Bare Metal Recovery" if (-not (Test-CommandExists "wbadmin")) { Record-Skip "Bare metal recovery components included" "wbadmin not available" return } try { # Check policy for BMR $policyOutput = wbadmin get policy 2>&1 | Out-String $hasBMRPolicy = $policyOutput -match "bare metal recovery" -or $policyOutput -match "baremetal" -or $policyOutput -match "BMR" # Check versions for BMR $versionsOutput = wbadmin get versions 2>&1 | Out-String $hasBMRVersion = $versionsOutput -match "Bare Metal Recovery" -or $versionsOutput -match "Can recover:.*Full Server" if ($hasBMRPolicy -or $hasBMRVersion) { Record-Pass "Bare metal recovery components included" } elseif ($versionsOutput -match "no previous backups" -or $versionsOutput -match "There are no versions") { Record-Fail "Bare metal recovery components included" "no backups found to check" } else { Record-Fail "Bare metal recovery components included" "BMR not detected in backup policy or versions" } } catch { Record-Fail "Bare metal recovery components included" "wbadmin error - $($_.Exception.Message)" } } # ============================================================================ # OUTPUT # ============================================================================ function Write-Header { if ($OutputFormat -eq "tap") { Write-Host "TAP version 13" } else { Write-Host "" Write-Color "Windows Backup Smoke Tests" "White" Write-Host "Host: $($env:COMPUTERNAME)" Write-Host "Target: $(if ($BackupTarget) { $BackupTarget } else { '(auto-detect)' })" Write-Host "Time: $(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ')" } } function Write-Summary { $duration = [math]::Floor(((Get-Date) - $script:StartTime).TotalSeconds) if ($OutputFormat -eq "tap") { Write-Host "1..$($script:Total)" Write-Host "# pass $($script:Pass)" Write-Host "# fail $($script:Fail)" Write-Host "# skip $($script:Skip)" } else { Write-Host "" $separator = [string]::new([char]0x2500, 40) Write-Color $separator "White" Write-Color "Summary $($env:COMPUTERNAME)" "White" $summaryLine = " $($script:Pass) passed $($script:Fail) failed $($script:Skip) skipped (${duration}s)" Write-Host $summaryLine Write-Color $separator "White" if ($script:Fail -eq 0) { Write-Color "All tests passed." "Green" } else { Write-Color "$($script:Fail) test(s) failed." "Red" } } } # ============================================================================ # MAIN # ============================================================================ Write-Header # Run all tests Test-BackupFeature Test-BackupScheduledTask Test-LastBackupStatus Test-BackupAge Test-BackupTargetAccessible Test-VSSWriters Test-VSSShadowCopies Test-SystemStateBackup Test-BackupDiskSpace Test-BackupEventLog Test-SQLBackupAge Test-BackupSchedule Test-BareMetalRecovery Write-Summary if ($script:Fail -eq 0) { exit 0 } else { exit 1 }