Public/Get-AzLocalClusterUpdateReadiness.ps1
|
function Get-AzLocalClusterUpdateReadiness { <# .SYNOPSIS Assesses update readiness across Azure Local clusters and reports available updates. .DESCRIPTION This function queries Azure Local clusters and reports their update readiness state, available updates, and provides summary statistics to help plan update deployments. Output includes: - Which clusters are in "Ready" state for updates - Which updates are available for each cluster - Summary totals showing the most common applicable update version Results are displayed on screen and optionally exported to CSV, JSON, or JUnit XML. .PARAMETER ClusterNames An array of Azure Local cluster names to assess. .PARAMETER ClusterResourceIds An array of full Azure Resource IDs for the clusters to assess. .PARAMETER ScopeByUpdateRingTag When specified, finds clusters by the 'UpdateRing' tag via Azure Resource Graph. Must be used together with -UpdateRingValue. .PARAMETER UpdateRingValue The value of the 'UpdateRing' tag to match when using -ScopeByUpdateRingTag. .PARAMETER ResourceGroupName The resource group containing the clusters (only used with -ClusterNames). .PARAMETER SubscriptionId The Azure subscription ID. If not specified, uses the current az CLI subscription. .PARAMETER ApiVersion The API version to use. Defaults to "2025-10-01". .PARAMETER ExportPath Path to export the results. Format is auto-detected from extension (.csv, .json, .xml) unless -ExportFormat is specified. - .csv = Standard CSV format - .json = JSON format with summary statistics - .xml = JUnit XML format for CI/CD pipeline integration (Azure DevOps, GitHub Actions, Jenkins, etc.) .PARAMETER ExportFormat Export format: Auto (default - detect from extension), Csv, Json, or JUnitXml. .EXAMPLE # Assess all clusters with a specific UpdateRing tag value Get-AzLocalClusterUpdateReadiness -ScopeByUpdateRingTag -UpdateRingValue "Wave1" .EXAMPLE # Assess specific clusters and export to CSV Get-AzLocalClusterUpdateReadiness -ClusterNames @("Cluster01", "Cluster02") -ExportPath "C:\Reports\readiness.csv" .EXAMPLE # Export to JUnit XML for CI/CD pipelines Get-AzLocalClusterUpdateReadiness -ScopeByUpdateRingTag -UpdateRingValue "Wave1" -ExportPath "C:\Reports\readiness.xml" .EXAMPLE # Assess clusters by Resource ID Get-AzLocalClusterUpdateReadiness -ClusterResourceIds @("/subscriptions/xxx/resourceGroups/RG1/providers/Microsoft.AzureStackHCI/clusters/Cluster01") .NOTES Author: Neil Bird, Microsoft. #> [CmdletBinding(DefaultParameterSetName = 'ByName')] [OutputType([PSCustomObject[]])] param( [Parameter(Mandatory = $true, ParameterSetName = 'ByName')] [string[]]$ClusterNames, [Parameter(Mandatory = $true, ParameterSetName = 'ByResourceId')] [string[]]$ClusterResourceIds, [Parameter(Mandatory = $true, ParameterSetName = 'ByTag')] [switch]$ScopeByUpdateRingTag, [ValidatePattern('^(\*\*\*|[A-Za-z0-9_-]{1,64}(;[A-Za-z0-9_-]{1,64})*)$')] [Parameter(Mandatory = $true, ParameterSetName = 'ByTag')] [string]$UpdateRingValue, [Parameter(Mandatory = $false, ParameterSetName = 'ByName')] [string]$ResourceGroupName, [Parameter(Mandatory = $false, ParameterSetName = 'ByName')] [Parameter(Mandatory = $false, ParameterSetName = 'ByResourceId')] [Parameter(Mandatory = $false, ParameterSetName = 'ByTag')] [string]$SubscriptionId, [Parameter(Mandatory = $false)] [string]$ApiVersion = $script:DefaultApiVersion, [Parameter(Mandatory = $false)] [string]$ExportPath, [Parameter(Mandatory = $false)] [ValidateSet('Auto', 'Csv', 'Json', 'JUnitXml')] [string]$ExportFormat = 'Auto', [Parameter(Mandatory = $false)] [switch]$PassThru ) # Pre-flight: Validate export path is writable before expensive operations Write-Log -Message "" -Level Info # Verify Azure CLI is installed and logged in Test-AzCliAvailable | Out-Null try { $null = az account show 2>$null if ($LASTEXITCODE -ne 0) { throw "Azure CLI is not logged in. Please run 'az login' first." } Write-Log -Message "Azure CLI authentication verified" -Level Success } catch { Write-Log -Message "Azure CLI is not logged in. Please run 'az login' first." -Level Error return } # Ensure resource-graph extension is installed (single-callsite for all # parameter sets - the readiness cmdlet is fully ARG-driven from v0.7.68). if (-not (Install-AzGraphExtension)) { Write-Error "Failed to install Azure CLI 'resource-graph' extension. Please install manually: az extension add --name resource-graph" return } # Build list of clusters to process $clustersToProcess = @() if ($PSCmdlet.ParameterSetName -eq 'ByTag') { Write-Log -Message "Querying Azure Resource Graph for clusters with tag 'UpdateRing' = '$UpdateRingValue'..." -Level Info # Build Azure Resource Graph query - use single line to avoid escaping issues with az CLI. # v0.7.68: project the full `properties` bag and `tags` so the downstream # readiness computation can read status / connectivityStatus / tags without # an additional ARM REST round trip per cluster. $ringFilter = ConvertTo-AzLocalUpdateRingKqlFilter -UpdateRingValue $UpdateRingValue $argQuery = "resources | where type =~ 'microsoft.azurestackhci/clusters' $ringFilter | project id, name, resourceGroup, subscriptionId, tags, properties" try { $argParams = @{ Query = $argQuery } if ($SubscriptionId) { $argParams['SubscriptionId'] = $SubscriptionId } $clusterRows = Invoke-AzResourceGraphQuery @argParams if (-not $clusterRows -or $clusterRows.Count -eq 0) { Write-Log -Message "No clusters found with tag 'UpdateRing' = '$UpdateRingValue'" -Level Warning return } Write-Log -Message "Found $($clusterRows.Count) cluster(s) matching tag criteria" -Level Success foreach ($cluster in $clusterRows) { $clustersToProcess += @{ ResourceId = $cluster.id Name = $cluster.name ResourceGroup = $cluster.resourceGroup SubscriptionId = $cluster.subscriptionId Tags = $cluster.tags Properties = $cluster.properties } } } catch { Write-Log -Message "Error querying Azure Resource Graph: $_" -Level Error return } } elseif ($PSCmdlet.ParameterSetName -eq 'ByResourceId') { # v0.7.68: resolve every supplied ResourceId with a single ARG batch # lookup so we can pick up tags and properties (status / connectivityStatus) # in one round trip, mirroring the ByTag projection. $idListKql = ($ClusterResourceIds | ForEach-Object { "'$($_.ToLower())'" }) -join ',' $argQuery = "resources | where type =~ 'microsoft.azurestackhci/clusters' | where tolower(id) in~ ($idListKql) | project id, name, resourceGroup, subscriptionId, tags, properties" try { $argParams = @{ Query = $argQuery } if ($SubscriptionId) { $argParams['SubscriptionId'] = $SubscriptionId } $clusterRows = Invoke-AzResourceGraphQuery @argParams } catch { Write-Log -Message "Error resolving cluster resource IDs via Azure Resource Graph: $_" -Level Error return } $foundIds = @{} foreach ($cluster in @($clusterRows)) { $foundIds[$cluster.id.ToLower()] = $cluster } foreach ($resourceId in $ClusterResourceIds) { $key = $resourceId.ToLower() if ($foundIds.ContainsKey($key)) { $cluster = $foundIds[$key] $clustersToProcess += @{ ResourceId = $cluster.id Name = $cluster.name ResourceGroup = $cluster.resourceGroup SubscriptionId = $cluster.subscriptionId Tags = $cluster.tags Properties = $cluster.properties } } else { $clusterRgName = ($resourceId -split '/resourceGroups/')[1] -split '/' | Select-Object -First 1 $clusterSubId = ($resourceId -split '/subscriptions/')[1] -split '/' | Select-Object -First 1 $clustersToProcess += @{ ResourceId = $resourceId Name = ($resourceId -split '/')[-1] ResourceGroup = $clusterRgName SubscriptionId = $clusterSubId Tags = $null Properties = $null NotFound = $true } } } } else { # ByName - resolve names to resource IDs via a single ARG batch lookup # (v0.7.68). Replaces the previous per-name Get-AzLocalClusterInfo # ARM REST loop. $nameListKql = ($ClusterNames | ForEach-Object { "'$($_.ToLower())'" }) -join ',' $rgFilter = '' if ($ResourceGroupName) { $rgFilter = "| where tolower(resourceGroup) =~ '$($ResourceGroupName.ToLower())'" } $argQuery = "resources | where type =~ 'microsoft.azurestackhci/clusters' | where tolower(name) in~ ($nameListKql) $rgFilter | project id, name, resourceGroup, subscriptionId, tags, properties" try { $argParams = @{ Query = $argQuery } if ($SubscriptionId) { $argParams['SubscriptionId'] = $SubscriptionId } $clusterRows = Invoke-AzResourceGraphQuery @argParams } catch { Write-Log -Message "Error resolving cluster names via Azure Resource Graph: $_" -Level Error return } $foundNames = @{} foreach ($cluster in @($clusterRows)) { $foundNames[$cluster.name.ToLower()] = $cluster } foreach ($name in $ClusterNames) { $key = $name.ToLower() if ($foundNames.ContainsKey($key)) { $cluster = $foundNames[$key] $clustersToProcess += @{ ResourceId = $cluster.id Name = $cluster.name ResourceGroup = $cluster.resourceGroup SubscriptionId = $cluster.subscriptionId Tags = $cluster.tags Properties = $cluster.properties } } else { Write-Log -Message "Cluster '$name' not found - skipping" -Level Warning } } } if (-not $clustersToProcess -or $clustersToProcess.Count -eq 0) { Write-Log -Message "No clusters resolved for readiness assessment." -Level Warning return } Write-Log -Message "" -Level Info Write-Log -Message "Assessing $($clustersToProcess.Count) cluster(s)..." -Level Info Write-Log -Message "" -Level Info # Collect results # Use Generic.List to avoid the O(n^2) cost of += array growth at fleet scale. $results = [System.Collections.Generic.List[object]]::new() $updateVersionCounts = @{} # Per-cluster readiness computation - v0.7.68 ARG-first design. # # The pre-0.7.68 implementation fanned out to Start-Job workers; each job # made three ARM REST calls per cluster (cluster GET, updateSummaries GET, # updates GET), yielding 3N round trips and significant runspace overhead # on fleets of dozens of clusters. The current design issues two batched # Azure Resource Graph queries (updatesummaries + updates) against every # input cluster in one round trip each - the cluster resource itself was # already pulled into $clustersToProcess during discovery - and then runs # the readiness/recommendation logic inline against the cached data. No # background jobs, no -ThrottleLimit knob. $idListKql = ($clustersToProcess | ForEach-Object { "'$($_.ResourceId.ToLower())'" }) -join ',' # ARG #1: per-cluster update summaries. $summariesKql = "extensibilityresources | where type =~ 'microsoft.azurestackhci/clusters/updatesummaries' | extend ids = split(id, '/') | extend ClusterResourceId_ = tolower(strcat('/subscriptions/', tostring(ids[2]), '/resourceGroups/', tostring(ids[4]), '/providers/Microsoft.AzureStackHCI/clusters/', tostring(ids[8]))) | where ClusterResourceId_ in~ ($idListKql) | project id, name, properties, ClusterResourceId_" try { $argParams = @{ Query = $summariesKql } if ($SubscriptionId) { $argParams['SubscriptionId'] = $SubscriptionId } $summaryRows = Invoke-AzResourceGraphQuery @argParams } catch { Write-Log -Message "Azure Resource Graph query for update summaries failed: $($_.Exception.Message)" -Level Error return } Write-Log -Message "Returned $(@($summaryRows).Count) update-summary record(s) via Azure Resource Graph" -Level Success # ARG #2: per-cluster available updates. $updatesKql = "extensibilityresources | where type =~ 'microsoft.azurestackhci/clusters/updates' | extend ids = split(id, '/') | extend ClusterName_ = tostring(ids[8]), UpdateName_ = tostring(ids[10]) | extend ClusterResourceId_ = tolower(strcat('/subscriptions/', tostring(ids[2]), '/resourceGroups/', tostring(ids[4]), '/providers/Microsoft.AzureStackHCI/clusters/', ClusterName_)) | where ClusterResourceId_ in~ ($idListKql) | project name, properties, ClusterResourceId_, UpdateName_" try { $argParams = @{ Query = $updatesKql } if ($SubscriptionId) { $argParams['SubscriptionId'] = $SubscriptionId } $updateRows = Invoke-AzResourceGraphQuery @argParams } catch { Write-Log -Message "Azure Resource Graph query for available updates failed: $($_.Exception.Message)" -Level Error return } Write-Log -Message "Returned $(@($updateRows).Count) available-update record(s) across $($clustersToProcess.Count) cluster(s) via Azure Resource Graph" -Level Success # Index update summaries by lowercased cluster id (one summary per cluster). $summaryByCluster = @{} foreach ($row in @($summaryRows)) { $summaryByCluster[[string]$row.ClusterResourceId_] = $row } # Index available-update rows by lowercased cluster id (N updates per cluster). $updatesByCluster = @{} foreach ($row in @($updateRows)) { $key = [string]$row.ClusterResourceId_ if (-not $updatesByCluster.ContainsKey($key)) { $updatesByCluster[$key] = [System.Collections.Generic.List[object]]::new() } $updatesByCluster[$key].Add($row) | Out-Null } # Synthesise a fake ARM-shaped updateSummary object so the existing # Get-HealthCheckFailureSummary helper (which reads .properties.healthCheckResult) # keeps working without modification. foreach ($cluster in $clustersToProcess) { $clusterName = $cluster.Name $key = $cluster.ResourceId.ToLower() Write-Host " Checking: $clusterName..." -ForegroundColor Gray -NoNewline if ($cluster.NotFound) { Write-Host ' Not Found' -ForegroundColor Red $results.Add([PSCustomObject]@{ ClusterName = $clusterName ClusterResourceId = $cluster.ResourceId ResourceGroup = $cluster.ResourceGroup SubscriptionId = $cluster.SubscriptionId ClusterState = 'Not Found' UpdateState = 'N/A' HealthState = 'N/A' CurrentVersion = '' CurrentSbeVersion = '' ReadyForUpdate = $false AvailableUpdates = '' ReadyUpdates = '' HasPrerequisiteUpdates = '' SBEDependency = '' RecommendedUpdate = '' HealthCheckFailures = '' BlockingReasons = '' UpdateWindow = '' UpdateExclusions = '' }) | Out-Null continue } try { $clusterProps = $cluster.Properties $clusterTags = $cluster.Tags $summaryRow = if ($summaryByCluster.ContainsKey($key)) { $summaryByCluster[$key] } else { $null } $sumProps = if ($summaryRow) { $summaryRow.properties } else { $null } $availableUpdates = @() if ($updatesByCluster.ContainsKey($key)) { $availableUpdates = @($updatesByCluster[$key]) } $updateState = if ($sumProps -and $sumProps.state) { [string]$sumProps.state } else { 'Unknown' } $readyUpdates = @($availableUpdates | Where-Object { $_.properties.state -in $script:ReadyStates }) $prereqUpdates = @($availableUpdates | Where-Object { $_.properties.state -in $script:PrereqStates }) # Build legacy ARM-shaped update objects with a .name property so # Get-LatestUpdateByYYMM (which sorts on .name parsing) keeps working. $availableUpdateNames = ($availableUpdates | ForEach-Object { [string]$_.UpdateName_ }) -join '; ' $readyUpdateNames = ($readyUpdates | ForEach-Object { [string]$_.UpdateName_ }) -join '; ' $prereqUpdateNames = ($prereqUpdates | ForEach-Object { [string]$_.UpdateName_ }) -join '; ' # SBE dependency surface from prereq SBE updates (unchanged business rule). $sbeDependencyInfo = '' foreach ($pu in $prereqUpdates) { $puProps = $pu.properties if ($puProps.packageType -eq 'SBE' -and $puProps.additionalProperties) { $addProps = ConvertTo-AzLocalAdditionalProperties -InputObject $puProps.additionalProperties if ($addProps) { $sbeParts = @() if ($addProps.SBEPublisher) { $sbeParts += "Publisher: $($addProps.SBEPublisher)" } if ($addProps.SBEFamily) { $sbeParts += "Family: $($addProps.SBEFamily)" } if ($sbeParts.Count -gt 0) { $sbeDependencyInfo = "$([string]$pu.UpdateName_): $($sbeParts -join '; ')" } } } } # Recommended update selection - identical to legacy logic but takes # ARG-shaped objects. Get-LatestUpdateByYYMM accepts any object with # a .name property; we wrap each row so .name aligns with UpdateName_. $wrapForLatest = { param($rows) @($rows | ForEach-Object { [PSCustomObject]@{ name = [string]$_.UpdateName_ properties = $_.properties } }) } $recommendedUpdate = '' $counted = $null $isUpToDateState = $updateState -in @('UpToDate', 'AppliedSuccessfully') $allInstalled = ($availableUpdates.Count -gt 0) -and ` -not ($availableUpdates | Where-Object { $_.properties.state -ne 'Installed' }) if ($readyUpdates.Count -gt 0) { $latestReady = Get-LatestUpdateByYYMM -Updates (& $wrapForLatest $readyUpdates) $recommendedUpdate = $latestReady.name $counted = $recommendedUpdate } elseif (-not $isUpToDateState -and -not $allInstalled -and $availableUpdates.Count -gt 0) { $nonInstalled = @($availableUpdates | Where-Object { $_.properties.state -ne 'Installed' }) if ($nonInstalled.Count -gt 0) { $latestAvailable = Get-LatestUpdateByYYMM -Updates (& $wrapForLatest $nonInstalled) $recommendedUpdate = $latestAvailable.name } } $isReady = ($updateState -in (@('UpdateAvailable') + $script:ReadyStates)) -and ($readyUpdates.Count -gt 0) # Health state + failure summary - re-use the existing helper by # passing the ARG-shaped summary row (it reads .properties.healthCheckResult # which is identical shape via ARG). $healthState = if ($sumProps -and $sumProps.healthState) { [string]$sumProps.healthState } else { 'Unknown' } $healthCheckFailures = '' if ($summaryRow -and $healthState -notin @('Success', 'Unknown')) { $healthCheckFailures = Get-HealthCheckFailureSummary -UpdateSummary $summaryRow } # Readiness gates (unchanged). $blockingReasons = @() if ($healthCheckFailures -and ($healthCheckFailures -match '\[Critical\]')) { $blockingReasons += 'CriticalHealthCheck' } $clusterStatus = if ($clusterProps -and $clusterProps.PSObject.Properties['status']) { [string]$clusterProps.status } else { '' } if ($clusterStatus -and $clusterStatus -ne 'ConnectedRecently') { $blockingReasons += $clusterStatus } if ($isReady -and $blockingReasons.Count -gt 0) { $isReady = $false $counted = $null } # Installed versions (Solution + SBE) from updateSummary. $currentVersion = '' $currentSbeVersion = '' if ($sumProps) { if ($sumProps.PSObject.Properties['currentVersion']) { $currentVersion = [string]$sumProps.currentVersion } if ($sumProps.PSObject.Properties['packageVersions'] -and $sumProps.packageVersions) { $sbePkgs = @($sumProps.packageVersions | Where-Object { $_.packageType -eq 'SBE' -and $_.version }) if ($sbePkgs.Count -gt 0) { $latestSbe = $sbePkgs | Sort-Object -Property @{ Expression = { if ($_.PSObject.Properties['lastUpdated'] -and $_.lastUpdated) { try { [datetime]$_.lastUpdated } catch { [datetime]::MinValue } } else { [datetime]::MinValue } } }, @{ Expression = { try { [version]($_.version -replace '[^0-9.]', '') } catch { [version]'0.0.0.0' } } } -Descending | Select-Object -First 1 if ($latestSbe -and $latestSbe.version) { $currentSbeVersion = [string]$latestSbe.version } } } } # Coloured per-cluster status line. if ($blockingReasons.Count -gt 0) { Write-Host " Blocked ($($blockingReasons -join ','))" -ForegroundColor Red } elseif ($isReady) { Write-Host " Ready ($recommendedUpdate)" -ForegroundColor Green } elseif ($allInstalled) { Write-Host ' UpToDate' -ForegroundColor Gray } elseif ($prereqUpdates.Count -gt 0 -and $readyUpdates.Count -eq 0) { Write-Host ' Has Prerequisite (SBE update required)' -ForegroundColor Yellow } elseif ($updateState -eq 'UpdateInProgress') { Write-Host ' Update In Progress' -ForegroundColor Yellow } elseif ($readyUpdates.Count -eq 0 -and $availableUpdates.Count -gt 0) { Write-Host ' Updates Downloading' -ForegroundColor Yellow } elseif ($healthState -in @('Failure', 'Warning')) { $c = if ($healthState -eq 'Failure') { 'Red' } else { 'Yellow' } Write-Host " $updateState ($healthState)" -ForegroundColor $c } else { Write-Host " $updateState" -ForegroundColor Gray } # Tally only non-blocked ready recommendations. if ($counted) { if ($updateVersionCounts.ContainsKey($counted)) { $updateVersionCounts[$counted]++ } else { $updateVersionCounts[$counted] = 1 } } $uw = if ($clusterTags) { Get-TagValue -Tags $clusterTags -Name $script:UpdateWindowTagName } else { $null } $ue = if ($clusterTags) { Get-TagValue -Tags $clusterTags -Name $script:UpdateExclusionsTagName } else { $null } $results.Add([PSCustomObject]@{ ClusterName = $clusterName ClusterResourceId = $cluster.ResourceId ResourceGroup = $cluster.ResourceGroup SubscriptionId = $cluster.SubscriptionId ClusterState = $clusterStatus UpdateState = $updateState HealthState = $healthState CurrentVersion = $currentVersion CurrentSbeVersion = $currentSbeVersion ReadyForUpdate = $isReady AvailableUpdates = $availableUpdateNames ReadyUpdates = $readyUpdateNames HasPrerequisiteUpdates = $prereqUpdateNames SBEDependency = $sbeDependencyInfo RecommendedUpdate = $recommendedUpdate HealthCheckFailures = $healthCheckFailures BlockingReasons = ($blockingReasons -join '; ') UpdateWindow = if ($uw) { $uw } else { '' } UpdateExclusions = if ($ue) { $ue } else { '' } }) | Out-Null } catch { Write-Host " Error: $($_.Exception.Message)" -ForegroundColor Red $results.Add([PSCustomObject]@{ ClusterName = $clusterName ClusterResourceId = $cluster.ResourceId ResourceGroup = $cluster.ResourceGroup SubscriptionId = $cluster.SubscriptionId ClusterState = 'Error' UpdateState = 'Error' HealthState = 'Error' CurrentVersion = '' CurrentSbeVersion = '' ReadyForUpdate = $false AvailableUpdates = '' ReadyUpdates = '' HasPrerequisiteUpdates = '' SBEDependency = '' RecommendedUpdate = '' HealthCheckFailures = $_.Exception.Message BlockingReasons = '' UpdateWindow = '' UpdateExclusions = '' }) | Out-Null } } # Display Summary Write-Log -Message "" -Level Info Write-Log -Message "========================================" -Level Header Write-Log -Message "Summary" -Level Header Write-Log -Message "========================================" -Level Header $totalClusters = $results.Count $readyClusters = @($results | Where-Object { $_.ReadyForUpdate -eq $true }).Count $notReadyClusters = $totalClusters - $readyClusters $inProgressClusters = @($results | Where-Object { $_.UpdateState -eq "UpdateInProgress" }).Count $prereqClusters = @($results | Where-Object { $_.HasPrerequisiteUpdates -ne "" }).Count $blockedClusters = @($results | Where-Object { $_.PSObject.Properties['BlockingReasons'] -and $_.BlockingReasons -ne "" }).Count Write-Log -Message "" -Level Info Write-Log -Message "Total Clusters Assessed: $totalClusters" -Level Info Write-Log -Message "Ready for Update: $readyClusters" -Level Success Write-Log -Message "Not Ready / Other State: $notReadyClusters" -Level $(if ($notReadyClusters -gt 0) { "Warning" } else { "Info" }) Write-Log -Message "Update In Progress: $inProgressClusters" -Level $(if ($inProgressClusters -gt 0) { "Warning" } else { "Info" }) if ($blockedClusters -gt 0) { Write-Log -Message "Blocked by Readiness Gate: $blockedClusters (see BlockingReasons column)" -Level Error } if ($prereqClusters -gt 0) { Write-Log -Message "Blocked by SBE Prereq: $prereqClusters" -Level Warning } # Show SBE dependency details for clusters with HasPrerequisite updates $clustersWithSBEDeps = @($results | Where-Object { $_.SBEDependency -ne "" }) if ($clustersWithSBEDeps.Count -gt 0) { Write-Log -Message "" -Level Info Write-Log -Message "Clusters Blocked by SBE Prerequisites:" -Level Warning Write-Log -Message " These clusters have updates that require a Solution Builder Extension (SBE) update from the hardware vendor before they can proceed." -Level Warning foreach ($dep in $clustersWithSBEDeps) { Write-Log -Message " $($dep.ClusterName): $($dep.SBEDependency)" -Level Warning } } # Show health state breakdown $healthFailures = @($results | Where-Object { $_.HealthState -eq "Failure" }).Count $healthWarnings = @($results | Where-Object { $_.HealthState -eq "Warning" }).Count if ($healthFailures -gt 0 -or $healthWarnings -gt 0) { Write-Log -Message "" -Level Info Write-Log -Message "Health Check Issues:" -Level Header if ($healthFailures -gt 0) { Write-Log -Message " Critical Failures: $healthFailures" -Level Error } if ($healthWarnings -gt 0) { Write-Log -Message " Warnings: $healthWarnings" -Level Warning } } # Show most common update versions if ($updateVersionCounts.Count -gt 0) { Write-Log -Message "" -Level Info Write-Log -Message "Available Update Versions (clusters ready to install):" -Level Header $sortedVersions = $updateVersionCounts.GetEnumerator() | Sort-Object -Property Value -Descending foreach ($version in $sortedVersions) { if ($readyClusters -gt 0) { $percentage = [math]::Round(($version.Value / $readyClusters) * 100, 1) Write-Log -Message " $($version.Key): $($version.Value) cluster(s) ($percentage%)" -Level Info } else { Write-Log -Message " $($version.Key): $($version.Value) cluster(s)" -Level Info } } $mostCommonVersion = ($sortedVersions | Select-Object -First 1).Key Write-Log -Message "" -Level Info Write-Log -Message "Most Common Applicable Update: $mostCommonVersion" -Level Success } # Display results table Write-Log -Message "" -Level Info Write-Log -Message "Detailed Results:" -Level Header $results | Format-Table ClusterName, ResourceGroup, CurrentVersion, UpdateState, HealthState, ReadyForUpdate, RecommendedUpdate -AutoSize | Out-Host # Show clusters with health check failures $clustersWithHealthIssues = @($results | Where-Object { $_.HealthCheckFailures -ne "" }) if ($clustersWithHealthIssues.Count -gt 0) { Write-Log -Message "" -Level Info Write-Log -Message "Clusters with Health Check Issues:" -Level Warning foreach ($cluster in $clustersWithHealthIssues) { $issueLevel = if ($cluster.HealthState -eq "Failure") { "Error" } else { "Warning" } Write-Log -Message " $($cluster.ClusterName): $($cluster.HealthCheckFailures)" -Level $issueLevel } } # Export if path specified if ($ExportPath) { try { $ExportPath = Resolve-SafeOutputPath -Path $ExportPath $exportDir = Split-Path -Path $ExportPath -Parent if ($exportDir -and -not (Test-Path $exportDir)) { New-Item -ItemType Directory -Path $exportDir -Force | Out-Null } $format = Get-ExportFormat -Path $ExportPath -ExportFormat $ExportFormat # Transform results for JUnit-compatible format $junitResults = $results | ForEach-Object { $statusVal = if ($_.ReadyForUpdate -eq $true) { 'Ready' } elseif ($_.PSObject.Properties['BlockingReasons'] -and $_.BlockingReasons -ne '') { 'Blocked' } elseif ($_.HealthState -eq 'Failure') { 'Failed' } else { 'Skipped' } [PSCustomObject]@{ ClusterName = $_.ClusterName Status = $statusVal Message = "CurrentVersion: $($_.CurrentVersion), CurrentSbeVersion: $($_.CurrentSbeVersion), UpdateState: $($_.UpdateState), HealthState: $($_.HealthState), RecommendedUpdate: $($_.RecommendedUpdate), BlockingReasons: $($_.BlockingReasons)" UpdateName = $_.RecommendedUpdate CurrentState = $_.UpdateState } } switch ($format) { 'Csv' { $results | ConvertTo-SafeCsvCollection | Export-Csv -Path $ExportPath -NoTypeInformation -Encoding UTF8 Write-Log -Message "Results exported to CSV: $ExportPath" -Level Success } 'Json' { $exportData = @{ Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" TotalClusters = $totalClusters ClustersReady = $readyClusters ClustersNotReady = $notReadyClusters Results = $results } Write-Utf8NoBomFile -Path $ExportPath -Content ($exportData | ConvertTo-Json -Depth 10) Write-Log -Message "Results exported to JSON: $ExportPath" -Level Success } 'JUnitXml' { Export-ResultsToJUnitXml -Results $junitResults -OutputPath $ExportPath ` -TestSuiteName "AzureLocalClusterReadiness" -OperationType "ReadinessCheck" Write-Log -Message "Results exported to JUnit XML (CI/CD compatible): $ExportPath" -Level Success } } } catch { Write-Log -Message "Failed to export results: $($_.Exception.Message)" -Level Error } } Write-Log -Message "" -Level Info if ($PassThru) { return $results } } |