The Veeam, MARS, WSB and VB365 collectors each carried identical copies of logging, ISO time conversion, property lookup, posting to the API, config loading, TLS setup, the sent-runs state file and the 401 check, and their installers repeated the same key prompt, locked-down config, file copy and scheduled-task registration. These now live in collectors/common/BackupDashboard.Collector.psm1: - Initialize-Collector / Test-RunSent / Send-JobReport / Test-AuthRejected / Complete-Collector for collector runs - Write-Log, Get-ErrorText, ConvertTo-IsoTime, Get-FirstValue - Install-CollectorTask for the shared half of each installer Collectors find the module next to themselves (installed) or in ..\common (repo); installers copy it into Program Files beside the collector. The diagnostic scripts stay standalone single files. No behavior change: a harness ran each collector against its mock (12 scenarios: dry run, two real runs against a stub API, state file, logs, exit codes, rejected key, en-GB, backfill) before and after, and all 84 outputs are identical. Installers, run with stand-ins for the Windows-only commands, write identical configs and tasks; the only differences are the module being copied and the summary line naming the shell's path. Co-Authored-By: Claude Opus 5.5 <[email protected]>
267 lines
11 KiB
PowerShell
267 lines
11 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Reports Veeam Backup & Replication job results to the Backup Dashboard.
|
|
|
|
.DESCRIPTION
|
|
Runs on the VBR server, normally as a SYSTEM scheduled task every 60 minutes
|
|
(see Install-VeeamCollector.ps1). For every backup, replica and backup copy job it sends:
|
|
- the job's next scheduled run (drives the dashboard's Overdue flag), and
|
|
- every session from the last LookbackHours that the dashboard hasn't already received
|
|
in its final state. Running sessions are re-sent each time until they finish.
|
|
|
|
Already-reported sessions 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.
|
|
|
|
Compatible with Windows PowerShell 5.1 and PowerShell 7.
|
|
|
|
.PARAMETER ConfigPath
|
|
JSON config with ApiUrl, ApiKey and Tenant (optional: 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-VeeamBackupStatus.ps1 -DryRun
|
|
.EXAMPLE
|
|
.\Send-VeeamBackupStatus.ps1 -LookbackHours 720
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[string]$ConfigPath = (Join-Path $env:ProgramData 'BackupDashboard\veeam-collector.json'),
|
|
[int]$LookbackHours = 0,
|
|
[switch]$DryRun
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
# Shared collector code: installed next to this script; in the repo, in ..\common
|
|
$sharedModule = @(
|
|
(Join-Path $PSScriptRoot 'BackupDashboard.Collector.psm1'),
|
|
(Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'common') 'BackupDashboard.Collector.psm1')
|
|
) | Where-Object { Test-Path $_ } | Select-Object -First 1
|
|
if (-not $sharedModule) {
|
|
Write-Host "BackupDashboard.Collector.psm1 not found next to $PSCommandPath; re-run the installer."
|
|
exit 2
|
|
}
|
|
Import-Module $sharedModule -Force
|
|
|
|
# ---------- Helpers ----------
|
|
|
|
function Import-Veeam {
|
|
if (Get-Command Get-VBRJob -ErrorAction SilentlyContinue) { return } # already loaded
|
|
if (Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) {
|
|
Import-Module Veeam.Backup.PowerShell -WarningAction SilentlyContinue # VBR 11+
|
|
} else {
|
|
Add-PSSnapin VeeamPSSnapin # VBR 10 and earlier
|
|
}
|
|
}
|
|
|
|
function Get-NextRun($Job) {
|
|
try {
|
|
if (-not $Job.IsScheduleEnabled) { return $null }
|
|
# Immediate-mode backup copy jobs run whenever their source job does. Their schedule still
|
|
# carries a NextRun, but it's a stale date (months old on VBR 13) that would show as Overdue.
|
|
if ($Job.IsContinuous) { return $null }
|
|
|
|
# The ScheduleOptions property is a stale snapshot on VBR 13 (NextRun empty, last run years
|
|
# old); GetScheduleOptions() reads the live schedule. Fall back for versions without it.
|
|
$options = $null
|
|
try { $options = $Job.GetScheduleOptions() } catch { }
|
|
if (-not $options) { $options = $Job.ScheduleOptions }
|
|
$raw = $options.NextRun
|
|
if (-not $raw) { return $null } # e.g. chained jobs that run "after" another job
|
|
if ($raw -is [datetime]) { return ConvertTo-IsoTime $raw }
|
|
# A string formatted by VBR ("2026-10-09 12:15:00 AM" on VBR 13, en-CA). Try the server's
|
|
# culture first, then the invariant one
|
|
foreach ($culture in [Globalization.CultureInfo]::CurrentCulture, [Globalization.CultureInfo]::InvariantCulture) {
|
|
$parsed = [datetime]::MinValue
|
|
if ([datetime]::TryParse([string]$raw, $culture, [Globalization.DateTimeStyles]::None, [ref]$parsed)) {
|
|
return ConvertTo-IsoTime $parsed
|
|
}
|
|
}
|
|
Write-Log "Couldn't parse next run '$raw' for job '$($Job.Name)'" 'WARN'
|
|
} catch {
|
|
Write-Log "Couldn't read schedule for job '$($Job.Name)': $(Get-ErrorText $_)" 'WARN'
|
|
}
|
|
return $null
|
|
}
|
|
|
|
function Get-RunStatus($Session) {
|
|
if ([string]$Session.State -ne 'Stopped') { return 'running' }
|
|
switch ([string]$Session.Result) {
|
|
'Success' { return 'success' }
|
|
'Warning' { return 'warning' }
|
|
'Failed' { return 'failed' }
|
|
# Stopped without a result (e.g. cancelled): surface it rather than hide it
|
|
default { return 'warning' }
|
|
}
|
|
}
|
|
|
|
# Builds a readable summary: the session's reason, any task (VM/object) that didn't succeed,
|
|
# and warning/error lines from the session log.
|
|
function Get-RunMessage($Session, [string]$Status, $Tasks) {
|
|
$lines = New-Object 'System.Collections.Generic.List[string]'
|
|
try {
|
|
$reason = [string]$Session.Info.Reason
|
|
if ($reason.Trim()) { $lines.Add($reason.Trim()) }
|
|
} catch { }
|
|
|
|
foreach ($task in $Tasks) {
|
|
$taskStatus = [string]$task.Status
|
|
if ($taskStatus -and $taskStatus -ne 'Success' -and $taskStatus -ne 'InProgress' -and $taskStatus -ne 'Pending') {
|
|
$taskReason = ''
|
|
try { $taskReason = ([string]$task.Info.Reason).Trim() } catch { }
|
|
$line = "$($task.Name): $taskStatus"
|
|
if ($taskReason) { $line += " - $taskReason" }
|
|
$lines.Add($line)
|
|
}
|
|
}
|
|
|
|
if ($Status -eq 'warning' -or $Status -eq 'failed') {
|
|
try {
|
|
foreach ($record in $Session.Logger.GetLog().UpdatedRecords) {
|
|
$recordStatus = [string]$record.Status
|
|
if ($recordStatus -eq 'EWarning' -or $recordStatus -eq 'EFailed') {
|
|
$title = ([string]$record.Title).Trim()
|
|
if ($title -and -not $lines.Contains($title)) { $lines.Add($title) }
|
|
}
|
|
}
|
|
} catch { }
|
|
}
|
|
|
|
if ($lines.Count -eq 0) {
|
|
switch ($Status) {
|
|
'success' { return 'Job completed successfully.' }
|
|
'running' { return '' }
|
|
default { return "Job finished with result '$($Session.Result)'." }
|
|
}
|
|
}
|
|
return ($lines -join "`n")
|
|
}
|
|
|
|
# VBR 12+ backup copy jobs: Get-VBRJob returns the copy (policy) job, but Get-VBRBackupSession
|
|
# returns only the sessions of its hidden worker jobs, one per source job, never the policy's own.
|
|
# Each worker job records its policy in PolicyJobId, so those sessions can be filed under it.
|
|
function Get-PolicyJobId($Session, [hashtable]$KnownJobIds) {
|
|
try {
|
|
$policyId = [string]$Session.GetJob().PolicyJobId
|
|
if ($policyId -and $KnownJobIds.ContainsKey($policyId)) { return $policyId }
|
|
} catch { }
|
|
return ''
|
|
}
|
|
|
|
function ConvertTo-Run($Session, $Job) {
|
|
$status = Get-RunStatus $Session
|
|
$tasks = @()
|
|
try { $tasks = @($Session.GetTaskSessions()) } catch { }
|
|
|
|
$details = [ordered]@{
|
|
job_type = [string]$Job.JobType
|
|
vbr_server = $env:COMPUTERNAME
|
|
objects = $tasks.Count
|
|
}
|
|
$failedTasks = @($tasks | Where-Object { [string]$_.Status -eq 'Failed' }).Count
|
|
$warningTasks = @($tasks | Where-Object { [string]$_.Status -eq 'Warning' }).Count
|
|
if ($failedTasks) { $details.objects_failed = $failedTasks }
|
|
if ($warningTasks) { $details.objects_warning = $warningTasks }
|
|
try { if ($Session.IsRetryMode) { $details.retry = $true } } catch { }
|
|
# Worker session of a backup copy job: say which source job it copied
|
|
# (worker sessions are named "<copy job>\<source job>")
|
|
if ($Session.JobId.ToString() -ne $Job.Id.ToString()) {
|
|
$details.copied_from = ([string]$Session.JobName -split '\\')[-1]
|
|
}
|
|
|
|
$run = [ordered]@{
|
|
external_id = $Session.Id.ToString()
|
|
status = $status
|
|
started_at = ConvertTo-IsoTime $Session.CreationTime
|
|
message = Get-RunMessage $Session $status $tasks
|
|
details = $details
|
|
}
|
|
if ($status -ne 'running') {
|
|
$finished = ConvertTo-IsoTime $Session.EndTime
|
|
if ($finished) { $run.finished_at = $finished } # otherwise the API uses receipt time
|
|
try {
|
|
$size = [long]$Session.BackupStats.BackupSize
|
|
if ($size -gt 0) { $run.size_bytes = $size }
|
|
$dataSize = [long]$Session.BackupStats.DataSize
|
|
if ($dataSize -gt 0) { $details.data_size_bytes = $dataSize }
|
|
} catch { }
|
|
}
|
|
return $run
|
|
}
|
|
|
|
# ---------- Main ----------
|
|
|
|
try {
|
|
$Config = Initialize-Collector -Name 'veeam' -ConfigPath $ConfigPath -LookbackHours $LookbackHours -DryRun:$DryRun
|
|
$SourceSystem = if ($Config.SourceSystem) { [string]$Config.SourceSystem } else { 'Veeam B&R' }
|
|
$LookbackHours = $Config.LookbackHours
|
|
|
|
Import-Veeam
|
|
|
|
$since = (Get-Date).AddHours(-$LookbackHours)
|
|
$jobs = @(Get-VBRJob -WarningAction SilentlyContinue)
|
|
|
|
$jobIds = @{}
|
|
foreach ($j in $jobs) { $jobIds[$j.Id.ToString()] = $true }
|
|
|
|
# Get-VBRBackupSession returns every session in the database, so fetch once and bucket by job
|
|
$sessionsByJob = @{}
|
|
$policyByWorker = @{} # worker job ID -> policy job ID ('' if it isn't a worker of a listed job)
|
|
foreach ($s in Get-VBRBackupSession -WarningAction SilentlyContinue) {
|
|
if ($s.CreationTime -lt $since) { continue }
|
|
$key = $s.JobId.ToString()
|
|
if (-not $jobIds.ContainsKey($key)) {
|
|
if (-not $policyByWorker.ContainsKey($key)) { $policyByWorker[$key] = Get-PolicyJobId $s $jobIds }
|
|
if (-not $policyByWorker[$key]) { continue }
|
|
$key = $policyByWorker[$key]
|
|
}
|
|
if (-not $sessionsByJob.ContainsKey($key)) { $sessionsByJob[$key] = New-Object 'System.Collections.Generic.List[object]' }
|
|
$sessionsByJob[$key].Add($s)
|
|
}
|
|
} catch {
|
|
Write-Log "Collector setup failed: $(Get-ErrorText $_)" 'ERROR'
|
|
exit 2
|
|
}
|
|
|
|
$failures = 0
|
|
foreach ($job in $jobs) {
|
|
$jobInfo = [ordered]@{
|
|
external_id = $job.Id.ToString()
|
|
name = $job.Name
|
|
# Always sent (null when unscheduled/disabled) so the dashboard's value tracks Veeam's
|
|
next_run_at = Get-NextRun $job
|
|
}
|
|
$pending = @()
|
|
if ($sessionsByJob.ContainsKey($jobInfo.external_id)) {
|
|
$pending = @($sessionsByJob[$jobInfo.external_id] |
|
|
Where-Object { -not (Test-RunSent ($_.Id.ToString())) } |
|
|
Sort-Object CreationTime)
|
|
}
|
|
|
|
try {
|
|
if ($pending.Count -eq 0) {
|
|
# Keep next_run_at fresh even when nothing new ran
|
|
Send-JobReport -Tenant $Config.Tenant -SourceSystem $SourceSystem -Job $jobInfo
|
|
}
|
|
foreach ($session in $pending) {
|
|
$run = ConvertTo-Run $session $job
|
|
Send-JobReport -Tenant $Config.Tenant -SourceSystem $SourceSystem -Job $jobInfo -Run $run -SentKey $run.external_id -StartedAt ([datetime]$session.CreationTime)
|
|
}
|
|
} catch {
|
|
$failures++
|
|
Write-Log "Failed to report job '$($job.Name)': $(Get-ErrorText $_)" 'ERROR'
|
|
if (Test-AuthRejected $_) {
|
|
Write-Log 'API key rejected; check ApiKey in the config. Stopping.' 'ERROR'
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
$exitCode = Complete-Collector -JobCount $jobs.Count -Failures $failures
|
|
if ($exitCode) { exit $exitCode }
|