Public/Invoke-AzLocalReadinessGatedClusterUpdate.ps1

function Invoke-AzLocalReadinessGatedClusterUpdate {
    <#
    .SYNOPSIS
        Loads the readiness CSV produced by Export-AzLocalClusterReadinessGateReport,
        applies updates to the gated cluster set via Start-AzLocalClusterUpdate,
        emits per-status step outputs (SUCCEEDED/SKIPPED/FAILED/HEALTH_BLOCKED/
        SCHEDULE_BLOCKED/SIDELOADED_BLOCKED/EXCLUDED_BY_TAG), and persists
        per-cluster apply results to apply-results.json.
    .DESCRIPTION
        v0.8.5 Step.6 thin-YAML helper. Replaces the ~110-line inline `run:`
        block that lived in both Step.6_apply-updates.yml pipelines.
 
        Behaviour matches the prior inline block byte-for-byte:
          - Loads readiness-report.csv, validates the ClusterResourceId
            column exists (introduced in v0.7.62), extracts rows with
            ReadyForUpdate='True' AND non-empty ClusterResourceId.
          - When the ready set is empty: emits zero counts and exits cleanly
            (matches the prior 'nothing to apply' short-circuit).
          - Otherwise: invokes Start-AzLocalClusterUpdate with the same
            parameter shape the inline block built (-ClusterResourceIds,
            -Force, -LogFolderPath, -ExportResultsPath, optional -UpdateName,
            optional -AllowedUpdateVersions, optional -WhatIf for dry runs).
          - Counts results into the seven status buckets and writes them as
            cross-job step outputs.
          - Persists per-cluster results to apply-results.json (selected
            columns: ClusterName, Status, UpdateName, Duration, Message).
          - On Azure DevOps: emits the same per-bucket warning/error
            task.logissue lines the inline block did (preserving CI behaviour).
    .PARAMETER ReadinessCsvPath
        Path to readiness-report.csv produced by the check-readiness job.
    .PARAMETER UpdateRing
        UpdateRing label used in console output (e.g. 'Wave1'). Cosmetic only;
        the actual apply scope comes from ReadinessCsvPath.
    .PARAMETER UpdateName
        Optional specific update name to apply (forwarded to
        Start-AzLocalClusterUpdate -UpdateName). Empty/whitespace = the
        cmdlet picks the latest Ready update on each cluster.
    .PARAMETER DryRun
        Switch. When set, Start-AzLocalClusterUpdate -WhatIf is invoked so no
        updates actually start.
    .PARAMETER AllowedUpdateVersions
        String[] or single ';'-joined string. Allow-list resolved from
        apply-updates-schedule.yml schema-v2 'allowedUpdateVersions'. Empty =
        no allow-list applied.
    .PARAMETER OutputDirectory
        Directory where logs / update-results.xml / apply-results.json are
        written. Defaults to the readiness CSV's parent directory.
    .PARAMETER JUnitFileName
        JUnit XML filename. Default 'update-results.xml'.
    .PARAMETER ApplyResultsJsonFileName
        Per-cluster JSON filename consumed by Add-AzLocalApplyUpdatesStepSummary.
        Default 'apply-results.json'.
    .PARAMETER PassThru
        Returns PSCustomObject with all seven counters + Results +
        JUnitPath + ApplyResultsJsonPath + ReadyResourceIds (the ARM IDs
        actually handed to Start-AzLocalClusterUpdate).
    .NOTES
        Author : AzLocal.UpdateManagement
        Version : 0.8.5 (Step.6 thin-YAML port)
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([void])]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$ReadinessCsvPath,

        [Parameter(Mandatory = $false)]
        [AllowEmptyString()]
        [string]$UpdateRing = '',

        [Parameter(Mandatory = $false)]
        [AllowEmptyString()]
        [string]$UpdateName = '',

        [switch]$DryRun,

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

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

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$JUnitFileName = 'update-results.xml',

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$ApplyResultsJsonFileName = 'apply-results.json',

        [switch]$PassThru
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $pipelineHost = Get-AzLocalPipelineHost

    # Normalise AllowedUpdateVersions into a [string[]] (accept string,
    # string[], or null/empty).
    [string[]]$allowList = @()
    if ($AllowedUpdateVersions) {
        if ($AllowedUpdateVersions -is [string]) {
            if (-not [string]::IsNullOrWhiteSpace($AllowedUpdateVersions)) {
                $allowList = @(($AllowedUpdateVersions -split ';') | ForEach-Object { $_.Trim() } | Where-Object { $_ })
            }
        }
        else {
            $allowList = @($AllowedUpdateVersions | ForEach-Object { "$_".Trim() } | Where-Object { $_ })
        }
    }

    # OutputDirectory default: parent of the readiness CSV.
    if (-not $OutputDirectory) {
        $OutputDirectory = Split-Path -Path $ReadinessCsvPath -Parent
        if (-not $OutputDirectory) { $OutputDirectory = '.' }
    }
    if (-not (Test-Path -LiteralPath $OutputDirectory)) {
        New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
    }

    $junitPath = Join-Path -Path $OutputDirectory -ChildPath $JUnitFileName
    $applyJsonPath = Join-Path -Path $OutputDirectory -ChildPath $ApplyResultsJsonFileName

    # Per-host step-output naming - PRESERVE existing pipeline downstream
    # bindings byte-for-byte: GH uses UPPER_SNAKE, ADO uses PascalCase
    # (e.g. stageDependencies...outputs['applyUpdates.Succeeded']).
    if ($pipelineHost -eq 'AzureDevOps') {
        $nSucceeded = 'Succeeded'; $nSkipped = 'Skipped'; $nFailed = 'Failed'
        $nHealth   = 'HealthBlocked'; $nSchedule = 'ScheduleBlocked'
        $nSideload = 'SideloadedBlocked'; $nExcluded = 'ExcludedByTag'
    }
    else {
        $nSucceeded = 'SUCCEEDED'; $nSkipped = 'SKIPPED'; $nFailed = 'FAILED'
        $nHealth   = 'HEALTH_BLOCKED'; $nSchedule = 'SCHEDULE_BLOCKED'
        $nSideload = 'SIDELOADED_BLOCKED'; $nExcluded = 'EXCLUDED_BY_TAG'
    }

    # Helper to emit the seven counters as step outputs. Do NOT call
    # .GetNewClosure() here - it severs the scriptblock from the module's
    # SessionState, making the private Set-AzLocalPipelineOutput function
    # invisible. Lexical scoping already gives this scriptblock access to
    # the $n* variables since '& $emitCounters' is invoked from within the
    # same function.
    $emitCounters = {
        param($s, $sk, $f, $hb, $scb, $sb, $ebt)
        Set-AzLocalPipelineOutput -Name $nSucceeded -Value "$s"   -CrossJob
        Set-AzLocalPipelineOutput -Name $nSkipped   -Value "$sk"  -CrossJob
        Set-AzLocalPipelineOutput -Name $nFailed    -Value "$f"   -CrossJob
        Set-AzLocalPipelineOutput -Name $nHealth    -Value "$hb"  -CrossJob
        Set-AzLocalPipelineOutput -Name $nSchedule  -Value "$scb" -CrossJob
        Set-AzLocalPipelineOutput -Name $nSideload  -Value "$sb"  -CrossJob
        Set-AzLocalPipelineOutput -Name $nExcluded  -Value "$ebt" -CrossJob
    }

    if (-not (Test-Path -LiteralPath $ReadinessCsvPath)) {
        throw "Invoke-AzLocalReadinessGatedClusterUpdate: Readiness CSV not found at '$ReadinessCsvPath'. The check-readiness job did not upload a readiness-report artifact - cannot determine which clusters to apply."
    }

    $readinessRows = @(Import-Csv -Path $ReadinessCsvPath)
    if ($readinessRows.Count -gt 0 -and -not ($readinessRows[0].PSObject.Properties.Name -contains 'ClusterResourceId')) {
        throw "Invoke-AzLocalReadinessGatedClusterUpdate: Readiness CSV at '$ReadinessCsvPath' is missing the 'ClusterResourceId' column. This column was added in AzLocal.UpdateManagement v0.7.62. Re-run check-readiness with v0.7.62+ or refresh the pipeline YAML via Copy-AzLocalPipelineExample -Update."
    }

    [string[]]$readyResourceIds = @($readinessRows |
            Where-Object { $_.ReadyForUpdate -eq 'True' -and $_.ClusterResourceId } |
            ForEach-Object { $_.ClusterResourceId })

    Write-Host "Readiness CSV: $($readinessRows.Count) row(s), $($readyResourceIds.Count) marked ReadyForUpdate=True."

    if ($readyResourceIds.Count -eq 0) {
        # Preserve original per-host wording for the 'nothing to apply' warning.
        switch ($pipelineHost) {
            'GitHub'      { Write-Host "::warning::Readiness CSV reports zero clusters with ReadyForUpdate=True - nothing to apply." }
            'AzureDevOps' { Write-Host "##vso[task.logissue type=warning]Readiness CSV reports zero clusters with ReadyForUpdate=True - nothing to apply." }
            default       { Write-Warning "Readiness CSV reports zero clusters with ReadyForUpdate=True - nothing to apply." }
        }
        & $emitCounters 0 0 0 0 0 0 0
        if ($PassThru) {
            return [pscustomobject]@{
                Succeeded             = 0
                Skipped               = 0
                Failed                = 0
                HealthBlocked         = 0
                ScheduleBlocked       = 0
                SideloadedBlocked     = 0
                ExcludedByTag         = 0
                Results               = @()
                JUnitPath             = $junitPath
                ApplyResultsJsonPath  = $applyJsonPath
                ReadyResourceIds      = @()
            }
        }
        return
    }

    $applyParams = @{
        ClusterResourceIds = $readyResourceIds
        Force              = $true
        LogFolderPath      = $OutputDirectory
        ExportResultsPath  = $junitPath
    }

    if ($UpdateName -and $UpdateName -ne '') {
        $applyParams['UpdateName'] = $UpdateName
        Write-Host "Applying specific update: $UpdateName"
    }

    if ($allowList.Count -gt 0) {
        $applyParams['AllowedUpdateVersions'] = $allowList
        Write-Host "AllowedUpdateVersions allow-list (schema v2): [$($allowList -join ', ')]. Clusters with no Ready update matching the list will be skipped with status 'NotInAllowList'."
    }

    if ($DryRun) {
        $applyParams['WhatIf'] = $true
        # Preserve original per-host wording.
        switch ($pipelineHost) {
            'AzureDevOps' { Write-Host "##vso[task.logissue type=warning]DRY RUN MODE - No updates will be applied" }
            default       { Write-Host "DRY RUN MODE - No updates will be applied" }
        }
    }

    Write-Host ""
    Write-Host "========================================" -ForegroundColor Cyan
    Write-Host "Applying Updates to UpdateRing: $UpdateRing" -ForegroundColor Cyan
    Write-Host " Clusters (from readiness CSV): $($readyResourceIds.Count)" -ForegroundColor Cyan
    Write-Host "========================================" -ForegroundColor Cyan

    $results = @(Start-AzLocalClusterUpdate @applyParams -PassThru)

    Write-Host ""
    Write-Host "Update operation complete"

    $succeeded         = @($results | Where-Object { $_.Status -eq 'Started' -or $_.Status -eq 'Success' -or $_.Status -eq 'UpdateStarted' }).Count
    $skipped           = @($results | Where-Object { $_.Status -in @('Skipped', 'NotReady', 'NoUpdatesAvailable', 'NoReadyUpdates', 'NotFound', 'UpdateNotFound', 'NotInAllowList') }).Count
    $failed            = @($results | Where-Object { $_.Status -in @('Failed', 'Error') }).Count
    $healthBlocked     = @($results | Where-Object { $_.Status -eq 'HealthCheckBlocked' }).Count
    $scheduleBlocked   = @($results | Where-Object { $_.Status -eq 'ScheduleBlocked' }).Count
    $sideloadedBlocked = @($results | Where-Object { $_.Status -eq 'SideloadedBlocked' }).Count
    $excludedByTag     = @($results | Where-Object { $_.Status -eq 'ExcludedByTag' }).Count

    & $emitCounters $succeeded $skipped $failed $healthBlocked $scheduleBlocked $sideloadedBlocked $excludedByTag

    # Persist per-cluster apply results to JSON for the downstream Summary step.
    @($results) | Select-Object ClusterName, Status, UpdateName, Duration, Message |
        ConvertTo-Json -Depth 4 |
        Out-File -FilePath $applyJsonPath -Encoding utf8 -Force
    Write-Host "Wrote per-cluster apply results to $applyJsonPath"

    # ADO-only: per-bucket warning/error log lines (preserves CI surface).
    if ($pipelineHost -eq 'AzureDevOps') {
        if ($failed -gt 0) {
            if ($succeeded -eq 0 -and $skipped -eq 0 -and $healthBlocked -eq 0 -and $scheduleBlocked -eq 0 -and $sideloadedBlocked -eq 0 -and $excludedByTag -eq 0) {
                Write-Host "##vso[task.logissue type=error]All $failed cluster(s) in scope failed to start updates. Review the Azure Local portal, cluster health, and the published update-results.xml for per-cluster detail."
            }
            else {
                Write-Host "##vso[task.logissue type=warning]$failed cluster(s) failed to start updates. $succeeded succeeded, $skipped skipped, $healthBlocked health-blocked, $scheduleBlocked schedule-blocked, $sideloadedBlocked sideloaded-blocked, $excludedByTag excluded-by-tag. See update-results.xml for per-cluster detail."
            }
        }
        if ($healthBlocked -gt 0) {
            Write-Host "##vso[task.logissue type=warning]$healthBlocked cluster(s) blocked by critical health check failures"
        }
        if ($scheduleBlocked -gt 0) {
            Write-Host "##vso[task.logissue type=warning]$scheduleBlocked cluster(s) blocked by maintenance schedule (outside UpdateStartWindow or in UpdateExclusionsWindow period)"
        }
        if ($sideloadedBlocked -gt 0) {
            Write-Host "##vso[task.logissue type=warning]$sideloadedBlocked cluster(s) blocked by UpdateSideloaded=False - operator must stage the sideloaded payload and flip the tag (or run Reset-AzLocalSideloadedTag) before updates can proceed"
        }
        if ($excludedByTag -gt 0) {
            Write-Host "##vso[task.logissue type=warning]$excludedByTag cluster(s) excluded by UpdateExcluded=True operator override - flip the UpdateExcluded tag to False (Azure portal or 'az tag update') once the cluster should rejoin automation"
        }
    }

    if ($PassThru) {
        return [pscustomobject]@{
            Succeeded            = $succeeded
            Skipped              = $skipped
            Failed               = $failed
            HealthBlocked        = $healthBlocked
            ScheduleBlocked      = $scheduleBlocked
            SideloadedBlocked    = $sideloadedBlocked
            ExcludedByTag        = $excludedByTag
            Results              = $results
            JUnitPath            = $junitPath
            ApplyResultsJsonPath = $applyJsonPath
            ReadyResourceIds     = $readyResourceIds
        }
    }
}