Public/Get-AzLocalFleetHealthOverview.ps1
|
function Get-AzLocalFleetHealthOverview { <# .SYNOPSIS Returns one row per Azure Local cluster summarising health state, update state, current version, SBE version, Azure connectivity, and the age (in days) of the last 24-hour health-check run, so an operator can see the whole fleet's readiness at a glance. .DESCRIPTION Returns an ARG-first fleet health summary, one row per cluster: joins the `microsoft.azurestackhci/clusters` resource (for cluster identity, tags, node count, Azure connectivity) with its `updateSummaries/default` child (for healthState, update state, currentVersion, healthCheckDate, packageVersions[]). Solution Builder Extension (SBE) version is rolled up from packageVersions by `mv-expand` + `maxif(packageType =~ 'SBE')` so callers get one SbeVersion column per cluster regardless of how many package rows the cluster reports. Where the existing `Get-AzLocalFleetHealthFailures` cmdlet focuses on individual failing checks, this cmdlet answers "how is the fleet doing overall?" - one row per cluster, sorted with the staleest health-check result first (HealthResultsAgeDays desc) so out-of-date check runs surface immediately. The query runs through the module's `Invoke-AzResourceGraphQuery` helper, which transparently pages for fleets larger than 1000 clusters. .PARAMETER SubscriptionId Optional. Limit the query to a specific Azure subscription ID. Omit to query every subscription the caller can read. .PARAMETER UpdateRingTag Optional UpdateRing tag filter. Accepts the same syntax as the rest of the module: a single ring (e.g. 'Wave1'), semicolon-delimited list (e.g. 'Wave1;Wave2'), or the literal '***' wildcard. ValidatePattern rejects '*', '**', '****' so a single-character typo cannot accidentally widen the scope. .PARAMETER ExportPath Optional. Path to export the result. Format is auto-detected from the file extension (.csv or .json). .PARAMETER PassThru Return objects to the pipeline even when -ExportPath is specified. .OUTPUTS PSCustomObject[] with the columns (in this order): ClusterName, ClusterPortalUrl, HealthStatus, UpdateStatus, CurrentVersion, SbeVersion, AzureConnection, LastChecked, HealthResultsAgeDays, ResourceGroup, NodeCount, SubscriptionId. HealthStatus values (normalised from ARG `properties.healthState` to an operator-friendly vocabulary): Healthy (Success), Critical (Failure), Warning, In progress (InProgress), Unknown (empty or NotKnown). Any other raw value the platform may add in future is passed through unchanged so it is still visible. .EXAMPLE Get-AzLocalFleetHealthOverview .EXAMPLE Get-AzLocalFleetHealthOverview -UpdateRingTag Wave1 -ExportPath .\fleet-overview.csv .NOTES Author: Neil Bird, Microsoft. Added: v0.7.70 Module: AzLocal.UpdateManagement #> [CmdletBinding()] [OutputType([PSCustomObject[]])] param( [Parameter(Mandatory = $false)] [string]$SubscriptionId, [Parameter(Mandatory = $false)] [ValidatePattern('^(\*\*\*|[A-Za-z0-9_-]{1,64}(;[A-Za-z0-9_-]{1,64})*)$')] [string]$UpdateRingTag, [Parameter(Mandatory = $false)] [string]$ExportPath, [Parameter(Mandatory = $false)] [switch]$PassThru ) if ($ExportPath) { try { Test-ExportPathWritable -Path $ExportPath | Out-Null } catch { throw "ExportPath is not writable: $($_.Exception.Message)" } } # Optional UpdateRing tag filter (KQL fragment) injected into the # cluster-side branch of the join so the filter is evaluated server- # side rather than client-side. $ringFilter = '' if ($UpdateRingTag) { $ringFilter = ConvertTo-AzLocalUpdateRingKqlFilter -UpdateRingValue $UpdateRingTag -TagAccessor "tostring(tags['UpdateRing'])" } # KQL: join clusters with their updateSummaries/default child, project # the raw packageVersions array (rolled up to SbeVersion client-side # below), compute HealthResultsAgeDays. Both sides of the join # lower-case the resource id so the ARM mixed-case vs the # extensibilityresources path (also mixed-case but built from split # segments) match deterministically. # # v0.7.74: HealthStatus normalises raw ARG `properties.healthState` # (Success / Failure / InProgress / Warning / NotKnown) to the documented # operator-friendly vocabulary the rest of the module + pipeline samples # consume: Healthy / Critical / Warning / In progress / Unknown. Any # future-added raw state passes through unchanged so it is still visible. # # v0.7.76: SBE roll-up moved client-side. The previous implementation # used `mv-expand pkg = properties.packageVersions | summarize ... maxif() # by ClusterResourceIdLower` server-side, but ARG silently caps # `mv-expand` at 128 expanded child rows per parent. While # `packageVersions` has historically been small (~4 entries), there is # no schema-level upper bound, so we now project the raw array and # find the SBE entry in PowerShell. This eliminates the entire class # of silent-truncation bugs for this cmdlet. # # IMPORTANT: keep this wire query lean. The az CLI argument layer truncates # very long single-arg payloads (observed regression in v0.7.73 at ~3.1KB # producing a KQL ParserFailure with token=<EOF>). Do NOT add `//` KQL # comments inside the here-string - document with PowerShell `#` comments # above the assignment instead. $kql = @" resources | where type =~ 'microsoft.azurestackhci/clusters' $ringFilter | extend ClusterResourceIdLower = tolower(tostring(id)) | extend NodeCount = iif(isnull(properties.reportedProperties.nodes), 0, toint(array_length(properties.reportedProperties.nodes))) | extend AzureConnection = tostring(properties.connectivityStatus) | project ClusterName=name, ClusterResourceId=tostring(id), ClusterResourceIdLower, ResourceGroup=tostring(resourceGroup), SubscriptionId=tostring(subscriptionId), NodeCount, AzureConnection | join kind=leftouter ( extensibilityresources | where type =~ 'microsoft.azurestackhci/clusters/updatesummaries' | extend segs = split(id, '/') | extend ClusterResourceIdLower = tolower(strcat('/subscriptions/', segs[2], '/resourceGroups/', segs[4], '/providers/Microsoft.AzureStackHCI/clusters/', segs[8])) | project ClusterResourceIdLower, HealthState = tostring(properties.healthState), UpdateState = tostring(properties.state), CurrentVersion = tostring(properties.currentVersion), LastChecked = todatetime(properties.healthCheckDate), PackageVersions = properties.packageVersions ) on ClusterResourceIdLower | extend HealthResultsAgeDays = iif(isnull(LastChecked), -1, datetime_diff('day', now(), LastChecked)) | extend ClusterPortalUrl = strcat('https://portal.azure.com/#@/resource', ClusterResourceId) | project ClusterName, ClusterPortalUrl, HealthStatus = case(isempty(HealthState),'Unknown', HealthState =~ 'Success','Healthy', HealthState =~ 'Failure','Critical', HealthState =~ 'InProgress','In progress', HealthState =~ 'NotKnown','Unknown', HealthState), UpdateStatus = iif(isempty(UpdateState),'Unknown',UpdateState), CurrentVersion = iif(isempty(CurrentVersion),'(unknown)',CurrentVersion), SbeVersion = '', PackageVersions, AzureConnection = iif(isempty(AzureConnection),'Unknown',AzureConnection), LastChecked, HealthResultsAgeDays, ResourceGroup, NodeCount, SubscriptionId | order by HealthResultsAgeDays desc, ClusterName asc "@ Write-Log -Message "Querying Azure Resource Graph for fleet health overview$(if($UpdateRingTag){", UpdateRingTag=$UpdateRingTag"})..." -Level Info try { $output = if ($SubscriptionId) { Invoke-AzResourceGraphQuery -Query $kql -SubscriptionId $SubscriptionId } else { Invoke-AzResourceGraphQuery -Query $kql } } catch { Write-Log -Message "Resource Graph query failed: $($_.Exception.Message)" -Level Error throw } if (-not $output) { $output = @() } $output = @($output) # v0.7.76: SBE roll-up moved client-side to avoid ARG `mv-expand` # 128-cap. The KQL projects `PackageVersions` as a raw array and # `SbeVersion` as an empty placeholder. We walk the array here to # find the entry with `packageType == 'SBE'` (case-insensitive) and # overwrite SbeVersion with its `version` field. Tests that mock the # already-projected ARG response (no PackageVersions column) are # left alone so backward compatibility is preserved. foreach ($row in $output) { if ($null -eq $row) { continue } $hasPackageVersions = ($row.PSObject -and ($row.PSObject.Properties.Match('PackageVersions').Count -gt 0)) if (-not $hasPackageVersions) { continue } $pkgs = $row.PackageVersions $sbeVersion = '(none)' if ($null -ne $pkgs) { foreach ($pkg in @($pkgs)) { if ($null -eq $pkg) { continue } $type = $null try { $type = $pkg.packageType } catch { $type = $null } if (-not $type) { try { $type = $pkg.PackageType } catch { $type = $null } } if ($type -and ([string]$type).Trim() -ieq 'SBE') { $ver = $null try { $ver = $pkg.version } catch { $ver = $null } if (-not $ver) { try { $ver = $pkg.Version } catch { $ver = $null } } if ($ver) { $sbeVersion = [string]$ver break } } } } if ($row.PSObject.Properties.Match('SbeVersion').Count -gt 0) { $row.SbeVersion = $sbeVersion } else { $row | Add-Member -NotePropertyName 'SbeVersion' -NotePropertyValue $sbeVersion -Force } } # Strip the intermediate PackageVersions column so callers see the # documented schema only. $output = @($output | ForEach-Object { if ($null -eq $_) { return } if ($_.PSObject.Properties.Match('PackageVersions').Count -gt 0) { $_ | Select-Object -Property * -ExcludeProperty PackageVersions } else { $_ } }) Write-Log -Message "Fleet Health Overview: $($output.Count) cluster row(s)." -Level Info # Export if requested. if ($ExportPath) { try { $ExportPath = Resolve-SafeOutputPath -Path $ExportPath $exportDir = Split-Path -Path $ExportPath -Parent if ($exportDir -and -not (Test-Path -Path $exportDir)) { $null = New-Item -ItemType Directory -Path $exportDir -Force } $ext = [System.IO.Path]::GetExtension($ExportPath).ToLower() switch ($ext) { '.json' { Write-Utf8NoBomFile -Path $ExportPath -Content ($output | ConvertTo-Json -Depth 6) Write-Log -Message "Fleet health overview exported to JSON: $ExportPath" -Level Success } default { $output | ConvertTo-SafeCsvCollection | Export-Csv -Path $ExportPath -NoTypeInformation -Force Write-Log -Message "Fleet health overview exported to CSV: $ExportPath" -Level Success } } } catch { Write-Log -Message "Failed to export fleet health overview: $($_.Exception.Message)" -Level Error } } if (-not $ExportPath -or $PassThru) { return , $output } } |