diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/README.md b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/README.md index 71f6417a..7ed78750 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/README.md +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/README.md @@ -64,10 +64,10 @@ By the end of this guide you will have: - **Fleet Connectivity Status** (Step.4, v0.7.79+, enhanced in v0.7.85) - read-only daily snapshot of Arc agent connectivity, physical NIC health, Azure Resource Bridge status, and the node-count reconciliation between cluster `reportedProperties.nodes` and Arc-tagged physical machines. *Scheduled daily 05:30 UTC + manual.* - **Assess Update Readiness** (Step.5) - pre-flight, report-only readiness + blocking-health snapshot, published as JUnit XML. *Manual only.* - **Sideload Updates** (Step.6, v0.8.7) - **opt-in, off by default, on-prem self-hosted runner/agent required.** Pre-stages solution-update media onto clusters that cannot pull updates from Azure directly (dark / air-gapped fabrics): Robocopy to the cluster import share, remote SHA256 verify, `Add-SolutionUpdate` import, then flip `UpdateSideloaded=True` for the downstream Step.7 apply. Re-entrant state machine driven by a frequent CRON; the multi-hour copy runs in a detached Scheduled Task. *Inert unless `SIDELOAD_UPDATES=true`. See [sideload.md](docs/sideload.md) and [sideload-robocopy.md](docs/sideload-robocopy.md).* - - **Apply Updates** (Step.7; **v0.8.79 break-glass override**: optional `force_immediate_update` / `forceImmediateUpdate` pipeline parameter (defaults `false`, **manual run / queue only**) bypasses the per-cluster `UpdateStartWindow` / `UpdateExclusionsWindow` maintenance-window gate for emergency / out-of-window patching - the override is unreachable from the scheduled `apply-updates-schedule.yml` configuration file and a high-visibility WARNING banner is emitted into the run log when it fires; **v0.8.78 step-summary UX polish**: ``ScheduleBlocked`` / ``SideloadedBlocked`` / ``ExcludedByTag`` outcomes render as JUnit ```` instead of ```` so ``dorny/test-reporter`` no longer flips Step.07 RED on by-design gate-respect outcomes - ``HealthCheckBlocked`` deliberately stays a ````; the Readiness KPI table now also surfaces **Already Up to Date** and **Not Ready (needs attention before updating)** rows so operators see the full ring breakdown - eligible, started, skipped, blocked, up-to-date, needs-attention - in one place; GHA-only: ``actions/download-artifact@v6 -> @v7`` silences the Node 20 deprecation warning) - apply updates to a single `UpdateRing` wave at a time, with WhatIf / dry-run support. *Manual only by default - **you must add a schedule** that lines up with your cluster `UpdateStartWindow` tags, see [Step 7 - Apply Updates](docs/appendix-pipelines.md#step-7---apply-updates) and [section 8](#8-scheduling-maintenance-windows-and-change-freeze-periods).* - - **Monitor In-Flight Updates** (Step.8, v0.7.90; v0.7.96 surfaces `Status` + deepest `ErrorMessage` columns, adds the `StepError` stuck-step JUnit type for runs that have hit an error inside a step without crossing the long-running threshold, and renders portal-linked Cluster Name / Update Name cells in the markdown summary; **v0.7.98 UX overhaul**: composite `SeverityScore` sort, per-cell `StateIcon` + `StatusIcon`, horizontal chip stack (`STEP-STUCK` / `RUN-STUCK` / `UNRESOLVED` / `RECENT-FAIL`), `CRITICAL / WARN / OK` fleet status badge at the top of the job summary, collapsible `
` Verbose Error block per row, and JUnit `` + `` populated with real run elapsed seconds). Operational snapshot during an active wave: lists each cluster whose latest update run is `InProgress`, with current step, progress (`completed/total steps`), elapsed duration, the `Status` column (`Success`/`Error`/`InProgress`/...), and the deepest `errorMessage` walked out of the nested ARM `steps[]` tree; flags long-running runs (default >6h) AND step-errored stuck runs as JUnit failures in the Checks tab. *Scheduled 5x/day at 20:00, 22:00, 00:00, 02:00, 04:00 UTC (every 2h across the typical overnight maintenance window) + manual; default cadence is editable in `monitor-updates.yml` (v0.7.92+).* - - **Fleet Update Status** (Step.9, formerly Step.8; v0.7.96 promotes `NeedsAttention` into the **Update Failed** bucket, adds a new **Action Required** bucket for `PreparationFailed`, and folds `PreparationInProgress` into **Update In Progress**; **v0.7.98** populates JUnit `time=` on each `` in the `📜 Update Run History and Error Details` testsuite using `DurationMinutes * 60`). Scheduled daily snapshot of fleet update state, surfaced in the Tests tab. Markdown summary's `📜 Update Run History and Error Details` table now also carries portal-linked Cluster Name / Update Name cells plus the deepest-step `ErrorMessage`. *Scheduled daily 06:00 UTC + manual.* - - **Fleet Health Status** (Step.10, formerly Step.9) - scheduled daily snapshot of 24-hour system health-check failures, surfaced in the Tests tab. *Scheduled daily 07:00 UTC + manual.* + - **Apply Updates** (Step.7; **v0.8.81 step-summary polish**: per-host apply-updates step summary now uses the new shared `Get-AzLocalStatusIconMap` host-aware icon helper (fixes literal `:white_check_mark:` text on Azure DevOps step summaries), the Cluster column wraps in a portal deep-link via `Get-AzLocalClusterPortalLink`, and the standing Ctrl-click tip is single-sourced via `Get-AzLocalCtrlClickTip`; **v0.8.79 break-glass override**: optional `force_immediate_update` / `forceImmediateUpdate` pipeline parameter (defaults `false`, **manual run / queue only**) bypasses the per-cluster `UpdateStartWindow` / `UpdateExclusionsWindow` maintenance-window gate for emergency / out-of-window patching - the override is unreachable from the scheduled `apply-updates-schedule.yml` configuration file and a high-visibility WARNING banner is emitted into the run log when it fires; **v0.8.78 step-summary UX polish**: ``ScheduleBlocked`` / ``SideloadedBlocked`` / ``ExcludedByTag`` outcomes render as JUnit ```` instead of ```` so ``dorny/test-reporter`` no longer flips Step.07 RED on by-design gate-respect outcomes - ``HealthCheckBlocked`` deliberately stays a ````; the Readiness KPI table now also surfaces **Already Up to Date** and **Not Ready (needs attention before updating)** rows so operators see the full ring breakdown - eligible, started, skipped, blocked, up-to-date, needs-attention - in one place; GHA-only: ``actions/download-artifact@v6 -> @v7`` silences the Node 20 deprecation warning) - apply updates to a single `UpdateRing` wave at a time, with WhatIf / dry-run support. *Manual only by default - **you must add a schedule** that lines up with your cluster `UpdateStartWindow` tags, see [Step 7 - Apply Updates](docs/appendix-pipelines.md#step-7---apply-updates) and [section 8](#8-scheduling-maintenance-windows-and-change-freeze-periods).* + - **Monitor In-Flight Updates** (Step.8, v0.7.90; **v0.8.81 host-aware icons**: the State and Current Step Status columns now use the shared `Get-AzLocalStatusIconMap` helper (fixes literal `:large_blue_circle:` / `:hourglass_flowing_sand:` text on Azure DevOps step summaries); v0.7.96 surfaces `Status` + deepest `ErrorMessage` columns, adds the `StepError` stuck-step JUnit type for runs that have hit an error inside a step without crossing the long-running threshold, and renders portal-linked Cluster Name / Update Name cells in the markdown summary; **v0.7.98 UX overhaul**: composite `SeverityScore` sort, per-cell `StateIcon` + `StatusIcon`, horizontal chip stack (`STEP-STUCK` / `RUN-STUCK` / `UNRESOLVED` / `RECENT-FAIL`), `CRITICAL / WARN / OK` fleet status badge at the top of the job summary, collapsible `
` Verbose Error block per row, and JUnit `` + `` populated with real run elapsed seconds). Operational snapshot during an active wave: lists each cluster whose latest update run is `InProgress`, with current step, progress (`completed/total steps`), elapsed duration, the `Status` column (`Success`/`Error`/`InProgress`/...), and the deepest `errorMessage` walked out of the nested ARM `steps[]` tree; flags long-running runs (default >6h) AND step-errored stuck runs as JUnit failures in the Checks tab. *Scheduled 5x/day at 20:00, 22:00, 00:00, 02:00, 04:00 UTC (every 2h across the typical overnight maintenance window) + manual; default cadence is editable in `monitor-updates.yml` (v0.7.92+).* + - **Fleet Update Status** (Step.9, formerly Step.8; **v0.8.81 host-aware icons**: the Critical Health Status table, the Primary Status table, and the Version Distribution Support column now use the shared `Get-AzLocalStatusIconMap` helper (fixes literal `:green_circle:` / `:yellow_circle:` / `:information_source:` text on Azure DevOps step summaries); v0.7.96 promotes `NeedsAttention` into the **Update Failed** bucket, adds a new **Action Required** bucket for `PreparationFailed`, and folds `PreparationInProgress` into **Update In Progress**; **v0.7.98** populates JUnit `time=` on each `` in the `📜 Update Run History and Error Details` testsuite using `DurationMinutes * 60`). Scheduled daily snapshot of fleet update state, surfaced in the Tests tab. Markdown summary's `📜 Update Run History and Error Details` table now also carries portal-linked Cluster Name / Update Name cells plus the deepest-step `ErrorMessage`. *Scheduled daily 06:00 UTC + manual.* + - **Fleet Health Status** (Step.10, formerly Step.9; **v0.8.81**: fixes the KPI counting bug where Healthy + Unhealthy did not sum to Total - the top KPI table is now split into **Cluster Counts** (Total / Healthy / Unhealthy / **Other**) and **Failing Checks Breakdown**, with a new `other_clusters` step output. Detailed Results columns reordered to put `Title` first and add a collapsible `Description` block that surfaces drive/volume-level detail (e.g. file paths from `Microsoft.Health.FaultType.Volume.FileSystem.Corruption.Correctable` warnings); raw fault-type `Name` preserved for grep/triage) - scheduled daily snapshot of 24-hour system health-check failures, surfaced in the Tests tab. *Scheduled daily 07:00 UTC + manual.* - An end-to-end "ring-based" rollout pattern: Pilot -> Wave2 -> Production, with each ring gated on the previous wave's success. - **Optional**: a ServiceNow integration that opens deduped incidents for clusters whose run status indicates the module's own retries cannot recover (failures, blocking health checks, sideloaded payload missing) - see [section 7](#7-optional-open-itsm-tickets-for-clusters-needing-operator-action). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/apply-updates-schedule-audit.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/apply-updates-schedule-audit.yml index 4b995c51..b606c36f 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/apply-updates-schedule-audit.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/apply-updates-schedule-audit.yml @@ -87,7 +87,7 @@ parameters: default: false variables: - GENERATED_AGAINST_MODULE_VERSION: '0.8.80' + GENERATED_AGAINST_MODULE_VERSION: '0.8.81' REQUIRED_MODULE_VERSION: '${{ parameters.moduleVersion }}' reportsPath: '$(Build.ArtifactStagingDirectory)/reports' # v0.8.7 sideload advisor defaults. Override at the pipeline / variable-group diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/apply-updates.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/apply-updates.yml index 8c7dc0a4..7e22993c 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/apply-updates.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/apply-updates.yml @@ -129,7 +129,7 @@ variables: # log if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample -Update. See Automation-Pipeline-Examples/README.md section 5. - name: GENERATED_AGAINST_MODULE_VERSION - value: '0.8.80' + value: '0.8.81' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): queue-time parameter > pipeline variable # 'REQUIRED_MODULE_VERSION' overridden at queue time > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/assess-update-readiness.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/assess-update-readiness.yml index a5d307af..a829f8a2 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/assess-update-readiness.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/assess-update-readiness.yml @@ -70,7 +70,7 @@ variables: # the version actually installed and to the latest on PSGallery, and emits a warning # log if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample -Update. See Automation-Pipeline-Examples/README.md section 5. - GENERATED_AGAINST_MODULE_VERSION: '0.8.80' + GENERATED_AGAINST_MODULE_VERSION: '0.8.81' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): queue-time parameter > pipeline variable # 'REQUIRED_MODULE_VERSION' overridden at queue time > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/authentication-test.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/authentication-test.yml index 58fb8981..0726ccf2 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/authentication-test.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/authentication-test.yml @@ -60,7 +60,7 @@ variables: # log if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample -Update. See Automation-Pipeline-Examples/README.md section 5. - name: GENERATED_AGAINST_MODULE_VERSION - value: '0.8.80' + value: '0.8.81' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): queue-time parameter > pipeline variable # 'REQUIRED_MODULE_VERSION' overridden at queue time > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/fleet-connectivity-status.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/fleet-connectivity-status.yml index 1f1dba5c..7221818b 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/fleet-connectivity-status.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/fleet-connectivity-status.yml @@ -110,7 +110,7 @@ variables: # the version actually installed and to the latest on PSGallery, and emits a warning # log if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample -Update. See Automation-Pipeline-Examples/README.md section 5. - GENERATED_AGAINST_MODULE_VERSION: '0.8.80' + GENERATED_AGAINST_MODULE_VERSION: '0.8.81' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): queue-time parameter > pipeline variable # 'REQUIRED_MODULE_VERSION' overridden at queue time > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/fleet-health-status.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/fleet-health-status.yml index e5051c1c..24ace5b3 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/fleet-health-status.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/fleet-health-status.yml @@ -105,7 +105,7 @@ variables: # the version actually installed and to the latest on PSGallery, and emits a warning # log if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample -Update. See Automation-Pipeline-Examples/README.md section 5. - GENERATED_AGAINST_MODULE_VERSION: '0.8.80' + GENERATED_AGAINST_MODULE_VERSION: '0.8.81' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): queue-time parameter > pipeline variable # 'REQUIRED_MODULE_VERSION' overridden at queue time > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/fleet-update-status.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/fleet-update-status.yml index 2ca68f95..15293fae 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/fleet-update-status.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/fleet-update-status.yml @@ -93,7 +93,7 @@ variables: # the version actually installed and to the latest on PSGallery, and emits a warning # log if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample -Update. See Automation-Pipeline-Examples/README.md section 5. - GENERATED_AGAINST_MODULE_VERSION: '0.8.80' + GENERATED_AGAINST_MODULE_VERSION: '0.8.81' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): queue-time parameter > pipeline variable # 'REQUIRED_MODULE_VERSION' overridden at queue time > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/inventory-clusters.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/inventory-clusters.yml index 26706861..184f6718 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/inventory-clusters.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/inventory-clusters.yml @@ -43,7 +43,7 @@ variables: # log if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample -Update. See Automation-Pipeline-Examples/README.md section 5. - name: GENERATED_AGAINST_MODULE_VERSION - value: '0.8.80' + value: '0.8.81' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): queue-time parameter > pipeline variable # 'REQUIRED_MODULE_VERSION' overridden at queue time > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/manage-updatering-tags.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/manage-updatering-tags.yml index d5cc940a..f5c4a4cf 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/manage-updatering-tags.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/manage-updatering-tags.yml @@ -46,7 +46,7 @@ variables: # log if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample -Update. See Automation-Pipeline-Examples/README.md section 5. - name: GENERATED_AGAINST_MODULE_VERSION - value: '0.8.80' + value: '0.8.81' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): queue-time parameter > pipeline variable # 'REQUIRED_MODULE_VERSION' overridden at queue time > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/monitor-updates.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/monitor-updates.yml index 680758cc..c51229aa 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/monitor-updates.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/monitor-updates.yml @@ -84,7 +84,7 @@ parameters: default: '' variables: - GENERATED_AGAINST_MODULE_VERSION: '0.8.80' + GENERATED_AGAINST_MODULE_VERSION: '0.8.81' REQUIRED_MODULE_VERSION: '${{ parameters.moduleVersion }}' reportsPath: '$(Build.ArtifactStagingDirectory)/reports' diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/sideload-updates.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/sideload-updates.yml index 0faebff1..3921a7e5 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/sideload-updates.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/sideload-updates.yml @@ -79,7 +79,7 @@ variables: # the version actually installed and to the latest on PSGallery, and emits a warning # log if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample -Update. See Automation-Pipeline-Examples/README.md section 5. - GENERATED_AGAINST_MODULE_VERSION: '0.8.80' + GENERATED_AGAINST_MODULE_VERSION: '0.8.81' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): queue-time parameter > pipeline variable # 'REQUIRED_MODULE_VERSION' overridden at queue time > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/docs/appendix-pipelines.md b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/docs/appendix-pipelines.md index 84a24b78..0b56013a 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/docs/appendix-pipelines.md +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/docs/appendix-pipelines.md @@ -111,6 +111,8 @@ The table below is the ground truth for what each shipped YAML does **out of the ## Step 5 - Assess Update Readiness +> **Behaviour change in v0.8.81**: status icons in the Summary counts, Not-Ready and All-clusters tables now render via the new shared `Get-AzLocalStatusIconMap` private helper (host-aware: GitHub Markdown shortcodes on the `GitHubActions` host, Unicode glyphs everywhere else - fixes literal `:white_check_mark:` text on Azure DevOps step summaries). The Cluster column wraps in a portal deep-link via `Get-AzLocalClusterPortalLink` and the standing **Ctrl-click tip** banner is single-sourced via `Get-AzLocalCtrlClickTip`. The Up-to-Date bucket gains the readiness-cascade check icon. + > **Behaviour change in v0.8.74**: clusters that have already applied every required update are now classified as **Up to Date** (counted separately) instead of being lumped into the Not-Ready bucket. The summary's "Up to date" count is now accurate, the "Not-Ready clusters (review first)" table excludes Up-to-Date and Ready clusters, and the "All clusters detail" table shows a readable `Status` column in place of the old `Ready` boolean. Classification is shared with Step 7 and Step 9 via the `Get-AzLocalClusterReadinessStatus` helper. | Aspect | Value | @@ -140,6 +142,8 @@ The table below is the ground truth for what each shipped YAML does **out of the ## Step 6 - Sideload Updates +> **Behaviour change in v0.8.81 (Step 6 - Apply-Updates Readiness Gate)**: NOTE this callout describes the **Apply-Updates Readiness Gate** report (`Export-AzLocalClusterReadinessGateReport`), which is internal to the Step 7 apply-updates pipeline (not the Sideload Updates pipeline documented in the table below). The gate report drops its inline icon switch in favour of the new shared `Get-AzLocalStatusIconMap` private helper (host-aware: fixes literal `:white_check_mark:` text on Azure DevOps step summaries); the Cluster column wraps in a portal deep-link via `Get-AzLocalClusterPortalLink`; and the standing Ctrl-click tip banner is single-sourced via `Get-AzLocalCtrlClickTip`. + > **OPT-IN, OFF BY DEFAULT, self-hosted runner required.** Introduced in v0.8.7. This pipeline is inert unless the repository variable `SIDELOAD_UPDATES` is the literal string `'true'`. Full architecture, the scheduled-task survival model, the shared-state / multi-runner contract, the auth-map, and the catalog format are in [sideload.md](sideload.md); robocopy throttling guidance is in [sideload-robocopy.md](sideload-robocopy.md). | Aspect | Value | @@ -158,6 +162,8 @@ The table below is the ground truth for what each shipped YAML does **out of the ## Step 7 - Apply Updates +> **Behaviour change in v0.8.81**: the per-host apply-updates step summary (`Add-AzLocalApplyUpdatesStepSummary`) drops its inline `$iconSuccess` / `$iconFail` / `$iconBlock` / `$iconSkip` / `$iconWarn` definitions in favour of the new shared `Get-AzLocalStatusIconMap` private helper (fixes literal `:white_check_mark:` text on Azure DevOps step summaries). The Cluster column in both the Cluster Actions and Clusters Skipped at Readiness Gate tables now resolves each cluster's resource id from the readiness CSV (built upstream by `Get-AzLocalClusterUpdateReadiness`) and wraps the cell in a portal deep-link via `Get-AzLocalClusterPortalLink`. Adds the standing Ctrl-click tip via `Get-AzLocalCtrlClickTip`. The upstream apply-results JSON shape (`ClusterName` / `Status` / `UpdateName` / `Duration` / `Message`) is intentionally unchanged - the cluster id lookup is done in the renderer. + > **Behaviour change in v0.8.74**: the pre-apply readiness gate report (`Export-AzLocalClusterReadinessGateReport`) replaces its binary `Ready?` column with a readable `Status` column so fully-patched clusters now show **Up to Date** instead of a no-entry icon that implied failure. The header line shows a distinct `Up to Date` count and the step emits a new additive `UP_TO_DATE_COUNT` / `UpToDateCount` output (the `READY_COUNT` gate that drives the apply is unchanged). Classification is shared with Step 5 and Step 9 via the `Get-AzLocalClusterReadinessStatus` helper. When the gate finds no clusters ready, the "No Clusters Ready" summary (`Add-AzLocalNoReadyClustersStepSummary`) now renders an Up-to-Date vs Not-Ready breakdown - so an idle run makes clear that already-patched clusters are a healthy steady state (a notice, not a warning, when every cluster is up to date) rather than a fault. > **Behaviour change in v0.8.78**: per-cluster `ScheduleBlocked` / `SideloadedBlocked` / `ExcludedByTag` outcomes now render as JUnit `` instead of `` so `dorny/test-reporter` no longer flips the Step.07 check RED on by-design gate-respect outcomes - `HealthCheckBlocked` deliberately stays a `` because a critical health failure IS actionable. The Step 7 Readiness KPI table also gains optional **Already Up to Date** and **Not Ready (needs attention before updating)** rows, wired in both shipped `apply-updates.yml` templates from the upstream `readiness.UpToDateCount` / `readiness.NotReadyCount` outputs (emitted since v0.8.74), so operators see the full ring breakdown - eligible / started / skipped / blocked / up-to-date / needs-attention - in one place. GitHub Actions only: `actions/download-artifact@v6 -> @v7` to silence the Node 20 deprecation warning (v7 chosen over v8 because v8 has a breaking `digest-mismatch=error` default). @@ -185,6 +191,8 @@ The table below is the ground truth for what each shipped YAML does **out of the ## Step 8 - Monitor In-Flight Updates +> **Behaviour change in v0.8.81**: the `$stateIcon` (`State` column) and `$statusIcon` (`Current Step Status` column) switches in `Export-AzLocalUpdateRunMonitorReport` no longer hard-code GitHub-Markdown shortcodes (`:large_blue_circle:` / `:hourglass_flowing_sand:` / `:red_circle:` / etc.) which previously rendered as literal text on Azure DevOps step summaries. Both switches now read from the shared `Get-AzLocalStatusIconMap` private helper using the new `State*` (`StateInProgress` / `StateSucceeded` / `StateFailed` / `StateNotStarted` / `StateUnknown`) and `Status*` (`StatusInProgress` / `StatusCancelled`) keys. JUnit XML output and CSV columns are unchanged. + | Aspect | Value | |---|---| | **Purpose** | In-flight observability for an active update wave. Reports clusters whose latest update run is currently `InProgress` with the CURRENT STEP each cluster is on, the PROGRESS (`completed/total steps`), and the ELAPSED DURATION. Long-running runs are flagged via the `long_running_threshold_hours` input (default 6h) so an operator can spot a stuck cluster without waiting for the next daily `fleet-update-status` snapshot. **No new ARM calls**: data source is `Get-AzLocalUpdateRuns -Latest` (one row per cluster) - the same call already made by `fleet-update-status`. **v0.7.96 enhancements:** (1) every in-flight row now carries a new `Status` column from `properties.progress.status` (`Success` / `Error` / `InProgress` / `NotStarted` / `Skipped` / `Cancelled` / `Unknown`); (2) a new `ErrorMessage` column surfaces the deepest non-empty `errorMessage` walked from the nested `properties.progress.steps[]` tree (uses a `coalesce(e9Msg..e1Msg)` recursion via the new private `Get-DeepestErrorMessage` helper); (3) runs whose `Status='Error'` but elapsed time is BELOW `long_running_threshold_hours` (the stuck-but-still-fresh Arizona-shaped case) now generate a new `failureType='StepError'` JUnit entry so they appear in the Checks tab WITHOUT needing the long-running threshold to fire; (4) the always-shown Failed-runs block carries an MS Learn TSG link plus the Progress Status column; (5) markdown summary's Cluster Name and Update Name cells render as `` deep-links to the cluster's Updates blade and the single-instance update-run history view. | @@ -201,6 +209,8 @@ The table below is the ground truth for what each shipped YAML does **out of the ## Step 9 - Fleet Update Status +> **Behaviour change in v0.8.81**: the GitHub-Markdown shortcodes used by the Critical Health Status table, the Primary Status table, and the Version Distribution Support column are now routed through the shared `Get-AzLocalStatusIconMap` private helper using the new `Info` / `GreenCircle` / `YellowCircle` / `CycleArrows` / `GreyQuestion` / `SupportSupported` / `SupportUnsupported` / `SupportUnknown` keys (fixes literal `:green_circle:` / `:yellow_circle:` / `:information_source:` / etc. text on Azure DevOps step summaries). JUnit XML output and CSV columns are unchanged. + | Aspect | Value | |---|---| | **Purpose** | Daily fleet-wide snapshot of cluster update state. Read-only. **v0.7.90 pivots the Version Distribution markdown table by YYMM** (leading column `Version` = `2511`, `2604`, ...; new `Update Versions` column lists each distinct full version installed within that YYMM as ` x ` separated by `
`; rows sorted ascending by YYMM so the oldest YYMM is at the top). The underlying `` JUnit XML is unchanged - still one `` per distinct full `CurrentVersion` - so machine-readable consumers and CI test-reporters are unaffected. **v0.7.96 reworks the Primary Status bucket cascade to align with the Azure portal Update Manager `state` filter:** `NeedsAttention` is now promoted into the **Update Failed** bucket; `PreparationFailed` lands in a new **Action Required** bucket (with its own actionable guidance in the Actions Required callout because the run never started, so re-arming is required); `PreparationInProgress` is folded into **Update In Progress**. The new bucket surfaces via (1) a new `` JUnit attribute on the primary testsuite, (2) per-testcase `failureType='PreparationFailed'` instead of the generic `UpdateFailure`, (3) a new `ACTION_REQUIRED` `GITHUB_OUTPUT` (`actionRequired` pipeline variable in ADO), (4) `Summary.UpdateFailures` + `Summary.ActionRequired` in `readiness-status.json`, and (5) a new "Action Required (PreparationFailed)" row in the Primary Status markdown table with separate actionable prose in the Actions Required callout. The `📜 Update Run History and Error Details` markdown table renders Cluster Name and Update Name cells as `
` deep-links (cluster's Updates blade + the single-instance update-run history view) - same linking as the Step.8 in-flight table. | @@ -217,6 +227,8 @@ The table below is the ground truth for what each shipped YAML does **out of the ## Step 10 - Fleet Health Status +> **Behaviour change in v0.8.81**: fixes the **"Healthy + Unhealthy != Total" KPI counting bug**. The previous KPI table summed only two buckets (Healthy + Unhealthy) and silently dropped clusters whose `HealthStatus` was `In progress` / `Unknown` / `Health check failed` AND had no failure rows. The KPI block is now split into two tables: **Cluster Counts** (Total / Healthy / Unhealthy / **Other**, where Other is computed as `Total - Healthy - Unhealthy` clamped non-negative so the three buckets always sum to Total) and **Failing Checks Breakdown** (Total / Critical / Warning / Distinct Reasons). New `other_clusters` pipeline step output; `-PassThru` PSCustomObject gains `OtherClusters`. **Detailed Results columns reordered** to put the most-specific identifier first: `Severity | Title | Failure Reason | Description | Health Check Name | Failure Remediation | Target Resource Name | Target Resource Type | Last Occurrence | Resource Group`. Description is a collapsible `
` block that surfaces drive/volume-level detail (e.g. the file path emitted by a `Microsoft.Health.FaultType.Volume.FileSystem.Corruption.Correctable` warning); Health Check Name is the raw fault-type name (preserved verbatim so operators can grep / triage by FaultType across runs). Icons in the table are wired through the new shared `Get-AzLocalStatusIconMap` private helper. + | Aspect | Value | |---|---| | **Purpose** | Daily fleet-wide snapshot of **24-hour system health-check failures** surfaced by every Azure Local cluster the identity can see. **Independent of update activity** - clusters that are "up to date" can still surface Critical / Warning issues that need operator triage. Read-only. | diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/apply-updates-schedule-audit.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/apply-updates-schedule-audit.yml index 02d4950c..0fdeb4a8 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/apply-updates-schedule-audit.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/apply-updates-schedule-audit.yml @@ -96,7 +96,7 @@ env: # this to the version actually installed and to the latest on PSGallery, and emits a # ::notice annotation if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample -Update. See Automation-Pipeline-Examples/README.md section 5. - GENERATED_AGAINST_MODULE_VERSION: '0.8.80' + GENERATED_AGAINST_MODULE_VERSION: '0.8.81' REQUIRED_MODULE_VERSION: ${{ github.event.inputs.module_version || vars.REQUIRED_MODULE_VERSION || '' }} # v0.8.4 - opt this workflow into Node.js 24 for all JavaScript actions # (actions/checkout, actions/download-artifact, actions/upload-artifact, diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/apply-updates.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/apply-updates.yml index bea73dc6..41e3c609 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/apply-updates.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/apply-updates.yml @@ -148,7 +148,7 @@ env: # this to the version actually installed and to the latest on PSGallery, and emits a # ::notice annotation if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample -Update. See Automation-Pipeline-Examples/README.md section 5. - GENERATED_AGAINST_MODULE_VERSION: '0.8.80' + GENERATED_AGAINST_MODULE_VERSION: '0.8.81' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): manual workflow_dispatch input > # repository variable 'REQUIRED_MODULE_VERSION' > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/assess-update-readiness.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/assess-update-readiness.yml index e2e018fd..8f336524 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/assess-update-readiness.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/assess-update-readiness.yml @@ -72,7 +72,7 @@ env: # this to the version actually installed and to the latest on PSGallery, and emits a # ::notice annotation if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample -Update. See Automation-Pipeline-Examples/README.md section 5. - GENERATED_AGAINST_MODULE_VERSION: '0.8.80' + GENERATED_AGAINST_MODULE_VERSION: '0.8.81' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): manual workflow_dispatch input > # repository variable 'REQUIRED_MODULE_VERSION' > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/authentication-test.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/authentication-test.yml index e7edf661..856536d3 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/authentication-test.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/authentication-test.yml @@ -79,7 +79,7 @@ env: # this to the version actually installed and to the latest on PSGallery, and emits a # ::notice annotation if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample. See Automation-Pipeline-Examples/README.md section 5. - GENERATED_AGAINST_MODULE_VERSION: '0.8.80' + GENERATED_AGAINST_MODULE_VERSION: '0.8.81' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): manual workflow_dispatch input > # repository variable 'REQUIRED_MODULE_VERSION' > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/fleet-connectivity-status.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/fleet-connectivity-status.yml index 20e809ac..ca67af27 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/fleet-connectivity-status.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/fleet-connectivity-status.yml @@ -116,7 +116,7 @@ env: # PSGallery, and emits a ::notice annotation if the YAML appears stale - # prompting you to refresh via Copy-AzLocalPipelineExample -Update. See # Automation-Pipeline-Examples/README.md section 5. - GENERATED_AGAINST_MODULE_VERSION: '0.8.80' + GENERATED_AGAINST_MODULE_VERSION: '0.8.81' # Resolution order for the module version pin (leave all unset to install # the latest, the default "fix-forward" behaviour): manual workflow_dispatch # input > repository variable 'REQUIRED_MODULE_VERSION' > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/fleet-health-status.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/fleet-health-status.yml index 498c669a..c134a10c 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/fleet-health-status.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/fleet-health-status.yml @@ -116,7 +116,7 @@ env: # this to the version actually installed and to the latest on PSGallery, and emits a # ::notice annotation if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample -Update. See Automation-Pipeline-Examples/README.md section 5. - GENERATED_AGAINST_MODULE_VERSION: '0.8.80' + GENERATED_AGAINST_MODULE_VERSION: '0.8.81' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): manual workflow_dispatch input > # repository variable 'REQUIRED_MODULE_VERSION' > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/fleet-update-status.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/fleet-update-status.yml index 58395cb3..91ada0eb 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/fleet-update-status.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/fleet-update-status.yml @@ -105,7 +105,7 @@ env: # this to the version actually installed and to the latest on PSGallery, and emits a # ::notice annotation if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample -Update. See Automation-Pipeline-Examples/README.md section 5. - GENERATED_AGAINST_MODULE_VERSION: '0.8.80' + GENERATED_AGAINST_MODULE_VERSION: '0.8.81' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): manual workflow_dispatch input > # repository variable 'REQUIRED_MODULE_VERSION' > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/inventory-clusters.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/inventory-clusters.yml index 84210f7a..f0852473 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/inventory-clusters.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/inventory-clusters.yml @@ -48,7 +48,7 @@ env: # this to the version actually installed and to the latest on PSGallery, and emits a # ::notice annotation if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample. See Automation-Pipeline-Examples/README.md section 5. - GENERATED_AGAINST_MODULE_VERSION: '0.8.80' + GENERATED_AGAINST_MODULE_VERSION: '0.8.81' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): manual workflow_dispatch input > # repository variable 'REQUIRED_MODULE_VERSION' > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/manage-updatering-tags.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/manage-updatering-tags.yml index 441d6053..2c7382d4 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/manage-updatering-tags.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/manage-updatering-tags.yml @@ -65,7 +65,7 @@ env: # this to the version actually installed and to the latest on PSGallery, and emits a # ::notice annotation if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample -Update. See Automation-Pipeline-Examples/README.md section 5. - GENERATED_AGAINST_MODULE_VERSION: '0.8.80' + GENERATED_AGAINST_MODULE_VERSION: '0.8.81' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): manual workflow_dispatch input > # repository variable 'REQUIRED_MODULE_VERSION' > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/monitor-updates.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/monitor-updates.yml index 15cd987f..f3f6dc40 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/monitor-updates.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/monitor-updates.yml @@ -86,7 +86,7 @@ env: # this to the version actually installed and to the latest on PSGallery, and emits a # ::notice annotation if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample -Update. See Automation-Pipeline-Examples/README.md section 5. - GENERATED_AGAINST_MODULE_VERSION: '0.8.80' + GENERATED_AGAINST_MODULE_VERSION: '0.8.81' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): manual workflow_dispatch input > # repository variable 'REQUIRED_MODULE_VERSION' > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/sideload-updates.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/sideload-updates.yml index d13c807b..b118ece4 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/sideload-updates.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/sideload-updates.yml @@ -83,7 +83,7 @@ env: # this to the version actually installed and to the latest on PSGallery, and emits a # ::notice annotation if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample -Update. See Automation-Pipeline-Examples/README.md section 5. - GENERATED_AGAINST_MODULE_VERSION: '0.8.80' + GENERATED_AGAINST_MODULE_VERSION: '0.8.81' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): manual workflow_dispatch input > # repository variable 'REQUIRED_MODULE_VERSION' > empty (latest). diff --git a/AzLocal.UpdateManagement/AzLocal.UpdateManagement.psd1 b/AzLocal.UpdateManagement/AzLocal.UpdateManagement.psd1 index 2c2abc46..d47208a5 100644 --- a/AzLocal.UpdateManagement/AzLocal.UpdateManagement.psd1 +++ b/AzLocal.UpdateManagement/AzLocal.UpdateManagement.psd1 @@ -3,7 +3,7 @@ RootModule = 'AzLocal.UpdateManagement.psm1' # Version number of this module. - ModuleVersion = '0.8.80' + ModuleVersion = '0.8.81' # Supported PSEditions CompatiblePSEditions = @('Desktop', 'Core') @@ -116,6 +116,10 @@ 'Private/Write-AzLocalPipelineWarning.ps1', # Generic JUnit XML emitter shared by every Public Step.* cmdlet (v0.8.5) 'Private/New-AzLocalPipelineJUnitXml.ps1', + # Shared step-summary helpers (v0.8.81) - host-aware status icons, cluster portal deep-links, Ctrl-click tip + 'Private/Get-AzLocalStatusIconMap.ps1', + 'Private/Get-AzLocalClusterPortalLink.ps1', + 'Private/Get-AzLocalCtrlClickTip.ps1', # Public exported functions 'Public/Connect-AzLocalServicePrincipal.ps1', @@ -310,6 +314,8 @@ # ReleaseNotes of this module ReleaseNotes = @' +## Version 0.8.81 - Step summary polish across Steps 05-10. Step.10 fixes the KPI bug where Healthy + Unhealthy did not sum to Total (splits into Cluster Counts + Failing Checks Breakdown tables, adds `OtherClusters` step output; Detailed Results gains Title, raw FailureName and collapsible Description columns surfacing drive/volume detail e.g. file paths from `...FileSystem.Corruption.Correctable`). Steps 05-09 adopt three shared private helpers: `Get-AzLocalStatusIconMap` (host-aware GitHub Unicode vs Azure DevOps shortcodes), `Get-AzLocalClusterPortalLink` (portal deep-link wrapper) and `Get-AzLocalCtrlClickTip` (single-source Ctrl-click banner). Fixes literal shortcode text (`:white_check_mark:` etc.) rendering on Azure DevOps step summaries in Step.08 monitor + Step.09 fleet-update-status. No public API change (still 60 exports). All bundled pipeline templates bump `GENERATED_AGAINST_MODULE_VERSION` from `'0.8.80'` to `'0.8.81'`. + ## Version 0.8.80 - Minor: pipeline failure-rendering improvements bundled in three additive changes (Q1 / Q2 / Q3) targeting Step.05 / Step.08 / Step.09 / Step.10 step summaries. **Q1 - HealthCheck failure enrichment** (Step.09 `fleet-update-status`): `Get-AzLocalUpdateRunFailures` now attaches a `HealthCheckEvidence` array column (same-cluster Critical `updateSummaries.healthCheckResult` entries within a +/-2h window) to every row whose `ErrorCategory='HealthCheck'`, surfaced as a bulleted list inside the per-row `
` panel of the markdown step summary and as a structured block in JUnit ``. New private helper `Get-AzLocalUpdateRunHealthEvidence` (single ARG hop per HealthCheck-category row, scoped to one cluster id, reuses the existing anti-mv-expand 128-cap projection pattern). New parameter `[bool]$EnrichWithHealthEvidence = $true` lets CSV-only consumers opt out. **Q2 - Title and TargetResourceID columns** on both fleet-wide (`Get-AzLocalFleetHealthFailures` for Step.10) and per-cluster (`Test-AzLocalClusterHealth` for Step.05) health failure outputs: per-check `title` (often more specific than `displayName`, e.g. names the failing node or volume) and full `targetResourceID` (lets renderers deep-link Server/Volume health failures back to the exact ARM resource). `Export-AzLocalFleetHealthStatusReport` adds a Title markdown column and wraps TargetResourceName in a portal hyperlink when TargetResourceID is present. **Q3 - Surface BOTH deepest-step `description` AND `errorMessage`** (Step.08 monitor + Step.09 fleet-update-status): both deepest-error walkers (`Resolve-AzLocalUpdateRunDeepestError` and `Get-DeepestErrorMessage`) now capture step description at the same recursion site as errorMessage. New `DeepestStepDescription` column on `Get-AzLocalUpdateRunFailures`; new `ErrorDescription` field on `Format-AzLocalUpdateRun` / `Get-AzLocalUpdateRuns -PassThru`; renderers combine `description + errorMessage` in markdown failure cells and JUnit bodies. Cross-reference `.NOTES` headers added to the four parallel functions so future maintainers know to keep them in sync. No new exports (count unchanged at 60). All bundled pipeline templates bump `GENERATED_AGAINST_MODULE_VERSION` from `'0.8.79'` to `'0.8.80'`. See CHANGELOG.md for the full v0.8.80 entry. ## Version 0.8.79 - Patch: operator break-glass override for Step.07. Adds `Force Immediate Update` to `Invoke-AzLocalReadinessGatedClusterUpdate` (`-ForceImmediateUpdate`) and `Start-AzLocalClusterUpdate` (`-IgnoreScheduleTags`), plus matching pipeline parameters `force_immediate_update` (GitHub Actions `workflow_dispatch` input) / `forceImmediateUpdate` (Azure DevOps `parameters:` boolean) both defaulting to `false`. When enabled, the per-cluster Step 3c maintenance-window gate (`UpdateStartWindow` / `UpdateExclusionsWindow` tags) is bypassed and updates start regardless of the current UTC time. Intended for emergency / out-of-window patching driven by an on-call operator. The override is UNREACHABLE from the apply-updates-schedule.yml configuration file - GHA wiring collapses the flag to `'false'` for any non-`workflow_dispatch` trigger (`${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_immediate_update || 'false' }}`), and ADO rechecks `$(Build.Reason) -eq 'Manual'` at runtime before honouring the parameter. A `WARNING:` GUI label is prepended on both pipeline hosts and a high-visibility `::warning::` (GHA) / `##vso[task.logissue type=warning]` (ADO) banner is emitted into the run log when the override fires. No new exports (still 60). All bundled pipeline templates bump `GENERATED_AGAINST_MODULE_VERSION` from `'0.8.78'` to `'0.8.79'`. @@ -332,10 +338,6 @@ ## Version 0.8.73 - Cycle calendar: cluster counts folded INLINE into the "Eligible rings" column (Step.3 apply-updates schedule audit). No public API or export-count change (still 60). See CHANGELOG for the full v0.8.73 entry. -## Version 0.8.72 - Patch: pipeline-template polish only. Schedule-file author guidance moved OUTSIDE the apply-updates.yml customise marker so it refreshes on existing consumers; single-digit Step.N display names zero-padded (Step.0 -> Step.00 ... Step.9 -> Step.09) so the GitHub Actions sidebar / Azure DevOps list sort in execution order. No public API or export-count change (still 60). See CHANGELOG for the full v0.8.72 entry. - -## Version 0.8.71 - Patch: production strict-mode crash fix in `Export-ResultsToJUnitXml` (`UpdateStarted` success row lacked `CurrentState`/`Progress`); GitHub Actions `sideload-updates.yml` schedule-path default corrected `./.github/` -> `./config/`; stale `Step.N_*.yml` filename references in pipeline doc strings de-numbered. No public API or export-count change (still 60). See CHANGELOG for the full v0.8.71 entry. - For full v0.7.x and v0.8.x release notes see: https://github.com/NeilBird/Azure-Local/blob/main/AzLocal.UpdateManagement/CHANGELOG.md diff --git a/AzLocal.UpdateManagement/AzLocal.UpdateManagement.psm1 b/AzLocal.UpdateManagement/AzLocal.UpdateManagement.psm1 index 7ba05cf7..e6e2ec1b 100644 --- a/AzLocal.UpdateManagement/AzLocal.UpdateManagement.psm1 +++ b/AzLocal.UpdateManagement/AzLocal.UpdateManagement.psm1 @@ -151,7 +151,7 @@ Set-StrictMode -Version 1.0 # bumps to one but not the other are caught before release. Two consumers: # - Start-AzLocalClusterUpdate emits this in the run log header. # - Get-AzLocalFleetStatusData stamps it into exported fleet-state JSON. -$script:ModuleVersion = '0.8.80' +$script:ModuleVersion = '0.8.81' $script:DefaultApiVersion = '2025-10-01' $script:DefaultLogFolder = Join-Path -Path $env:ProgramData -ChildPath 'AzLocal.UpdateManagement' diff --git a/AzLocal.UpdateManagement/CHANGELOG.md b/AzLocal.UpdateManagement/CHANGELOG.md index c20d6286..b2213cbf 100644 --- a/AzLocal.UpdateManagement/CHANGELOG.md +++ b/AzLocal.UpdateManagement/CHANGELOG.md @@ -5,6 +5,105 @@ All notable changes to the AzLocal.UpdateManagement module (renamed from AzStack The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.81] - 2026-06-17 + +Patch release. Step summary polish across Steps 05-10: fixes a Step.10 KPI counting +bug, surfaces drive/volume-level detail in the Step.10 health-failure renderer, and +moves status-icon / cluster-deep-link / Ctrl-click-tip rendering onto three shared +private helpers so Steps 05-09 stay consistent and so the Azure DevOps step summary +no longer leaks literal GitHub-Markdown shortcodes (`:white_check_mark:`, etc.) into +the rendered output. No public API or export-count change (still 60 exports). One +new private helper trio (`Get-AzLocalStatusIconMap`, `Get-AzLocalClusterPortalLink`, +`Get-AzLocalCtrlClickTip`). + +### Added + +- **Shared private helpers** (Step summary DRY foundation): + - `Private/Get-AzLocalStatusIconMap.ps1` - host-aware (GitHub vs Azure DevOps) + status-name -> icon-cell map. Returns GitHub-Markdown shortcodes + (`:white_check_mark:` / `:x:` / `:warning:` / ...) on the `GitHubActions` host + where they render natively, and Unicode glyphs (U+2705 / U+274C / U+26A0 ...) + on every other host (Azure DevOps and local console). Covers the full readiness + cascade, health-overview buckets, severity tags, apply-updates outcomes, run-state + buckets, progress-status buckets, fleet status, and version-support cells - so + one helper is the single source of truth for every Step.* pipeline icon. + - `Private/Get-AzLocalClusterPortalLink.ps1` - wraps a cluster name in a Markdown + anchor pointing at the Azure Portal resource blade + (`https://portal.azure.com/#@/resource{resourceId}`) when a resource id is + supplied, falls back to the plain cluster name otherwise. Used by the + Cluster column in Step.05 / Step.06 / Step.07 / Step.10 tables. + - `Private/Get-AzLocalCtrlClickTip.ps1` - single-source the standing + `> **Tip:** Hold Ctrl... when clicking - or middle-click - Cluster links to + open them in a new tab.` line so it stays identical across pipelines. + +- **Step.10 Fleet Health Status report** (`Export-AzLocalFleetHealthStatusReport`): + - **Fixes "Healthy + Unhealthy != Total" KPI bug**. The KPI table previously + summed only two buckets (Healthy + Unhealthy) and silently dropped clusters + whose `HealthStatus` was `In progress` / `Unknown` / `Health check failed` AND + had no failure rows. The table is now split into two: + - **Cluster Counts** - Total / Healthy / Unhealthy / **Other** (new). Other + is computed as `Total - Healthy - Unhealthy` (clamped non-negative) so the + three buckets always sum to Total. + - **Failing Checks Breakdown** - Total / Critical / Warning / Distinct + Reasons (the old sub-counts, separated for clarity). + - New `other_clusters` pipeline step output (number of clusters in the Other + bucket); `-PassThru` PSCustomObject gains a matching `OtherClusters` field. + - **Detailed Results columns reordered** to put the most-specific identifier + first: `Severity | Title | Failure Reason | Description | Health Check Name | + Failure Remediation | Target Resource Name | Target Resource Type | + Last Occurrence | Resource Group`. Title is the per-check title (often + names the failing node or volume); Description is a collapsible `
` + block that surfaces drive/volume-level detail (e.g. the + `\Device\HarddiskVolume6\...vhdx` file path emitted by a + `Microsoft.Health.FaultType.Volume.FileSystem.Corruption.Correctable` + warning); Health Check Name is the raw fault-type name (kept verbatim so + operators can grep/triage by FaultType across runs). + +### Changed + +- **Step.05 Pre-flight Update Readiness Assessment** + (`Export-AzLocalClusterUpdateReadinessReport`): Summary counts table and + Not-Ready + All-clusters tables now render status icons via + `Get-AzLocalStatusIconMap`; Cluster cells wrapped via + `Get-AzLocalClusterPortalLink`; standing Ctrl-click tip via + `Get-AzLocalCtrlClickTip`; "Up to Date" bucket gains the readiness-cascade + check icon. +- **Step.06 Apply-Updates Readiness Gate report** + (`Export-AzLocalClusterReadinessGateReport`): drops the inline icon `switch` + block in favour of `Get-AzLocalStatusIconMap`; Cluster cells now wrap via + `Get-AzLocalClusterPortalLink`; Ctrl-click tip via + `Get-AzLocalCtrlClickTip`. +- **Step.07 Apply-Updates step summary** (`Add-AzLocalApplyUpdatesStepSummary`): + drops the inline `$iconSuccess` / `$iconFail` / `$iconBlock` / `$iconSkip` / + `$iconWarn` definitions in favour of `Get-AzLocalStatusIconMap` (so the + icons render correctly on Azure DevOps). The Cluster Actions and Clusters + Skipped at Readiness Gate tables now resolve each cluster's resource id from + the readiness CSV (built from `Get-AzLocalClusterUpdateReadiness`) and wrap + the Cluster cell via `Get-AzLocalClusterPortalLink`. Adds the standing + Ctrl-click tip via `Get-AzLocalCtrlClickTip`. (The upstream apply-results + JSON intentionally keeps its minimal `ClusterName / Status / UpdateName / + Duration / Message` shape - the cluster id lookup is done in the renderer.) +- **Step.08 In-flight Update Run Monitor** + (`Export-AzLocalUpdateRunMonitorReport`): the `$stateIcon` and `$statusIcon` + switches no longer hard-code GitHub-Markdown shortcodes (e.g. + `:large_blue_circle:` / `:hourglass_flowing_sand:`) which rendered as literal + text on Azure DevOps. Both switches now read from `Get-AzLocalStatusIconMap` + using the new `State*` (`StateInProgress` / `StateSucceeded` / `StateFailed` / + `StateNotStarted` / `StateUnknown`) and `Status*` + (`StatusInProgress` / `StatusCancelled`) keys. +- **Step.09 Fleet Update Status report** + (`Export-AzLocalFleetUpdateStatusReport`): the inline shortcodes used by the + Critical Health Status table, the Primary Status table, and the Version + Distribution Support column are routed through `Get-AzLocalStatusIconMap` + (new keys `Info`, `GreenCircle`, `YellowCircle`, `CycleArrows`, + `GreyQuestion`, `SupportSupported`, `SupportUnsupported`, + `SupportUnknown`) so they render correctly on both pipeline hosts. + +### Pipeline templates + +- All bundled pipeline templates bump `GENERATED_AGAINST_MODULE_VERSION` from + `'0.8.80'` to `'0.8.81'`. + ## [0.8.80] - 2026-06-16 Minor release. Bundles three pipeline-failure-rendering improvements that target the diff --git a/AzLocal.UpdateManagement/Private/Get-AzLocalClusterPortalLink.ps1 b/AzLocal.UpdateManagement/Private/Get-AzLocalClusterPortalLink.ps1 new file mode 100644 index 00000000..8c29077f --- /dev/null +++ b/AzLocal.UpdateManagement/Private/Get-AzLocalClusterPortalLink.ps1 @@ -0,0 +1,90 @@ +function Get-AzLocalClusterPortalLink { + ######################################## + <# + .SYNOPSIS + Wraps a cluster name in an Azure-portal deep-link anchor for + step-summary markdown. + .DESCRIPTION + v0.8.81 single source of truth for the "{name}" + snippet shared by every Public cmdlet that renders a cluster column + in a markdown step summary. + + Prior to this helper the same anchor was inlined into Step.04/08/09/10 + with subtle drift (some used $r.ClusterPortalUrl, some constructed the + URL from ClusterResourceId, some forgot to escape, etc.). All consumers + now call this helper. + + Falls back gracefully: + - If ClusterPortalUrl is supplied, uses it verbatim. + - Else if ClusterResourceId is supplied, builds the portal URL. + - Else returns the plain cluster name (no markup). + + Output stays HTML-safe by default - the cluster name is passed through + as-is because every emitting site already controls the value + (it comes from ARG output, never user input). Override -Escape to + force HTML-escape if the caller is not certain. + + .PARAMETER ClusterName + The visible cluster name to display inside the anchor. Required. + + .PARAMETER ClusterResourceId + The full ARM resource id of the cluster (used to build the portal URL + when ClusterPortalUrl is not supplied). + + .PARAMETER ClusterPortalUrl + A pre-built portal URL. When supplied, takes precedence over + ClusterResourceId. + + .PARAMETER Escape + Force HTML escaping of the cluster name inside the anchor. Off by + default - cluster names come from ARG and never contain markup. + + .OUTPUTS + [string] - HTML anchor (when a URL is available) or plain name. + + .NOTES + Author : AzLocal.UpdateManagement + Version : 0.8.81 + Created : 2026-06-15 + #> + ######################################## + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [string]$ClusterName, + + [Parameter()] + [string]$ClusterResourceId, + + [Parameter()] + [string]$ClusterPortalUrl, + + [Parameter()] + [switch]$Escape + ) + + Set-StrictMode -Version Latest + + if (-not $ClusterName) { return '' } + + $name = if ($Escape) { + $ClusterName -replace '&', '&' -replace '<', '<' -replace '>', '>' + } else { + $ClusterName + } + + $url = if ($ClusterPortalUrl) { + $ClusterPortalUrl + } elseif ($ClusterResourceId) { + 'https://portal.azure.com/#@/resource{0}' -f $ClusterResourceId + } else { + '' + } + + if ($url) { + return ('{1}' -f $url, $name) + } + + return $name +} diff --git a/AzLocal.UpdateManagement/Private/Get-AzLocalCtrlClickTip.ps1 b/AzLocal.UpdateManagement/Private/Get-AzLocalCtrlClickTip.ps1 new file mode 100644 index 00000000..2b061d5b --- /dev/null +++ b/AzLocal.UpdateManagement/Private/Get-AzLocalCtrlClickTip.ps1 @@ -0,0 +1,35 @@ +function Get-AzLocalCtrlClickTip { + ######################################## + <# + .SYNOPSIS + Returns the canonical "Hold Ctrl when clicking" markdown tip line + used above every cluster-link table in pipeline step summaries. + .DESCRIPTION + v0.8.81 single source of truth for the tip note shared by Step.05-10 + cmdlets that render cluster portal links. GitHub markdown strips + target="_blank" from anchors, so without this hint operators end up + navigating away from the step summary every time they click a cluster + name. The tip explains the Ctrl/Cmd-click workaround once per table. + + Prior to this helper the same string was duplicated (with subtle drift) + across Step.08, Step.09 and Step.10. All consumers now call this helper. + + .OUTPUTS + [string] - the markdown blockquote line, ready to add to a markdown + builder list. Does NOT include a trailing newline; callers add it + with their own [Environment]::NewLine join. + + .NOTES + Author : AzLocal.UpdateManagement + Version : 0.8.81 + Created : 2026-06-15 + #> + ######################################## + [CmdletBinding()] + [OutputType([string])] + param() + + Set-StrictMode -Version Latest + + return '> **Tip:** Hold `Ctrl` (or `Cmd` on macOS) when clicking - or middle-click - Cluster links to open them in a new tab. (GitHub markdown strips `target="_blank"`.)' +} diff --git a/AzLocal.UpdateManagement/Private/Get-AzLocalStatusIconMap.ps1 b/AzLocal.UpdateManagement/Private/Get-AzLocalStatusIconMap.ps1 new file mode 100644 index 00000000..9cfaeb51 --- /dev/null +++ b/AzLocal.UpdateManagement/Private/Get-AzLocalStatusIconMap.ps1 @@ -0,0 +1,158 @@ +function Get-AzLocalStatusIconMap { + ######################################## + <# + .SYNOPSIS + Returns the host-aware status-icon map used by every pipeline + step-summary renderer. + .DESCRIPTION + v0.8.81 single source of truth for the "status name -> icon" map shared + by every Public cmdlet that emits a markdown step summary (Step.05-10). + + Prior to this helper each cmdlet hard-coded its own icon glyphs, with + Step.07 also implementing a host-aware switch between Unicode glyphs + (for GitHub Actions and local runs) and GitHub-style markdown + shortcodes (`:white_check_mark:` etc., for Azure DevOps where the + Wiki/Markdown rendering surface understands shortcodes more reliably + than Unicode at small font sizes). + + The map is keyed by status NAME (not emoji) so callers stay readable. + Add new entries here rather than inline in renderers. + + .PARAMETER PipelineHost + Optional host override. Accepted values: 'GitHub', 'AzureDevOps', 'Local'. + When omitted, the helper auto-detects via Get-AzLocalPipelineHost. + + .OUTPUTS + [hashtable] keyed by status-name (string) -> formatted display cell (string). + Example: $map['UpToDate'] -> ':white_check_mark: Up to Date' on ADO + -> ' Up to Date' on GitHub/Local + + .NOTES + Author : AzLocal.UpdateManagement + Version : 0.8.81 + Created : 2026-06-15 + #> + ######################################## + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter()] + [ValidateSet('GitHub', 'AzureDevOps', 'Local', 'Auto')] + [string]$PipelineHost = 'Auto' + ) + + Set-StrictMode -Version Latest + + $resolvedHost = if ($PipelineHost -eq 'Auto') { Get-AzLocalPipelineHost } else { $PipelineHost } + $useShortcodes = ($resolvedHost -eq 'AzureDevOps') + + # Glyph constants -- declared once so the hashtable below stays readable. + $check = [string][char]0x2705 # ✅ + $cross = [string][char]0x274C # ❌ + $noEntry = [string][char]0x26D4 # ⛔ + $hourglass = [string][char]0x23F3 # ⏳ + $warn = [string][char]0x26A0 + [string][char]0xFE0F # ⚠️ + $question = [string][char]0x2753 # ❓ + + if ($useShortcodes) { + return @{ + # Readiness cascade + 'Ready' = ':white_check_mark: Ready' + 'ReadyForUpdate' = ':white_check_mark: Ready for Update' + 'UpToDate' = ':white_check_mark: Up to Date' + 'InProgress' = ':hourglass_flowing_sand: In Progress' + 'SbeBlocked' = ':no_entry: SBE Prerequisite' + 'HealthFailure' = ':x: Health Failure' + 'UpdateFailed' = ':x: Update Failed' + 'ActionRequired' = ':x: Action Required' + 'NeedsInvestigation' = ':warning: Needs Investigation' + # Health overview + 'Healthy' = ':white_check_mark: Healthy' + 'Critical' = ':x: Critical' + 'Warning' = ':warning: Warning' + 'Unknown' = ':question: Unknown' + 'HealthCheckFailed' = ':x: Health check failed' + # Severity tags (for failure-row labels) + 'SeverityCritical' = ':x: Critical' + 'SeverityWarning' = ':warning: Warning' + # Apply-updates outcomes + 'Success' = ':white_check_mark:' + 'Fail' = ':x:' + 'Block' = ':no_entry:' + 'Skip' = ':next_track_button:' + 'Warn' = ':warning:' + # Run.State buckets (Step.08 monitor) - bare glyph for the State + # column. Kept visually distinct from progress-status icons above + # so the State column doesn't blur into the Current Step icon. + 'StateInProgress' = ':large_blue_circle:' + 'StateSucceeded' = ':white_check_mark:' + 'StateFailed' = ':x:' + 'StateNotStarted' = ':white_circle:' + 'StateUnknown' = ':grey_question:' + # Progress-status buckets (Step.08 Current Step Status column) - + # bare glyph form of the same icons used in the readiness cascade. + 'StatusInProgress' = ':hourglass_flowing_sand:' + 'StatusCancelled' = ':fast_forward:' + # Fleet status (Step.09 Primary/Critical Health tables) - bare + # glyphs for the per-row Status column. Mostly aliases of the + # outcome icons; named so each renderer can stay readable. + 'Info' = ':information_source:' + 'GreenCircle' = ':green_circle:' + 'YellowCircle' = ':yellow_circle:' + 'CycleArrows' = ':arrows_counterclockwise:' + 'GreyQuestion' = ':grey_question:' + # Fleet version-distribution Support cell (Step.09). + 'SupportSupported' = ':white_check_mark: Supported' + 'SupportUnsupported' = ':warning: Unsupported' + 'SupportUnknown' = ':grey_question: Unknown' + } + } + + return @{ + # Readiness cascade + 'Ready' = ('{0} Ready' -f $check) + 'ReadyForUpdate' = ('{0} Ready for Update' -f $check) + 'UpToDate' = ('{0} Up to Date' -f $check) + 'InProgress' = ('{0} In Progress' -f $hourglass) + 'SbeBlocked' = ('{0} SBE Prerequisite' -f $noEntry) + 'HealthFailure' = ('{0} Health Failure' -f $cross) + 'UpdateFailed' = ('{0} Update Failed' -f $cross) + 'ActionRequired' = ('{0} Action Required' -f $cross) + 'NeedsInvestigation' = ('{0} Needs Investigation' -f $warn) + # Health overview + 'Healthy' = ('{0} Healthy' -f $check) + 'Critical' = ('{0} Critical' -f $cross) + 'Warning' = ('{0} Warning' -f $warn) + 'Unknown' = ('{0} Unknown' -f $question) + 'HealthCheckFailed' = ('{0} Health check failed' -f $cross) + # Severity tags + 'SeverityCritical' = ('{0} Critical' -f $cross) + 'SeverityWarning' = ('{0} Warning' -f $warn) + # Apply-updates outcomes (bare glyphs for inline use) + 'Success' = $check + 'Fail' = $cross + 'Block' = $noEntry + 'Skip' = ([string][char]0x23ED + [string][char]0xFE0F) + 'Warn' = $warn + # Run.State buckets (Step.08 monitor) - bare glyph for the State column. + 'StateInProgress' = ([char]::ConvertFromUtf32(0x1F535)) # blue circle (supplementary plane) + 'StateSucceeded' = $check + 'StateFailed' = $cross + 'StateNotStarted' = ([string][char]0x26AA) # white circle + 'StateUnknown' = ([string][char]0x2754) # white question mark + # Progress-status buckets (Step.08 Current Step Status column) - + # bare glyph form of the same icons used in the readiness cascade. + 'StatusInProgress' = $hourglass + 'StatusCancelled' = ([string][char]0x23E9) # fast-forward + # Fleet status (Step.09 Primary/Critical Health tables) - bare glyphs. + 'Info' = ([string][char]0x2139 + [string][char]0xFE0F) # information source + 'GreenCircle' = ([char]::ConvertFromUtf32(0x1F7E2)) # green circle (supplementary plane) + 'YellowCircle' = ([char]::ConvertFromUtf32(0x1F7E1)) # yellow circle (supplementary plane) + 'CycleArrows' = ([char]::ConvertFromUtf32(0x1F504)) # arrows counterclockwise (supplementary plane) + 'GreyQuestion' = $question + # Fleet version-distribution Support cell (Step.09). + 'SupportSupported' = ('{0} Supported' -f $check) + 'SupportUnsupported' = ('{0} Unsupported' -f $warn) + 'SupportUnknown' = ('{0} Unknown' -f $question) + } +} diff --git a/AzLocal.UpdateManagement/Public/Add-AzLocalApplyUpdatesStepSummary.ps1 b/AzLocal.UpdateManagement/Public/Add-AzLocalApplyUpdatesStepSummary.ps1 index 271be687..7b2dd2d5 100644 --- a/AzLocal.UpdateManagement/Public/Add-AzLocalApplyUpdatesStepSummary.ps1 +++ b/AzLocal.UpdateManagement/Public/Add-AzLocalApplyUpdatesStepSummary.ps1 @@ -136,12 +136,19 @@ function Add-AzLocalApplyUpdatesStepSummary { $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) } + # 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 { '##' } @@ -217,6 +224,26 @@ function Add-AzLocalApplyUpdatesStepSummary { $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) @@ -224,6 +251,8 @@ function Add-AzLocalApplyUpdatesStepSummary { [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') @@ -241,7 +270,9 @@ function Add-AzLocalApplyUpdatesStepSummary { $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 |") + $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++ } } @@ -257,6 +288,8 @@ function Add-AzLocalApplyUpdatesStepSummary { [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)) { @@ -273,7 +306,9 @@ function Add-AzLocalApplyUpdatesStepSummary { '^Failure$' { "$iconFail $hSt"; break } default { $hSt } } - [void]$sb.AppendLine("| ``$($b.ClusterName)`` | $($b.UpdateState) | $hCell | $curr | $reco | $blocking |") + $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) { diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalClusterReadinessGateReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalClusterReadinessGateReport.ps1 index 82404aaa..46d1f880 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalClusterReadinessGateReport.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalClusterReadinessGateReport.ps1 @@ -165,11 +165,16 @@ function Export-AzLocalClusterReadinessGateReport { # on ADO (file is its own summary card) and `## Cluster Readiness` on # GitHub (appended into GITHUB_STEP_SUMMARY which already has H1). $headingLevel = if ($pipelineHost -eq 'AzureDevOps') { '#' } else { '##' } + # v0.8.81: shared status-icon map + portal deep-link + Ctrl+click tip so + # the Step.6 gate report mirrors the markdown polish in Step.05 / Step.10. + $iconMap = Get-AzLocalStatusIconMap -PipelineHost $pipelineHost $sb = New-Object System.Text.StringBuilder [void]$sb.AppendLine("$headingLevel Cluster Readiness ($UpdateRing)") [void]$sb.AppendLine() [void]$sb.AppendLine("**Total:** $totalCount  |  **Ready:** $readyCount  |  **Up to Date:** $upToDateCount  |  **Not Ready:** $notReadyCount") [void]$sb.AppendLine() + [void]$sb.AppendLine((Get-AzLocalCtrlClickTip)) + [void]$sb.AppendLine() [void]$sb.AppendLine('| Cluster | Current Version | Update State | Health | Status | Recommended Update | Blocking Reasons |') [void]$sb.AppendLine('|---|---|---|---|---|---|---|') @@ -179,21 +184,14 @@ function Export-AzLocalClusterReadinessGateReport { # v0.8.74: a readable Status cell driven by the shared readiness cascade. # Up-to-Date clusters now show a green check + "Up to Date" instead of the # no-entry icon that the old binary Ready? column rendered for them. - $statusCell = switch (Get-AzLocalClusterReadinessStatus -ReadinessRow $r) { - 'ReadyForUpdate' { "{0} Ready" -f [char]0x2705 } - 'UpToDate' { "{0} Up to Date" -f [char]0x2705 } - 'InProgress' { "{0} In Progress" -f ([string]([char]0x23F3)) } - 'SbeBlocked' { "{0} SBE Prerequisite" -f [char]0x26D4 } - 'HealthFailure' { "{0} Health Failure" -f [char]0x274C } - 'UpdateFailed' { "{0} Update Failed" -f [char]0x274C } - 'ActionRequired' { "{0} Action Required" -f [char]0x274C } - default { "{0} Needs Investigation" -f ([string]([char]0x26A0) + [char]0xFE0F) } - } + # v0.8.81: icon glyph now comes from Get-AzLocalStatusIconMap (host-aware). + $statusKey = Get-AzLocalClusterReadinessStatus -ReadinessRow $r + $statusCell = if ($iconMap.ContainsKey($statusKey)) { $iconMap[$statusKey] } else { $iconMap['NeedsInvestigation'] } $hSt = "$($r.HealthState)" $hCell = switch -Regex ($hSt) { - '^Success$' { ("{0} {1}" -f [char]0x2705, $hSt); break } - '^Warning$' { ("{0} {1}" -f ([string]([char]0x26A0) + [char]0xFE0F), $hSt); break } - '^Failure$' { ("{0} {1}" -f [char]0x274C, $hSt); break } + '^Success$' { $iconMap['Healthy'] -replace ' Healthy$', " $hSt"; break } + '^Warning$' { $iconMap['Warning'] -replace ' Warning$', " $hSt"; break } + '^Failure$' { $iconMap['Critical'] -replace ' Critical$', " $hSt"; break } default { $hSt } } $blocking = "$($r.BlockingReasons)" @@ -201,7 +199,9 @@ function Export-AzLocalClusterReadinessGateReport { $blocking = $blocking -replace '\|', '\|' -replace '\r?\n', ' ' $reco = if ($r.RecommendedUpdate) { '`' + $r.RecommendedUpdate + '`' } else { '-' } $curr = if ($r.CurrentVersion) { '`' + $r.CurrentVersion + '`' } else { '-' } - [void]$sb.AppendLine("| ``$($r.ClusterName)`` | $curr | $($r.UpdateState) | $hCell | $statusCell | $reco | $blocking |") + $clusterResId = if ($r.PSObject.Properties['ClusterResourceId'] -and $r.ClusterResourceId) { [string]$r.ClusterResourceId } else { '' } + $clusterCell = Get-AzLocalClusterPortalLink -ClusterName ([string]$r.ClusterName) -ClusterResourceId $clusterResId + [void]$sb.AppendLine("| $clusterCell | $curr | $($r.UpdateState) | $hCell | $statusCell | $reco | $blocking |") $rendered++ } diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalClusterUpdateReadinessReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalClusterUpdateReadinessReport.ps1 index 61ca0683..d1b1690f 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalClusterUpdateReadinessReport.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalClusterUpdateReadinessReport.ps1 @@ -364,15 +364,17 @@ function Export-AzLocalClusterUpdateReadinessReport { [void]$md.Add('') # 3. Summary counts + # v0.8.81: use shared icon map so the summary mirrors the table rows below. + $iconMap = Get-AzLocalStatusIconMap -PipelineHost $pipelineHost [void]$md.Add('### Summary counts') [void]$md.Add('') [void]$md.Add('| Metric | Count |') [void]$md.Add('|--------|-------|') [void]$md.Add("| Total clusters in scope | $total |") - [void]$md.Add("| Ready for update | $readyForUpdate |") - [void]$md.Add("| Up to date | $upToDate |") - [void]$md.Add("| Not ready for update | $notReady |") - [void]$md.Add("| Clusters with Critical health failures | $clustersWithCritical |") + [void]$md.Add(("| {0} Ready for update | {1} |" -f $iconMap['ReadyForUpdate'], $readyForUpdate)) + [void]$md.Add(("| {0} Up to date | {1} |" -f $iconMap['UpToDate'], $upToDate)) + [void]$md.Add(("| {0} Not ready for update | {1} |" -f $iconMap['ActionRequired'], $notReady)) + [void]$md.Add(("| {0} Clusters with Critical health failures | {1} |" -f $iconMap['HealthFailure'], $clustersWithCritical)) [void]$md.Add("| Total Critical findings | $criticalFindings |") [void]$md.Add('') @@ -385,13 +387,22 @@ function Export-AzLocalClusterUpdateReadinessReport { if ($notReadyRows.Count -gt 0) { [void]$md.Add('### Not-Ready clusters (review first)') [void]$md.Add('') - [void]$md.Add('| Cluster | UpdateRing | Current version | Update state | Health | Blocking reasons |') - [void]$md.Add('|---------|------------|-----------------|--------------|--------|------------------|') + # v0.8.81: portal deep-links on the Cluster column - operators can + # jump straight to the cluster blade. Tip explains the GitHub-strips- + # target=_blank behaviour for the first column. + [void]$md.Add((Get-AzLocalCtrlClickTip)) + [void]$md.Add('') + [void]$md.Add('| Cluster | UpdateRing | Current version | Update state | Health | Status | Blocking reasons |') + [void]$md.Add('|---------|------------|-----------------|--------------|--------|--------|------------------|') foreach ($r in ($notReadyRows | Sort-Object @{Expression={ if ($ringByResourceId.ContainsKey($_.ClusterResourceId)) { $ringByResourceId[$_.ClusterResourceId] } else { 'zzz' } }}, ClusterName)) { $ring = if ($ringByResourceId.ContainsKey($r.ClusterResourceId)) { $ringByResourceId[$r.ClusterResourceId] } else { '-' } $cv = if ($r.CurrentVersion) { $r.CurrentVersion } else { '-' } $br = if ($r.PSObject.Properties['BlockingReasons'] -and $r.BlockingReasons) { $r.BlockingReasons } else { '-' } - [void]$md.Add("| $($r.ClusterName) | $ring | $cv | $($r.UpdateState) | $($r.HealthState) | $br |") + $clusterResId = if ($r.PSObject.Properties['ClusterResourceId'] -and $r.ClusterResourceId) { [string]$r.ClusterResourceId } else { '' } + $clusterCell = Get-AzLocalClusterPortalLink -ClusterName ([string]$r.ClusterName) -ClusterResourceId $clusterResId + $statusKey = Get-AzLocalClusterReadinessStatus -ReadinessRow $r + $statusCell = if ($iconMap.ContainsKey($statusKey)) { $iconMap[$statusKey] } else { $iconMap['NeedsInvestigation'] } + [void]$md.Add("| $clusterCell | $ring | $cv | $($r.UpdateState) | $($r.HealthState) | $statusCell | $br |") } [void]$md.Add('') } @@ -433,6 +444,10 @@ function Export-AzLocalClusterUpdateReadinessReport { if ($total -gt 0) { [void]$md.Add('### All clusters detail') [void]$md.Add('') + # v0.8.81: portal deep-links on the Cluster column + status icons via + # shared iconMap (mirrors Step.06 readiness-gate output). + [void]$md.Add((Get-AzLocalCtrlClickTip)) + [void]$md.Add('') [void]$md.Add('| Cluster | UpdateRing | Current version | Current SBE version | Update state | Health | Status | Recommended update |') [void]$md.Add('|---------|------------|-----------------|---------------------|--------------|--------|--------|--------------------|') foreach ($r in ($readiness | Sort-Object @{Expression={ if ($ringByResourceId.ContainsKey($_.ClusterResourceId)) { $ringByResourceId[$_.ClusterResourceId] } else { 'zzz' } }}, ClusterName)) { @@ -440,17 +455,11 @@ function Export-AzLocalClusterUpdateReadinessReport { $cv = if ($r.CurrentVersion) { $r.CurrentVersion } else { '-' } $csv = if ($r.PSObject.Properties['CurrentSbeVersion'] -and $r.CurrentSbeVersion) { $r.CurrentSbeVersion } else { '-' } $ru = if ($r.RecommendedUpdate) { $r.RecommendedUpdate } else { '-' } - $statusLabel = switch (Get-AzLocalClusterReadinessStatus -ReadinessRow $r) { - 'ReadyForUpdate' { 'Ready for Update' } - 'UpToDate' { 'Up to Date' } - 'InProgress' { 'In Progress' } - 'SbeBlocked' { 'SBE Prerequisite' } - 'HealthFailure' { 'Health Failure' } - 'UpdateFailed' { 'Update Failed' } - 'ActionRequired' { 'Action Required' } - default { 'Needs Investigation' } - } - [void]$md.Add("| $($r.ClusterName) | $ring | $cv | $csv | $($r.UpdateState) | $($r.HealthState) | $statusLabel | $ru |") + $statusKey = Get-AzLocalClusterReadinessStatus -ReadinessRow $r + $statusCell = if ($iconMap.ContainsKey($statusKey)) { $iconMap[$statusKey] } else { $iconMap['NeedsInvestigation'] } + $clusterResId = if ($r.PSObject.Properties['ClusterResourceId'] -and $r.ClusterResourceId) { [string]$r.ClusterResourceId } else { '' } + $clusterCell = Get-AzLocalClusterPortalLink -ClusterName ([string]$r.ClusterName) -ClusterResourceId $clusterResId + [void]$md.Add("| $clusterCell | $ring | $cv | $csv | $($r.UpdateState) | $($r.HealthState) | $statusCell | $ru |") } [void]$md.Add('') } diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 index 69989647..97df925f 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 @@ -434,42 +434,69 @@ function Export-AzLocalFleetHealthStatusReport { Set-AzLocalPipelineOutput -Name 'overview_rows' -Value ([string]$overview.Count) Set-AzLocalPipelineOutput -Name 'healthy_clusters' -Value ([string]$healthyClusters) Set-AzLocalPipelineOutput -Name 'total_in_sub' -Value ([string]$totalInSub) - + # v0.8.81: Other-bucket step output so consumers can validate + # healthy + unhealthy + other = total_in_sub without re-deriving. + $otherClustersOut = $totalInSub - $healthyClusters - $totalClusters + if ($otherClustersOut -lt 0) { $otherClustersOut = 0 } + Set-AzLocalPipelineOutput -Name 'other_clusters' -Value ([string]$otherClustersOut) Write-Host "" Write-Host "Fleet Health Collection complete:" Write-Host " Total clusters in scope : $totalInSub" Write-Host " Healthy clusters : $healthyClusters" Write-Host " Unhealthy clusters : $totalClusters" + $otherClustersDiag = $totalInSub - $healthyClusters - $totalClusters + if ($otherClustersDiag -lt 0) { $otherClustersDiag = 0 } + Write-Host " Other clusters (non-Healthy, no failures): $otherClustersDiag" Write-Host " Total failing checks : $totalFailures (Critical=$criticalCount, Warning=$warningCount)" Write-Host " Distinct failure reasons: $distinctReasons" Write-Host " Overview rows : $($overview.Count)" # ---- Step 7: markdown summary ---------------------------------------- + # v0.8.81: split the legacy single KPI table into TWO tables so each one + # has a row set that actually sums to its denominator: + # 1. Cluster Counts (Total = Healthy + Unhealthy + Other) + # 2. Failing Checks Breakdown (Total = Critical + Warning, plus + # Distinct Reasons as a secondary metric) + # The legacy table mixed both axes and produced the confusing + # "11 + 8 != 20" output when one or more clusters had HealthStatus other + # than 'Healthy' AND no failing detail rows (e.g. 'In progress', + # 'Unknown', 'Health check failed'). $generatedUtc = $Now.ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss UTC') + $iconMap = Get-AzLocalStatusIconMap -PipelineHost $pipelineHost + $otherClusters = $totalInSub - $healthyClusters - $totalClusters + if ($otherClusters -lt 0) { $otherClusters = 0 } $md = New-Object 'System.Collections.Generic.List[string]' [void]$md.Add('## Fleet Health Status Summary') [void]$md.Add('') + [void]$md.Add('### Cluster Counts') + [void]$md.Add('') [void]$md.Add('| Metric | Count |') [void]$md.Add('|--------|-------|') [void]$md.Add("| **Total Clusters in Subscription** | $totalInSub |") - [void]$md.Add("| **Healthy Clusters** | $healthyClusters |") - [void]$md.Add("| **Unhealthy Clusters** | $totalClusters |") + [void]$md.Add(("| {0} **Healthy Clusters** | {1} |" -f $iconMap['Healthy'], $healthyClusters)) + [void]$md.Add(("| {0} **Unhealthy Clusters (with failing checks)** | {1} |" -f $iconMap['SeverityCritical'], $totalClusters)) + [void]$md.Add(("| {0} **Other (In progress / Unknown / Health check failed)** | {1} |" -f $iconMap['Warning'], $otherClusters)) + [void]$md.Add('') + [void]$md.Add('> _Cluster counts sum to **Total Clusters in Subscription**. **Unhealthy** = clusters with at least one Critical or Warning health-check failure in the Detail view. **Other** captures clusters with no failing checks but a non-`Healthy` overview state (e.g. `In progress`, `Unknown`, `Health check failed`, `Warning`)._') + [void]$md.Add('') + [void]$md.Add('### Failing Checks Breakdown') + [void]$md.Add('') + [void]$md.Add('| Metric | Count |') + [void]$md.Add('|--------|-------|') [void]$md.Add("| **Total Failing Checks** | $totalFailures |") - [void]$md.Add("| **Critical** | $criticalCount |") - [void]$md.Add("| **Warning** | $warningCount |") + [void]$md.Add(("| {0} **Critical** | {1} |" -f $iconMap['SeverityCritical'], $criticalCount)) + [void]$md.Add(("| {0} **Warning** | {1} |" -f $iconMap['SeverityWarning'], $warningCount)) [void]$md.Add("| **Distinct Failure Reasons** | $distinctReasons |") [void]$md.Add('') - [void]$md.Add('> _**Healthy** / **Unhealthy** count clusters via `Get-AzLocalFleetHealthOverview`; **Unhealthy** = at least one Critical or Warning health-check failure. **Total Failing Checks** counts individual failing checks (one cluster can contribute multiple)._') + [void]$md.Add('> _**Total Failing Checks** = **Critical** + **Warning**. One cluster can contribute multiple failing checks. **Distinct Failure Reasons** is a secondary axis (Critical + Warning rolled up by reason)._') [void]$md.Add('') # ---- Fleet Health Overview table ------------------------------------- [void]$md.Add('### Fleet Health Overview (fleet rollup)') [void]$md.Add('') - # GitHub / ADO step-summary sanitisers strip `target="_blank"` (and force - # `rel="nofollow"`), so the Cluster portal links open in the current tab - # by default. Tip stays even when there are zero overview rows because the - # detail / by-reason tables further down can still emit cluster links. - [void]$md.Add('> **Tip:** Hold `Ctrl` (or `Cmd` on macOS) when clicking - or middle-click - Cluster links to open them in a new tab. (GitHub markdown strips `target="_blank"`.)') + # v0.8.81: tip is shared across Step.04/08/09/10 - factored out so the + # phrasing stays in sync. + [void]$md.Add((Get-AzLocalCtrlClickTip)) [void]$md.Add('') if ($overview.Count -eq 0) { [void]$md.Add('*No clusters returned from Get-AzLocalFleetHealthOverview.*') @@ -479,18 +506,16 @@ function Export-AzLocalFleetHealthStatusReport { [void]$md.Add('|---------|--------|---------------|------------------|--------------|------------------|---------------|--------------------------|------------|') foreach ($o in (@($overview) | Select-Object -First $MaxOverviewRows)) { # target="_blank" intentionally omitted: sanitiser strips it. See Tip above. - $clusterCell = if ($o.ClusterPortalUrl) { - ('{1}' -f $o.ClusterPortalUrl, $o.ClusterName) - } - else { [string]$o.ClusterName } - # Use literal Unicode glyphs (not GH ':name:' shortcodes) so GH + - # ADO render identically. Label is retained for greppability. + $clusterCell = Get-AzLocalClusterPortalLink -ClusterName ([string]$o.ClusterName) -ClusterPortalUrl ([string]$o.ClusterPortalUrl) + # v0.8.81: use shared icon map - keep readable status names rather + # than inlining glyph constants. $healthTag = switch ($o.HealthStatus) { - 'Healthy' { "$([char]0x2705) Healthy" } - 'Critical' { "$([char]0x274C) Critical" } - 'Warning' { "$([char]0x26A0)$([char]0xFE0F) Warning" } - 'In progress' { "$([char]0x23F3) In progress" } - 'Health check failed' { "$([char]0x274C) Failed" } + 'Healthy' { $iconMap['Healthy'] } + 'Critical' { $iconMap['Critical'] } + 'Warning' { $iconMap['Warning'] } + 'In progress' { $iconMap['InProgress'] } + 'Health check failed' { $iconMap['HealthCheckFailed'] } + 'Unknown' { $iconMap['Unknown'] } default { '[' + [string]$o.HealthStatus + ']' } } [void]$md.Add(('| {0} | {1} | {2} | {3} | {4} | {5} | {6} | {7} | {8} |' -f $clusterCell, $healthTag, $o.UpdateStatus, $o.CurrentVersion, $o.SbeVersion, $o.AzureConnection, $o.LastChecked, $o.HealthResultsAgeDays, $o.NodeCount)) @@ -512,7 +537,7 @@ function Export-AzLocalFleetHealthStatusReport { [void]$md.Add('| Severity | Failure Reason | Cluster Count | Failure Count | Affected Clusters | Latest |') [void]$md.Add('|----------|----------------|---------------|---------------|--------------------|--------|') foreach ($r in (@($summary) | Select-Object -First $MaxSummaryRows)) { - $sevTag = if ($r.Severity -eq 'Critical') { '[Critical]' } else { '[Warning]' } + $sevTag = if ($r.Severity -eq 'Critical') { $iconMap['SeverityCritical'] } else { $iconMap['SeverityWarning'] } # Keep @() OUTSIDE the 'if' so single-element splits do not unwrap # to a bare String (PowerShell scalar-unwrap guard). $names = @(if ($r.AffectedClusters) { $r.AffectedClusters -split '; ' } else { @() }) @@ -579,29 +604,60 @@ function Export-AzLocalFleetHealthStatusReport { if ($cl.WarningCount -gt 0) { $sevParts += ('[Warning] x {0}' -f $cl.WarningCount) } $sevTally = $sevParts -join '  ·  ' - $clusterCell = if ($cl.ClusterPortalUrl) { - ('{1}' -f $cl.ClusterPortalUrl, $cl.ClusterName) - } - else { [string]$cl.ClusterName } + $clusterCell = Get-AzLocalClusterPortalLink -ClusterName ([string]$cl.ClusterName) -ClusterPortalUrl ([string]$cl.ClusterPortalUrl) $lastOccStr = if ($cl.LastOccurrence) { ('{0:yyyy-MM-ddTHH:mm:ssZ}' -f $cl.LastOccurrence) } else { '-' } [void]$md.Add('
') [void]$md.Add(('{0}  ·  {1}  ·  last {2}' -f $clusterCell, $sevTally, $lastOccStr)) [void]$md.Add('') - [void]$md.Add('| Severity | Failure Reason | Title | Failure Remediation | Target Resource Name | Target Resource Type | Last Occurrence | Resource Group |') - [void]$md.Add('|----------|----------------|-------|---------------------|----------------------|----------------------|------------------|----------------|') + # v0.8.81: column order rework so the most-specific cell appears + # first. `Title` (per-check title) is usually the most operator- + # readable description of the failure ("Node-03 disk D: failed"); + # `Failure Reason` (the displayName ARG payload) is the broader + # category ("Storage Physical Disks Health Check"). When Title + # is empty or identical to Failure Reason, the Title cell shows + # an em-dash so the column stays self-evidently empty rather than + # duplicating the reason. `Description` is wrapped in an inline + #
so long contextual text (file paths, volume names, + # node identifiers) does not bloat the row. `Health Check Name` + # is the raw ARG `name` field (e.g. + # `Microsoft.Health.FaultType.Volume.FileSystem.Corruption.Correctable`) + # in monospace for grep/triage. + [void]$md.Add('| Severity | Title | Failure Reason | Description | Health Check Name | Failure Remediation | Target Resource Name | Target Resource Type | Last Occurrence | Resource Group |') + [void]$md.Add('|----------|-------|----------------|-------------|--------------------|---------------------|----------------------|----------------------|------------------|----------------|') foreach ($r in $cl.Rows) { - $sevTag = if ($r.Severity -eq 'Critical') { '[Critical]' } else { '[Warning]' } + $sevTag = if ($r.Severity -eq 'Critical') { $iconMap['SeverityCritical'] } else { $iconMap['SeverityWarning'] } $rem = if ($r.PSObject.Properties.Match('Remediation').Count -gt 0) { [string]$r.Remediation } else { '' } $remCell = if ($rem -and $rem.StartsWith('https://')) { ('link' -f $rem) } else { $rem } $tName = if ($r.PSObject.Properties.Match('TargetResourceName').Count -gt 0) { [string]$r.TargetResourceName } else { '' } $tType = if ($r.PSObject.Properties.Match('TargetResourceType').Count -gt 0) { [string]$r.TargetResourceType } else { '' } - # v0.8.80: add per-check `title` (often the most specific - # single field - names the failing node/volume) and wrap - # TargetResourceName in a portal deep-link when the - # corresponding TargetResourceID is present. $tTitle = if ($r.PSObject.Properties.Match('Title').Count -gt 0) { [string]$r.Title } else { '' } $tResId = if ($r.PSObject.Properties.Match('TargetResourceID').Count -gt 0) { [string]$r.TargetResourceID } else { '' } + $tDesc = if ($r.PSObject.Properties.Match('Description').Count -gt 0) { [string]$r.Description } else { '' } + $tName_raw = if ($r.PSObject.Properties.Match('FailureName').Count -gt 0) { [string]$r.FailureName } else { '' } + + # v0.8.81: collapse Title when it duplicates the Failure Reason + # verbatim (cluster-rollup checks often have identical title + + # displayName). em-dash keeps the column visually distinct. + $titleCell = if ($tTitle -and ($tTitle -ne [string]$r.FailureReason)) { $tTitle } else { ([string][char]0x2014) } + + # v0.8.81: collapsible Description cell (operators told us they + # could not see *which volume* was corrupt - the file path lives + # in Description, which the legacy renderer only surfaced via + # the JUnit XML body, not the markdown table). + $descCell = if ($tDesc) { + $descEscaped = $tDesc -replace '\|', '\|' -replace '`', '\`' + # Markdown tables forbid raw newlines in cells - render as
. + $descEscaped = $descEscaped -replace "`r`n", '
' -replace "`n", '
' + if ($descEscaped.Length -gt 120) { + '
view{0}
' -f $descEscaped + } else { + $descEscaped + } + } else { '' } + + $nameRawCell = if ($tName_raw) { ('`{0}`' -f $tName_raw) } else { '' } + $tNameCell = if ($tResId -and $tName) { '{1}' -f $tResId, $tName } elseif ($tResId) { @@ -609,7 +665,7 @@ function Export-AzLocalFleetHealthStatusReport { } else { $tName } - [void]$md.Add(('| {0} | {1} | {2} | {3} | {4} | {5} | {6} | {7} |' -f $sevTag, $r.FailureReason, $tTitle, $remCell, $tNameCell, $tType, $r.LastOccurrence, $r.ResourceGroup)) + [void]$md.Add(('| {0} | {1} | {2} | {3} | {4} | {5} | {6} | {7} | {8} | {9} |' -f $sevTag, $titleCell, $r.FailureReason, $descCell, $nameRawCell, $remCell, $tNameCell, $tType, $r.LastOccurrence, $r.ResourceGroup)) } [void]$md.Add('
') [void]$md.Add('') @@ -651,6 +707,7 @@ function Export-AzLocalFleetHealthStatusReport { OverviewRows = [int]$overview.Count HealthyClusters = [int]$healthyClusters TotalInSub = [int]$totalInSub + OtherClusters = [int]$otherClustersOut DetailCsvPath = $detailCsv DetailJsonPath = $detailJson SummaryCsvPath = $summaryCsv diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetUpdateStatusReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetUpdateStatusReport.ps1 index 9d7a78f4..e6ec77eb 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetUpdateStatusReport.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetUpdateStatusReport.ps1 @@ -249,6 +249,11 @@ function Export-AzLocalFleetUpdateStatusReport { $pipelineHost = Get-AzLocalPipelineHost + # v0.8.81: shared status-icon map (host-aware) - replaces the inline + # GitHub shortcodes that previously rendered as literal text on Azure + # DevOps step summaries. + $iconMap = Get-AzLocalStatusIconMap -PipelineHost $pipelineHost + if (-not $OutputDirectory) { if ($pipelineHost -eq 'AzureDevOps' -and $env:BUILD_ARTIFACTSTAGINGDIRECTORY) { $OutputDirectory = Join-Path -Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY -ChildPath 'reports' @@ -847,9 +852,9 @@ function Export-AzLocalFleetUpdateStatusReport { $yymmDisplay = if ($g.Name) { $g.Name } else { '_(unknown)_' } $supportStatusForYymm = $groupRows[0].SupportStatus $supportEmoji = switch ($supportStatusForYymm) { - 'Supported' { ':white_check_mark: Supported' } - 'Unsupported' { ':warning: Unsupported' } - default { ':grey_question: Unknown' } + 'Supported' { $iconMap['SupportSupported'] } + 'Unsupported' { $iconMap['SupportUnsupported'] } + default { $iconMap['SupportUnknown'] } } $updateVersionsCell = (($groupRows | ForEach-Object { '{0} x {1}' -f $_.Version, $_.Count }) -join '
') $clusterNames = @() @@ -884,8 +889,8 @@ function Export-AzLocalFleetUpdateStatusReport { [void]$md.Add('') [void]$md.Add('| Metric | Count | Status |') [void]$md.Add('|--------|-------|--------|') - [void]$md.Add("| **Passed** (healthy, no failures, not SBE-blocked) | $criticalHealthPassed | :white_check_mark: |") - $chFailedIcon = if ($criticalHealthFailed -gt 0) { ':x:' } else { ':white_check_mark:' } + [void]$md.Add("| **Passed** (healthy, no failures, not SBE-blocked) | $criticalHealthPassed | $($iconMap['Success']) |") + $chFailedIcon = if ($criticalHealthFailed -gt 0) { $iconMap['Fail'] } else { $iconMap['Success'] } [void]$md.Add("| **Failed** (HealthState=Failure OR UpdateState=Failed OR SBE prerequisite blocked) | $criticalHealthFailed | $chFailedIcon |") [void]$md.Add('') @@ -894,19 +899,19 @@ function Export-AzLocalFleetUpdateStatusReport { [void]$md.Add('') [void]$md.Add('| Metric | Count | Status |') [void]$md.Add('|--------|-------|--------|') - [void]$md.Add("| **Total Clusters** | $totalTests | :information_source: |") - [void]$md.Add("| **Up to Date** | $stUpToDate | :white_check_mark: |") - [void]$md.Add("| **Ready for Update** | $stReadyForUpdate | :green_circle: |") - [void]$md.Add("| **Update In Progress** | $stInProgress | :arrows_counterclockwise: |") - $sbeIcon = if ($stSbeBlocked -gt 0) { ':yellow_circle:' } else { ':white_check_mark:' } + [void]$md.Add("| **Total Clusters** | $totalTests | $($iconMap['Info']) |") + [void]$md.Add("| **Up to Date** | $stUpToDate | $($iconMap['Success']) |") + [void]$md.Add("| **Ready for Update** | $stReadyForUpdate | $($iconMap['GreenCircle']) |") + [void]$md.Add("| **Update In Progress** | $stInProgress | $($iconMap['CycleArrows']) |") + $sbeIcon = if ($stSbeBlocked -gt 0) { $iconMap['YellowCircle'] } else { $iconMap['Success'] } [void]$md.Add("| **SBE Prerequisite Blocked** | $stSbeBlocked | $sbeIcon |") - $hfIcon = if ($stHealthFailure -gt 0) { ':x:' } else { ':white_check_mark:' } + $hfIcon = if ($stHealthFailure -gt 0) { $iconMap['Fail'] } else { $iconMap['Success'] } [void]$md.Add("| **Health Failure** (HealthState=Failure) | $stHealthFailure | $hfIcon |") - $ufIcon = if ($stUpdateFailed -gt 0) { ':x:' } else { ':white_check_mark:' } + $ufIcon = if ($stUpdateFailed -gt 0) { $iconMap['Fail'] } else { $iconMap['Success'] } [void]$md.Add("| **Update Failed** (Failed / UpdateFailed / NeedsAttention) | $stUpdateFailed | $ufIcon |") - $arIcon = if ($stActionRequired -gt 0) { ':x:' } else { ':white_check_mark:' } + $arIcon = if ($stActionRequired -gt 0) { $iconMap['Fail'] } else { $iconMap['Success'] } [void]$md.Add("| **Action Required** (PreparationFailed) | $stActionRequired | $arIcon |") - $niIcon = if ($stOther -gt 0) { ':warning:' } else { ':white_check_mark:' } + $niIcon = if ($stOther -gt 0) { $iconMap['Warn'] } else { $iconMap['Success'] } [void]$md.Add("| **Needs Investigation** | $stOther | $niIcon |") [void]$md.Add('') [void]$md.Add('Priority cascade used to bucket each cluster exactly once:') diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalUpdateRunMonitorReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalUpdateRunMonitorReport.ps1 index 5c62e2a6..746bb24e 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalUpdateRunMonitorReport.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalUpdateRunMonitorReport.ps1 @@ -185,6 +185,11 @@ function Export-AzLocalUpdateRunMonitorReport { $pipelineHost = Get-AzLocalPipelineHost + # v0.8.81: shared status-icon map (host-aware) - replaces the inline + # red/yellow/green-circle and check/cross shortcodes that previously + # rendered as literal text on Azure DevOps. + $iconMap = Get-AzLocalStatusIconMap -PipelineHost $pipelineHost + if (-not $OutputDirectory) { if ($pipelineHost -eq 'AzureDevOps' -and $env:BUILD_ARTIFACTSTAGINGDIRECTORY) { $OutputDirectory = $env:BUILD_ARTIFACTSTAGINGDIRECTORY @@ -322,18 +327,18 @@ function Export-AzLocalUpdateRunMonitorReport { elseif ($elapsed -gt $thresholdSpan) { $runSeverity = 'warn' } } $stateIcon = switch ($r.State) { - 'InProgress' { ':large_blue_circle:' } - 'Succeeded' { ':large_green_circle:' } - 'Failed' { ':red_circle:' } - 'NotStarted' { ':white_circle:' } - default { ':grey_question:' } + 'InProgress' { $iconMap['StateInProgress'] } + 'Succeeded' { $iconMap['StateSucceeded'] } + 'Failed' { $iconMap['StateFailed'] } + 'NotStarted' { $iconMap['StateNotStarted'] } + default { $iconMap['StateUnknown'] } } $statusIcon = switch ($progressStatus) { - 'Success' { ':white_check_mark:' } - 'Error' { ':x:' } - 'InProgress' { ':hourglass_flowing_sand:' } - 'Skipped' { ':no_entry:' } - 'Cancelled' { ':fast_forward:' } + 'Success' { $iconMap['Success'] } + 'Error' { $iconMap['Fail'] } + 'InProgress' { $iconMap['StatusInProgress'] } + 'Skipped' { $iconMap['Block'] } + 'Cancelled' { $iconMap['StatusCancelled'] } default { '' } } $chipList = New-Object 'System.Collections.Generic.List[string]' diff --git a/AzLocal.UpdateManagement/README.md b/AzLocal.UpdateManagement/README.md index bd56a6bb..761355b7 100644 --- a/AzLocal.UpdateManagement/README.md +++ b/AzLocal.UpdateManagement/README.md @@ -2,7 +2,7 @@ > ⚠️ **Disclaimer**: This module is **NOT** a Microsoft supported service offering or product. It is provided as example code only, with no warranty or official support. Refer to the [MIT license](https://github.com/NeilBird/Azure-Local/blob/main/LICENSE) for further information. -**Latest Version:** v0.8.80 - [Published in PowerShell Gallery](https://www.powershellgallery.com/packages/AzLocal.UpdateManagement/0.8.80) +**Latest Version:** v0.8.81 - [Published in PowerShell Gallery](https://www.powershellgallery.com/packages/AzLocal.UpdateManagement/0.8.81) > 📢 **Renamed in v0.7.3**: this module was previously published as `AzStackHci.ManageUpdates`. The new module name aligns with the Azure Local product name (_Microsoft retired the *Azure Stack HCI* brand in late 2024_). The module GUID is preserved across the rename. If you have the old name installed, run: > @@ -23,7 +23,7 @@ Azure Local REST API specification (includes update management endpoints): https **This README (overview + most-recent release notes):** - [Where to Start](#where-to-start) -- [What's New in v0.8.80](#whats-new-in-v0880) +- [What's New in v0.8.81](#whats-new-in-v0881) - [Files](#files) - [Prerequisites](#prerequisites) - [RBAC Requirements](#rbac-requirements) (summary; full reference in [docs/rbac.md](docs/rbac.md)) @@ -86,36 +86,40 @@ If you are new to this module, work through these in order from a regular PowerS > Most CI/CD pipelines in [Automation-Pipeline-Examples/](Automation-Pipeline-Examples/) are direct implementations of one of these workflows. Start there if you want a copy-pasteable end-to-end pipeline. -## What's New in v0.8.80 +## What's New in v0.8.81 -**Minor release. Pipeline failure-rendering improvements** bundled as three additive changes targeting the Step.05 / Step.08 / Step.09 / Step.10 step summaries. All changes are backward-compatible. One new private helper (`Get-AzLocalUpdateRunHealthEvidence`). +**Patch release. Step summary polish across Steps 05-10.** Fixes a Step.10 KPI counting bug, surfaces drive/volume-level detail in the Step.10 health-failure renderer, and consolidates status-icon / cluster-deep-link / Ctrl-click-tip rendering onto three new shared private helpers so Steps 05-09 stay consistent and the Azure DevOps step summary no longer leaks literal GitHub-Markdown shortcodes (`:white_check_mark:`, etc.) into the rendered output. No public API or export-count change (still 60 exports). One new private helper trio. -### Q1 - HealthCheck failure enrichment (Step.09 `fleet-update-status`) +### Step.10 Fleet Health Status report - KPI fix + drive-level detail -When `Get-AzLocalUpdateRunFailures` categorises a failed run as `ErrorCategory='HealthCheck'` (the default), it now attaches a `HealthCheckEvidence` array column carrying same-cluster **Critical** `updateSummaries.healthCheckResult` entries that fired within a `+/-2h` window around the run. The Step.09 markdown step summary renders this evidence as bullets inside the per-row `
` panel and the JUnit XML systemOut block lists each evidence entry's timestamp / title / severity / target resource id. New parameter `[bool]$EnrichWithHealthEvidence = $true` lets CSV-only consumers opt out of the extra ARG hops. +- **Fixes the "Healthy + Unhealthy != Total" KPI counting bug** (`Export-AzLocalFleetHealthStatusReport`). The KPI table previously summed only two buckets (Healthy + Unhealthy) and silently dropped clusters whose `HealthStatus` was `In progress` / `Unknown` / `Health check failed` AND had no failure rows. The KPI block is now split into two tables: + - **Cluster Counts** - Total / Healthy / Unhealthy / **Other** (new). Other is computed as `Total - Healthy - Unhealthy` (clamped non-negative) so the three buckets always sum to Total. + - **Failing Checks Breakdown** - Total / Critical / Warning / Distinct Reasons (the old sub-counts, separated for clarity). +- New `other_clusters` pipeline step output; `-PassThru` PSCustomObject gains an `OtherClusters` field. +- **Detailed Results columns reordered** to put the most-specific identifier first: `Severity | Title | Failure Reason | Description | Health Check Name | Failure Remediation | Target Resource Name | Target Resource Type | Last Occurrence | Resource Group`. Description is rendered as a collapsible `
` block so file-paths and volume detail (e.g. the `\Device\HarddiskVolume6\...vhdx` path emitted by a `Microsoft.Health.FaultType.Volume.FileSystem.Corruption.Correctable` warning) are surfaced without bloating the default row height. The raw fault-type Name is preserved so operators can grep / triage by FaultType across runs. -The new private helper `Get-AzLocalUpdateRunHealthEvidence` performs a single ARG hop per HealthCheck-category row, scoped to one cluster id, reusing the existing anti-mv-expand 128-cap projection pattern. +### Steps 05-09 - shared step-summary helpers -### Q2 - Title and TargetResourceID columns (Step.05 + Step.10) +Three new Private helpers: -Both fleet-wide (`Get-AzLocalFleetHealthFailures`, Step.10) and per-cluster (`Test-AzLocalClusterHealth`, Step.05) health failure outputs now project two extra fields off every `properties.healthCheckResult` entry: +- **`Get-AzLocalStatusIconMap`** - host-aware (GitHub vs Azure DevOps) status-name -> icon-cell map. Returns GitHub-Markdown shortcodes (`:white_check_mark:` / `:x:` / `:warning:` / ...) on the `GitHubActions` host where they render natively, and Unicode glyphs (U+2705 / U+274C / U+26A0 / ...) on every other host. Covers the full readiness cascade, health-overview buckets, severity tags, apply-updates outcomes, run-state buckets, progress-status buckets, fleet-status icons, and version-support cells - single source of truth for every Step.* pipeline icon. +- **`Get-AzLocalClusterPortalLink`** - wraps a cluster name in a Markdown anchor pointing at the Azure Portal resource blade (`https://portal.azure.com/#@/resource{resourceId}`) when a resource id is supplied; falls back to the plain cluster name otherwise. +- **`Get-AzLocalCtrlClickTip`** - single-source the standing `> **Tip:** Hold Ctrl... when clicking - or middle-click - Cluster links to open them in a new tab.` line so it stays identical across pipelines. -- **`Title`** - the per-check title (often more specific than `displayName`, e.g. names the failing node or volume). -- **`TargetResourceID`** - full ARM id of the failing component; lets renderers deep-link Server / Volume health failures back to the exact node or volume. +Per-pipeline polish: -`Export-AzLocalFleetHealthStatusReport` adds Title as a markdown column and wraps TargetResourceName in a portal hyperlink when TargetResourceID is present. `Test-AzLocalClusterHealth` adds Title to the dedup composite key, the `Format-Table` console output, and the JUnit `Message` field. - -### Q3 - Surface BOTH deepest-step `description` AND `errorMessage` (Step.08 + Step.09) - -The two deepest-error walkers (`Resolve-AzLocalUpdateRunDeepestError` and `Get-DeepestErrorMessage`) now capture step `description` at the same recursion site as `errorMessage` (previously description was silently dropped via coalesce). New `DeepestStepDescription` column on `Get-AzLocalUpdateRunFailures`; new `ErrorDescription` field on `Format-AzLocalUpdateRun` / `Get-AzLocalUpdateRuns -PassThru`. The Step.08 (`Export-AzLocalUpdateRunMonitorReport`) and Step.09 (`Export-AzLocalFleetUpdateStatusReport`) renderers combine `description + errorMessage` in markdown failure cells and JUnit bodies. Cross-reference `.NOTES` headers added to the four parallel functions so future maintainers know to keep them in sync. +- **Step.05** (`Export-AzLocalClusterUpdateReadinessReport`): icons via `Get-AzLocalStatusIconMap`, Cluster cell via `Get-AzLocalClusterPortalLink`, Ctrl-click tip via `Get-AzLocalCtrlClickTip`; "Up to Date" gains the readiness-cascade check icon. +- **Step.06** (`Export-AzLocalClusterReadinessGateReport`): drops the inline icon switch in favour of the shared helper; cluster cells now deep-link to the portal; Ctrl-click tip added. +- **Step.07** (`Add-AzLocalApplyUpdatesStepSummary`): drops the inline `$iconSuccess` / `$iconFail` / `$iconBlock` / `$iconSkip` / `$iconWarn` definitions in favour of the shared helper (so the icons render correctly on Azure DevOps). The Cluster Actions and Clusters Skipped at Readiness Gate tables now resolve each cluster's resource id from the readiness CSV (built from `Get-AzLocalClusterUpdateReadiness`) and wrap the Cluster cell via `Get-AzLocalClusterPortalLink`; Ctrl-click tip added. (The upstream apply-results JSON intentionally keeps its minimal `ClusterName / Status / UpdateName / Duration / Message` shape - the cluster id lookup is done in the renderer.) +- **Step.08** (`Export-AzLocalUpdateRunMonitorReport`): the `$stateIcon` and `$statusIcon` switches no longer hard-code GitHub-Markdown shortcodes (`:large_blue_circle:` / `:hourglass_flowing_sand:` / etc.) which rendered as literal text on Azure DevOps. Both switches read from the shared icon map using the new `State*` and `Status*` keys. +- **Step.09** (`Export-AzLocalFleetUpdateStatusReport`): the inline shortcodes used by the Critical Health Status table, the Primary Status table, and the Version Distribution Support column are routed through the shared icon map (new keys `Info`, `GreenCircle`, `YellowCircle`, `CycleArrows`, `GreyQuestion`, `SupportSupported`, `SupportUnsupported`, `SupportUnknown`). ### Notes -- **No new exports** (count unchanged at 60). -- **Step.07** (`Add-AzLocalApplyUpdatesStepSummary`) was deliberately left out of the Q1 / Q3 wiring because its data source is the live `Invoke-AzLocalReadinessGatedClusterUpdate` result row (apply-results.json), not `Get-AzLocalUpdateRunFailures`. Surfacing Q1 / Q3 at Step.07 requires a separate upstream change and is tracked for a follow-up release. -- **`GENERATED_AGAINST_MODULE_VERSION`** bumped from `0.8.79` to `0.8.80` across all bundled pipeline templates. +- **No new exports** (count unchanged at 60). Helpers are Private and live under `Private/`. +- **`GENERATED_AGAINST_MODULE_VERSION`** bumped from `0.8.80` to `0.8.81` across all bundled pipeline templates. -See [CHANGELOG.md](CHANGELOG.md#0880---2026-06-16) for the full v0.8.80 entry. See [`What's New in v0.8.79`](docs/release-history.md#whats-new-in-v0879) in the Release History for the previous release. +See [CHANGELOG.md](CHANGELOG.md#0881---2026-06-17) for the full v0.8.81 entry. See [`What's New in v0.8.80`](docs/release-history.md#whats-new-in-v0880) in the Release History for the previous release. ## Files @@ -594,7 +598,11 @@ This code is provided as-is for educational and reference purposes. The full What's-New history (v0.7.81 and earlier) has moved to [docs/release-history.md](docs/release-history.md). -The most recent release notes for **v0.8.80** stay above under [`What's New in v0.8.80`](#whats-new-in-v0880). +The most recent release notes for **v0.8.81** stay above under [`What's New in v0.8.81`](#whats-new-in-v0881). + +### What's New in v0.8.80 + +**Minor release. Pipeline failure-rendering improvements** bundled as three additive changes targeting the Step.05 / Step.08 / Step.09 / Step.10 step summaries. (Q1) `Get-AzLocalUpdateRunFailures` attaches a `HealthCheckEvidence` array column with same-cluster Critical health-check entries within +/-2h of a HealthCheck-category run (new private helper `Get-AzLocalUpdateRunHealthEvidence`; opt-out via `-EnrichWithHealthEvidence:$false`). (Q2) `Get-AzLocalFleetHealthFailures` (Step.10) and `Test-AzLocalClusterHealth` (Step.05) project per-check `Title` and full `TargetResourceID`; `Export-AzLocalFleetHealthStatusReport` adds Title as a column and wraps TargetResourceName in a portal hyperlink. (Q3) Both deepest-error walkers (`Resolve-AzLocalUpdateRunDeepestError`, `Get-DeepestErrorMessage`) now capture step `description` alongside `errorMessage`; new `DeepestStepDescription` column on `Get-AzLocalUpdateRunFailures`; new `ErrorDescription` field on `Format-AzLocalUpdateRun` / `Get-AzLocalUpdateRuns -PassThru`; Step.08 + Step.09 renderers combine the two in markdown failure cells and JUnit bodies. No public API or export-count change (still 60). `GENERATED_AGAINST_MODULE_VERSION` bumped from `0.8.79` to `0.8.80` across all bundled pipeline templates. See [CHANGELOG.md](CHANGELOG.md#0880---2026-06-16) for the full v0.8.80 entry and [docs/release-history.md](docs/release-history.md#whats-new-in-v0880) for the archived entry. ### What's New in v0.8.78 diff --git a/AzLocal.UpdateManagement/Tests/AzLocal.UpdateManagement.Tests.ps1 b/AzLocal.UpdateManagement/Tests/AzLocal.UpdateManagement.Tests.ps1 index 689d353f..95db5eb3 100644 --- a/AzLocal.UpdateManagement/Tests/AzLocal.UpdateManagement.Tests.ps1 +++ b/AzLocal.UpdateManagement/Tests/AzLocal.UpdateManagement.Tests.ps1 @@ -34,8 +34,8 @@ Describe 'Module: AzLocal.UpdateManagement' { $script:ModuleInfo | Should -Not -BeNullOrEmpty } - It 'Should have version 0.8.80' { - $script:ModuleInfo.Version | Should -Be '0.8.80' + It 'Should have version 0.8.81' { + $script:ModuleInfo.Version | Should -Be '0.8.81' } It 'Module version constants are in sync between .psm1 and .psd1' { @@ -15757,11 +15757,12 @@ Describe 'Thin-YAML Step.9: Export-AzLocalFleetHealthStatusReport' { $summary | Should -Not -Match ']*target="_blank"' $summary | Should -Match '### Health Check Failures By Reason' $summary | Should -Match '### Detailed Results' - # KPI table values + # KPI table values - row labels are prefixed with the host-aware iconMap status + # tag (e.g. '❌ Critical' / '✅ Healthy'), so anchor on the row label + count only. $summary | Should -Match '\| \*\*Total Failing Checks\*\* \| 3 \|' - $summary | Should -Match '\| \*\*Critical\*\* \| 2 \|' - $summary | Should -Match '\| \*\*Warning\*\* \| 1 \|' - $summary | Should -Match '\| \*\*Healthy Clusters\*\* \| 1 \|' + $summary | Should -Match '\*\*Critical\*\* \| 2 \|' + $summary | Should -Match '\*\*Warning\*\* \| 1 \|' + $summary | Should -Match '\*\*Healthy Clusters\*\* \| 1 \|' # step outputs $out = Get-Content -LiteralPath $script:_s9_ghOutputFile -Raw $out | Should -Match 'total_failures=3' @@ -18669,3 +18670,249 @@ Describe 'v0.8.80 Q2: Export-AzLocalFleetHealthStatusReport renders Title + port } #endregion v0.8.80 Pipeline-failure rendering improvements (Q1 / Q2 / Q3) + +#region v0.8.81 Shared step-summary helpers + Step.10 KPI fix + +Describe 'v0.8.81: Get-AzLocalStatusIconMap private helper' { + It 'Is registered as a private module function' { + InModuleScope AzLocal.UpdateManagement { + (Get-Command Get-AzLocalStatusIconMap -ErrorAction SilentlyContinue) | Should -Not -BeNullOrEmpty + } + # NOT exported + (Get-Command Get-AzLocalStatusIconMap -ErrorAction SilentlyContinue -Module AzLocal.UpdateManagement | Where-Object { $_.CommandType -eq 'Function' -and $_.Visibility -eq 'Public' }) | Should -BeNullOrEmpty + } + + It 'Returns a hashtable for the GitHubActions host (Unicode glyphs - shortcodes only on ADO)' { + InModuleScope AzLocal.UpdateManagement { + $m = Get-AzLocalStatusIconMap -PipelineHost 'GitHub' + $m | Should -BeOfType ([hashtable]) + # GitHub Actions step-summary renders Unicode emoji natively, so the helper + # returns the glyph block - identical to the Local block. Shortcodes are an + # AzureDevOps-only branch (their wiki/markdown renderer prefers them). + $m['Success'] | Should -Not -Match ':white_check_mark:' + $m['Fail'] | Should -Not -Match ':x:' + $m['StateInProgress'] | Should -Not -Match ':large_blue_circle:' + $m['Info'] | Should -Not -Match ':information_source:' + # And confirm the actual Unicode code-points are there. + $m['Success'] | Should -Match ([string][char]0x2705) # ✅ + $m['Fail'] | Should -Match ([string][char]0x274C) # ❌ + $m['StateInProgress'] | Should -Match ([regex]::Escape([char]::ConvertFromUtf32(0x1F535))) # 🔵 + $m['GreenCircle'] | Should -Match ([regex]::Escape([char]::ConvertFromUtf32(0x1F7E2))) # 🟢 + $m['YellowCircle'] | Should -Match ([regex]::Escape([char]::ConvertFromUtf32(0x1F7E1))) # 🟡 + } + } + + It 'Returns a hashtable for the AzureDevOps host (shortcodes too - ADO renders them)' { + InModuleScope AzLocal.UpdateManagement { + $m = Get-AzLocalStatusIconMap -PipelineHost 'AzureDevOps' + $m | Should -BeOfType ([hashtable]) + # ADO branch returns Markdown shortcodes (block matches the GitHub block). + $m['Success'] | Should -Match ':white_check_mark:' + $m['StateInProgress'] | Should -Match ':large_blue_circle:' + $m['Info'] | Should -Match ':information_source:' + } + } + + It 'Returns a hashtable for the Local host (Unicode glyphs, not shortcodes)' { + InModuleScope AzLocal.UpdateManagement { + $m = Get-AzLocalStatusIconMap -PipelineHost 'Local' + $m | Should -BeOfType ([hashtable]) + # Local host emits raw Unicode - confirm the Markdown-shortcode form is NOT present. + $m['Success'] | Should -Not -Match ':white_check_mark:' + $m['Fail'] | Should -Not -Match ':x:' + $m['StateInProgress'] | Should -Not -Match ':large_blue_circle:' + $m['Info'] | Should -Not -Match ':information_source:' + # And confirm the actual Unicode code-points are there. + $m['Success'] | Should -Match ([string][char]0x2705) # check (BMP) + $m['Fail'] | Should -Match ([string][char]0x274C) # cross (BMP) + $m['StateInProgress'] | Should -Match ([regex]::Escape([char]::ConvertFromUtf32(0x1F535))) # blue circle + $m['StateNotStarted'] | Should -Match ([string][char]0x26AA) # white circle (BMP) + $m['GreenCircle'] | Should -Match ([regex]::Escape([char]::ConvertFromUtf32(0x1F7E2))) # green circle + $m['YellowCircle'] | Should -Match ([regex]::Escape([char]::ConvertFromUtf32(0x1F7E1))) # yellow circle + $m['CycleArrows'] | Should -Match ([regex]::Escape([char]::ConvertFromUtf32(0x1F504))) # cycle arrows + } + } + + It 'Both return blocks (shortcodes vs glyphs) expose the same required key set (no drift)' { + InModuleScope AzLocal.UpdateManagement { + $required = @( + 'Ready','ReadyForUpdate','UpToDate','InProgress','SbeBlocked', + 'HealthFailure','UpdateFailed','ActionRequired','NeedsInvestigation', + 'Healthy','Critical','Warning','Unknown','HealthCheckFailed', + 'SeverityCritical','SeverityWarning', + 'Success','Fail','Block','Skip','Warn', + 'StateInProgress','StateSucceeded','StateFailed','StateNotStarted','StateUnknown', + 'StatusInProgress','StatusCancelled', + 'Info','GreenCircle','YellowCircle','CycleArrows','GreyQuestion', + 'SupportSupported','SupportUnsupported','SupportUnknown' + ) + $ado = Get-AzLocalStatusIconMap -PipelineHost 'AzureDevOps' + $local = Get-AzLocalStatusIconMap -PipelineHost 'Local' + foreach ($k in $required) { + $ado.ContainsKey($k) | Should -BeTrue -Because "AzureDevOps (shortcode) map must define key '$k'" + $local.ContainsKey($k) | Should -BeTrue -Because "Local (glyph) map must define key '$k'" + } + } + } +} + +Describe 'v0.8.81: Get-AzLocalClusterPortalLink private helper' { + It 'Is registered as a private module function (not exported)' { + InModuleScope AzLocal.UpdateManagement { + (Get-Command Get-AzLocalClusterPortalLink -ErrorAction SilentlyContinue) | Should -Not -BeNullOrEmpty + } + (Get-Command Get-AzLocalClusterPortalLink -ErrorAction SilentlyContinue -Module AzLocal.UpdateManagement | Where-Object { $_.CommandType -eq 'Function' -and $_.Visibility -eq 'Public' }) | Should -BeNullOrEmpty + } + + It 'Wraps the cluster name in a portal anchor when ClusterResourceId is supplied' { + InModuleScope AzLocal.UpdateManagement { + $rid = '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/Portland' + $out = Get-AzLocalClusterPortalLink -ClusterName 'Portland' -ClusterResourceId $rid + $out | Should -Match ' calls Get-AzLocalStatusIconMap and reads from $iconMap' -ForEach $cases { + Test-Path -LiteralPath $Path | Should -BeTrue -Because "$Step source must exist at $Path" + $src = Get-Content -LiteralPath $Path -Raw + $src | Should -Match 'Get-AzLocalStatusIconMap' + $src | Should -Match '\$iconMap\b' + } +} + +Describe 'v0.8.81: Step.08 monitor uses iconMap for State and Status columns' { + BeforeAll { + $script:src08 = Get-Content -LiteralPath "$PSScriptRoot/../Public/Export-AzLocalUpdateRunMonitorReport.ps1" -Raw + } + It 'Resolves $stateIcon via $iconMap State* keys' { + $src08 | Should -Match "\`$iconMap\['StateInProgress'\]" + $src08 | Should -Match "\`$iconMap\['StateSucceeded'\]" + $src08 | Should -Match "\`$iconMap\['StateFailed'\]" + $src08 | Should -Match "\`$iconMap\['StateNotStarted'\]" + $src08 | Should -Match "\`$iconMap\['StateUnknown'\]" + } + It 'Resolves $statusIcon via $iconMap Success/Fail/StatusInProgress/Block/StatusCancelled keys' { + $src08 | Should -Match "\`$iconMap\['Success'\]" + $src08 | Should -Match "\`$iconMap\['Fail'\]" + $src08 | Should -Match "\`$iconMap\['StatusInProgress'\]" + $src08 | Should -Match "\`$iconMap\['Block'\]" + $src08 | Should -Match "\`$iconMap\['StatusCancelled'\]" + } + It 'No longer hard-codes the GitHub-Markdown shortcodes inline in the State / Current Step Status switches' { + # The shortcodes are now centralised in Get-AzLocalStatusIconMap. The renderer + # should not contain the bare ':large_blue_circle:' / ':hourglass_flowing_sand:' + # literal strings in its switch arms any more (they only live inside the helper). + $src08 | Should -Not -Match "= ':large_blue_circle:'" + $src08 | Should -Not -Match "= ':hourglass_flowing_sand:'" + } +} + +Describe 'v0.8.81: Step.09 fleet status uses iconMap for Critical Health / Primary Status / Version Distribution' { + BeforeAll { + $script:src09 = Get-Content -LiteralPath "$PSScriptRoot/../Public/Export-AzLocalFleetUpdateStatusReport.ps1" -Raw + } + It 'Resolves Critical Health Status / Primary Status icons via $iconMap (Info / GreenCircle / YellowCircle / CycleArrows / Warn / Fail)' { + $src09 | Should -Match "\`$iconMap\['Info'\]" + $src09 | Should -Match "\`$iconMap\['GreenCircle'\]" + $src09 | Should -Match "\`$iconMap\['YellowCircle'\]" + $src09 | Should -Match "\`$iconMap\['CycleArrows'\]" + $src09 | Should -Match "\`$iconMap\['Warn'\]" + $src09 | Should -Match "\`$iconMap\['Fail'\]" + } + It 'Resolves Version Distribution Support cell via $iconMap (SupportSupported / SupportUnsupported / SupportUnknown)' { + $src09 | Should -Match "\`$iconMap\['SupportSupported'\]" + $src09 | Should -Match "\`$iconMap\['SupportUnsupported'\]" + $src09 | Should -Match "\`$iconMap\['SupportUnknown'\]" + } +} + +Describe 'v0.8.81: Step.10 Fleet Health Status - KPI counting fix + Description / FailureName columns' { + BeforeAll { + $script:src10 = Get-Content -LiteralPath "$PSScriptRoot/../Public/Export-AzLocalFleetHealthStatusReport.ps1" -Raw + } + It 'Splits the KPI into Cluster Counts and Failing Checks Breakdown tables' { + $src10 | Should -Match '###\s*Cluster Counts' + $src10 | Should -Match '###\s*Failing Checks Breakdown' + } + It 'Computes Other as Total - Healthy - Unhealthy clamped non-negative (Cluster Counts sums to Total)' { + $src10 | Should -Match '\$totalInSub\s*-\s*\$healthyClusters\s*-\s*\$totalClusters' + $src10 | Should -Match 'if \(\$otherClusters\s*-lt\s*0\)\s*\{\s*\$otherClusters\s*=\s*0\s*\}' + } + It 'Emits the new other_clusters pipeline step output' { + $src10 | Should -Match "Set-AzLocalPipelineOutput\s+-Name\s+'other_clusters'" + } + It 'Exposes OtherClusters on the -PassThru PSCustomObject' { + $src10 | Should -Match 'OtherClusters\s*=\s*\[int\]\$otherClustersOut' + } + It 'Detailed Results includes a Description column (collapsible drive/volume detail)' { + $src10 | Should -Match '\|\s*Description\s*\|' + } + It 'Detailed Results preserves the raw FailureName column (for grep / triage by FaultType)' { + $src10 | Should -Match 'FailureName' + } + It 'Reads icons via $iconMap rather than inline shortcodes' { + $src10 | Should -Match '\$iconMap\b' + $src10 | Should -Match "\`$iconMap\['Warning'\]" + } +} + +#endregion v0.8.81 Shared step-summary helpers + Step.10 KPI fix + diff --git a/AzLocal.UpdateManagement/Tests/test-run-timings.csv b/AzLocal.UpdateManagement/Tests/test-run-timings.csv index 35fd150a..cb0e61fa 100644 --- a/AzLocal.UpdateManagement/Tests/test-run-timings.csv +++ b/AzLocal.UpdateManagement/Tests/test-run-timings.csv @@ -15,3 +15,6 @@ TimestampUtc,ModuleVersion,Total,Passed,Failed,Skipped,WallClockSeconds,PesterDu "2026-06-14T18:27:18Z","0.8.79","1200","1160","1","1","86.13","84.83","9.85","Invoke-Tests.ps1","5.1.26100.8655" "2026-06-14T18:29:15Z","0.8.79","1200","1161","0","1","46.34","45.08","2.96","Invoke-Tests.ps1","5.1.26100.8655" "2026-06-15T10:31:58Z","0.8.80","1220","1176","5","1","185.27","181.31","18.22","Invoke-Tests.ps1","5.1.26100.8730" +"2026-06-15T11:48:21Z","0.8.81","1250","1171","40","1","153.02","149.64","15.99","Invoke-Tests.ps1","5.1.26100.8730" +"2026-06-15T11:51:46Z","0.8.81","1250","1209","2","1","98.16","95.19","4.39","Invoke-Tests.ps1","5.1.26100.8730" +"2026-06-15T11:55:10Z","0.8.81","1250","1211","0","1","78.55","76.67","3.94","Invoke-Tests.ps1","5.1.26100.8730" diff --git a/AzLocal.UpdateManagement/docs/release-history.md b/AzLocal.UpdateManagement/docs/release-history.md index 6517b72e..f894d366 100644 --- a/AzLocal.UpdateManagement/docs/release-history.md +++ b/AzLocal.UpdateManagement/docs/release-history.md @@ -4,7 +4,21 @@ > > **For older releases**, this is the canonical reference; the main README intentionally stays slim so the most recent block is easy to find. > -> **For v0.8.80 (the current release)**, see the main [README.md](../README.md#whats-new-in-v0880) `What's New in v0.8.80` section. +> **For v0.8.81 (the current release)**, see the main [README.md](../README.md#whats-new-in-v0881) `What's New in v0.8.81` section. + +--- + +### What's New in v0.8.80 + +v0.8.80 was a minor release that bundled three additive pipeline-failure-rendering improvements targeting the Step.05 / Step.08 / Step.09 / Step.10 step summaries. All changes were backward-compatible. One new private helper (`Get-AzLocalUpdateRunHealthEvidence`). + +**Q1 - HealthCheck failure enrichment (Step.09).** When `Get-AzLocalUpdateRunFailures` categorises a failed run as `ErrorCategory='HealthCheck'`, it now attaches a `HealthCheckEvidence` array column carrying same-cluster Critical `updateSummaries.healthCheckResult` entries that fired within a +/-2h window around the run. The Step.09 markdown step summary renders this evidence as bullets inside the per-row `
` panel and the JUnit XML systemOut block lists each evidence entry's timestamp / title / severity / target resource id. New parameter `[bool]$EnrichWithHealthEvidence = $true` lets CSV-only consumers opt out. Backed by a new private helper `Get-AzLocalUpdateRunHealthEvidence` (one ARG hop per HealthCheck-category row, scoped to a single cluster id; reuses the anti-mv-expand 128-cap projection pattern). + +**Q2 - Title and TargetResourceID columns (Step.05 + Step.10).** Both `Get-AzLocalFleetHealthFailures` (Step.10) and `Test-AzLocalClusterHealth` (Step.05) project per-check `Title` (often more specific than `displayName`, e.g. names the failing node or volume) and full `TargetResourceID` (lets renderers deep-link Server/Volume health failures back to the exact ARM resource). `Export-AzLocalFleetHealthStatusReport` adds Title as a markdown column and wraps TargetResourceName in a portal hyperlink when TargetResourceID is present. `Test-AzLocalClusterHealth` adds Title to the dedup composite key, the `Format-Table` console output, and the JUnit `Message` field. + +**Q3 - Surface BOTH deepest-step `description` AND `errorMessage` (Step.08 + Step.09).** Both deepest-error walkers (`Resolve-AzLocalUpdateRunDeepestError` and `Get-DeepestErrorMessage`) now capture step `description` at the same recursion site as `errorMessage` (previously description was silently dropped via coalesce). New `DeepestStepDescription` column on `Get-AzLocalUpdateRunFailures`; new `ErrorDescription` field on `Format-AzLocalUpdateRun` / `Get-AzLocalUpdateRuns -PassThru`; renderers combine `description + errorMessage` in markdown failure cells and JUnit bodies. Cross-reference `.NOTES` headers added to the four parallel functions so future maintainers keep them in sync. + +`GENERATED_AGAINST_MODULE_VERSION` bumped from `0.8.79` to `0.8.80` across all bundled pipeline templates. No new exports (still 60). ---