Private/Invoke-AzLocalUpdateRingTagWorker.ps1

function New-AzLocalUpdateRingTagPlan {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        $ClusterEntry,

        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [object[]]$ClusterTagFilters,

        [Parameter(Mandatory = $false)]
        [bool]$Force = $false,

        [Parameter(Mandatory = $false)]
        [bool]$CaptureVerbose = $false
    )

    $logEntries = [System.Collections.Generic.List[object]]::new()
    $verboseMessages = [System.Collections.Generic.List[string]]::new()

    if ($ClusterEntry -is [System.Collections.IDictionary]) {
        $ClusterEntry = [PSCustomObject]$ClusterEntry
    }

    $inputIndex = if ($ClusterEntry.PSObject.Properties['InputIndex']) { [int]$ClusterEntry.InputIndex } else { 0 }
    $resourceId = if ($ClusterEntry.PSObject.Properties['ResourceId']) { [string]$ClusterEntry.ResourceId } else { '' }
    $currentUpdateRingValue = if ($ClusterEntry.PSObject.Properties['UpdateRingValue']) { [string]$ClusterEntry.UpdateRingValue } else { '' }
    $updateStartWindowValue = if ($ClusterEntry.PSObject.Properties['UpdateStartWindowValue']) { [string]$ClusterEntry.UpdateStartWindowValue } else { '' }
    $updateExclusionsWindowValue = if ($ClusterEntry.PSObject.Properties['UpdateExclusionsWindowValue']) { [string]$ClusterEntry.UpdateExclusionsWindowValue } else { '' }
    $updateExcludedValue = if ($ClusterEntry.PSObject.Properties['UpdateExcludedValue']) { [string]$ClusterEntry.UpdateExcludedValue } else { '' }
    $updateAuthAccountIdValue = if ($ClusterEntry.PSObject.Properties['UpdateAuthAccountIdValue']) { [string]$ClusterEntry.UpdateAuthAccountIdValue } else { '' }

    $headerClusterName = ($resourceId -split '/')[-1]
    if ([string]::IsNullOrWhiteSpace($headerClusterName)) { $headerClusterName = $resourceId }

    [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = '' })
    [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = '----------------------------------------' })
    [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = "Processing: $headerClusterName" })
    [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = "ARM Resource ID: $resourceId" })
    [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = "Target UpdateRing: $currentUpdateRingValue" })
    [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = '----------------------------------------' })

    $clusterName = ''
    $resourceGroup = ''
    $subscriptionId = ''
    $previousTagValue = ''

    $newResult = {
        param(
            [string]$Action,
            [string]$Status,
            [string]$Message
        )
        [PSCustomObject]@{
            ClusterName      = $clusterName
            ResourceGroup    = $resourceGroup
            SubscriptionId   = $subscriptionId
            ResourceId       = $resourceId
            Action           = $Action
            PreviousTagValue = $previousTagValue
            NewTagValue      = $currentUpdateRingValue
            Status           = $Status
            Message          = $Message
        }
    }

    $newEnvelope = {
        param($Result, $Plan)
        [PSCustomObject]@{
            InputIndex      = $inputIndex
            ResourceId      = $resourceId
            Result          = $Result
            Plan            = $Plan
            LogEntries      = $logEntries.ToArray()
            VerboseMessages = $verboseMessages.ToArray()
        }
    }

    try {
        if ($resourceId -notmatch '/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/([^/]+)/([^/]+)/([^/]+)') {
            $message = 'Invalid Resource ID format'
            [void]$logEntries.Add([PSCustomObject]@{ Level = 'Error'; Message = "Invalid Resource ID format: $resourceId" })
            return & $newEnvelope (& $newResult 'Skipped' 'Failed' $message) $null
        }

        $subscriptionId = $Matches[1]
        $resourceGroup = $Matches[2]
        $providerNamespace = $Matches[3]
        $resourceType = $Matches[4]
        $clusterName = $Matches[5]
        $actualType = "$providerNamespace/$resourceType"

        if ($actualType -notlike 'Microsoft.AzureStackHCI/clusters') {
            $message = "Invalid resource type: $actualType (expected Microsoft.AzureStackHCI/clusters)"
            [void]$logEntries.Add([PSCustomObject]@{ Level = 'Error'; Message = "Resource is not an Azure Local cluster. Type: $actualType" })
            return & $newEnvelope (& $newResult 'Skipped' 'Failed' $message) $null
        }

        [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = "Cluster: $clusterName" })
        [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = "Resource Group: $resourceGroup" })
        [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = "Subscription: $subscriptionId" })
        [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = 'Verifying cluster exists and retrieving current tags...' })

        $uri = "https://management.azure.com$resourceId`?api-version=2025-10-01"
        $restOutput = @(Invoke-AzRestJson -Uri $uri -Verbose:$CaptureVerbose 4>&1)
        $clusterResponse = $null
        foreach ($outputItem in $restOutput) {
            if ($outputItem -is [System.Management.Automation.VerboseRecord]) {
                [void]$verboseMessages.Add([string]$outputItem.Message)
            }
            elseif ($outputItem -and $outputItem.PSObject.Properties['Ok']) {
                $clusterResponse = $outputItem
            }
        }

        if (-not $clusterResponse -or -not $clusterResponse.Ok -or -not $clusterResponse.Data) {
            $detail = if ($clusterResponse -and $clusterResponse.Error) { ": $($clusterResponse.Error)" } else { '' }
            $message = "Cluster not found or access denied$detail"
            [void]$logEntries.Add([PSCustomObject]@{ Level = 'Error'; Message = "Failed to retrieve cluster. It may not exist or you don't have access.$detail" })
            return & $newEnvelope (& $newResult 'Skipped' 'Failed' $message) $null
        }
        $clusterInfo = $clusterResponse.Data

        if (-not (Test-AzLocalClusterMatchesTagFilter -Tags $clusterInfo.tags -ClusterTagFilters $ClusterTagFilters)) {
            $message = 'Cluster does not match the configured scope.clusterTagFilters policy. No tags were changed.'
            [void]$logEntries.Add([PSCustomObject]@{ Level = 'Warning'; Message = "$message ResourceId: $resourceId" })
            return & $newEnvelope (& $newResult 'Skipped' 'GlobalFilterMismatch' $message) $null
        }

        if ($clusterInfo.type -notlike 'Microsoft.AzureStackHCI/clusters') {
            $message = "Resource type mismatch: $($clusterInfo.type)"
            [void]$logEntries.Add([PSCustomObject]@{ Level = 'Error'; Message = "Resource type mismatch. Expected Azure Local cluster, got: $($clusterInfo.type)" })
            return & $newEnvelope (& $newResult 'Skipped' 'Failed' $message) $null
        }

        [void]$logEntries.Add([PSCustomObject]@{ Level = 'Success'; Message = "Cluster verified: $($clusterInfo.name)" })

        $currentTags = if ($clusterInfo.tags) { $clusterInfo.tags } else { [PSCustomObject]@{} }
        if ($currentTags -is [System.Collections.IDictionary]) {
            $currentTags = [PSCustomObject]$currentTags
        }

        $action = ''
        $hasUpdateRing = [bool]$currentTags.PSObject.Properties['UpdateRing']
        if ($hasUpdateRing) {
            $previousTagValue = [string]$currentTags.UpdateRing
            if ($previousTagValue -eq $currentUpdateRingValue) {
                [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = "Existing UpdateRing tag found with value: '$previousTagValue' (matches target)" })
            }
            else {
                [void]$logEntries.Add([PSCustomObject]@{ Level = 'Warning'; Message = "Existing UpdateRing tag found with value: '$previousTagValue' (differs from target '$currentUpdateRingValue')" })
            }

            $needsExcludedDefaultStamp = (-not $currentTags.PSObject.Properties[$script:UpdateExcludedTagName]) -and (-not $updateExcludedValue)
            $hasNewScheduleTags = ($updateStartWindowValue -and (-not $currentTags.PSObject.Properties[$script:UpdateStartWindowTagName] -or $currentTags.$($script:UpdateStartWindowTagName) -ne $updateStartWindowValue)) -or
                                  ($updateExclusionsWindowValue -and (-not $currentTags.PSObject.Properties[$script:UpdateExclusionsWindowTagName] -or $currentTags.$($script:UpdateExclusionsWindowTagName) -ne $updateExclusionsWindowValue)) -or
                                  ($updateExcludedValue -and (-not $currentTags.PSObject.Properties[$script:UpdateExcludedTagName] -or $currentTags.$($script:UpdateExcludedTagName) -ne $updateExcludedValue)) -or
                                  ($updateAuthAccountIdValue -and (-not $currentTags.PSObject.Properties[$script:UpdateAuthAccountIdTagName] -or $currentTags.$($script:UpdateAuthAccountIdTagName) -ne $updateAuthAccountIdValue)) -or
                                  $needsExcludedDefaultStamp

            if (-not $Force -and -not $hasNewScheduleTags) {
                if ($previousTagValue -eq $currentUpdateRingValue) {
                    $message = 'All managed tags (UpdateRing, UpdateStartWindow, UpdateExclusionsWindow, UpdateExcluded) already match desired state.'
                    [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = 'All managed tags already match desired state - no action needed' })
                    return & $newEnvelope (& $newResult 'NoChange' 'AlreadyInSync' $message) $null
                }

                $message = "Existing UpdateRing tag (value: $previousTagValue) differs from target ($currentUpdateRingValue). Use -Force to overwrite."
                [void]$logEntries.Add([PSCustomObject]@{ Level = 'Warning'; Message = 'Skipping cluster - UpdateRing differs from target; use -Force to overwrite existing tag' })
                return & $newEnvelope (& $newResult 'Skipped' 'Skipped' $message) $null
            }

            if (-not $Force) {
                [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = 'UpdateRing unchanged but new schedule tags to apply - proceeding' })
                $action = 'Updated'
            }
            elseif ($previousTagValue -eq $currentUpdateRingValue -and -not $hasNewScheduleTags) {
                $message = 'All managed tags already match desired state; -Force PATCH skipped (no-op).'
                [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = 'Force mode enabled but all managed tags already match desired state - no PATCH needed' })
                return & $newEnvelope (& $newResult 'NoChange' 'AlreadyInSync' $message) $null
            }
            else {
                [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = 'Force mode enabled - will update existing tag' })
                $action = 'Updated'
            }
        }
        else {
            [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = 'No existing UpdateRing tag - will create new tag' })
            $action = 'Created'
        }

        $tagsToMerge = [ordered]@{ UpdateRing = $currentUpdateRingValue }
        if ($updateStartWindowValue) {
            $tagsToMerge[$script:UpdateStartWindowTagName] = $updateStartWindowValue
            [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = " Will also set $($script:UpdateStartWindowTagName) tag: $updateStartWindowValue" })
        }
        if ($updateExclusionsWindowValue) {
            $tagsToMerge[$script:UpdateExclusionsWindowTagName] = $updateExclusionsWindowValue
            [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = " Will also set $($script:UpdateExclusionsWindowTagName) tag: $updateExclusionsWindowValue" })
        }
        if ($updateExcludedValue) {
            $tagsToMerge[$script:UpdateExcludedTagName] = $updateExcludedValue
            [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = " Will also set $($script:UpdateExcludedTagName) tag: $updateExcludedValue" })
        }
        elseif (-not $currentTags.PSObject.Properties[$script:UpdateExcludedTagName]) {
            $tagsToMerge[$script:UpdateExcludedTagName] = 'False'
            [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = " Will default-stamp $($script:UpdateExcludedTagName) tag: 'False' (tag absent on cluster)" })
        }

        if ($updateAuthAccountIdValue) {
            $authIdToWrite = $updateAuthAccountIdValue.Trim()
            if ($authIdToWrite -notmatch '^\d{1,3}$') {
                throw "Invalid UpdateAuthAccountId '$authIdToWrite' for cluster '$clusterName' - must be numeric (1-3 digits, e.g. 001)."
            }
            $tagsToMerge[$script:UpdateAuthAccountIdTagName] = $authIdToWrite
            [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = " Will also set $($script:UpdateAuthAccountIdTagName) tag: $authIdToWrite" })
        }

        $tagDeltas = [System.Collections.Generic.List[string]]::new()
        foreach ($tagName in $tagsToMerge.Keys) {
            $newValue = [string]$tagsToMerge[$tagName]
            $oldValue = if ($currentTags.PSObject.Properties[$tagName]) { [string]$currentTags.$tagName } else { '<absent>' }
            if ($oldValue -ne $newValue) {
                [void]$tagDeltas.Add(("{0}: '{1}' -> '{2}'" -f $tagName, $oldValue, $newValue))
            }
        }

        $patchBodyObject = [PSCustomObject]@{
            operation  = 'Merge'
            properties = [PSCustomObject]@{ tags = [PSCustomObject]$tagsToMerge }
        }
        $successMessage = if ($tagDeltas.Count -gt 0) {
            "Tags $($action.ToLower()): " + ($tagDeltas -join '; ')
        }
        else {
            "UpdateRing tag $($action.ToLower()) successfully"
        }
        $whatIfMessage = if ($tagDeltas.Count -gt 0) {
            "Would $($action.ToLower()) tags: " + ($tagDeltas -join '; ')
        }
        else {
            "Would $($action.ToLower()) UpdateRing tag"
        }

        $plan = [PSCustomObject]@{
            InputIndex       = $inputIndex
            ClusterName      = $clusterName
            ResourceGroup    = $resourceGroup
            SubscriptionId   = $subscriptionId
            ResourceId       = $resourceId
            Action           = $action
            PreviousTagValue = $previousTagValue
            NewTagValue      = $currentUpdateRingValue
            PatchBody        = ($patchBodyObject | ConvertTo-Json -Compress -Depth 10)
            SuccessMessage   = $successMessage
            WhatIfMessage    = $whatIfMessage
        }
        return & $newEnvelope $null $plan
    }
    catch {
        $message = $_.Exception.Message
        [void]$logEntries.Add([PSCustomObject]@{ Level = 'Error'; Message = "Error processing cluster: $message" })
        return & $newEnvelope (& $newResult 'Error' 'Failed' $message) $null
    }
}

function Invoke-AzLocalUpdateRingTagPatch {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        $Plan,

        [Parameter(Mandatory = $false)]
        [bool]$CaptureVerbose = $false
    )

    if ($Plan -is [System.Collections.IDictionary]) {
        $Plan = [PSCustomObject]$Plan
    }

    $logEntries = [System.Collections.Generic.List[object]]::new()
    $verboseMessages = [System.Collections.Generic.List[string]]::new()
    $result = $null

    try {
        [void]$logEntries.Add([PSCustomObject]@{ Level = 'Info'; Message = "Applying UpdateRing tag with value: '$($Plan.NewTagValue)'..." })
        $tagsUri = "https://management.azure.com$($Plan.ResourceId)/providers/Microsoft.Resources/tags/default?api-version=2021-04-01"
        $restOutput = @(Invoke-AzRestJson -Uri $tagsUri -Method PATCH -Body ([string]$Plan.PatchBody) -Headers @('Content-Type=application/json') -Verbose:$CaptureVerbose 4>&1)
        $patchResponse = $null
        foreach ($outputItem in $restOutput) {
            if ($outputItem -is [System.Management.Automation.VerboseRecord]) {
                [void]$verboseMessages.Add([string]$outputItem.Message)
            }
            elseif ($outputItem -and $outputItem.PSObject.Properties['Ok']) {
                $patchResponse = $outputItem
            }
        }

        if ($patchResponse -and $patchResponse.Ok) {
            [void]$logEntries.Add([PSCustomObject]@{ Level = 'Success'; Message = "Successfully $(([string]$Plan.Action).ToLower()) UpdateRing tag" })
            $result = [PSCustomObject]@{
                ClusterName      = [string]$Plan.ClusterName
                ResourceGroup    = [string]$Plan.ResourceGroup
                SubscriptionId   = [string]$Plan.SubscriptionId
                ResourceId       = [string]$Plan.ResourceId
                Action           = [string]$Plan.Action
                PreviousTagValue = [string]$Plan.PreviousTagValue
                NewTagValue      = [string]$Plan.NewTagValue
                Status           = 'Success'
                Message          = [string]$Plan.SuccessMessage
            }
        }
        else {
            $errorMessage = if ($patchResponse -and $patchResponse.Error) { [string]$patchResponse.Error } else { 'No response returned by ARM.' }
            [void]$logEntries.Add([PSCustomObject]@{ Level = 'Error'; Message = "Failed to apply tag: $errorMessage" })
            $result = [PSCustomObject]@{
                ClusterName      = [string]$Plan.ClusterName
                ResourceGroup    = [string]$Plan.ResourceGroup
                SubscriptionId   = [string]$Plan.SubscriptionId
                ResourceId       = [string]$Plan.ResourceId
                Action           = [string]$Plan.Action
                PreviousTagValue = [string]$Plan.PreviousTagValue
                NewTagValue      = [string]$Plan.NewTagValue
                Status           = 'Failed'
                Message          = "Failed to apply tag: $errorMessage"
            }
        }
    }
    catch {
        $message = $_.Exception.Message
        [void]$logEntries.Add([PSCustomObject]@{ Level = 'Error'; Message = "Error applying tag: $message" })
        $result = [PSCustomObject]@{
            ClusterName      = [string]$Plan.ClusterName
            ResourceGroup    = [string]$Plan.ResourceGroup
            SubscriptionId   = [string]$Plan.SubscriptionId
            ResourceId       = [string]$Plan.ResourceId
            Action           = 'Error'
            PreviousTagValue = [string]$Plan.PreviousTagValue
            NewTagValue      = [string]$Plan.NewTagValue
            Status           = 'Failed'
            Message          = $message
        }
    }

    return [PSCustomObject]@{
        InputIndex      = [int]$Plan.InputIndex
        ResourceId      = [string]$Plan.ResourceId
        Result          = $result
        LogEntries      = $logEntries.ToArray()
        VerboseMessages = $verboseMessages.ToArray()
    }
}