Files
Backup-Dashboard/collectors/mars/Send-MarsBackupStatus.ps1
kyleandClaude Opus 5.5 5982fc9434 Fix MARS collector against a real agent's object model
Checked with Get-MarsCollectorDiagnostics.ps1 output from MARS agent
2.0.9274 (VA-FS-01):

- JobStatus.StartTime/EndTime are UTC values not marked as UTC
  (DisplayTime is the local one); they were read as local, putting
  runs 6 hours off. Lookback filtering now compares in UTC too
- ErrorInfo is always present with ErrorCode 0 on success; every
  completed backup would have been a warning. Only non-zero error
  codes count
- Schedule times are TimeSpans; casting them to DateTime failed, so
  next_run_at was always empty
- DatasourceStatus.Datasource is an object; use its DataSourceName
- Also report FileProgress.Failed file counts and FailedFileLog
- Mock rebuilt on the real shapes; diagnostics handle no System State
  policy

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 15:40:08 -06:00

414 lines
17 KiB
PowerShell

<#
.SYNOPSIS
Reports Microsoft Azure Recovery Services (MARS) agent backups to the Backup Dashboard.
.DESCRIPTION
Runs on a server with the MARS agent, normally as a SYSTEM scheduled task every 60 minutes
(see Install-MarsCollector.ps1). The server appears on the dashboard as one job. Each run sends:
- the next scheduled backup, worked out from the backup (and System State) policy schedules,
which drives the dashboard's Overdue flag, and
- every backup job from the last LookbackHours that the dashboard hasn't already received in
its final state. A backup in progress is sent as Running until it finishes.
Already-reported jobs 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.
Written for Windows PowerShell 5.1, which the MARS agent's MSOnlineBackup module targets.
.PARAMETER ConfigPath
JSON config with ApiUrl, ApiKey and Tenant (optional: JobName, 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-MarsBackupStatus.ps1 -DryRun
.EXAMPLE
.\Send-MarsBackupStatus.ps1 -LookbackHours 720
#>
[CmdletBinding()]
param(
[string]$ConfigPath = (Join-Path $env:ProgramData 'BackupDashboard\mars-collector.json'),
[int]$LookbackHours = 0,
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
$DataDir = Split-Path -Parent $ConfigPath
$LogPath = Join-Path $DataDir 'mars-collector.log'
$StatePath = Join-Path $DataDir 'mars-collector.state.json'
$MaxLogBytes = 5MB
# MARS keeps a limited job history; this comfortably covers 30 days at 3 backups a day
$MaxPreviousJobs = 200
$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. DateTimeOffset keeps a Local/Unspecified time as local and a
# Utc time as +00:00.
function ConvertTo-IsoTime($Value) {
if ($null -eq $Value) { return $null }
$dt = [datetime]$Value
if ($dt.Year -le 1900) { return $null } # "not set" (MARS uses 1601-01-01)
return ([DateTimeOffset]$dt).ToString("yyyy-MM-dd'T'HH:mm:sszzz", $Invariant)
}
# First property of $Object, out of $Names, that exists and isn't empty. MARS object property names
# aren't documented, so reads go through this rather than assuming one spelling.
function Get-FirstValue($Object, [string[]]$Names) {
if ($null -eq $Object) { return $null }
foreach ($name in $Names) {
try {
$prop = $Object.PSObject.Properties[$name]
if (-not $prop) { continue }
$value = $prop.Value
# Check emptiness by type: casting to [string] would make a one-item list of objects ''
if ($null -eq $value) { continue }
if ($value -is [string] -and $value -eq '') { continue }
if ($value -is [Collections.ICollection] -and $value.Count -eq 0) { continue }
return $value
} catch { }
}
return $null
}
function Import-Mars {
if (Get-Command Get-OBJob -ErrorAction SilentlyContinue) { return } # already loaded
if (Get-Module -ListAvailable -Name MSOnlineBackup) {
Import-Module MSOnlineBackup
return
}
# The agent normally adds its module folder to PSModulePath; fall back to the default install path
$agentModule = Join-Path $env:ProgramFiles 'Microsoft Azure Recovery Services Agent\bin\Modules\MSOnlineBackup'
Import-Module $agentModule
}
# ---------- Schedule ----------
# Next slot strictly after $After for a MARS schedule (days of week + times of day).
# Returns $null when there's no schedule, or when it runs every N>1 weeks: we can't tell which
# week is next, and a wrong guess would raise a false Overdue alarm.
function Get-NextSlot($Schedule, [datetime]$After) {
if ($null -eq $Schedule) { return $null }
$weeks = Get-FirstValue $Schedule @('ScheduleWeeklyFrequency', 'WeeksInterval')
if ($weeks -and [int]$weeks -gt 1) { return $null }
$days = @(Get-FirstValue $Schedule @('ScheduleRunDays', 'DaysOfWeek') | ForEach-Object { [string]$_ })
# TimeSpans (time of day, local) on real agents; accept DateTimes too
$times = @(Get-FirstValue $Schedule @('ScheduleRunTimes', 'TimesOfDay') | ForEach-Object {
if ($_ -is [timespan]) { $_ } else { ([datetime]$_).TimeOfDay }
} | Sort-Object)
if ($days.Count -eq 0 -or $times.Count -eq 0) { return $null }
for ($offset = 0; $offset -le 7; $offset++) {
$date = $After.Date.AddDays($offset)
if ($days -notcontains [string]$date.DayOfWeek) { continue }
foreach ($time in $times) {
$candidate = $date + $time
if ($candidate -gt $After) { return $candidate }
}
}
return $null
}
function Get-NextRun {
$next = $null
$policies = @()
try { $policies += @(Get-OBPolicy) } catch { Write-Log "Couldn't read the backup policy: $(Get-ErrorText $_)" 'WARN' }
# Windows Server can have a separate System State policy with its own schedule
if (Get-Command Get-OBSystemStatePolicy -ErrorAction SilentlyContinue) {
try { $policies += @(Get-OBSystemStatePolicy) } catch { }
}
foreach ($policy in $policies) {
if ($null -eq $policy) { continue }
try {
$slot = Get-NextSlot (Get-OBSchedule -Policy $policy) (Get-Date)
if ($slot -and (-not $next -or $slot -lt $next)) { $next = $slot }
} catch {
Write-Log "Couldn't read a policy schedule: $(Get-ErrorText $_)" 'WARN'
}
}
return ConvertTo-IsoTime $next
}
# ---------- Jobs ----------
# A job's StartTime/EndTime are UTC values, though not necessarily marked as UTC: on a real agent
# StartTime matched the "StartTimeUTC" in the status text, while DisplayTime held local time.
# Returns $null for "not set" (1601-01-01), e.g. EndTime while a backup is running.
function Get-JobTimeUtc($Job, [string]$Field) {
$value = Get-FirstValue $Job.JobStatus @($Field)
if ($null -eq $value) { return $null }
$dt = [datetime]$value
if ($dt.Year -le 1900) { return $null }
return [datetime]::SpecifyKind($dt, [DateTimeKind]::Utc)
}
# MARS always includes an ErrorInfo; ErrorCode 0 means no error
function Test-HasError($ErrorInfo) {
if (-not $ErrorInfo) { return $false }
$code = Get-FirstValue $ErrorInfo @('ErrorCode')
$detailed = Get-FirstValue $ErrorInfo @('DetailedErrorCode')
return ($code -and [long]$code -ne 0) -or ($detailed -and [long]$detailed -ne 0)
}
# DatasourceStatus.Datasource is an object whose DataSourceName is e.g. "E:\" or "System State"
function Get-DatasourceName($DatasourceStatus) {
$ds = Get-FirstValue $DatasourceStatus @('Datasource', 'DataSource')
$name = Get-FirstValue $ds @('DataSourceName', 'FriendlyNameDSPS', 'Name')
if ($name) { return [string]$name }
if ($ds -is [string]) { return $ds }
return 'Datasource'
}
function Get-FailedFiles($Job) {
$sum = [long]0
foreach ($ds in Get-DatasourceStatuses $Job) {
$value = Get-FirstValue (Get-FirstValue $ds @('FileProgress')) @('Failed')
if ($value) { $sum += [long]$value }
}
return $sum
}
function Get-JobStatus($Job) {
$status = $Job.JobStatus
$state = [string](Get-FirstValue $status @('JobState', 'State'))
if (-not (Get-JobTimeUtc $Job 'EndTime') -and $state -notmatch 'Fail|Abort|Cancel|Complete|Success|Succeed|Warn') {
return 'running'
}
if ($state -match 'Fail') { return 'failed' }
if ($state -match 'Warn|Abort|Cancel|Stop') { return 'warning' }
if ($state -match 'Complete|Success|Succeed') {
# MARS can report Completed while individual files failed or an error was recorded
if ((Get-FailedBytes $Job) -gt 0 -or (Get-FailedFiles $Job) -gt 0 -or
(Test-HasError (Get-FirstValue $status @('ErrorInfo')))) { return 'warning' }
return 'success'
}
return 'warning' # finished in a state we don't recognise: surface it rather than hide it
}
function Get-DatasourceStatuses($Job) {
return @(Get-FirstValue $Job.JobStatus @('DatasourceStatus', 'DataSourceStatus') | Where-Object { $_ })
}
function Get-ByteTotal($Job, [string]$Field) {
$sum = [long]0
foreach ($ds in Get-DatasourceStatuses $Job) {
$progress = Get-FirstValue $ds @('ByteProgress')
$value = Get-FirstValue $progress @($Field)
if ($value) { $sum += [long]$value }
}
return $sum
}
function Get-FailedBytes($Job) { return Get-ByteTotal $Job 'Failed' }
function Format-ErrorInfo($ErrorInfo) {
if (-not (Test-HasError $ErrorInfo)) { return $null }
$text = Get-FirstValue $ErrorInfo @('ErrorMessage', 'Message', 'Description')
if ($text) { return ([string]$text).Trim() }
$code = Get-FirstValue $ErrorInfo @('ErrorCode', 'DetailedErrorCode')
$params = @(Get-FirstValue $ErrorInfo @('ErrorParamList') | Where-Object { $_ } | ForEach-Object {
$name = Get-FirstValue $_ @('Name')
$value = Get-FirstValue $_ @('Value')
if ($name) { "$name=$value" } else { [string]$_ }
} | Where-Object { $_ })
$line = if ($code) { "Error code $code" } else { [string]$ErrorInfo }
if ($params.Count) { $line += " ($($params -join ', '))" }
return $line
}
function Get-RunMessage($Job, [string]$Status) {
$lines = New-Object 'System.Collections.Generic.List[string]'
$state = [string](Get-FirstValue $Job.JobStatus @('JobState', 'State'))
$jobError = Format-ErrorInfo (Get-FirstValue $Job.JobStatus @('ErrorInfo'))
if ($jobError) { $lines.Add($jobError) }
foreach ($ds in Get-DatasourceStatuses $Job) {
$name = Get-DatasourceName $ds
$dsError = Format-ErrorInfo (Get-FirstValue $ds @('ErrorInfo'))
$failedFiles = Get-FirstValue (Get-FirstValue $ds @('FileProgress')) @('Failed')
$failedBytes = Get-FirstValue (Get-FirstValue $ds @('ByteProgress')) @('Failed')
if ($dsError) { $lines.Add("${name}: $dsError") }
if ($failedFiles -and [long]$failedFiles -gt 0) { $lines.Add("${name}: $failedFiles file(s) failed to back up") }
elseif ($failedBytes -and [long]$failedBytes -gt 0) { $lines.Add("${name}: $failedBytes bytes failed to back up") }
}
$failedLog = Get-FirstValue $Job.JobStatus @('FailedFileLog')
if ($failedLog -and $lines.Count) { $lines.Add("Failed file log: $failedLog") }
if ($lines.Count -eq 0) {
switch ($Status) {
'success' { return 'Backup completed successfully.' }
'running' { return '' }
default { return "Backup finished with state '$state'." }
}
}
if ($state -and $Status -ne 'success') { $lines.Insert(0, "Job state: $state") }
return ($lines -join "`n")
}
function ConvertTo-Run($Job) {
$status = Get-JobStatus $Job
$datasources = @(Get-DatasourceStatuses $Job | ForEach-Object { Get-DatasourceName $_ })
$details = [ordered]@{ server = $env:COMPUTERNAME }
if ($datasources.Count) { $details.datasources = $datasources }
$agentState = Get-FirstValue $Job.JobStatus @('JobState', 'State')
if ($agentState) { $details.mars_state = [string]$agentState }
$run = [ordered]@{
external_id = [string]$Job.JobId
status = $status
started_at = ConvertTo-IsoTime (Get-JobTimeUtc $Job 'StartTime')
message = Get-RunMessage $Job $status
details = $details
}
if ($status -ne 'running') {
$finished = ConvertTo-IsoTime (Get-JobTimeUtc $Job 'EndTime')
if ($finished) { $run.finished_at = $finished } # otherwise the API uses receipt time
$transferred = Get-ByteTotal $Job 'Progress'
if ($transferred -gt 0) { $run.size_bytes = $transferred }
$failedBytes = Get-FailedBytes $Job
if ($failedBytes -gt 0) { $details.failed_bytes = $failedBytes }
$failedFiles = Get-FailedFiles $Job
if ($failedFiles -gt 0) { $details.failed_files = $failedFiles }
}
return $run
}
function Test-IsBackupJob($Job) {
# Get-OBJob also lists restores (JobType Recovery); only backups belong on the dashboard
$type = [string](Get-FirstValue $Job @('JobType', 'Type'))
return (-not $type) -or $type -match 'Backup'
}
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
$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 { 'Azure Backup (MARS)' }
$JobName = if ($Config.JobName) { [string]$Config.JobName } else { "$env:COMPUTERNAME - Azure Backup" }
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-Mars
# Job ID -> start time (Unix seconds), for jobs already delivered in their final state
$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 } }
}
$sinceUtc = (Get-Date).ToUniversalTime().AddHours(-$LookbackHours)
# Get-OBJob with no arguments lists jobs in progress; -Previous lists finished ones
$jobs = @(@(Get-OBJob) + @(Get-OBJob -Previous $MaxPreviousJobs) | Where-Object {
$_ -and (Test-IsBackupJob $_)
})
$byId = [ordered]@{}
foreach ($job in $jobs) {
$start = Get-JobTimeUtc $job 'StartTime'
if (-not $start -or $start -lt $sinceUtc) { continue }
$byId[[string]$job.JobId] = $job # a job in both lists: the finished entry (later) wins
}
$pending = @($byId.Values |
Where-Object { -not $sent.ContainsKey([string]$_.JobId) } |
Sort-Object { Get-JobTimeUtc $_ 'StartTime' })
$jobInfo = [ordered]@{
# Stable per server, so renaming the dashboard job via JobName doesn't create a new one
external_id = "mars:$($env:COMPUTERNAME.ToLowerInvariant())"
name = $JobName
# Always sent (null when unscheduled) so the dashboard's value tracks the agent's
next_run_at = Get-NextRun
}
} catch {
Write-Log "Collector setup failed: $(Get-ErrorText $_)" 'ERROR'
exit 2
}
$failures = 0
$runsSent = 0
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 ($job in $pending) {
$run = ConvertTo-Run $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](Get-JobTimeUtc $job 'StartTime')).ToUnixTimeSeconds()
}
}
} catch {
$failures++
Write-Log "Failed to report: $(Get-ErrorText $_)" 'ERROR'
}
if (-not $DryRun) {
# Forget jobs 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} runs, {1} failures" -f $runsSent, $failures)
if ($failures) { exit 1 }