#Requires -Version 7.5 <# .SYNOPSIS Exports only allowlisted backend-health observations from private controller journals. .DESCRIPTION Reads protected native-command JSON journals. It never copies complete command output, Azure resource IDs, tenant/subscription IDs, public addresses, or certificate material. Selects the first actual 404 probe fault and certificate-name TLS fault, with the nearest healthy observations for the same backend address before and after each fault. An unfinished recovery remains null; rerun after the controller has retained its result. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $PrivateLogDirectory, [Parameter(Mandatory)] [string] $OutputPath ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $sourceDirectory = [IO.Path]::GetFullPath($PrivateLogDirectory) $destination = [IO.Path]::GetFullPath($OutputPath) if (-not (Test-Path -LiteralPath $sourceDirectory -PathType Container)) { throw 'The specified private journal directory does not exist.' } $observations = [Collections.Generic.List[object]]::new() foreach ($file in Get-ChildItem -LiteralPath $sourceDirectory -Filter '*-azure.json' -File) { # Some Azure command results are arrays or other non-health responses. Ignore those # without copying their data or reporting their contents to the public output. try { $journal = [IO.File]::ReadAllText($file.FullName) | ConvertFrom-Json -AsHashtable -Depth 100 -DateKind String if ($journal.exit_code -ne 0 -or [string]::IsNullOrWhiteSpace($journal.stdout)) { continue } $health = $journal.stdout | ConvertFrom-Json -AsHashtable -Depth 100 -DateKind String if ($health -isnot [Collections.IDictionary] -or -not $health.ContainsKey('backendAddressPools')) { continue } $instant = [DateTimeOffset]::Parse([string] $journal.utc) $sourceHash = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() foreach ($pool in $health.backendAddressPools) { foreach ($setting in $pool.backendHttpSettingsCollection) { $name = ([string] $setting.backendHttpSettings.id -split '/')[-1] if ($name -notin @('probe-settings', 'tls-settings')) { continue } foreach ($server in $setting.servers) { $address = [string] $server.address if ($address -notmatch '^10\.171\.(?:[0-9]{1,3})\.(?:[0-9]{1,3})$') { continue } $parsed = $null if (-not [Net.IPAddress]::TryParse($address, [ref] $parsed)) { continue } $status = [string] $server.health $reason = [string] $server.healthProbeLog $safeReason = $null $excerpt = $false if ($status -eq 'Healthy' -and $reason -eq 'Success. Received 200 status code') { $safeReason = $reason } elseif ($status -eq 'Unhealthy' -and $name -eq 'probe-settings') { $match = [regex]::Match($reason, 'Received invalid status code: 404') if ($match.Success) { $safeReason = $match.Value; $excerpt = $true } } elseif ($status -eq 'Unhealthy' -and $name -eq 'tls-settings' -and $reason -eq 'The Common Name of the leaf certificate presented by the backend server does not match the Probe or Backend Setting hostname of the application gateway.') { $safeReason = $reason } if ($null -eq $safeReason) { continue } $observations.Add([pscustomobject] @{ instant = $instant public = [ordered] @{ journal_utc = $instant.ToUniversalTime().ToString('o') source_file_basename = $file.Name source_sha256 = $sourceHash setting_name = $name backend_private_address = $address health = $status reason = $safeReason reason_is_excerpt = $excerpt } }) } } } } catch { # A partial or non-JSON journal is not evidence. Never echo private contents. continue } } $scenarios = [Collections.Generic.List[object]]::new() foreach ($name in @('probe-settings', 'tls-settings')) { $fault = $observations | Where-Object { $_.public.setting_name -eq $name -and $_.public.health -eq 'Unhealthy' } | Sort-Object instant | Select-Object -First 1 if ($null -eq $fault) { throw "No matching observed fault was retained for $name; no evidence file was written." } $healthy = @($observations | Where-Object { $_.public.setting_name -eq $name -and $_.public.health -eq 'Healthy' -and $_.public.backend_private_address -eq $fault.public.backend_private_address }) $before = $healthy | Where-Object { $_.instant -lt $fault.instant } | Sort-Object instant -Descending | Select-Object -First 1 $after = $healthy | Where-Object { $_.instant -gt $fault.instant } | Sort-Object instant | Select-Object -First 1 $scenarios.Add([ordered] @{ setting_name = $name observed_fault = $fault.public nearest_healthy_before = $(if ($null -ne $before) { $before.public } else { $null }) nearest_healthy_after = $(if ($null -ne $after) { $after.public } else { $null }) recovery_observed = ($null -ne $after) }) } $report = [ordered] @{ schema_version = 1 observed_live = $true extracted_utc = [DateTimeOffset]::UtcNow.ToString('o') provenance = 'Allowlisted observations extracted from retained native Azure CLI backend-health journals; source-file hashes refer to protected originals.' selection = 'First matching unhealthy observation, with nearest healthy observations before and after for the same setting and private backend address.' private_material_included = $false scenarios = $scenarios.ToArray() } [void] [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) [IO.File]::WriteAllText($destination, ($report | ConvertTo-Json -Depth 12), [Text.UTF8Encoding]::new($false)) Write-Output $destination