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]>
279 lines
12 KiB
PowerShell
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 }
|