Private/Core/Write-IDBridgeRunSummary.ps1
|
<# .SYNOPSIS Write the end-of-run summary file (Data\LastRun.json) a reader on the box can consume. .DESCRIPTION Called from the finally block of Invoke-IDBridge after telemetry and before the PostRun plugins, on every run that is not a Preview. Replaces <DataRoot>\LastRun.json whole with the run's counts, mode flags, timing and module version, so a monitor on the same box can see how the last sync went without reading the log. The schema is versioned (schemaVersion 1) and grows additively only - a reader typed to this schema refuses a key it does not know. Counts are APPLIED work, so a ReadOnly run writes zeros alongside readOnly = true. lastSuccessAt is runEnd on a successful run and CARRIED FORWARD from the previous file on a failed one (empty when there has never been one) - the one field a single run cannot supply. A failed run contributes only the exception CLASS name and the throwing FUNCTION name, never the message, which can contain UPNs/DNs; no name, id, UPN, DN or group name is ever written. The reader is SC Networks' Beacon collector on the same box where a district runs Beacon; nothing here leaves the machine by IDBridge's doing (see PRIVACY.md). The write is self-contained: a failure logs a Warn and never affects the run. .PARAMETER RunResult The run report built in the finally block of Invoke-IDBridge. .OUTPUTS None. Side effects: one file write and log output. .EXAMPLE Write-IDBridgeRunSummary -RunResult $runResult .NOTES Created by: Sam Cattanach Modified: 2026-09-21 #> function Write-IDBridgeRunSummary { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [pscustomobject]$RunResult ) $IDConfig = Get-IDBridgeConfig $path = "$($IDConfig.Paths.DataRoot)\LastRun.json" #region Carry Forward # A failed run has no success time of its own, so the previous file's survives it - a # reader can tell a sync that just broke from one that has been broken for a week. No # previous file, or one that won't parse, means no known success. $previousSuccessAt = '' if (Test-Path $path) { try { $previous = Get-Content -Path $path -Raw | ConvertFrom-Json # ConvertFrom-Json turns the ISO-8601 stamp back into a [datetime]; it goes out # again the way it was written. $previousSuccessAt = if ($previous.lastSuccessAt -is [datetime]) { $previous.lastSuccessAt.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") } else { "$($previous.lastSuccessAt)" } } catch { Write-Log -Message "Run summary: Previous $path unreadable ($($_.Exception.GetType().Name)) - no carried-forward success time." -Level Trace } } #endregion Carry Forward #region Build Payload $directories = @( if ($IDConfig.AD.enabled -eq $true) { 'AD' } if ($IDConfig.Google.enabled -eq $true) { 'Google' } ) -join '+' if (-not $directories) { $directories = 'None' } $success = [bool]$RunResult.Success $runEnd = $RunResult.RunEnd.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") $payload = [ordered]@{ schemaVersion = 1 moduleVersion = "$($MyInvocation.MyCommand.Module.Version)" runStart = $RunResult.RunStart.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") runEnd = $runEnd durationSeconds = [int]$RunResult.DurationSeconds success = $success readOnly = [bool]$RunResult.ReadOnly testRun = [bool]$RunResult.TestRun directories = $directories managed = [int]$RunResult.Counts.Managed created = [int]$RunResult.Counts.Create updated = [int]$RunResult.Counts.Update deactivated = [int]$RunResult.Counts.Deactivate groupAdds = [int]$RunResult.Counts.GroupAdd groupRemoves = [int]$RunResult.Counts.GroupRemove writeFailures = [int]$RunResult.Counts.Failed thresholdExceeded = [bool](@($RunResult.ThresholdResults).Where({ $null -ne $_ -and $_.Exceeded }).Count -gt 0) errorType = '' errorFunction = '' lastSuccessAt = if ($success) { $runEnd } else { $previousSuccessAt } } if ($RunResult.RunError) { # Exception CLASS and FUNCTION name only - message text can contain UPNs/DNs and never leaves the box. $payload.errorType = $RunResult.RunError.Exception.GetType().Name $errorFunction = "$($RunResult.RunError.InvocationInfo.MyCommand)" if (-not $errorFunction -and $RunResult.RunError.ScriptStackTrace) { # A Throw statement has no MyCommand; take the function name from the first stack # frame ("at Function-Name, <file>: line N") and drop the file path after the comma. $errorFunction = ($RunResult.RunError.ScriptStackTrace -split "`n")[0] -replace '^at ', '' -replace ',.*$', '' } $payload.errorFunction = $errorFunction } #endregion Build Payload #region Write try { # -ErrorAction Stop so an unwritable Data directory reaches the catch below instead # of the error stream: the run is over and nothing here is worth failing it for. $payload | ConvertTo-Json -Depth 3 | Set-Content -Path $path -Encoding utf8 -ErrorAction Stop Write-Log -Message "Run summary: Wrote $path" -Level Trace } catch { Write-Log -Message "Run summary: Write failed (non-fatal, run unaffected): $($_.Exception.GetType().Name)" -Level Warn } #endregion Write } |