Public/Get-AzureLocalFleetStatusData.ps1
|
function Get-AzureLocalFleetStatusData { <# .SYNOPSIS Collects comprehensive fleet status data from Azure Local clusters with optional parallelism. .DESCRIPTION Performs a single-pass data collection across Azure Local clusters, making only 3 core API calls per cluster (cluster info, update summary, available updates) plus update run queries. Returns a structured PSCustomObject containing readiness, cluster details, update runs, and health check data. This object can be: - Exported to JSON for CI/CD pipeline artifact passing between jobs - Passed to New-AzureLocalFleetStatusHtmlReport via -StatusData to avoid redundant API calls - Used directly for custom reporting or analysis When -ThrottleLimit is greater than 1, splits the cluster list into batches and uses Start-Job for parallel data collection. Each job imports the module and calls this function with -ThrottleLimit 1 for its batch. Results are merged automatically. Note: Azure ARM allows ~200 reads/5 minutes per subscription. With ThrottleLimit 4 and 4 API calls per cluster, parallel execution processes clusters ~4x faster while staying within throttling limits for most fleet sizes. .PARAMETER ClusterResourceIds An array of full Azure Resource IDs for the clusters to collect data for. .PARAMETER ClusterNames An array of Azure Local cluster names to collect data for. .PARAMETER ScopeByUpdateRingTag Find clusters by their 'UpdateRing' tag value via Azure Resource Graph. .PARAMETER UpdateRingValue The value of the 'UpdateRing' tag to match when using -ScopeByUpdateRingTag. .PARAMETER AllClusters Discovers all Azure Local clusters via Azure Resource Graph (limited to 100). .PARAMETER ResourceGroupName Resource group containing the clusters (only used with -ClusterNames). .PARAMETER SubscriptionId Azure subscription ID (defaults to current subscription). .PARAMETER IncludeUpdateRuns Collect latest update run history per cluster. .PARAMETER IncludeHealthDetails Collect detailed health check failure data per cluster. .PARAMETER ThrottleLimit Number of parallel workers for data collection. Default: 4. Set to 1 for sequential collection. Maximum: 8 (to respect ARM throttling). .PARAMETER ExportPath Path to export the collected data as JSON. This JSON artifact can be passed between CI/CD pipeline jobs to avoid redundant API calls. .OUTPUTS PSCustomObject with properties: SchemaVersion, Timestamp, ModuleVersion, Scope, Readiness, ClusterDetails, LatestRuns, HealthResults. .EXAMPLE # Collect data for all clusters (parallel) $data = Get-AzureLocalFleetStatusData -AllClusters -IncludeUpdateRuns -IncludeHealthDetails .EXAMPLE # Export to JSON artifact for CI/CD pipeline Get-AzureLocalFleetStatusData -ScopeByUpdateRingTag -UpdateRingValue "Wave1" -ExportPath "fleet-data.json" .EXAMPLE # Collect data then generate HTML report (no redundant API calls) $data = Get-AzureLocalFleetStatusData -AllClusters -ThrottleLimit 4 -IncludeUpdateRuns -IncludeHealthDetails New-AzureLocalFleetStatusHtmlReport -StatusData $data -OutputPath "report.html" .EXAMPLE # Sequential collection (for debugging or small fleets) $data = Get-AzureLocalFleetStatusData -ClusterResourceIds $ids -ThrottleLimit 1 #> [CmdletBinding(DefaultParameterSetName = 'All')] [OutputType([PSCustomObject])] param( [Parameter(Mandatory = $true, ParameterSetName = 'ByResourceId')] [string[]]$ClusterResourceIds, [Parameter(Mandatory = $true, ParameterSetName = 'ByName')] [string[]]$ClusterNames, [Parameter(Mandatory = $true, ParameterSetName = 'ByTag')] [switch]$ScopeByUpdateRingTag, [ValidatePattern('^[A-Za-z0-9_-]{1,64}$')] [Parameter(Mandatory = $true, ParameterSetName = 'ByTag')] [string]$UpdateRingValue, [Parameter(Mandatory = $false, ParameterSetName = 'All')] [switch]$AllClusters, [Parameter(Mandatory = $false, ParameterSetName = 'ByName')] [string]$ResourceGroupName, [Parameter(Mandatory = $false, ParameterSetName = 'ByName')] [string]$SubscriptionId, [Parameter(Mandatory = $false)] [switch]$IncludeUpdateRuns, [Parameter(Mandatory = $false)] [switch]$IncludeHealthDetails, [Parameter(Mandatory = $false)] [ValidateRange(1, 8)] [int]$ThrottleLimit = 4, # Optional cap on clusters returned by -AllClusters discovery. # Default: 0 (no cap, returns all discovered clusters). Set to a positive integer # to limit the number of clusters included (e.g. for testing or targeted runs). [Parameter(Mandatory = $false)] [ValidateRange(0, 100000)] [int]$MaxClusters = 0, [Parameter(Mandatory = $false)] [string]$ExportPath ) # Pre-flight: Validate export path is writable before expensive operations if ($ExportPath) { try { Test-ExportPathWritable -Path $ExportPath | Out-Null } catch { Write-Warning $_.Exception.Message; return } } # Verify Azure CLI Test-AzCliAvailable | Out-Null try { $null = az account show 2>$null if ($LASTEXITCODE -ne 0) { throw "Not logged in" } } catch { Write-Log -Message "Azure CLI is not logged in. Please run 'az login' first." -Level Error return $null } # Resolve scope to resource IDs $allResourceIds = @() $scopeDescription = "" switch ($PSCmdlet.ParameterSetName) { 'ByTag' { if (-not (Install-AzGraphExtension)) { Write-Log -Message "Failed to install 'resource-graph' extension." -Level Error return $null } $argQuery = "resources | where type =~ 'microsoft.azurestackhci/clusters' | where tags['UpdateRing'] =~ '$($UpdateRingValue -replace "'", "''")' | project id" try { $tagData = Invoke-AzResourceGraphQuery -Query $argQuery } catch { Write-Log -Message "ARG query failed: $($_.Exception.Message)" -Level Error return $null } if (-not $tagData -or $tagData.Count -eq 0) { Write-Log -Message "No clusters found with UpdateRing = '$UpdateRingValue'" -Level Warning; return $null } $allResourceIds = @($tagData | Select-Object -ExpandProperty id) $scopeDescription = "UpdateRing = $UpdateRingValue" } 'ByResourceId' { $allResourceIds = $ClusterResourceIds $scopeDescription = "$($ClusterResourceIds.Count) cluster(s) by Resource ID" } 'ByName' { if (-not $SubscriptionId) { $SubscriptionId = (az account show --query id -o tsv) } foreach ($name in $ClusterNames) { $infoParams = @{ ClusterName = $name; SubscriptionId = $SubscriptionId } if ($ResourceGroupName) { $infoParams['ResourceGroupName'] = $ResourceGroupName } $ci = Get-AzureLocalClusterInfo @infoParams if ($ci -and $ci.id) { $allResourceIds += $ci.id } else { Write-Log -Message "Cluster '$name' not found - skipping" -Level Warning } } $scopeDescription = "$($ClusterNames.Count) cluster(s) by name" } 'All' { $inventory = @(Get-AzureLocalClusterInventory -PassThru) if (-not $inventory -or $inventory.Count -eq 0) { Write-Log -Message "No clusters found." -Level Warning; return $null } if ($MaxClusters -gt 0 -and $inventory.Count -gt $MaxClusters) { Write-Log -Message "Discovered $($inventory.Count) clusters; trimming to first $MaxClusters (-MaxClusters)." -Level Warning $inventory = $inventory | Select-Object -First $MaxClusters } $allResourceIds = @($inventory | Select-Object -ExpandProperty ResourceId) $scopeDescription = "All clusters ($($allResourceIds.Count))" } } if ($allResourceIds.Count -eq 0) { Write-Log -Message "No cluster resource IDs resolved." -Level Warning return $null } Write-Log -Message "Collecting fleet status data for $($allResourceIds.Count) cluster(s) [ThrottleLimit=$ThrottleLimit]..." -Level Info # Determine if parallel execution is warranted $useParallel = ($ThrottleLimit -gt 1) -and ($allResourceIds.Count -gt $ThrottleLimit) $readiness = [System.Collections.Generic.List[object]]::new() $clusterDetails = [System.Collections.Generic.List[object]]::new() $latestRuns = [System.Collections.Generic.List[object]]::new() $healthResults = [System.Collections.Generic.List[object]]::new() # Track clusters whose data could not be collected (failed job / parse error) $failedClusters = [System.Collections.Generic.List[object]]::new() if ($useParallel) { #--- Parallel collection using Start-Job --- $batchSize = [math]::Ceiling($allResourceIds.Count / $ThrottleLimit) $batches = @() for ($i = 0; $i -lt $allResourceIds.Count; $i += $batchSize) { $batches += ,@($allResourceIds[$i..[math]::Min($i + $batchSize - 1, $allResourceIds.Count - 1)]) } Write-Log -Message "Splitting $($allResourceIds.Count) clusters into $($batches.Count) parallel batches of ~$batchSize" -Level Info $modulePath = Join-Path -Path $PSScriptRoot -ChildPath 'AzLocal.UpdateManagement.psm1' # Pre-flight: jobs must be able to re-import this module by path if (-not (Test-Path -LiteralPath $modulePath)) { throw "Parallel collection requires module path '$modulePath' to be reachable by background jobs, but it does not exist. Falling back to sequential mode is not supported here - re-run without -ThrottleLimit > 1 or from a directory where PSScriptRoot resolves correctly." } $incRuns = $IncludeUpdateRuns.IsPresent $incHealth = $IncludeHealthDetails.IsPresent $apiVer = $script:DefaultApiVersion $jobScriptBlock = { param([string[]]$BatchIds, [string]$ApiVer, [bool]$IncRuns, [bool]$IncHealth, [string]$ModPath) Import-Module $ModPath -Force -ErrorAction Stop $params = @{ ClusterResourceIds = $BatchIds ThrottleLimit = 1 } if ($IncRuns) { $params['IncludeUpdateRuns'] = $true } if ($IncHealth) { $params['IncludeHealthDetails'] = $true } $result = Get-AzureLocalFleetStatusData @params $result | ConvertTo-Json -Depth 15 -Compress } $jobs = [System.Collections.Generic.List[object]]::new() # Track which cluster IDs were dispatched to each job so we can report # which clusters are missing data when a job fails. $jobClusterMap = @{} $batchNum = 0 foreach ($batch in $batches) { $batchNum++ Write-Log -Message " Starting batch $batchNum ($($batch.Count) clusters)..." -Level Info $job = Start-Job -ScriptBlock $jobScriptBlock -ArgumentList @($batch, $apiVer, $incRuns, $incHealth, $modulePath) $jobs.Add($job) | Out-Null $jobClusterMap[$job.Id] = $batch } Write-Log -Message "Waiting for $($jobs.Count) parallel jobs to complete..." -Level Info $jobs | Wait-Job | Out-Null foreach ($job in $jobs) { $batchForJob = $jobClusterMap[$job.Id] # Treat any non-Completed terminal state as a job failure. PowerShell jobs # can also enter Stopped (Stop-Job / Ctrl-C) and Disconnected (PSSession # disconnect) states; previously only 'Failed' was caught, leaving these # cases to fall through into Receive-Job and be misdiagnosed as 'no output'. if ($job.State -in @('Failed', 'Stopped', 'Disconnected')) { $reason = if ($job.ChildJobs -and $job.ChildJobs[0]) { $job.ChildJobs[0].JobStateInfo.Reason } else { 'Unknown' } Write-Log -Message " Job $($job.Id) terminated in state '$($job.State)': $reason" -Level Error foreach ($rid in $batchForJob) { $failedClusters.Add([PSCustomObject]@{ ResourceId = $rid ClusterName = ($rid -split '/')[-1] Reason = "Job $($job.State): $reason" }) | Out-Null } continue } $jobOutput = Receive-Job $job -ErrorAction SilentlyContinue if (-not $jobOutput) { Write-Log -Message " Job $($job.Id) returned no output; marking $($batchForJob.Count) cluster(s) as failed" -Level Warning foreach ($rid in $batchForJob) { $failedClusters.Add([PSCustomObject]@{ ResourceId = $rid ClusterName = ($rid -split '/')[-1] Reason = 'Job returned no output' }) | Out-Null } continue } $jobJson = $jobOutput -join "`n" try { $jobData = $jobJson | ConvertFrom-Json -ErrorAction Stop if ($jobData.Readiness) { foreach ($r in @($jobData.Readiness)) { $readiness.Add($r) | Out-Null } } if ($jobData.ClusterDetails) { foreach ($c in @($jobData.ClusterDetails)) { $clusterDetails.Add($c) | Out-Null } } if ($jobData.LatestRuns) { foreach ($l in @($jobData.LatestRuns)) { $latestRuns.Add($l) | Out-Null } } if ($jobData.HealthResults) { foreach ($h in @($jobData.HealthResults)) { $healthResults.Add($h) | Out-Null } } } catch { Write-Log -Message " Failed to parse job $($job.Id) output: $($_.Exception.Message); marking $($batchForJob.Count) cluster(s) as failed" -Level Error foreach ($rid in $batchForJob) { $failedClusters.Add([PSCustomObject]@{ ResourceId = $rid ClusterName = ($rid -split '/')[-1] Reason = "Parse error: $($_.Exception.Message)" }) | Out-Null } } } $jobs | Remove-Job -Force -ErrorAction SilentlyContinue Write-Log -Message "Parallel collection complete: $($readiness.Count) cluster(s) collected, $($failedClusters.Count) failed" -Level Success } else { #--- Sequential collection (single-pass per cluster) --- $apiVer = $script:DefaultApiVersion $clusterIndex = 0 $totalToProcess = $allResourceIds.Count foreach ($rid in $allResourceIds) { $clusterIndex++ $clusterName = ($rid -split '/')[-1] $rgName = ($rid -split '/resourceGroups/')[1] -split '/' | Select-Object -First 1 $subId = ($rid -split '/subscriptions/')[1] -split '/' | Select-Object -First 1 Write-Host " [$clusterIndex/$totalToProcess] $clusterName..." -ForegroundColor Gray -NoNewline try { # API Call 1/3: GET cluster info $clusterInfoUri = "https://management.azure.com${rid}?api-version=$apiVer" $clusterInfo = (Invoke-AzRestJson -Uri $clusterInfoUri).Data if ($LASTEXITCODE -ne 0 -or $null -eq $clusterInfo) { Write-Host " Not Found" -ForegroundColor Red $readiness.Add([PSCustomObject]@{ ClusterName = $clusterName; ResourceGroup = $rgName; SubscriptionId = $subId ClusterState = "Not Found"; UpdateState = "N/A"; HealthState = "N/A" ReadyForUpdate = $false; AvailableUpdates = ""; ReadyUpdates = "" HasPrerequisiteUpdates = ""; SBEDependency = "" RecommendedUpdate = ""; HealthCheckFailures = "" UpdateWindow = ""; UpdateExclusions = "" }) | Out-Null $clusterDetails.Add([PSCustomObject]@{ ClusterName = $clusterName; ResourceGroup = $rgName CurrentVersion = "N/A"; CurrentSbeVersion = "N/A"; NodeCount = "N/A"; ResourceId = $rid }) | Out-Null continue } $clusterState = $clusterInfo.properties.status $nodeCount = "N/A" if ($clusterInfo.properties.reportedProperties.nodes) { $nodeCount = $clusterInfo.properties.reportedProperties.nodes.Count } # API Call 2/3: GET update summary $summaryUri = "https://management.azure.com${rid}/updateSummaries/default?api-version=$apiVer" $updateSummary = (Invoke-AzRestJson -Uri $summaryUri).Data $hasSummary = ($LASTEXITCODE -eq 0 -and $null -ne $updateSummary -and $null -ne $updateSummary.properties) $updateState = if ($hasSummary -and $updateSummary.properties.state) { $updateSummary.properties.state } else { "Unknown" } $healthState = if ($hasSummary -and $updateSummary.properties.healthState) { $updateSummary.properties.healthState } else { "Unknown" } $currentVersion = if ($hasSummary -and $updateSummary.properties.currentVersion) { $updateSummary.properties.currentVersion } else { "N/A" } # SBE version lives inside properties.packageVersions[] where # packageType == 'SBE'; pick the newest by lastUpdated then [version]. $currentSbeVersion = "N/A" if ($hasSummary -and $updateSummary.properties.PSObject.Properties['packageVersions'] -and $updateSummary.properties.packageVersions) { $sbePkgs = @($updateSummary.properties.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 } } } # API Call 3/3: GET available updates $updatesUri = "https://management.azure.com${rid}/updates?api-version=$apiVer" $updatesResponse = (Invoke-AzRestJson -Uri $updatesUri).Data $hasUpdates = ($LASTEXITCODE -eq 0 -and $null -ne $updatesResponse -and $null -ne $updatesResponse.value) $availableUpdates = if ($hasUpdates) { @($updatesResponse.value) } else { @() } $readyUpdates = @($availableUpdates | Where-Object { $_.properties.state -in $script:ReadyStates }) $prereqUpdates = @($availableUpdates | Where-Object { $_.properties.state -in $script:PrereqStates }) $availableUpdateNames = ($availableUpdates | ForEach-Object { $_.name }) -join "; " $readyUpdateNames = ($readyUpdates | ForEach-Object { $_.name }) -join "; " $prereqUpdateNames = ($prereqUpdates | ForEach-Object { $_.name }) -join "; " # Extract SBE dependency info for HasPrerequisite/AdditionalContentRequired updates $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 = "$($pu.name): $($sbeParts -join '; ')" } } } } $recommendedUpdate = "" $isUpToDateState = $updateState -in @("UpToDate", "AppliedSuccessfully") if ($readyUpdates.Count -gt 0) { $latestReady = Get-LatestUpdateByYYMM -Updates $readyUpdates $recommendedUpdate = $latestReady.name } elseif (-not $isUpToDateState -and $availableUpdates.Count -gt 0) { $latestAvailable = Get-LatestUpdateByYYMM -Updates $availableUpdates $recommendedUpdate = $latestAvailable.name } $isReady = ($updateState -in (@("UpdateAvailable") + $script:ReadyStates)) -and ($readyUpdates.Count -gt 0) $healthCheckFailures = "" if ($hasSummary -and $healthState -notin @("Success", "Unknown")) { $healthCheckFailures = Get-HealthCheckFailureSummary -UpdateSummary $updateSummary } $readiness.Add([PSCustomObject]@{ ClusterName = $clusterName; ResourceGroup = $rgName; SubscriptionId = $subId ClusterState = $clusterState; UpdateState = $updateState; HealthState = $healthState ReadyForUpdate = $isReady; AvailableUpdates = $availableUpdateNames ReadyUpdates = $readyUpdateNames; HasPrerequisiteUpdates = $prereqUpdateNames SBEDependency = $sbeDependencyInfo; RecommendedUpdate = $recommendedUpdate HealthCheckFailures = $healthCheckFailures UpdateWindow = if ($clusterInfo.tags -and $clusterInfo.tags.$($script:UpdateWindowTagName)) { $clusterInfo.tags.$($script:UpdateWindowTagName) } else { "" } UpdateExclusions = if ($clusterInfo.tags -and $clusterInfo.tags.$($script:UpdateExclusionsTagName)) { $clusterInfo.tags.$($script:UpdateExclusionsTagName) } else { "" } }) | Out-Null $clusterDetails.Add([PSCustomObject]@{ ClusterName = $clusterName; ResourceGroup = $rgName CurrentVersion = $currentVersion; CurrentSbeVersion = $currentSbeVersion NodeCount = $nodeCount; ResourceId = $rid }) | Out-Null # Update runs (reuse already-fetched update list). # Collect ALL runs across all available updates and group them by UpdateName so the # reporting row for each update version reflects the TOTAL elapsed time across all # attempts (re-runs after failures), not just the most recent attempt. if ($IncludeUpdateRuns) { $allRunsForCluster = [System.Collections.Generic.List[object]]::new() foreach ($update in $availableUpdates) { $runsUri = "https://management.azure.com${rid}/updates/$($update.name)/updateRuns?api-version=$apiVer" $runsResult = (Invoke-AzRestJson -Uri $runsUri).Data if ($LASTEXITCODE -eq 0 -and $runsResult.value) { foreach ($run in $runsResult.value) { [void]$allRunsForCluster.Add($run) } } } if ($allRunsForCluster.Count -gt 0) { # Group by update name extracted from run.id $runsByUpdate = @{} foreach ($r in $allRunsForCluster) { $uName = '' if ($r.id -match '/updates/([^/]+)/updateRuns/([^/]+)$') { $uName = $matches[1] } elseif ($r.name) { $uName = $r.name } if (-not $runsByUpdate.ContainsKey($uName)) { $runsByUpdate[$uName] = [System.Collections.Generic.List[object]]::new() } [void]$runsByUpdate[$uName].Add($r) } # Pick ONLY the most-recently-started update (one row per cluster) so the # report isn't cluttered with historical update versions. Attempts within # that latest update version are still aggregated below. $latestUpdateName = $null $latestUpdateStart = [datetime]::MinValue foreach ($k in $runsByUpdate.Keys) { foreach ($r in $runsByUpdate[$k]) { if ($r.properties.timeStarted) { $ts = [datetime]$r.properties.timeStarted if ($ts -gt $latestUpdateStart) { $latestUpdateStart = $ts; $latestUpdateName = $k } } } } if ($latestUpdateName) { $uName = $latestUpdateName $attempts = @($runsByUpdate[$uName]) # Sort attempts by timeStarted descending; [0] = latest, [-1] = earliest $sorted = @($attempts | Sort-Object { if ($_.properties.timeStarted) { [datetime]$_.properties.timeStarted } else { [datetime]::MinValue } } -Descending) $latestRun = $sorted[0] $earliestRun = $sorted[-1] $latestProps = $latestRun.properties # Sum durations across all attempts. For InProgress attempts, use "now" as end. $totalSpan = [TimeSpan]::Zero $hasInProgress = $false foreach ($a in $attempts) { $ap = $a.properties if (-not $ap.timeStarted) { continue } $aStart = [datetime]$ap.timeStarted if ($ap.lastUpdatedTime) { $totalSpan = $totalSpan.Add(([datetime]$ap.lastUpdatedTime) - $aStart) } elseif ($ap.state -eq 'InProgress') { $totalSpan = $totalSpan.Add((Get-Date) - $aStart) $hasInProgress = $true } } $runDuration = '' if ($totalSpan.TotalSeconds -ge 1) { # HH:MM:SS (total hours as left component so 25h+ stays readable) $fmt = '{0:00}:{1:00}:{2:00}' -f [int][Math]::Floor($totalSpan.TotalHours), $totalSpan.Minutes, $totalSpan.Seconds $runDuration = if ($hasInProgress) { "$fmt (running)" } else { $fmt } } $currentStep = ''; $currentStepDetail = ''; $runProgress = '' if ($latestProps.progress -and $latestProps.progress.steps) { $steps = $latestProps.progress.steps $runProgress = "$(@($steps | Where-Object { $_.status -eq 'Success' }).Count)/$(@($steps).Count) steps" $ipStep = $steps | Where-Object { $_.status -eq 'InProgress' } | Select-Object -First 1 $fStep = $steps | Where-Object { $_.status -in @('Error','Failed') } | Select-Object -First 1 if ($ipStep) { $currentStep = $ipStep.name } elseif ($fStep) { $currentStep = "$($fStep.name) (FAILED)" } $currentStepDetail = Get-CurrentStepPath -Steps $steps -IncludeErrorMessage if ([string]::IsNullOrWhiteSpace($currentStepDetail)) { $currentStepDetail = $currentStep } } $runId = '' if ($latestRun.id -match '/updates/([^/]+)/updateRuns/([^/]+)$') { $runId = $matches[2] } else { $runId = $latestRun.name } # StartTime reflects when work FIRST began on this update (earliest attempt) $firstStartDisplay = if ($earliestRun.properties.timeStarted) { ([datetime]$earliestRun.properties.timeStarted).ToString('yyyy-MM-dd HH:mm') } else { '' } # EndTime reflects when the LATEST attempt finished (or blank if still running). # Uses the central Get-AzLocalRunEndTime helper so this path can't drift from # the per-run formatter. $latestEndDt = Get-AzLocalRunEndTime -props $latestProps $latestEndDisplay = if ($latestEndDt) { $latestEndDt.ToString('yyyy-MM-dd HH:mm') } else { '' } $latestRuns.Add([PSCustomObject]@{ ClusterName = $clusterName; UpdateName = $uName; RunId = $runId State = $latestProps.state StartTime = $firstStartDisplay EndTime = $latestEndDisplay Duration = $runDuration; Progress = $runProgress CurrentStep = $currentStep; CurrentStepDetail = $currentStepDetail Location = $latestProps.location Attempts = $attempts.Count }) | Out-Null } } } # Health details (from already-fetched update summary) if ($IncludeHealthDetails) { $failures = @() if ($hasSummary -and $updateSummary.properties.healthCheckResult) { foreach ($check in $updateSummary.properties.healthCheckResult) { if ($check.status -eq "Failed") { $sev = if ($check.severity) { $check.severity } else { "Unknown" } $displayName = if ($check.displayName) { $check.displayName } elseif ($check.name) { ($check.name -split '/')[0] } else { "Unknown" } $failures += [PSCustomObject]@{ ClusterName = $clusterName; CheckName = $displayName; Severity = $sev Description = if ($check.description) { $check.description } else { "" } Remediation = if ($check.remediation) { $check.remediation } else { "" } TargetResourceName = if ($check.targetResourceName) { $check.targetResourceName } else { "" } Timestamp = if ($check.timestamp) { $check.timestamp } else { "" } } } } } $critCount = @($failures | Where-Object { $_.Severity -eq "Critical" }).Count $warnCount = @($failures | Where-Object { $_.Severity -eq "Warning" }).Count $infoCount = @($failures | Where-Object { $_.Severity -eq "Informational" }).Count $healthResults.Add([PSCustomObject]@{ ClusterName = $clusterName; HealthState = $healthState; Passed = ($critCount -eq 0) CriticalCount = $critCount; WarningCount = $warnCount; InfoCount = $infoCount Failures = $failures }) | Out-Null } # Status output if ($isReady) { Write-Host " Ready" -ForegroundColor Green } elseif ($prereqUpdates.Count -gt 0 -and $readyUpdates.Count -eq 0) { Write-Host " Has Prerequisite" -ForegroundColor Yellow } elseif ($updateState -eq "UpdateInProgress") { Write-Host " In Progress" -ForegroundColor Yellow } elseif ($updateState -in @("UpToDate", "AppliedSuccessfully")) { Write-Host " Up to Date" -ForegroundColor Green } elseif ($healthState -eq "Failure") { Write-Host " Health Failure" -ForegroundColor Red } else { Write-Host " $updateState" -ForegroundColor Gray } } catch { Write-Host " Error: $($_.Exception.Message)" -ForegroundColor Red $readiness.Add([PSCustomObject]@{ ClusterName = $clusterName; ResourceGroup = $rgName; SubscriptionId = $subId ClusterState = "Error"; UpdateState = "Error"; HealthState = "Error" ReadyForUpdate = $false; AvailableUpdates = ""; ReadyUpdates = "" HasPrerequisiteUpdates = ""; SBEDependency = "" RecommendedUpdate = ""; HealthCheckFailures = $_.Exception.Message UpdateWindow = ""; UpdateExclusions = "" }) | Out-Null $clusterDetails.Add([PSCustomObject]@{ ClusterName = $clusterName; ResourceGroup = $rgName CurrentVersion = "N/A"; NodeCount = "N/A"; ResourceId = $rid }) | Out-Null } } Write-Log -Message "Sequential collection complete: $($readiness.Count) cluster(s)" -Level Success } # Build result object with stable schema $result = [PSCustomObject]@{ SchemaVersion = "1.0" Timestamp = Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ" ModuleVersion = $script:ModuleVersion Scope = $scopeDescription TotalClusters = $readiness.Count Readiness = @($readiness) ClusterDetails = @($clusterDetails) LatestRuns = @($latestRuns) HealthResults = @($healthResults) FailedClusters = @($failedClusters) } # Export to JSON if path specified if ($ExportPath) { $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 } Write-Utf8NoBomFile -Path $ExportPath -Content ($result | ConvertTo-Json -Depth 15) Write-Log -Message "Fleet status data exported to: $ExportPath" -Level Success } return $result } |