Private/Update-FslRunHistory.ps1
|
function Update-FslRunHistory { <# .SYNOPSIS Persists a diagnostic run to the history folder and diffs it against the previous run of the same scope: current findings are tagged New/Persisting (with a consecutive-run counter), and Critical/Warning findings that disappeared come back as synthetic 'Resolved' Info findings. Findings match across runs on Category|Check|Target - messages carry volatile counters and are deliberately not part of the identity. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Internal helper that only runs after the caller explicitly opted in via -HistoryPath; writing the history file is its entire purpose.')] [CmdletBinding()] [OutputType([pscustomobject])] param( [AllowEmptyCollection()] [object[]]$Finding = @(), [Parameter(Mandatory)] [string]$HistoryPath, [Parameter(Mandatory)] [string]$Scope ) if (-not (Test-Path -LiteralPath $HistoryPath)) { New-Item -ItemType Directory -Path $HistoryPath -Force | Out-Null } # One history stream per scope (host or sorted fleet list), so alternating # between different targets never produces bogus diffs. $sha = [System.Security.Cryptography.SHA256]::Create() try { $scopeHash = ([System.BitConverter]::ToString($sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Scope)))).Replace('-', '').Substring(0, 10).ToLowerInvariant() } finally { $sha.Dispose() } $previous = $null $previousFile = Get-ChildItem -LiteralPath $HistoryPath -Filter ("run-{0}-*.json" -f $scopeHash) -File -ErrorAction SilentlyContinue | Sort-Object Name -Descending | Select-Object -First 1 if ($previousFile) { try { $previous = Get-Content -LiteralPath $previousFile.FullName -Raw | ConvertFrom-Json } catch { Write-Warning ("History file '{0}' could not be read - continuing without a diff." -f $previousFile.Name) } } $prevMap = @{} if ($previous -and $previous.Findings) { foreach ($prevFinding in $previous.Findings) { $prevMap[('{0}|{1}|{2}' -f $prevFinding.Category, $prevFinding.Check, $prevFinding.Target)] = $prevFinding } } $output = New-Object System.Collections.Generic.List[object] $records = New-Object System.Collections.Generic.List[object] foreach ($currentFinding in $Finding) { $key = '{0}|{1}|{2}' -f $currentFinding.Category, $currentFinding.Check, $currentFinding.Target $persistCount = 1 $status = '' if ($prevMap.ContainsKey($key)) { $status = 'Persisting' $persistCount = [int]$prevMap[$key].PersistCount + 1 $prevMap.Remove($key) } elseif ($previous) { $status = 'New' } Add-Member -InputObject $currentFinding -NotePropertyName ChangeStatus -NotePropertyValue $status -Force # Only alert-worthy findings get the annotation - Pass/Info stay quiet. if ($previous -and $currentFinding.Severity -in @('Critical', 'Warning')) { if ($status -eq 'New') { $currentFinding.Message += ' (New since the last run.)' } elseif ($persistCount -gt 1) { $currentFinding.Message += (" (Persisting - seen in {0} consecutive runs.)" -f $persistCount) } } $records.Add([pscustomobject]@{ Category = [string]$currentFinding.Category Check = [string]$currentFinding.Check Severity = [string]$currentFinding.Severity Target = [string]$currentFinding.Target Message = [string]$currentFinding.Message PersistCount = $persistCount }) $output.Add($currentFinding) } # Whatever alert-worthy finding is left in the previous map has disappeared. if ($previous) { foreach ($leftKey in @($prevMap.Keys)) { $gone = $prevMap[$leftKey] if ([string]$gone.Severity -notin @('Critical', 'Warning')) { continue } $oldMessage = [string]$gone.Message if ($oldMessage.Length -gt 160) { $oldMessage = $oldMessage.Substring(0, 160) + '...' } $resolvedFinding = New-FslFinding -Category ([string]$gone.Category) -Check ([string]$gone.Check) -Severity Info -Target ([string]$gone.Target) ` -Message ("Resolved since the last run: the previous {0} finding is gone. Was: {1}" -f $gone.Severity, $oldMessage) Add-Member -InputObject $resolvedFinding -NotePropertyName ChangeStatus -NotePropertyValue 'Resolved' -Force $output.Add($resolvedFinding) } } $runRecord = [pscustomobject]@{ Scope = $Scope Timestamp = (Get-Date).ToString('o') Findings = $records } $fileName = 'run-{0}-{1}.json' -f $scopeHash, (Get-Date -Format 'yyyyMMdd-HHmmssfff') $runRecord | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $HistoryPath $fileName) -Encoding UTF8 $output } |