Files
Backup-Dashboard/collectors/vb365/Send-Vb365BackupStatus.ps1
T
kyleandClaude Opus 5.5 8d0e6dca71 Move code shared by the collectors into one module
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]>
2026-10-08 11:09:01 -06:00

279 lines
12 KiB
PowerShell

<#
.SYNOPSIS
Reports Veeam Backup for Microsoft 365 (VB365) job results to the Backup Dashboard.
.DESCRIPTION
Runs on the VB365 server, normally as a SYSTEM scheduled task every 60 minutes
(see Install-Vb365Collector.ps1). For every backup job (and backup copy job) it sends:
- the job's next scheduled run, which 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.
One VB365 server can protect several Microsoft 365 organizations, so the dashboard company is
chosen per job from its organization: OrganizationTenants in the config maps an organization
name (e.g. "contoso.onmicrosoft.com") to a company; otherwise Tenant is used; otherwise the
organization's own name.
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.
.PARAMETER ConfigPath
JSON config with ApiUrl and ApiKey (optional: Tenant, OrganizationTenants, 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-Vb365BackupStatus.ps1 -DryRun
.EXAMPLE
.\Send-Vb365BackupStatus.ps1 -LookbackHours 720
#>
[CmdletBinding()]
param(
[string]$ConfigPath = (Join-Path $env:ProgramData 'BackupDashboard\vb365-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
$Invariant = [Globalization.CultureInfo]::InvariantCulture
# ---------- Helpers ----------
function Import-Vb365 {
if (Get-Command Get-VBOJob -ErrorAction SilentlyContinue) { return } # already loaded
if (Get-Module -ListAvailable -Name Veeam.Archiver.PowerShell) {
Import-Module Veeam.Archiver.PowerShell
return
}
# Fall back to the default install path
Import-Module (Join-Path $env:ProgramFiles 'Veeam\Backup365\Veeam.Archiver.PowerShell\Veeam.Archiver.PowerShell.psd1')
}
function Get-Time($Object, [string]$Field) {
$value = Get-FirstValue $Object @($Field)
if ($null -eq $value) { return $null }
$dt = [datetime]$value
if ($dt.Year -le 1900) { return $null } # e.g. EndTime while a session is running
return $dt
}
# "1.5 GB", "512 MB", "123456" -> bytes; $null when it can't be read
function ConvertTo-Bytes($Value) {
if ($null -eq $Value) { return $null }
if ($Value -is [ValueType]) { return [long]$Value }
$match = [regex]::Match([string]$Value, '^\s*([\d.,]+)\s*([KMGTP]?B)?\s*$', 'IgnoreCase')
if (-not $match.Success) { return $null }
$number = 0.0
if (-not [double]::TryParse(($match.Groups[1].Value -replace ',', '.'), [Globalization.NumberStyles]::Float, $Invariant, [ref]$number)) {
return $null
}
$power = @{ '' = 0; 'B' = 0; 'KB' = 1; 'MB' = 2; 'GB' = 3; 'TB' = 4; 'PB' = 5 }[$match.Groups[2].Value.ToUpperInvariant()]
return [long]($number * [math]::Pow(1024, $power))
}
# ---------- Jobs and tenants ----------
function Get-OrganizationName($Job) {
$org = Get-FirstValue $Job @('Organization')
if ($null -eq $org) {
# Copy jobs point at their source backup job
$org = Get-FirstValue (Get-FirstValue $Job @('BackupJob')) @('Organization')
}
if ($org -is [string]) { return $org }
$name = Get-FirstValue $org @('Name', 'OfficeName', 'DisplayName')
if ($name) { return [string]$name }
return $null
}
function Get-TenantForJob($Job) {
$org = Get-OrganizationName $Job
if ($org -and $Config.OrganizationTenants) {
$mapped = $Config.OrganizationTenants.PSObject.Properties[$org] # case-insensitive lookup
if ($mapped -and $mapped.Value) { return [string]$mapped.Value }
}
if ($Config.Tenant) { return [string]$Config.Tenant }
if ($org) { return $org }
return $env:COMPUTERNAME
}
function Get-NextRun($Job) {
try {
$enabled = Get-FirstValue $Job @('IsEnabled', 'Enabled')
if ($enabled -eq $false) { return $null }
return ConvertTo-IsoTime (Get-Time $Job 'NextRun')
} catch {
Write-Log "Couldn't read schedule for job '$($Job.Name)': $(Get-ErrorText $_)" 'WARN'
return $null
}
}
# ---------- Sessions ----------
function Get-RunStatus($Session) {
$status = [string](Get-FirstValue $Session @('Status', 'Result', 'State'))
if ($status -match 'Fail|Error') { return 'failed' }
if ($status -match 'Warn') { return 'warning' }
if ($status -match 'Succe') { return 'success' }
if ($status -match 'Run|Queue|Progress|Start|Wait') { return 'running' }
if (-not (Get-Time $Session 'EndTime')) { return 'running' }
# Stopped (cancelled), Disconnected, or a state we don't recognise: surface it rather than hide it
return 'warning'
}
function Test-EmptyGuid($Value) {
return (-not $Value) -or [string]$Value -eq [string][guid]::Empty
}
# Warning/error lines from the session log. Each log item's status is a prefix of its Title, e.g.
# "[Warning] Item was skipped" (there's no separate status property), so match on that rather than
# on words in the text, which would catch e.g. "[Success] Processing site: .../error-reports".
function Get-RunMessage($Session, [string]$Status) {
if ($Status -eq 'running') { return '' }
$lines = New-Object 'System.Collections.Generic.List[string]'
$sessionStatus = [string](Get-FirstValue $Session @('Status', 'Result', 'State'))
if ($Status -ne 'success') {
foreach ($entry in @(Get-FirstValue $Session @('Log', 'Logs') | Where-Object { $_ })) {
$title = ([string](Get-FirstValue $entry @('Title', 'Message', 'Text'))).Trim()
$prefix = [regex]::Match($title, '^\[(\w+)\]\s*')
if ($prefix.Success) {
if ($prefix.Groups[1].Value -notmatch 'Warn|Fail|Error') { continue }
$title = $title.Substring($prefix.Length)
} elseif ([string](Get-FirstValue $entry @('Status', 'Type', 'Severity')) -notmatch 'Warn|Fail|Error') {
continue
}
if ($title -and -not $lines.Contains($title)) { $lines.Add($title) }
}
}
if ($lines.Count -eq 0) {
if ($Status -eq 'success') { $lines.Add('Job completed successfully.') }
else { $lines.Add("Job finished with status '$sessionStatus'.") }
} elseif ($sessionStatus -and $sessionStatus -notmatch 'Fail|Warn') {
$lines.Insert(0, "Session status: $sessionStatus")
}
# A warning/failure VB365 is about to retry; the retry arrives as its own run
if ($Status -ne 'success' -and (Get-FirstValue $Session @('JobWillBeRetried')) -eq $true) {
$lines.Add('Veeam will retry this job.')
}
return ($lines -join "`n")
}
function ConvertTo-Run($Session, $Job, [string]$JobKind) {
$status = Get-RunStatus $Session
$details = [ordered]@{ vb365_server = $env:COMPUTERNAME; job_kind = $JobKind }
$org = Get-OrganizationName $Job
if ($org) { $details.organization = $org }
$sessionStatus = Get-FirstValue $Session @('Status', 'Result', 'State')
if ($sessionStatus) { $details.vb365_status = [string]$sessionStatus }
# Retries are separate sessions pointing at the session they retry
$mainSession = Get-FirstValue $Session @('MainSessionId')
if (-not (Test-EmptyGuid $mainSession)) { $details.retry_of = [string]$mainSession }
if ((Get-FirstValue $Session @('JobWillBeRetried')) -eq $true) { $details.will_retry = $true }
$run = [ordered]@{
external_id = [string](Get-FirstValue $Session @('Id', 'SessionId'))
status = $status
started_at = ConvertTo-IsoTime (Get-Time $Session 'CreationTime')
message = Get-RunMessage $Session $status
details = $details
}
if ($status -ne 'running') {
$finished = ConvertTo-IsoTime (Get-Time $Session 'EndTime')
if ($finished) { $run.finished_at = $finished } # otherwise the API uses receipt time
$stats = Get-FirstValue $Session @('Statistics')
$processed = Get-FirstValue $stats @('ProcessedObjects')
if ($null -ne $processed) { $details.processed_objects = $processed }
$transferred = ConvertTo-Bytes (Get-FirstValue $stats @('TransferredData', 'TransferredDataSize'))
if ($transferred -gt 0) { $run.size_bytes = $transferred }
}
return $run
}
# ---------- Main ----------
try {
$Config = Initialize-Collector -Name 'vb365' -ConfigPath $ConfigPath -LookbackHours $LookbackHours -DryRun:$DryRun -RequiredKeys 'ApiUrl', 'ApiKey'
$SourceSystem = if ($Config.SourceSystem) { [string]$Config.SourceSystem } else { 'Veeam Backup for M365' }
$LookbackHours = $Config.LookbackHours
Import-Vb365
$since = (Get-Date).AddHours(-$LookbackHours)
# (job, kind, sessions) for backup jobs, plus backup copy jobs where the cmdlets exist
$work = New-Object 'System.Collections.Generic.List[object]'
foreach ($job in @(Get-VBOJob)) {
$sessions = @(Get-VBOJobSession -Job $job | Where-Object { (Get-Time $_ 'CreationTime') -ge $since })
$work.Add([pscustomobject]@{ Job = $job; Kind = 'backup'; Sessions = $sessions })
}
if ((Get-Command Get-VBOCopyJob -ErrorAction SilentlyContinue) -and (Get-Command Get-VBOCopyJobSession -ErrorAction SilentlyContinue)) {
$copyJobs = @(Get-VBOCopyJob)
if ($copyJobs.Count) {
# Fetch once and match by job ID, since the per-job parameter name isn't consistent
$copySessions = @(Get-VBOCopyJobSession | Where-Object { (Get-Time $_ 'CreationTime') -ge $since })
foreach ($job in $copyJobs) {
$id = [string]$job.Id
$work.Add([pscustomobject]@{
Job = $job; Kind = 'copy'
Sessions = @($copySessions | Where-Object { [string](Get-FirstValue $_ @('JobId')) -eq $id })
})
}
}
}
} catch {
Write-Log "Collector setup failed: $(Get-ErrorText $_)" 'ERROR'
exit 2
}
$failures = 0
foreach ($item in $work) {
$job = $item.Job
$tenant = Get-TenantForJob $job
$jobInfo = [ordered]@{
external_id = [string]$job.Id
name = [string]$job.Name
# Always sent (null when disabled/unscheduled) so the dashboard's value tracks VB365's
next_run_at = Get-NextRun $job
}
$pending = @($item.Sessions |
Where-Object { $_ -and -not (Test-RunSent ([string](Get-FirstValue $_ @('Id', 'SessionId')))) } |
Sort-Object { Get-Time $_ 'CreationTime' })
try {
if ($pending.Count -eq 0) {
# Keep next_run_at fresh even when nothing new ran
Send-JobReport -Tenant $tenant -SourceSystem $SourceSystem -Job $jobInfo
}
foreach ($session in $pending) {
$run = ConvertTo-Run $session $job $item.Kind
Send-JobReport -Tenant $tenant -SourceSystem $SourceSystem -Job $jobInfo -Run $run -SentKey $run.external_id -StartedAt ((Get-Time $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 $work.Count -Failures $failures
if ($exitCode) { exit $exitCode }