Private/Set-AzLocalClusterTagsMerge.ps1
|
function Set-AzLocalClusterTagsMerge { <# .SYNOPSIS Merges a set of tag key/value pairs into an Azure Local cluster's tags via the ARM tags subresource. .DESCRIPTION Private helper that performs an additive tag merge on a single Azure Local cluster resource using the generic ARM tags subresource: PATCH https://management.azure.com{resourceId}/providers/Microsoft.Resources/tags/default?api-version=2021-04-01 Two PATCH operations are emitted when needed: - operation=Merge for any tag key being set/overwritten (non-$null value) - operation=Delete for any tag key whose value is $null in the input Existing tags not mentioned in the input are preserved by the subresource contract; no read-modify-write of the cluster body is required. RBAC: this path requires only Microsoft.Resources/tags/* (Tag Contributor) on the resource, plus read on the resource group. It does NOT require the broader microsoft.azurestackhci/clusters/write that the old full-PATCH approach demanded, which is what v0.7.62 fixes. Used by: - Start-AzureLocalClusterUpdate to write UpdateVersionInProgress at update start. - Reset-AzureLocalSideloadedTag (and the auto-reset path in Get-AzureLocalUpdateRuns) to flip UpdateSideloaded=False and clear UpdateVersionInProgress on matched success. Failures are surfaced as terminating errors so callers can wrap in try/catch and decide whether to treat the failure as fatal (reset) or warn-and-continue (start). .PARAMETER ClusterResourceId The full ARM resource ID of the microsoft.azurestackhci/clusters resource. .PARAMETER Tags Hashtable of tag keys to set. Use $null as a value to remove a tag key. .PARAMETER ApiVersion Retained for backward compatibility with existing callers; ignored. The tags subresource always uses api-version=2021-04-01. .OUTPUTS [bool] - $true on success. Throws on failure. #> [CmdletBinding(SupportsShouldProcess = $true)] [OutputType([bool])] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$ClusterResourceId, [Parameter(Mandatory = $true)] [ValidateNotNull()] [hashtable]$Tags, [Parameter(Mandatory = $false)] [string]$ApiVersion = $script:DefaultApiVersion ) if ($Tags.Count -eq 0) { Write-Verbose "Set-AzLocalClusterTagsMerge: no tags supplied; nothing to do." return $true } # Tags subresource is implemented by ARM itself (not by the HCI RP) and uses a # fixed api-version. Do not substitute the cluster RP's api-version here. $tagsApiVersion = '2021-04-01' $tagsUri = "https://management.azure.com$ClusterResourceId/providers/Microsoft.Resources/tags/default`?api-version=$tagsApiVersion" # Split incoming Tags into merge-set (keys to set/overwrite) and delete-set # (keys whose requested value is $null, meaning remove). $toMergeRequested = [ordered]@{} $toDeleteRequested = [ordered]@{} foreach ($key in $Tags.Keys) { if ($null -eq $Tags[$key]) { $toDeleteRequested[$key] = $true } else { $toMergeRequested[$key] = [string]$Tags[$key] } } # Read current tags via the tags subresource (requires Microsoft.Resources/tags/read). # This is what enables idempotency: skip PATCHes that would be a no-op against # the cluster's current state. $existingTagsJson = az rest --method GET --uri $tagsUri --only-show-errors 2>&1 if ($LASTEXITCODE -ne 0) { throw "Set-AzLocalClusterTagsMerge: failed to read tags subresource for '$ClusterResourceId': $existingTagsJson" } $existing = $existingTagsJson | ConvertFrom-Json $existingTags = @{} if ($existing -and $existing.properties -and $existing.properties.tags) { foreach ($prop in $existing.properties.tags.PSObject.Properties) { if ($prop.MemberType -eq 'NoteProperty') { $existingTags[$prop.Name] = [string]$prop.Value } } } # Idempotency: only Merge keys whose value differs from what's already on the resource. $toMerge = [ordered]@{} foreach ($key in $toMergeRequested.Keys) { $current = if ($existingTags.ContainsKey($key)) { $existingTags[$key] } else { $null } if ($null -eq $current -or $current -cne $toMergeRequested[$key]) { $toMerge[$key] = $toMergeRequested[$key] } } # Idempotency: only Delete keys that actually exist on the resource. ARM's Delete # operation expects a {key:value} dictionary; we pass the existing value (its # content is not used for matching, only the key is, but ARM requires the shape). $toDelete = [ordered]@{} foreach ($key in $toDeleteRequested.Keys) { if ($existingTags.ContainsKey($key)) { $toDelete[$key] = $existingTags[$key] } } if ($toMerge.Count -eq 0 -and $toDelete.Count -eq 0) { Write-Verbose "Set-AzLocalClusterTagsMerge: no tag changes for '$ClusterResourceId'; skipping PATCH." return $true } $describe = (@($Tags.Keys | ForEach-Object { "$_=$($Tags[$_])" })) -join ', ' if (-not $PSCmdlet.ShouldProcess($ClusterResourceId, "Merge/Delete tags ($describe)")) { return $true } # Emit up to two PATCHes against the tags subresource: Merge first (to set # values), then Delete (to remove keys). Order matters only insofar as ARM # processes each request independently; this ordering matches operator # intent of "stage new value before removing the old breadcrumb". if ($toMerge.Count -gt 0) { $mergeBodyObj = [PSCustomObject]@{ operation = 'Merge' properties = [PSCustomObject]@{ tags = [PSCustomObject]$toMerge } } $mergeBody = $mergeBodyObj | ConvertTo-Json -Compress -Depth 10 $tempFile = [System.IO.Path]::GetTempFileName() try { Write-Utf8NoBomFile -Path $tempFile -Content $mergeBody $patchResult = az rest --method PATCH --uri $tagsUri --body "@$tempFile" --headers "Content-Type=application/json" --only-show-errors 2>&1 if ($LASTEXITCODE -ne 0) { throw "Set-AzLocalClusterTagsMerge: PATCH (Merge) failed for '$ClusterResourceId': $patchResult" } } finally { if (Test-Path $tempFile) { Remove-Item $tempFile -Force -ErrorAction SilentlyContinue -WhatIf:$false } } } if ($toDelete.Count -gt 0) { $deleteBodyObj = [PSCustomObject]@{ operation = 'Delete' properties = [PSCustomObject]@{ tags = [PSCustomObject]$toDelete } } $deleteBody = $deleteBodyObj | ConvertTo-Json -Compress -Depth 10 $tempFile = [System.IO.Path]::GetTempFileName() try { Write-Utf8NoBomFile -Path $tempFile -Content $deleteBody $patchResult = az rest --method PATCH --uri $tagsUri --body "@$tempFile" --headers "Content-Type=application/json" --only-show-errors 2>&1 if ($LASTEXITCODE -ne 0) { throw "Set-AzLocalClusterTagsMerge: PATCH (Delete) failed for '$ClusterResourceId': $patchResult" } } finally { if (Test-Path $tempFile) { Remove-Item $tempFile -Force -ErrorAction SilentlyContinue -WhatIf:$false } } } return $true } |