Public/Sync-AzLocalClusterUpdateSummary.ps1
|
function Sync-AzLocalClusterUpdateSummary { <# .SYNOPSIS Triggers a fresh "Check for updates" scan on one or more Azure Local clusters. .DESCRIPTION Forces Azure Local (Azure Stack HCI) clusters to re-evaluate their update availability by POSTing to the cluster's `updateSummaries/default/checkUpdates` ARM action - the programmatic equivalent of the Azure portal "Check for updates" button. This is the remediation for a STALE update assessment: a cluster can report `state = AppliedSuccessfully` / "Up to date" while a newer solution version is actually available, because its cached assessment has not been refreshed. The checkUpdates action makes the cluster re-scan and (when a newer build exists) flip to `UpdateAvailable`, surfacing the recommended update. The action is asynchronous on the ARM side (returns 202 Accepted and runs a long-running operation on the cluster): - Default (fire-and-forget): the cmdlet POSTs checkUpdates and returns immediately with a `Triggered` result per cluster. This is the safe default for fleet use - an offline or busy cluster cannot stall the run. - -Wait: after triggering, the cmdlet polls the cluster's `updateSummaries/default` until `properties.lastChecked` advances past the pre-trigger baseline (or -TimeoutSeconds elapses), then stores the refreshed summary payload on the returned object (UpdateState, CurrentVersion, LastChecked, AvailableUpdateCount, and the raw UpdateSummary object). Clusters are selected by name, by Resource ID, or by their 'UpdateRing' tag, mirroring Start-AzLocalClusterUpdate / Get-AzLocalUpdateSummary. .PARAMETER ClusterNames Array of cluster names to scan. Use this OR -ClusterResourceIds OR -ScopeByUpdateRingTag. .PARAMETER ClusterResourceIds Array of full Azure Resource IDs for clusters. Use when clusters are in different resource groups, or when the caller has already resolved the IDs. .PARAMETER ScopeByUpdateRingTag Switch to 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 ResourceGroupName Resource group containing the clusters (only used with -ClusterNames). .PARAMETER SubscriptionId Azure subscription ID (defaults to the current az CLI subscription). .PARAMETER ApiVersion Azure REST API version for both the checkUpdates POST and the updateSummaries poll. Defaults to "2026-03-01-preview" - the checkUpdates action is only exposed on the preview API surface. .PARAMETER Wait Opt-in. After triggering, poll the cluster's updateSummaries until the scan completes (lastChecked advances) or -TimeoutSeconds elapses, and attach the refreshed summary data to the result object. .PARAMETER TimeoutSeconds Maximum seconds to wait per cluster when -Wait is set. Default 300. .PARAMETER PollIntervalSeconds Seconds between updateSummaries polls when -Wait is set. Default 15. .PARAMETER Force Skip the confirmation prompt (checkUpdates is a state-changing action). .PARAMETER PassThru Emit a result object per cluster to the pipeline. .OUTPUTS PSCustomObject[] - One result per cluster: ClusterName, ClusterResourceId, ResourceGroup, SubscriptionId, Triggered, Status, Message. When -Wait completed a poll, also: PreviousLastChecked, LastChecked, UpdateState, CurrentVersion, AvailableUpdateCount, WaitedSeconds, UpdateSummary (raw refreshed payload). .EXAMPLE Sync-AzLocalClusterUpdateSummary -ClusterNames "Sydney" -ResourceGroupName "Prod-RG" -Force Fire-and-forget: triggers a fresh update scan on one cluster. .EXAMPLE Sync-AzLocalClusterUpdateSummary -ClusterNames "Sydney" -Wait -PassThru Triggers the scan and waits for it to finish, returning the refreshed summary. .EXAMPLE Sync-AzLocalClusterUpdateSummary -ScopeByUpdateRingTag -UpdateRingValue "Production" -Force Triggers a fresh scan on every cluster tagged UpdateRing=Production. .NOTES Author: Neil Bird, Microsoft. ARM action: POST {clusterId}/updateSummaries/default/checkUpdates?api-version=2026-03-01-preview #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium', 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 = '2026-03-01-preview', [Parameter(Mandatory = $false)] [switch]$Wait, [Parameter(Mandatory = $false)] [ValidateRange(30, 3600)] [int]$TimeoutSeconds = 300, [Parameter(Mandatory = $false)] [ValidateRange(5, 300)] [int]$PollIntervalSeconds = 15, [Parameter(Mandatory = $false)] [switch]$Force, [Parameter(Mandatory = $false)] [switch]$PassThru ) # If -Force is supplied, suppress the per-cluster ShouldProcess confirmation # prompt (matches Start-AzLocalClusterUpdate semantics). if ($Force -and -not $PSBoundParameters.ContainsKey('Confirm')) { $ConfirmPreference = 'None' } Write-Log -Message "" -Level Info Write-Log -Message "========================================" -Level Header Write-Log -Message "Azure Local - Check for Updates (refresh assessment)" -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: each entry is @{ Name; ResourceId; ResourceGroup; SubscriptionId; NotFound } $clustersToProcess = [System.Collections.Generic.List[object]]::new() if ($PSCmdlet.ParameterSetName -eq 'ByResourceId') { # Trust caller-supplied Resource IDs directly (no ARG round trip). This is # the path the readiness auto-scan uses - IDs are already resolved. 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.Add([pscustomobject]@{ Name = ($resourceId -split '/')[-1] ResourceId = $resourceId ResourceGroup = $clusterRgName SubscriptionId = $clusterSubId NotFound = $false }) | Out-Null } } else { # ByName / ByTag both resolve through Azure Resource Graph. if (-not (Install-AzGraphExtension)) { Write-Log -Message "Failed to install Azure CLI 'resource-graph' extension." -Level Error return } if ($PSCmdlet.ParameterSetName -eq 'ByTag') { Write-Log -Message "Querying Azure Resource Graph for clusters with tag 'UpdateRing' = '$UpdateRingValue'..." -Level Info $ringFilter = ConvertTo-AzLocalUpdateRingKqlFilter -UpdateRingValue $UpdateRingValue $argQuery = "resources | where type =~ 'microsoft.azurestackhci/clusters' $ringFilter | project id, name, resourceGroup, subscriptionId" } else { $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" } try { $argParams = @{ Query = $argQuery } if ($SubscriptionId) { $argParams['SubscriptionId'] = $SubscriptionId } $clusterRows = Invoke-AzResourceGraphQuery @argParams } catch { Write-Log -Message "Error resolving clusters via Azure Resource Graph: $($_.Exception.Message)" -Level Error return } if (-not $clusterRows -or @($clusterRows).Count -eq 0) { Write-Log -Message "No clusters resolved for the supplied selection." -Level Warning return } if ($PSCmdlet.ParameterSetName -eq 'ByName') { $foundNames = @{} foreach ($cluster in @($clusterRows)) { $foundNames[$cluster.name.ToLower()] = $cluster } foreach ($name in $ClusterNames) { if ($foundNames.ContainsKey($name.ToLower())) { $cluster = $foundNames[$name.ToLower()] $clustersToProcess.Add([pscustomobject]@{ Name = $cluster.name ResourceId = $cluster.id ResourceGroup = $cluster.resourceGroup SubscriptionId = $cluster.subscriptionId NotFound = $false }) | Out-Null } else { Write-Log -Message "Cluster '$name' not found in Azure Resource Graph - skipping" -Level Warning } } } else { foreach ($cluster in @($clusterRows)) { $clustersToProcess.Add([pscustomobject]@{ Name = $cluster.name ResourceId = $cluster.id ResourceGroup = $cluster.resourceGroup SubscriptionId = $cluster.subscriptionId NotFound = $false }) | Out-Null } } } if ($clustersToProcess.Count -eq 0) { Write-Log -Message "No clusters resolved - nothing to do." -Level Warning return } Write-Log -Message "" -Level Info Write-Log -Message "Triggering 'Check for updates' on $($clustersToProcess.Count) cluster(s)..." -Level Info Write-Log -Message "" -Level Info $results = [System.Collections.Generic.List[object]]::new() foreach ($cluster in $clustersToProcess) { $clusterName = $cluster.Name $resourceId = $cluster.ResourceId # Base result row; -Wait fields are filled in only when a poll completes. $row = [ordered]@{ ClusterName = $clusterName ClusterResourceId = $resourceId ResourceGroup = $cluster.ResourceGroup SubscriptionId = $cluster.SubscriptionId Triggered = $false Status = 'NotAttempted' Message = '' PreviousLastChecked = $null LastChecked = $null UpdateState = $null CurrentVersion = $null AvailableUpdateCount = $null WaitedSeconds = $null UpdateSummary = $null } $target = "$clusterName ($resourceId)" if (-not $PSCmdlet.ShouldProcess($target, "Trigger 'Check for updates' (refresh update assessment)")) { $row.Status = 'Skipped' $row.Message = 'Skipped (ShouldProcess / WhatIf)' $results.Add([pscustomobject]$row) | Out-Null continue } $summaryUri = "https://management.azure.com$resourceId/updateSummaries/default?api-version=$ApiVersion" # Capture the pre-trigger lastChecked baseline only when we intend to wait. $baselineLastChecked = $null if ($Wait) { $baselineResp = Invoke-AzRestJson -Uri $summaryUri -Method GET if ($baselineResp.Ok -and $baselineResp.Data -and $baselineResp.Data.PSObject.Properties['properties']) { $baselineProps = $baselineResp.Data.properties if ($baselineProps.PSObject.Properties['lastChecked'] -and $baselineProps.lastChecked) { try { $baselineLastChecked = [datetime]$baselineProps.lastChecked } catch { $baselineLastChecked = $null } } $row.PreviousLastChecked = if ($baselineProps.PSObject.Properties['lastChecked']) { [string]$baselineProps.lastChecked } else { $null } } } # Fire the checkUpdates action (empty body POST -> 202 Accepted / async LRO). $checkUri = "https://management.azure.com$resourceId/updateSummaries/default/checkUpdates?api-version=$ApiVersion" Write-Log -Message " $clusterName : POST checkUpdates" -Level Info $postResp = Invoke-AzRestJson -Uri $checkUri -Method POST if (-not $postResp.Ok) { $errorText = [string]$postResp.Error $row.Status = 'Failed' $row.Message = "checkUpdates POST failed: $errorText" # Always echo the raw error (which, for an RBAC denial, contains the # exact 'Action' string and scope) so it is captured in the pipeline # console log even on the fire-and-forget auto-scan path. Write-Log -Message " $clusterName : FAILED - $errorText" -Level Error # Make an authorization / 403 denial unmistakable. The least-privilege # custom role 'Azure Stack HCI Update Operator (custom)' does NOT yet # authorize checkUpdates (the preview action is absent from the # Microsoft.AzureStackHCI provider operations catalog, so it cannot be # added to a custom role today). Use 'Azure Stack HCI Administrator' or # 'Contributor' until checkUpdates GAs, then add the action surfaced in # the error below to the custom role. if ($errorText -match 'AuthorizationFailed|does not have authorization|Forbidden|\b403\b') { $row.Status = 'AuthorizationFailed' Write-Log -Message " $clusterName : AUTHORIZATION ERROR - the signed-in identity is not permitted to run 'Check for updates' (checkUpdates). Copy the exact 'Action' name and scope from the error line above; grant that action (or assign 'Azure Stack HCI Administrator' / 'Contributor'). The least-privilege custom role does not yet include the preview checkUpdates action." -Level Warning } $results.Add([pscustomobject]$row) | Out-Null continue } $row.Triggered = $true $row.Status = 'Triggered' $row.Message = 'checkUpdates accepted; assessment refresh in progress.' Write-Log -Message " $clusterName : triggered" -Level Success if (-not $Wait) { $results.Add([pscustomobject]$row) | Out-Null continue } # -Wait: poll updateSummaries until lastChecked advances past the baseline # (scan completed) or the timeout elapses. $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() $completed = $false $lastSummaryData = $null while ($stopwatch.Elapsed.TotalSeconds -lt $TimeoutSeconds) { Start-Sleep -Seconds $PollIntervalSeconds $pollResp = Invoke-AzRestJson -Uri $summaryUri -Method GET if (-not $pollResp.Ok -or -not $pollResp.Data) { continue } $lastSummaryData = $pollResp.Data $pollProps = if ($pollResp.Data.PSObject.Properties['properties']) { $pollResp.Data.properties } else { $null } if (-not $pollProps) { continue } $pollLastChecked = $null if ($pollProps.PSObject.Properties['lastChecked'] -and $pollProps.lastChecked) { try { $pollLastChecked = [datetime]$pollProps.lastChecked } catch { $pollLastChecked = $null } } $advanced = $false if ($null -ne $pollLastChecked) { if ($null -eq $baselineLastChecked) { $advanced = $true } elseif ($pollLastChecked -gt $baselineLastChecked) { $advanced = $true } } if ($advanced) { $completed = $true break } } $stopwatch.Stop() $row.WaitedSeconds = [int]$stopwatch.Elapsed.TotalSeconds if ($lastSummaryData) { $row.UpdateSummary = $lastSummaryData $finalProps = if ($lastSummaryData.PSObject.Properties['properties']) { $lastSummaryData.properties } else { $null } if ($finalProps) { if ($finalProps.PSObject.Properties['state']) { $row.UpdateState = [string]$finalProps.state } if ($finalProps.PSObject.Properties['currentVersion']) { $row.CurrentVersion = [string]$finalProps.currentVersion } if ($finalProps.PSObject.Properties['lastChecked']) { $row.LastChecked = [string]$finalProps.lastChecked } } } if ($completed) { $row.Status = 'Completed' $row.Message = "Assessment refreshed (lastChecked advanced). State: $($row.UpdateState)." Write-Log -Message " $clusterName : refresh completed (state: $($row.UpdateState))" -Level Success } else { $row.Status = 'TimedOut' $row.Message = "Triggered, but lastChecked did not advance within $TimeoutSeconds s. Re-check later." Write-Log -Message " $clusterName : wait timed out after $TimeoutSeconds s (scan may still be running)" -Level Warning } $results.Add([pscustomobject]$row) | Out-Null } Write-Log -Message "" -Level Info $triggeredCount = @($results | Where-Object { $_.Triggered }).Count Write-Log -Message "Check for updates: triggered on $triggeredCount of $($clustersToProcess.Count) cluster(s)." -Level Info if ($PassThru) { return $results.ToArray() } } |