Private/Set-AzLocalClusterTagsMerge.ps1

function Set-AzLocalClusterTagsMerge {
    <#
    .SYNOPSIS
        Merges a set of tag key/value pairs into an Azure Local cluster's tags via ARM PATCH.
    .DESCRIPTION
        Private helper that performs an additive ARM tag merge on a single cluster
        resource. Existing tags are preserved; supplied keys are added or overwritten.
        A supplied value of $null removes that tag key from the cluster.
 
        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
        The ARM api-version to use for the PATCH. Defaults to a stable cluster api-version.
    .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
    }

    # Fetch current cluster to preserve existing tags
    $getUri = "https://management.azure.com$ClusterResourceId`?api-version=$ApiVersion"
    $clusterJson = az rest --method GET --uri $getUri --only-show-errors 2>&1
    if ($LASTEXITCODE -ne 0) {
        throw "Set-AzLocalClusterTagsMerge: failed to fetch cluster '$ClusterResourceId': $clusterJson"
    }

    $cluster = $clusterJson | ConvertFrom-Json
    $newTags = [ordered]@{}
    $existingTags = [ordered]@{}
    if ($cluster.tags) {
        foreach ($prop in $cluster.tags.PSObject.Properties) {
            if ($prop.MemberType -eq 'NoteProperty') {
                $newTags[$prop.Name] = $prop.Value
                $existingTags[$prop.Name] = $prop.Value
            }
        }
    }

    # Apply the merge: set non-null values, remove null values
    $changed = $false
    foreach ($key in $Tags.Keys) {
        $val = $Tags[$key]
        if ($null -eq $val) {
            if ($newTags.Contains($key)) {
                $newTags.Remove($key)
                $changed = $true
            }
        }
        else {
            $existingValue = if ($existingTags.Contains($key)) { [string]$existingTags[$key] } else { $null }
            if ($null -eq $existingValue -or $existingValue -cne [string]$val) {
                $newTags[$key] = $val
                $changed = $true
            }
        }
    }

    # Idempotency: if the merge produces no actual change, skip the PATCH entirely.
    # Avoids redundant ARM writes when running auto-reset paths against already-clean
    # clusters (common at fleet scale and across overlapping pipeline runs).
    if (-not $changed) {
        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 tags ($describe)")) {
        return $true
    }

    $patchBodyObj = [PSCustomObject]@{ tags = [PSCustomObject]$newTags }
    $patchBody = $patchBodyObj | ConvertTo-Json -Compress -Depth 10

    $tempFile = [System.IO.Path]::GetTempFileName()
    try {
        Write-Utf8NoBomFile -Path $tempFile -Content $patchBody
        $patchResult = az rest --method PATCH --uri $getUri --body "@$tempFile" --headers "Content-Type=application/json" --only-show-errors 2>&1
        if ($LASTEXITCODE -ne 0) {
            throw "Set-AzLocalClusterTagsMerge: PATCH failed for '$ClusterResourceId': $patchResult"
        }
    }
    finally {
        if (Test-Path $tempFile) { Remove-Item $tempFile -Force -ErrorAction SilentlyContinue -WhatIf:$false }
    }

    return $true
}