From a14b1b0dc9690233f29c2384bfa7851597729aea Mon Sep 17 00:00:00 2001 From: kyle Date: Wed, 7 Oct 2026 15:56:04 -0600 Subject: [PATCH] Add Windows Server Backup collector PowerShell script for servers using Windows Server Backup, run hourly as a SYSTEM scheduled task under Windows PowerShell 5.1. Each server is one dashboard job. - Backup jobs from Get-WBJob (in progress and -Previous) within the lookback window; restores are ignored. WSB jobs have no ID, so runs are keyed on start time, which also merges a Running report with the finished one - Error HRESULT -> failed; detailed error or a failed item -> warning; messages include the error description, codes as 0x8XXXXXXX, failed items and the failure log path - Next run from Get-WBSummary's NextBackupTime - Get-WsbCollectorDiagnostics.ps1 dumps the cmdlets' real objects and recent Microsoft-Windows-Backup events (to check whether job times are local or UTC); installer adapted from the MARS one Not yet run against a real server. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../wsb/Get-WsbCollectorDiagnostics.ps1 | 103 ++++++ collectors/wsb/Install-WsbCollector.ps1 | 89 +++++ collectors/wsb/README.md | 69 ++++ collectors/wsb/Send-WsbBackupStatus.ps1 | 350 ++++++++++++++++++ collectors/wsb/tests/MockWsb.ps1 | 61 +++ 6 files changed, 673 insertions(+), 1 deletion(-) create mode 100644 collectors/wsb/Get-WsbCollectorDiagnostics.ps1 create mode 100644 collectors/wsb/Install-WsbCollector.ps1 create mode 100644 collectors/wsb/README.md create mode 100644 collectors/wsb/Send-WsbBackupStatus.ps1 create mode 100644 collectors/wsb/tests/MockWsb.ps1 diff --git a/README.md b/README.md index 79cfab7..21543fe 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ browser ──nginx (basic auth, TLS)──▶ /api/* ─▶│ SQLite │ - `public/`: static frontend (no build step). Add `?mock=1` to the URL to use built-in sample data. - `backend/`: FastAPI API backed by a single SQLite file. - `deploy/`: nginx site and systemd unit. -- `collectors/`: scripts that report results from backup products ([Veeam B&R](collectors/veeam/README.md), [Azure Backup / MARS](collectors/mars/README.md)). +- `collectors/`: scripts that report results from backup products ([Veeam B&R](collectors/veeam/README.md), [Azure Backup / MARS](collectors/mars/README.md), [Windows Server Backup](collectors/wsb/README.md)). ## Data model diff --git a/collectors/wsb/Get-WsbCollectorDiagnostics.ps1 b/collectors/wsb/Get-WsbCollectorDiagnostics.ps1 new file mode 100644 index 0000000..a5da054 --- /dev/null +++ b/collectors/wsb/Get-WsbCollectorDiagnostics.ps1 @@ -0,0 +1,103 @@ +<# +.SYNOPSIS + Read-only dump of what the Windows Server Backup cmdlets return, for troubleshooting the collector. + +.DESCRIPTION + The collector reads properties of Get-WBJob / Get-WBSummary results whose names and time zones + aren't fully documented. This prints those objects' real structure (property names, types and + values, a few levels deep), plus recent events from the Microsoft-Windows-Backup log, whose + timestamps are reliable local times to compare against. Nothing is changed. Output is also + saved to a text file. + +.EXAMPLE + .\Get-WsbCollectorDiagnostics.ps1 +#> +[CmdletBinding()] +param( + [int]$Jobs = 3, + [int]$Events = 15, + [string]$OutFile = (Join-Path $env:TEMP 'wsb-collector-diagnostics.txt') +) + +$ErrorActionPreference = 'Stop' +$MaxDepth = 6 + +if (-not (Get-Command Get-WBJob -ErrorAction SilentlyContinue)) { + Import-Module WindowsServerBackup +} + +function Test-Scalar($Value) { + return $null -eq $Value -or $Value -is [string] -or $Value -is [ValueType] -or $Value -is [guid] +} + +# Recursive "name (type) = value" listing. Collections show their first 3 items. +function Show-Object($Value, [string]$Indent = ' ', [int]$Depth = 0) { + if (Test-Scalar $Value) { return } + if ($Depth -ge $MaxDepth) { "$Indent..."; return } + if ($Value -is [Collections.IEnumerable]) { + $items = @($Value) + "$Indent[$($items.Count) item(s)]" + $i = 0 + foreach ($item in $items | Select-Object -First 3) { + "$Indent[$i] ($($item.GetType().FullName))$(if (Test-Scalar $item) { " = $item" })" + Show-Object $item "$Indent " ($Depth + 1) + $i++ + } + return + } + foreach ($p in $Value.PSObject.Properties) { + try { $v = $p.Value } catch { "$Indent$($p.Name) = "; continue } + $type = if ($null -eq $v) { 'null' } else { $v.GetType().FullName } + if (Test-Scalar $v) { + "$Indent$($p.Name) ($type) = $v" + } else { + $text = [string]$v + if ($text.Length -gt 100) { $text = $text.Substring(0, 97) + '...' } + "$Indent$($p.Name) ($type) = $text" + Show-Object $v "$Indent " ($Depth + 1) + } + } +} + +function Show-Section([string]$Title, [scriptblock]$Get) { + "== $Title" + try { + $result = @(& $Get) + if ($result.Count -eq 0 -or ($result.Count -eq 1 -and $null -eq $result[0])) { ' (nothing returned)' } + $n = 0 + foreach ($item in $result) { + if ($null -eq $item) { continue } + " [$n] ($($item.GetType().FullName))" + Show-Object $item ' ' + $n++ + } + } catch { + " error: $($_.Exception.Message)" + } + '' +} + +$report = & { + "Generated $(Get-Date -Format o) on $env:COMPUTERNAME, PowerShell $($PSVersionTable.PSVersion)" + "Local time zone: $([TimeZoneInfo]::Local.Id), UTC offset now $([TimeZoneInfo]::Local.GetUtcOffset((Get-Date)))" + '' + Show-Section 'Get-WBSummary' { Get-WBSummary } + Show-Section 'Get-WBJob (in progress)' { Get-WBJob } + Show-Section "Get-WBJob -Previous $Jobs" { Get-WBJob -Previous $Jobs } + Show-Section 'Get-WBPolicy | Get-WBSchedule' { + $policy = Get-WBPolicy + if ($policy) { Get-WBSchedule -Policy $policy } else { '(no scheduled backup policy)' } + } + "== Microsoft-Windows-Backup event log (last $Events, local times)" + try { + Get-WinEvent -LogName 'Microsoft-Windows-Backup' -MaxEvents $Events | + Select-Object TimeCreated, Id, LevelDisplayName, @{ n = 'Message'; e = { ($_.Message -split "`r?`n")[0] } } | + Format-Table -AutoSize -Wrap | Out-String -Width 300 + } catch { + " error: $($_.Exception.Message)" + } +} | Out-String -Width 300 + +$report +$report | Set-Content -Path $OutFile -Encoding UTF8 +Write-Host "Saved to $OutFile" diff --git a/collectors/wsb/Install-WsbCollector.ps1 b/collectors/wsb/Install-WsbCollector.ps1 new file mode 100644 index 0000000..963d74c --- /dev/null +++ b/collectors/wsb/Install-WsbCollector.ps1 @@ -0,0 +1,89 @@ +<# +.SYNOPSIS + Installs the Windows Server Backup collector as a SYSTEM scheduled task. + +.DESCRIPTION + - Copies Send-WsbBackupStatus.ps1 to Program Files (only admins can modify it, which + matters because it runs as SYSTEM). + - Writes the config to ProgramData\BackupDashboard and restricts that folder to SYSTEM and + Administrators, since it contains the API key. + - Registers a scheduled task that runs every IntervalMinutes under Windows PowerShell 5.1 + (powershell.exe), which the WindowsServerBackup module targets. + Safe to re-run to change settings or update the script. Can sit alongside the other collectors. + +.EXAMPLE + .\Install-WsbCollector.ps1 -ApiUrl https://backups.example.com -Tenant "Acme Corp" + (prompts for the API key) +#> +#Requires -RunAsAdministrator +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$ApiUrl, + [Parameter(Mandatory)][string]$Tenant, + # Name shown on the dashboard; defaults to " - Windows Server Backup" + [string]$JobName, + [string]$SourceSystem = 'Windows Server Backup', + [ValidateRange(5, 1440)][int]$IntervalMinutes = 60, + [ValidateRange(1, 720)][int]$LookbackHours = 48, + [string]$InstallDir = (Join-Path $env:ProgramFiles 'BackupDashboard') +) + +$ErrorActionPreference = 'Stop' +$TaskName = 'Backup Dashboard - WSB collector' +$DataDir = Join-Path $env:ProgramData 'BackupDashboard' +$ConfigPath = Join-Path $DataDir 'wsb-collector.json' +$WindowsPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + +# Check Windows Server Backup is here before changing anything +if (-not (Get-Module -ListAvailable -Name WindowsServerBackup)) { + throw 'The WindowsServerBackup PowerShell module is not available. Install the Windows Server Backup feature (Install-WindowsFeature Windows-Server-Backup).' +} + +if ($ApiUrl -notmatch '^https://') { + Write-Warning 'ApiUrl is not HTTPS: the API key would be sent in clear text.' +} + +# Read the key as a SecureString so it isn't echoed to the console or kept in shell history +$secure = Read-Host -AsSecureString 'API key (from: python manage.py create-key )' +$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) +try { $apiKey = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } +finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } +if (-not $apiKey.StartsWith('bdk_')) { throw "That doesn't look like a dashboard API key (expected 'bdk_...')." } + +# Config folder: SYSTEM + Administrators only (SIDs so this works on non-English Windows) +New-Item -ItemType Directory -Path $DataDir -Force | Out-Null +& icacls.exe $DataDir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null +if ($LASTEXITCODE) { throw "icacls failed on $DataDir" } + +$config = [ordered]@{ + ApiUrl = $ApiUrl.TrimEnd('/') + ApiKey = $apiKey + Tenant = $Tenant + SourceSystem = $SourceSystem + LookbackHours = $LookbackHours +} +if ($JobName) { $config.JobName = $JobName } +$config | ConvertTo-Json | Set-Content -Path $ConfigPath -Encoding UTF8 + +New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null +$scriptPath = Join-Path $InstallDir 'Send-WsbBackupStatus.ps1' +Copy-Item -Path (Join-Path $PSScriptRoot 'Send-WsbBackupStatus.ps1') -Destination $scriptPath -Force +Unblock-File -Path $scriptPath + +$action = New-ScheduledTaskAction -Execute $WindowsPowerShell ` + -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$scriptPath`"" +$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) ` + -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes) +$principal = New-ScheduledTaskPrincipal -UserId 'S-1-5-18' -LogonType ServiceAccount -RunLevel Highest +$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 30) ` + -MultipleInstances IgnoreNew -StartWhenAvailable +Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal ` + -Settings $settings -Description 'Sends Windows Server Backup results to the Backup Dashboard' -Force | Out-Null + +Write-Host "Installed. Config: $ConfigPath" +Write-Host "Task '$TaskName' runs every $IntervalMinutes minutes as SYSTEM, using Windows PowerShell." +Write-Host '' +Write-Host 'Next steps:' +Write-Host " 1. Preview what will be sent: & '$WindowsPowerShell' -File '$scriptPath' -DryRun" +Write-Host " 2. Backfill 30 days of history: & '$WindowsPowerShell' -File '$scriptPath' -LookbackHours 720" +Write-Host " 3. Check the log: Get-Content '$DataDir\wsb-collector.log' -Tail 20" diff --git a/collectors/wsb/README.md b/collectors/wsb/README.md new file mode 100644 index 0000000..65f8fa7 --- /dev/null +++ b/collectors/wsb/README.md @@ -0,0 +1,69 @@ +# Windows Server Backup collector + +A PowerShell script that runs on each server using Windows Server Backup (WSB) and reports its +backups to the dashboard. Each server appears as one job, named ` - Windows Server Backup` +by default. + +> **Not yet checked against a real server.** The cmdlets are the `WindowsServerBackup` module's own +> (`Get-WBJob`, `Get-WBSummary`), but the properties of the objects they return, and whether job +> times are local or UTC, aren't fully documented. Before installing on the first server, run +> `Get-WsbCollectorDiagnostics.ps1` and the collector with `-DryRun` (see below) to confirm what it +> reads. + +## Install (on the server, as an administrator) + +Requires the Windows Server Backup feature with its PowerShell module +(`Install-WindowsFeature Windows-Server-Backup`). Runs under Windows PowerShell 5.1 +(`powershell.exe`), which that module targets. + +1. On the dashboard host, create a key for this server: + ```bash + python manage.py create-key wsb-acme-dc01 + ``` +2. Copy this folder to the server and run: + ```powershell + .\Install-WsbCollector.ps1 -ApiUrl https://backups.example.com -Tenant "Acme Corp" + ``` + Add `-JobName "Domain Controller"` to choose the name shown on the dashboard. It prompts for the + key, then: + - writes `C:\ProgramData\BackupDashboard\wsb-collector.json` (readable only by SYSTEM and Administrators) + - copies the script to `C:\Program Files\BackupDashboard\` + - registers the scheduled task **Backup Dashboard - WSB collector** (SYSTEM, every 60 minutes) +3. Preview the payloads, then backfill 30 days so the history view is populated straight away: + ```powershell + powershell.exe -File 'C:\Program Files\BackupDashboard\Send-WsbBackupStatus.ps1' -DryRun + powershell.exe -File 'C:\Program Files\BackupDashboard\Send-WsbBackupStatus.ps1' -LookbackHours 720 + ``` + +It can be installed alongside the other collectors: it uses its own config, state, log and task. +Re-run the installer to change settings or update the script. + +## What it sends + +| Dashboard | Windows Server Backup | +|---|---| +| Company | `Tenant` from the config | +| Job | One per server, ID `wsb:`, so changing `JobName` renames rather than duplicates it | +| Run | One per backup job from `Get-WBJob`, identified by its start time (WSB jobs have no ID). Restores are ignored | +| Status | Error HRESULT → **Failed**. Success, but a detailed error or a failed item (e.g. one volume) → **Warning**. Cancelled or unrecognised → **Warning**. In progress → **Running** | +| Started / finished | Job start / end time | +| Next scheduled | `Get-WBSummary`'s `NextBackupTime`. Empty when there's no scheduled backup | +| Message | Error description, error codes (as `0x8XXXXXXX`), failed items, and the failure log path | + +Each run covers the last 48 hours (`LookbackHours`). Runs already delivered are recorded in +`wsb-collector.state.json`. + +## Troubleshooting + +- Log: `C:\ProgramData\BackupDashboard\wsb-collector.log`. Exit codes: `0` OK, `1` some runs + failed to send (retried next run), `2` setup failed (config or WindowsServerBackup module). +- Wrong status, times or messages: run `.\Get-WsbCollectorDiagnostics.ps1` (read-only). It prints + the real structure of what the cmdlets return, plus recent `Microsoft-Windows-Backup` events to + compare times against. + +## Testing without Windows Server Backup + +```powershell +. ./tests/MockWsb.ps1 # or: . ./tests/MockWsb.ps1 -WithRunningJob +./Send-WsbBackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun +``` diff --git a/collectors/wsb/Send-WsbBackupStatus.ps1 b/collectors/wsb/Send-WsbBackupStatus.ps1 new file mode 100644 index 0000000..90587f2 --- /dev/null +++ b/collectors/wsb/Send-WsbBackupStatus.ps1 @@ -0,0 +1,350 @@ +<# +.SYNOPSIS + Reports Windows Server Backup (WSB) results to the Backup Dashboard. + +.DESCRIPTION + Runs on a server with the Windows Server Backup feature, normally as a SYSTEM scheduled task + every 60 minutes (see Install-WsbCollector.ps1). The server appears on the dashboard as one job. + Each run sends: + - the next scheduled backup (Get-WBSummary's NextBackupTime), which drives the dashboard's + Overdue flag, and + - every backup from the last LookbackHours that the dashboard hasn't already received in its + final state. A backup in progress is sent as Running until it finishes. + + WSB jobs have no ID, so a run is identified by its start time. Already-reported runs are + tracked in a state file, and the ingest API is idempotent, so a failed or repeated run is + harmless: anything not confirmed is retried next time. + + Written for Windows PowerShell 5.1, which the WindowsServerBackup module targets. + +.PARAMETER ConfigPath + JSON config with ApiUrl, ApiKey and Tenant (optional: JobName, SourceSystem, LookbackHours). + +.PARAMETER LookbackHours + Overrides the config value. Use 720 once to backfill 30 days of history. + +.PARAMETER DryRun + Print the payloads instead of sending them. Doesn't touch the state or log files. + +.EXAMPLE + .\Send-WsbBackupStatus.ps1 -DryRun +.EXAMPLE + .\Send-WsbBackupStatus.ps1 -LookbackHours 720 +#> +[CmdletBinding()] +param( + [string]$ConfigPath = (Join-Path $env:ProgramData 'BackupDashboard\wsb-collector.json'), + [int]$LookbackHours = 0, + [switch]$DryRun +) + +$ErrorActionPreference = 'Stop' + +$DataDir = Split-Path -Parent $ConfigPath +$LogPath = Join-Path $DataDir 'wsb-collector.log' +$StatePath = Join-Path $DataDir 'wsb-collector.state.json' +$MaxLogBytes = 5MB +# Covers 30 days at several backups a day +$MaxPreviousJobs = 200 +$Invariant = [Globalization.CultureInfo]::InvariantCulture + +# ---------- Helpers ---------- + +function Write-Log { + param([string]$Message, [string]$Level = 'INFO') + $line = '{0} [{1}] {2}' -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'), $Level, $Message + Write-Host $line + if ($DryRun) { return } + if ((Test-Path $LogPath) -and (Get-Item $LogPath).Length -gt $MaxLogBytes) { + Move-Item $LogPath "$LogPath.1" -Force + } + Add-Content -Path $LogPath -Value $line -Encoding UTF8 +} + +function Get-ErrorText($ErrorRecord) { + # Windows PowerShell puts the HTTP response body (e.g. the API's 422 explanation) here + if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) { + return "$($ErrorRecord.Exception.Message) $($ErrorRecord.ErrorDetails.Message)" + } + return $ErrorRecord.Exception.Message +} + +# The API needs an explicit UTC offset. DateTimeOffset keeps a Local/Unspecified time as local and a +# Utc time as +00:00. +function ConvertTo-IsoTime($Value) { + if ($null -eq $Value) { return $null } + $dt = [datetime]$Value + if ($dt.Year -le 1900) { return $null } # "not set" (DateTime.MinValue and friends) + return ([DateTimeOffset]$dt).ToString("yyyy-MM-dd'T'HH:mm:sszzz", $Invariant) +} + +# First property of $Object, out of $Names, that exists and isn't empty. The WSB object model isn't +# fully documented, so reads go through this rather than assuming one spelling. +function Get-FirstValue($Object, [string[]]$Names) { + if ($null -eq $Object) { return $null } + foreach ($name in $Names) { + try { + $prop = $Object.PSObject.Properties[$name] + if (-not $prop) { continue } + $value = $prop.Value + # Check emptiness by type: casting to [string] would make a one-item list of objects '' + if ($null -eq $value) { continue } + if ($value -is [string] -and $value -eq '') { continue } + if ($value -is [Collections.ICollection] -and $value.Count -eq 0) { continue } + return $value + } catch { } + } + return $null +} + +function Import-Wsb { + if (Get-Command Get-WBJob -ErrorAction SilentlyContinue) { return } # already loaded + # Present only when the Windows Server Backup feature (with its command-line tools) is installed + Import-Module WindowsServerBackup +} + +# ---------- Jobs ---------- + +function Get-JobTime($Job, [string]$Field) { + $value = Get-FirstValue $Job @($Field) + if ($null -eq $value) { return $null } + $dt = [datetime]$value + if ($dt.Year -le 1900) { return $null } # e.g. EndTime while a backup is running + return $dt +} + +# HRESULTs as numbers; 0 = OK. $null when the job doesn't have the property. +function Get-HResult($Object, [string]$Field) { + $value = Get-FirstValue $Object @($Field) + if ($null -eq $value) { return $null } + return [long]$value +} + +function Format-HResult([long]$Value) { + if ($Value -lt 0) { $Value += 4294967296 } # show as the familiar unsigned 0x8XXXXXXX + return '0x{0:X8}' -f $Value +} + +function Get-JobItems($Job) { + return @(Get-FirstValue $Job @('JobItems', 'Items') | Where-Object { $_ }) +} + +function Get-ItemName($Item) { + $name = Get-FirstValue $Item @('Name', 'VolumeLabel', 'MountPath', 'Path', 'Type') + if ($name) { return [string]$name } + return 'Item' +} + +# Items (volumes, system state, ...) that reported an error +function Get-FailedItems($Job) { + return @(Get-JobItems $Job | Where-Object { + $hr = Get-HResult $_ 'HResult' + $state = [string](Get-FirstValue $_ @('State', 'JobState')) + ($hr -and $hr -ne 0) -or $state -match 'Fail' + }) +} + +function Get-RunStatus($Job) { + $state = [string](Get-FirstValue $Job @('JobState', 'State')) + if (-not (Get-JobTime $Job 'EndTime') -and $state -notmatch 'Complet|Fail|Cancel|Abort|Stop|Succe') { + return 'running' + } + if ($state -match 'Fail') { return 'failed' } + if ($state -match 'Cancel|Abort|Stop') { return 'warning' } + $hr = Get-HResult $Job 'HResult' + if ($hr -and $hr -ne 0) { return 'failed' } + # Overall success but something underneath went wrong (e.g. a volume or files skipped) + $detailed = Get-HResult $Job 'DetailedHResult' + if (($detailed -and $detailed -ne 0) -or (Get-FailedItems $Job).Count) { return 'warning' } + if (-not $state -or $state -match 'Complet|Succe') { return 'success' } + return 'warning' # finished in a state we don't recognise: surface it rather than hide it +} + +function Get-RunMessage($Job, [string]$Status) { + $lines = New-Object 'System.Collections.Generic.List[string]' + $state = [string](Get-FirstValue $Job @('JobState', 'State')) + $description = Get-FirstValue $Job @('ErrorDescription', 'DetailedMessage') + if ($description) { $lines.Add(([string]$description).Trim()) } + + $hr = Get-HResult $Job 'HResult' + $detailed = Get-HResult $Job 'DetailedHResult' + if (($hr -and $hr -ne 0) -or ($detailed -and $detailed -ne 0)) { + $codes = @() + if ($hr -and $hr -ne 0) { $codes += "error $(Format-HResult $hr)" } + if ($detailed -and $detailed -ne 0) { $codes += "detailed error $(Format-HResult $detailed)" } + $lines.Add('Windows Server Backup reported ' + ($codes -join ', ') + '.') + } + + foreach ($item in Get-FailedItems $Job) { + $itemError = Get-FirstValue $item @('ErrorDescription', 'DetailedMessage') + $itemHr = Get-HResult $item 'HResult' + $line = "$(Get-ItemName $item) - " # names are often volumes like "D:", so no colon + if ($itemError) { $line += ([string]$itemError).Trim() } + elseif ($itemHr) { $line += "error $(Format-HResult $itemHr)" } + else { $line += [string](Get-FirstValue $item @('State', 'JobState')) } + $lines.Add($line) + } + + if ($Status -ne 'success') { + $failureLog = Get-FirstValue $Job @('FailureLogPath') + if ($failureLog) { $lines.Add("Failure log: $failureLog") } + } + + if ($lines.Count -eq 0) { + switch ($Status) { + 'success' { return 'Backup completed successfully.' } + 'running' { return '' } + default { return "Backup finished with state '$state'." } + } + } + # WSB reports "Completed" even for a backup that failed with an error code, so only mention + # the state when it says something else (e.g. cancelled) + if ($state -and $Status -ne 'success' -and $state -notmatch 'Complet') { $lines.Insert(0, "Job state: $state") } + return ($lines -join "`n") +} + +function ConvertTo-Run($Job) { + $status = Get-RunStatus $Job + $details = [ordered]@{ server = $env:COMPUTERNAME } + $items = @(Get-JobItems $Job | ForEach-Object { Get-ItemName $_ }) + if ($items.Count) { $details.items = $items } + $state = Get-FirstValue $Job @('JobState', 'State') + if ($state) { $details.wsb_state = [string]$state } + $version = Get-FirstValue $Job @('VersionId') + if ($version) { $details.version_id = [string]$version } + + # No external_id: WSB jobs have no ID, so the API keys the run on started_at + $run = [ordered]@{ + status = $status + started_at = ConvertTo-IsoTime (Get-JobTime $Job 'StartTime') + message = Get-RunMessage $Job $status + details = $details + } + if ($status -ne 'running') { + $finished = ConvertTo-IsoTime (Get-JobTime $Job 'EndTime') + if ($finished) { $run.finished_at = $finished } # otherwise the API uses receipt time + } + return $run +} + +function Test-IsBackupJob($Job) { + # Get-WBJob also lists recoveries; only backups belong on the dashboard + $type = [string](Get-FirstValue $Job @('JobType', 'Type')) + return (-not $type) -or $type -match 'Backup' +} + +function Get-NextRun { + try { + $summary = Get-WBSummary + return ConvertTo-IsoTime (Get-FirstValue $summary @('NextBackupTime')) + } catch { + Write-Log "Couldn't read the backup summary: $(Get-ErrorText $_)" 'WARN' + return $null + } +} + +function Send-Report($Body) { + $json = $Body | ConvertTo-Json -Depth 6 -Compress + if ($DryRun) { + Write-Host $json + return + } + # Send bytes: Windows PowerShell would otherwise encode a string body as Latin-1 + $params = @{ + Uri = "$($Config.ApiUrl.TrimEnd('/'))/api/v1/ingest" + Method = 'Post' + Headers = @{ Authorization = "Bearer $($Config.ApiKey)" } + ContentType = 'application/json; charset=utf-8' + Body = [Text.Encoding]::UTF8.GetBytes($json) + TimeoutSec = 30 + } + Invoke-RestMethod @params | Out-Null +} + +# ---------- Main ---------- + +try { + $Config = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json + foreach ($key in 'ApiUrl', 'ApiKey', 'Tenant') { + if (-not $Config.$key) { throw "Config $ConfigPath is missing '$key'" } + } + $SourceSystem = if ($Config.SourceSystem) { [string]$Config.SourceSystem } else { 'Windows Server Backup' } + $JobName = if ($Config.JobName) { [string]$Config.JobName } else { "$env:COMPUTERNAME - Windows Server Backup" } + if ($LookbackHours -le 0) { + $LookbackHours = if ($Config.LookbackHours) { [int]$Config.LookbackHours } else { 48 } + } + + # Windows PowerShell 5.1 may default to TLS 1.0 + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + + Import-Wsb + + # Run start time (ISO, as sent) -> Unix seconds, for runs already delivered in their final state + $sent = @{} + if (Test-Path $StatePath) { + $saved = Get-Content -Path $StatePath -Raw | ConvertFrom-Json + if ($saved) { foreach ($p in $saved.PSObject.Properties) { $sent[$p.Name] = [long]$p.Value } } + } + + $since = (Get-Date).AddHours(-$LookbackHours) + # Get-WBJob with no arguments returns the job in progress (if any); -Previous lists finished ones + $jobs = @(@(Get-WBJob) + @(Get-WBJob -Previous $MaxPreviousJobs) | Where-Object { + $_ -and (Test-IsBackupJob $_) -and (Get-JobTime $_ 'StartTime') + }) + $byStart = [ordered]@{} + foreach ($job in $jobs) { + $start = Get-JobTime $job 'StartTime' + if ($start -lt $since) { continue } + $byStart[(ConvertTo-IsoTime $start)] = $job # a job in both lists: the finished entry (later) wins + } + $pending = @($byStart.Keys | Sort-Object | Where-Object { -not $sent.ContainsKey($_) } | + ForEach-Object { $byStart[$_] }) + + $jobInfo = [ordered]@{ + # Stable per server, so renaming the dashboard job via JobName doesn't create a new one + external_id = "wsb:$($env:COMPUTERNAME.ToLowerInvariant())" + name = $JobName + # Always sent (null when unscheduled) so the dashboard's value tracks WSB's + next_run_at = Get-NextRun + } +} catch { + Write-Log "Collector setup failed: $(Get-ErrorText $_)" 'ERROR' + exit 2 +} + +$failures = 0 +$runsSent = 0 +try { + if ($pending.Count -eq 0) { + # Keep next_run_at fresh even when nothing new ran + Send-Report ([ordered]@{ tenant = $Config.Tenant; source_system = $SourceSystem; job = $jobInfo }) + } + foreach ($job in $pending) { + $run = ConvertTo-Run $job + Send-Report ([ordered]@{ tenant = $Config.Tenant; source_system = $SourceSystem; job = $jobInfo; run = $run }) + $runsSent++ + if ($run.status -ne 'running') { + $sent[$run.started_at] = ([DateTimeOffset](Get-JobTime $job 'StartTime')).ToUnixTimeSeconds() + } + } +} catch { + $failures++ + Write-Log "Failed to report: $(Get-ErrorText $_)" 'ERROR' +} + +if (-not $DryRun) { + # Forget runs that have aged out of the lookback window (plus a day of slack) + $cutoff = [DateTimeOffset]::Now.AddHours(-$LookbackHours - 24).ToUnixTimeSeconds() + $keep = [ordered]@{} + foreach ($key in $sent.Keys) { + if ($sent[$key] -ge $cutoff) { $keep[$key] = $sent[$key] } + } + try { + $keep | ConvertTo-Json -Compress | Set-Content -Path $StatePath -Encoding UTF8 + } catch { + Write-Log "Couldn't save state file: $(Get-ErrorText $_)" 'WARN' + } +} + +Write-Log ("Reported {0} runs, {1} failures" -f $runsSent, $failures) +if ($failures) { exit 1 } diff --git a/collectors/wsb/tests/MockWsb.ps1 b/collectors/wsb/tests/MockWsb.ps1 new file mode 100644 index 0000000..7d8e22f --- /dev/null +++ b/collectors/wsb/tests/MockWsb.ps1 @@ -0,0 +1,61 @@ +# Stand-ins for the WindowsServerBackup cmdlets. Object shapes are best guesses until checked +# against Get-WsbCollectorDiagnostics.ps1 output from a real server. +# +# . ./tests/MockWsb.ps1 # nothing running +# . ./tests/MockWsb.ps1 -WithRunningJob # a backup in progress +# ./Send-WsbBackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun +param([switch]$WithRunningJob) + +# Fixed to the top of the hour so repeated runs see the same jobs +$now = Get-Date -Minute 0 -Second 0 -Millisecond 0 + +function New-MockItem($Name, [int]$HResult = 0, $ErrorDescription = '') { + [pscustomobject]@{ Name = $Name; Type = 'Volume'; State = $(if ($HResult) { 'Failed' } else { 'Completed' }) + HResult = $HResult; ErrorDescription = $ErrorDescription } +} + +function New-MockJob($Type, $Start, $End, $State, [int]$HResult = 0, [int]$DetailedHResult = 0, $ErrorDescription = '', $Items = @()) { + [pscustomobject]@{ + JobType = $Type + StartTime = $Start + EndTime = $(if ($End) { $End } else { [datetime]::MinValue }) + JobState = $State + HResult = $HResult + DetailedHResult = $DetailedHResult + ErrorDescription = $ErrorDescription + JobItems = $Items + VersionId = $(if ($End) { $Start.ToUniversalTime().ToString('MM/dd/yyyy-HH:mm') } else { '' }) + SuccessLogPath = 'C:\Windows\Logs\WindowsServerBackup\Backup-success.log' + FailureLogPath = $(if ($HResult -or $DetailedHResult) { 'C:\Windows\Logs\WindowsServerBackup\Backup_Error.log' } else { '' }) + } +} + +$global:MockWsbRunning = if ($WithRunningJob) { + @(New-MockJob 'Backup' $now.AddMinutes(-10) $null 'Running' -Items @((New-MockItem 'C:'), (New-MockItem 'D:'))) +} else { + # What an idle server might return: an empty job rather than nothing + @([pscustomobject]@{ JobType = 'None'; StartTime = [datetime]::MinValue; EndTime = [datetime]::MinValue; JobState = 'Unknown' }) +} + +$global:MockWsbPrevious = @( + (New-MockJob 'Backup' $now.AddHours(-12) $now.AddHours(-11) 'Completed' -Items @((New-MockItem 'C:'), (New-MockItem 'D:'))), + # Completed overall, but one volume failed + (New-MockJob 'Backup' $now.AddHours(-36) $now.AddHours(-35) 'Completed' 0 -2147024891 '' @( + (New-MockItem 'C:'), (New-MockItem 'D:' -2147024891 'Access is denied.'))), + # Failed outright: backup target not found (0x80042302) + (New-MockJob 'Backup' $now.AddHours(-30) $now.AddHours(-29.9) 'Completed' -2147212542 0 ` + 'The backup storage location could not be found.'), + (New-MockJob 'Recovery' $now.AddHours(-5) $now.AddHours(-4) 'Completed'), # a restore: must be ignored + (New-MockJob 'Backup' $now.AddDays(-5) $now.AddDays(-5).AddHours(1) 'Completed') # outside 48h lookback +) + +$global:MockWsbSummary = [pscustomobject]@{ + NextBackupTime = $now.Date.AddHours(21).AddDays($(if ($now.Hour -ge 21) { 1 } else { 0 })) + LastSuccessfulBackupTime = $now.AddHours(-11) + LastBackupResultHR = 0 +} + +function Get-WBJob { param([int]$Previous) if ($PSBoundParameters.ContainsKey('Previous')) { $global:MockWsbPrevious } else { $global:MockWsbRunning } } +function Get-WBSummary { $global:MockWsbSummary } +function Get-WBPolicy { [pscustomobject]@{ PolicyName = 'Scheduled' } } +function Get-WBSchedule { param($Policy) @($global:MockWsbSummary.NextBackupTime) }