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 UpToDateCount Readiness up-to-date count (clusters already fully patched - no action needed). Optional; defaults to -1 ("unknown") in which case the Readiness table omits the row. (v0.8.78) .PARAMETER NotReadyCount Readiness not-ready count (clusters held back by a previously-failed run, an in-progress run, pending SBE prerequisites or a critical health-check failure - typically clusters in the 'NeedsAttention', 'UpdateInProgress', 'UpdateFailed' or 'HasPrerequisite' states). Optional; defaults to -1 ("unknown") in which case the Readiness table omits the row. (v0.8.78) .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, # v0.8.78: optional readiness-gate breakdown so operators see the full # ring picture (Up to Date / Not Ready) alongside Ready, not just the # apply-buckets. -1 = unknown -> row omitted. [Parameter(Mandatory = $false)] [object]$UpToDateCount = -1, [Parameter(Mandatory = $false)] [object]$NotReadyCount = -1, [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') # v0.8.81: status-icon glyphs now come from the shared private helper # Get-AzLocalStatusIconMap (host-aware: GH/local Unicode vs ADO short- # codes). Local aliases keep the rest of this file readable without a # blanket find-and-replace. $iconMap = Get-AzLocalStatusIconMap -PipelineHost $pipelineHost $iconSuccess = $iconMap['Success'] $iconFail = $iconMap['Fail'] $iconBlock = $iconMap['Block'] $iconSkip = $iconMap['Skip'] $iconWarn = $iconMap['Warn'] # Suppress unused-variable analyzer noise; the alias above keeps backwards # compatibility with future code that may reference $useShortcodeIcons. $null = $useShortcodeIcons $headingLevel = if ($pipelineHost -eq 'AzureDevOps') { '#' } else { '##' } # Coerce numeric inputs. $totalInt = [int]([string]$TotalCount) $readyInt = [int]([string]$ReadyCount) # v0.8.78: tolerate empty strings (unresolved pipeline macros) by parsing # to -1 ("unknown") so the Readiness rows are quietly omitted instead of # throwing or rendering '0' for callers that haven't been updated yet. $parseOptional = { param($value) $parsed = 0 if ([int]::TryParse([string]$value, [ref]$parsed)) { return $parsed } return -1 } $upToDateInt = & $parseOptional $UpToDateCount $notReadyInt = & $parseOptional $NotReadyCount $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 |") # v0.8.78: surface Up to Date and Not Ready so the operator sees the FULL # ring picture in the apply-updates summary - not just the clusters that # entered Apply Updates. Up-to-Date is a healthy steady state (already # fully patched, nothing to apply). Not-Ready includes clusters held back # by a previously-failed run (state=NeedsAttention / UpdateFailed), an # in-progress run (UpdateInProgress), pending SBE prerequisites, or a # critical health failure - those clusters were correctly skipped by the # readiness gate and Start-AzLocalClusterUpdate's Step 3 state check. if ($upToDateInt -ge 0) { [void]$sb.AppendLine("| Already Up to Date | $upToDateInt |") } if ($notReadyInt -ge 0) { [void]$sb.AppendLine("| Not Ready (needs attention before updating) | $notReadyInt |") } [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 # v0.8.81: build a ClusterName -> ClusterResourceId lookup from the readiness # CSV (when supplied) so the Cluster Actions + Skipped at Readiness Gate # tables can render portal deep-links without requiring upstream Start- # AzLocalClusterUpdate / Invoke-AzLocalReadinessGatedClusterUpdate shape # changes. Falls back to plain cluster names when the CSV is absent or # the row has no ClusterResourceId column. $clusterIdByName = @{} if ($ReadinessCsvPath -and (Test-Path -LiteralPath $ReadinessCsvPath)) { try { foreach ($row in (Import-Csv -Path $ReadinessCsvPath)) { if ($row.PSObject.Properties['ClusterName'] -and $row.PSObject.Properties['ClusterResourceId'] -and $row.ClusterName -and $row.ClusterResourceId) { $clusterIdByName[[string]$row.ClusterName] = [string]$row.ClusterResourceId } } } catch { Write-Verbose ("Add-AzLocalApplyUpdatesStepSummary: unable to read ClusterResourceId map from {0}: {1}" -f $ReadinessCsvPath, $_.Exception.Message) } } # 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((Get-AzLocalCtrlClickTip)) [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', ' ' $clusterResId = if ($clusterIdByName.ContainsKey([string]$r.ClusterName)) { $clusterIdByName[[string]$r.ClusterName] } else { '' } $clusterCell = Get-AzLocalClusterPortalLink -ClusterName ([string]$r.ClusterName) -ClusterResourceId $clusterResId [void]$sb.AppendLine("| $clusterCell | $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((Get-AzLocalCtrlClickTip)) [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 } } $clusterResId = if ($b.PSObject.Properties['ClusterResourceId'] -and $b.ClusterResourceId) { [string]$b.ClusterResourceId } else { '' } $clusterCell = Get-AzLocalClusterPortalLink -ClusterName ([string]$b.ClusterName) -ClusterResourceId $clusterResId [void]$sb.AppendLine("| $clusterCell | $($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 } } } |