Public/Set-AzLocalClusterUpdateRingTagFromCsv.ps1

function Set-AzLocalClusterUpdateRingTagFromCsv {
    <#
    .SYNOPSIS
        Runs the Step.2 "Manage UpdateRing Tags" pipeline workload: validates
        the operator-edited CSV, applies UpdateRing tags via
        Set-AzLocalClusterUpdateRingTag, writes a JSON results sidecar plus
        markdown step summary, and emits step outputs for the v0.8.5
        thin-YAML Step.2 pipeline.
 
    .DESCRIPTION
        Phase 1 (v0.8.5) of the thin-YAML refactor. Condenses the inline
        `run: |` body of the v0.8.4 `Step.2_manage-updatering-tags.yml`
        (GitHub Actions + Azure DevOps) into a single cmdlet call so the
        per-platform yml shrinks to a few lines and the workload becomes
        unit-testable against a mocked Set-AzLocalClusterUpdateRingTag.
 
        The cmdlet:
 
          1. Validates that the CSV file exists. When it does not, writes
             the operator-recovery instructions to host (the same TWO-STAGE
             workflow guidance the v0.8.4 yml used) and throws so the
             pipeline step fails with a clear message.
          2. Validates that the CSV contains the required columns
             (`ResourceId`, `UpdateRing`) and at least one row with a
             non-empty `UpdateRing` value.
          3. Resolves the output directory (defaults to `./artifacts` on
             GitHub Actions / local, or `$env:BUILD_ARTIFACTSTAGINGDIRECTORY`
             on Azure DevOps - matching the v0.8.4 yml).
          4. Calls `Set-AzLocalClusterUpdateRingTag -InputCsvPath <csv>
             -LogFolderPath <out> -PassThru` (with `-Force` / `-WhatIf`
             propagated from this cmdlet) ONCE.
          5. Serialises the per-cluster results to
             `UpdateRingTag_Results.json` next to the logs (the canonical
             sidecar the v0.8.4 Summary step consumed).
          6. Emits the markdown step summary (Settings table + Result
             breakdown table + per-cluster details) via
             `Add-AzLocalPipelineStepSummary`.
          7. Emits step outputs via `Set-AzLocalPipelineOutput`:
             `total_count`, `created_count`, `updated_count`,
             `already_in_sync_count`, `skipped_count`, `failed_count`,
             `whatif_count`, `results_json_path`.
 
        Internal reuse (per the v0.8.5 thin-YAML consistency contract):
          * `Set-AzLocalClusterUpdateRingTag` for the actual tag write.
          * `Add-AzLocalPipelineStepSummary` for the rendered markdown.
          * `Set-AzLocalPipelineOutput` for the step outputs.
          * `Get-AzLocalPipelineHost` is implicit (the above branch on it).
 
    .PARAMETER InputCsvPath
        Path to the operator-edited CSV produced by Step.1
        (Invoke-AzLocalClusterInventory). Must contain at minimum the
        columns `ResourceId` and `UpdateRing`. Rows with an empty
        `UpdateRing` are skipped by the inner cmdlet.
 
    .PARAMETER OutputDirectory
        Directory to write the JSON results sidecar and per-cluster log
        files into. Created if it does not exist. Defaults to
        `./artifacts` (which is what the v0.8.4 GH yml uses) or, on
        AzureDevOps, to `$env:BUILD_ARTIFACTSTAGINGDIRECTORY` if that env
        var is set (matching the v0.8.4 ADO yml).
 
    .PARAMETER Force
        Propagated to `Set-AzLocalClusterUpdateRingTag -Force`. When set,
        existing UpdateRing tag values are overwritten. Without it,
        clusters that already carry an UpdateRing value are skipped with
        a Status of 'Skipped'.
 
    .PARAMETER ResultsJsonFileName
        Filename for the per-cluster JSON results sidecar. Default
        `UpdateRingTag_Results.json` - matches the v0.8.4 yml.
 
    .PARAMETER InstalledModuleVersion
        Optional [string] surfaced in the step summary footer
        ('Generated by AzLocal.UpdateManagement v<x>').
 
    .PARAMETER SummaryFileName
        Per-task summary filename used by `Add-AzLocalPipelineStepSummary`
        on Azure DevOps and Local hosts. Default
        `updatering-tag-summary.md`.
 
    .PARAMETER PassThru
        When set, returns a single PSCustomObject summarising the run
        (TotalCount, CreatedCount, UpdatedCount, AlreadyInSyncCount,
        SkippedCount, FailedCount, WhatIfCount, ResultsJsonPath, Results).
        Without -PassThru the cmdlet emits nothing to the pipeline; the
        artifacts and step outputs are still produced.
 
    .OUTPUTS
        Nothing by default. When -PassThru is set, a single PSCustomObject:
          TotalCount [int]
          CreatedCount [int]
          UpdatedCount [int]
          AlreadyInSyncCount [int]
          SkippedCount [int]
          FailedCount [int]
          WhatIfCount [int]
          ResultsJsonPath [string]
          Results [PSCustomObject[]]
 
    .EXAMPLE
        Set-AzLocalClusterUpdateRingTagFromCsv -InputCsvPath ./config/ClusterUpdateRings.csv -WhatIf
 
        Previews the tag changes that would be applied without writing
        anything to Azure. Emits the same markdown summary + JSON sidecar
        as a real run (every row is Status='WhatIf').
 
    .EXAMPLE
        Set-AzLocalClusterUpdateRingTagFromCsv -InputCsvPath ./config/ClusterUpdateRings.csv -Force -PassThru
 
        Applies the tags, overwriting any existing UpdateRing values, and
        returns the per-cluster summary object.
 
    .NOTES
        Module: AzLocal.UpdateManagement (v0.8.5+)
        Roadmap: Step.2 - Manage UpdateRing Tags.
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$InputCsvPath,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$OutputDirectory,

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

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$ResultsJsonFileName = 'UpdateRingTag_Results.json',

        [Parameter(Mandatory = $false)]
        [AllowEmptyString()]
        [AllowNull()]
        [string]$InstalledModuleVersion,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$SummaryFileName = 'updatering-tag-summary.md',

        [Parameter(Mandatory = $false)]
        [switch]$PassThru
    )

    $pipelineHost = Get-AzLocalPipelineHost

    # ----- 1. CSV existence guard --------------------------------------
    if (-not (Test-Path -LiteralPath $InputCsvPath -PathType Leaf)) {
        Write-Host ''
        Write-Host '==========================================================================='
        Write-Host ' UpdateRing tagging is a TWO-STAGE workflow. You must run the inventory'
        Write-Host ' pipeline first, edit the CSV, commit it, then re-run THIS pipeline.'
        Write-Host '==========================================================================='
        Write-Host ''
        Write-Host 'STEP 1 - Generate the cluster inventory (Step.1 pipeline).'
        Write-Host 'STEP 2 - Edit ClusterUpdateRings.csv in Excel and fill in the UpdateRing column.'
        Write-Host 'STEP 3 - Commit the edited CSV into your ops repo (recommended path config/ClusterUpdateRings.csv).'
        Write-Host 'STEP 4 - Re-run THIS pipeline pointing at the committed CSV.'
        Write-Host ''
        throw "CSV file not found at path: $InputCsvPath. See operator instructions above."
    }

    # ----- 2. CSV column / value guard ---------------------------------
    $rows = @(Import-Csv -LiteralPath $InputCsvPath)
    $requiredColumns = @('ResourceId', 'UpdateRing')
    if ($rows.Count -gt 0) {
        $columns = $rows[0].PSObject.Properties.Name
        foreach ($col in $requiredColumns) {
            if ($col -notin $columns) {
                throw "Required column '$col' not found in CSV: $InputCsvPath"
            }
        }
    }
    else {
        throw "CSV file is empty (no rows): $InputCsvPath. Re-run the Step.1 inventory pipeline to regenerate the file."
    }

    $rowsWithValues = @($rows | Where-Object { $_.UpdateRing -and $_.UpdateRing.Trim() -ne '' }).Count
    Write-Host ("CSV File: {0}" -f $InputCsvPath)
    Write-Host ("Total Rows: {0}" -f $rows.Count)
    Write-Host ("Rows with UpdateRing values: {0}" -f $rowsWithValues)
    if ($rowsWithValues -eq 0) {
        throw "No rows have UpdateRing values set. Edit the CSV and set UpdateRing values before running this pipeline."
    }

    # ----- 3. Output directory -----------------------------------------
    if (-not $OutputDirectory) {
        if ($pipelineHost -eq 'AzureDevOps' -and $env:BUILD_ARTIFACTSTAGINGDIRECTORY) {
            $OutputDirectory = $env:BUILD_ARTIFACTSTAGINGDIRECTORY
        }
        else {
            $OutputDirectory = './artifacts'
        }
    }
    if (-not (Test-Path -LiteralPath $OutputDirectory)) {
        New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
    }

    # ----- 4. Apply tags via Set-AzLocalClusterUpdateRingTag -----------
    $applyParams = @{
        InputCsvPath  = $InputCsvPath
        LogFolderPath = $OutputDirectory
        PassThru      = $true
    }
    if ($Force) {
        $applyParams['Force'] = $true
        Write-Host 'Force mode enabled - will overwrite existing tags'
    }
    if ($WhatIfPreference) {
        $applyParams['WhatIf'] = $true
        Write-Host 'DRY RUN MODE - No changes will be applied'
    }

    Write-Host ''
    Write-Host '========================================'
    Write-Host 'Applying UpdateRing Tags'
    Write-Host '========================================'

    $results = @(Set-AzLocalClusterUpdateRingTag @applyParams)

    # Defence-in-depth: filter to only per-cluster result objects so any stray
    # Format-Table formatter objects (header / row / footer wrappers) from
    # older module versions cannot inflate $results.Count. A genuine result row
    # always exposes a ClusterName string property; formatter objects do not.
    $results = @($results | Where-Object {
        $_ -and $_.PSObject.Properties['ClusterName'] -and $_.ClusterName -is [string]
    })

    # ----- 5. JSON sidecar ---------------------------------------------
    $resultsJsonPath = Join-Path -Path $OutputDirectory -ChildPath $ResultsJsonFileName
    if ($results.Count -gt 0) {
        $results | ConvertTo-Json -Depth 5 | Out-File -FilePath $resultsJsonPath -Encoding utf8
        Write-Host ("Wrote {0} per-cluster result row(s) to: {1}" -f $results.Count, $resultsJsonPath)
    }
    else {
        '[]' | Out-File -FilePath $resultsJsonPath -Encoding utf8
        Write-Host ("No per-cluster results returned (empty CSV?). Wrote empty array to: {0}" -f $resultsJsonPath)
    }

    # ----- 6. Tally outcomes -------------------------------------------
    $total         = $results.Count
    $created       = @($results | Where-Object { $_.Action -eq 'Created'  -and $_.Status -eq 'Success' }).Count
    $updated       = @($results | Where-Object { $_.Action -eq 'Updated'  -and $_.Status -eq 'Success' }).Count
    $alreadyInSync = @($results | Where-Object { $_.Status -eq 'AlreadyInSync' }).Count
    $skipped       = @($results | Where-Object { $_.Status -eq 'Skipped'       }).Count
    $failed        = @($results | Where-Object { $_.Status -eq 'Failed'        }).Count
    $whatIfCount   = @($results | Where-Object { $_.Status -eq 'WhatIf'        }).Count

    Write-Host ''
    Write-Host '========================================'
    Write-Host 'Tag management complete'
    Write-Host '========================================'
    Write-Host ("Total clusters processed: {0}" -f $total)
    Write-Host ("Tags created : {0}" -f $created)
    Write-Host ("Tags updated : {0}" -f $updated)
    Write-Host ("Already in sync : {0}" -f $alreadyInSync)
    Write-Host ("Skipped (no -Force) : {0}" -f $skipped)
    Write-Host ("WhatIf (dry-run) : {0}" -f $whatIfCount)
    Write-Host ("Failed : {0}" -f $failed)

    # ----- 7. Step outputs ---------------------------------------------
    Set-AzLocalPipelineOutput -Name 'total_count'           -Value ([string]$total)
    Set-AzLocalPipelineOutput -Name 'created_count'         -Value ([string]$created)
    Set-AzLocalPipelineOutput -Name 'updated_count'         -Value ([string]$updated)
    Set-AzLocalPipelineOutput -Name 'already_in_sync_count' -Value ([string]$alreadyInSync)
    Set-AzLocalPipelineOutput -Name 'skipped_count'         -Value ([string]$skipped)
    Set-AzLocalPipelineOutput -Name 'failed_count'          -Value ([string]$failed)
    Set-AzLocalPipelineOutput -Name 'whatif_count'          -Value ([string]$whatIfCount)
    Set-AzLocalPipelineOutput -Name 'results_json_path'     -Value ([string]$resultsJsonPath)

    # ----- 8. Markdown step summary ------------------------------------
    $sb = New-Object System.Text.StringBuilder
    [void]$sb.AppendLine('## Step.2 - UpdateRing Tag Management Summary')
    [void]$sb.AppendLine('')
    [void]$sb.AppendLine('| Setting | Value |')
    [void]$sb.AppendLine('|---------|-------|')
    [void]$sb.AppendLine(("| Dry Run | {0} |"         -f $WhatIfPreference.ToString()))
    [void]$sb.AppendLine(("| Force Overwrite | {0} |" -f $Force.IsPresent))
    [void]$sb.AppendLine(("| Input CSV | {0} |"       -f $InputCsvPath))
    [void]$sb.AppendLine('')
    [void]$sb.AppendLine('### Result breakdown')
    [void]$sb.AppendLine('')
    [void]$sb.AppendLine('| Outcome | Count |')
    [void]$sb.AppendLine('|---------|------:|')
    [void]$sb.AppendLine(("| Total clusters processed | {0} |"                 -f $total))
    [void]$sb.AppendLine(("| Tags created | {0} |"                 -f $created))
    [void]$sb.AppendLine(("| Tags updated | {0} |"                 -f $updated))
    [void]$sb.AppendLine(("| Already in sync (no-op) | {0} |"                 -f $alreadyInSync))
    [void]$sb.AppendLine(("| Skipped (UpdateRing differs, no -Force) | {0} |"  -f $skipped))
    if ($whatIfCount -gt 0) {
        [void]$sb.AppendLine(("| WhatIf (dry-run preview) | {0} |" -f $whatIfCount))
    }
    [void]$sb.AppendLine(("| Failed | {0} |" -f $failed))
    if ($total -gt 0) {
        # Bucket the per-cluster results so the summary UI is easier to scan:
        # - "Tag Updates Applied" : rows that actually changed at least one
        # managed tag (Status=Success or WhatIf).
        # - "No Tag Updates (no-op)" : steady-state rows where every managed
        # tag already matched desired state (Status=AlreadyInSync).
        # - "Skipped / Failed" : rows that need operator attention.
        $appliedRows = @($results | Where-Object { $_.Status -eq 'Success' -or $_.Status -eq 'WhatIf' })
        $noopRows    = @($results | Where-Object { $_.Status -eq 'AlreadyInSync' })
        $issueRows   = @($results | Where-Object { $_.Status -eq 'Skipped' -or $_.Status -eq 'Failed' })

        $renderRows = {
            param($rows)
            [void]$sb.AppendLine('| Cluster | Action | Previous | New | Status | Message |')
            [void]$sb.AppendLine('|---------|--------|----------|-----|--------|---------|')
            foreach ($r in $rows) {
                $cn  = ([string]$r.ClusterName)      -replace '\|','\|'
                $act = ([string]$r.Action)           -replace '\|','\|'
                $pv  = ([string]$r.PreviousTagValue) -replace '\|','\|'
                $nv  = ([string]$r.NewTagValue)      -replace '\|','\|'
                $st  = ([string]$r.Status)           -replace '\|','\|'
                $msg = (([string]$r.Message)         -replace '\|','\|') -replace '\r?\n',' '
                [void]$sb.AppendLine(("| {0} | {1} | {2} | {3} | {4} | {5} |" -f $cn, $act, $pv, $nv, $st, $msg))
            }
        }

        if ($appliedRows.Count -gt 0) {
            [void]$sb.AppendLine('')
            [void]$sb.AppendLine(("<details open><summary>Clusters with Tag Updates Applied ({0} rows)</summary>" -f $appliedRows.Count))
            [void]$sb.AppendLine('')
            & $renderRows $appliedRows
            [void]$sb.AppendLine('')
            [void]$sb.AppendLine('</details>')
        }

        if ($issueRows.Count -gt 0) {
            [void]$sb.AppendLine('')
            [void]$sb.AppendLine(("<details open><summary>Clusters Skipped or Failed ({0} rows)</summary>" -f $issueRows.Count))
            [void]$sb.AppendLine('')
            & $renderRows $issueRows
            [void]$sb.AppendLine('')
            [void]$sb.AppendLine('</details>')
        }

        if ($noopRows.Count -gt 0) {
            [void]$sb.AppendLine('')
            [void]$sb.AppendLine(("<details><summary>Clusters with No Tag Updates (no-op) ({0} rows)</summary>" -f $noopRows.Count))
            [void]$sb.AppendLine('')
            & $renderRows $noopRows
            [void]$sb.AppendLine('')
            [void]$sb.AppendLine('</details>')
        }
    }
    if ($WhatIfPreference) {
        [void]$sb.AppendLine('')
        [void]$sb.AppendLine('**This was a dry run. No changes were applied.**')
    }
    if ($InstalledModuleVersion) {
        [void]$sb.AppendLine('')
        [void]$sb.AppendLine(("_Generated by AzLocal.UpdateManagement v{0}._" -f $InstalledModuleVersion))
    }

    Add-AzLocalPipelineStepSummary -Markdown $sb.ToString() -SummaryFileName $SummaryFileName | Out-Null

    if ($PassThru) {
        return [pscustomobject]@{
            TotalCount         = $total
            CreatedCount       = $created
            UpdatedCount       = $updated
            AlreadyInSyncCount = $alreadyInSync
            SkippedCount       = $skipped
            FailedCount        = $failed
            WhatIfCount        = $whatIfCount
            ResultsJsonPath    = $resultsJsonPath
            Results            = $results
        }
    }
}