Public/Export-AzLocalFleetConnectivityStatusReport.ps1

function Export-AzLocalFleetConnectivityStatusReport {
    <#
    .SYNOPSIS
        Runs the Step.4 Fleet Connectivity Status workload:
        Get-AzLocalFleetConnectivityStatus + per-scope severity
        classification + JUnit XML + markdown summary + step outputs
        for the v0.8.5 thin-YAML Step.4 pipeline.
 
    .DESCRIPTION
        Phase 1 (v0.8.5) of the thin-YAML refactor. Condenses the inline
        `run: |` body of the v0.8.4 Step.4_fleet-connectivity-status.yml
        (GitHub Actions + Azure DevOps) into a single cmdlet call so the
        per-platform yml shrinks to a few lines and the workload becomes
        unit-testable against synthetic Get-AzLocalFleetConnectivityStatus
        results.
 
        The cmdlet:
 
          1. Resolves the output directory (defaults to './reports' on
             GitHub Actions / Local, or `$env:BUILD_ARTIFACTSTAGINGDIRECTORY`
             on Azure DevOps - matching the v0.8.4 yml).
          2. Calls `Get-AzLocalFleetConnectivityStatus` once (with the
             optional -SubscriptionId scope) to get all 7 row-sets:
             ClusterRows, ArcSummary, NonConnectedMachines, NicIssues,
             NicAll, NicStats, ArbRows. The cmdlet's native -ExportPath
             emitter writes the 7 CSV + 7 JSON files to the output dir.
          3. Classifies severity per row using the v0.8.4 rules:
             Critical = confirmed disconnected/offline/expired,
             Warning = expired/partial/unknown, Pass = healthy.
          4. Builds a JUnit XML report (4 suites: Cluster, Arc Agent,
             Physical NIC, ARB) via the shared
             `New-AzLocalPipelineJUnitXml` Private helper. When the
             fleet is fully green, a single synthetic
             'Fleet Connectivity' suite with one passing testcase is
             emitted so dorny renders a green banner instead of an
             empty file (matches v0.8.4 byte-for-byte).
          5. Calls `New-AzLocalFleetConnectivityStatusSummary` (Public
             renderer shared with the v0.8.4 ADO yml) to build the
             markdown step summary.
          6. Pushes the markdown via `Add-AzLocalPipelineStepSummary`.
          7. Emits 12 step outputs via `Set-AzLocalPipelineOutput`
             (lowercase snake_case per v0.8.5 convention):
             cluster_total, cluster_fail, arc_total, arc_fail,
             nic_total, nic_fail, nic_all_total, arb_total, arb_fail,
             total_failures, critical_count, warning_count.
 
        Internal reuse (per the v0.8.5 thin-YAML consistency contract):
          * `Get-AzLocalFleetConnectivityStatus` for all data fetch.
          * `New-AzLocalPipelineJUnitXml` for JUnit XML (replaces the
            inline 200-line StringBuilder of the v0.8.4 yml).
          * `New-AzLocalFleetConnectivityStatusSummary` for markdown.
          * `Add-AzLocalPipelineStepSummary` for the rendered markdown.
          * `Set-AzLocalPipelineOutput` for the step outputs.
          * `Get-AzLocalPipelineHost` is implicit (the above branch on it).
 
    .PARAMETER OutputDirectory
        Directory to write artifacts into. Created if it does not exist.
        Defaults to './reports' (GH / Local) or
        `$env:BUILD_ARTIFACTSTAGINGDIRECTORY` (Azure DevOps).
 
    .PARAMETER SubscriptionFilter
        Optional comma-delimited subscription-id list. When set, the
        first entry is forwarded as -SubscriptionId to
        `Get-AzLocalFleetConnectivityStatus` (matches the v0.8.4 yml's
        INPUT_SUBSCRIPTION_FILTER semantics). When empty, the cmdlet
        scans every subscription visible to the federated identity.
 
    .PARAMETER JUnitXmlFileName
        Filename for the connectivity-status JUnit XML report.
        Default 'fleet-connectivity-status.xml'.
 
    .PARAMETER SummaryFileName
        Per-task markdown summary filename used by
        `Add-AzLocalPipelineStepSummary` on Azure DevOps and Local hosts.
        Default 'fleet-connectivity-summary.md'.
 
    .PARAMETER InstalledModuleVersion
        Optional [string] used in the markdown footer
        ('Generated by AzLocal.UpdateManagement v<x>'). Forwarded to
        `New-AzLocalFleetConnectivityStatusSummary`.
 
    .PARAMETER PassThru
        When set, returns a single PSCustomObject summarising the run
        (counts, the raw data row-sets, the filtered Fail row-sets,
        and the JUnit/summary file paths). Without -PassThru the
        cmdlet emits nothing to the pipeline; the artifacts and step
        outputs are still produced.
 
    .OUTPUTS
        Nothing by default. When -PassThru is set, a single PSCustomObject
        with: ClusterTotal, ClusterFail, ArcTotal, ArcFail, NicTotal,
        NicFail, NicAllTotal, ArbTotal, ArbFail, TotalFailures,
        CriticalCount, WarningCount, ClusterRows, ArcSummary,
        NonConnectedMachines, NicIssues, NicAll, NicStats, ArbRows,
        ClusterFailures, ArcFailures, NicFailures, ArbFailures,
        JUnitXmlPath, SummaryPath.
 
    .EXAMPLE
        Export-AzLocalFleetConnectivityStatusReport -PassThru
 
    .EXAMPLE
        Export-AzLocalFleetConnectivityStatusReport `
            -SubscriptionFilter '00000000-0000-0000-0000-000000000001' `
            -OutputDirectory './my-reports'
 
    .NOTES
        Module: AzLocal.UpdateManagement (v0.8.5+)
        Roadmap: Step.4 - Fleet Connectivity Status (observability gate).
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$OutputDirectory,

        [Parameter(Mandatory = $false)]
        [AllowEmptyString()]
        [AllowNull()]
        [string]$SubscriptionFilter,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$JUnitXmlFileName = 'fleet-connectivity-status.xml',

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$SummaryFileName = 'fleet-connectivity-summary.md',

        [Parameter(Mandatory = $false)]
        [AllowEmptyString()]
        [AllowNull()]
        [string]$InstalledModuleVersion,

        [Parameter(Mandatory = $false)]
        [switch]$PassThru
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $pipelineHost = Get-AzLocalPipelineHost

    if (-not $OutputDirectory) {
        if ($pipelineHost -eq 'AzureDevOps' -and $env:BUILD_ARTIFACTSTAGINGDIRECTORY) {
            $OutputDirectory = $env:BUILD_ARTIFACTSTAGINGDIRECTORY
        }
        else {
            $OutputDirectory = './reports'
        }
    }
    if (-not (Test-Path -LiteralPath $OutputDirectory)) {
        New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
    }

    $xmlPath = Join-Path -Path $OutputDirectory -ChildPath $JUnitXmlFileName

    # ---- Optional subscription scope --------------------------------------
    $invokeArgs = @{ ExportPath = $OutputDirectory; PassThru = $true }
    if ($SubscriptionFilter) {
        $subList = $SubscriptionFilter -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
        if ($subList -and @($subList).Count -gt 0) {
            $invokeArgs['SubscriptionId'] = [string]$subList[0]
            Write-Host "Subscription filter active (first sub): $($subList[0])"
        }
    }
    if (-not $invokeArgs.ContainsKey('SubscriptionId')) {
        Write-Host 'Subscription filter empty - scanning all subscriptions visible to the federated identity.'
    }

    Write-Host '========================================'
    Write-Host 'Fleet Connectivity Status Collection'
    Write-Host '========================================'

    # Collect all 7 connectivity row-sets via the module cmdlet.
    # Runs 5 ARG queries, processes results client-side, and exports
    # 7 CSV + 7 JSON files to $OutputDirectory.
    $data = Get-AzLocalFleetConnectivityStatus @invokeArgs

    $clusterRows = @($data.ClusterRows)
    $arcSummary  = @($data.ArcSummary)
    $arcRows     = @($data.NonConnectedMachines)
    $nicRows     = @($data.NicIssues)
    $nicAllRows  = @($data.NicAll)
    $nicStats    = @($data.NicStats)
    $arbRows     = @($data.ArbRows)

    Write-Host 'Fleet Connectivity Collection complete:'
    Write-Host " Clusters : $($clusterRows.Count) total"
    Write-Host " Arc agents: $($arcSummary.Count) distinct status value(s); $($arcRows.Count) NOT 'Connected'"
    Write-Host " Physical NIC issues: $($nicRows.Count) (Disconnected with non-APIPA IP)"
    Write-Host " NIC inventory (full): $($nicAllRows.Count) total NICs across $($nicStats.Count) NicType+NicStatus groups"
    Write-Host " ARB appliances: $($arbRows.Count)"

    # ---- Severity classifiers ---------------------------------------------
    # Case-insensitive. Healthy = Pass; confirmed bad = Critical;
    # everything else (Unknown / NotSpecified / partial) = Warning.
    $getClusterSeverity = { param($s) switch -Regex ([string]$s) { '^(Connected|ConnectedRecently)$' { 'Pass'; break } '^(Disconnected|Expired|Offline|Error)$' { 'Critical'; break } default { 'Warning' } } }
    $getArcSeverity     = { param($s) switch -Regex ([string]$s) { '^(Connected)$'                   { 'Pass'; break } '^(Disconnected|Expired|Offline|Error)$' { 'Critical'; break } default { 'Warning' } } }
    $getNicSeverity     = { param($s) switch -Regex ([string]$s) { '^(Connected|Up)$'                { 'Pass'; break } '^(Disconnected|Down|Disabled)$'         { 'Critical'; break } default { 'Warning' } } }
    $getArbSeverity     = { param($s) switch -Regex ([string]$s) { '^(Running)$'                     { 'Pass'; break } '^(Offline|Failed)$'                    { 'Critical'; break } default { 'Warning' } } }

    $clusterFail = @($clusterRows | Where-Object { (& $getClusterSeverity $_.ConnectivityStatus) -ne 'Pass' })
    $arcFail     = @($arcRows     | Where-Object { (& $getArcSeverity     $_.AgentStatus)         -ne 'Pass' })
    $nicFail     = @($nicRows     | Where-Object { (& $getNicSeverity     $_.NicStatus)           -ne 'Pass' })
    $arbFail     = @($arbRows     | Where-Object { (& $getArbSeverity     $_.ArbStatus)           -ne 'Pass' })

    $totalFailures = $clusterFail.Count + $arcFail.Count + $nicFail.Count + $arbFail.Count
    $criticalCount = @($clusterFail | Where-Object { (& $getClusterSeverity $_.ConnectivityStatus) -eq 'Critical' }).Count `
                   + @($arcFail     | Where-Object { (& $getArcSeverity     $_.AgentStatus)         -eq 'Critical' }).Count `
                   + @($nicFail     | Where-Object { (& $getNicSeverity     $_.NicStatus)           -eq 'Critical' }).Count `
                   + @($arbFail     | Where-Object { (& $getArbSeverity     $_.ArbStatus)           -eq 'Critical' }).Count
    $warningCount  = $totalFailures - $criticalCount

    # ---- Build JUnit suites via the shared helper -------------------------
    $suites = @()

    if ($clusterFail.Count -gt 0) {
        $cases = foreach ($r in $clusterFail) {
            $sev    = & $getClusterSeverity $r.ConnectivityStatus
            $portal = "https://portal.azure.com/#@/resource$($r.ClusterId)"
            @{
                Name      = "Cluster '$($r.ClusterName)' :: ConnectivityStatus=$($r.ConnectivityStatus)"
                ClassName = [string]$r.ClusterName
                Failure   = @{
                    Type    = $sev
                    Message = "Cluster connectivity is '$($r.ConnectivityStatus)' (cluster.status='$($r.ClusterStatus)')"
                    Body    = "ResourceGroup: $($r.ResourceGroup)`nSubscriptionId: $($r.SubscriptionId)`nClusterPortalUrl: $portal"
                }
                Properties = [ordered]@{
                    ClusterName        = [string]$r.ClusterName
                    ClusterResourceId  = [string]$r.ClusterId
                    UpdateName         = "ClusterConnectivity=$($r.ConnectivityStatus)"
                    Status             = $sev
                    FailureReason      = "Cluster connectivity '$($r.ConnectivityStatus)'"
                    Severity           = $sev
                    ClusterPortalUrl   = $portal
                    TargetResourceName = [string]$r.ClusterName
                    TargetResourceType = 'microsoft.azurestackhci/clusters'
                }
            }
        }
        $suites += @{ Name = 'Cluster Connectivity'; TestCases = @($cases) }
    }

    if ($arcFail.Count -gt 0) {
        $cases = foreach ($r in $arcFail) {
            $sev    = & $getArcSeverity $r.AgentStatus
            $portal = "https://portal.azure.com/#@/resource$($r.MachineId)"
            @{
                Name      = "Machine '$($r.NodeName)' :: Status=$($r.AgentStatus)"
                ClassName = [string]$r.ClusterName
                Failure   = @{
                    Type    = $sev
                    Message = "Machine '$($r.NodeName)' has Arc agent status '$($r.AgentStatus)' (last status change: $($r.LastStatusChange))"
                    Body    = "ClusterName: $($r.ClusterName)`n" +
                              "AgentStatus: $($r.AgentStatus)`n" +
                              "OS SKU: $($r.OsSku)`n" +
                              "OS Version: $($r.OsVersion)`n" +
                              "Cluster Version: $($r.ClusterVersion)`n" +
                              "AgentVersion: $($r.AgentVersion)`n" +
                              "Disconnected Since (lastStatusChange): $($r.LastStatusChange)`n" +
                              "ResourceGroup: $($r.ResourceGroup)`n" +
                              "MachineId: $($r.MachineId)"
                }
                Properties = [ordered]@{
                    ClusterName        = [string]$r.ClusterName
                    ClusterResourceId  = [string]$r.ClusterId
                    UpdateName         = "ArcAgent=$($r.AgentStatus) [$($r.NodeName)]"
                    Status             = $sev
                    FailureReason      = "Arc agent '$($r.AgentStatus)' on machine '$($r.NodeName)'"
                    Severity           = $sev
                    ClusterPortalUrl   = $portal
                    TargetResourceName = [string]$r.NodeName
                    TargetResourceType = 'microsoft.hybridcompute/machines'
                }
            }
        }
        $suites += @{ Name = 'Arc Agent Connectivity'; TestCases = @($cases) }
    }

    if ($nicFail.Count -gt 0) {
        $cases = foreach ($r in $nicFail) {
            $sev    = & $getNicSeverity $r.NicStatus
            $portal = "https://portal.azure.com/#@/resource$($r.MachineId)"
            @{
                Name      = "NIC '$($r.NicName)' on '$($r.NodeName)' :: NicStatus=$($r.NicStatus)"
                ClassName = [string]$r.ClusterName
                Failure   = @{
                    Type    = $sev
                    Message = "Physical NIC '$($r.NicName)' on node '$($r.NodeName)' reports status '$($r.NicStatus)'"
                    Body    = "ClusterName: $($r.ClusterName)`nInterface: $($r.InterfaceDescription)`nDriverVersion: $($r.DriverVersion)`nIp4Address: $($r.Ip4Address)`nMachineId: $($r.MachineId)"
                }
                Properties = [ordered]@{
                    ClusterName        = [string]$r.ClusterName
                    ClusterResourceId  = ''
                    UpdateName         = "PhysicalNic=$($r.NicStatus) [$($r.NodeName)/$($r.NicName)]"
                    Status             = $sev
                    FailureReason      = "Physical NIC '$($r.NicName)' status '$($r.NicStatus)' on node '$($r.NodeName)'"
                    Severity           = $sev
                    ClusterPortalUrl   = $portal
                    TargetResourceName = "$($r.NodeName)/$($r.NicName)"
                    TargetResourceType = 'microsoft.azurestackhci/edgedevices/nicDetails'
                }
            }
        }
        $suites += @{ Name = 'Physical NIC Status'; TestCases = @($cases) }
    }

    if ($arbFail.Count -gt 0) {
        $cases = foreach ($r in $arbFail) {
            $sev    = & $getArbSeverity $r.ArbStatus
            $portal = "https://portal.azure.com/#@/resource$($r.ArbId)"
            # ClusterId / ClusterName may be a comma-separated list when the
            # ARB's resource group hosts multiple HCI clusters. The explicit
            # if/else assignment with @() wrap on the VARIABLE is required -
            # `[string[]]$x = if (...) { ... } else { @() }` is NOT sufficient
            # under Set-StrictMode -Version Latest when the inner pipeline yields
            # a single scalar (v0.8.6-fix3 - same fix pattern as Step.4's $matched).
            if ($r.ClusterId) {
                $clusterIdList = @(($r.ClusterId -split ',\s*') | Where-Object { $_ })
            } else {
                $clusterIdList = @()
            }
            $primaryClusterId = if ($clusterIdList.Count -gt 0) { ([string]$clusterIdList[0]).Trim() } else { '' }
            $clusterPortal    = if ($primaryClusterId) { "https://portal.azure.com/#@/resource$primaryClusterId" } else { '' }
            $multiClusterNote = if ($clusterIdList.Count -gt 1) { " (multi-cluster RG; $($clusterIdList.Count) clusters)" } else { '' }
            @{
                Name      = "ARB '$($r.ArbName)' :: ArbStatus=$($r.ArbStatus)"
                ClassName = if ($r.ClusterName) { [string]$r.ClusterName } else { '(no cluster mapping)' }
                Failure   = @{
                    Type    = $sev
                    Message = "Azure Resource Bridge '$($r.ArbName)' is '$($r.ArbStatus)' (days since lastModified=$($r.DaysSinceLastModified))$multiClusterNote"
                    Body    = "ClusterName: $($r.ClusterName)`nArbId: $($r.ArbId)`nLastModified: $($r.LastModified)`nDaysSinceLastModified: $($r.DaysSinceLastModified)`nClusterPortalUrl: $clusterPortal"
                }
                Properties = [ordered]@{
                    ClusterName        = if ($r.ClusterName) { [string]$r.ClusterName } else { '' }
                    ClusterResourceId  = if ($primaryClusterId) { $primaryClusterId } else { '' }
                    UpdateName         = "ARB=$($r.ArbStatus) [$($r.ArbName)]"
                    Status             = $sev
                    FailureReason      = "ARB '$($r.ArbName)' status '$($r.ArbStatus)'$multiClusterNote"
                    Severity           = $sev
                    ClusterPortalUrl   = $clusterPortal
                    TargetResourceName = [string]$r.ArbName
                    TargetResourceType = 'microsoft.resourceconnector/appliances'
                }
            }
        }
        $suites += @{ Name = 'Azure Resource Bridge'; TestCases = @($cases) }
    }

    if ($totalFailures -eq 0) {
        # All-green: emit a single passing testcase so dorny renders a
        # green banner instead of an empty file (matches v0.8.4 yml).
        $suites += @{
            Name      = 'Fleet Connectivity'
            TestCases = @(
                @{ Name = 'No connectivity issues across the fleet' }
            )
        }
    }

    $null = New-AzLocalPipelineJUnitXml `
        -TestSuitesName 'AzureLocalFleetConnectivity' `
        -Suites $suites `
        -OutputPath $xmlPath

    # ---- Markdown summary via the shared renderer -------------------------
    # Arc totals come from the status-summary histogram so the markdown
    # table can show <Connected count> healthy + <total non-connected>.
    $arcTotalMachines = ($arcSummary | Measure-Object -Property Count -Sum).Sum
    if ($null -eq $arcTotalMachines) { $arcTotalMachines = 0 }

    $counts = @{
        ClusterTotal  = $clusterRows.Count
        ClusterFail   = $clusterFail.Count
        ArcTotal      = $arcTotalMachines
        ArcFail       = $arcRows.Count
        NicTotal      = $nicRows.Count
        NicFail       = $nicFail.Count
        NicAllTotal   = $nicAllRows.Count
        ArbTotal      = $arbRows.Count
        ArbFail       = $arbFail.Count
        TotalFailures = $totalFailures
        CriticalCount = $criticalCount
        WarningCount  = $warningCount
    }

    $summaryArgs = @{
        ClusterRows = $clusterRows
        ArcSummary  = $arcSummary
        ArcRows     = $arcRows
        NicRows     = $nicRows
        NicStats    = $nicStats
        ArbRows     = $arbRows
        Counts      = $counts
    }
    $md = New-AzLocalFleetConnectivityStatusSummary @summaryArgs

    Add-AzLocalPipelineStepSummary -Markdown $md -SummaryFileName $SummaryFileName | Out-Null

    # ---- Step outputs (lowercase snake_case, v0.8.5 convention) -----------
    Set-AzLocalPipelineOutput -Name 'cluster_total'   -Value ([string]$clusterRows.Count)
    Set-AzLocalPipelineOutput -Name 'cluster_fail'    -Value ([string]$clusterFail.Count)
    Set-AzLocalPipelineOutput -Name 'arc_total'       -Value ([string]$arcTotalMachines)
    Set-AzLocalPipelineOutput -Name 'arc_fail'        -Value ([string]$arcRows.Count)
    Set-AzLocalPipelineOutput -Name 'nic_total'       -Value ([string]$nicRows.Count)
    Set-AzLocalPipelineOutput -Name 'nic_fail'        -Value ([string]$nicFail.Count)
    Set-AzLocalPipelineOutput -Name 'nic_all_total'   -Value ([string]$nicAllRows.Count)
    Set-AzLocalPipelineOutput -Name 'arb_total'       -Value ([string]$arbRows.Count)
    Set-AzLocalPipelineOutput -Name 'arb_fail'        -Value ([string]$arbFail.Count)
    Set-AzLocalPipelineOutput -Name 'total_failures'  -Value ([string]$totalFailures)
    Set-AzLocalPipelineOutput -Name 'critical_count'  -Value ([string]$criticalCount)
    Set-AzLocalPipelineOutput -Name 'warning_count'   -Value ([string]$warningCount)

    Write-Host ''
    Write-Host "TOTAL FAILURES: $totalFailures (Critical=$criticalCount, Warning=$warningCount)"

    if ($PassThru) {
        return [pscustomobject]@{
            ClusterTotal         = $clusterRows.Count
            ClusterFail          = $clusterFail.Count
            ArcTotal             = $arcTotalMachines
            ArcFail              = $arcRows.Count
            NicTotal             = $nicRows.Count
            NicFail              = $nicFail.Count
            NicAllTotal          = $nicAllRows.Count
            ArbTotal             = $arbRows.Count
            ArbFail              = $arbFail.Count
            TotalFailures        = $totalFailures
            CriticalCount        = $criticalCount
            WarningCount         = $warningCount
            ClusterRows          = $clusterRows
            ArcSummary           = $arcSummary
            NonConnectedMachines = $arcRows
            NicIssues            = $nicRows
            NicAll               = $nicAllRows
            NicStats             = $nicStats
            ArbRows              = $arbRows
            ClusterFailures      = $clusterFail
            ArcFailures          = $arcFail
            NicFailures          = $nicFail
            ArbFailures          = $arbFail
            JUnitXmlPath         = $xmlPath
            SummaryPath          = (Join-Path -Path $OutputDirectory -ChildPath $SummaryFileName)
        }
    }
}