Public/Test-TaxEditorHealth.ps1
|
# Copyright (c) 2026 Jeffrey Snover. All rights reserved. # Licensed under the MIT License. See LICENSE file in the project root. function Test-TaxEditorHealth { <# .SYNOPSIS Tests liveness and readiness of the deployed Taxonomy Editor site. .DESCRIPTION Hits /healthz (liveness) and /health (readiness) endpoints on the remote Taxonomy Editor instance. Returns a structured result with status, response times, and diagnostic details. Also probes the anonymous-auth layer (t/1841): GET /.auth/anonymous must return 2xx AND issue a session cookie, then an authenticated GET /api/flags must return JSON (not the login-page HTML). This catches the failure mode where /healthz + /health are green but every real API call redirects to a login page — an outage the liveness/readiness probes miss. Consolidates the health-poll patterns duplicated across health-monitor.yml, deploy-azure.yml, and deploy-staging.yml (t/1491). .PARAMETER BaseUrl The base URL of the deployed Taxonomy Editor site. .PARAMETER TimeoutSec HTTP request timeout in seconds. Default: 10. .PARAMETER MaxAttempts Poll up to this many attempts, returning on first fully-healthy result. Default 1 (no retry — preserves prior behavior). .PARAMETER RetryIntervalSec Sleep between attempts when MaxAttempts > 1. Default: 10. .EXAMPLE Test-TaxEditorHealth .EXAMPLE Test-TaxEditorHealth -BaseUrl 'https://my-instance.azurecontainerapps.io' .EXAMPLE # Poll a fresh deploy for up to 5 minutes waiting for readiness. Test-TaxEditorHealth -MaxAttempts 30 -RetryIntervalSec 10 .LINK Show-AITriadHelp .LINK Test-TaxEditorEndpoints .LINK Test-AnonymousDebateFlow .LINK Test-PersonaEndpoints .LINK Test-ServiceWorkerHealth .LINK Get-FreeTierStatus .LINK Invoke-TaxEditorSmokeTest #> [CmdletBinding()] param( [Parameter(Position = 0)] [string]$BaseUrl = (Get-TaxEditorBaseUrl), [Parameter()] [ValidateRange(1, 120)] [int]$TimeoutSec = 10, [Parameter()] [ValidateRange(1, 60)] [int]$MaxAttempts = 1, [Parameter()] [ValidateRange(1, 300)] [int]$RetryIntervalSec = 10 ) Set-StrictMode -Version Latest $BaseUrl = $BaseUrl.TrimEnd('/') $Result = $null for ($Attempt = 1; $Attempt -le $MaxAttempts; $Attempt++) { $Result = Invoke-HealthProbe -BaseUrl $BaseUrl -TimeoutSec $TimeoutSec if ($Result.Healthy) { return $Result } if ($Attempt -lt $MaxAttempts) { Write-Verbose "Attempt $Attempt/$MaxAttempts unhealthy — sleeping ${RetryIntervalSec}s" Start-Sleep -Seconds $RetryIntervalSec } } return $Result } function Invoke-HealthProbe { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$BaseUrl, [Parameter(Mandatory)] [int]$TimeoutSec ) Set-StrictMode -Version Latest $Checks = [System.Collections.Generic.List[HealthCheck]]::new() # ── /healthz (liveness) ────────────────────────────────────────────── $LivenessResult = Invoke-RemoteCheck -BaseUrl $BaseUrl -Path '/healthz' ` -TimeoutSec $TimeoutSec -ExpectedField 'status' $LiveCheck = [HealthCheck]::new() $LiveCheck.Endpoint = '/healthz' $LiveCheck.Purpose = 'Liveness' $LiveCheck.Status = $LivenessResult.StatusCode $LiveCheck.Healthy = $LivenessResult.Success -and $LivenessResult.Body.status -eq 'healthy' $LiveCheck.Ms = $LivenessResult.ResponseMs $LiveCheck.Detail = if ($LivenessResult.Success) { $LivenessResult.Body.status } else { $LivenessResult.Error } $Checks.Add($LiveCheck) # ── /health (readiness) ────────────────────────────────────────────── $ReadinessResult = Invoke-RemoteCheck -BaseUrl $BaseUrl -Path '/health' ` -TimeoutSec $TimeoutSec -ExpectedField 'status' $ReadyDetail = if ($ReadinessResult.Success) { $b = $ReadinessResult.Body $parts = @("status=$($b.status)") if ($b.PSObject.Properties['ai'] -and $b.ai.PSObject.Properties['geminiKeyConfigured']) { $parts += "geminiKey=$($b.ai.geminiKeyConfigured)" if ($b.ai.PSObject.Properties['freeTierKeyPoolSize']) { $pool = $b.ai.freeTierKeyPoolSize $parts += "freeKeyPool=$pool" if ($pool -lt 2) { $parts += '(NO ROUND-ROBIN)' } } } if ($b.PSObject.Properties['version']) { $parts += "version=$($b.version)" } if ($b.PSObject.Properties['uptime']) { $parts += "uptime=$($b.uptime)s" } $parts -join ' | ' } else { $ReadinessResult.Error } $ReadCheck = [HealthCheck]::new() $ReadCheck.Endpoint = '/health' $ReadCheck.Purpose = 'Readiness' $ReadCheck.Status = $ReadinessResult.StatusCode $ReadCheck.Healthy = $ReadinessResult.Success -and $ReadinessResult.Body.status -eq 'ok' $ReadCheck.Ms = $ReadinessResult.ResponseMs $ReadCheck.Detail = $ReadyDetail $Checks.Add($ReadCheck) # ── /.auth/anonymous (anonymous auth layer) ────────────────────────── # A server can pass /healthz + /health while its anonymous-auth layer is # broken — every real API call then returns the login-page HTML instead of # JSON, so the liveness/readiness probes look green while the app is unusable # (flight recorder 2026-07-28: 30ms health baseline while all API calls # redirected to a login page, t/1841). New-AnonymousWebSession GETs # /.auth/anonymous (Azure Easy Auth) and returns a cookie jar on 2xx, $null # on non-2xx / transport error. $AuthSw = [System.Diagnostics.Stopwatch]::StartNew() $AuthSession = New-AnonymousWebSession -BaseUrl $BaseUrl -TimeoutSec $TimeoutSec $AuthSw.Stop() $CookieCount = if ($AuthSession) { $AuthSession.Cookies.Count } else { 0 } $AuthHealthy = ($null -ne $AuthSession) -and ($CookieCount -gt 0) $AuthDetail = if ($null -eq $AuthSession) { 'no session established (/.auth/anonymous non-2xx or transport error)' } elseif ($CookieCount -eq 0) { '2xx but no Set-Cookie — anonymous auth layer not issuing sessions' } else { "anon session established ($CookieCount cookie$(if ($CookieCount -ne 1) { 's' }))" } $AuthCheck = [HealthCheck]::new() $AuthCheck.Endpoint = '/.auth/anonymous' $AuthCheck.Purpose = 'AnonymousAuth' $AuthCheck.Status = if ($AuthHealthy) { 200 } else { 0 } $AuthCheck.Healthy = $AuthHealthy $AuthCheck.Ms = $AuthSw.ElapsedMilliseconds $AuthCheck.Detail = $AuthDetail $Checks.Add($AuthCheck) # ── /api/flags (authenticated JSON call using the anon cookie) ──────── # Directly reproduces the reported failure: a real API path serving the # login shell (200 text/html) instead of JSON. -ExpectJson flips Success on # text/html. Only meaningful once we hold a session cookie, so it's skipped # when the anon session didn't establish (the AuthCheck above already fails). if ($AuthHealthy) { $FlagsResult = Invoke-RemoteCheck -BaseUrl $BaseUrl -Path '/api/flags' ` -TimeoutSec $TimeoutSec -Session $AuthSession -ExpectJson $FlagsDetail = if ($FlagsResult.Success) { 'JSON response' } elseif ($FlagsResult.ContentType -match 'text/html') { 'login-page HTML returned for an API path — anon session cookie not honored' } elseif ($FlagsResult.Error) { $FlagsResult.Error } else { "HTTP $($FlagsResult.StatusCode) ($($FlagsResult.ContentType))" } $FlagsCheck = [HealthCheck]::new() $FlagsCheck.Endpoint = '/api/flags' $FlagsCheck.Purpose = 'AuthenticatedApi' $FlagsCheck.Status = $FlagsResult.StatusCode $FlagsCheck.Healthy = $FlagsResult.Success $FlagsCheck.Ms = $FlagsResult.ResponseMs $FlagsCheck.Detail = $FlagsDetail $Checks.Add($FlagsCheck) } # ── Summary ────────────────────────────────────────────────────────── $AllHealthy = @($Checks | Where-Object { -not $_.Healthy }).Count -eq 0 $AvgMs = [math]::Round(($Checks | Measure-Object -Property Ms -Average).Average, 0) $PoolSize = 0 if ($ReadinessResult.Success -and $ReadinessResult.Body.PSObject.Properties['ai'] -and $ReadinessResult.Body.ai.PSObject.Properties['freeTierKeyPoolSize']) { $PoolSize = [int]$ReadinessResult.Body.ai.freeTierKeyPoolSize } $Result = [TaxEditorHealthResult]::new() $Result.BaseUrl = $BaseUrl $Result.Healthy = $AllHealthy $Result.Checks = @($Checks) $Result.AverageMs = $AvgMs $Result.FreeTierKeyPoolSize = $PoolSize $Result.Timestamp = (Get-Date).ToString('o') $Result } |