From 728b5771ccc26122001616ca1b791dbb93dac4df Mon Sep 17 00:00:00 2001 From: kyle Date: Tue, 6 Oct 2026 19:30:48 -0600 Subject: [PATCH] Add Veeam Backup & Replication collector PowerShell script for the VBR server, run as a SYSTEM scheduled task. Each run reports every Get-VBRJob job's next scheduled run and any sessions from the lookback window (default 48h) not yet delivered in their final state; running sessions are resent until they finish. - Session result maps to success/warning/failed; non-Stopped states report as running; message combines the session reason, objects that didn't succeed, and warning/error lines from the session log - Delivered sessions are tracked in a state file; failures are retried next run, and a rejected API key stops the run immediately - Install-VeeamCollector.ps1 writes an ACL-restricted config, copies the script to Program Files and registers the task - tests/MockVeeam.ps1 provides stand-in cmdlets for testing without VBR Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + collectors/veeam/Install-VeeamCollector.ps1 | 78 +++++ collectors/veeam/README.md | 68 +++++ collectors/veeam/Send-VeeamBackupStatus.ps1 | 310 ++++++++++++++++++++ collectors/veeam/tests/MockVeeam.ps1 | 65 ++++ 5 files changed, 522 insertions(+) create mode 100644 collectors/veeam/Install-VeeamCollector.ps1 create mode 100644 collectors/veeam/README.md create mode 100644 collectors/veeam/Send-VeeamBackupStatus.ps1 create mode 100644 collectors/veeam/tests/MockVeeam.ps1 diff --git a/README.md b/README.md index a774af1..1afaefb 100644 --- a/README.md +++ b/README.md @@ -12,6 +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)). ## Data model diff --git a/collectors/veeam/Install-VeeamCollector.ps1 b/collectors/veeam/Install-VeeamCollector.ps1 new file mode 100644 index 0000000..0225eb4 --- /dev/null +++ b/collectors/veeam/Install-VeeamCollector.ps1 @@ -0,0 +1,78 @@ +<# +.SYNOPSIS + Installs the Veeam collector on a VBR server as a SYSTEM scheduled task. + +.DESCRIPTION + - Copies Send-VeeamBackupStatus.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. + Safe to re-run to change settings or update the script. + +.EXAMPLE + .\Install-VeeamCollector.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, + [string]$SourceSystem = 'Veeam B&R', + [ValidateRange(5, 1440)][int]$IntervalMinutes = 15, + [ValidateRange(1, 720)][int]$LookbackHours = 48, + [string]$InstallDir = (Join-Path $env:ProgramFiles 'BackupDashboard') +) + +$ErrorActionPreference = 'Stop' +$TaskName = 'Backup Dashboard - Veeam collector' +$DataDir = Join-Path $env:ProgramData 'BackupDashboard' +$ConfigPath = Join-Path $DataDir 'veeam-collector.json' + +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" } + +[ordered]@{ + ApiUrl = $ApiUrl.TrimEnd('/') + ApiKey = $apiKey + Tenant = $Tenant + SourceSystem = $SourceSystem + LookbackHours = $LookbackHours +} | ConvertTo-Json | Set-Content -Path $ConfigPath -Encoding UTF8 + +New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null +$scriptPath = Join-Path $InstallDir 'Send-VeeamBackupStatus.ps1' +Copy-Item -Path (Join-Path $PSScriptRoot 'Send-VeeamBackupStatus.ps1') -Destination $scriptPath -Force +Unblock-File -Path $scriptPath + +$action = New-ScheduledTaskAction -Execute 'powershell.exe' ` + -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 Veeam job results to the Backup Dashboard' -Force | Out-Null + +Write-Host "Installed. Config: $ConfigPath" +Write-Host "Task '$TaskName' runs every $IntervalMinutes minutes as SYSTEM." +Write-Host '' +Write-Host 'Next steps:' +Write-Host " 1. Preview what will be sent: & '$scriptPath' -DryRun" +Write-Host " 2. Backfill 30 days of history: & '$scriptPath' -LookbackHours 720" +Write-Host " 3. Check the log: Get-Content '$DataDir\veeam-collector.log' -Tail 20" diff --git a/collectors/veeam/README.md b/collectors/veeam/README.md new file mode 100644 index 0000000..da63567 --- /dev/null +++ b/collectors/veeam/README.md @@ -0,0 +1,68 @@ +# Veeam Backup & Replication collector + +A PowerShell script that runs on each client's VBR server and reports job results to the dashboard. +It covers jobs returned by `Get-VBRJob`: VM backup, replication and backup copy jobs. + +## Install (on the VBR server, as an administrator) + +1. On the dashboard host, create a key for this server: + ```bash + python manage.py create-key veeam-acme + ``` +2. Copy this folder to the VBR server and run: + ```powershell + .\Install-VeeamCollector.ps1 -ApiUrl https://backups.example.com -Tenant "Acme Corp" + ``` + It prompts for the key, then: + - writes `C:\ProgramData\BackupDashboard\veeam-collector.json` (readable only by SYSTEM and Administrators) + - copies the script to `C:\Program Files\BackupDashboard\` + - registers the scheduled task **Backup Dashboard - Veeam collector** (SYSTEM, every 15 minutes) +3. Preview the payloads, then backfill 30 days so the history view is populated straight away: + ```powershell + & 'C:\Program Files\BackupDashboard\Send-VeeamBackupStatus.ps1' -DryRun + & 'C:\Program Files\BackupDashboard\Send-VeeamBackupStatus.ps1' -LookbackHours 720 + ``` + +Re-run the installer to change settings or update the script. To remove the collector, +delete the scheduled task and both `BackupDashboard` folders. + +## What it sends + +| Dashboard | Veeam | +|---|---| +| Company | `Tenant` from the config | +| Job | Job name. The job's GUID is the ID, so renaming a job in Veeam doesn't duplicate it | +| Status | Session result (Success / Warning / Failed). Any state other than Stopped is **Running**. A session that stopped with no result (e.g. cancelled) is reported as **Warning** | +| Started / finished | Session `CreationTime` / `EndTime` | +| Next scheduled | Job's next run. Sent as empty for disabled jobs and chained ("run after") jobs, so those are never flagged Overdue | +| Message | Session reason, each VM or object that didn't succeed, and warning/error lines from the session log | +| Size | `BackupStats.BackupSize` | + +Each run covers the last 48 hours (`LookbackHours`), so a few missed runs (server rebooting, API +down) fill in automatically. Sessions already delivered are recorded in +`veeam-collector.state.json`. Running sessions are re-sent until they finish. + +## Troubleshooting + +- Log: `C:\ProgramData\BackupDashboard\veeam-collector.log` (rotates at 5 MB). +- Exit codes: `0` OK, `1` some jobs failed to send (they're retried next run), `2` setup failed + (config, Veeam module, or Veeam connection). +- `API key rejected`: the key was revoked or mistyped. Re-run the installer with a new key. +- `Get-VBRBackupSession` loads every session in the VBR database. On a server with years of history, + this can make each run take a minute or more. The task's 30-minute limit and "don't start if + already running" setting keep that safe. + +## Testing without Veeam + +`tests/MockVeeam.ps1` defines stand-in `Get-VBRJob` / `Get-VBRBackupSession` cmdlets with a mix of +success, warning, failed, running and disabled jobs: + +```powershell +. ./tests/MockVeeam.ps1 +./Send-VeeamBackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun +``` + +## Not covered yet + +- Veeam Agent jobs managed by VBR (`Get-VBRComputerBackupJob`), tape jobs, and SureBackup. +- Veeam Backup for Microsoft 365, which has its own PowerShell module. diff --git a/collectors/veeam/Send-VeeamBackupStatus.ps1 b/collectors/veeam/Send-VeeamBackupStatus.ps1 new file mode 100644 index 0000000..f377b24 --- /dev/null +++ b/collectors/veeam/Send-VeeamBackupStatus.ps1 @@ -0,0 +1,310 @@ +<# +.SYNOPSIS + Reports Veeam Backup & Replication job results to the Backup Dashboard. + +.DESCRIPTION + Runs on the VBR server, normally as a SYSTEM scheduled task every 15 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' + +$DataDir = Split-Path -Parent $ConfigPath +$LogPath = Join-Path $DataDir 'veeam-collector.log' +$StatePath = Join-Path $DataDir 'veeam-collector.state.json' +$MaxLogBytes = 5MB +$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. Veeam returns local times, often with Kind=Unspecified, +# which DateTimeOffset treats as local, exactly what we want. +function ConvertTo-IsoTime($Value) { + if ($null -eq $Value) { return $null } + $dt = [datetime]$Value + if ($dt.Year -le 1900) { return $null } # Veeam's "not set" value (e.g. EndTime while running) + return ([DateTimeOffset]$dt).ToString("yyyy-MM-dd'T'HH:mm:sszzz", $Invariant) +} + +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 } + $raw = $Job.ScheduleOptions.NextRun + if (-not $raw) { return $null } # e.g. chained jobs that run "after" another job + if ($raw -is [datetime]) { return ConvertTo-IsoTime $raw } + # Older versions return a string formatted with the server's culture, so parse with it + $parsed = [datetime]::MinValue + if ([datetime]::TryParse([string]$raw, [Globalization.CultureInfo]::CurrentCulture, + [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") +} + +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 { } + + $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 +} + +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, + # mangling non-ASCII job names (e.g. en dashes). + $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 { 'Veeam B&R' } + 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-Veeam + + # Session ID -> start time (Unix seconds), for sessions already delivered in their final state. + # Numbers rather than ISO strings: PowerShell 7's ConvertFrom-Json turns date strings into + # DateTimes and loses the offset. + $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) + $jobs = @(Get-VBRJob -WarningAction SilentlyContinue) + + # Get-VBRBackupSession returns every session in the database, so fetch once and bucket by job + $sessionsByJob = @{} + foreach ($s in Get-VBRBackupSession -WarningAction SilentlyContinue) { + if ($s.CreationTime -lt $since) { continue } + $key = $s.JobId.ToString() + 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 +$runsSent = 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 $sent.ContainsKey($_.Id.ToString()) } | + Sort-Object CreationTime) + } + + 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 ($session in $pending) { + $run = ConvertTo-Run $session $job + Send-Report ([ordered]@{ tenant = $Config.Tenant; source_system = $SourceSystem; job = $jobInfo; run = $run }) + $runsSent++ + if ($run.status -ne 'running') { + $sent[$run.external_id] = ([DateTimeOffset][datetime]$session.CreationTime).ToUnixTimeSeconds() + } + } + } catch { + $failures++ + Write-Log "Failed to report job '$($job.Name)': $(Get-ErrorText $_)" 'ERROR' + $httpStatus = 0 + try { $httpStatus = [int]$_.Exception.Response.StatusCode } catch { } + if ($httpStatus -eq 401) { + Write-Log 'API key rejected; check ApiKey in the config. Stopping.' 'ERROR' + break + } + } +} + +if (-not $DryRun) { + # Forget sessions that have aged out of the lookback window (plus a day of slack) + $cutoff = [DateTimeOffset]::Now.AddHours(-$LookbackHours - 24).ToUnixTimeSeconds() + $keep = [ordered]@{} + foreach ($id in $sent.Keys) { + if ($sent[$id] -ge $cutoff) { $keep[$id] = $sent[$id] } + } + 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} jobs, {1} runs, {2} failures" -f $jobs.Count, $runsSent, $failures) +if ($failures) { exit 1 } diff --git a/collectors/veeam/tests/MockVeeam.ps1 b/collectors/veeam/tests/MockVeeam.ps1 new file mode 100644 index 0000000..28ad26e --- /dev/null +++ b/collectors/veeam/tests/MockVeeam.ps1 @@ -0,0 +1,65 @@ +# Stand-ins for the Veeam cmdlets, shaped like the real objects' properties the collector reads. +# Dot-source this before running the collector to try it without a VBR server: +# +# . ./tests/MockVeeam.ps1 +# ./Send-VeeamBackupStatus.ps1 -ConfigPath ./tests/config.json -DryRun + +# Fixed to the top of the hour, and IDs derived from names/times, so repeated runs see the same +# jobs and sessions, like a real server +$now = Get-Date -Minute 0 -Second 0 -Millisecond 0 +function New-StableGuid([string]$Seed) { + [guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed))) +} +$notSet = Get-Date '1900-01-01' + +function New-MockJob($Name, $Type, $NextRun, [bool]$Enabled = $true) { + [pscustomobject]@{ + Id = New-StableGuid $Name; Name = $Name; JobType = $Type + IsScheduleEnabled = $Enabled + ScheduleOptions = [pscustomobject]@{ NextRun = $NextRun } + } +} + +function New-MockSession($Job, $Start, $End, $State, $Result, $Reason = '', $Tasks = @(), $LogRecords = @(), $Size = 0) { + $s = [pscustomobject]@{ + Id = New-StableGuid "$($Job.Name)|$($Start.Ticks)"; JobId = $Job.Id; JobName = $Job.Name + CreationTime = $Start; EndTime = $End; State = $State; Result = $Result + IsRetryMode = $false + Info = [pscustomobject]@{ Reason = $Reason } + BackupStats = [pscustomobject]@{ BackupSize = $Size; DataSize = $Size * 3 } + Logger = [pscustomobject]@{ Records = $LogRecords } + Tasks = $Tasks + } + $s.Logger | Add-Member ScriptMethod GetLog { [pscustomobject]@{ UpdatedRecords = $this.Records } } + $s | Add-Member ScriptMethod GetTaskSessions { $this.Tasks } + $s +} + +function New-MockTask($Name, $Status, $Reason = '') { + [pscustomobject]@{ Name = $Name; Status = $Status; Info = [pscustomobject]@{ Reason = $Reason } } +} + +$global:MockJobs = @( + (New-MockJob 'File Server – Nightly' 'Backup' $now.Date.AddDays(1).AddHours(22)), + # Older VBR versions return NextRun as a culture-formatted string + (New-MockJob 'SQL01 – Logs' 'Backup' $now.AddMinutes(40).ToString()), + (New-MockJob 'Offsite Copy' 'BackupSync' $null), + (New-MockJob 'Old Exchange' 'Backup' $now.AddDays(1) $false) +) +$fs, $sql, $copy, $old = $global:MockJobs + +$global:MockSessions = @( + (New-MockSession $fs $now.AddDays(-1).AddHours(-2) $now.AddDays(-1).AddHours(-1) 'Stopped' 'Success' -Size 128GB -Tasks @( + (New-MockTask 'FS01' 'Success'), (New-MockTask 'FS02' 'Success'))), + (New-MockSession $fs $now.AddMinutes(-20) $notSet 'Working' 'None' -Tasks @((New-MockTask 'FS01' 'InProgress'))), + (New-MockSession $sql $now.AddHours(-1) $now.AddMinutes(-55) 'Stopped' 'Warning' -Size 2GB ` + -Tasks @((New-MockTask 'SQL01' 'Warning' 'Failed to truncate transaction logs for database "ERP_PROD"'))), + (New-MockSession $copy $now.AddHours(-5) $now.AddHours(-4) 'Stopped' 'Failed' 'Error: Unable to connect to repository NAS02' ` + -LogRecords @( + [pscustomobject]@{ Title = 'Preparing backup chain'; Status = 'ESucceeded' }, + [pscustomobject]@{ Title = 'Network path not found (0x80070035)'; Status = 'EFailed' })), + (New-MockSession $old $now.AddDays(-10) $now.AddDays(-10).AddHours(1) 'Stopped' 'Success') # outside 48h lookback +) + +function Get-VBRJob { [CmdletBinding()] param() $global:MockJobs } +function Get-VBRBackupSession { [CmdletBinding()] param() $global:MockSessions }