Patching Azure Local
Scheduled Updates with Azure Automation
2026-07-01
When Broadcom took over VMware and promised price increases, we quickly began evaluating alternatives before our next renewal. One viable candidate was Azure Local (a.k.a. Azure Stack HCI). We deployed a small two-server cluster at one of our remote sites to host some local services as a test. While we didn't end up moving forward with the platform site-wide, we've maintained the cluster to continuously evaluate the product as it matures.
Since we're already committed to Azure Arc & Update Manager as our organization's patching solution, I was encouraged to see Azure Local embedded into the platform. However, it seems this is still in the early stages because the patch process reuqires manual triggering from the UI. As much as I'd love to be up at midnight to click a button, it seemed like this should be controllable by maintenance configurations like standard OS's in Arc.
Until then, we're using the script below to trigger the process and monitor it to completion. This fires the same process the is triggered in the Azure Local section of the Update Manager UI.
Patches can be very large (5GB+), so expect the time to download & install to be several hours in some cases.
$subscriptionId = ""
$resourceGroup = ""
$clusterName = ""
$apiVersion = "2024-04-01"
$pollIntervalSeconds = 60
$baseUri = "https://management.azure.com/subscriptions/$subscriptionId/resourceGroups/$resourceGroup/providers/Microsoft.AzureStackHCI/clusters/$clusterName"
try {
Connect-AzAccount -Identity -ErrorAction Stop | Out-Null
Write-Output "Connected to Azure using managed identity."
}
catch {
Write-Output "Failed to connect to Azure: $($_.Exception.Message)"
exit 1
}
function Invoke-HciGet {
param([string]$Uri)
$response = Invoke-AzRestMethod -Method GET -Uri $Uri
if ($response.StatusCode -ne 200) {
throw "Request failed with status $($response.StatusCode): $($response.Content)"
}
return $response.Content | ConvertFrom-Json
}
function Invoke-HciPost {
param([string]$Uri)
$response = Invoke-AzRestMethod -Method POST -Uri $Uri -Payload "{}"
if ($response.StatusCode -notin @(200, 202)) {
throw "Request failed with status $($response.StatusCode): $($response.Content)"
}
return $response
}
try {
$allUpdates = (Invoke-HciGet -Uri "$baseUri/updates?api-version=$apiVersion").value
}
catch {
Write-Output "Failed to fetch available updates: $($_.Exception.Message)"
exit 1
}
# Filtering out firmware updates at this time
$osUpdates = $allUpdates | Where-Object {
$_.properties.state -eq "Ready" -and
$_.properties.displayName -notlike "SBE_*" -and
$_.properties.displayName -notlike "*Dell*"
}
if ($osUpdates.Count -eq 0) {
Write-Output "No OS updates ready to install. Cluster is up to date."
exit 0
}
$targetUpdate = $osUpdates | Sort-Object { $_.properties.version } -Descending | Select-Object -First 1
if ($osUpdates.Count -gt 1) {
Write-Output "$($osUpdates.Count) OS updates ready. Applying latest ($($targetUpdate.properties.version)); earlier versions will be superseded."
}
Write-Output "Applying update: [$($targetUpdate.properties.version)] $($targetUpdate.properties.displayName)"
try {
Invoke-HciPost -Uri "$baseUri/updates/$($targetUpdate.name)/apply?api-version=$apiVersion" | Out-Null
Write-Output "Update triggered. Monitoring progress every $pollIntervalSeconds seconds."
}
catch {
Write-Output "Failed to trigger update: $($_.Exception.Message)"
exit 1
}
# Allow time for the update resource to be created before first poll
Start-Sleep -Seconds 30
$startTime = Get-Date
$terminalStates = @("Succeeded", "Failed", "Canceled", "Unknown")
$state = $null
$longRunAlertSent = $false
do {
Start-Sleep -Seconds $pollIntervalSeconds
try {
$runs = (Invoke-HciGet -Uri "$baseUri/updates/$($targetUpdate.name)/updateRuns?api-version=$apiVersion").value
$latestRun = $runs |
Sort-Object { $_.properties.timeStarted } |
Select-Object -Last 1
if (-not $latestRun -or ([datetime]$latestRun.properties.timeStarted) -lt $startTime.AddMinutes(-2)) {
Write-Output "Waiting for update run to appear..."
continue
}
$state = $latestRun.properties.state
$elapsed = [int]((Get-Date) - $startTime).TotalMinutes
Write-Output "[$($elapsed)m elapsed] State: $state"
if ($elapsed -ge 240 -and -not $longRunAlertSent) {
Write-Output "Update [$($targetUpdate.properties.version)] has been running for over 4 hours. Manual review may be required."
$longRunAlertSent = $true
}
}
catch {
Write-Output "Poll attempt failed (will retry): $($_.Exception.Message)"
}
} while ($state -notin $terminalStates)
$totalMinutes = [int]((Get-Date) - $startTime).TotalMinutes
if ($state -eq "Succeeded") {
Write-Output "Update [$($targetUpdate.properties.version)] completed successfully in $totalMinutes minutes."
}
else {
Write-Output "Update [$($targetUpdate.properties.version)] ended with state '$state' after $totalMinutes minutes."
exit 1
}