Public/Get-AzureLocalAvailableUpdates.ps1
|
function Get-AzureLocalAvailableUpdates { <# .SYNOPSIS Gets the list of available updates for one or more Azure Local clusters. .DESCRIPTION Retrieves all updates that are available to install on the specified Azure Local cluster(s). Returns update objects containing details such as update name, version, description, and state. Supports multiple input methods: - Single cluster by resource ID (original behavior, returns raw API objects) - Multiple clusters by name or resource ID - All clusters matching an UpdateRing tag value When querying multiple clusters, returns formatted results with export options. .PARAMETER ClusterResourceId The full Azure Resource ID of a single cluster (original behavior). Example: "/subscriptions/xxx/resourceGroups/RG1/providers/Microsoft.AzureStackHCI/clusters/Cluster01" .PARAMETER ClusterNames An array of Azure Local cluster names to query. .PARAMETER ClusterResourceIds An array of full Azure Resource IDs for the clusters to query. .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. .PARAMETER ExportFormat Export format: Auto (default - detect from extension), Csv, Json, or JUnitXml. .OUTPUTS Returns an array of PSCustomObjects representing available updates. .EXAMPLE # Single cluster (original behavior) Get-AzureLocalAvailableUpdates -ClusterResourceId "/subscriptions/xxx/resourceGroups/RG1/providers/Microsoft.AzureStackHCI/clusters/Cluster01" .EXAMPLE # Multiple clusters by tag Get-AzureLocalAvailableUpdates -ScopeByUpdateRingTag -UpdateRingValue "Wave1" .EXAMPLE # Export to CSV Get-AzureLocalAvailableUpdates -ScopeByUpdateRingTag -UpdateRingValue "Production" -ExportPath "C:\Reports\updates.csv" #> [CmdletBinding(DefaultParameterSetName = 'SingleCluster')] [OutputType([PSCustomObject[]])] param( [Parameter(Mandatory = $true, ParameterSetName = 'SingleCluster')] [string]$ClusterResourceId, [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}$')] [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, ParameterSetName = 'ByName')] [Parameter(Mandatory = $false, ParameterSetName = 'ByResourceId')] [Parameter(Mandatory = $false, ParameterSetName = 'ByTag')] [string]$ExportPath, [Parameter(Mandatory = $false, ParameterSetName = 'ByName')] [Parameter(Mandatory = $false, ParameterSetName = 'ByResourceId')] [Parameter(Mandatory = $false, ParameterSetName = 'ByTag')] [ValidateSet('Auto', 'Csv', 'Json', 'JUnitXml')] [string]$ExportFormat = 'Auto', [Parameter(Mandatory = $false)] [switch]$PassThru, [Parameter(Mandatory = $false, ParameterSetName = 'SingleCluster')] [switch]$Raw, [Parameter(Mandatory = $false, ParameterSetName = 'ByName')] [Parameter(Mandatory = $false, ParameterSetName = 'ByResourceId')] [Parameter(Mandatory = $false, ParameterSetName = 'ByTag')] [ValidateRange(1, 16)] [int]$ThrottleLimit = 1 ) # 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 } } # Original single-cluster behavior if ($PSCmdlet.ParameterSetName -eq 'SingleCluster') { Test-AzCliAvailable | Out-Null $uri = "https://management.azure.com$ClusterResourceId/updates?api-version=$ApiVersion" Write-Verbose "Getting available updates from: $uri" $result = (Invoke-AzRestJson -Uri $uri).Data if ($LASTEXITCODE -ne 0 -or -not $result.value) { if (-not $Raw) { Write-Log -Message "No updates returned for cluster '$(($ClusterResourceId -split '/')[-1])'." -Level Warning } return @() } # -Raw returns the unprocessed ARM API objects (used by internal callers) if ($Raw) { return $result.value } # Default: return enriched objects with SBE dependency info $clusterName = ($ClusterResourceId -split '/')[-1] $rgName = ($ClusterResourceId -split '/resourceGroups/')[1] -split '/' | Select-Object -First 1 $subId = ($ClusterResourceId -split '/subscriptions/')[1] -split '/' | Select-Object -First 1 # Header banner (matches multi-cluster output style) Write-Log -Message "" -Level Info Write-Log -Message "========================================" -Level Header Write-Log -Message "Azure Local Available Updates" -Level Header Write-Log -Message "========================================" -Level Header Write-Log -Message "Cluster: $clusterName" -Level Info Write-Log -Message "Resource Group: $rgName" -Level Info Write-Log -Message "Subscription: $subId" -Level Info $enriched = @() foreach ($update in $result.value) { $props = $update.properties $state = if ($props.state) { $props.state } else { "Unknown" } $packageType = if ($props.packageType) { $props.packageType } else { "" } $sbeDependency = "" if ($state -in @("HasPrerequisite", "AdditionalContentRequired") -and $packageType -eq "SBE") { $additionalProps = ConvertTo-AzLocalAdditionalProperties -InputObject $props.additionalProperties $sbeParts = @() if ($additionalProps -and $additionalProps.SBEPublisher) { $sbeParts += "Publisher: $($additionalProps.SBEPublisher)" } if ($additionalProps -and $additionalProps.SBEFamily) { $sbeParts += "Family: $($additionalProps.SBEFamily)" } if ($additionalProps -and $additionalProps.SBEReleaseLink) { $sbeParts += "ReleaseNotes: $($additionalProps.SBEReleaseLink)" } if ($sbeParts.Count -gt 0) { $sbeDependency = $sbeParts -join '; ' } } $enriched += [PSCustomObject]@{ ClusterName = $clusterName ResourceGroup = $rgName SubscriptionId = $subId UpdateName = $update.name UpdateState = $state Version = if ($props.version) { $props.version } else { "" } PackageType = $packageType SBEDependency = $sbeDependency Description = if ($props.description) { $props.description.Substring(0, [Math]::Min(100, $props.description.Length)) } else { "" } } } # Summary block (matches multi-cluster output style) $readyCount = @($enriched | Where-Object { $_.UpdateState -in $script:ReadyStates }).Count $prereqCount = @($enriched | Where-Object { $_.UpdateState -in $script:PrereqStates }).Count $otherCount = $enriched.Count - $readyCount - $prereqCount Write-Log -Message "" -Level Info Write-Log -Message "========================================" -Level Header Write-Log -Message "Summary" -Level Header Write-Log -Message "========================================" -Level Header Write-Log -Message "Total Updates: $($enriched.Count)" -Level Info Write-Log -Message "Ready to Install: $readyCount" -Level $(if ($readyCount -gt 0) { "Success" } else { "Info" }) Write-Log -Message "Has Prerequisite (SBE): $prereqCount" -Level $(if ($prereqCount -gt 0) { "Warning" } else { "Info" }) if ($otherCount -gt 0) { Write-Log -Message "Other States: $otherCount" -Level Info } if ($prereqCount -gt 0) { Write-Log -Message "" -Level Info Write-Log -Message "Updates blocked by SBE prerequisites:" -Level Warning foreach ($u in ($enriched | Where-Object { $_.UpdateState -in $script:PrereqStates })) { $msg = " - $($u.UpdateName): $($u.UpdateState)" if ($u.SBEDependency) { $msg += " ($($u.SBEDependency))" } Write-Log -Message $msg -Level Warning } Write-Log -Message "Install the required SBE (Solution Builder Extension) update from your hardware vendor before these updates can proceed." -Level Warning } Write-Log -Message "" -Level Info Write-Log -Message "Detailed Results:" -Level Header $enriched | Format-Table UpdateName, UpdateState, Version, PackageType, SBEDependency -AutoSize | Out-String | Write-Host return $enriched } # Multi-cluster mode Write-Log -Message "" -Level Info Write-Log -Message "========================================" -Level Header Write-Log -Message "Azure Local Available Updates" -Level Header Write-Log -Message "========================================" -Level Header # 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 } # Build list of clusters to process $clustersToProcess = @() if ($PSCmdlet.ParameterSetName -eq 'ByTag') { if (-not (Install-AzGraphExtension)) { Write-Error "Failed to install Azure CLI 'resource-graph' extension." return } Write-Log -Message "Querying Azure Resource Graph for clusters with tag 'UpdateRing' = '$UpdateRingValue'..." -Level Info $argQuery = "resources | where type =~ 'microsoft.azurestackhci/clusters' | where tags['UpdateRing'] =~ '$($UpdateRingValue -replace "'", "''")' | project id, name, resourceGroup, subscriptionId, tags" try { $clusterRows = Invoke-AzResourceGraphQuery -Query $argQuery 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 } } } catch { Write-Log -Message "Error querying Azure Resource Graph: $_" -Level Error return } } elseif ($PSCmdlet.ParameterSetName -eq 'ByResourceId') { foreach ($resourceId in $ClusterResourceIds) { $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 } } } else { # ByName - resolve names to resource IDs upfront to avoid per-cluster lookups if (-not $SubscriptionId) { $SubscriptionId = (az account show --query id -o tsv) } foreach ($name in $ClusterNames) { $clusterInfo = Get-AzureLocalClusterInfo -ClusterName $name ` -ResourceGroupName $ResourceGroupName -SubscriptionId $SubscriptionId -ApiVersion $ApiVersion if ($clusterInfo) { $clustersToProcess += @{ ResourceId = $clusterInfo.id Name = $clusterInfo.name ResourceGroup = ($clusterInfo.id -split '/resourceGroups/')[1] -split '/' | Select-Object -First 1 SubscriptionId = ($clusterInfo.id -split '/subscriptions/')[1] -split '/' | Select-Object -First 1 } } else { Write-Log -Message "Cluster '$name' not found - skipping" -Level Warning } } } Write-Log -Message "" -Level Info Write-Log -Message "Querying available updates for $($clustersToProcess.Count) cluster(s)..." -Level Info # Collect results $results = @() $updateVersionCounts = @{} # Parallel dispatch (v0.7.0+): when -ThrottleLimit > 1 and we have multiple clusters, # shard them across background jobs. Each job re-imports the module and calls this # function recursively with -ThrottleLimit 1 on its own subset, then returns the # flattened per-cluster rows. This avoids parallelising shared state (Write-Host # progress, $results accumulation, $updateVersionCounts hashtable) inside a single # runspace while still giving an N-way speedup on large fleets. if ($ThrottleLimit -gt 1 -and $clustersToProcess.Count -gt 1) { Write-Log -Message "Dispatching to $ThrottleLimit parallel workers..." -Level Info $jobScript = { param([object[]]$Batch, [string]$ApiVersionArg, [string]$ModulePath) Import-Module $ModulePath -Force $resourceIds = @($Batch | ForEach-Object { $_.ResourceId } | Where-Object { $_ }) if ($resourceIds.Count -eq 0) { return @() } Get-AzureLocalAvailableUpdates -ClusterResourceIds $resourceIds ` -ApiVersion $ApiVersionArg -ThrottleLimit 1 -PassThru } $batchResults = Invoke-FleetJobsInParallel ` -InputItems $clustersToProcess ` -ScriptBlock $jobScript ` -ThrottleLimit $ThrottleLimit ` -ArgumentList @($ApiVersion) ` -ActivityName 'AvailableUpdates' foreach ($br in $batchResults) { if ($br.Failed) { Write-Log -Message " Parallel batch $($br.BatchIndex) failed: $($br.Error)" -Level Error continue } if ($br.Output) { $results += @($br.Output) } } # Re-build version counts from the merged results foreach ($row in $results) { if ($row.UpdateState -in $script:ReadyStates -and $row.UpdateName) { if ($updateVersionCounts.ContainsKey($row.UpdateName)) { $updateVersionCounts[$row.UpdateName]++ } else { $updateVersionCounts[$row.UpdateName] = 1 } } } } else { foreach ($cluster in $clustersToProcess) { $clusterName = $cluster.Name Write-Host " Checking: $clusterName..." -ForegroundColor Gray -NoNewline try { # Get cluster info if we don't have ResourceId $resourceId = $cluster.ResourceId if (-not $resourceId) { $clusterInfo = Get-AzureLocalClusterInfo -ClusterName $clusterName ` -ResourceGroupName $cluster.ResourceGroup ` -SubscriptionId $cluster.SubscriptionId ` -ApiVersion $ApiVersion if ($clusterInfo) { $resourceId = $clusterInfo.id } } if (-not $resourceId) { Write-Host " Not Found" -ForegroundColor Red $results += [PSCustomObject]@{ ClusterName = $clusterName ResourceGroup = $cluster.ResourceGroup SubscriptionId = $cluster.SubscriptionId UpdateName = "N/A" UpdateState = "Cluster Not Found" Version = "" PackageType = "" SBEDependency = "" Description = "" } continue } # Get available updates $uri = "https://management.azure.com$resourceId/updates?api-version=$ApiVersion" $response = (Invoke-AzRestJson -Uri $uri).Data if ($LASTEXITCODE -eq 0 -and $response.value -and $response.value.Count -gt 0) { $updates = $response.value $readyCount = @($updates | Where-Object { $_.properties.state -in $script:ReadyStates }).Count $prereqCount = @($updates | Where-Object { $_.properties.state -in $script:PrereqStates }).Count $statusParts = @("$readyCount ready") if ($prereqCount -gt 0) { $statusParts += "$prereqCount has prerequisite" } $statusText = $statusParts -join ', ' $statusColor = if ($readyCount -gt 0) { "Green" } elseif ($prereqCount -gt 0) { "Yellow" } else { "Yellow" } Write-Host " $($updates.Count) update(s) ($statusText)" -ForegroundColor $statusColor foreach ($update in $updates) { $props = $update.properties $state = if ($props.state) { $props.state } else { "Unknown" } # Track update versions if ($state -in $script:ReadyStates) { if ($updateVersionCounts.ContainsKey($update.name)) { $updateVersionCounts[$update.name]++ } else { $updateVersionCounts[$update.name] = 1 } } # Extract SBE dependency info for HasPrerequisite/AdditionalContentRequired updates $packageType = if ($props.packageType) { $props.packageType } else { "" } $sbeDependency = "" if ($state -in @("HasPrerequisite", "AdditionalContentRequired") -and $packageType -eq "SBE") { $additionalProps = ConvertTo-AzLocalAdditionalProperties -InputObject $props.additionalProperties $sbePublisher = if ($additionalProps -and $additionalProps.SBEPublisher) { $additionalProps.SBEPublisher } else { "" } $sbeFamily = if ($additionalProps -and $additionalProps.SBEFamily) { $additionalProps.SBEFamily } else { "" } $sbeReleaseLink = if ($additionalProps -and $additionalProps.SBEReleaseLink) { $additionalProps.SBEReleaseLink } else { "" } $sbeParts = @() if ($sbePublisher) { $sbeParts += "Publisher: $sbePublisher" } if ($sbeFamily) { $sbeParts += "Family: $sbeFamily" } if ($sbeReleaseLink) { $sbeParts += "ReleaseNotes: $sbeReleaseLink" } if ($sbeParts.Count -gt 0) { $sbeDependency = $sbeParts -join '; ' } } $results += [PSCustomObject]@{ ClusterName = $clusterName ResourceGroup = $cluster.ResourceGroup SubscriptionId = $cluster.SubscriptionId UpdateName = $update.name UpdateState = $state Version = if ($props.version) { $props.version } else { "" } PackageType = $packageType SBEDependency = $sbeDependency Description = if ($props.description) { $props.description.Substring(0, [Math]::Min(100, $props.description.Length)) } else { "" } } } } else { Write-Host " No updates available" -ForegroundColor Gray $results += [PSCustomObject]@{ ClusterName = $clusterName ResourceGroup = $cluster.ResourceGroup SubscriptionId = $cluster.SubscriptionId UpdateName = "None" UpdateState = "No Updates" Version = "" PackageType = "" SBEDependency = "" Description = "" } } } catch { Write-Host " Error: $($_.Exception.Message)" -ForegroundColor Red $results += [PSCustomObject]@{ ClusterName = $clusterName ResourceGroup = $cluster.ResourceGroup SubscriptionId = $cluster.SubscriptionId UpdateName = "Error" UpdateState = "Error" Version = "" PackageType = "" SBEDependency = "" Description = $_.Exception.Message } } } } # end else (serial path) # Display Summary Write-Log -Message "" -Level Info Write-Log -Message "========================================" -Level Header Write-Log -Message "Summary" -Level Header Write-Log -Message "========================================" -Level Header $totalClusters = $clustersToProcess.Count $clustersWithUpdates = @($results | Where-Object { $_.UpdateName -notin @("N/A", "None", "Error") } | Select-Object -ExpandProperty ClusterName -Unique).Count $clustersWithReadyUpdates = @($results | Where-Object { $_.UpdateState -in $script:ReadyStates } | Select-Object -ExpandProperty ClusterName -Unique).Count $clustersWithPrereqUpdates = @($results | Where-Object { $_.UpdateState -in $script:PrereqStates } | Select-Object -ExpandProperty ClusterName -Unique).Count $totalUpdates = @($results | Where-Object { $_.UpdateName -notin @("N/A", "None", "Error") }).Count Write-Log -Message "" -Level Info Write-Log -Message "Total Clusters: $totalClusters" -Level Info Write-Log -Message "Clusters with Updates: $clustersWithUpdates" -Level $(if ($clustersWithUpdates -gt 0) { "Warning" } else { "Info" }) Write-Log -Message "Clusters with Ready Updates: $clustersWithReadyUpdates" -Level $(if ($clustersWithReadyUpdates -gt 0) { "Success" } else { "Info" }) if ($clustersWithPrereqUpdates -gt 0) { Write-Log -Message "Clusters with Prerequisite: $clustersWithPrereqUpdates (SBE update required first)" -Level Warning } Write-Log -Message "Total Updates Found: $totalUpdates" -Level Info # Show SBE dependency details for HasPrerequisite/AdditionalContentRequired updates $prereqUpdates = @($results | Where-Object { $_.UpdateState -in @("HasPrerequisite", "AdditionalContentRequired") -and $_.SBEDependency }) if ($prereqUpdates.Count -gt 0) { Write-Log -Message "" -Level Info Write-Log -Message "Updates Blocked by SBE Prerequisites:" -Level Warning foreach ($pu in $prereqUpdates) { Write-Log -Message " $($pu.ClusterName) - $($pu.UpdateName): $($pu.SBEDependency)" -Level Warning } } # Show most common update versions if ($updateVersionCounts.Count -gt 0) { Write-Log -Message "" -Level Info Write-Log -Message "Ready Update Versions:" -Level Header $sortedVersions = $updateVersionCounts.GetEnumerator() | Sort-Object -Property Value -Descending foreach ($version in $sortedVersions) { Write-Log -Message " $($version.Key): $($version.Value) cluster(s)" -Level Info } } # Display results table Write-Log -Message "" -Level Info Write-Log -Message "Detailed Results:" -Level Header $results | Format-Table ClusterName, UpdateName, UpdateState, Version, PackageType -AutoSize # 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 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 ClustersWithUpdates = $clustersWithUpdates UpdateVersionSummary = $updateVersionCounts Results = $results } Write-Utf8NoBomFile -Path $ExportPath -Content ($exportData | ConvertTo-Json -Depth 10) Write-Log -Message "Results exported to JSON: $ExportPath" -Level Success } 'JUnitXml' { $junitResults = $results | ForEach-Object { [PSCustomObject]@{ ClusterName = $_.ClusterName Status = if ($_.UpdateState -in $script:ReadyStates) { "Ready" } elseif ($_.UpdateState -eq "Error") { "Failed" } else { "Skipped" } Message = "Update: $($_.UpdateName), State: $($_.UpdateState), Version: $($_.Version)" UpdateName = $_.UpdateName CurrentState = $_.UpdateState } } Export-ResultsToJUnitXml -Results $junitResults -OutputPath $ExportPath ` -TestSuiteName "AzureLocalAvailableUpdates" -OperationType "AvailableUpdates" Write-Log -Message "Results exported to JUnit XML: $ExportPath" -Level Success } } } catch { Write-Log -Message "Failed to export results: $($_.Exception.Message)" -Level Error } } Write-Log -Message "" -Level Info if ($PassThru) { return $results } } |