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
}