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 <[email protected]>
This commit is contained in:
@@ -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) = <error: $($_.Exception.Message)>"; 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"
|
||||
@@ -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 "<server> - 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 <name>)'
|
||||
$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"
|
||||
@@ -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 `<SERVER> - 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:<computer name>`, 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
|
||||
```
|
||||
@@ -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 }
|
||||
@@ -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) }
|
||||
Reference in New Issue
Block a user