<# .SYNOPSIS Collects Veeam backup schedule (frequency) and 21-day run history per protected machine. .DESCRIPTION Run on each site's Veeam Backup & Replication (VBR) server, for example through NinjaOne. Produces two CSV files per VBR server: __schedule.csv - one row per job/protected machine with schedule and retention __history.csv - one row per machine per job run over the look-back window Supports both the Veeam PowerShell module (v11+) and the legacy snap-in. .PARAMETER SiteName Friendly site name for the report. Defaults to the NinjaOne location environment variable, then the hostname. .PARAMETER Days Look-back window in days. Default 21. .PARAMETER OutputPath Folder for CSV output. Default C:\ProgramData\DTC\VeeamReport #> [CmdletBinding()] param( [string]$SiteName = $(if ($env:NINJA_LOCATION_NAME) { $env:NINJA_LOCATION_NAME } else { $env:COMPUTERNAME }), [int]$Days = 21, [string]$OutputPath = 'C:\ProgramData\DTC\VeeamReport' ) $ErrorActionPreference = 'Stop' $WarningPreference = 'SilentlyContinue' # Suppress Get-VBRJob deprecation warnings on v12+ #region Relaunch in 64-bit PowerShell if NinjaOne started a 32-bit host (Veeam module is 64-bit only) if ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) { $ps64 = Join-Path $env:WINDIR 'sysnative\WindowsPowerShell\v1.0\powershell.exe' $argList = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $PSCommandPath) foreach ($kv in $PSBoundParameters.GetEnumerator()) { $argList += "-$($kv.Key)"; $argList += "$($kv.Value)" } Write-Output "INFO: 32-bit host detected; relaunching in 64-bit PowerShell." & $ps64 @argList exit $LASTEXITCODE } #endregion #region Relaunch in PowerShell 7 when VBR 13+ is installed (its module requires PowerShell 7.6+) if ($PSVersionTable.PSVersion.Major -lt 7) { $modPath = "$env:ProgramFiles\Veeam\Backup and Replication\Console\Veeam.Backup.PowerShell\Veeam.Backup.PowerShell.psd1" $needsPwsh = $false if (Test-Path $modPath) { $minVer = (Import-PowerShellDataFile $modPath -ErrorAction SilentlyContinue).PowerShellVersion if ($minVer -and [version]$minVer -ge [version]'7.0') { $needsPwsh = $true } } if ($needsPwsh) { $pwsh = @( "$env:ProgramFiles\PowerShell\7\pwsh.exe", "$env:ProgramFiles\PowerShell\7-preview\pwsh.exe", (Get-Command pwsh.exe -ErrorAction SilentlyContinue).Source ) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1 if (-not $pwsh) { Write-Output "ERROR: Veeam module on $env:COMPUTERNAME requires PowerShell $minVer, and pwsh.exe was not found. Install PowerShell 7 (winget install Microsoft.PowerShell) and rerun." exit 1 } $argList = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $PSCommandPath) foreach ($kv in $PSBoundParameters.GetEnumerator()) { $argList += "-$($kv.Key)"; $argList += "$($kv.Value)" } Write-Output "INFO: Veeam module requires PowerShell $minVer; relaunching in $pwsh." & $pwsh @argList exit $LASTEXITCODE } } #endregion #region Load Veeam PowerShell $loadErrors = @() $loaded = $false # 1. Module by name (works when PSModulePath is current) try { Import-Module Veeam.Backup.PowerShell -DisableNameChecking -ErrorAction Stop; $loaded = $true } catch { $loadErrors += "Module by name: $($_.Exception.Message)" } # 2. Module by explicit path (covers agent services started before Veeam was installed) if (-not $loaded) { $candidates = @() $vbrKey = 'HKLM:\SOFTWARE\Veeam\Veeam Backup and Replication' if (Test-Path $vbrKey) { $props = Get-ItemProperty $vbrKey -ErrorAction SilentlyContinue foreach ($p in 'CorePath', 'ConsoleInstallPath', 'InstallationPath') { if ($props.$p) { $candidates += Join-Path $props.$p 'Veeam.Backup.PowerShell\Veeam.Backup.PowerShell.psd1' $candidates += Join-Path (Split-Path $props.$p -Parent) 'Console\Veeam.Backup.PowerShell\Veeam.Backup.PowerShell.psd1' } } } $candidates += "$env:ProgramFiles\Veeam\Backup and Replication\Console\Veeam.Backup.PowerShell\Veeam.Backup.PowerShell.psd1" $found = $candidates | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1 if (-not $found -and (Test-Path "$env:ProgramFiles\Veeam")) { $found = (Get-ChildItem "$env:ProgramFiles\Veeam" -Filter 'Veeam.Backup.PowerShell.psd1' -Recurse -Depth 4 -ErrorAction SilentlyContinue | Select-Object -First 1).FullName } if ($found) { try { Import-Module $found -DisableNameChecking -ErrorAction Stop; $loaded = $true } catch { $loadErrors += "Module at ${found}: $($_.Exception.Message)" } } else { $loadErrors += 'Module file Veeam.Backup.PowerShell.psd1 not found under Program Files\Veeam' } } # 3. Legacy snap-in (VBR 10 and earlier) if (-not $loaded) { try { Add-PSSnapin VeeamPSSnapin -ErrorAction Stop; $loaded = $true } catch { $loadErrors += "Snap-in: $($_.Exception.Message)" } } if (-not $loaded) { # Identify what Veeam product is actually on this machine $svc = Get-Service -ErrorAction SilentlyContinue | Where-Object Name -match '^Veeam' | Select-Object -ExpandProperty Name $product = if ($svc -contains 'VeeamBackupSvc') { 'Veeam Backup & Replication server (service present, PowerShell not loadable)' } elseif ($svc -contains 'VeeamEndpointBackupSvc') { 'Standalone Veeam Agent for Windows only - no VBR server here; job data lives on the managing VBR server or the agent itself' } elseif ($svc) { "Veeam components only: $($svc -join ', ')" } else { 'No Veeam services found' } Write-Output "ERROR: Veeam PowerShell not available on $env:COMPUTERNAME." Write-Output "Detected: $product" Write-Output "64-bit process: $([Environment]::Is64BitProcess) | PS version: $($PSVersionTable.PSVersion)" $loadErrors | ForEach-Object { Write-Output " - $_" } exit 1 } #endregion if (-not (Test-Path $OutputPath)) { New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null } $safeSite = ($SiteName -replace '[^\w\-]', '_') $server = $env:COMPUTERNAME $since = (Get-Date).AddDays(-$Days) $schedRows = [System.Collections.Generic.List[object]]::new() $histRows = [System.Collections.Generic.List[object]]::new() function Get-ScheduleText { param($Job) try { if (-not $Job.IsScheduleEnabled) { return @{ Kind = 'Disabled / manual'; Detail = 'No schedule'; NextRun = '' } } $so = $Job.ScheduleOptions $next = try { $so.NextRun } catch { '' } if ($so.OptionsContinuous.Enabled) { return @{ Kind = 'Continuous'; Detail = 'Runs continuously'; NextRun = $next } } if ($so.OptionsPeriodically.Enabled) { return @{ Kind = 'Periodic'; Detail = "Every $($so.OptionsPeriodically.FullPeriod) $($so.OptionsPeriodically.Unit)"; NextRun = $next } } if ($so.OptionsDaily.Enabled) { $d = $so.OptionsDaily $days = if ("$($d.Kind)" -eq 'SelectedDays') { ($d.DaysSrv -join ', ') } else { "$($d.Kind)" } return @{ Kind = 'Daily'; Detail = "$days at $($d.TimeLocal.ToString('HH:mm'))"; NextRun = $next } } if ($so.OptionsMonthly.Enabled) { $m = $so.OptionsMonthly return @{ Kind = 'Monthly'; Detail = "$($m.DayNumberInMonth) $($m.DayOfWeek) at $($m.TimeLocal.ToString('HH:mm'))"; NextRun = $next } } if ($so.OptionsScheduleAfterJob.IsEnabled) { $parent = try { (Get-VBRJob | Where-Object Id -eq $so.OptionsScheduleAfterJob.Id).Name } catch { 'another job' } return @{ Kind = 'Chained'; Detail = "After job: $parent"; NextRun = '' } } return @{ Kind = 'Unknown'; Detail = 'Schedule enabled, type not recognized'; NextRun = $next } } catch { return @{ Kind = 'Error'; Detail = "$_"; NextRun = '' } } } #region Schedules - VM/host backup jobs (and agent jobs exposed through Get-VBRJob) foreach ($job in (Get-VBRJob | Where-Object { $_.JobType -notin 'Replica','BackupSync','SimpleBackupCopyPolicy' })) { $s = Get-ScheduleText -Job $job $retain = try { $job.BackupStorageOptions.RetainCycles } catch { '' } $objects = try { (Get-VBRJobObject -Job $job | Where-Object { $_.Type -ne 'Exclude' }).Name } catch { @() } if (-not $objects) { $objects = @('(job-level - no objects listed)') } foreach ($obj in $objects) { $schedRows.Add([pscustomobject]@{ Site = $SiteName; VBRServer = $server; Job = $job.Name; JobType = "$($job.JobType)" Machine = $obj; ScheduleEnabled = $job.IsScheduleEnabled; ScheduleKind = $s.Kind ScheduleDetail = $s.Detail; NextRun = $s.NextRun; RetainRestorePoints = $retain JobEnabled = -not $job.IsDisabled }) } } #endregion #region Schedules - Veeam Agent (computer) backup jobs, v11+ try { foreach ($cjob in Get-VBRComputerBackupJob) { $so = $cjob.ScheduleOptions $kind = 'Unknown'; $detail = '' if (-not $cjob.ScheduleEnabled) { $kind = 'Disabled / manual'; $detail = 'No schedule' } elseif ($so.DailyOptions -and $so.Type -eq 'Daily') { $kind = 'Daily'; $detail = "$($so.DailyOptions.Type) at $($so.DailyOptions.Period)" } elseif ($so.PeriodicallyOptions -and $so.Type -eq 'Periodically') { $kind = 'Periodic'; $detail = "Every $($so.PeriodicallyOptions.FullPeriod) $($so.PeriodicallyOptions.PeriodicallyKind)" } elseif ($so.MonthlyOptions -and $so.Type -eq 'Monthly') { $kind = 'Monthly'; $detail = "$($so.MonthlyOptions.DayNumberInMonth) $($so.MonthlyOptions.DayOfWeek)" } else { $kind = "$($so.Type)" } $names = try { $cjob.BackupObject.Name } catch { @('(agent job)') } foreach ($n in $names) { $schedRows.Add([pscustomobject]@{ Site = $SiteName; VBRServer = $server; Job = $cjob.Name; JobType = 'Agent' Machine = $n; ScheduleEnabled = $cjob.ScheduleEnabled; ScheduleKind = $kind ScheduleDetail = $detail; NextRun = ''; RetainRestorePoints = $cjob.RetentionPolicy.Value JobEnabled = $cjob.JobEnabled }) } } } catch { Write-Verbose "Agent job enumeration skipped: $_" } #endregion #region History - per-machine task results within the window $sessions = Get-VBRBackupSession | Where-Object { $_.CreationTime -ge $since -and $_.JobType -notin 'Replica','BackupSync' } foreach ($sess in $sessions) { $tasks = try { Get-VBRTaskSession -Session $sess -ErrorAction Stop } catch { @() } if (-not $tasks) { $tasks = try { $sess.GetTaskSessions() } catch { @() } } if (-not $tasks) { $histRows.Add([pscustomobject]@{ Site = $SiteName; VBRServer = $server; Job = $sess.JobName; Machine = '(job-level)' Start = $sess.CreationTime; End = $sess.EndTime; Result = "$($sess.Result)" DurationMin = if ($sess.EndTime -gt $sess.CreationTime) { [math]::Round(($sess.EndTime - $sess.CreationTime).TotalMinutes,1) } else { '' } IsRetry = $sess.IsRetryMode; TransferredGB = ''; Reason = ($sess.Info.Reason -replace '\s+',' ') }) continue } foreach ($t in $tasks) { $start = $t.Progress.StartTimeLocal; $end = $t.Progress.StopTimeLocal $histRows.Add([pscustomobject]@{ Site = $SiteName; VBRServer = $server; Job = $sess.JobName; Machine = $t.Name Start = $start; End = $end; Result = "$($t.Status)" DurationMin = if ($end -gt $start) { [math]::Round(($end - $start).TotalMinutes,1) } else { '' } IsRetry = $sess.IsRetryMode TransferredGB = [math]::Round($t.Progress.TransferedSize / 1GB, 2) Reason = ($t.Info.Reason -replace '\s+',' ') }) } } # Agent job sessions (v11+), if not already captured above try { foreach ($cs in (Get-VBRComputerBackupJobSession | Where-Object { $_.CreationTime -ge $since })) { foreach ($t in (Get-VBRTaskSession -Session $cs)) { $histRows.Add([pscustomobject]@{ Site = $SiteName; VBRServer = $server; Job = $cs.JobName; Machine = $t.Name Start = $t.Progress.StartTimeLocal; End = $t.Progress.StopTimeLocal; Result = "$($t.Status)" DurationMin = ''; IsRetry = ''; TransferredGB = ''; Reason = ($t.Info.Reason -replace '\s+',' ') }) } } } catch { Write-Verbose "Agent session enumeration skipped: $_" } #endregion #region Output $schedFile = Join-Path $OutputPath "${safeSite}_${server}_schedule.csv" $histFile = Join-Path $OutputPath "${safeSite}_${server}_history.csv" $schedRows | Sort-Object Job, Machine | Export-Csv $schedFile -NoTypeInformation -Encoding UTF8 $histRows | Sort-Object Start | Export-Csv $histFile -NoTypeInformation -Encoding UTF8 $summary = $histRows | Group-Object Result | ForEach-Object { "$($_.Name)=$($_.Count)" } Write-Output "Site: $SiteName | Server: $server | Jobs/machines scheduled: $($schedRows.Count) | Runs in last $Days days: $($histRows.Count) ($($summary -join ', '))" Write-Output "Schedule CSV: $schedFile" Write-Output "History CSV: $histFile" #endregion