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) } } } |