<# .SYNOPSIS Read-only daily report for Veeam Backup & Replication 13: last result per job, age of the last successful session against an RPO threshold, repositories and object storage immutability flags. Exits with code 2 when the RPO is violated. .DESCRIPTION Uses only cmdlets documented in the Veeam Backup & Replication 13 PowerShell Reference: Connect-VBRServer, Get-VBRServerSession, Get-VBRJob, Get-VBRBackupCopyJob, Get-VBRComputerBackupJob, Get-VBRBackupSession, Get-VBRComputerBackupJobSession, Get-VBRBackupRepository, Get-VBRObjectStorageRepository, Disconnect-VBRServer. Session properties used (JobId, Result, EndTime) are documented for the VBRBackupSession and VBRSession objects. Job objects returned by Get-VBRJob (CBackupJob) are internal .NET classes: the script only reads Name and Id and tolerates their absence. Repository free space is NOT reported because no documented cmdlet or property exposes it in the v13 reference. Nothing is created, modified, started or deleted on the backup server. .PARAMETER Server Backup server name or address. Default: localhost. .PARAMETER Port Connection port. Default: 443 (Veeam Backup & Replication 13 default). .PARAMETER Credential Optional PSCredential (DOMAIN\User or UPN format). MFA is not supported for PowerShell: use a dedicated service account with MFA disabled. .PARAMETER RpoHours Maximum accepted age, in hours, of the last successful session. Default: 24. .PARAMETER WarningIsSuccess Count sessions that ended with Warning as successful. Default: on. .PARAMETER ExcludeJob Job names (wildcards allowed) to leave out of the RPO evaluation. .PARAMETER OutputDirectory Folder for the CSV and HTML files. Default: current folder. .PARAMETER Delimiter CSV delimiter. Default: ';' (Excel with Belgian/French regional settings). .PARAMETER NoHtml Do not write the HTML report. .EXAMPLE .\Get-VBRDailyReport.ps1 -RpoHours 26 .EXAMPLE .\Get-VBRDailyReport.ps1 -Server vbr01.mgmt.local -Credential (Get-Credential) -OutputDirectory D:\Reports .NOTES Requires the Veeam.Backup.PowerShell module (installed with the Veeam Backup & Replication console, or on Linux under /opt/veeam/powershell) and the Backup Administrator role, as stated in the Veeam PowerShell Reference. Exit codes: 0 = all evaluated jobs within RPO, 2 = at least one RPO violation, 1 = script error. Source: ANYSYS ICT, https://www.anysys.be/articles/ #> [CmdletBinding()] param( [string]$Server = 'localhost', [int]$Port = 443, [System.Management.Automation.PSCredential]$Credential, [ValidateRange(1, 8760)] [int]$RpoHours = 24, [bool]$WarningIsSuccess = $true, [string[]]$ExcludeJob = @(), [string]$OutputDirectory = (Get-Location).Path, [string]$Delimiter = ';', [switch]$NoHtml ) $ErrorActionPreference = 'Stop' function Get-PropValue { # Returns a property value if the object exposes it, otherwise $null. param($InputObject, [string]$Name) if ($null -eq $InputObject) { return $null } $prop = $InputObject.PSObject.Properties[$Name] if ($prop) { return $prop.Value } return $null } function ConvertTo-IdString { param($Value) if ($null -eq $Value) { return '' } return ([string]$Value).ToLowerInvariant() } function Test-Excluded { param([string]$Name, [string[]]$Pattern) foreach ($p in $Pattern) { if ($Name -like $p) { return $true } } return $false } $connectedHere = $false $exitCode = 0 try { # 1. Load the official module (Windows console install or Linux path). if (-not (Get-Module -Name Veeam.Backup.PowerShell)) { $linuxPath = '/opt/veeam/powershell/Veeam.Backup.PowerShell/Veeam.Backup.PowerShell.psd1' if (Get-Module -Name Veeam.Backup.PowerShell -ListAvailable) { Import-Module -Name Veeam.Backup.PowerShell } elseif (Test-Path -LiteralPath $linuxPath) { Import-Module -Name $linuxPath } else { throw 'Module Veeam.Backup.PowerShell not found. Install the Veeam Backup & Replication console first.' } } # 2. Reuse an existing session, otherwise connect (read-only usage). if (-not (Get-VBRServerSession)) { $connect = @{ Server = $Server; Port = $Port } if ($Credential) { $connect.Credential = $Credential } Connect-VBRServer @connect $connectedHere = $true } $now = Get-Date # 3. Jobs: VM/host jobs, backup copy jobs, agent jobs and policies. $jobList = [System.Collections.Generic.List[object]]::new() foreach ($j in @(Get-VBRJob)) { $jobList.Add([pscustomobject]@{ Name = Get-PropValue $j 'Name'; Id = ConvertTo-IdString (Get-PropValue $j 'Id'); Kind = 'Job' }) } foreach ($j in @(Get-VBRBackupCopyJob)) { $jobList.Add([pscustomobject]@{ Name = Get-PropValue $j 'Name'; Id = ConvertTo-IdString (Get-PropValue $j 'Id'); Kind = 'BackupCopy' }) } foreach ($j in @(Get-VBRComputerBackupJob)) { $jobList.Add([pscustomobject]@{ Name = Get-PropValue $j 'Name'; Id = ConvertTo-IdString (Get-PropValue $j 'Id'); Kind = 'Agent' }) } $jobs = @($jobList | Where-Object { $_.Id } | Sort-Object -Property Id -Unique) # 4. Sessions, grouped by JobId (documented property). $sessions = @(Get-VBRBackupSession) + @(Get-VBRComputerBackupJobSession) $byJob = @{} foreach ($s in $sessions) { $key = ConvertTo-IdString (Get-PropValue $s 'JobId') if (-not $key) { continue } if (-not $byJob.ContainsKey($key)) { $byJob[$key] = [System.Collections.Generic.List[object]]::new() } $byJob[$key].Add($s) } $okResults = @('Success') if ($WarningIsSuccess) { $okResults += 'Warning' } $jobRows = foreach ($job in $jobs) { $list = if ($byJob.ContainsKey($job.Id)) { @($byJob[$job.Id]) } else { @() } $ended = @($list | Where-Object { Get-PropValue $_ 'EndTime' } | Sort-Object -Property { Get-PropValue $_ 'EndTime' } -Descending) $last = $ended | Select-Object -First 1 $lastOk = $ended | Where-Object { [string](Get-PropValue $_ 'Result') -in $okResults } | Select-Object -First 1 $lastOkTime = Get-PropValue $lastOk 'EndTime' $ageHours = if ($lastOkTime) { [math]::Round(($now - [datetime]$lastOkTime).TotalHours, 1) } else { $null } $excluded = Test-Excluded -Name $job.Name -Pattern $ExcludeJob $violated = (-not $excluded) -and (($null -eq $ageHours) -or ($ageHours -gt $RpoHours)) [pscustomobject]@{ Job = $job.Name Kind = $job.Kind LastResult = [string](Get-PropValue $last 'Result') LastEnd = Get-PropValue $last 'EndTime' LastSuccessEnd = $lastOkTime SuccessAgeH = $ageHours RpoHours = $RpoHours Excluded = $excluded RpoViolated = $violated } } $jobRows = @($jobRows) # 5. Repositories. Free space is not exposed by a documented cmdlet/property. $repoRows = [System.Collections.Generic.List[object]]::new() foreach ($r in @(Get-VBRBackupRepository)) { $repoRows.Add([pscustomobject]@{ Repository = Get-PropValue $r 'Name'; Kind = 'Standard'; ImmutabilityEnabled = 'n/a (not documented)'; ImmutabilityDays = $null }) } foreach ($r in @(Get-VBRBackupRepository -ScaleOut)) { $repoRows.Add([pscustomobject]@{ Repository = Get-PropValue $r 'Name'; Kind = 'ScaleOut'; ImmutabilityEnabled = 'see extents'; ImmutabilityDays = $null }) } foreach ($r in @(Get-VBRObjectStorageRepository)) { $imm = Get-PropValue $r 'BackupImmutabilityEnabled' $repoRows.Add([pscustomobject]@{ Repository = Get-PropValue $r 'Name' Kind = 'ObjectStorage:' + $r.GetType().Name ImmutabilityEnabled = if ($null -eq $imm) { 'n/a (not documented)' } else { [string]$imm } ImmutabilityDays = Get-PropValue $r 'ImmutabilityPeriod' }) } # 6. Output files. if (-not (Test-Path -LiteralPath $OutputDirectory)) { throw "Output directory not found: $OutputDirectory" } $stamp = $now.ToString('yyyyMMdd-HHmm') $jobCsv = Join-Path $OutputDirectory "vbr-jobs-$stamp.csv" $repoCsv = Join-Path $OutputDirectory "vbr-repositories-$stamp.csv" $jobRows | Export-Csv -LiteralPath $jobCsv -NoTypeInformation -Delimiter $Delimiter -Encoding utf8 $repoRows | Export-Csv -LiteralPath $repoCsv -NoTypeInformation -Delimiter $Delimiter -Encoding utf8 $violations = @($jobRows | Where-Object RpoViolated) if (-not $NoHtml) { $htmlPath = Join-Path $OutputDirectory "vbr-report-$stamp.html" $title = "Veeam daily report $($now.ToString('yyyy-MM-dd HH:mm')) - $($violations.Count) RPO violation(s)" $body = @( "

$([System.Net.WebUtility]::HtmlEncode($title))

" '

Jobs

' ($jobRows | Sort-Object RpoViolated -Descending | ConvertTo-Html -Fragment) '

Repositories

' ($repoRows | ConvertTo-Html -Fragment) ) -join "`n" ConvertTo-Html -Title $title -Body $body | Set-Content -LiteralPath $htmlPath -Encoding utf8 } Write-Host ("Jobs evaluated: {0} | RPO violations: {1} | Repositories: {2}" -f $jobRows.Count, $violations.Count, $repoRows.Count) foreach ($v in $violations) { Write-Host (" RPO VIOLATION: {0} (last success age: {1} h)" -f $v.Job, $(if ($null -eq $v.SuccessAgeH) { 'never' } else { $v.SuccessAgeH })) } Write-Host "CSV: $jobCsv ; $repoCsv" if ($violations.Count -gt 0) { $exitCode = 2 } } catch { Write-Error -Message $_.Exception.Message -ErrorAction Continue $exitCode = 1 } finally { if ($connectedHere) { Disconnect-VBRServer } } exit $exitCode