Public/Test-AzureHealth.ps1

# Copyright (c) 2026 Jeffrey Snover. All rights reserved.
# Licensed under the MIT License. See LICENSE file in the project root.

function Test-AzureHealth {
    <#
    .SYNOPSIS
        Tests health of Azure infrastructure backing the Taxonomy Editor.
    .DESCRIPTION
        Checks Azure platform status, Container App responsiveness, and
        key dependent services (status.azure.com). Requires no Azure CLI
        login — all checks use public HTTP endpoints.
 
        When -UseCLI is specified AND 'az' is installed, also queries the
        Container App's provisioning state and replica count via Azure CLI.
    .PARAMETER BaseUrl
        The Container App URL. Default: production taxonomy-editor.
    .PARAMETER ResourceGroup
        Azure resource group name (used with -UseCLI).
    .PARAMETER AppName
        Container App name (used with -UseCLI).
    .PARAMETER UseCLI
        Use Azure CLI for deeper Container App diagnostics.
    .PARAMETER TimeoutSec
        HTTP request timeout in seconds. Default: 10.
    .EXAMPLE
        Test-AzureHealth
    .EXAMPLE
        Test-AzureHealth -UseCLI -ResourceGroup ai-triad -AppName taxonomy-editor
    .LINK
        Show-AITriadHelp
    .LINK
        Get-ContainerAppRevision
    .LINK
        Remove-StaleContainerImages
    .LINK
        Get-AzureFlightRecorder
    .LINK
        Test-TaxEditorInfra
    .LINK
        Deploy-TaxEditorInfra
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [string]$BaseUrl = (Get-TaxEditorBaseUrl),

        [Parameter()]
        [string]$ResourceGroup = 'ai-triad',

        [Parameter()]
        [string]$AppName = 'taxonomy-editor',

        [Parameter()]
        [switch]$UseCLI,

        [Parameter()]
        [ValidateRange(1, 120)]
        [int]$TimeoutSec = 10
    )

    Set-StrictMode -Version Latest

    $BaseUrl = $BaseUrl.TrimEnd('/')
    $Checks = [System.Collections.Generic.List[PSObject]]::new()

    # ── Azure Status Page ────────────────────────────────────────────────
    $AzureStatus = Invoke-RemoteCheck -BaseUrl 'https://status.azure.com' -Path '/en-us/status' `
        -TimeoutSec $TimeoutSec -AcceptableStatusCodes @(200, 301, 302, 304)

    $Checks.Add([PSCustomObject]@{
        Check      = 'Azure Status Page'
        Pass       = $AzureStatus.Success
        ResponseMs = $AzureStatus.ResponseMs
        Detail     = if ($AzureStatus.Success) { "Reachable (HTTP $($AzureStatus.StatusCode))" } else { $AzureStatus.Error }
    })

    # ── Container App Liveness ───────────────────────────────────────────
    $AppLiveness = Invoke-RemoteCheck -BaseUrl $BaseUrl -Path '/healthz' `
        -TimeoutSec $TimeoutSec -ExpectedField 'status'

    $Checks.Add([PSCustomObject]@{
        Check      = 'Container App Liveness'
        Pass       = $AppLiveness.Success -and $AppLiveness.Body.status -eq 'healthy'
        ResponseMs = $AppLiveness.ResponseMs
        Detail     = if ($AppLiveness.Success) { $AppLiveness.Body.status } else { $AppLiveness.Error }
    })

    # ── Container App Readiness ──────────────────────────────────────────
    $AppReady = Invoke-RemoteCheck -BaseUrl $BaseUrl -Path '/health' `
        -TimeoutSec $TimeoutSec -ExpectedField 'status'

    $ReadyDetail = if ($AppReady.Success) {
        $parts = @("status=$($AppReady.Body.status)")
        if ($AppReady.Body.PSObject.Properties['ai'] -and $AppReady.Body.ai.PSObject.Properties['geminiKeyConfigured']) {
            $parts += "geminiKey=$($AppReady.Body.ai.geminiKeyConfigured)"
        }
        $parts -join ' | '
    } else { $AppReady.Error }

    $Checks.Add([PSCustomObject]@{
        Check      = 'Container App Readiness'
        Pass       = $AppReady.Success -and $AppReady.Body.status -eq 'ok'
        ResponseMs = $AppReady.ResponseMs
        Detail     = $ReadyDetail
    })

    # ── TLS Certificate ──────────────────────────────────────────────────
    $TlsPass = $false
    $TlsDetail = 'Skipped'
    try {
        $Uri = [Uri]$BaseUrl
        $TcpClient = [System.Net.Sockets.TcpClient]::new()
        $TcpClient.Connect($Uri.Host, 443)
        $SslStream = [System.Net.Security.SslStream]::new($TcpClient.GetStream(), $false)
        $SslStream.AuthenticateAsClient($Uri.Host)
        $Cert = $SslStream.RemoteCertificate
        if ($Cert) {
            $Expiry = [datetime]$Cert.GetExpirationDateString()
            $DaysLeft = ($Expiry - (Get-Date)).Days
            $TlsPass = $DaysLeft -gt 7
            $TlsDetail = "Expires $($Expiry.ToString('yyyy-MM-dd')) ($DaysLeft days)"
        }
        $SslStream.Dispose()
        $TcpClient.Dispose()
    }
    catch {
        $TlsDetail = $_.Exception.Message
    }

    $Checks.Add([PSCustomObject]@{
        Check      = 'TLS Certificate'
        Pass       = $TlsPass
        ResponseMs = 0
        Detail     = $TlsDetail
    })

    # ── Azure CLI diagnostics (optional) ─────────────────────────────────
    if ($UseCLI) {
        $AzCmd = Get-Command az -ErrorAction SilentlyContinue
        if ($AzCmd) {
            try {
                $AppJson = & az containerapp show -g $ResourceGroup -n $AppName --output json 2>$null
                if ($LASTEXITCODE -eq 0 -and $AppJson) {
                    $App = $AppJson | ConvertFrom-Json
                    $ProvState = $App.properties.provisioningState
                    $RunState = $App.properties.runningStatus
                    $Checks.Add([PSCustomObject]@{
                        Check      = 'ACA Provisioning State'
                        Pass       = $ProvState -eq 'Succeeded'
                        ResponseMs = 0
                        Detail     = "provisioningState=$ProvState"
                    })
                    if ($RunState) {
                        $Checks.Add([PSCustomObject]@{
                            Check      = 'ACA Running Status'
                            Pass       = $RunState -eq 'Running'
                            ResponseMs = 0
                            Detail     = "runningStatus=$RunState"
                        })
                    }

                    # ── Active Revision Health ───────────────────────────
                    try {
                        $RevJson = & az containerapp revision list -g $ResourceGroup -n $AppName --output json 2>$null
                        if ($LASTEXITCODE -eq 0 -and $RevJson) {
                            $Revisions = @($RevJson | ConvertFrom-Json)
                            $ActiveRevs = @($Revisions | Where-Object {
                                $_.PSObject.Properties['properties'] -and
                                $_.properties.PSObject.Properties['active'] -and
                                $_.properties.active -eq $true
                            })
                            foreach ($Rev in $ActiveRevs) {
                                $RevName = if ($Rev.PSObject.Properties['name']) { $Rev.name } else { 'unknown' }
                                $HealthState = 'Unknown'
                                if ($Rev.properties.PSObject.Properties['healthState']) {
                                    $HealthState = $Rev.properties.healthState
                                }
                                $ProvError = $null
                                if ($Rev.properties.PSObject.Properties['provisioningError']) {
                                    $ProvError = $Rev.properties.provisioningError
                                }
                                $RevDetail = "revision=$RevName healthState=$HealthState"
                                if ($ProvError) { $RevDetail += " provisioningError=$ProvError" }
                                $Checks.Add([PSCustomObject]@{
                                    Check      = 'ACA Active Revision Health'
                                    Pass       = $HealthState -eq 'Healthy' -and -not $ProvError
                                    ResponseMs = 0
                                    Detail     = $RevDetail
                                })
                            }
                        }
                    }
                    catch {
                        $Checks.Add([PSCustomObject]@{
                            Check      = 'ACA Active Revision Health'
                            Pass       = $false
                            ResponseMs = 0
                            Detail     = "revision list failed: $($_.Exception.Message)"
                        })
                    }

                    # ── Replica Count ────────────────────────────────────
                    $MinReplicas = 1
                    if ($App.properties.template.PSObject.Properties['scale'] -and
                        $App.properties.template.scale.PSObject.Properties['minReplicas']) {
                        $MinReplicas = [int]$App.properties.template.scale.minReplicas
                    }

                    $ActiveRevision = $null
                    if ($App.properties.PSObject.Properties['latestRevisionName']) {
                        $ActiveRevision = $App.properties.latestRevisionName
                    }

                    if ($ActiveRevision) {
                        try {
                            $ReplicaJson = & az containerapp replica list -g $ResourceGroup -n $AppName --revision $ActiveRevision --output json 2>$null
                            if ($LASTEXITCODE -eq 0 -and $ReplicaJson) {
                                $Replicas = @($ReplicaJson | ConvertFrom-Json)
                                $RunningCount = @($Replicas | Where-Object {
                                    $_.PSObject.Properties['properties'] -and
                                    $_.properties.PSObject.Properties['runningState'] -and
                                    $_.properties.runningState -eq 'Running'
                                }).Count
                                $ReplicaPass = $RunningCount -ge $MinReplicas
                                $Checks.Add([PSCustomObject]@{
                                    Check      = 'ACA Replica Count'
                                    Pass       = $ReplicaPass
                                    ResponseMs = 0
                                    Detail     = "running=$RunningCount/minReplicas=$MinReplicas"
                                })

                                # ── Container Pull Status ────────────────
                                $BadContainers = [System.Collections.Generic.List[string]]::new()
                                foreach ($Replica in $Replicas) {
                                    $ReplicaName = if ($Replica.PSObject.Properties['name']) { $Replica.name } else { 'unknown' }
                                    $Containers = @()
                                    if ($Replica.PSObject.Properties['properties'] -and
                                        $Replica.properties.PSObject.Properties['containers']) {
                                        $Containers = @($Replica.properties.containers)
                                    }
                                    foreach ($Container in $Containers) {
                                        $State = 'Unknown'
                                        if ($Container.PSObject.Properties['runningState']) {
                                            $State = $Container.runningState
                                        }
                                        if ($State -ne 'Running') {
                                            $CName = if ($Container.PSObject.Properties['name']) { $Container.name } else { 'unnamed' }
                                            $BadContainers.Add("$ReplicaName/$CName=$State")
                                        }
                                    }
                                }
                                $ContainerPass = $BadContainers.Count -eq 0
                                $ContainerDetail = if ($ContainerPass) { "All containers Running ($($Replicas.Count) replicas)" }
                                                   else { $BadContainers -join ', ' }
                                $Checks.Add([PSCustomObject]@{
                                    Check      = 'ACA Container Pull Status'
                                    Pass       = $ContainerPass
                                    ResponseMs = 0
                                    Detail     = $ContainerDetail
                                })
                            }
                        }
                        catch {
                            $Checks.Add([PSCustomObject]@{
                                Check      = 'ACA Replica Count'
                                Pass       = $false
                                ResponseMs = 0
                                Detail     = "replica list failed: $($_.Exception.Message)"
                            })
                        }
                    }
                }
                else {
                    $Checks.Add([PSCustomObject]@{
                        Check      = 'ACA CLI Query'
                        Pass       = $false
                        ResponseMs = 0
                        Detail     = 'az containerapp show failed — verify login and resource group'
                    })
                }
            }
            catch {
                $Checks.Add([PSCustomObject]@{
                    Check      = 'ACA CLI Query'
                    Pass       = $false
                    ResponseMs = 0
                    Detail     = $_.Exception.Message
                })
            }
        }
        else {
            $Checks.Add([PSCustomObject]@{
                Check      = 'ACA CLI Query'
                Pass       = $false
                ResponseMs = 0
                Detail     = 'Azure CLI (az) not found — install from https://aka.ms/installazurecli'
            })
        }
    }

    # ── Summary ──────────────────────────────────────────────────────────
    $AllPass = @($Checks | Where-Object { -not $_.Pass }).Count -eq 0

    [PSCustomObject]@{
        Platform  = 'Azure'
        Healthy   = $AllPass
        Checks    = @($Checks)
        Timestamp = (Get-Date).ToString('o')
    }
}