Public/Add-AzLocalApplyUpdatesStepSummary.ps1
|
function Add-AzLocalApplyUpdatesStepSummary { <# .SYNOPSIS Renders the Update Application Summary markdown for the apply-updates job in Step.6. Replaces ~100 lines of inline `run:` summary boilerplate in both Step.6 pipeline YAMLs. .DESCRIPTION v0.8.5 Step.6 thin-YAML helper. Emits the same markdown layout the prior inline blocks produced: - H1: 'Update Application Summary' (ADO) / H2 (GitHub - already inside a single $GITHUB_STEP_SUMMARY file). - Target UpdateRing line. - 'This was a dry run' note when -DryRun is set. - Readiness KPI table (Total / Ready). - Results KPI table (the seven status buckets). - Cluster Actions table read from apply-results.json (when present). - Clusters Skipped at Readiness Gate table read from readiness-report.csv (when present, capped at MaxSkippedRows). - Actions Required bullet list (one bullet per non-zero blocking bucket, with the same operator-facing remediation text). Per-host emoji choice (preserved byte-for-byte from the prior inline blocks): - GitHub Actions : Unicode emoji glyphs (U+2705 / U+274C / U+26D4 / U+23ED). - Azure DevOps : GitHub-Markdown shortcode tokens (:white_check_mark: / :x: / :no_entry: / :next_track_button: / :warning:). - Local : same as GitHub (Unicode emoji glyphs). .PARAMETER UpdateRing Resolved UpdateRing label (string). .PARAMETER DryRun Switch. When set, the dry-run note is rendered and Actions Required is suppressed (matching prior inline behaviour on ADO). .PARAMETER TotalCount Readiness total count (string-or-int accepted; coerced to string for display, to int for KPI checks). .PARAMETER ReadyCount Readiness ready count. .PARAMETER Succeeded Apply succeeded count. .PARAMETER Skipped Apply skipped count. .PARAMETER Failed Apply failed count. .PARAMETER HealthBlocked Apply health-blocked count. .PARAMETER ScheduleBlocked Apply schedule-blocked count. .PARAMETER SideloadedBlocked Apply sideloaded-blocked count. .PARAMETER ExcludedByTag Apply excluded-by-tag count. .PARAMETER ApplyResultsJsonPath Path to apply-results.json written by Invoke-AzLocalReadinessGatedClusterUpdate. When the file is missing the Cluster Actions table is omitted. .PARAMETER ReadinessCsvPath Path to readiness-report.csv. When missing the Clusters Skipped table is omitted. .PARAMETER MaxClusterActionRows Maximum cluster action rows rendered. Default 200 (the prior inline block had no explicit cap - the entire list was rendered). .PARAMETER MaxSkippedRows Maximum skipped-cluster rows rendered. Default 100 (matches prior). .PARAMETER SummaryFileName Per-task markdown filename (ADO/Local only). Default 'azlocal-step6-apply-summary.md'. .PARAMETER PassThru Returns PSCustomObject with: SummaryPath, ActionsRequiredCount, ClusterActionsRendered, SkippedClustersRendered. .NOTES Author : AzLocal.UpdateManagement Version : 0.8.5 (Step.6 thin-YAML port) #> [CmdletBinding()] [OutputType([void])] [OutputType([pscustomobject])] param( [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$UpdateRing, [switch]$DryRun, [Parameter(Mandatory = $false)] [object]$TotalCount = 0, [Parameter(Mandatory = $false)] [object]$ReadyCount = 0, [Parameter(Mandatory = $false)] [object]$Succeeded = 0, [Parameter(Mandatory = $false)] [object]$Skipped = 0, [Parameter(Mandatory = $false)] [object]$Failed = 0, [Parameter(Mandatory = $false)] [object]$HealthBlocked = 0, [Parameter(Mandatory = $false)] [object]$ScheduleBlocked = 0, [Parameter(Mandatory = $false)] [object]$SideloadedBlocked = 0, [Parameter(Mandatory = $false)] [object]$ExcludedByTag = 0, [Parameter(Mandatory = $false)] [AllowEmptyString()] [string]$ApplyResultsJsonPath = '', [Parameter(Mandatory = $false)] [AllowEmptyString()] [string]$ReadinessCsvPath = '', [Parameter(Mandatory = $false)] [ValidateRange(1, 10000)] [int]$MaxClusterActionRows = 200, [Parameter(Mandatory = $false)] [ValidateRange(1, 10000)] [int]$MaxSkippedRows = 100, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$SummaryFileName = 'azlocal-step6-apply-summary.md', [switch]$PassThru ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $pipelineHost = Get-AzLocalPipelineHost $useShortcodeIcons = ($pipelineHost -eq 'AzureDevOps') # Unicode emoji glyphs (GH/Local) vs GitHub-Markdown shortcodes (ADO). $iconSuccess = if ($useShortcodeIcons) { ':white_check_mark:' } else { [string][char]0x2705 } $iconFail = if ($useShortcodeIcons) { ':x:' } else { [string][char]0x274C } $iconBlock = if ($useShortcodeIcons) { ':no_entry:' } else { [string][char]0x26D4 } $iconSkip = if ($useShortcodeIcons) { ':next_track_button:' } else { ([string][char]0x23ED + [string][char]0xFE0F) } $iconWarn = if ($useShortcodeIcons) { ':warning:' } else { ([string][char]0x26A0 + [string][char]0xFE0F) } $headingLevel = if ($pipelineHost -eq 'AzureDevOps') { '#' } else { '##' } # Coerce numeric inputs. $totalInt = [int]([string]$TotalCount) $readyInt = [int]([string]$ReadyCount) $succeededInt = [int]([string]$Succeeded) $skippedInt = [int]([string]$Skipped) $failedInt = [int]([string]$Failed) $healthBlockedInt = [int]([string]$HealthBlocked) $scheduleBlockedInt = [int]([string]$ScheduleBlocked) $sideloadedInt = [int]([string]$SideloadedBlocked) $excludedTagInt = [int]([string]$ExcludedByTag) $sb = New-Object System.Text.StringBuilder [void]$sb.AppendLine("$headingLevel Update Application Summary") [void]$sb.AppendLine() [void]$sb.AppendLine("**Target UpdateRing:** $UpdateRing") [void]$sb.AppendLine() if ($DryRun) { [void]$sb.AppendLine("**This was a dry run. No updates were applied.**") [void]$sb.AppendLine() } $h3 = if ($pipelineHost -eq 'AzureDevOps') { '##' } else { '###' } [void]$sb.AppendLine("$h3 Readiness") [void]$sb.AppendLine() [void]$sb.AppendLine('| Metric | Count |') [void]$sb.AppendLine('|--------|-------|') [void]$sb.AppendLine("| Total Clusters | $totalInt |") [void]$sb.AppendLine("| Ready for Update | $readyInt |") [void]$sb.AppendLine() [void]$sb.AppendLine("$h3 Results") [void]$sb.AppendLine() [void]$sb.AppendLine('| Status | Count |') [void]$sb.AppendLine('|--------|-------|') [void]$sb.AppendLine("| Updates Started | $succeededInt |") [void]$sb.AppendLine("| Skipped | $skippedInt |") [void]$sb.AppendLine("| Failed | $failedInt |") [void]$sb.AppendLine("| Health Blocked | $healthBlockedInt |") [void]$sb.AppendLine("| Schedule Blocked | $scheduleBlockedInt |") [void]$sb.AppendLine("| Sideloaded Blocked | $sideloadedInt |") [void]$sb.AppendLine("| Excluded By Tag | $excludedTagInt |") $actionsRendered = 0 # Per-cluster apply results table (from apply-results.json). if ($ApplyResultsJsonPath -and (Test-Path -LiteralPath $ApplyResultsJsonPath)) { $applyRows = @(Get-Content -Raw -Path $ApplyResultsJsonPath | ConvertFrom-Json) if ($applyRows.Count -gt 0) { [void]$sb.AppendLine() [void]$sb.AppendLine("$h3 Cluster Actions ($($applyRows.Count) cluster(s))") [void]$sb.AppendLine() [void]$sb.AppendLine('| Cluster | Status | Update | Duration | Message |') [void]$sb.AppendLine('|---|---|---|---|---|') $startedStates = @('UpdateStarted', 'Started', 'Success') $blockedStates = @('HealthCheckBlocked', 'ScheduleBlocked', 'SideloadedBlocked', 'ExcludedByTag', 'NotConnected') $failedStates = @('Failed', 'Error', 'NotFound') foreach ($r in ($applyRows | Sort-Object Status, ClusterName)) { if ($actionsRendered -ge $MaxClusterActionRows) { break } $st = "$($r.Status)" $icon = if ($startedStates -contains $st) { $iconSuccess } elseif ($failedStates -contains $st) { $iconFail } elseif ($blockedStates -contains $st) { $iconBlock } else { $iconSkip } $upd = if ($r.UpdateName) { '`' + $r.UpdateName + '`' } else { '-' } $dur = if ($r.Duration) { "$($r.Duration)" } else { '-' } $msg = "$($r.Message)" if ($msg.Length -gt 180) { $msg = $msg.Substring(0, 177) + '...' } $msg = $msg -replace '\|', '\|' -replace '\r?\n', ' ' [void]$sb.AppendLine("| ``$($r.ClusterName)`` | $icon $st | $upd | $dur | $msg |") $actionsRendered++ } } } # Clusters Skipped at Readiness Gate table (from readiness-report.csv). $skippedRendered = 0 if ($ReadinessCsvPath -and (Test-Path -LiteralPath $ReadinessCsvPath)) { $blockedRows = @(Import-Csv -Path $ReadinessCsvPath | Where-Object { $_.ReadyForUpdate -ne 'True' }) if ($blockedRows.Count -gt 0) { [void]$sb.AppendLine() [void]$sb.AppendLine("$h3 Clusters Skipped at Readiness Gate ($($blockedRows.Count) cluster(s))") [void]$sb.AppendLine() [void]$sb.AppendLine('_These clusters were assessed by Check Update Readiness but not handed to Apply Updates. Resolve the listed blocking reasons (or wait for them to clear) then re-run the pipeline._') [void]$sb.AppendLine() [void]$sb.AppendLine('| Cluster | Update State | Health | Current Version | Recommended | Blocking Reasons |') [void]$sb.AppendLine('|---|---|---|---|---|---|') foreach ($b in ($blockedRows | Sort-Object UpdateState, ClusterName)) { if ($skippedRendered -ge $MaxSkippedRows) { break } $blocking = "$($b.BlockingReasons)" if ($blocking.Length -gt 200) { $blocking = $blocking.Substring(0, 197) + '...' } $blocking = $blocking -replace '\|', '\|' -replace '\r?\n', ' ' $reco = if ($b.RecommendedUpdate) { '`' + $b.RecommendedUpdate + '`' } else { '-' } $curr = if ($b.CurrentVersion) { '`' + $b.CurrentVersion + '`' } else { '-' } $hSt = "$($b.HealthState)" $hCell = switch -Regex ($hSt) { '^Success$' { "$iconSuccess $hSt"; break } '^Warning$' { "$iconWarn $hSt"; break } '^Failure$' { "$iconFail $hSt"; break } default { $hSt } } [void]$sb.AppendLine("| ``$($b.ClusterName)`` | $($b.UpdateState) | $hCell | $curr | $reco | $blocking |") $skippedRendered++ } if ($blockedRows.Count -gt $MaxSkippedRows) { [void]$sb.AppendLine() [void]$sb.AppendLine("_Showing first $MaxSkippedRows of $($blockedRows.Count) skipped clusters. Download the readiness-report.csv artifact for the full list._") } } } # Actions Required section (suppressed when -DryRun was set, matching ADO # prior behaviour). GitHub always rendered it - keep consistent: emit # when there is at least one actionable bucket regardless of dry-run. # NOTE: prior GH inline block rendered Actions Required even on dry-run, # so we match GH and ALWAYS rendered when non-dry; for dry-run we skip # on ADO (matching ADO prior behaviour) but emit on GitHub. $shouldRenderActions = if ($DryRun -and $pipelineHost -eq 'AzureDevOps') { $false } else { $true } $actionsNeeded = @() if ($shouldRenderActions) { if ($failedInt -gt 0) { $actionsNeeded += "- **$failedInt cluster(s) failed** - Review pipeline logs and cluster health in Azure Portal" } if ($healthBlockedInt -gt 0) { $actionsNeeded += "- **$healthBlockedInt cluster(s) blocked by health checks** - Resolve critical health failures before retrying" } if ($scheduleBlockedInt -gt 0) { $actionsNeeded += "- **$scheduleBlockedInt cluster(s) outside maintenance window** - Updates will proceed when the cluster's UpdateStartWindow schedule allows, or re-run during the maintenance window" } if ($sideloadedInt -gt 0) { $actionsNeeded += "- **$sideloadedInt cluster(s) blocked by UpdateSideloaded=False** - Operator must stage the sideloaded payload and flip the tag to True (or run Reset-AzLocalSideloadedTag) before updates can proceed" } if ($excludedTagInt -gt 0) { $actionsNeeded += "- **$excludedTagInt cluster(s) excluded by UpdateExcluded=True operator override** - Flip the cluster's UpdateExcluded tag to False (Azure portal or 'az tag update') once the cluster should rejoin automation" } } if ($actionsNeeded.Count -gt 0) { [void]$sb.AppendLine() [void]$sb.AppendLine("$h3 Actions Required") [void]$sb.AppendLine() foreach ($a in $actionsNeeded) { [void]$sb.AppendLine($a) } } $summaryPath = Add-AzLocalPipelineStepSummary -Markdown $sb.ToString() -SummaryFileName $SummaryFileName if ($PassThru) { return [pscustomobject]@{ SummaryPath = $summaryPath ActionsRequiredCount = $actionsNeeded.Count ClusterActionsRendered = $actionsRendered SkippedClustersRendered = $skippedRendered } } } |