From 98c3c18d9d4f993a7f0fbc9745dcc4b790c9b3c0 Mon Sep 17 00:00:00 2001 From: Neil Bird Date: Mon, 15 Jun 2026 13:20:28 +0100 Subject: [PATCH 1/2] Step.5 polish: drop duplicated Summary counts labels + sort All-clusters detail by Status priority - Summary counts: each row reused the icon-map cell (which already includes its own label) AND appended a duplicate trailing label, producing 'Ready for Update Ready for update' / 'Up to Date Up to date' / 'Action Required Not ready for update' / 'Health Failure Clusters with Critical health failures'. Fixed by emitting the icon-map cell unmodified; the HealthFailure row keeps the 'Clusters with Critical health failures' qualifier in parentheses since it counts something different from the readiness cascade. - All-clusters detail: previously sorted only by UpdateRing + ClusterName so operator-actionable rows were scattered. Now sorts by Status priority first (InProgress -> HealthFailure -> UpdateFailed -> ActionRequired -> SbeBlocked -> NeedsInvestigation -> ReadyForUpdate -> UpToDate), then UpdateRing + ClusterName as before. Up-to-Date clusters drop to the bottom; in-flight + remediation rows surface at the top. - Pester: 1211 passed, 0 failed (no behavioural test relied on the duplicated labels or the prior sort order). --- ...rt-AzLocalClusterUpdateReadinessReport.ps1 | 34 +++++++++++++++---- .../Tests/test-run-timings.csv | 1 + 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalClusterUpdateReadinessReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalClusterUpdateReadinessReport.ps1 index d1b1690f..5f76bcf3 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalClusterUpdateReadinessReport.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalClusterUpdateReadinessReport.ps1 @@ -364,17 +364,21 @@ function Export-AzLocalClusterUpdateReadinessReport { [void]$md.Add('') # 3. Summary counts - # v0.8.81: use shared icon map so the summary mirrors the table rows below. + # v0.8.82: use shared icon map cells AS the metric label (each icon-map + # value already includes its own text, e.g. " Ready for Update"). + # Previously each row also appended a duplicate trailing label, producing + # " Ready for Update Ready for update" - fixed by emitting the + # icon-map cell unmodified. $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(("| {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(("| {0} | {1} |" -f $iconMap['ReadyForUpdate'], $readyForUpdate)) + [void]$md.Add(("| {0} | {1} |" -f $iconMap['UpToDate'], $upToDate)) + [void]$md.Add(("| {0} | {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('') @@ -450,7 +454,25 @@ function Export-AzLocalClusterUpdateReadinessReport { [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)) { + # v0.8.82: sort by Status priority (operator-actionable items first): + # InProgress -> HealthFailure -> UpdateFailed -> ActionRequired -> + # SbeBlocked -> NeedsInvestigation -> ReadyForUpdate -> UpToDate. + # Within the same Status, sort by UpdateRing then ClusterName. + $statusOrder = @{ + 'InProgress' = 1 + 'HealthFailure' = 2 + 'UpdateFailed' = 3 + 'ActionRequired' = 4 + 'SbeBlocked' = 5 + 'NeedsInvestigation' = 6 + 'ReadyForUpdate' = 7 + 'UpToDate' = 8 + } + $sorted = $readiness | Sort-Object ` + @{Expression={ $k = Get-AzLocalClusterReadinessStatus -ReadinessRow $_; if ($statusOrder.ContainsKey($k)) { $statusOrder[$k] } else { 99 } }}, ` + @{Expression={ if ($ringByResourceId.ContainsKey($_.ClusterResourceId)) { $ringByResourceId[$_.ClusterResourceId] } else { 'zzz' } }}, ` + ClusterName + foreach ($r in $sorted) { $ring = if ($ringByResourceId.ContainsKey($r.ClusterResourceId)) { $ringByResourceId[$r.ClusterResourceId] } else { '-' } $cv = if ($r.CurrentVersion) { $r.CurrentVersion } else { '-' } $csv = if ($r.PSObject.Properties['CurrentSbeVersion'] -and $r.CurrentSbeVersion) { $r.CurrentSbeVersion } else { '-' } diff --git a/AzLocal.UpdateManagement/Tests/test-run-timings.csv b/AzLocal.UpdateManagement/Tests/test-run-timings.csv index cb0e61fa..f1d68127 100644 --- a/AzLocal.UpdateManagement/Tests/test-run-timings.csv +++ b/AzLocal.UpdateManagement/Tests/test-run-timings.csv @@ -18,3 +18,4 @@ TimestampUtc,ModuleVersion,Total,Passed,Failed,Skipped,WallClockSeconds,PesterDu "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" +"2026-06-15T12:20:08Z","0.8.81","1250","1211","0","1","92.49","89.64","3.84","Invoke-Tests.ps1","5.1.26100.8730" From 683d65a2b0179e23535037c38cbea6766eb4e970 Mon Sep 17 00:00:00 2001 From: Neil Bird Date: Mon, 15 Jun 2026 14:43:02 +0100 Subject: [PATCH 2/2] v0.8.82: Item 5 - UpdateLastAttempt audit tag + Step.05 Last Updated column + Step.08 reconciliation Three Item-5 deliverables shipped together, plus the residual v0.8.81 review polish. Step.05 (Get-AzLocalClusterUpdateReadiness + Export-AzLocalClusterUpdateReadinessReport): - New LastUpdated property on every output row, computed from max packageVersions[].lastUpdated across all packageTypes (Solution / Services / Platform / SBE). - New 'Last Updated' column in the detail table between 'Status' and 'Recommended update'. - Detail-table sort changed to UpdateRing -> Status priority -> ClusterName so ring cohorts stay grouped with in-flight/remediation rows at the top of each cohort. UpdateLastAttempt cluster tag (new, owned by AzLocal.UpdateManagement): - Format: ;;; truncated to the 256-char Azure tag-value limit (reason field truncated first with a trailing ~ sentinel). - Stamped by Start-AzLocalClusterUpdate at every attempt outcome (HealthCheckBlocked, UpdateStarted, Failed). Writes swallow ARM errors and log Warning - the audit tag MUST NOT block the apply flow. - Auto-cleared by Invoke-AzLocalSideloadedAutoResetForCluster (independent block, separate from sideloaded reset logic) when the latest update run is Succeeded AND either the UpdateName matches the recorded attempt OR the attempt was a HealthCheckBlocked/Failed outcome more than 1h old. - New Private helpers: Format-AzLocalUpdateLastAttemptTagValue, ConvertFrom-AzLocalUpdateLastAttemptTagValue, Write-AzLocalUpdateLastAttemptTag. New script-scoped constant $script:UpdateLastAttemptTagName = 'UpdateLastAttempt'. Step.08 (Export-AzLocalUpdateRunMonitorReport): - New 'Recent update attempts with no observable updateRun' section. Joins the per-cluster UpdateLastAttempt tag against the observed updateRun list and surfaces any attempt within the last -RecentAttemptWindowHours (default 72) whose matching updateRun is missing or has a StartTimeUtc before (attempt-5min). - Captures Portland-style URP package-internal pre-install health-check failures (cluster activity log shows 'Allows to apply updates: Succeeded' but no updateRun resource is ever persisted - confirmed via ARG on 2026-06-15: Portland has 10 updateRuns across 5 versions, none for Solution12.2605.1003.210 which is in its UpdateVersionInProgress tag). - by-update-ring scope path now ALWAYS calls Get-AzLocalClusterInventory (with -ScopeByUpdateRingTag) so per-cluster tags are available for the reconciliation pass. The all-clusters path already had inventory. - New pipeline output: attempts_without_run. - New PassThru fields: AttemptWithoutRunCount + AttemptGaps (uses .ToArray() not @(...) - empty Generic.List[object] wrap throws ArgumentException in PS 5.1). - New parameter: [ValidateRange(0,8760)][int] = 72. - Each gap also emits a JUnit testcase with Type='AttemptWithoutRun', ClassName='UpdateMonitor'. Tests: - Pester suite expanded: 1248 tests, 0 failures. New Describe blocks cover Format/Convert tag helpers (round-trip, truncation, semicolon stripping, whitespace collapse, malformed-input -> ), the script-scoped tag-name constant, Get-AzLocalClusterUpdateReadiness LastUpdated presence on all shapes (success / NotFound / Error), Step.05 column + sort, Start-AzLocalClusterUpdate three call sites, Invoke-AzLocalSideloadedAutoResetForCluster clear logic, and Step.08 reconciliation (param, builder, 5-min slack, by-update-ring inventory, pipeline output, markdown section, JUnit Type, PassThru shape, empty-fleet PassThru). - Pre-existing Scope=by-update-ring test updated: now asserts Get-AzLocalClusterInventory IS called with -ScopeByUpdateRingTag (was asserting it must NOT be called - intentional behavior change for the reconciliation pass). CHANGELOG + docs/release-history.md + README.md updated. Pipeline YAML GENERATED_AGAINST_MODULE_VERSION pins bumped to '0.8.82' across all 22 ADO + GHA workflow templates. Module export count unchanged (60). Resolves: ship Item-5 backlog from /memories/repo/AzLocal.UpdateManagement-post-v0.7.76-backlog.md. --- .../apply-updates-schedule-audit.yml | 2 +- .../azure-devops/apply-updates.yml | 2 +- .../azure-devops/assess-update-readiness.yml | 2 +- .../azure-devops/authentication-test.yml | 2 +- .../fleet-connectivity-status.yml | 2 +- .../azure-devops/fleet-health-status.yml | 2 +- .../azure-devops/fleet-update-status.yml | 2 +- .../azure-devops/inventory-clusters.yml | 2 +- .../azure-devops/manage-updatering-tags.yml | 2 +- .../azure-devops/monitor-updates.yml | 2 +- .../azure-devops/sideload-updates.yml | 2 +- .../apply-updates-schedule-audit.yml | 2 +- .../github-actions/apply-updates.yml | 2 +- .../assess-update-readiness.yml | 2 +- .../github-actions/authentication-test.yml | 2 +- .../fleet-connectivity-status.yml | 2 +- .../github-actions/fleet-health-status.yml | 2 +- .../github-actions/fleet-update-status.yml | 2 +- .../github-actions/inventory-clusters.yml | 2 +- .../github-actions/manage-updatering-tags.yml | 2 +- .../github-actions/monitor-updates.yml | 2 +- .../github-actions/sideload-updates.yml | 2 +- .../AzLocal.UpdateManagement.psd1 | 17 +- .../AzLocal.UpdateManagement.psm1 | 15 +- AzLocal.UpdateManagement/CHANGELOG.md | 113 ++++++++ ...tFrom-AzLocalUpdateLastAttemptTagValue.ps1 | 40 +++ ...ormat-AzLocalUpdateLastAttemptTagValue.ps1 | 53 ++++ ...e-AzLocalSideloadedAutoResetForCluster.ps1 | 34 +++ .../Write-AzLocalUpdateLastAttemptTag.ps1 | 62 ++++ ...rt-AzLocalClusterUpdateReadinessReport.ps1 | 51 +++- .../Export-AzLocalFleetHealthStatusReport.ps1 | 5 +- .../Export-AzLocalUpdateRunMonitorReport.ps1 | 100 +++++++ .../Get-AzLocalClusterUpdateReadiness.ps1 | 22 +- .../Public/Start-AzLocalClusterUpdate.ps1 | 33 +++ AzLocal.UpdateManagement/README.md | 53 ++-- .../Tests/AzLocal.UpdateManagement.Tests.ps1 | 264 +++++++++++++++++- .../Tests/test-run-timings.csv | 8 + .../docs/release-history.md | 30 +- 38 files changed, 863 insertions(+), 81 deletions(-) create mode 100644 AzLocal.UpdateManagement/Private/ConvertFrom-AzLocalUpdateLastAttemptTagValue.ps1 create mode 100644 AzLocal.UpdateManagement/Private/Format-AzLocalUpdateLastAttemptTagValue.ps1 create mode 100644 AzLocal.UpdateManagement/Private/Write-AzLocalUpdateLastAttemptTag.ps1 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 b606c36f..df65b935 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.81' + GENERATED_AGAINST_MODULE_VERSION: '0.8.82' 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 7e22993c..2f7e4767 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.81' + value: '0.8.82' # 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 a829f8a2..ba872542 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.81' + GENERATED_AGAINST_MODULE_VERSION: '0.8.82' # 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 0726ccf2..f1d3f49f 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.81' + value: '0.8.82' # 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 7221818b..e704ec57 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.81' + GENERATED_AGAINST_MODULE_VERSION: '0.8.82' # 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 24ace5b3..18d3d941 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.81' + GENERATED_AGAINST_MODULE_VERSION: '0.8.82' # 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 15293fae..56eecbbf 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.81' + GENERATED_AGAINST_MODULE_VERSION: '0.8.82' # 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 184f6718..f6a14518 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.81' + value: '0.8.82' # 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 f5c4a4cf..36c6cf6a 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.81' + value: '0.8.82' # 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 c51229aa..1afbd133 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.81' + GENERATED_AGAINST_MODULE_VERSION: '0.8.82' 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 3921a7e5..50757f39 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.81' + GENERATED_AGAINST_MODULE_VERSION: '0.8.82' # 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/github-actions/apply-updates-schedule-audit.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/apply-updates-schedule-audit.yml index 0fdeb4a8..0a4dec12 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.81' + GENERATED_AGAINST_MODULE_VERSION: '0.8.82' 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 41e3c609..70ee333a 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.81' + GENERATED_AGAINST_MODULE_VERSION: '0.8.82' # 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 8f336524..e4c5c94e 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.81' + GENERATED_AGAINST_MODULE_VERSION: '0.8.82' # 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 856536d3..03d5f4d9 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.81' + GENERATED_AGAINST_MODULE_VERSION: '0.8.82' # 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 ca67af27..3cbfae1a 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.81' + GENERATED_AGAINST_MODULE_VERSION: '0.8.82' # 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 c134a10c..0603bbb0 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.81' + GENERATED_AGAINST_MODULE_VERSION: '0.8.82' # 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 91ada0eb..461004f2 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.81' + GENERATED_AGAINST_MODULE_VERSION: '0.8.82' # 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 f0852473..a3d472dd 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.81' + GENERATED_AGAINST_MODULE_VERSION: '0.8.82' # 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 2c7382d4..381cb051 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.81' + GENERATED_AGAINST_MODULE_VERSION: '0.8.82' # 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 f3f6dc40..f6ebbfee 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.81' + GENERATED_AGAINST_MODULE_VERSION: '0.8.82' # 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 b118ece4..238cae2a 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.81' + GENERATED_AGAINST_MODULE_VERSION: '0.8.82' # 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 d47208a5..32f05894 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.81' + ModuleVersion = '0.8.82' # Supported PSEditions CompatiblePSEditions = @('Desktop', 'Core') @@ -35,6 +35,7 @@ 'Private/ConvertFrom-AzLocalScheduleYaml.ps1', 'Private/ConvertFrom-AzLocalUpdateExcluded.ps1', 'Private/ConvertFrom-AzLocalUpdateSideloaded.ps1', + 'Private/ConvertFrom-AzLocalUpdateLastAttemptTagValue.ps1', 'Private/ConvertFrom-AzLocalUpdateWindow.ps1', 'Private/Convert-AzLocalScheduleSchemaVersion.ps1', 'Private/ConvertTo-AzLocalAdditionalProperties.ps1', @@ -46,6 +47,7 @@ 'Private/Format-AzLocalDurationHuman.ps1', 'Private/Format-AzLocalIncidentBody.ps1', 'Private/Format-AzLocalUpdateRun.ps1', + 'Private/Format-AzLocalUpdateLastAttemptTagValue.ps1', 'Private/Get-AzLocalClusterReadinessStatus.ps1', 'Private/Get-AzLocalClusterUpdateRuns.ps1', 'Private/Get-AzLocalItsmDedupeKey.ps1', @@ -84,6 +86,7 @@ 'Private/Resolve-WildcardDate.ps1', 'Private/Resolve-WildcardDateRange.ps1', 'Private/Set-AzLocalClusterTagsMerge.ps1', + 'Private/Write-AzLocalUpdateLastAttemptTag.ps1', # On-prem solution-update sideloading automation (v0.8.7) 'Private/Get-AzLocalSideloadAuthMap.ps1', 'Private/Get-AzLocalSideloadCatalog.ps1', @@ -314,6 +317,8 @@ # ReleaseNotes of this module ReleaseNotes = @' +## Version 0.8.82 - Patch: four Step-summary UX polish fixes from v0.8.81 manual pipeline-run review. (1) Step.05 Summary counts table no longer duplicates labels: each row reused the shared `Get-AzLocalStatusIconMap` cell (which already includes its own label) AND appended a duplicate trailing label, producing `Ready for Update Ready for update` / `Up to Date Up to date` / `Action Required Not ready for update` / `Health Failure Clusters with Critical health failures`. Fixed by emitting the icon-map cell unmodified; the HealthFailure row keeps `(Clusters with Critical health failures)` in parentheses since it counts something different from the readiness cascade. (2) Step.05 All clusters detail table now sorts by Status priority first (`InProgress` -> `HealthFailure` -> `UpdateFailed` -> `ActionRequired` -> `SbeBlocked` -> `NeedsInvestigation` -> `ReadyForUpdate` -> `UpToDate`), then UpdateRing + ClusterName. In-flight + remediation rows surface at the top; Up-to-Date drops to the bottom. (3) Step.05 Not-Ready clusters (review first) table no longer leaves the `Blocking reasons` column as `-` for rows blocked by `UpdateFailed` / `NeedsAttention` / `InProgress` / Warning-only HealthFailure / SbeBlocked - the renderer now derives an actionable token from the Status bucket when the upstream `BlockingReasons` is empty (e.g. `UpdateInProgress (run in-flight)`, `UpdateState=NeedsAttention`, `PrerequisiteRequired (SBE update first)`, `HealthState=Failure (no Critical findings; review Warning findings)`), with `; HealthState=Warning` appended where relevant. (4) Step.10 Detailed Results Description column inline-vs-collapse threshold bumped from 120 to 280 characters, so short single-sentence descriptions render inline and only long multi-line descriptions collapse behind `
view...
`. The previous 120-char cutoff put roughly half the rows inline and half collapsed on the same table, which looked broken. No public API or export-count change (still 60). All bundled pipeline templates bump `GENERATED_AGAINST_MODULE_VERSION` from `'0.8.81'` to `'0.8.82'`. + ## 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. @@ -328,16 +333,6 @@ ## Version 0.8.78 - Patch: pipeline-summary UX polish. (1) JUnit re-classification - `ScheduleBlocked` / `SideloadedBlocked` / `ExcludedByTag` are designed gate-respect outcomes, now render as `` so `dorny/test-reporter` no longer flips Step.07 RED (`HealthCheckBlocked` stays ``). (2) `Add-AzLocalApplyUpdatesStepSummary` gains optional `-UpToDateCount` / `-NotReadyCount` for full ring breakdown in the Readiness KPI table (both `apply-updates.yml` templates wire the upstream `readiness.UpToDateCount` / `readiness.NotReadyCount` outputs through). (3) `actions/download-artifact@v6` -> `@v7` in `apply-updates.yml` (GHA) silences the Node.js 20 deprecation warning. No public API or export-count change (still 60). All bundled pipeline templates bump `GENERATED_AGAINST_MODULE_VERSION` from `'0.8.77'` to `'0.8.78'`. See CHANGELOG.md / docs/release-history.md for the full v0.8.78 entry. -## Version 0.8.77 - Patch: fixes two production strict-mode crashes that surfaced in Step.05 / Step.06 / Step.07 of the bundled apply-updates pipelines. Both bugs share the same root cause: bare `$obj.Prop` property access under `Set-StrictMode -Version Latest` THROWS when `Prop` is absent on a PSCustomObject instead of returning `$null`. Fixes use `PSObject.Properties['Prop'] -and $obj.Prop` guards (and `IDictionary.Contains` for tag bags returned by `Invoke-AzRestJson`) in `Start-AzLocalClusterUpdate` (UpdateStartWindow / UpdateExclusionsWindow tag lookup) and in `Test-AzLocalClusterHealth` / `Get-HealthCheckFailureSummary` / `Get-AzLocalFleetStatusData` (`properties.healthCheckResult` access). No public API or export-count change (still 60). All bundled pipeline templates bump `GENERATED_AGAINST_MODULE_VERSION` from `'0.8.76'` to `'0.8.77'`. See CHANGELOG.md / docs/release-history.md for the full v0.8.77 entry. - -## Version 0.8.76 - Patch: adds a Microsoft-hosted Windows preflight job (GitHub Actions) / preflight stage (Azure DevOps) in front of the opt-in Step.6 `sideload-updates.yml` pipeline so triggering it without first completing the opt-in setup produces an actionable panel rather than a silent `Status: Skipped`. Also broadens the master gate to accept `'true'` / `'True'` / `'TRUE'` / `'1'`. See CHANGELOG for the full v0.8.76 entry. - -## Version 0.8.75 - Patch: removes the duplicate Cycle calendar from the Step.3 apply-updates schedule audit step summary on Recommend-view runs. New additive `-OmitCycleCalendar` switch; drift-notice banner now recommends `Update-AzLocalPipelineExample`. No public API or export-count change (still 60). See CHANGELOG.md for the full v0.8.75 entry. - -## Version 0.8.74 - Consistent "Up to Date" classification across Step.5 / Step.7 / Step.9 readiness reports; deep-tree fix for the `Progress` column in the Step.7 monitor and standalone HTML "Recent Update Run History" report; new shared `Get-AzLocalClusterReadinessStatus` private helper as single source of truth for the readiness cascade. Step.7 / Step.5 add a readable `Status` column and Step.7 adds a `UP_TO_DATE_COUNT` step output. No public API or export-count change (still 60). See CHANGELOG.md / docs/release-history.md for the full per-bullet detail. - -## 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. - 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 e6e2ec1b..fa0ce749 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.81' +$script:ModuleVersion = '0.8.82' $script:DefaultApiVersion = '2025-10-01' $script:DefaultLogFolder = Join-Path -Path $env:ProgramData -ChildPath 'AzLocal.UpdateManagement' @@ -218,6 +218,19 @@ $script:UpdateSideloadedTagName = 'UpdateSideloaded' $script:UpdateVersionInProgressTagName = 'UpdateVersionInProgress' +# v0.8.82: per-cluster "we attempted an apply at this UTC time, here is the +# outcome our module observed" audit tag. Written by Start-AzLocalClusterUpdate +# at EVERY attempt outcome (including pre-update HealthCheckBlocked, +# Failed-to-start from ARM, and successful Applied). Cleared by the same +# auto-reset pathway as UpdateVersionInProgress once the corresponding +# updateRun has reached a terminal Succeeded state. Used by Step.08 to +# surface gaps where an attempt was made but no updateRun ever materialised +# - the audit-log-only outcome that hides Portland-style URP package +# pre-install health-check failures from the standard "Failed runs" table. +# Format: ';;;' +# Truncated to 256 chars (Azure tag value limit). +$script:UpdateLastAttemptTagName = 'UpdateLastAttempt' + # v0.8.7: numeric account id (1-3 digits, e.g. 001) that maps a cluster to a # row in the sideload auth-map CSV (Key Vault + remoting settings) used by the # on-prem solution-update sideloading automation. Written/exported by Step.1 diff --git a/AzLocal.UpdateManagement/CHANGELOG.md b/AzLocal.UpdateManagement/CHANGELOG.md index b2213cbf..76904d67 100644 --- a/AzLocal.UpdateManagement/CHANGELOG.md +++ b/AzLocal.UpdateManagement/CHANGELOG.md @@ -5,6 +5,119 @@ 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.82] - 2026-06-15 + +Patch release. Step.05 + Step.10 step-summary UX polish from the v0.8.81 manual +pipeline-run review **plus three Item-5 deliverables**: (1) Step.05 "Last Updated" +column + UpdateRing-first sort, (2) per-cluster `UpdateLastAttempt` audit tag +written at every `Start-AzLocalClusterUpdate` outcome (and auto-cleared by the +existing sideloaded-reset path when a matching Succeeded run is observed), and +(3) a Step.08 reconciliation section that surfaces clusters where the +`UpdateLastAttempt` tag indicates an apply attempt with no observable updateRun +(captures Portland-style URP package-internal pre-install health-check failures). +No public API change. Export count unchanged (still 60). + +### Added + +- **`UpdateLastAttempt` cluster tag** (new). `Start-AzLocalClusterUpdate` now + stamps each cluster with an ISO-8601 timestamp + outcome + update name + + short reason at every attempt outcome: `HealthCheckBlocked`, `UpdateStarted`, + and `Failed`. Format: `;;;` truncated to + the 256-char Azure tag-value limit (reason field truncated first with a + trailing `~` sentinel; embedded `;` in reason is replaced with `,`). Writes + swallow ARM errors and log a Warning - the audit tag MUST NOT block the + apply flow. New Private helpers: `Format-AzLocalUpdateLastAttemptTagValue`, + `ConvertFrom-AzLocalUpdateLastAttemptTagValue`, + `Write-AzLocalUpdateLastAttemptTag`. New script-scoped constant + `$script:UpdateLastAttemptTagName = 'UpdateLastAttempt'`. +- **Auto-clear of `UpdateLastAttempt`** in + `Invoke-AzLocalSideloadedAutoResetForCluster`. The independent clear + block (separate from the existing sideloaded reset logic) clears the tag + when the latest update run is `Succeeded` AND either the UpdateName + matches (case-insensitive) the recorded attempt OR the attempt was a + `HealthCheckBlocked` / `Failed` outcome more than 1h old. Wrapped in + try/catch with Verbose-only logging - never modifies the function's + return shape. +- **Step.05 "Last Updated" column** in `Export-AzLocalClusterUpdateReadinessReport`. + Sourced from `Get-AzLocalClusterUpdateReadiness`, which now computes the + most-recent `packageVersions[].lastUpdated` timestamp across ALL + packageTypes (Solution / Services / Platform / SBE / etc.) and exposes it + as a new `LastUpdated` property on every output row (success, NotFound, and + Error rows). The detail table grows a new "Last Updated" cell between + "Status" and "Recommended update". +- **Step.05 sort change**: detail table now sorts by **UpdateRing -> Status + priority -> ClusterName** (was Status -> UpdateRing -> ClusterName). Ring + cohorts (Validation -> Canary -> Pilot -> Production / Default) stay + grouped, with In-Flight + remediation rows surfacing at the top of each + ring cohort. The Status-priority ordering inside each ring matches the + v0.8.82 fix above (InProgress -> HealthFailure -> UpdateFailed -> ...). +- **Step.08 "Recent update attempts with no observable updateRun" section** + in `Export-AzLocalUpdateRunMonitorReport`. Joins the per-cluster + `UpdateLastAttempt` tag against the observed updateRun list and surfaces + any attempt within the last `-RecentAttemptWindowHours` (default 72) + that does not match an updateRun whose `StartTimeUtc` is at-or-after + the attempt timestamp (-5 min slack). Each gap emits a JUnit testcase + with `Type='AttemptWithoutRun'`. New pipeline output + `attempts_without_run` and new `PassThru` fields + `AttemptWithoutRunCount` + `AttemptGaps`. Common cause for a gap: URP + package-internal pre-install health-check failure (audit log shows + `Allows to apply updates: Succeeded` but no updateRun resource is ever + persisted). New parameter: + `[ValidateRange(0,8760)][int]$RecentAttemptWindowHours = 72`. + +### Fixed + +- **Step.05 Summary counts table** (`Export-AzLocalClusterUpdateReadinessReport`) + no longer duplicates row labels. Each row reused the shared + `Get-AzLocalStatusIconMap` cell (which already includes its own label) AND + appended a duplicate trailing label, producing `Ready for Update Ready for + update` / `Up to Date Up to date` / `Action Required Not ready for update` / + `Health Failure Clusters with Critical health failures`. The icon-map cell + is now emitted unmodified. The HealthFailure row keeps `(Clusters with + Critical health failures)` in parentheses since it counts something + different from the readiness cascade. +- **Step.05 All clusters detail table** now sorts by **Status priority** first + (`InProgress` -> `HealthFailure` -> `UpdateFailed` -> `ActionRequired` -> + `SbeBlocked` -> `NeedsInvestigation` -> `ReadyForUpdate` -> `UpToDate`), + then `UpdateRing` + `ClusterName` as before. In-flight + remediation rows + surface at the top; Up-to-Date drops to the bottom so operators see + actionable items first. +- **Step.05 Not-Ready clusters (review first) table - Blocking reasons column** + no longer shows `-` for rows blocked by `UpdateFailed` / `NeedsAttention` / + `InProgress` / Warning-only `HealthFailure` / `SbeBlocked`. The upstream + `Get-AzLocalClusterUpdateReadiness` only populates `BlockingReasons` for + `CriticalHealthCheck` findings and abnormal cluster connectivity states + (e.g. `NotConnectedRecently`). For every other Not-Ready category the + column was left empty and rendered as `-`, forcing operators to cross-read + the `Update state` + `Health` columns to infer the reason. The renderer + now derives an actionable token from the Status bucket when the upstream + `BlockingReasons` is empty: + - `InProgress` -> `UpdateInProgress (run in-flight)` + - `UpdateFailed` -> `UpdateState=` (e.g. `UpdateState=NeedsAttention`, `UpdateState=UpdateFailed`) + - `ActionRequired` -> `UpdateState=PreparationFailed` + - `HealthFailure` -> `HealthState=Failure (no Critical findings; review Warning findings)` + - `SbeBlocked` -> `PrerequisiteRequired (SBE update first)` + - `NeedsInvestigation` -> `NeedsInvestigation (no Update or Health signal)` + + For any of the above, `; HealthState=Warning` is appended when the + cluster's `HealthState` is `Warning` and the Status bucket is not already + `HealthFailure`. The upstream `Get-AzLocalClusterUpdateReadiness` output + shape is unchanged; the derivation is renderer-side only so JSON / CSV / + `-PassThru` consumers continue to receive the original empty + `BlockingReasons` value for these rows. +- **Step.10 Detailed Results Description column** inline-vs-collapse threshold + bumped from 120 to **280 characters** in + `Export-AzLocalFleetHealthStatusReport`. Short single-sentence descriptions + render inline and only long multi-line descriptions collapse behind + `
view...
`. The previous 120-char + cutoff put roughly half the rows inline and half collapsed on the same + table, which looked broken. + +### Changed + +- `GENERATED_AGAINST_MODULE_VERSION` bumped from `'0.8.81'` to `'0.8.82'` + across all bundled GitHub Actions and Azure DevOps pipeline templates. + ## [0.8.81] - 2026-06-17 Patch release. Step summary polish across Steps 05-10: fixes a Step.10 KPI counting diff --git a/AzLocal.UpdateManagement/Private/ConvertFrom-AzLocalUpdateLastAttemptTagValue.ps1 b/AzLocal.UpdateManagement/Private/ConvertFrom-AzLocalUpdateLastAttemptTagValue.ps1 new file mode 100644 index 00000000..d051e2a5 --- /dev/null +++ b/AzLocal.UpdateManagement/Private/ConvertFrom-AzLocalUpdateLastAttemptTagValue.ps1 @@ -0,0 +1,40 @@ +function ConvertFrom-AzLocalUpdateLastAttemptTagValue { + <# + .SYNOPSIS + Parse the value written by Format-AzLocalUpdateLastAttemptTagValue. + .DESCRIPTION + Returns a PSCustomObject with AttemptUtc (DateTime), Outcome (string), + UpdateName (string), Reason (string). Returns $null when the value is + empty / cannot be parsed. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $false)] + [AllowNull()] + [AllowEmptyString()] + [string]$Value + ) + + if ([string]::IsNullOrWhiteSpace($Value)) { return $null } + + $parts = $Value -split ';', 4 + if ($parts.Count -lt 2) { return $null } + + [datetime]$ts = [datetime]::MinValue + if (-not [datetime]::TryParse($parts[0], [System.Globalization.CultureInfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::RoundtripKind, [ref]$ts)) { + return $null + } + + $outcome = $parts[1] + $updateName = if ($parts.Count -ge 3) { $parts[2] } else { '' } + $reason = if ($parts.Count -ge 4) { $parts[3] } else { '' } + + return [PSCustomObject]@{ + AttemptUtc = $ts.ToUniversalTime() + Outcome = [string]$outcome + UpdateName = [string]$updateName + Reason = [string]$reason + } +} diff --git a/AzLocal.UpdateManagement/Private/Format-AzLocalUpdateLastAttemptTagValue.ps1 b/AzLocal.UpdateManagement/Private/Format-AzLocalUpdateLastAttemptTagValue.ps1 new file mode 100644 index 00000000..47d96f16 --- /dev/null +++ b/AzLocal.UpdateManagement/Private/Format-AzLocalUpdateLastAttemptTagValue.ps1 @@ -0,0 +1,53 @@ +function Format-AzLocalUpdateLastAttemptTagValue { + <# + .SYNOPSIS + Build the value string written to the UpdateLastAttempt cluster tag. + .DESCRIPTION + Format is intentionally simple, semicolon-delimited, parseable by + ConvertFrom-AzLocalUpdateLastAttemptTagValue: + + ;;; + + Truncated to 256 chars (Azure tag value upper limit). Reason is + truncated first when the total length would exceed 256. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [datetime]$AttemptUtc, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Outcome, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [AllowEmptyString()] + [string]$UpdateName, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [AllowEmptyString()] + [string]$Reason + ) + + $ts = $AttemptUtc.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $u = if ([string]::IsNullOrWhiteSpace($UpdateName)) { '' } else { $UpdateName.Trim() } + $r = if ([string]::IsNullOrWhiteSpace($Reason)) { '' } else { + # collapse whitespace + strip semicolons so the field stays a single token + ($Reason -replace ';', ',' -replace '\s+', ' ').Trim() + } + + $head = "$ts;$Outcome;$u;" + $maxReasonLen = 256 - $head.Length + if ($maxReasonLen -lt 0) { $maxReasonLen = 0 } + if ($r.Length -gt $maxReasonLen) { + if ($maxReasonLen -gt 1) { + $r = $r.Substring(0, $maxReasonLen - 1) + '~' + } else { + $r = '' + } + } + return ($head + $r) +} diff --git a/AzLocal.UpdateManagement/Private/Invoke-AzLocalSideloadedAutoResetForCluster.ps1 b/AzLocal.UpdateManagement/Private/Invoke-AzLocalSideloadedAutoResetForCluster.ps1 index 5f8b0a2b..3c413e0c 100644 --- a/AzLocal.UpdateManagement/Private/Invoke-AzLocalSideloadedAutoResetForCluster.ps1 +++ b/AzLocal.UpdateManagement/Private/Invoke-AzLocalSideloadedAutoResetForCluster.ps1 @@ -98,6 +98,40 @@ function Invoke-AzLocalSideloadedAutoResetForCluster { $result.PreviousSideloaded = $tagSideloaded $result.StagedVersion = $tagVersion + # v0.8.82: independent auto-clear for the UpdateLastAttempt audit tag. + # Runs regardless of UpdateSideloaded so it works on every fleet cluster. + # Clear when EITHER (a) latest Succeeded run's UpdateName matches the tag's + # recorded UpdateName, OR (b) the tag is a stale Blocked/Failed attempt + # (>1h old) and a Succeeded run has subsequently materialised. + $tagLastAttempt = Get-TagValue -Tags $cluster.tags -Name $script:UpdateLastAttemptTagName + if (-not [string]::IsNullOrWhiteSpace($tagLastAttempt) -and $LatestRunState -eq 'Succeeded') { + $parsedAttempt = ConvertFrom-AzLocalUpdateLastAttemptTagValue -Value $tagLastAttempt + if ($parsedAttempt) { + $shouldClear = $false + if (-not [string]::IsNullOrWhiteSpace($parsedAttempt.UpdateName) ` + -and -not [string]::IsNullOrWhiteSpace($LatestRunUpdateName) ` + -and $parsedAttempt.UpdateName.Trim() -ieq $LatestRunUpdateName.Trim()) { + $shouldClear = $true + } + elseif ($parsedAttempt.Outcome -in @('HealthCheckBlocked','Failed') ` + -and ((Get-Date).ToUniversalTime() - $parsedAttempt.AttemptUtc).TotalHours -ge 1) { + $shouldClear = $true + } + if ($shouldClear) { + try { + [void](Set-AzLocalClusterTagsMerge ` + -ClusterResourceId $ClusterResourceId ` + -Tags @{ $script:UpdateLastAttemptTagName = $null } ` + -ApiVersion $ApiVersion) + Write-Verbose "Cleared stale UpdateLastAttempt='$tagLastAttempt' for '$ClusterName' (latest run '$LatestRunUpdateName' Succeeded)." + } + catch { + Write-Verbose "Failed to clear UpdateLastAttempt for '$ClusterName': $($_.Exception.Message)" + } + } + } + } + # 1. UpdateSideloaded tag absent if ([string]::IsNullOrWhiteSpace($tagSideloaded)) { # Orphan-cleanup branch: if there's a leftover UpdateVersionInProgress tag diff --git a/AzLocal.UpdateManagement/Private/Write-AzLocalUpdateLastAttemptTag.ps1 b/AzLocal.UpdateManagement/Private/Write-AzLocalUpdateLastAttemptTag.ps1 new file mode 100644 index 00000000..b24ddbf9 --- /dev/null +++ b/AzLocal.UpdateManagement/Private/Write-AzLocalUpdateLastAttemptTag.ps1 @@ -0,0 +1,62 @@ +function Write-AzLocalUpdateLastAttemptTag { + <# + .SYNOPSIS + Best-effort writer for the UpdateLastAttempt cluster tag. + .DESCRIPTION + Wraps Set-AzLocalClusterTagsMerge + Format-AzLocalUpdateLastAttemptTagValue + so callers in Start-AzLocalClusterUpdate keep a single one-liner per + outcome path. Failures are logged at Warning level and SWALLOWED + (this is an informational/audit tag - never block the calling flow). + + Tag format documented on $script:UpdateLastAttemptTagName. + #> + [CmdletBinding()] + [OutputType([void])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$ClusterResourceId, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$ClusterName, + + [Parameter(Mandatory = $true)] + [datetime]$AttemptUtc, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Outcome, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [AllowEmptyString()] + [string]$UpdateName, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [AllowEmptyString()] + [string]$Reason, + + [Parameter(Mandatory = $false)] + [string]$ApiVersion = $script:DefaultApiVersion + ) + + try { + $value = Format-AzLocalUpdateLastAttemptTagValue ` + -AttemptUtc $AttemptUtc ` + -Outcome $Outcome ` + -UpdateName $UpdateName ` + -Reason $Reason + + [void](Set-AzLocalClusterTagsMerge ` + -ClusterResourceId $ClusterResourceId ` + -Tags @{ $script:UpdateLastAttemptTagName = $value } ` + -ApiVersion $ApiVersion) + + Write-Log -Message "Set $($script:UpdateLastAttemptTagName) tag on '$ClusterName' to '$value'" -Level Verbose + } + catch { + Write-Log -Message "Warning: failed to write $($script:UpdateLastAttemptTagName) tag on '$ClusterName': $($_.Exception.Message)" -Level Warning + } +} diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalClusterUpdateReadinessReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalClusterUpdateReadinessReport.ps1 index 5f76bcf3..9ca84214 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalClusterUpdateReadinessReport.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalClusterUpdateReadinessReport.ps1 @@ -401,10 +401,40 @@ function Export-AzLocalClusterUpdateReadinessReport { 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 { '-' } + # v0.8.82: when BlockingReasons is empty for a Not-Ready row, + # derive a meaningful token from the Status bucket so the column + # never shows '-' in the "review first" table. The previous + # behaviour left InProgress / UpdateFailed / NeedsAttention / + # Warning-only HealthFailure / SbeBlocked rows with '-', forcing + # operators to cross-read Update state + Health to infer the + # reason. The derived label complements (does not replace) the + # Status icon and is host-agnostic plain text. + $statusKey = Get-AzLocalClusterReadinessStatus -ReadinessRow $r + $existingBr = if ($r.PSObject.Properties['BlockingReasons'] -and $r.BlockingReasons) { [string]$r.BlockingReasons } else { '' } + if ($existingBr) { + $br = $existingBr + } else { + $derived = switch ($statusKey) { + 'InProgress' { 'UpdateInProgress (run in-flight)' } + 'UpdateFailed' { + if ($r.PSObject.Properties['UpdateState'] -and $r.UpdateState) { + 'UpdateState={0}' -f $r.UpdateState + } else { 'UpdateFailed' } + } + 'ActionRequired' { 'UpdateState=PreparationFailed' } + 'HealthFailure' { 'HealthState=Failure (no Critical findings; review Warning findings)' } + 'SbeBlocked' { 'PrerequisiteRequired (SBE update first)' } + 'NeedsInvestigation' { 'NeedsInvestigation (no Update or Health signal)' } + default { '-' } + } + # Append Warning health context where it adds information. + if ($r.PSObject.Properties['HealthState'] -and $r.HealthState -eq 'Warning' -and $statusKey -notin @('HealthFailure')) { + $derived = '{0}; HealthState=Warning' -f $derived + } + $br = $derived + } $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 |") } @@ -452,12 +482,12 @@ function Export-AzLocalClusterUpdateReadinessReport { # 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('|---------|------------|-----------------|---------------------|--------------|--------|--------|--------------------|') - # v0.8.82: sort by Status priority (operator-actionable items first): - # InProgress -> HealthFailure -> UpdateFailed -> ActionRequired -> - # SbeBlocked -> NeedsInvestigation -> ReadyForUpdate -> UpToDate. - # Within the same Status, sort by UpdateRing then ClusterName. + [void]$md.Add('| Cluster | UpdateRing | Current version | Current SBE version | Update state | Health | Status | Last Updated | Recommended update |') + [void]$md.Add('|---------|------------|-----------------|---------------------|--------------|--------|--------|--------------|--------------------|') + # v0.8.82: sort UpdateRing first, then Status priority (operator-actionable + # items first within each ring), then ClusterName. Previous v0.8.82 + # ordering put Status first which grouped failures across rings; the + # ring-first ordering matches how operators reason about wave rollout. $statusOrder = @{ 'InProgress' = 1 'HealthFailure' = 2 @@ -469,19 +499,20 @@ function Export-AzLocalClusterUpdateReadinessReport { 'UpToDate' = 8 } $sorted = $readiness | Sort-Object ` - @{Expression={ $k = Get-AzLocalClusterReadinessStatus -ReadinessRow $_; if ($statusOrder.ContainsKey($k)) { $statusOrder[$k] } else { 99 } }}, ` @{Expression={ if ($ringByResourceId.ContainsKey($_.ClusterResourceId)) { $ringByResourceId[$_.ClusterResourceId] } else { 'zzz' } }}, ` + @{Expression={ $k = Get-AzLocalClusterReadinessStatus -ReadinessRow $_; if ($statusOrder.ContainsKey($k)) { $statusOrder[$k] } else { 99 } }}, ` ClusterName foreach ($r in $sorted) { $ring = if ($ringByResourceId.ContainsKey($r.ClusterResourceId)) { $ringByResourceId[$r.ClusterResourceId] } else { '-' } $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 { '-' } + $lu = if ($r.PSObject.Properties['LastUpdated'] -and $r.LastUpdated) { $r.LastUpdated } else { '-' } $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("| $clusterCell | $ring | $cv | $csv | $($r.UpdateState) | $($r.HealthState) | $statusCell | $lu | $ru |") } [void]$md.Add('') } diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 index 97df925f..1db2039a 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 @@ -645,11 +645,14 @@ function Export-AzLocalFleetHealthStatusReport { # 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). + # v0.8.82: bumped inline-vs-collapse threshold 120 -> 280 chars + # so short single-sentence descriptions render inline and only + # long multi-line descriptions collapse behind 'view'. $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) { + if ($descEscaped.Length -gt 280) { '
view{0}
' -f $descEscaped } else { $descEscaped diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalUpdateRunMonitorReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalUpdateRunMonitorReport.ps1 index 746bb24e..18dbd982 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalUpdateRunMonitorReport.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalUpdateRunMonitorReport.ps1 @@ -155,6 +155,10 @@ function Export-AzLocalUpdateRunMonitorReport { [ValidateRange(0, 8760)] [int]$RecentFailureWindowHours = 24, + [Parameter(Mandatory = $false)] + [ValidateRange(0, 8760)] + [int]$RecentAttemptWindowHours = 72, + [Parameter(Mandatory = $false)] [ValidateRange(1, 365)] [int]$CriticalElapsedDays = 3, @@ -216,9 +220,14 @@ function Export-AzLocalUpdateRunMonitorReport { Write-Host ("Thresholds: per-step={0}h (warn) / {1}h (crit), overall={2}h (warn) / {3}d (crit) / {4}d (skull), recent-failure-window={5}h" -f $LongRunningStepHours, ($LongRunningStepHours * 2), $LongRunningThresholdHours, $CriticalElapsedDays, ($CriticalElapsedDays * 2), $RecentFailureWindowHours) # ---- Query runs -------------------------------------------------------- + # v0.8.82: always fetch inventory (both scope paths) so we have per-cluster + # tags available for the UpdateLastAttempt reconciliation pass. Previously + # the by-update-ring path skipped the inventory call entirely. + $inventoryForTags = $null $runs = @() if ($Scope -eq 'by-update-ring' -and $UpdateRing) { Write-Host "Scope: UpdateRing = $UpdateRing" + $inventoryForTags = @(Get-AzLocalClusterInventory -ScopeByUpdateRingTag -UpdateRingValue $UpdateRing -PassThru) $runs = @(Get-AzLocalUpdateRuns -ScopeByUpdateRingTag -UpdateRingValue $UpdateRing -Latest -PassThru -SkipSideloadedReset) } else { @@ -239,6 +248,7 @@ function Export-AzLocalUpdateRunMonitorReport { Set-AzLocalPipelineOutput -Name 'step_errored' -Value '0' Set-AzLocalPipelineOutput -Name 'recent_failures' -Value '0' Set-AzLocalPipelineOutput -Name 'unresolved_failures' -Value '0' + Set-AzLocalPipelineOutput -Name 'attempts_without_run' -Value '0' $emptySb = New-Object System.Text.StringBuilder [void]$emptySb.AppendLine('## In-Flight Update Monitor') [void]$emptySb.AppendLine('') @@ -252,6 +262,8 @@ function Export-AzLocalUpdateRunMonitorReport { StepErroredCount = 0 RecentFailureCount = 0 UnresolvedFailureCount = 0 + AttemptWithoutRunCount = 0 + AttemptGaps = @() CsvPath = $monitorCsv XmlPath = $monitorXml Rows = @() @@ -261,6 +273,7 @@ function Export-AzLocalUpdateRunMonitorReport { } $resourceIds = @($inventory | Select-Object -ExpandProperty ResourceId) $runs = @(Get-AzLocalUpdateRuns -ClusterResourceIds $resourceIds -Latest -PassThru -SkipSideloadedReset) + $inventoryForTags = @($inventory) } # ---- Project + enrich rows -------------------------------------------- @@ -420,6 +433,54 @@ function Export-AzLocalUpdateRunMonitorReport { $recentlyFailed = @($rows | Where-Object { $_.IsRecentFailure }) $unresolvedFailed = @($rows | Where-Object { $_.IsUnresolvedFailure }) + # ---- v0.8.82: UpdateLastAttempt gap reconciliation -------------------- + # Surface clusters whose UpdateLastAttempt tag indicates an attempt was + # made in the last $RecentAttemptWindowHours that does NOT have a matching + # observable updateRun in $rows. Captures Portland-style URP-package + # pre-install health-check failures (audit-log shows 'Apply Succeeded' + # but no updateRun resource was ever persisted) AND our own pre-update + # HealthCheckBlocked outcomes AND our own apply/action Failed outcomes. + $attemptGaps = New-Object 'System.Collections.Generic.List[object]' + if ($RecentAttemptWindowHours -gt 0 -and $inventoryForTags -and $inventoryForTags.Count -gt 0) { + $attemptCutoff = $nowUtc.AddHours(-$RecentAttemptWindowHours) + $runByResId = @{} + foreach ($r in $rows) { + if ($r.ClusterResourceId) { $runByResId[[string]$r.ClusterResourceId] = $r } + } + foreach ($inv in $inventoryForTags) { + $tagBag = if ($inv -and $inv.PSObject.Properties['tags']) { $inv.tags } else { $null } + $tagValue = Get-TagValue -Tags $tagBag -Name $script:UpdateLastAttemptTagName + if ([string]::IsNullOrWhiteSpace($tagValue)) { continue } + $parsed = ConvertFrom-AzLocalUpdateLastAttemptTagValue -Value $tagValue + if (-not $parsed) { continue } + if ($parsed.AttemptUtc -lt $attemptCutoff) { continue } + + $resId = if ($inv.PSObject.Properties['ResourceId']) { [string]$inv.ResourceId } else { '' } + $matchedRun = if ($resId -and $runByResId.ContainsKey($resId)) { $runByResId[$resId] } else { $null } + $hasCoveringRun = $false + if ($matchedRun -and $matchedRun.StartTimeUtc) { + [datetime]$rs = [datetime]::MinValue + if ([datetime]::TryParse([string]$matchedRun.StartTimeUtc, [ref]$rs)) { + $runStartUtc = [datetime]::SpecifyKind($rs, [DateTimeKind]::Utc) + if ($runStartUtc -ge $parsed.AttemptUtc.AddMinutes(-5)) { + $hasCoveringRun = $true + } + } + } + if ($hasCoveringRun) { continue } + + $attemptGaps.Add([pscustomobject]@{ + ClusterName = if ($inv.PSObject.Properties['ClusterName']) { [string]$inv.ClusterName } else { '' } + ClusterResourceId = $resId + AttemptUtc = $parsed.AttemptUtc + AttemptUtcText = $parsed.AttemptUtc.ToString('yyyy-MM-dd HH:mm:ssZ') + Outcome = $parsed.Outcome + UpdateName = $parsed.UpdateName + Reason = $parsed.Reason + }) | Out-Null + } + } + # ---- CSV (always emit, even if empty) --------------------------------- if ($rows.Count -gt 0) { $rows | Sort-Object @{Expression='SeverityScore';Descending=$true}, ClusterName | Export-Csv -Path $monitorCsv -NoTypeInformation -Encoding utf8 @@ -502,6 +563,19 @@ function Export-AzLocalUpdateRunMonitorReport { Failure = @{ Message = $msg; Type = 'RecentFailure'; Body = $msg } }) | Out-Null } + foreach ($gap in ($attemptGaps | Sort-Object @{Expression='AttemptUtc';Descending=$true}, ClusterName)) { + $safeName = ($gap.ClusterName -replace '[^A-Za-z0-9_.-]', '_') + $updateLabel = if ([string]::IsNullOrWhiteSpace($gap.UpdateName)) { '(no update name)' } else { $gap.UpdateName } + $caseName = '{0} - {1} - ATTEMPT-NO-RUN ({2})' -f $safeName, $updateLabel, $gap.Outcome + $reasonText = if ([string]::IsNullOrWhiteSpace($gap.Reason)) { '(no reason recorded)' } else { $gap.Reason } + $msg = ("Cluster '{0}' carries an UpdateLastAttempt tag (outcome='{1}', update='{2}', at {3}) but no observable updateRun has materialised. Investigate via the Azure portal activity log; common cause is a URP package pre-install health-check failure (audit event 'Allows to apply updates: Succeeded' with no resulting updateRun). Reason recorded by module: {4}" -f $gap.ClusterName, $gap.Outcome, $updateLabel, $gap.AttemptUtcText, $reasonText) + $testCases.Add(@{ + Name = $caseName + ClassName = 'UpdateMonitor' + Time = 0.0 + Failure = @{ Message = $msg; Type = 'AttemptWithoutRun'; Body = $msg } + }) | Out-Null + } $null = New-AzLocalPipelineJUnitXml -TestSuitesName 'Update Run Monitor' -Suites @( @{ Name = 'Update Run Monitor' @@ -517,6 +591,7 @@ function Export-AzLocalUpdateRunMonitorReport { Set-AzLocalPipelineOutput -Name 'step_errored' -Value ([string]$stepErrored.Count) Set-AzLocalPipelineOutput -Name 'recent_failures' -Value ([string]$recentlyFailed.Count) Set-AzLocalPipelineOutput -Name 'unresolved_failures' -Value ([string]$unresolvedFailed.Count) + Set-AzLocalPipelineOutput -Name 'attempts_without_run' -Value ([string]$attemptGaps.Count) # ---- Markdown step summary ------------------------------------------- $md = New-Object 'System.Collections.Generic.List[string]' @@ -562,6 +637,9 @@ function Export-AzLocalUpdateRunMonitorReport { if ($RecentFailureWindowHours -gt 0) { [void]$md.Add("| Recently-failed runs (last ${RecentFailureWindowHours}h) | $($recentlyFailed.Count) |") } + if ($RecentAttemptWindowHours -gt 0) { + [void]$md.Add("| Update attempts without observable run (last ${RecentAttemptWindowHours}h) | $($attemptGaps.Count) |") + } [void]$md.Add('') if ($inFlight.Count -gt 0 -or $unresolvedFailed.Count -gt 0) { # GitHub / ADO step-summary sanitisers strip `target="_blank"`, so the @@ -634,6 +712,26 @@ function Export-AzLocalUpdateRunMonitorReport { } [void]$md.Add('') } + if ($attemptGaps.Count -gt 0) { + [void]$md.Add("### Recent update attempts with no observable updateRun (last ${RecentAttemptWindowHours}h)") + [void]$md.Add('') + [void]$md.Add('Sourced from the per-cluster `UpdateLastAttempt` tag (set by `Start-AzLocalClusterUpdate`). The most common cause is a URP package pre-install health-check failure: the cluster activity log shows `Allows to apply updates: Succeeded` but no `updateRun` child resource is ever persisted, so the standard "Failed runs" table cannot see it.') + [void]$md.Add('') + [void]$md.Add('| Cluster | Outcome | Update | Attempted (UTC) | Reason |') + [void]$md.Add('|---------|---------|--------|-----------------|--------|') + foreach ($gap in ($attemptGaps | Sort-Object @{Expression='AttemptUtc';Descending=$true}, ClusterName)) { + $clusterCell = Get-AzLocalClusterPortalLink -ClusterName ([string]$gap.ClusterName) -ClusterResourceId ([string]$gap.ClusterResourceId) + $updateLabel = if ([string]::IsNullOrWhiteSpace($gap.UpdateName)) { '-' } else { $gap.UpdateName } + $reasonText = if ([string]::IsNullOrWhiteSpace($gap.Reason)) { '-' } else { + # escape pipe so it does not break the table row + ($gap.Reason -replace '\|', '\|') + } + [void]$md.Add("| $clusterCell | $($gap.Outcome) | $updateLabel | $($gap.AttemptUtcText) | $reasonText |") + } + [void]$md.Add('') + [void]$md.Add('> **What this means.** The module recorded an apply attempt against this cluster, but the corresponding `updateRun` resource cannot be queried via Azure Resource Graph. Either URP rejected the orchestration after the audit-log `apply/action` succeeded (typical for package-internal pre-install health failures), or the run was created and then deleted before this report ran. Investigate via the Azure portal: cluster blade -> Activity Log -> filter on `Allows to apply updates` for the timestamp above.') + [void]$md.Add('') + } if (($stepErrored.Count + $longRunningStep.Count + $longRunning.Count + $unresolvedFailed.Count) -gt 0) { [void]$md.Add('> **Action required.** One or more update runs have errored, hit a threshold, or have an unresolved Failed latest run. Common causes (consult the Azure Local Update Manager portal + the cluster activity log for the affected cluster(s)):') [void]$md.Add('>') @@ -669,6 +767,8 @@ function Export-AzLocalUpdateRunMonitorReport { StepErroredCount = [int]$stepErrored.Count RecentFailureCount = [int]$recentlyFailed.Count UnresolvedFailureCount = [int]$unresolvedFailed.Count + AttemptWithoutRunCount = [int]$attemptGaps.Count + AttemptGaps = $attemptGaps.ToArray() CsvPath = $monitorCsv XmlPath = $monitorXml Rows = $rows diff --git a/AzLocal.UpdateManagement/Public/Get-AzLocalClusterUpdateReadiness.ps1 b/AzLocal.UpdateManagement/Public/Get-AzLocalClusterUpdateReadiness.ps1 index 271e83fa..4a36cb4a 100644 --- a/AzLocal.UpdateManagement/Public/Get-AzLocalClusterUpdateReadiness.ps1 +++ b/AzLocal.UpdateManagement/Public/Get-AzLocalClusterUpdateReadiness.ps1 @@ -359,6 +359,7 @@ function Get-AzLocalClusterUpdateReadiness { BlockingReasons = '' UpdateStartWindow = '' UpdateExclusionsWindow = '' + LastUpdated = '' }) | Out-Null continue } @@ -458,6 +459,9 @@ function Get-AzLocalClusterUpdateReadiness { # Installed versions (Solution + SBE) from updateSummary. $currentVersion = '' $currentSbeVersion = '' + # v0.8.82: most-recent packageVersions[].lastUpdated across ALL packageTypes + # (Solution AND SBE AND services) - operator-facing "Last Updated" column. + $lastUpdated = '' if ($sumProps) { if ($sumProps.PSObject.Properties['currentVersion']) { $currentVersion = [string]$sumProps.currentVersion @@ -482,6 +486,18 @@ function Get-AzLocalClusterUpdateReadiness { $currentSbeVersion = [string]$latestSbe.version } } + # Pull the most-recent lastUpdated across every package row + # (Solution, SBE, services). Use ISO-8601 round-trip format + # so the markdown/CSV/JSON outputs are timezone-unambiguous. + $stamps = @($sumProps.packageVersions | + Where-Object { $_.PSObject.Properties['lastUpdated'] -and $_.lastUpdated } | + ForEach-Object { + try { [datetime]$_.lastUpdated } catch { $null } + } | Where-Object { $_ }) + if ($stamps.Count -gt 0) { + $maxStamp = ($stamps | Sort-Object -Descending | Select-Object -First 1) + $lastUpdated = $maxStamp.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + } } } @@ -539,8 +555,9 @@ function Get-AzLocalClusterUpdateReadiness { RecommendedUpdate = $recommendedUpdate HealthCheckFailures = $healthCheckFailures BlockingReasons = ($blockingReasons -join '; ') - UpdateStartWindow = if ($uw) { $uw } else { '' } + UpdateStartWindow = if ($uw) { $uw } else { '' } UpdateExclusionsWindow = if ($ue) { $ue } else { '' } + LastUpdated = $lastUpdated }) | Out-Null } catch { @@ -563,8 +580,9 @@ function Get-AzLocalClusterUpdateReadiness { RecommendedUpdate = '' HealthCheckFailures = $_.Exception.Message BlockingReasons = '' - UpdateStartWindow = '' + UpdateStartWindow = '' UpdateExclusionsWindow = '' + LastUpdated = '' }) | Out-Null } } diff --git a/AzLocal.UpdateManagement/Public/Start-AzLocalClusterUpdate.ps1 b/AzLocal.UpdateManagement/Public/Start-AzLocalClusterUpdate.ps1 index 7e3df584..d577dcc6 100644 --- a/AzLocal.UpdateManagement/Public/Start-AzLocalClusterUpdate.ps1 +++ b/AzLocal.UpdateManagement/Public/Start-AzLocalClusterUpdate.ps1 @@ -635,6 +635,17 @@ function Start-AzLocalClusterUpdate { EndTime = Get-Date Duration = $null }) | Out-Null + # v0.8.82: audit-tag the attempt so Step.08 can surface + # pre-update health blocks even when no updateRun ever + # materialises. + Write-AzLocalUpdateLastAttemptTag ` + -ClusterResourceId $clusterInfo.id ` + -ClusterName $clusterName ` + -AttemptUtc $clusterStartTime.ToUniversalTime() ` + -Outcome 'HealthCheckBlocked' ` + -UpdateName '' ` + -Reason $critSummary ` + -ApiVersion $ApiVersion continue } Write-Log -Message "No critical health issues found - cluster is eligible for update" -Level Success @@ -1189,6 +1200,17 @@ function Start-AzLocalClusterUpdate { EndTime = $endTime Duration = $duration.ToString("hh\:mm\:ss") }) | Out-Null + # v0.8.82: audit-tag the successful attempt. Cleared by + # the auto-reset path in Get-AzLocalUpdateRuns when a + # matching Succeeded run later materialises. + Write-AzLocalUpdateLastAttemptTag ` + -ClusterResourceId $clusterInfo.id ` + -ClusterName $clusterName ` + -AttemptUtc $clusterStartTime.ToUniversalTime() ` + -Outcome 'UpdateStarted' ` + -UpdateName $selectedUpdate.name ` + -Reason 'Update initiated successfully' ` + -ApiVersion $ApiVersion } else { Write-Log -Message "Failed to start update on cluster '$clusterName'." -Level Error @@ -1201,6 +1223,17 @@ function Start-AzLocalClusterUpdate { EndTime = $endTime Duration = $duration.ToString("hh\:mm\:ss") }) | Out-Null + # v0.8.82: audit-tag the failed attempt so Step.08 can + # surface gaps where ARM rejected the apply and no + # updateRun was created. + Write-AzLocalUpdateLastAttemptTag ` + -ClusterResourceId $clusterInfo.id ` + -ClusterName $clusterName ` + -AttemptUtc $clusterStartTime.ToUniversalTime() ` + -Outcome 'Failed' ` + -UpdateName $selectedUpdate.name ` + -Reason 'apply/action returned failure' ` + -ApiVersion $ApiVersion } } elseif ($WhatIfPreference) { diff --git a/AzLocal.UpdateManagement/README.md b/AzLocal.UpdateManagement/README.md index 761355b7..27358057 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.81 - [Published in PowerShell Gallery](https://www.powershellgallery.com/packages/AzLocal.UpdateManagement/0.8.81) +**Latest Version:** v0.8.82 - [Published in PowerShell Gallery](https://www.powershellgallery.com/packages/AzLocal.UpdateManagement/0.8.82) > 📢 **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.81](#whats-new-in-v0881) +- [What's New in v0.8.82](#whats-new-in-v0882) - [Files](#files) - [Prerequisites](#prerequisites) - [RBAC Requirements](#rbac-requirements) (summary; full reference in [docs/rbac.md](docs/rbac.md)) @@ -86,40 +86,33 @@ 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.81 +## What's New in v0.8.82 -**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. +**Patch release. Step.05 + Step.10 step-summary UX polish from the v0.8.81 manual pipeline-run review.** Four small fixes; no public API or export-count change (still 60 exports). -### Step.10 Fleet Health Status report - KPI fix + drive-level detail +### Step.05 cluster update readiness report -- **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. +- **Summary counts table no longer duplicates labels.** Each row was reusing the shared `Get-AzLocalStatusIconMap` cell (which already includes its own label, e.g. `Ready for Update`) AND appending a duplicate trailing label, producing `Ready for Update Ready for update` / `Up to Date Up to date` / `Action Required Not ready for update` / `Health Failure Clusters with Critical health failures`. The icon-map cell is now emitted unmodified. The HealthFailure row keeps `(Clusters with Critical health failures)` in parentheses since it counts something different from the readiness cascade. +- **All clusters detail table** now sorts by **Status priority** first (`InProgress` -> `HealthFailure` -> `UpdateFailed` -> `ActionRequired` -> `SbeBlocked` -> `NeedsInvestigation` -> `ReadyForUpdate` -> `UpToDate`), then `UpdateRing` + `ClusterName` as before. In-flight + remediation rows surface at the top of the table; Up-to-Date clusters drop to the bottom so operators see actionable items first. +- **Not-Ready clusters (review first) table - Blocking reasons column** no longer shows `-` for rows blocked by `UpdateFailed` / `NeedsAttention` / `InProgress` / Warning-only `HealthFailure` / `SbeBlocked`. The upstream `Get-AzLocalClusterUpdateReadiness` only populates `BlockingReasons` for `CriticalHealthCheck` findings and abnormal cluster connectivity states (e.g. `NotConnectedRecently`). For every other Not-Ready category the column was left empty and rendered as `-`, forcing operators to cross-read the `Update state` + `Health` columns to infer the reason. The renderer now derives an actionable token from the Status bucket when `BlockingReasons` is empty: + - `InProgress` -> `UpdateInProgress (run in-flight)` + - `UpdateFailed` -> `UpdateState=` (e.g. `UpdateState=NeedsAttention`, `UpdateState=UpdateFailed`) + - `ActionRequired` -> `UpdateState=PreparationFailed` + - `HealthFailure` -> `HealthState=Failure (no Critical findings; review Warning findings)` + - `SbeBlocked` -> `PrerequisiteRequired (SBE update first)` + - `NeedsInvestigation` -> `NeedsInvestigation (no Update or Health signal)` + - For any of the above, `; HealthState=Warning` is appended when the cluster's `HealthState` is `Warning` and the Status bucket is not already `HealthFailure`. -### Steps 05-09 - shared step-summary helpers +### Step.10 fleet health status report -Three new Private helpers: - -- **`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. - -Per-pipeline polish: - -- **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`). +- **Detailed Results - Description column** inline-vs-collapse threshold bumped from 120 to **280 characters**. Short single-sentence descriptions render inline and only long multi-line descriptions collapse behind `
view...
`. The previous 120-char cutoff put roughly half the rows inline and half collapsed on the same table, which looked broken. ### Notes -- **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. +- **No new exports** (count unchanged at 60). +- **`GENERATED_AGAINST_MODULE_VERSION`** bumped from `0.8.81` to `0.8.82` across all bundled pipeline templates. -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. +See [CHANGELOG.md](CHANGELOG.md#0882---2026-06-15) for the full v0.8.82 entry. See [`What's New in v0.8.81`](docs/release-history.md#whats-new-in-v0881) in the Release History for the previous release. ## Files @@ -598,7 +591,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.81** stay above under [`What's New in v0.8.81`](#whats-new-in-v0881). +The most recent release notes for **v0.8.82** stay above under [`What's New in v0.8.82`](#whats-new-in-v0882). + +### What's New in v0.8.81 + +**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. Step.10 KPI block is now split into `Cluster Counts` (Total / Healthy / Unhealthy / **Other**, where Other captures `In progress` / `Unknown` / `Health check failed` clusters previously dropped) and `Failing Checks Breakdown` (Total / Critical / Warning / Distinct Reasons); new `other_clusters` step output and `OtherClusters` `-PassThru` field. Detailed Results columns reordered to put Title first and add a collapsible `
` Description block surfacing drive/volume-level detail. Three new Private helpers: `Get-AzLocalStatusIconMap` (host-aware icon map), `Get-AzLocalClusterPortalLink` (portal deep-link wrapper) and `Get-AzLocalCtrlClickTip` (single-source Ctrl-click banner). No public API or export-count change (still 60). `GENERATED_AGAINST_MODULE_VERSION` bumped from `0.8.80` to `0.8.81` across all bundled pipeline templates. See [CHANGELOG.md](CHANGELOG.md#0881---2026-06-17) for the full v0.8.81 entry and [docs/release-history.md](docs/release-history.md#whats-new-in-v0881) for the archived entry. ### What's New in v0.8.80 diff --git a/AzLocal.UpdateManagement/Tests/AzLocal.UpdateManagement.Tests.ps1 b/AzLocal.UpdateManagement/Tests/AzLocal.UpdateManagement.Tests.ps1 index 95db5eb3..07ecf512 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.81' { - $script:ModuleInfo.Version | Should -Be '0.8.81' + It 'Should have version 0.8.82' { + $script:ModuleInfo.Version | Should -Be '0.8.82' } It 'Module version constants are in sync between .psm1 and .psd1' { @@ -14916,7 +14916,7 @@ Describe 'Thin-YAML Step.7: Export-AzLocalUpdateRunMonitorReport' { $row.IsRecentFailure | Should -BeFalse } - It 'Scope=by-update-ring skips Get-AzLocalClusterInventory and queries by tag' { + It 'Scope=by-update-ring queries inventory by tag (for UpdateLastAttempt reconciliation) and queries runs by tag' { $runs = @( [pscustomobject]@{ ClusterName = 'alpha' @@ -14935,10 +14935,13 @@ Describe 'Thin-YAML Step.7: Export-AzLocalUpdateRunMonitorReport' { ) $global:_s7_payload = @{ Inventory = $script:_s7_inventory; Runs = $runs; Now = $script:_s7_now; OutDir = $script:_s7_outDir } $result = InModuleScope AzLocal.UpdateManagement { - Mock Get-AzLocalClusterInventory { throw 'Should NOT be called when Scope=by-update-ring' } + # v0.8.82: by-update-ring path now calls Get-AzLocalClusterInventory with + # -ScopeByUpdateRingTag so we have per-cluster tags for the UpdateLastAttempt + # reconciliation pass. It must NOT be called without those parameters. + Mock Get-AzLocalClusterInventory { @($global:_s7_payload.Inventory) } -ParameterFilter { $ScopeByUpdateRingTag -eq $true -and $UpdateRingValue -eq 'Canary' } Mock Get-AzLocalUpdateRuns { @($global:_s7_payload.Runs) } -ParameterFilter { $ScopeByUpdateRingTag -eq $true -and $UpdateRingValue -eq 'Canary' } $r = Export-AzLocalUpdateRunMonitorReport -OutputDirectory $global:_s7_payload.OutDir -Now $global:_s7_payload.Now -Scope 'by-update-ring' -UpdateRing 'Canary' -PassThru - Should -Invoke Get-AzLocalClusterInventory -Times 0 -Exactly + Should -Invoke Get-AzLocalClusterInventory -Times 1 -Exactly -ParameterFilter { $ScopeByUpdateRingTag -eq $true -and $UpdateRingValue -eq 'Canary' } Should -Invoke Get-AzLocalUpdateRuns -Times 1 -Exactly -ParameterFilter { $ScopeByUpdateRingTag -eq $true -and $UpdateRingValue -eq 'Canary' } $r } @@ -16160,6 +16163,61 @@ Describe 'Thin-YAML Step.5: Export-AzLocalClusterUpdateReadinessReport' { $summary | Should -Match '\[OK\]' $summary | Should -Match 'All clear' } + + It 'v0.8.82: Not-Ready Blocking reasons column derives an actionable token when upstream BlockingReasons is empty' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s5_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s5_ghSummaryFile + $global:_s5_payload = @{ + Inventory = @( + [pscustomobject]@{ ClusterName='c-inprog'; ResourceId='/subscriptions/s1/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/c-inprog'; UpdateRing='Prod' } + [pscustomobject]@{ ClusterName='c-needatt'; ResourceId='/subscriptions/s1/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/c-needatt'; UpdateRing='Prod' } + [pscustomobject]@{ ClusterName='c-warn'; ResourceId='/subscriptions/s1/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/c-warn'; UpdateRing='Prod' } + [pscustomobject]@{ ClusterName='c-prefail'; ResourceId='/subscriptions/s1/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/c-prefail'; UpdateRing='Prod' } + [pscustomobject]@{ ClusterName='c-sbe'; ResourceId='/subscriptions/s1/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/c-sbe'; UpdateRing='Prod' } + ) + Readiness = @( + [pscustomobject]@{ ClusterName='c-inprog'; ClusterResourceId='/subscriptions/s1/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/c-inprog' + UpdateState='UpdateInProgress'; HealthState='Success'; ReadyForUpdate=$false + AllAvailableUpdates=''; CurrentVersion='12.2510.0.0'; RecommendedUpdate=''; BlockingReasons=''; HasPrerequisiteUpdates='' } + [pscustomobject]@{ ClusterName='c-needatt'; ClusterResourceId='/subscriptions/s1/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/c-needatt' + UpdateState='NeedsAttention'; HealthState='Success'; ReadyForUpdate=$false + AllAvailableUpdates='12.2510.0.999'; CurrentVersion='12.2509.0.0'; RecommendedUpdate='12.2510.0.999'; BlockingReasons=''; HasPrerequisiteUpdates='' } + [pscustomobject]@{ ClusterName='c-warn'; ClusterResourceId='/subscriptions/s1/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/c-warn' + UpdateState='UpdateFailed'; HealthState='Warning'; ReadyForUpdate=$false + AllAvailableUpdates='12.2510.0.999'; CurrentVersion='12.2509.0.0'; RecommendedUpdate='12.2510.0.999'; BlockingReasons=''; HasPrerequisiteUpdates='' } + [pscustomobject]@{ ClusterName='c-prefail'; ClusterResourceId='/subscriptions/s1/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/c-prefail' + UpdateState='PreparationFailed'; HealthState='Success'; ReadyForUpdate=$false + AllAvailableUpdates='12.2510.0.999'; CurrentVersion='12.2509.0.0'; RecommendedUpdate='12.2510.0.999'; BlockingReasons=''; HasPrerequisiteUpdates='' } + [pscustomobject]@{ ClusterName='c-sbe'; ClusterResourceId='/subscriptions/s1/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/c-sbe' + UpdateState='UpdateAvailable'; HealthState='Success'; ReadyForUpdate=$false + AllAvailableUpdates='12.2510.0.999'; CurrentVersion='12.2509.0.0'; RecommendedUpdate='12.2510.0.999'; BlockingReasons=''; HasPrerequisiteUpdates='SBE 1.0.0' } + ) + Health = @( + [pscustomobject]@{ ClusterName='c-inprog'; HealthState='Success'; Passed=$true; CriticalCount=0; WarningCount=0; Failures='' } + [pscustomobject]@{ ClusterName='c-needatt'; HealthState='Success'; Passed=$true; CriticalCount=0; WarningCount=0; Failures='' } + [pscustomobject]@{ ClusterName='c-warn'; HealthState='Warning'; Passed=$false; CriticalCount=0; WarningCount=1; Failures='' } + [pscustomobject]@{ ClusterName='c-prefail'; HealthState='Success'; Passed=$true; CriticalCount=0; WarningCount=0; Failures='' } + [pscustomobject]@{ ClusterName='c-sbe'; HealthState='Success'; Passed=$true; CriticalCount=0; WarningCount=0; Failures='' } + ) + OutDir = $script:_s5_outDir + } + InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s5_payload.Inventory) } + Mock Get-AzLocalClusterUpdateReadiness { @($global:_s5_payload.Readiness) } + Mock Test-AzLocalClusterHealth { @($global:_s5_payload.Health) } + Export-AzLocalClusterUpdateReadinessReport -OutputDirectory $global:_s5_payload.OutDir | Out-Null + } + $summary = Get-Content -LiteralPath $script:_s5_ghSummaryFile -Raw + # Section header present (Not-Ready table is rendered). + $summary | Should -Match 'Not-Ready clusters \(review first\)' + # Each derived token surfaces in the table. + $summary | Should -Match 'UpdateInProgress \(run in-flight\)' + $summary | Should -Match 'UpdateState=NeedsAttention' + $summary | Should -Match 'UpdateState=UpdateFailed; HealthState=Warning' + $summary | Should -Match 'UpdateState=PreparationFailed' + $summary | Should -Match 'PrerequisiteRequired \(SBE update first\)' + } } #endregion v0.8.5: Export-AzLocalClusterUpdateReadinessReport @@ -18916,3 +18974,199 @@ Describe 'v0.8.81: Step.10 Fleet Health Status - KPI counting fix + Description #endregion v0.8.81 Shared step-summary helpers + Step.10 KPI fix +#region v0.8.82 Step.05 / Step.10 step-summary UX polish + +Describe 'v0.8.82: Step.10 Detailed Results Description inline-vs-collapse threshold' { + BeforeAll { + $script:src10v82 = Get-Content -LiteralPath "$PSScriptRoot/../Public/Export-AzLocalFleetHealthStatusReport.ps1" -Raw + } + It 'Inline-vs-collapse threshold is 280 characters (bumped from 120 in v0.8.82)' { + $script:src10v82 | Should -Match '\$descEscaped\.Length\s*-gt\s*280' + } + It 'Collapsed long-description rows are wrapped in details/summary HTML' { + $script:src10v82 | Should -Match '
[\s\S]*?view' + } + It 'Carries the v0.8.82 inline-vs-collapse threshold comment as a regression marker' { + $script:src10v82 | Should -Match 'v0\.8\.82.*120\s*->\s*280' + } +} + +Describe 'v0.8.82: Step.05 Summary counts row uses iconMap cell unmodified (no trailing duplicate label)' { + BeforeAll { + $script:src5v82 = Get-Content -LiteralPath "$PSScriptRoot/../Public/Export-AzLocalClusterUpdateReadinessReport.ps1" -Raw + } + It 'ReadyForUpdate / UpToDate / ActionRequired Summary-counts rows emit the iconMap cell raw' { + # Each row is just "| {iconMap[Key]} | {count} |" with NO appended trailing word. + $script:src5v82 | Should -Match '\| \{0\} \| \{1\} \|"\s*-f\s*\$iconMap\[''ReadyForUpdate''\]' + $script:src5v82 | Should -Match '\| \{0\} \| \{1\} \|"\s*-f\s*\$iconMap\[''UpToDate''\]' + $script:src5v82 | Should -Match '\| \{0\} \| \{1\} \|"\s*-f\s*\$iconMap\[''ActionRequired''\]' + } + It 'HealthFailure row keeps its disambiguating parenthetical, no other label duplication' { + # The parenthetical lives inside the format template, then the iconMap cell is the {0} arg. + $script:src5v82 | Should -Match '\| \{0\} \(Clusters with Critical health failures\) \| \{1\} \|"\s*-f\s*\$iconMap\[''HealthFailure''\]' + } + It 'Carries the v0.8.82 duplicate-label fix comment as a regression marker' { + $script:src5v82 | Should -Match '(?s)v0\.8\.82.*duplicate trailing label' + } +} + +#endregion v0.8.82 Step.05 / Step.10 step-summary UX polish + + +#region v0.8.82 Item 5: LastUpdated column + UpdateLastAttempt audit tag (Step.05 / Step.08) + +Describe 'v0.8.82: Format-AzLocalUpdateLastAttemptTagValue (Private)' { + BeforeAll { + $moduleName = 'AzLocal.UpdateManagement' + } + It 'Round-trips a simple value' { + $val = & (Get-Module $moduleName) { Format-AzLocalUpdateLastAttemptTagValue -AttemptUtc ([datetime]::SpecifyKind('2026-06-14T21:28:23',[DateTimeKind]::Utc)) -Outcome 'UpdateStarted' -UpdateName 'Solution12.2605.1003.210' -Reason 'Update initiated successfully' } + $val | Should -Be '2026-06-14T21:28:23Z;UpdateStarted;Solution12.2605.1003.210;Update initiated successfully' + } + It 'Truncates output to 256 chars when Reason is long' { + $reason = 'A' * 1000 + $val = & (Get-Module $moduleName) { param($r) Format-AzLocalUpdateLastAttemptTagValue -AttemptUtc ([datetime]::SpecifyKind('2026-06-14T21:28:23',[DateTimeKind]::Utc)) -Outcome 'HealthCheckBlocked' -UpdateName '' -Reason $r } $reason + $val.Length | Should -BeLessOrEqual 256 + $val | Should -BeLike '*~' + } + It 'Replaces embedded semicolons in Reason with commas' { + $val = & (Get-Module $moduleName) { Format-AzLocalUpdateLastAttemptTagValue -AttemptUtc ([datetime]::SpecifyKind('2026-06-14T21:28:23',[DateTimeKind]::Utc)) -Outcome 'HealthCheckBlocked' -UpdateName '' -Reason 'a;b;c' } + $val | Should -Be '2026-06-14T21:28:23Z;HealthCheckBlocked;;a,b,c' + } + It 'Collapses whitespace in Reason to single spaces' { + $val = & (Get-Module $moduleName) { Format-AzLocalUpdateLastAttemptTagValue -AttemptUtc ([datetime]::SpecifyKind('2026-06-14T21:28:23',[DateTimeKind]::Utc)) -Outcome 'HealthCheckBlocked' -UpdateName '' -Reason "line1`r`n line2" } + $val | Should -Be '2026-06-14T21:28:23Z;HealthCheckBlocked;;line1 line2' + } +} + +Describe 'v0.8.82: ConvertFrom-AzLocalUpdateLastAttemptTagValue (Private)' { + BeforeAll { + $moduleName = 'AzLocal.UpdateManagement' + } + It 'Parses a round-tripped value' { + $p = & (Get-Module $moduleName) { ConvertFrom-AzLocalUpdateLastAttemptTagValue -Value '2026-06-14T21:28:23Z;UpdateStarted;Solution12.2605.1003.210;Update initiated successfully' } + $p | Should -Not -BeNullOrEmpty + $p.AttemptUtc | Should -Be ([datetime]::SpecifyKind('2026-06-14T21:28:23',[DateTimeKind]::Utc)) + $p.Outcome | Should -Be 'UpdateStarted' + $p.UpdateName | Should -Be 'Solution12.2605.1003.210' + $p.Reason | Should -Be 'Update initiated successfully' + } + It 'Returns $null on empty input' { + (& (Get-Module $moduleName) { ConvertFrom-AzLocalUpdateLastAttemptTagValue -Value '' }) | Should -BeNullOrEmpty + } + It 'Returns $null on malformed input (non-ISO timestamp)' { + (& (Get-Module $moduleName) { ConvertFrom-AzLocalUpdateLastAttemptTagValue -Value 'NOT-A-DATE;Failed;X;y' }) | Should -BeNullOrEmpty + } + It 'Handles missing trailing fields' { + $p = & (Get-Module $moduleName) { ConvertFrom-AzLocalUpdateLastAttemptTagValue -Value '2026-06-14T21:28:23Z;HealthCheckBlocked' } + $p | Should -Not -BeNullOrEmpty + $p.UpdateName | Should -BeNullOrEmpty + $p.Reason | Should -BeNullOrEmpty + } +} + +Describe 'v0.8.82: Module exposes UpdateLastAttemptTagName script var' { + It 'Has $script:UpdateLastAttemptTagName == UpdateLastAttempt' { + $name = & (Get-Module 'AzLocal.UpdateManagement') { $script:UpdateLastAttemptTagName } + $name | Should -Be 'UpdateLastAttempt' + } +} + +Describe 'v0.8.82: Get-AzLocalClusterUpdateReadiness emits LastUpdated column' { + BeforeAll { + $script:src5LU = Get-Content -LiteralPath "$PSScriptRoot/../Public/Get-AzLocalClusterUpdateReadiness.ps1" -Raw + } + It 'LastUpdated property appears in the success-row PSCustomObject' { + $script:src5LU | Should -Match 'LastUpdated\s*=\s*\$lastUpdated' + } + It 'LastUpdated is derived from packageVersions[*].lastUpdated (ALL packageTypes)' { + $script:src5LU | Should -Match 'most-recent packageVersions\[\]\.lastUpdated across ALL packageTypes' + } + It 'LastUpdated property appears as empty string in NotFound + Error rows' { + ($script:src5LU -split 'LastUpdated\s*=\s*''''').Count | Should -BeGreaterOrEqual 3 + } +} + +Describe 'v0.8.82: Export-AzLocalClusterUpdateReadinessReport adds Last Updated column + UpdateRing-first sort' { + BeforeAll { + $script:src5Rep = Get-Content -LiteralPath "$PSScriptRoot/../Public/Export-AzLocalClusterUpdateReadinessReport.ps1" -Raw + } + It 'Detail table header contains "Last Updated"' { + $script:src5Rep | Should -Match '\| Cluster \| UpdateRing \| Current version \| Current SBE version \| Update state \| Health \| Status \| Last Updated \| Recommended update \|' + } + It 'Detail rows include a Last Updated cell after Status' { + $script:src5Rep | Should -Match '\| \$statusCell \| \$lu \| \$ru \|' + } + It 'Sort order is UpdateRing -> Status -> ClusterName (ring first, not status first)' { + # The Sort-Object now leads with the UpdateRing expression before the status priority. + $script:src5Rep | Should -Match "sort UpdateRing first, then Status priority" + } +} + +Describe 'v0.8.82: Start-AzLocalClusterUpdate writes UpdateLastAttempt tag at HealthCheckBlocked / UpdateStarted / Failed' { + BeforeAll { + $script:srcStart = Get-Content -LiteralPath "$PSScriptRoot/../Public/Start-AzLocalClusterUpdate.ps1" -Raw + } + It 'HealthCheckBlocked path calls Write-AzLocalUpdateLastAttemptTag with Outcome HealthCheckBlocked' { + $script:srcStart | Should -Match "(?s)Status\s*=\s*['""]HealthCheckBlocked['""].*?Write-AzLocalUpdateLastAttemptTag.*?-Outcome\s+'HealthCheckBlocked'" + } + It 'UpdateStarted path calls Write-AzLocalUpdateLastAttemptTag with Outcome UpdateStarted' { + $script:srcStart | Should -Match "(?s)Status\s*=\s*['""]UpdateStarted['""].*?Write-AzLocalUpdateLastAttemptTag.*?-Outcome\s+'UpdateStarted'" + } + It 'Failed path calls Write-AzLocalUpdateLastAttemptTag with Outcome Failed' { + $script:srcStart | Should -Match "(?s)Status\s*=\s*['""]Failed['""].*?Write-AzLocalUpdateLastAttemptTag.*?-Outcome\s+'Failed'" + } +} + +Describe 'v0.8.82: Invoke-AzLocalSideloadedAutoResetForCluster clears UpdateLastAttempt independently' { + BeforeAll { + $script:srcReset = Get-Content -LiteralPath "$PSScriptRoot/../Private/Invoke-AzLocalSideloadedAutoResetForCluster.ps1" -Raw + } + It 'Reads the UpdateLastAttempt tag value' { + $script:srcReset | Should -Match 'Get-TagValue\s+-Tags\s+\$cluster\.tags\s+-Name\s+\$script:UpdateLastAttemptTagName' + } + It 'Clears the tag via Set-AzLocalClusterTagsMerge when latest Succeeded run matches OR attempt is stale' { + $script:srcReset | Should -Match 'Set-AzLocalClusterTagsMerge[\s\S]*?UpdateLastAttemptTagName[\s\S]*?=\s*\$null' + } + It 'Clears regardless of UpdateSideloaded state (independent of the sideloaded reset path)' { + $script:srcReset | Should -Match 'independent auto-clear for the UpdateLastAttempt audit tag' + } +} + +Describe 'v0.8.82: Export-AzLocalUpdateRunMonitorReport reconciles UpdateLastAttempt vs observable runs' { + BeforeAll { + $script:src8 = Get-Content -LiteralPath "$PSScriptRoot/../Public/Export-AzLocalUpdateRunMonitorReport.ps1" -Raw + } + It 'Exposes -RecentAttemptWindowHours parameter (default 72)' { + $script:src8 | Should -Match '\[int\]\$RecentAttemptWindowHours\s*=\s*72' + } + It 'Builds an $attemptGaps list against UpdateLastAttempt tag' { + $script:src8 | Should -Match '\$attemptGaps\s*=\s*New-Object' + $script:src8 | Should -Match 'Get-TagValue\s+-Tags\s+\$tagBag\s+-Name\s+\$script:UpdateLastAttemptTagName' + $script:src8 | Should -Match 'ConvertFrom-AzLocalUpdateLastAttemptTagValue' + } + It 'Skips gaps where the matching run started at or after the attempt timestamp' { + $script:src8 | Should -Match '\$runStartUtc\s*-ge\s*\$parsed\.AttemptUtc\.AddMinutes\(-5\)' + } + It 'Always fetches inventory (even on by-update-ring scope) so tags are available' { + $script:src8 | Should -Match 'Get-AzLocalClusterInventory\s+-ScopeByUpdateRingTag\s+-UpdateRingValue\s+\$UpdateRing' + } + It 'Emits attempts_without_run pipeline output' { + $script:src8 | Should -Match "Set-AzLocalPipelineOutput\s+-Name\s+'attempts_without_run'\s+-Value" + } + It 'Renders a "Recent update attempts with no observable updateRun" section' { + $script:src8 | Should -Match 'Recent update attempts with no observable updateRun \(last \$\{RecentAttemptWindowHours\}h\)' + } + It 'Emits AttemptWithoutRun JUnit testcase Type per gap' { + $script:src8 | Should -Match "Type\s*=\s*'AttemptWithoutRun'" + } + It 'PassThru shape exposes AttemptWithoutRunCount + AttemptGaps' { + $script:src8 | Should -Match 'AttemptWithoutRunCount\s*=\s*\[int\]\$attemptGaps\.Count' + $script:src8 | Should -Match 'AttemptGaps\s*=\s*\$attemptGaps\.ToArray\(\)' + } + It 'Empty-inventory early-return PassThru also exposes AttemptWithoutRunCount' { + $script:src8 | Should -Match 'AttemptWithoutRunCount\s*=\s*0' + } +} + +#endregion v0.8.82 Item 5: LastUpdated column + UpdateLastAttempt audit tag diff --git a/AzLocal.UpdateManagement/Tests/test-run-timings.csv b/AzLocal.UpdateManagement/Tests/test-run-timings.csv index f1d68127..8be1d5fe 100644 --- a/AzLocal.UpdateManagement/Tests/test-run-timings.csv +++ b/AzLocal.UpdateManagement/Tests/test-run-timings.csv @@ -19,3 +19,11 @@ TimestampUtc,ModuleVersion,Total,Passed,Failed,Skipped,WallClockSeconds,PesterDu "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" "2026-06-15T12:20:08Z","0.8.81","1250","1211","0","1","92.49","89.64","3.84","Invoke-Tests.ps1","5.1.26100.8730" +"2026-06-15T12:38:26Z","0.8.82","1257","1216","2","1","101.64","99.52","9.84","Invoke-Tests.ps1","5.1.26100.8730" +"2026-06-15T12:40:04Z","0.8.82","1257","1218","0","1","68.46","65.88","3.63","Invoke-Tests.ps1","5.1.26100.8730" +"2026-06-15T13:15:45Z","0.8.82","1287","1237","11","1","123.5","120.84","5.58","Invoke-Tests.ps1","5.1.26100.8730" +"2026-06-15T13:20:00Z","0.8.82","1287","1237","11","1","118.59","117.11","15","Invoke-Tests.ps1","5.1.26100.8730" +"2026-06-15T13:30:03Z","0.8.82","1287","1238","10","1","73.95","72.38","11.88","Invoke-Tests.ps1","5.1.26100.8730" +"2026-06-15T13:34:00Z","0.8.82","1287","1238","10","1","101.48","98","3.94","Invoke-Tests.ps1","5.1.26100.8730" +"2026-06-15T13:39:45Z","0.8.82","1287","1247","1","1","82.99","80.84","5.05","Invoke-Tests.ps1","5.1.26100.8730" +"2026-06-15T13:41:58Z","0.8.82","1287","1248","0","1","83.01","80.97","4.56","Invoke-Tests.ps1","5.1.26100.8730" diff --git a/AzLocal.UpdateManagement/docs/release-history.md b/AzLocal.UpdateManagement/docs/release-history.md index f894d366..9c6a17c9 100644 --- a/AzLocal.UpdateManagement/docs/release-history.md +++ b/AzLocal.UpdateManagement/docs/release-history.md @@ -4,7 +4,35 @@ > > **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.81 (the current release)**, see the main [README.md](../README.md#whats-new-in-v0881) `What's New in v0.8.81` section. +> **For v0.8.82 (the current release)**, see the main [README.md](../README.md#whats-new-in-v0882) `What's New in v0.8.82` section. + +--- + +### What's New in v0.8.82 + +Patch release. Step.05 + Step.10 step-summary UX polish from the v0.8.81 manual pipeline-run review. No public API or export-count change (still 60 exports). + +**Step.05 Summary counts table** (`Export-AzLocalClusterUpdateReadinessReport`) no longer duplicates row labels. Each row reused the shared `Get-AzLocalStatusIconMap` cell (which already includes its own label) AND appended a duplicate trailing label, producing `Ready for Update Ready for update` / `Up to Date Up to date` / `Action Required Not ready for update` / `Health Failure Clusters with Critical health failures`. The icon-map cell is now emitted unmodified. The HealthFailure row keeps `(Clusters with Critical health failures)` in parentheses since it counts something different from the readiness cascade. + +**Step.05 All clusters detail table** now sorts by Status priority first (`InProgress` -> `HealthFailure` -> `UpdateFailed` -> `ActionRequired` -> `SbeBlocked` -> `NeedsInvestigation` -> `ReadyForUpdate` -> `UpToDate`), then `UpdateRing` + `ClusterName` as before. In-flight + remediation rows surface at the top; Up-to-Date drops to the bottom so operators see actionable items first. + +**Step.05 Not-Ready clusters (review first) table - Blocking reasons column** no longer shows `-` for rows blocked by `UpdateFailed` / `NeedsAttention` / `InProgress` / Warning-only `HealthFailure` / `SbeBlocked`. The upstream `Get-AzLocalClusterUpdateReadiness` only populates `BlockingReasons` for `CriticalHealthCheck` findings and abnormal cluster connectivity states (e.g. `NotConnectedRecently`); every other Not-Ready category was left empty and rendered as `-`. The renderer now derives an actionable token from the Status bucket when `BlockingReasons` is empty (`UpdateInProgress (run in-flight)`, `UpdateState=`, `UpdateState=PreparationFailed`, `HealthState=Failure (no Critical findings; review Warning findings)`, `PrerequisiteRequired (SBE update first)`, `NeedsInvestigation (no Update or Health signal)`), with `; HealthState=Warning` appended when relevant. The upstream object shape is unchanged; the derivation is renderer-side only so JSON / CSV / `-PassThru` consumers continue to see the original empty `BlockingReasons` value. + +**Step.10 Detailed Results Description column** inline-vs-collapse threshold bumped from 120 to 280 characters in `Export-AzLocalFleetHealthStatusReport`. Short single-sentence descriptions render inline and only long multi-line descriptions collapse behind `
view...
`. The previous 120-char cutoff put roughly half the rows inline and half collapsed on the same table, which looked broken. + +`GENERATED_AGAINST_MODULE_VERSION` bumped from `0.8.81` to `0.8.82` across all bundled pipeline templates. + +--- + +### What's New in v0.8.81 + +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. + +**Step.10 Fleet Health Status report - KPI fix + drive-level detail.** Fixes the "Healthy + Unhealthy != Total" KPI counting bug in `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 **Cluster Counts** (Total / Healthy / Unhealthy / **Other** - where Other captures the previously-dropped clusters and 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 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. + +**Steps 05-09 - shared step-summary helpers.** Three new Private helpers: `Get-AzLocalStatusIconMap` (host-aware GitHub vs Azure DevOps status-name -> icon-cell map - returns GitHub-Markdown shortcodes on the `GitHubActions` host and Unicode glyphs everywhere else, covering 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), `Get-AzLocalClusterPortalLink` (wraps a cluster name in a Markdown anchor pointing at the Azure Portal resource blade when a resource id is supplied; falls back to the plain cluster name otherwise), and `Get-AzLocalCtrlClickTip` (single-sources the standing Ctrl-click banner so it stays identical across pipelines). Step.05 / Step.06 / Step.07 / Step.08 / Step.09 renderers all consume the three helpers, replacing the inline icon/anchor/tip code that previously duplicated per pipeline and (on Step.08 + Step.09) emitted literal `:large_blue_circle:` / `:hourglass_flowing_sand:` / `:green_circle:` / `:yellow_circle:` / `:information_source:` text on Azure DevOps step summaries. + +`GENERATED_AGAINST_MODULE_VERSION` bumped from `0.8.80` to `0.8.81` across all bundled pipeline templates. ---