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 29713abe..5882179c 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.73' + GENERATED_AGAINST_MODULE_VERSION: '0.8.74' 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 3cd3dd4d..9f683ca8 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/apply-updates.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/apply-updates.yml @@ -120,7 +120,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.73' + value: '0.8.74' # 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). @@ -511,6 +511,11 @@ stages: variables: totalCount: $[ stageDependencies.CheckReadiness.ReadinessCheck.outputs['readiness.TotalCount'] ] + # v0.8.74: Up-to-Date vs Not-Ready breakdown so the summary can + # explain WHY there is nothing to apply (already-patched clusters are + # a healthy steady state, not a failure). + upToDateCount: $[ stageDependencies.CheckReadiness.ReadinessCheck.outputs['readiness.UpToDateCount'] ] + notReadyCount: $[ stageDependencies.CheckReadiness.ReadinessCheck.outputs['readiness.NotReadyCount'] ] # report the resolved ring (not the raw parameter input) so # scheduled runs surface the actual ring the resolver picked. resolvedUpdateRing: $[ stageDependencies.CheckReadiness.ReadinessCheck.outputs['resolveRing.RESOLVED_UPDATE_RING'] ] @@ -542,4 +547,4 @@ stages: pwsh: true script: | Import-Module AzLocal.UpdateManagement -Force - Add-AzLocalNoReadyClustersStepSummary -UpdateRing $env:RESOLVED_UPDATE_RING -TotalCount "$(totalCount)" + Add-AzLocalNoReadyClustersStepSummary -UpdateRing $env:RESOLVED_UPDATE_RING -TotalCount "$(totalCount)" -UpToDateCount "$(upToDateCount)" -NotReadyCount "$(notReadyCount)" 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 78478bb9..3eee24f7 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.73' + GENERATED_AGAINST_MODULE_VERSION: '0.8.74' # 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 8eed01fa..609dabf5 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.73' + value: '0.8.74' # 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 a43127aa..d0675a3a 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.73' + GENERATED_AGAINST_MODULE_VERSION: '0.8.74' # 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 a25137ba..2998a3bb 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.73' + GENERATED_AGAINST_MODULE_VERSION: '0.8.74' # 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 f7532965..fa56748b 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.73' + GENERATED_AGAINST_MODULE_VERSION: '0.8.74' # 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 1af6fba6..86aa14c2 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.73' + value: '0.8.74' # 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 1263ccc7..39a08090 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.73' + value: '0.8.74' # 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 4a734bba..3209a272 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.73' + GENERATED_AGAINST_MODULE_VERSION: '0.8.74' 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 f6f633fb..b879c838 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.73' + GENERATED_AGAINST_MODULE_VERSION: '0.8.74' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): queue-time parameter > pipeline variable # 'REQUIRED_MODULE_VERSION' overridden at queue time > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/docs/appendix-pipelines.md b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/docs/appendix-pipelines.md index 34bdd002..7dcee952 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/docs/appendix-pipelines.md +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/docs/appendix-pipelines.md @@ -111,6 +111,8 @@ The table below is the ground truth for what each shipped YAML does **out of the ## Step 5 - Assess Update Readiness +> **Behaviour change in v0.8.74**: clusters that have already applied every required update are now classified as **Up to Date** (counted separately) instead of being lumped into the Not-Ready bucket. The summary's "Up to date" count is now accurate, the "Not-Ready clusters (review first)" table excludes Up-to-Date and Ready clusters, and the "All clusters detail" table shows a readable `Status` column in place of the old `Ready` boolean. Classification is shared with Step 7 and Step 9 via the `Get-AzLocalClusterReadinessStatus` helper. + | Aspect | Value | |---|---| | **Purpose** | Pre-flight, report-only readiness + blocking-health snapshot for a single `UpdateRing`. **Always succeeds** - per-cluster failures show up as JUnit test failures rather than failing the whole run. | @@ -156,6 +158,8 @@ The table below is the ground truth for what each shipped YAML does **out of the ## Step 7 - Apply Updates +> **Behaviour change in v0.8.74**: the pre-apply readiness gate report (`Export-AzLocalClusterReadinessGateReport`) replaces its binary `Ready?` column with a readable `Status` column so fully-patched clusters now show **Up to Date** instead of a no-entry icon that implied failure. The header line shows a distinct `Up to Date` count and the step emits a new additive `UP_TO_DATE_COUNT` / `UpToDateCount` output (the `READY_COUNT` gate that drives the apply is unchanged). Classification is shared with Step 5 and Step 9 via the `Get-AzLocalClusterReadinessStatus` helper. When the gate finds no clusters ready, the "No Clusters Ready" summary (`Add-AzLocalNoReadyClustersStepSummary`) now renders an Up-to-Date vs Not-Ready breakdown - so an idle run makes clear that already-patched clusters are a healthy steady state (a notice, not a warning, when every cluster is up to date) rather than a fault. + | Aspect | Value | |---|---| | **Purpose** | Apply updates to clusters filtered by `UpdateRing` tag value. The actual mutation step; every other pipeline in this folder is either preparation, monitoring, or reporting. | 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 4565014c..cc0371b2 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.73' + GENERATED_AGAINST_MODULE_VERSION: '0.8.74' 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 dafd80de..68164c4c 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/apply-updates.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/apply-updates.yml @@ -135,7 +135,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.73' + GENERATED_AGAINST_MODULE_VERSION: '0.8.74' # 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). @@ -176,6 +176,11 @@ jobs: outputs: ready_count: ${{ steps.readiness.outputs.READY_COUNT }} total_count: ${{ steps.readiness.outputs.TOTAL_COUNT }} + # v0.8.74: Up-to-Date vs Not-Ready breakdown so the No Clusters Ready + # job can explain WHY there is nothing to apply (already-patched clusters + # are a healthy steady state, not a failure). + up_to_date_count: ${{ steps.readiness.outputs.UP_TO_DATE_COUNT }} + not_ready_count: ${{ steps.readiness.outputs.NOT_READY_COUNT }} # surface the timestamped readiness-artifact name so the downstream # apply-updates job can download it (cross-job step outputs are not visible - # everything passed to the next job has to go through the job 'outputs' map). @@ -536,4 +541,4 @@ jobs: INPUT_UPDATE_RING: ${{ needs.check-readiness.outputs.resolved_update_ring }} run: | Import-Module AzLocal.UpdateManagement -Force - Add-AzLocalNoReadyClustersStepSummary -UpdateRing $env:INPUT_UPDATE_RING -TotalCount "${{ needs.check-readiness.outputs.total_count }}" + Add-AzLocalNoReadyClustersStepSummary -UpdateRing $env:INPUT_UPDATE_RING -TotalCount "${{ needs.check-readiness.outputs.total_count }}" -UpToDateCount "${{ needs.check-readiness.outputs.up_to_date_count }}" -NotReadyCount "${{ needs.check-readiness.outputs.not_ready_count }}" 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 da6da90d..36c76f97 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.73' + GENERATED_AGAINST_MODULE_VERSION: '0.8.74' # 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 c2c06ebe..4fa3ec92 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.73' + GENERATED_AGAINST_MODULE_VERSION: '0.8.74' # 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 f7ecdf5d..3e579b02 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.73' + GENERATED_AGAINST_MODULE_VERSION: '0.8.74' # 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 118b5236..d290bb87 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.73' + GENERATED_AGAINST_MODULE_VERSION: '0.8.74' # 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 1aab4534..9a77c6e2 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.73' + GENERATED_AGAINST_MODULE_VERSION: '0.8.74' # 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 380318e4..5c7d201d 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.73' + GENERATED_AGAINST_MODULE_VERSION: '0.8.74' # 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 53a3520a..194816c7 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.73' + GENERATED_AGAINST_MODULE_VERSION: '0.8.74' # 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 e3ceb68c..82840974 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.73' + GENERATED_AGAINST_MODULE_VERSION: '0.8.74' # 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 53067284..9c981116 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.73' + GENERATED_AGAINST_MODULE_VERSION: '0.8.74' # 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 8a628b24..da7637d7 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.73' + ModuleVersion = '0.8.74' # Supported PSEditions CompatiblePSEditions = @('Desktop', 'Core') @@ -46,6 +46,7 @@ 'Private/Format-AzLocalDurationHuman.ps1', 'Private/Format-AzLocalIncidentBody.ps1', 'Private/Format-AzLocalUpdateRun.ps1', + 'Private/Get-AzLocalClusterReadinessStatus.ps1', 'Private/Get-AzLocalClusterUpdateRuns.ps1', 'Private/Get-AzLocalItsmDedupeKey.ps1', 'Private/Get-AzLocalItsmTriggerDecision.ps1', @@ -54,6 +55,7 @@ 'Private/Get-AzLocalPipelineId.ps1', 'Private/Get-AzLocalPipelineManifest.ps1', 'Private/Get-AzLocalRunEndTime.ps1', + 'Private/Get-AzLocalUpdateRunStepStats.ps1', 'Private/Get-CurrentStepPath.ps1', 'Private/Get-DeepestActiveStep.ps1', 'Private/Get-DeepestErrorMessage.ps1', @@ -307,6 +309,17 @@ # ReleaseNotes of this module ReleaseNotes = @' +## 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 the standalone HTML "Recent Update Run History" report, and removal of dead `target="_blank"` portal-link attrs (GH / ADO step-summary sanitisers strip them - replaced with an explicit Ctrl/Cmd/middle-click tip). No public API or export-count change (still 60). + +- **FIX (reporting)**: a cluster that has already applied every required update was being mis-classified. Step.5 (`Export-AzLocalClusterUpdateReadinessReport`) showed `0` in its "Up to date" summary count and listed fully-patched clusters in the "Not-Ready clusters (review first)" table; Step.7 (`Export-AzLocalClusterReadinessGateReport`) rendered them with a no-entry icon in a binary `Ready?` column, implying failure. Root cause: the "Up to Date" test required `AllAvailableUpdates` to be empty, but a cluster that has installed all updates still lists those (now-Installed) package names there, so the strict test never matched. +- **FIX (Progress column)**: the `Progress` column rendered by the Step.7 in-flight monitor and the standalone HTML "Recent Update Run History" table was always reporting the coarse top-level wrapper count (typically `1/2 steps`) for the whole multi-hour run because Azure Local only exposes two top-level wrapper steps (`Prepare update` + `Start update`) and `Start update` stays `InProgress` end-to-end. Both paths now traverse the full nested step tree via a new private helper (`Get-AzLocalUpdateRunStepStats`) and report leaf-step completion as `M/N steps (P%)` (optionally suffixed with `, K failed`), so the Progress column climbs as the install proceeds and matches how `CurrentStep` already walks the tree via `Get-DeepestActiveStep`. +- **FIX (portal hyperlinks)**: the Cluster / Update portal hyperlinks in the Step.7 (monitor), Step.8 (fleet update status) and Step.10 (fleet health status) step-summary tables always opened in the current tab even though the markdown set `target="_blank"`. GitHub's GFM sanitiser (and the ADO equivalent) strips `target` entirely and forces `rel="nofollow"` - confirmed in the rendered HTML of a real GHA job summary. The dead `target="_blank"` / `rel="noopener"` attributes are now removed (so the emitted markdown matches the rendered DOM), and each affected table is preceded by an explicit tip: "Hold `Ctrl` (or `Cmd` on macOS) when clicking - or middle-click - Cluster or Update links to open them in a new tab." The standalone HTML report (`New-AzLocalFleetStatusHtmlReport`) is unaffected - it ships as a raw `.html` artifact, not a step-summary. +- **NEW (internal)**: `Get-AzLocalClusterReadinessStatus` private helper is now the single source of truth for the readiness priority cascade (UpdateFailed > ActionRequired > HealthFailure > SbeBlocked > InProgress > ReadyForUpdate > UpToDate > NeedsInvestigation). Step.5, Step.7 and Step.9 (`Export-AzLocalFleetUpdateStatusReport`) all classify clusters identically via this helper - previously each re-implemented the logic inline and the definitions had drifted. +- **CHANGE (Step.7)**: the per-cluster readiness table's binary `Ready?` column is replaced with a readable `Status` column (`Ready`, `Up to Date`, `In Progress`, `SBE Prerequisite`, `Health Failure`, `Update Failed`, `Action Required`, `Needs Investigation`). The header now shows a distinct `Up to Date` count and a new `UP_TO_DATE_COUNT` / `UpToDateCount` step output is emitted (additive; existing `READY_COUNT` gate semantics are unchanged). PassThru gains `UpToDateCount`. +- **CHANGE (Step.5)**: the "All clusters detail" table's `Ready` boolean column is replaced with the same readable `Status` label, and the "Not-Ready clusters (review first)" table now excludes Up-to-Date and Ready clusters. +- **CHANGE (Step.7)**: the "No Clusters Ready" summary (`Add-AzLocalNoReadyClustersStepSummary`) gains `-UpToDateCount` / `-NotReadyCount` parameters and renders an Up-to-Date vs Not-Ready breakdown so idle runs show that already-patched clusters are a healthy steady state (a notice, not a warning, when all are up to date). Both apply-updates templates wire the counts through. +- **All bundled pipeline templates** bump `GENERATED_AGAINST_MODULE_VERSION` from `'0.8.73'` to `'0.8.74'`. + ## 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). - **CHANGE**: `Get-AzLocalApplyUpdatesScheduleCycleCalendar` no longer renders a separate "Clusters in ring(s)" column when `-ClusterRingCounts` is supplied. Instead the "Eligible rings" header is relabelled "Eligible rings (cluster count)" and each ring token carries its count inline, e.g. `` `Prod` (9), `Canary` (3) ``. Dead days still render `_(none - dead day)_`. The per-ring projection table keeps its separate "Cluster count" column. @@ -315,41 +328,18 @@ ## Version 0.8.72 - Patch: pipeline-template polish only. Schedule-file author guidance moved OUTSIDE the apply-updates.yml customise marker so it refreshes on existing consumers; single-digit Step.N display names zero-padded (Step.0 -> Step.00 ... Step.9 -> Step.09) so the GitHub Actions sidebar / Azure DevOps list sort in execution order. No public API or export-count change (still 60). -- **FIX**: `apply-updates.yml` (GitHub Actions + Azure DevOps) - the schedule-file author guidance comments were trapped INSIDE the `# BEGIN/END-AZLOCAL-CUSTOMIZE:schedule-triggers` block. Because `Update-AzLocalPipelineExample` preserves the marker body verbatim from the consumer's file, any correction to that guidance (e.g. the v0.8.71 `.github` -> `config` schedule-path fix) could never reach an already-deployed consumer. All author guidance is now ABOVE the marker; the marker body holds only the trigger directive (placeholder comment on GH, `trigger: none` on ADO). -- **CHANGE (display only)**: single-digit pipeline step numbers zero-padded to two digits in all display names / titles - GitHub Actions workflow `name:` fields, Azure DevOps `name:` / stage `displayName:` labels, and the `# Step.N - ` header comments (`Step.0` -> `Step.00` ... `Step.9` -> `Step.09`; `Step.10` unchanged). The GitHub Actions sidebar and Azure DevOps pipelines list sort alphabetically by display name, so `Step.10` previously sorted between `Step.1` and `Step.2`. Functional identifiers (artifact names `azlocal-step.N-*`) and cross-reference prose are unchanged. +- **FIX**: `apply-updates.yml` (GitHub Actions + Azure DevOps) - the schedule-file author guidance comments were trapped INSIDE the `# BEGIN/END-AZLOCAL-CUSTOMIZE:schedule-triggers` block, so corrections could never reach an already-deployed consumer. All author guidance is now ABOVE the marker; the marker body holds only the trigger directive. +- **CHANGE (display only)**: single-digit pipeline step numbers zero-padded to two digits in all display names / titles (`Step.0` -> `Step.00` ... `Step.9` -> `Step.09`; `Step.10` unchanged) so the GitHub Actions sidebar and Azure DevOps list sort in execution order. Functional identifiers (artifact names) are unchanged. - **All bundled pipeline templates** bump `GENERATED_AGAINST_MODULE_VERSION` from `'0.8.71'` to `'0.8.72'`. -## Version 0.8.71 - Patch: JUnit export strict-mode crash fix + sideload schedule-path default corrected + pipeline doc-string filenames de-numbered. No public API or export-count change (still 60). - -- **FIX (production)**: `Export-ResultsToJUnitXml` no longer throws `The property 'CurrentState' cannot be found on this object` under `Set-StrictMode -Version Latest` when an apply run emits an `UpdateStarted` success row that lacks `CurrentState`/`Progress`. All bare property reads are now guarded with `PSObject.Properties[...]`. -- **FIX**: GitHub Actions `sideload-updates.yml` `APPLY_UPDATES_SCHEDULE_PATH` default corrected from `./.github/apply-updates-schedule.yml` to `./config/apply-updates-schedule.yml`, so it matches where `Copy-AzLocalPipelineExample` drops the starter (alongside the auth-map + catalog). -- **FIX**: De-numbered stale `Step.N_*.yml` filename references in pipeline header comments / input descriptions (apply-updates-schedule-audit, assess-update-readiness, authentication-test, and the schedule starter example). The files were renamed in v0.8.7; these doc strings now name the current files (`apply-updates.yml`, `authentication-test.yml`, etc.). -- **All bundled pipeline templates** bump `GENERATED_AGAINST_MODULE_VERSION` from `'0.8.7'` to `'0.8.71'`. - -## Version 0.8.7 - On-prem solution-update sideloading automation (new Step.6 pipeline + 5 new Public cmdlets) + pipeline filenames de-numbered (rename-aware Update) + breaking display-step renumber. Module export count grows 55 -> 60. - -Adds opt-in, off-by-default on-prem sideloading for Azure Local clusters that cannot pull solution updates from Azure directly. A new self-hosted Step.6 pipeline (`sideload-updates.yml`) robocopies update media to each cluster's import share, verifies the SHA256 over WinRM, runs Add-SolutionUpdate, and flips `UpdateSideloaded=True` for the downstream apply. It is driven as a re-entrant state machine on a frequent CRON; the multi-hour copy runs in a detached Windows Scheduled Task so no single pipeline run is long-lived. +## Version 0.8.71 - Patch: production strict-mode crash fix in `Export-ResultsToJUnitXml` (`UpdateStarted` success row lacked `CurrentState`/`Progress`); GitHub Actions `sideload-updates.yml` schedule-path default corrected `./.github/` -> `./config/`; stale `Step.N_*.yml` filename references in pipeline doc strings de-numbered. No public API or export-count change (still 60). See CHANGELOG for the full v0.8.71 entry. -- **NEW Public cmdlets (5)**: `Update-AzLocalSideloadCatalog`, `Resolve-AzLocalSideloadPlan`, `Invoke-AzLocalSideloadUpdate`, `Export-AzLocalSideloadStatusReport`, `Add-AzLocalSideloadStepSummary`. Export count grows 55 -> 60. -- **NEW Step.6 sideload pipeline** (`sideload-updates.yml`, GitHub Actions + Azure DevOps), opt-in via the `SIDELOAD_UPDATES` repository variable; targets a self-hosted runner (GH) / self-hosted agent pool (ADO) labelled `azlocal-sideload`. Catalog (Solution + OEM SBE packageType), shared-UNC state, Key Vault-sourced AD credential for WinRM, and robocopy throttling are documented in Automation-Pipeline-Examples/docs/sideload.md + sideload-robocopy.md. -- **BREAKING - pipeline files de-numbered.** The `Step.N_` filename prefix is removed (e.g. `Step.7_apply-updates.yml` -> `apply-updates.yml`); the in-pipeline `Step.N - ` display names are unchanged. Stable filenames mean future reorders are pure display-name edits. `Update-AzLocalPipelineExample` now matches each destination pipeline by a stable logical id (embedded `# AZLOCAL-PIPELINE-ID:` marker, with legacy-filename aliases) and AUTO-RENAMES any older `Step.N_*.yml` to the new name while preserving your `BEGIN/END-AZLOCAL-CUSTOMIZE` CRON edits (emits a `RenamedFrom` result + a GH/ADO required-check warning). -- **BREAKING - display-step renumber** to make room for sideload at Step.6: apply-updates 6->7, monitor-updates 7->8, fleet-update-status 8->9, fleet-health-status 9->10. -- **Step.1 / Step.2 sideload-aware**: inventory can emit the `UpdateAuthAccountId` column (`-IncludeSideloadColumns`, auto-enabled from `SIDELOAD_UPDATES`); tag management can set `UpdateAuthAccountId` from CSV. Byte-identical output when sideload is off. -- **Step.3 advisor** can emit a recommended sideload CRON (apply window minus `SIDELOAD_LEAD_DAYS`) when sideloading is enabled. -- **All bundled pipeline templates** bump `GENERATED_AGAINST_MODULE_VERSION` from `'0.8.6'` to `'0.8.7'`. +## Version 0.8.7 - On-prem solution-update sideloading automation (new Step.6 pipeline + 5 new Public cmdlets) + pipeline filenames de-numbered (rename-aware Update) + breaking display-step renumber. Module export count grows 55 -> 60. See CHANGELOG for the full v0.8.7 entry. ## Version 0.8.6 - Step.3 cycle-calendar enrichment (per-day CRON + UpdateStartWindow tag coverage) + six v0.8.5 pipeline regression fixes (Step.0/3/4/6/9) + new Pester static-audit guards. See CHANGELOG for the full v0.8.6 entry. ## Version 0.8.5 - New Public cmdlet `Get-AzLocalApplyUpdatesScheduleCycleCalendar` + Step.6 manual schedule-file inputs + Step.3 cycle-calendar regression fix + per-ring cluster-count column + full thin-YAML port of all 10 Step pipelines (14 new Public cmdlets). Module export count grows 35 -> 55. See CHANGELOG for the full v0.8.5 entry. -## Version 0.8.3 - Test-AzLocalApplyUpdatesScheduleCoverage Step.3 advisor accuracy + readability fixes: Recommend now diff-prunes against `-PipelineYamlPath`, Step.3 yml `pipeline_path` REQUIRED, Allow-list heading reframed, closing-fence typo fixed - -## Version 0.8.2 - Test-AzLocalApplyUpdatesScheduleCoverage operator-UX release: -View Recommend snippet embeds `# All cron times below are UTC` comment + `Indent tip` blockquote; -View Audit `NoWindowTag` row now names affected clusters grouped by `UpdateRing` + sorts AFTER Covered; Step.3 GH/ADO Allow-list section trimmed; five new internal pipeline-host helpers (Get/Set/Add/Write-AzLocalPipeline*) laid down as foundations for the upcoming executable-YAML refactor - -## Version 0.8.1 - Test-AzLocalApplyUpdatesScheduleCoverage -View Recommend GH snippet emits ONLY the `schedule:` block (no `on:` / `workflow_dispatch:` lines) so it can be pasted straight into Step.6_apply-updates.yml without producing a duplicate-key YAML error - -## Version 0.8.0 - Step.7 form-default regressions fixed (criticalElapsedDays 7->3, updateRing Wave1->empty) + Pii-Guard.Tests.ps1 (repo-hygiene guard) + Publish-Module.ps1 excludes maintainer-only RELEASE-PROCESS.md - ## Version 0.7.99 - Property/Summary renames (AvailableUpdates -> AllAvailableUpdates, AvailableUpdatesCount -> ActionableUpdatesCount, Ready/NotReady Summary -> ReadyForUpdate/UpToDate/NotReadyForUpdate) + Step.7 CRITICAL elapsed-days 7->3 + artifact zip names prefixed with step.X- For full v0.7.x and v0.8.x release notes see: diff --git a/AzLocal.UpdateManagement/AzLocal.UpdateManagement.psm1 b/AzLocal.UpdateManagement/AzLocal.UpdateManagement.psm1 index e5885012..ed5017ce 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.73' +$script:ModuleVersion = '0.8.74' $script:DefaultApiVersion = '2025-10-01' $script:DefaultLogFolder = Join-Path -Path $env:ProgramData -ChildPath 'AzLocal.UpdateManagement' diff --git a/AzLocal.UpdateManagement/CHANGELOG.md b/AzLocal.UpdateManagement/CHANGELOG.md index cc47d724..f0afa63f 100644 --- a/AzLocal.UpdateManagement/CHANGELOG.md +++ b/AzLocal.UpdateManagement/CHANGELOG.md @@ -5,6 +5,28 @@ 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.74] - 2026-06-12 + +Consistency release for the cluster readiness reports. A cluster that has already applied every required update is now classified and labelled identically across the Step.5 readiness report, the Step.7 readiness gate, and the Step.9 fleet update-status report. Also corrects the `Progress` column in the Step.7 in-flight monitor and the standalone HTML report's "Recent Update Run History" table, which had been reporting a near-constant `1/2 steps` for the whole multi-hour run, and drops the dead `target="_blank"` / `rel="noopener"` attributes from every Step.7 / Step.8 / Step.10 portal-link cell (GitHub Actions and Azure DevOps step-summary sanitisers strip them anyway) in favour of an explicit Ctrl/Cmd/middle-click tip rendered above the affected tables. No public API or export-count change (still 60). + +### Fixed + +- **Fully-patched clusters mis-classified as "not ready" in Step.5 and Step.7** - a cluster that had successfully installed every required update showed `0` in the Step.5 (`Export-AzLocalClusterUpdateReadinessReport`) "Up to date" summary count and appeared in its "Not-Ready clusters (review first)" table, and rendered with a no-entry icon in the Step.7 (`Export-AzLocalClusterReadinessGateReport`) binary `Ready?` column - both implying failure. Root cause: the "Up to Date" determination required the `AllAvailableUpdates` collection to be empty, but a cluster that has installed all updates still lists those (now-Installed) package names there, so the test never matched a fully-patched cluster. Step.9 already classified these correctly via its priority cascade; Step.5/Step.7 now share that exact logic. +- **`Progress` column always reported the coarse top-level wrapper (`1/2 steps`) across the Step.7 monitor and the standalone HTML "Recent Update Run History" report.** Real Azure Local update runs expose only two top-level wrapper steps in `properties.progress.steps` (`Prepare update` + `Start update`), and `Start update` stays `InProgress` for the entire run. Counting that level alone reported `1/2 steps` (or `0/2 steps`) for multi-hour, ~150-leaf solution updates regardless of real progress. Both reporting paths now walk the full nested step tree via a new private helper (`Get-AzLocalUpdateRunStepStats`) and report leaf-step completion as `M/N steps (P%)`, optionally suffixed with `, K failed` when leaf-level errors are present - mirroring how `CurrentStep` already traverses the tree via `Get-DeepestActiveStep`. Affects the Step.7 monitor formatter (`Format-AzLocalUpdateRun`) and the fleet-status data collector (`Get-AzLocalFleetStatusData`, which feeds the HTML report's `Recent Update Run History` table and the `latest-runs.csv`). +- **Portal-link `target="_blank"` / `rel="noopener"` were silently stripped by the step-summary sanitiser**, so the Cluster / Update hyperlinks rendered in the Step.7 (`Export-AzLocalUpdateRunMonitorReport`) in-flight + failed-runs tables, Step.8 (`Export-AzLocalFleetUpdateStatusReport`) Update Run History table, and Step.10 (`Export-AzLocalFleetHealthStatusReport`) Fleet Health Overview / By-Reason / Detailed Results tables always opened in the current tab even though the markdown source asked for a new tab. Confirmed in the rendered HTML of a real GitHub Actions job summary: GitHub's GFM sanitiser drops `target` entirely and forces `rel="nofollow"` (also documented behaviour for `Set-Content` / step-summary markdown). The dead attributes are now removed (so the emitted markdown matches the rendered DOM) and each affected table is preceded by a short tip - **"Hold `Ctrl` (or `Cmd` on macOS) when clicking - or middle-click - Cluster or Update links to open them in a new tab. (GitHub markdown strips `target="_blank"`.)"** The standalone HTML report (`New-AzLocalFleetStatusHtmlReport`) is unaffected; it is written as a raw `.html` artifact, not a step-summary, so its `target="_blank"` already works as intended in any browser. + +### Added + +- **`Get-AzLocalClusterReadinessStatus` private helper** - the single source of truth for the readiness priority cascade (UpdateFailed > ActionRequired > HealthFailure > SbeBlocked > InProgress > ReadyForUpdate > UpToDate > NeedsInvestigation). Step.5, Step.7 and Step.9 (`Export-AzLocalFleetUpdateStatusReport`) now all classify clusters through this one helper; previously each report re-implemented the logic inline and the definitions had drifted. +- **Step.7 `UP_TO_DATE_COUNT` / `UpToDateCount` step output** - additive; the existing `READY_COUNT` gate semantics are unchanged. `Export-AzLocalClusterReadinessGateReport -PassThru` now also returns `UpToDateCount`. +- **Step.7 "No Clusters Ready" summary now explains why** - `Add-AzLocalNoReadyClustersStepSummary` accepts the new `-UpToDateCount` / `-NotReadyCount` parameters and renders an Up-to-Date vs Not-Ready breakdown table, so an idle apply-updates run makes clear that already-patched clusters are a healthy steady state (not a failure) and points to the per-cluster `Status` / `Blocking Reasons` detail. When every discovered cluster is already up to date the run now logs an informational notice instead of a warning. Both the GitHub Actions and Azure DevOps `apply-updates` templates wire the two new counts through from the readiness gate. + +### Changed + +- **Step.7 per-cluster table now has a `Status` column instead of a binary `Ready?` column** - it renders readable labels (`Ready`, `Up to Date`, `In Progress`, `SBE Prerequisite`, `Health Failure`, `Update Failed`, `Action Required`, `Needs Investigation`) and the header line now shows a distinct `Up to Date` count. +- **Step.5 "All clusters detail" table `Ready` boolean column replaced with the same readable `Status` label**, and the "Not-Ready clusters (review first)" table now excludes both Up-to-Date and Ready clusters. +- All bundled pipeline templates bump `GENERATED_AGAINST_MODULE_VERSION` from `'0.8.73'` to `'0.8.74'`. + ## [0.8.73] - 2026-06-11 Cycle-calendar refinement. The Step.3 apply-updates schedule audit now shows the per-ring cluster count INLINE in the "Eligible rings" column instead of as a separate column, and the Step.3 pipeline render path actually populates those counts (previously the counts never reached the rendered calendar). No public API or export-count change (still 60). diff --git a/AzLocal.UpdateManagement/Private/Format-AzLocalUpdateRun.ps1 b/AzLocal.UpdateManagement/Private/Format-AzLocalUpdateRun.ps1 index e2904441..10a74150 100644 --- a/AzLocal.UpdateManagement/Private/Format-AzLocalUpdateRun.ps1 +++ b/AzLocal.UpdateManagement/Private/Format-AzLocalUpdateRun.ps1 @@ -43,11 +43,30 @@ function Format-AzLocalUpdateRun { $errorMessage = '' if ($props.progress -and $props.progress.steps) { $steps = $props.progress.steps - # Wrap in @() so .Count returns 0 (not $null) when no step matches -- previously the - # "completed" numerator rendered blank for runs that failed before any step succeeded. - $completedSteps = @($steps | Where-Object { $_.status -eq "Success" }).Count - $totalSteps = @($steps).Count - $progress = "$completedSteps/$totalSteps steps" + # Progress must reflect the DEEP nested step tree, not the top-level wrapper steps. + # Real Azure Local update runs expose only ~2 coarse top-level steps ("Prepare update" + # Success + "Start update" InProgress/Error), so counting the top level alone reported a + # near-constant "1/2 steps" for the entire multi-hour run regardless of real progress + # (validated against live ARM data: a 7-level / 167-leaf run still showed top-level 1/2). + # Get-AzLocalUpdateRunStepStats walks to the leaf steps (the atomic work units) so the + # numerator climbs as the install proceeds, mirroring how CurrentStep already traverses + # the full tree via Get-DeepestActiveStep. + $stepStats = Get-AzLocalUpdateRunStepStats -Steps $steps + if ($stepStats.TotalLeaf -gt 0) { + $pct = [int][math]::Round((($stepStats.CompletedLeaf / $stepStats.TotalLeaf) * 100)) + $progress = "$($stepStats.CompletedLeaf)/$($stepStats.TotalLeaf) steps ($pct%)" + if ($stepStats.FailedLeaf -gt 0) { + $progress = "$progress, $($stepStats.FailedLeaf) failed" + } + } + else { + # Defensive fallback: no leaf steps resolved (e.g. an empty nested array). Use the + # top-level Success count so the column is never blank. Guarded property read keeps + # this safe under Set-StrictMode -Version Latest. + $completedSteps = @($steps | Where-Object { $_.PSObject.Properties['status'] -and $_.status -eq 'Success' }).Count + $totalSteps = @($steps).Count + $progress = "$completedSteps/$totalSteps steps" + } # Resolve the deepest InProgress/Error/Failed step in the nested progress tree. # The top-level steps array only exposes a coarse wrapper step (e.g. "Start update") diff --git a/AzLocal.UpdateManagement/Private/Get-AzLocalClusterReadinessStatus.ps1 b/AzLocal.UpdateManagement/Private/Get-AzLocalClusterReadinessStatus.ps1 new file mode 100644 index 00000000..a0b38bc8 --- /dev/null +++ b/AzLocal.UpdateManagement/Private/Get-AzLocalClusterReadinessStatus.ps1 @@ -0,0 +1,66 @@ +function Get-AzLocalClusterReadinessStatus { + ######################################## + <# + .SYNOPSIS + Classifies a single Get-AzLocalClusterUpdateReadiness row into exactly + one readiness-status bucket using the canonical priority cascade. + .DESCRIPTION + v0.8.74 single source of truth for the "what state is this cluster in" + classification shared by Step.5 (Export-AzLocalClusterUpdateReadinessReport), + Step.7 (Export-AzLocalClusterReadinessGateReport) and Step.9 + (Export-AzLocalFleetUpdateStatusReport). + + Prior to this helper each step re-implemented the bucket logic inline, + and the "Up to Date" definition drifted: Step.9's rendered Primary Status + table used a priority cascade (correct), while Step.5 and the JSON exports + used a stricter test that also required AllAvailableUpdates to be empty. + That strict test silently returned zero "Up to Date" clusters in + production because a cluster that has applied all updates still carries + the already-Installed update names in AllAvailableUpdates - so up-to-date + clusters were mis-bucketed as "Not Ready" (Step.5) or shown with a + no-entry icon (Step.7), implying failure. + + Every consumer now calls this helper so the classification is identical + across all three steps. + + Priority cascade (first match wins, cluster counted exactly once): + UpdateFailed > ActionRequired > HealthFailure > SbeBlocked > + InProgress > ReadyForUpdate > UpToDate > NeedsInvestigation + .PARAMETER ReadinessRow + A single PSCustomObject row as emitted by Get-AzLocalClusterUpdateReadiness. + Optional properties are read defensively for Set-StrictMode -Version Latest. + .OUTPUTS + [string] one of: + UpdateFailed, ActionRequired, HealthFailure, SbeBlocked, + InProgress, ReadyForUpdate, UpToDate, NeedsInvestigation + .NOTES + Author : AzLocal.UpdateManagement + Version : 0.8.74 + #> + ######################################## + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [psobject]$ReadinessRow + ) + + Set-StrictMode -Version Latest + + # Strict-mode-safe optional property reads. Different code paths in + # Get-AzLocalClusterUpdateReadiness emit rows with the same shape, but + # external callers (and the error/catch row) may not, so guard every read. + $updateState = if ($ReadinessRow.PSObject.Properties['UpdateState'] -and $ReadinessRow.UpdateState) { [string]$ReadinessRow.UpdateState } else { '' } + $healthState = if ($ReadinessRow.PSObject.Properties['HealthState'] -and $ReadinessRow.HealthState) { [string]$ReadinessRow.HealthState } else { '' } + $hasPrereq = if ($ReadinessRow.PSObject.Properties['HasPrerequisiteUpdates'] -and $ReadinessRow.HasPrerequisiteUpdates) { [string]$ReadinessRow.HasPrerequisiteUpdates } else { '' } + $readyForUpdate = if ($ReadinessRow.PSObject.Properties['ReadyForUpdate']) { [bool]$ReadinessRow.ReadyForUpdate } else { $false } + + if ($updateState -in @('Failed', 'UpdateFailed', 'NeedsAttention')) { return 'UpdateFailed' } + elseif ($updateState -eq 'PreparationFailed') { return 'ActionRequired' } + elseif ($healthState -eq 'Failure') { return 'HealthFailure' } + elseif ($hasPrereq) { return 'SbeBlocked' } + elseif ($updateState -in @('UpdateInProgress', 'PreparationInProgress')) { return 'InProgress' } + elseif ($readyForUpdate -eq $true) { return 'ReadyForUpdate' } + elseif ($updateState -in @('UpToDate', 'AppliedSuccessfully')) { return 'UpToDate' } + else { return 'NeedsInvestigation' } +} diff --git a/AzLocal.UpdateManagement/Private/Get-AzLocalUpdateRunStepStats.ps1 b/AzLocal.UpdateManagement/Private/Get-AzLocalUpdateRunStepStats.ps1 new file mode 100644 index 00000000..c2b6273c --- /dev/null +++ b/AzLocal.UpdateManagement/Private/Get-AzLocalUpdateRunStepStats.ps1 @@ -0,0 +1,74 @@ +function Get-AzLocalUpdateRunStepStats { + <# + .SYNOPSIS + Recursively walks the update run step hierarchy and returns leaf-step completion statistics. + .DESCRIPTION + Azure Local update runs expose progress as a deeply nested step tree (observed up to + 7-9 levels deep with 150+ leaf steps on real solution updates). The top-level + progress.steps array only ever holds a couple of coarse wrapper steps - typically + "Prepare update" (Success) and "Start update" (InProgress/Error) - so counting the + TOP level alone reports a near-constant "1/2 steps" for the entire multi-hour run, + regardless of how far the install has actually progressed. + + This helper traverses the full tree and tallies the LEAF steps (those with no child + steps). Leaf steps are the atomic units of work, so completed-vs-total leaves gives a + meaningful progress signal that climbs as the run proceeds. Parent/wrapper steps are + deliberately excluded from the totals to avoid double-counting (a parent only reports + Success once all of its children have). + + Status mapping (case-insensitive): + * Success / Skipped -> Completed (Skipped counts as done so a finished run reaches 100%) + * Error / Failed -> Failed + * InProgress -> InProgress + * anything else / blank (NotStarted/pending) -> counted in Total only + + Companion to Get-DeepestActiveStep / Get-CurrentStepPath / Get-DeepestErrorMessage, + which already traverse the same tree for the CurrentStep / ErrorMessage columns. + .NOTES + Returns a PSCustomObject with TotalLeaf / CompletedLeaf / InProgressLeaf / FailedLeaf + (all [int], all 0 when no steps are supplied). + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $false)] + [array]$Steps, + + [Parameter(Mandatory = $false)] + [int]$MaxDepth = 20 + ) + + $stats = [PSCustomObject]@{ + TotalLeaf = 0 + CompletedLeaf = 0 + InProgressLeaf = 0 + FailedLeaf = 0 + } + + if (-not $Steps -or @($Steps).Count -eq 0 -or $MaxDepth -le 0) { return $stats } + + foreach ($step in $Steps) { + $children = if ($step.PSObject.Properties['steps']) { $step.steps } else { $null } + + if ($children -and @($children).Count -gt 0) { + $childStats = Get-AzLocalUpdateRunStepStats -Steps $children -MaxDepth ($MaxDepth - 1) + $stats.TotalLeaf += $childStats.TotalLeaf + $stats.CompletedLeaf += $childStats.CompletedLeaf + $stats.InProgressLeaf += $childStats.InProgressLeaf + $stats.FailedLeaf += $childStats.FailedLeaf + continue + } + + # Leaf step. + $stats.TotalLeaf++ + $status = if ($step.PSObject.Properties['status'] -and $step.status) { [string]$step.status } else { '' } + switch -Regex ($status) { + '^(Success|Skipped)$' { $stats.CompletedLeaf++; break } + '^(Error|Failed)$' { $stats.FailedLeaf++; break } + '^InProgress$' { $stats.InProgressLeaf++; break } + default { <# NotStarted / pending / blank: counted in TotalLeaf only #> } + } + } + + return $stats +} diff --git a/AzLocal.UpdateManagement/Public/Add-AzLocalNoReadyClustersStepSummary.ps1 b/AzLocal.UpdateManagement/Public/Add-AzLocalNoReadyClustersStepSummary.ps1 index ae51b8ff..8c89d6f7 100644 --- a/AzLocal.UpdateManagement/Public/Add-AzLocalNoReadyClustersStepSummary.ps1 +++ b/AzLocal.UpdateManagement/Public/Add-AzLocalNoReadyClustersStepSummary.ps1 @@ -8,23 +8,35 @@ function Add-AzLocalNoReadyClustersStepSummary { block in both Step.6 pipelines (GH job no-clusters-ready, ADO stage NoClustersReady). - Behaviour matches the prior inline block: - - Emits the same Markdown headings and bullet lists. - - When -TotalCount is 0: renders the "No clusters found with - UpdateRing tag value 'X'" message. - - When -TotalCount > 0: renders the "Found N cluster(s) ... but none - are ready" message + "Possible reasons" bullet list. - - On GitHub Actions ONLY: also emits Write-Warning with the same - text the prior inline block did (matches the GH no-clusters-ready - run block's `Write-Warning "No clusters ready for update in ring - ''"` final line so logs are identical). - - On Azure DevOps ONLY: emits the same task.logissue warning the - prior inline block did. + Behaviour: + - When -UpdateRing is empty/whitespace (the schedule resolver found + NO row matching this firing's date/time): renders an informational + "No UpdateRing Scheduled for This Firing" section explaining this + is an EXPECTED idle cron tick (not a misconfiguration), and emits a + plain informational log line - NOT a warning - so the run does not + surface a spurious warning annotation. (v0.8.74) + - When -UpdateRing is non-empty and -TotalCount is 0: renders the + "No clusters found with UpdateRing tag value 'X'" message. + - When -UpdateRing is non-empty and -TotalCount > 0: renders the + "Found N cluster(s) ... but none are ready" message + "Possible + reasons" bullet list. + - For the two non-empty-ring cases above, emits a Write-Warning + (GitHub / Local) or task.logissue warning (Azure DevOps) with the + ring name, matching the prior inline block byte-for-byte. .PARAMETER UpdateRing Resolved UpdateRing label (string; may be empty for schedule-no-row case). .PARAMETER TotalCount Total count of clusters discovered by the readiness query (string- or-int accepted). 0 = no clusters found at all. + .PARAMETER UpToDateCount + Count of discovered clusters that are already fully patched (the + readiness gate's Up-to-Date bucket). Optional; pass -1 (the default) + when the caller does not have the breakdown - the summary then falls + back to the generic "possible reasons" list. (v0.8.74) + .PARAMETER NotReadyCount + Count of discovered clusters that are held back and need attention + before they can update. Optional; pass -1 (the default) when the + caller does not have the breakdown. (v0.8.74) .PARAMETER SummaryFileName Per-task markdown filename (ADO/Local). Default 'azlocal-step6-no-ready-summary.md'. @@ -45,6 +57,12 @@ function Add-AzLocalNoReadyClustersStepSummary { [Parameter(Mandatory = $false)] [object]$TotalCount = 0, + [Parameter(Mandatory = $false)] + [object]$UpToDateCount = -1, + + [Parameter(Mandatory = $false)] + [object]$NotReadyCount = -1, + [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$SummaryFileName = 'azlocal-step6-no-ready-summary.md', @@ -58,35 +76,124 @@ function Add-AzLocalNoReadyClustersStepSummary { $pipelineHost = Get-AzLocalPipelineHost $headingLevel = if ($pipelineHost -eq 'AzureDevOps') { '#' } else { '##' } $totalInt = [int]([string]$TotalCount) + # Defensive parse: a count may arrive as an empty string when an upstream + # pipeline variable / job output was not set (e.g. an unresolved ADO + # $(macro)). Treat any non-integer value as -1 (unknown) so the summary + # falls back to the generic message instead of throwing. + $parseCount = { + param($value) + $parsed = 0 + if ([int]::TryParse([string]$value, [ref]$parsed)) { return $parsed } + return -1 + } + $upToDateInt = & $parseCount $UpToDateCount + $notReadyInt = & $parseCount $NotReadyCount + $haveBreakdown = ($upToDateInt -ge 0 -or $notReadyInt -ge 0) + $ringIsEmpty = [string]::IsNullOrWhiteSpace($UpdateRing) + + $checkChar = [string][char]0x2705 # green check + $warnChar = [string][char]0x26A0 + [string][char]0xFE0F # warning sign $sb = New-Object System.Text.StringBuilder - [void]$sb.AppendLine("$headingLevel No Clusters Ready for Update") - [void]$sb.AppendLine() - [void]$sb.AppendLine("**Target UpdateRing:** $UpdateRing") - [void]$sb.AppendLine() - if ($totalInt -eq 0) { - [void]$sb.AppendLine("No clusters found with UpdateRing tag value '$UpdateRing'") + if ($ringIsEmpty) { + # v0.8.74: the schedule resolver returned NO ring for this firing + # (e.g. a cron tick on a day/cycleWeek with no schedule row). This is + # an EXPECTED idle run, not a "no clusters found" failure - render an + # informational section that says so rather than the misleading + # "No clusters found with UpdateRing tag value ''" message. + [void]$sb.AppendLine("$headingLevel No UpdateRing Scheduled for This Firing") + [void]$sb.AppendLine() + [void]$sb.AppendLine("This scheduled run did not match any row in the apply-updates schedule for the current date/time, so there is no UpdateRing to process. **This is expected** on cron firings that fall outside your configured maintenance windows - no action is required and no clusters were queried.") + [void]$sb.AppendLine() + [void]$sb.AppendLine('- Apply-updates was skipped cleanly (the readiness gate reported ready_count=0).') + [void]$sb.AppendLine('- See the **Resolve UpdateRing from schedule** step log for the cycleWeek / dayOfWeek that was evaluated and why no schedule row matched.') + [void]$sb.AppendLine('- To change which days trigger updates, edit your `apply-updates-schedule.yml`.') } else { - [void]$sb.AppendLine("Found $totalInt cluster(s) with UpdateRing='$UpdateRing', but none are ready for updates.") + [void]$sb.AppendLine("$headingLevel No Clusters Ready for Update") [void]$sb.AppendLine() - [void]$sb.AppendLine('Possible reasons:') - [void]$sb.AppendLine('- Clusters may already be up to date') - [void]$sb.AppendLine('- Updates may be in progress') - [void]$sb.AppendLine('- Clusters may have health check failures') + [void]$sb.AppendLine("**Target UpdateRing:** $UpdateRing") [void]$sb.AppendLine() - [void]$sb.AppendLine('Download the readiness report artifact for details.') + + if ($totalInt -eq 0) { + [void]$sb.AppendLine("No clusters found with UpdateRing tag value '$UpdateRing'") + } + elseif ($haveBreakdown) { + # v0.8.74: clusters were found but none are ready to START a new + # update. Break the outcome down so "no clusters ready" is not + # alarming - clusters that are already fully patched are a HEALTHY + # steady state, not a failure. Per-cluster "why" lives in the + # Check Update Readiness table + readiness-report.csv (re-deriving + # status from the CSV here is unsafe - imported booleans are + # strings, e.g. [bool]'False' is $true). + $udShown = if ($upToDateInt -ge 0) { $upToDateInt } else { 0 } + $nrShown = if ($notReadyInt -ge 0) { $notReadyInt } else { $totalInt - $udShown } + + if ($nrShown -le 0 -and $udShown -gt 0) { + [void]$sb.AppendLine("All $totalInt cluster(s) tagged UpdateRing='$UpdateRing' are already up to date - there is nothing to apply. This is a healthy steady state, not a failure.") + } + else { + [void]$sb.AppendLine("Found $totalInt cluster(s) tagged UpdateRing='$UpdateRing'. None are ready to start a new update right now.") + } + [void]$sb.AppendLine() + [void]$sb.AppendLine('| Outcome | Clusters |') + [void]$sb.AppendLine('|---|---|') + [void]$sb.AppendLine(("| {0} Up to Date (already fully patched - no action needed) | {1} |" -f $checkChar, $udShown)) + [void]$sb.AppendLine(("| {0} Not Ready (needs attention before updating) | {1} |" -f $warnChar, $nrShown)) + [void]$sb.AppendLine() + if ($nrShown -gt 0) { + [void]$sb.AppendLine("**Not Ready** clusters were held back for one or more of: an update already in progress, a pending SBE / prerequisite update, or a blocking health-check failure. See the per-cluster **Status** and **Blocking Reasons** columns in the **Check Update Readiness** summary (or the ``readiness-report.csv`` artifact) for the exact reason on each cluster.") + } + else { + [void]$sb.AppendLine("See the **Check Update Readiness** summary table (or the ``readiness-report.csv`` artifact) for the per-cluster detail.") + } + } + else { + # Fallback when the caller did not supply the Up-to-Date / Not-Ready + # breakdown (older callers / -UpToDateCount and -NotReadyCount left + # at the -1 default). + [void]$sb.AppendLine("Found $totalInt cluster(s) with UpdateRing='$UpdateRing', but none are ready for updates.") + [void]$sb.AppendLine() + [void]$sb.AppendLine('Possible reasons:') + [void]$sb.AppendLine('- Clusters may already be up to date') + [void]$sb.AppendLine('- Updates may be in progress') + [void]$sb.AppendLine('- Clusters may have health check failures') + [void]$sb.AppendLine() + [void]$sb.AppendLine('Download the readiness report artifact for details.') + } } $summaryPath = Add-AzLocalPipelineStepSummary -Markdown $sb.ToString() -SummaryFileName $SummaryFileName - # Preserve per-host log surfacing of the warning (byte-for-byte equivalent - # to the prior inline run-block). - switch ($pipelineHost) { - 'GitHub' { Write-Warning "No clusters ready for update in ring '$UpdateRing'" } - 'AzureDevOps' { Write-Host "##vso[task.logissue type=warning]No clusters are ready for updates in ring '$UpdateRing'" } - default { Write-Warning "No clusters ready for update in ring '$UpdateRing'" } + # Per-host log surfacing. For a non-empty ring with no ready clusters this + # is a genuine warning. For the empty-ring (no schedule row matched) case + # it is an EXPECTED idle firing, so emit a plain informational line - NOT a + # warning - to avoid a spurious warning annotation on the run. (v0.8.74) + # Likewise, when EVERY discovered cluster is already up to date (the + # breakdown shows zero Not-Ready) that is a healthy steady state, so emit a + # notice rather than a warning. (v0.8.74) + $allUpToDate = ($haveBreakdown -and ($notReadyInt -le 0) -and ($upToDateInt -gt 0)) + if ($ringIsEmpty) { + switch ($pipelineHost) { + 'GitHub' { Write-Host "::notice title=No UpdateRing scheduled for this firing::No schedule row matched the current date/time - apply-updates skipped cleanly (expected)." } + 'AzureDevOps' { Write-Host "No UpdateRing scheduled for this firing - apply-updates skipped cleanly (expected). No schedule row matched the current date/time." } + default { Write-Host "[notice] No UpdateRing scheduled for this firing - apply-updates skipped cleanly (expected)." } + } + } + elseif ($allUpToDate) { + switch ($pipelineHost) { + 'GitHub' { Write-Host "::notice title=All clusters up to date::All clusters in ring '$UpdateRing' are already up to date - nothing to apply (expected)." } + 'AzureDevOps' { Write-Host "All clusters in ring '$UpdateRing' are already up to date - nothing to apply (expected)." } + default { Write-Host "[notice] All clusters in ring '$UpdateRing' are already up to date - nothing to apply (expected)." } + } + } + else { + switch ($pipelineHost) { + 'GitHub' { Write-Warning "No clusters ready for update in ring '$UpdateRing'" } + 'AzureDevOps' { Write-Host "##vso[task.logissue type=warning]No clusters are ready for updates in ring '$UpdateRing'" } + default { Write-Warning "No clusters ready for update in ring '$UpdateRing'" } + } } if ($PassThru) { diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalClusterReadinessGateReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalClusterReadinessGateReport.ps1 index 51869ac6..82404aaa 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalClusterReadinessGateReport.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalClusterReadinessGateReport.ps1 @@ -36,10 +36,10 @@ function Export-AzLocalClusterReadinessGateReport { Filename for the per-task markdown summary (ADO/Local only). Default: 'azlocal-step6-readiness-summary.md'. .PARAMETER PassThru - Returns PSCustomObject with: TotalCount, ReadyCount, NotReadyCount, - UpdateRing, ReadinessCsvPath, SummaryPath, Results (raw rows from - Get-AzLocalClusterUpdateReadiness when not in the short-circuit path, - else @()). + Returns PSCustomObject with: TotalCount, ReadyCount, UpToDateCount, + NotReadyCount, UpdateRing, ReadinessCsvPath, SummaryPath, Results (raw + rows from Get-AzLocalClusterUpdateReadiness when not in the + short-circuit path, else @()). .NOTES Author : AzLocal.UpdateManagement Version : 0.8.5 (Step.6 thin-YAML port) @@ -97,10 +97,10 @@ function Export-AzLocalClusterReadinessGateReport { # bindings byte-for-byte: GH uses UPPER_SNAKE, ADO uses PascalCase # (e.g. stageDependencies.CheckReadiness.ReadinessCheck.outputs['readiness.ReadyCount']). if ($pipelineHost -eq 'AzureDevOps') { - $nReadyCount = 'ReadyCount'; $nTotalCount = 'TotalCount'; $nNotReadyCount = 'NotReadyCount' + $nReadyCount = 'ReadyCount'; $nTotalCount = 'TotalCount'; $nNotReadyCount = 'NotReadyCount'; $nUpToDateCount = 'UpToDateCount' } else { - $nReadyCount = 'READY_COUNT'; $nTotalCount = 'TOTAL_COUNT'; $nNotReadyCount = 'NOT_READY_COUNT' + $nReadyCount = 'READY_COUNT'; $nTotalCount = 'TOTAL_COUNT'; $nNotReadyCount = 'NOT_READY_COUNT'; $nUpToDateCount = 'UP_TO_DATE_COUNT' } # Short-circuit when the schedule resolver returned no ring. @@ -108,11 +108,13 @@ function Export-AzLocalClusterReadinessGateReport { Write-Host "No UpdateRing scheduled for this firing - skipping readiness check." Set-AzLocalPipelineOutput -Name $nReadyCount -Value '0' -CrossJob Set-AzLocalPipelineOutput -Name $nTotalCount -Value '0' -CrossJob + Set-AzLocalPipelineOutput -Name $nUpToDateCount -Value '0' -CrossJob Set-AzLocalPipelineOutput -Name $nNotReadyCount -Value '0' -CrossJob if ($PassThru) { return [pscustomobject]@{ TotalCount = 0 ReadyCount = 0 + UpToDateCount = 0 NotReadyCount = 0 UpdateRing = '' ReadinessCsvPath = $csvPath @@ -133,7 +135,12 @@ function Export-AzLocalClusterReadinessGateReport { $totalCount = $results.Count $readyCount = @($results | Where-Object { $_.ReadyForUpdate -eq $true }).Count - $notReadyCount = $totalCount - $readyCount + # v0.8.74: Up to Date is now its own displayed bucket (shared cascade with + # Step.5 / Step.9) so clusters that have already applied all updates are not + # lumped into "Not Ready" - that previously implied failure for healthy, + # fully-patched clusters. READY_COUNT (the apply-updates gate) is unchanged. + $upToDateCount = @($results | Where-Object { (Get-AzLocalClusterReadinessStatus -ReadinessRow $_) -eq 'UpToDate' }).Count + $notReadyCount = $totalCount - $readyCount - $upToDateCount Write-Host "" Write-Host "========================================" -ForegroundColor Cyan @@ -141,10 +148,12 @@ function Export-AzLocalClusterReadinessGateReport { Write-Host "========================================" -ForegroundColor Cyan Write-Host "Total Clusters: $totalCount" Write-Host "Ready for Update: $readyCount" + Write-Host "Up to Date: $upToDateCount" Write-Host "Not Ready: $notReadyCount" Set-AzLocalPipelineOutput -Name $nReadyCount -Value "$readyCount" -CrossJob Set-AzLocalPipelineOutput -Name $nTotalCount -Value "$totalCount" -CrossJob + Set-AzLocalPipelineOutput -Name $nUpToDateCount -Value "$upToDateCount" -CrossJob Set-AzLocalPipelineOutput -Name $nNotReadyCount -Value "$notReadyCount" -CrossJob if ($readyCount -eq 0 -and $pipelineHost -eq 'AzureDevOps') { @@ -159,15 +168,27 @@ function Export-AzLocalClusterReadinessGateReport { $sb = New-Object System.Text.StringBuilder [void]$sb.AppendLine("$headingLevel Cluster Readiness ($UpdateRing)") [void]$sb.AppendLine() - [void]$sb.AppendLine("**Total:** $totalCount  |  **Ready:** $readyCount  |  **Not Ready:** $notReadyCount") + [void]$sb.AppendLine("**Total:** $totalCount  |  **Ready:** $readyCount  |  **Up to Date:** $upToDateCount  |  **Not Ready:** $notReadyCount") [void]$sb.AppendLine() - [void]$sb.AppendLine('| Cluster | Current Version | Update State | Health | Ready? | Recommended Update | Blocking Reasons |') + [void]$sb.AppendLine('| Cluster | Current Version | Update State | Health | Status | Recommended Update | Blocking Reasons |') [void]$sb.AppendLine('|---|---|---|---|---|---|---|') $rendered = 0 foreach ($r in ($results | Sort-Object @{Expression = { [bool]$_.ReadyForUpdate }; Descending = $true }, ClusterName)) { if ($rendered -ge $MaxRows) { break } - $readyIcon = if ($r.ReadyForUpdate -eq $true) { [char]0x2705 } else { [char]0x26D4 } + # v0.8.74: a readable Status cell driven by the shared readiness cascade. + # Up-to-Date clusters now show a green check + "Up to Date" instead of the + # no-entry icon that the old binary Ready? column rendered for them. + $statusCell = switch (Get-AzLocalClusterReadinessStatus -ReadinessRow $r) { + 'ReadyForUpdate' { "{0} Ready" -f [char]0x2705 } + 'UpToDate' { "{0} Up to Date" -f [char]0x2705 } + 'InProgress' { "{0} In Progress" -f ([string]([char]0x23F3)) } + 'SbeBlocked' { "{0} SBE Prerequisite" -f [char]0x26D4 } + 'HealthFailure' { "{0} Health Failure" -f [char]0x274C } + 'UpdateFailed' { "{0} Update Failed" -f [char]0x274C } + 'ActionRequired' { "{0} Action Required" -f [char]0x274C } + default { "{0} Needs Investigation" -f ([string]([char]0x26A0) + [char]0xFE0F) } + } $hSt = "$($r.HealthState)" $hCell = switch -Regex ($hSt) { '^Success$' { ("{0} {1}" -f [char]0x2705, $hSt); break } @@ -180,7 +201,7 @@ function Export-AzLocalClusterReadinessGateReport { $blocking = $blocking -replace '\|', '\|' -replace '\r?\n', ' ' $reco = if ($r.RecommendedUpdate) { '`' + $r.RecommendedUpdate + '`' } else { '-' } $curr = if ($r.CurrentVersion) { '`' + $r.CurrentVersion + '`' } else { '-' } - [void]$sb.AppendLine("| ``$($r.ClusterName)`` | $curr | $($r.UpdateState) | $hCell | $readyIcon | $reco | $blocking |") + [void]$sb.AppendLine("| ``$($r.ClusterName)`` | $curr | $($r.UpdateState) | $hCell | $statusCell | $reco | $blocking |") $rendered++ } @@ -196,6 +217,7 @@ function Export-AzLocalClusterReadinessGateReport { return [pscustomobject]@{ TotalCount = $totalCount ReadyCount = $readyCount + UpToDateCount = $upToDateCount NotReadyCount = $notReadyCount UpdateRing = $UpdateRing ReadinessCsvPath = $csvPath diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalClusterUpdateReadinessReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalClusterUpdateReadinessReport.ps1 index 648bf74c..61ca0683 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalClusterUpdateReadinessReport.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalClusterUpdateReadinessReport.ps1 @@ -264,12 +264,13 @@ function Export-AzLocalClusterUpdateReadinessReport { # v0.7.99: 3-bucket model matches Get-AzLocalClusterUpdateReadiness Summary. # UpToDate clusters are NOT rolled into NotReady - they are a distinct bucket. - $readyForUpdate = @($readiness | Where-Object { $_.ReadyForUpdate -eq $true }).Count - $upToDate = @($readiness | Where-Object { - $_.ReadyForUpdate -ne $true -and - $_.UpdateState -in @('UpToDate', 'AppliedSuccessfully') -and - [string]::IsNullOrEmpty([string]$_.AllAvailableUpdates) - }).Count + # v0.8.74: classification now uses the shared Get-AzLocalClusterReadinessStatus + # priority cascade (identical to Step.9) so a cluster that has applied all + # updates is counted as Up to Date even though its AllAvailableUpdates still + # lists the already-installed packages. The previous strict + # IsNullOrEmpty(AllAvailableUpdates) test silently returned zero. + $readyForUpdate = @($readiness | Where-Object { (Get-AzLocalClusterReadinessStatus -ReadinessRow $_) -eq 'ReadyForUpdate' }).Count + $upToDate = @($readiness | Where-Object { (Get-AzLocalClusterReadinessStatus -ReadinessRow $_) -eq 'UpToDate' }).Count $total = @($readiness).Count $notReady = $total - $readyForUpdate - $upToDate @@ -376,7 +377,11 @@ function Export-AzLocalClusterUpdateReadinessReport { [void]$md.Add('') # 4. Not-Ready cluster table (blocking findings first) - $notReadyRows = @($readiness | Where-Object { $_.ReadyForUpdate -ne $true }) + # v0.8.74: exclude Up-to-Date clusters (and Ready clusters) - they require no + # action and previously cluttered this "review first" table, implying failure. + $notReadyRows = @($readiness | Where-Object { + (Get-AzLocalClusterReadinessStatus -ReadinessRow $_) -notin @('ReadyForUpdate', 'UpToDate') + }) if ($notReadyRows.Count -gt 0) { [void]$md.Add('### Not-Ready clusters (review first)') [void]$md.Add('') @@ -416,12 +421,8 @@ function Export-AzLocalClusterUpdateReadinessReport { [void]$md.Add('| UpdateRing | Total | Ready for Update | Up to Date | Not Ready for Update |') [void]$md.Add('|------------|-------|------------------|------------|----------------------|') foreach ($g in $ringGroups) { - $gReady = @($g.Group | Where-Object { $_.ReadyForUpdate -eq $true }).Count - $gUpToDate = @($g.Group | Where-Object { - $_.ReadyForUpdate -ne $true -and - $_.UpdateState -in @('UpToDate', 'AppliedSuccessfully') -and - [string]::IsNullOrEmpty([string]$_.AllAvailableUpdates) - }).Count + $gReady = @($g.Group | Where-Object { (Get-AzLocalClusterReadinessStatus -ReadinessRow $_) -eq 'ReadyForUpdate' }).Count + $gUpToDate = @($g.Group | Where-Object { (Get-AzLocalClusterReadinessStatus -ReadinessRow $_) -eq 'UpToDate' }).Count $gNotReady = $g.Count - $gReady - $gUpToDate [void]$md.Add("| $($g.Name) | $($g.Count) | $gReady | $gUpToDate | $gNotReady |") } @@ -432,14 +433,24 @@ function Export-AzLocalClusterUpdateReadinessReport { if ($total -gt 0) { [void]$md.Add('### All clusters detail') [void]$md.Add('') - [void]$md.Add('| Cluster | UpdateRing | Current version | Current SBE version | Update state | Health | Ready | Recommended update |') - [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)) { $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 { '-' } - [void]$md.Add("| $($r.ClusterName) | $ring | $cv | $csv | $($r.UpdateState) | $($r.HealthState) | $($r.ReadyForUpdate) | $ru |") + $statusLabel = switch (Get-AzLocalClusterReadinessStatus -ReadinessRow $r) { + 'ReadyForUpdate' { 'Ready for Update' } + 'UpToDate' { 'Up to Date' } + 'InProgress' { 'In Progress' } + 'SbeBlocked' { 'SBE Prerequisite' } + 'HealthFailure' { 'Health Failure' } + 'UpdateFailed' { 'Update Failed' } + 'ActionRequired' { 'Action Required' } + default { 'Needs Investigation' } + } + [void]$md.Add("| $($r.ClusterName) | $ring | $cv | $csv | $($r.UpdateState) | $($r.HealthState) | $statusLabel | $ru |") } [void]$md.Add('') } diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 index f3e6a4b6..95bfd2b1 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 @@ -455,6 +455,12 @@ function Export-AzLocalFleetHealthStatusReport { # ---- Fleet Health Overview table ------------------------------------- [void]$md.Add('### Fleet Health Overview (fleet rollup)') [void]$md.Add('') + # GitHub / ADO step-summary sanitisers strip `target="_blank"` (and force + # `rel="nofollow"`), so the Cluster portal links open in the current tab + # by default. Tip stays even when there are zero overview rows because the + # detail / by-reason tables further down can still emit cluster links. + [void]$md.Add('> **Tip:** Hold `Ctrl` (or `Cmd` on macOS) when clicking - or middle-click - Cluster links to open them in a new tab. (GitHub markdown strips `target="_blank"`.)') + [void]$md.Add('') if ($overview.Count -eq 0) { [void]$md.Add('*No clusters returned from Get-AzLocalFleetHealthOverview.*') } @@ -462,10 +468,9 @@ function Export-AzLocalFleetHealthStatusReport { [void]$md.Add('| Cluster | Health | Update Status | Current Version | SBE Version | Azure Connection | Last Checked | Health Check Age (days) | Node Count |') [void]$md.Add('|---------|--------|---------------|------------------|--------------|------------------|---------------|--------------------------|------------|') foreach ($o in (@($overview) | Select-Object -First $MaxOverviewRows)) { - # target="_blank" so clicking a portal link opens in a new tab - # and the operator does not lose the pipeline run page. + # target="_blank" intentionally omitted: sanitiser strips it. See Tip above. $clusterCell = if ($o.ClusterPortalUrl) { - ('{1}' -f $o.ClusterPortalUrl, $o.ClusterName) + ('{1}' -f $o.ClusterPortalUrl, $o.ClusterName) } else { [string]$o.ClusterName } # Use literal Unicode glyphs (not GH ':name:' shortcodes) so GH + @@ -505,7 +510,8 @@ function Export-AzLocalFleetHealthStatusReport { $linkedParts = for ($i = 0; $i -lt $names.Count; $i++) { $n = $names[$i] $u = if ($i -lt $urls.Count) { $urls[$i] } else { '' } - if ($u) { ('{1}' -f $u, $n) } else { $n } + # target="_blank" intentionally omitted: sanitiser strips it. + if ($u) { ('{1}' -f $u, $n) } else { $n } } $clList = if ($linkedParts.Count -le 10) { $linkedParts -join ', ' @@ -564,7 +570,7 @@ function Export-AzLocalFleetHealthStatusReport { $sevTally = $sevParts -join '  ·  ' $clusterCell = if ($cl.ClusterPortalUrl) { - ('{1}' -f $cl.ClusterPortalUrl, $cl.ClusterName) + ('{1}' -f $cl.ClusterPortalUrl, $cl.ClusterName) } else { [string]$cl.ClusterName } $lastOccStr = if ($cl.LastOccurrence) { ('{0:yyyy-MM-ddTHH:mm:ssZ}' -f $cl.LastOccurrence) } else { '-' } @@ -577,7 +583,7 @@ function Export-AzLocalFleetHealthStatusReport { foreach ($r in $cl.Rows) { $sevTag = if ($r.Severity -eq 'Critical') { '[Critical]' } else { '[Warning]' } $rem = if ($r.PSObject.Properties.Match('Remediation').Count -gt 0) { [string]$r.Remediation } else { '' } - $remCell = if ($rem -and $rem.StartsWith('https://')) { ('link' -f $rem) } else { $rem } + $remCell = if ($rem -and $rem.StartsWith('https://')) { ('link' -f $rem) } else { $rem } $tName = if ($r.PSObject.Properties.Match('TargetResourceName').Count -gt 0) { [string]$r.TargetResourceName } else { '' } $tType = if ($r.PSObject.Properties.Match('TargetResourceType').Count -gt 0) { [string]$r.TargetResourceType } else { '' } [void]$md.Add(('| {0} | {1} | {2} | {3} | {4} | {5} | {6} |' -f $sevTag, $r.FailureReason, $remCell, $tName, $tType, $r.LastOccurrence, $r.ResourceGroup)) diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetUpdateStatusReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetUpdateStatusReport.ps1 index c3b61b05..e1d4c701 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetUpdateStatusReport.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetUpdateStatusReport.ps1 @@ -361,6 +361,9 @@ function Export-AzLocalFleetUpdateStatusReport { # Cluster is counted exactly once. Priority (first match wins): # UpdateFailed > ActionRequired > HealthFailure > SbeBlocked > # InProgress > ReadyForUpdate > UpToDate > NeedsInvestigation + # v0.8.74: the cascade lives in the shared Private helper + # Get-AzLocalClusterReadinessStatus so Step.5 / Step.7 / Step.9 classify + # "Up to Date" identically (was previously re-implemented inline here). $failureStates = @('Failed','UpdateFailed','NeedsAttention','PreparationFailed') $stUpdateFailed = 0 $stActionRequired = 0 @@ -371,14 +374,16 @@ function Export-AzLocalFleetUpdateStatusReport { $stUpToDate = 0 $stOther = 0 foreach ($c in $readiness) { - if ($c.UpdateState -in @('Failed','UpdateFailed','NeedsAttention')) { $stUpdateFailed++ } - elseif ($c.UpdateState -eq 'PreparationFailed') { $stActionRequired++ } - elseif ($c.HealthState -eq 'Failure') { $stHealthFailure++ } - elseif ($c.HasPrerequisiteUpdates) { $stSbeBlocked++ } - elseif ($c.UpdateState -in @('UpdateInProgress','PreparationInProgress')){ $stInProgress++ } - elseif ($c.ReadyForUpdate -eq $true) { $stReadyForUpdate++ } - elseif ($c.UpdateState -in @('UpToDate','AppliedSuccessfully')) { $stUpToDate++ } - else { $stOther++ } + switch (Get-AzLocalClusterReadinessStatus -ReadinessRow $c) { + 'UpdateFailed' { $stUpdateFailed++ } + 'ActionRequired' { $stActionRequired++ } + 'HealthFailure' { $stHealthFailure++ } + 'SbeBlocked' { $stSbeBlocked++ } + 'InProgress' { $stInProgress++ } + 'ReadyForUpdate' { $stReadyForUpdate++ } + 'UpToDate' { $stUpToDate++ } + default { $stOther++ } + } } $hasPrerequisite = @($readiness | Where-Object { $_.HasPrerequisiteUpdates -ne '' -and $null -ne $_.HasPrerequisiteUpdates }).Count @@ -396,21 +401,13 @@ function Export-AzLocalFleetUpdateStatusReport { TotalClusters = $totalTests Summary = [ordered]@{ ReadyForUpdate = @($readiness | Where-Object { $_.ReadyForUpdate -eq $true }).Count - UpToDate = @($readiness | Where-Object { - $_.ReadyForUpdate -ne $true -and - $_.UpdateState -in @('UpToDate','AppliedSuccessfully') -and - [string]::IsNullOrEmpty([string]$_.AllAvailableUpdates) - }).Count + UpToDate = $stUpToDate InProgress = @($readiness | Where-Object { $_.UpdateState -in @('UpdateInProgress','PreparationInProgress') }).Count HealthFailures = @($readiness | Where-Object { $_.HealthState -eq 'Failure' }).Count UpdateFailures = @($readiness | Where-Object { $_.UpdateState -in @('Failed','UpdateFailed','NeedsAttention') }).Count ActionRequired = @($readiness | Where-Object { $_.UpdateState -eq 'PreparationFailed' }).Count HasPrerequisite = $hasPrerequisite - NotReadyForUpdate = @($readiness | Where-Object { - $_.ReadyForUpdate -ne $true -and - ($_.UpdateState -notin @('UpdateInProgress','PreparationInProgress')) -and - -not ($_.UpdateState -in @('UpToDate','AppliedSuccessfully') -and [string]::IsNullOrEmpty([string]$_.AllAvailableUpdates)) - }).Count + NotReadyForUpdate = ($totalTests - $stReadyForUpdate - $stUpToDate - $stInProgress) } Clusters = $readiness } @@ -928,6 +925,10 @@ function Export-AzLocalFleetUpdateStatusReport { [void]$md.Add('') [void]$md.Add("_ARG-first, fleet-scale failure-detail view. Shows up to $RunHistoryTopRows most recent unresolved Failed update runs (last $RunHistorySinceDays days). Source cmdlet: ``Get-AzLocalUpdateRunFailures -State Failed -OnlyUnresolved``.$dedupSuffix._") [void]$md.Add('') + # GitHub / ADO step-summary sanitisers strip `target="_blank"`, so the + # Cluster + Update portal links open in the current tab by default. + [void]$md.Add('> **Tip:** Hold `Ctrl` (or `Cmd` on macOS) when clicking - or middle-click - Cluster or Update links to open them in a new tab. (GitHub markdown strips `target="_blank"`.)') + [void]$md.Add('') [void]$md.Add('| Cluster Name | Update Name | Update State | Status | Current Step | Verbose Error Details | Duration | Time Started | Last Updated |') [void]$md.Add('|---|---|---|---|---|---|---|---|---|') foreach ($r in $renderRows) { @@ -946,8 +947,11 @@ function Export-AzLocalFleetUpdateStatusReport { '
Show error
' + $e + '' + $extraBlock + '
' } else { '_(none)_' } - $clusterCell = if ($r.ClusterResourceId) { '{1}' -f $r.ClusterResourceId, $r.ClusterName } else { [string]$r.ClusterName } - $updCell = if ($r.UpdateRunPortalUrl) { '{1}' -f $r.UpdateRunPortalUrl, $r.UpdateName } else { [string]$r.UpdateName } + # target="_blank" intentionally omitted: GitHub Actions + ADO step-summary + # markdown sanitisers strip it (and force `rel="nofollow"`). The Tip above + # the table tells operators to Ctrl-click to open in a new tab. + $clusterCell = if ($r.ClusterResourceId) { '{1}' -f $r.ClusterResourceId, $r.ClusterName } else { [string]$r.ClusterName } + $updCell = if ($r.UpdateRunPortalUrl) { '{1}' -f $r.UpdateRunPortalUrl, $r.UpdateName } else { [string]$r.UpdateName } [void]$md.Add("| $clusterCell | $updCell | $($r.State) | $($r.Status) | $($r.CurrentStep) | $errCell | $($r.Duration) | $($r.StartTime) | $($r.LastUpdated) |") } [void]$md.Add('') diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalUpdateRunMonitorReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalUpdateRunMonitorReport.ps1 index b65dfd16..27eaadd1 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalUpdateRunMonitorReport.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalUpdateRunMonitorReport.ps1 @@ -536,6 +536,12 @@ function Export-AzLocalUpdateRunMonitorReport { [void]$md.Add("| Recently-failed runs (last ${RecentFailureWindowHours}h) | $($recentlyFailed.Count) |") } [void]$md.Add('') + if ($inFlight.Count -gt 0 -or $unresolvedFailed.Count -gt 0) { + # GitHub / ADO step-summary sanitisers strip `target="_blank"`, so the + # Cluster + Update portal links open in the current tab by default. + [void]$md.Add('> **Tip:** Hold `Ctrl` (or `Cmd` on macOS) when clicking - or middle-click - Cluster or Update links to open them in a new tab. (GitHub markdown strips `target="_blank"`.)') + [void]$md.Add('') + } if ($inFlight.Count -gt 0) { [void]$md.Add('### In-flight runs (sorted by severity score, worst first)') [void]$md.Add('') @@ -554,8 +560,11 @@ function Export-AzLocalUpdateRunMonitorReport { $stepEl = if ($r.StepElapsedDisplay) { $stepElPrefix + $r.StepElapsedDisplay } else { '-' } $runEl = if ($r.ElapsedDisplay) { $runElPrefix + $r.ElapsedDisplay } else { '-' } $flagCell = if ($r.Flags) { $r.Flags } else { '-' } - $clusterCell = if ($r.ClusterPortalUrl) { '' + $r.ClusterName + '' } else { $r.ClusterName } - $updateCell = if ($r.UpdateRunPortalUrl) { '' + $r.UpdateName + '' } else { $r.UpdateName } + # target="_blank" intentionally omitted: GitHub Actions + ADO step-summary + # markdown sanitisers strip it (and force `rel="nofollow"`). The Tip above + # the table tells operators to Ctrl-click to open in a new tab. + $clusterCell = if ($r.ClusterPortalUrl) { '' + $r.ClusterName + '' } else { $r.ClusterName } + $updateCell = if ($r.UpdateRunPortalUrl) { '' + $r.UpdateName + '' } else { $r.UpdateName } [void]$md.Add("| $clusterCell | $updateCell | $stateCell | $statusCell | $cs | $pg | $stepStart | $stepEl | $($r.StartTimeUtc) | $runEl | $flagCell |") } [void]$md.Add('') @@ -580,8 +589,9 @@ function Export-AzLocalUpdateRunMonitorReport { '
Show error
' + $e + '
' } $recentTag = if ($r.IsRecentFailure) { ":fire: last ${RecentFailureWindowHours}h" } else { '-' } - $clusterCell = if ($r.ClusterPortalUrl) { '' + $r.ClusterName + '' } else { $r.ClusterName } - $updateCell = if ($r.UpdateRunPortalUrl) { '' + $r.UpdateName + '' } else { $r.UpdateName } + # target="_blank" intentionally omitted (see in-flight table above). + $clusterCell = if ($r.ClusterPortalUrl) { '' + $r.ClusterName + '' } else { $r.ClusterName } + $updateCell = if ($r.UpdateRunPortalUrl) { '' + $r.UpdateName + '' } else { $r.UpdateName } [void]$md.Add("| $clusterCell | $updateCell | $($r.EndTimeUtc) | $cs | $detailCell | $recentTag |") } [void]$md.Add('') diff --git a/AzLocal.UpdateManagement/Public/Get-AzLocalClusterUpdateReadiness.ps1 b/AzLocal.UpdateManagement/Public/Get-AzLocalClusterUpdateReadiness.ps1 index 8d347c7f..271e83fa 100644 --- a/AzLocal.UpdateManagement/Public/Get-AzLocalClusterUpdateReadiness.ps1 +++ b/AzLocal.UpdateManagement/Public/Get-AzLocalClusterUpdateReadiness.ps1 @@ -578,14 +578,13 @@ function Get-AzLocalClusterUpdateReadiness { $totalClusters = $results.Count $readyForUpdateClusters = @($results | Where-Object { $_.ReadyForUpdate -eq $true }).Count # v0.7.99: UpToDate is now its own bucket (was previously rolled into NotReady). - # A cluster is UpToDate when its UpdateState is UpToDate/AppliedSuccessfully AND it - # has no remaining available updates to install. Previous reporting buried these in - # the catch-all NotReady total even though no action was required. - $upToDateClusters = @($results | Where-Object { - $_.ReadyForUpdate -ne $true -and - $_.UpdateState -in @('UpToDate', 'AppliedSuccessfully') -and - [string]::IsNullOrEmpty([string]$_.AllAvailableUpdates) - }).Count + # v0.8.74: classification uses the shared Get-AzLocalClusterReadinessStatus + # priority cascade (identical to Step.5 / Step.7 / Step.9). The previous strict + # IsNullOrEmpty(AllAvailableUpdates) test silently returned zero because a + # cluster that has applied all updates still lists the already-installed + # packages in AllAvailableUpdates - so up-to-date clusters were mis-counted + # into the catch-all NotReady total even though no action was required. + $upToDateClusters = @($results | Where-Object { (Get-AzLocalClusterReadinessStatus -ReadinessRow $_) -eq 'UpToDate' }).Count $notReadyForUpdateClusters = $totalClusters - $readyForUpdateClusters - $upToDateClusters $inProgressClusters = @($results | Where-Object { $_.UpdateState -eq "UpdateInProgress" }).Count $prereqClusters = @($results | Where-Object { $_.HasPrerequisiteUpdates -ne "" }).Count diff --git a/AzLocal.UpdateManagement/Public/Get-AzLocalFleetStatusData.ps1 b/AzLocal.UpdateManagement/Public/Get-AzLocalFleetStatusData.ps1 index b3baf620..03017258 100644 --- a/AzLocal.UpdateManagement/Public/Get-AzLocalFleetStatusData.ps1 +++ b/AzLocal.UpdateManagement/Public/Get-AzLocalFleetStatusData.ps1 @@ -405,7 +405,29 @@ function Get-AzLocalFleetStatusData { $currentStep = ''; $currentStepDetail = ''; $runProgress = '' if ($latestProps.progress -and $latestProps.progress.steps) { $steps = $latestProps.progress.steps - $runProgress = "$(@($steps | Where-Object { $_.status -eq 'Success' }).Count)/$(@($steps).Count) steps" + # Progress must reflect the DEEP nested step tree, not the coarse top-level + # wrapper. Real Azure Local update runs expose only ~2 top-level steps + # ("Prepare update" + "Start update"), so counting at the top reports a + # near-constant "1/2 steps" for the whole multi-hour run. Walk to leaf + # steps via Get-AzLocalUpdateRunStepStats so the Progress column rendered + # in the standalone HTML report (Recent Update Run History) and the + # latest-runs.csv climbs as the install proceeds - matching the Step.7 + # monitor output formatted by Format-AzLocalUpdateRun. + $stepStats = Get-AzLocalUpdateRunStepStats -Steps $steps + if ($stepStats.TotalLeaf -gt 0) { + $pct = [int][math]::Round((($stepStats.CompletedLeaf / $stepStats.TotalLeaf) * 100)) + $runProgress = "$($stepStats.CompletedLeaf)/$($stepStats.TotalLeaf) steps ($pct%)" + if ($stepStats.FailedLeaf -gt 0) { + $runProgress = "$runProgress, $($stepStats.FailedLeaf) failed" + } + } + else { + # Defensive fallback: no leaves resolved. Use the top-level Success + # count so the column is never blank. Guarded property read for strict mode. + $completedSteps = @($steps | Where-Object { $_.PSObject.Properties['status'] -and $_.status -eq 'Success' }).Count + $totalSteps = @($steps).Count + $runProgress = "$completedSteps/$totalSteps steps" + } # Walk to the deepest InProgress/Error/Failed step rather than the # coarse top-level wrapper (e.g. "Start update"), so CurrentStep stays # consistent with CurrentStepDetail / the standard update-progress output. diff --git a/AzLocal.UpdateManagement/README.md b/AzLocal.UpdateManagement/README.md index b66d7bc9..cea10b51 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.73 - [Published in PowerShell Gallery](https://www.powershellgallery.com/packages/AzLocal.UpdateManagement/0.8.73) +**Latest Version:** v0.8.74 - [Published in PowerShell Gallery](https://www.powershellgallery.com/packages/AzLocal.UpdateManagement/0.8.74) > 📢 **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.73](#whats-new-in-v0873) +- [What's New in v0.8.74](#whats-new-in-v0874) - [Files](#files) - [Prerequisites](#prerequisites) - [RBAC Requirements](#rbac-requirements) (summary; full reference in [docs/rbac.md](docs/rbac.md)) @@ -86,16 +86,22 @@ 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.73 +## What's New in v0.8.74 -**Cycle-calendar refinement.** The Step.3 apply-updates schedule audit now shows the per-ring cluster count INLINE in the "Eligible rings" column (instead of a separate column), and the Step.3 pipeline render path actually populates those counts. No public API or export-count change (still 60). +**Consistent "Up to Date" classification across the readiness reports, a deep-tree fix for the `Progress` column in the Step.7 monitor and the standalone HTML "Recent Update Run History" report, and removal of the dead `target="_blank"` portal-link attributes that GitHub / ADO step-summary sanitisers were stripping anyway (replaced with an explicit Ctrl/Cmd/middle-click tip above each affected table).** A cluster that has already applied every required update is now classified and labelled identically across the Step.5 readiness report, the Step.7 readiness gate, and the Step.9 fleet update-status report. The Step.7 monitor and the standalone HTML report now report `Progress` as deep leaf-step completion (`M/N steps (P%)`) instead of the coarse top-level wrapper count (`1/2 steps`) that they had been emitting for the whole multi-hour run. No public API or export-count change (still 60). -1. **Changed**: `Get-AzLocalApplyUpdatesScheduleCycleCalendar` no longer adds a separate "Clusters in ring(s)" column when `-ClusterRingCounts` is supplied. Instead the per-day calendar relabels the header "Eligible rings" -> "Eligible rings (cluster count)" and appends each ring's count inline to its token, e.g. `` `Prod` (9), `Canary` (3) ``. Dead days still render `_(none - dead day)_`. The per-ring projection table (with `-IncludePerRingSummary`) keeps its separate "Cluster count" column. -2. **Fixed**: `Export-AzLocalApplyUpdatesScheduleAudit` (the cmdlet the Step.3 `apply-updates-schedule-audit` pipeline calls) never forwarded `-ClusterRingCounts` to the cycle-calendar cmdlet, so the cluster-count enrichment was silently absent from every rendered Step.3 summary. The Export wrapper now builds a ring -> tagged-cluster-count map from the supplied cluster CSV and forwards it, so the inline counts appear whenever a `-ClusterCsvPath` is provided. +1. **Fixed**: fully-patched clusters were mis-classified as "not ready". Step.5 (`Export-AzLocalClusterUpdateReadinessReport`) showed `0` in its "Up to date" summary count and listed such clusters in the "Not-Ready clusters (review first)" table; Step.7 (`Export-AzLocalClusterReadinessGateReport`) rendered them with a no-entry icon in the binary `Ready?` column - both implying failure. Root cause: the "Up to Date" test required the `AllAvailableUpdates` collection to be empty, but a cluster that has installed all updates still lists those (now-Installed) package names there, so the test never matched. Step.9 already classified these correctly; Step.5/Step.7 now share that exact logic. +2. **Fixed (Progress column)**: the `Progress` column rendered by the Step.7 in-flight monitor (`Export-AzLocalUpdateRunMonitorReport`) and the standalone HTML report's `Recent Update Run History` table (`New-AzLocalFleetStatusHtmlReport` / `Get-AzLocalFleetStatusData`) always reported the top-level wrapper count (typically `1/2 steps`) because Azure Local only exposes two top-level wrapper steps in `properties.progress.steps` (`Prepare update` + `Start update`) and `Start update` stays `InProgress` for the whole run. Both paths now traverse the full nested step tree via a new private helper (`Get-AzLocalUpdateRunStepStats`) and report leaf-step completion as `M/N steps (P%)`, optionally suffixed with `, K failed`. Matches how `CurrentStep` already walks the tree via `Get-DeepestActiveStep`. +3. **Fixed (portal hyperlinks)**: the Cluster / Update hyperlinks in Step.7 (`Export-AzLocalUpdateRunMonitorReport`), Step.8 (`Export-AzLocalFleetUpdateStatusReport`) and Step.10 (`Export-AzLocalFleetHealthStatusReport`) step-summary tables always opened in the current tab even though the markdown source set `target="_blank"`. GitHub's GFM sanitiser (and the ADO equivalent) strips `target` entirely and forces `rel="nofollow"` - confirmed in the rendered HTML of a real job summary. The dead attributes are now removed and each affected table is preceded by an explicit tip: *"Hold `Ctrl` (or `Cmd` on macOS) when clicking - or middle-click - Cluster or Update links to open them in a new tab. (GitHub markdown strips `target="_blank"`.)"* The standalone HTML report (`New-AzLocalFleetStatusHtmlReport`) is unaffected - it ships as a raw `.html` artifact, not a step-summary, so its `target="_blank"` already works. +4. **Added**: `Get-AzLocalClusterReadinessStatus` private helper is the single source of truth for the readiness priority cascade (UpdateFailed > ActionRequired > HealthFailure > SbeBlocked > InProgress > ReadyForUpdate > UpToDate > NeedsInvestigation). Step.5, Step.7 and Step.9 (`Export-AzLocalFleetUpdateStatusReport`) all classify clusters through this one helper. +5. **Added**: `Get-AzLocalUpdateRunStepStats` private helper recursively walks the nested `properties.progress.steps` tree and returns leaf-step totals (`TotalLeaf` / `CompletedLeaf` / `InProgressLeaf` / `FailedLeaf`). `Skipped` leaves count as completed so a fully-finished run reaches 100%; wrapper steps are excluded so children aren't double-counted. +6. **Added**: Step.7 emits a new `UP_TO_DATE_COUNT` / `UpToDateCount` step output (additive; existing `READY_COUNT` gate semantics unchanged) and `-PassThru` returns `UpToDateCount`. +7. **Changed**: Step.7's per-cluster table replaces the binary `Ready?` column with a readable `Status` column (`Ready`, `Up to Date`, `In Progress`, `SBE Prerequisite`, `Health Failure`, `Update Failed`, `Action Required`, `Needs Investigation`) and the header line shows a distinct `Up to Date` count. Step.5's "All clusters detail" table replaces its `Ready` boolean column with the same readable `Status` label, and its "Not-Ready clusters (review first)" table excludes Up-to-Date and Ready clusters. +8. **Changed**: the Step.7 "No Clusters Ready" summary now explains *why* there is nothing to apply. `Add-AzLocalNoReadyClustersStepSummary` takes new `-UpToDateCount` / `-NotReadyCount` parameters and renders an Up-to-Date vs Not-Ready breakdown table - so an idle run makes clear that already-patched clusters are a healthy steady state (and logs a notice, not a warning, when *every* cluster is up to date) and points to the per-cluster `Status` / `Blocking Reasons` detail. Both the GitHub Actions and Azure DevOps `apply-updates` templates wire the two counts through from the readiness gate. -`GENERATED_AGAINST_MODULE_VERSION` bumped from `0.8.72` to `0.8.73` across all bundled pipeline templates. +`GENERATED_AGAINST_MODULE_VERSION` bumped from `0.8.73` to `0.8.74` across all bundled pipeline templates. -See [CHANGELOG.md](CHANGELOG.md#0873---2026-06-11) for the full v0.8.73 entry. See [`What's New in v0.8.72`](#whats-new-in-v0872) in the Release History section below for the previous release. +See [CHANGELOG.md](CHANGELOG.md#0874---2026-06-11) for the full v0.8.74 entry. See [`What's New in v0.8.73`](#whats-new-in-v0873) in the Release History section below for the previous release. ## Files @@ -574,7 +580,13 @@ 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.73** stay above under [`What's New in v0.8.73`](#whats-new-in-v0873). +The most recent release notes for **v0.8.74** stay above under [`What's New in v0.8.74`](#whats-new-in-v0874). + +### What's New in v0.8.73 + +**Cycle-calendar refinement.** The Step.3 apply-updates schedule audit now shows the per-ring cluster count INLINE in the "Eligible rings" column (instead of a separate column), and the Step.3 pipeline render path actually populates those counts. `Get-AzLocalApplyUpdatesScheduleCycleCalendar` no longer adds a separate "Clusters in ring(s)" column when `-ClusterRingCounts` is supplied; instead the per-day calendar relabels the header "Eligible rings" -> "Eligible rings (cluster count)" and appends each ring's count inline. `Export-AzLocalApplyUpdatesScheduleAudit` now forwards `-ClusterRingCounts` (previously it never did, so the counts were silently absent). No public API or export-count change (still 60). + +See [CHANGELOG.md](CHANGELOG.md#0873---2026-06-11) for the full v0.8.73 entry. ### What's New in v0.8.72 diff --git a/AzLocal.UpdateManagement/Tests/AzLocal.UpdateManagement.Tests.ps1 b/AzLocal.UpdateManagement/Tests/AzLocal.UpdateManagement.Tests.ps1 index b31e5c61..3d562e05 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.73' { - $script:ModuleInfo.Version | Should -Be '0.8.73' + It 'Should have version 0.8.74' { + $script:ModuleInfo.Version | Should -Be '0.8.74' } It 'Module version constants are in sync between .psm1 and .psd1' { @@ -1714,6 +1714,179 @@ Describe 'Helper Function: Format-AzLocalUpdateRun (Internal)' { $f.CurrentStep | Should -Be 'Run Pre Update Validation (FAILED)' } } + + Context 'Progress reflects deep leaf steps, not the top-level wrapper (v0.8.74)' { + + It 'Progress counts deep leaf steps and a percentage, not the coarse top-level 1/2' { + InModuleScope AzLocal.UpdateManagement { + # Real Azure Local runs expose only 2 top-level steps ("Prepare update" Success + # + "Start update" InProgress), so the OLD top-level count always rendered + # "1/2 steps" for the whole multi-hour run. The deep tree below has 4 leaf steps + # (3 Success + 1 InProgress) -> Progress must read 3/4 (75%). + $run = [PSCustomObject]@{ + id = '/subscriptions/x/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/c1/updates/Sol/updateRuns/rProg' + name = 'rProg' + properties = [PSCustomObject]@{ + state = 'InProgress' + progress = [PSCustomObject]@{ + status = 'InProgress' + steps = @( + [PSCustomObject]@{ name = 'Prepare update'; status = 'Success' } + [PSCustomObject]@{ + name = 'Start update' + status = 'InProgress' + steps = @( + [PSCustomObject]@{ + name = 'Update Cluster' + status = 'InProgress' + steps = @( + [PSCustomObject]@{ name = 'Update Node 1'; status = 'Success' } + [PSCustomObject]@{ name = 'Update Node 2'; status = 'Success' } + [PSCustomObject]@{ name = 'Update Node 3'; status = 'InProgress' } + ) + } + ) + } + ) + } + location = 'eastus' + } + } + $f = Format-AzLocalUpdateRun -run $run -clusterName 'c1' + $f.Progress | Should -Be '3/4 steps (75%)' + } + } + + It 'Progress appends a failed-leaf count when the deep tree has Error/Failed leaves' { + InModuleScope AzLocal.UpdateManagement { + $run = [PSCustomObject]@{ + id = '/subscriptions/x/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/c1/updates/Sol/updateRuns/rFail' + name = 'rFail' + properties = [PSCustomObject]@{ + state = 'Failed' + progress = [PSCustomObject]@{ + status = 'Error' + steps = @( + [PSCustomObject]@{ name = 'Prepare update'; status = 'Success' } + [PSCustomObject]@{ + name = 'Start update' + status = 'Error' + steps = @( + [PSCustomObject]@{ name = 'Step A'; status = 'Success' } + [PSCustomObject]@{ name = 'Step B'; status = 'Error' } + ) + } + ) + } + location = 'eastus' + } + } + $f = Format-AzLocalUpdateRun -run $run -clusterName 'c1' + $f.Progress | Should -Be '2/3 steps (67%), 1 failed' + } + } + } +} + +Describe 'Helper Function: Get-AzLocalUpdateRunStepStats (Internal)' { + + It 'Should return all-zero stats for an empty steps array' { + InModuleScope AzLocal.UpdateManagement { + $s = Get-AzLocalUpdateRunStepStats -Steps @() + $s.TotalLeaf | Should -Be 0 + $s.CompletedLeaf | Should -Be 0 + $s.InProgressLeaf | Should -Be 0 + $s.FailedLeaf | Should -Be 0 + } + } + + It 'Should count only leaf steps, excluding parent/wrapper steps' { + InModuleScope AzLocal.UpdateManagement { + # Wrapper "P" (Success) has 2 children; only the children are leaves. + $steps = @( + [PSCustomObject]@{ + name = 'P' + status = 'Success' + steps = @( + [PSCustomObject]@{ name = 'a'; status = 'Success' } + [PSCustomObject]@{ name = 'b'; status = 'InProgress' } + ) + } + ) + $s = Get-AzLocalUpdateRunStepStats -Steps $steps + $s.TotalLeaf | Should -Be 2 + $s.CompletedLeaf | Should -Be 1 + $s.InProgressLeaf | Should -Be 1 + } + } + + It 'Should count Skipped leaves as completed so a finished run can reach 100 percent' { + InModuleScope AzLocal.UpdateManagement { + $steps = @( + [PSCustomObject]@{ name = 'a'; status = 'Success' } + [PSCustomObject]@{ name = 'b'; status = 'Skipped' } + ) + $s = Get-AzLocalUpdateRunStepStats -Steps $steps + $s.TotalLeaf | Should -Be 2 + $s.CompletedLeaf | Should -Be 2 + } + } + + It 'Should count Error and Failed leaves as failed' { + InModuleScope AzLocal.UpdateManagement { + $steps = @( + [PSCustomObject]@{ name = 'a'; status = 'Error' } + [PSCustomObject]@{ name = 'b'; status = 'Failed' } + [PSCustomObject]@{ name = 'c'; status = 'Success' } + ) + $s = Get-AzLocalUpdateRunStepStats -Steps $steps + $s.FailedLeaf | Should -Be 2 + $s.CompletedLeaf | Should -Be 1 + } + } + + It 'Should count blank/NotStarted leaves in Total only (remaining work)' { + InModuleScope AzLocal.UpdateManagement { + $steps = @( + [PSCustomObject]@{ name = 'a'; status = 'Success' } + [PSCustomObject]@{ name = 'b'; status = '' } + [PSCustomObject]@{ name = 'c' } + ) + $s = Get-AzLocalUpdateRunStepStats -Steps $steps + $s.TotalLeaf | Should -Be 3 + $s.CompletedLeaf | Should -Be 1 + $s.InProgressLeaf | Should -Be 0 + $s.FailedLeaf | Should -Be 0 + } + } + + It 'Should aggregate leaves recursively across a deeply nested tree' { + InModuleScope AzLocal.UpdateManagement { + $steps = @( + [PSCustomObject]@{ name = 'Prepare update'; status = 'Success' } + [PSCustomObject]@{ + name = 'Start update' + status = 'InProgress' + steps = @( + [PSCustomObject]@{ + name = 'Update Cluster' + status = 'InProgress' + steps = @( + [PSCustomObject]@{ name = 'Node 1'; status = 'Success' } + [PSCustomObject]@{ name = 'Node 2'; status = 'Success' } + [PSCustomObject]@{ name = 'Node 3'; status = 'InProgress' } + ) + } + ) + } + ) + $s = Get-AzLocalUpdateRunStepStats -Steps $steps + # Leaves: Prepare update, Node 1, Node 2, Node 3 = 4 (Start update + Update Cluster are wrappers) + $s.TotalLeaf | Should -Be 4 + $s.CompletedLeaf | Should -Be 3 + $s.InProgressLeaf | Should -Be 1 + } + } } Describe 'Helper Function: Get-DeepestErrorMessage (Internal)' { @@ -4598,6 +4771,87 @@ Describe 'Get-AzLocalFleetStatusData (schema contract)' { $script:ModuleVersion | Should -Match '^\d+\.\d+\.\d+$' } } + + # v0.8.74: the LatestRuns[].Progress field feeds the standalone HTML report's + # "Recent Update Run History" table AND the latest-runs.csv. The previous top- + # level-only count ("$(Success).Count/$(steps).Count steps") rendered a near- + # constant "1/2 steps" for the whole multi-hour run because Azure Local only + # exposes two coarse top-level wrapper steps ("Prepare update" + "Start + # update"). This guards against a regression back to the top-level count. + It 'LatestRuns Progress counts deep leaf steps with a percentage, not top-level wrappers' { + InModuleScope AzLocal.UpdateManagement { + function global:az { $global:LASTEXITCODE = 0; return '{}' } + Mock Test-AzCliAvailable { return $true } + Mock Install-AzGraphExtension { return $true } + + $deepSteps = @( + [PSCustomObject]@{ name = 'Prepare update'; status = 'Success' } + [PSCustomObject]@{ + name = 'Start update' + status = 'InProgress' + steps = @( + [PSCustomObject]@{ + name = 'Update Cluster' + status = 'InProgress' + steps = @( + [PSCustomObject]@{ name = 'Update Node 1'; status = 'Success' } + [PSCustomObject]@{ name = 'Update Node 2'; status = 'Success' } + [PSCustomObject]@{ name = 'Update Node 3'; status = 'InProgress' } + ) + } + ) + } + ) + + Mock Invoke-AzRestJson { + param($Uri) + if ($Uri -match '/updateSummaries/default') { + return [PSCustomObject]@{ Data = [PSCustomObject]@{ properties = [PSCustomObject]@{ + state = 'AppliedSuccessfully'; healthState = 'Success' + currentVersion = '12.2402.0'; currentSbeVersion = 'N/A' + lastChecked = '2025-10-01T00:00:00Z'; lastUpdated = '2025-10-01T00:00:00Z' + healthCheckResult = @() + } } } + } + if ($Uri -match '/updates\?api-version') { + return [PSCustomObject]@{ Data = [PSCustomObject]@{ value = @( + [PSCustomObject]@{ name = '12.2402.1'; properties = [PSCustomObject]@{ state = 'Ready' } } + ) } } + } + if ($Uri -match '/updates/[^/]+/updateRuns\?api-version') { + return [PSCustomObject]@{ Data = [PSCustomObject]@{ value = @( + [PSCustomObject]@{ + id = '/subscriptions/s/resourceGroups/r/providers/Microsoft.AzureStackHCI/clusters/c1/updates/12.2402.1/updateRuns/run1' + name = 'run1' + properties = [PSCustomObject]@{ + state = 'InProgress' + timeStarted = '2026-06-10T08:00:00Z' + lastUpdatedTime = '2026-06-10T10:00:00Z' + location = 'eastus' + progress = [PSCustomObject]@{ + status = 'InProgress' + steps = $deepSteps + } + } + } + ) } } + } + return [PSCustomObject]@{ Data = [PSCustomObject]@{ + properties = [PSCustomObject]@{ status = 'Succeeded' } + } } + } + + $rid = '/subscriptions/s/resourceGroups/r/providers/Microsoft.AzureStackHCI/clusters/c1' + $result = Get-AzLocalFleetStatusData -ClusterResourceIds @($rid) -IncludeUpdateRuns + + $result.LatestRuns | Should -Not -BeNullOrEmpty + $row = @($result.LatestRuns)[0] + # 4 leaves (Prepare update + 3 nodes), 3 completed, 1 in-progress -> 3/4 (75%) + $row.Progress | Should -Be '3/4 steps (75%)' + # Must NOT regress to the old top-level count of "1/2 steps". + $row.Progress | Should -Not -Match '^\d+/2 steps$' + } + } } } @@ -14500,14 +14754,13 @@ Describe 'Thin-YAML Step.7: Export-AzLocalUpdateRunMonitorReport' { } } - It 'In-flight runs table renders Cluster and Update cells as target=_blank links so they do NOT navigate away from the pipeline output' { + It 'In-flight runs table renders Cluster and Update cells as plain anchor links (sanitiser strips target/rel) and emits a Ctrl-click tip above the tables' { # Regression guard: the In-flight runs (and Failed runs) tables MUST - # render Cluster + Update cells as ... so operators clicking through to the Azure - # portal from the pipeline run summary keep the pipeline tab open. - # The cmdlet honours both ClusterPortalUrl and UpdateRunPortalUrl - so - # both must be present on the source row AND surface in the rendered - # markdown summary. + # render Cluster + Update cells as .... We intentionally + # do NOT emit target="_blank" / rel="noopener" because the GitHub Actions + # and Azure DevOps step-summary markdown sanitisers strip them (and + # force rel="nofollow"). A Ctrl-click tip directly above the tables + # tells operators how to open links in a new tab. $env:GITHUB_ACTIONS = 'true' $env:GITHUB_OUTPUT = $script:_s7_ghOutputFile $env:GITHUB_STEP_SUMMARY = $script:_s7_ghSummaryFile @@ -14535,10 +14788,14 @@ Describe 'Thin-YAML Step.7: Export-AzLocalUpdateRunMonitorReport' { } $summary = Get-Content -Raw -LiteralPath $script:_s7_ghSummaryFile $summary | Should -Match '### In-flight runs' - # Cluster cell renders as target=_blank link with rel=noopener - $summary | Should -Match 'alpha' - # Update cell renders as target=_blank link with rel=noopener - $summary | Should -Match '12\.2509\.1\.21' + # Ctrl-click tip rendered above the tables + $summary | Should -Match '\*\*Tip:\*\* Hold `Ctrl`.*Cluster or Update links' + # Cluster cell renders as plain href (no target/rel attrs - sanitiser strips them) + $summary | Should -Match 'alpha' + # Update cell renders as plain href + $summary | Should -Match '12\.2509\.1\.21' + # Regression: target="_blank" must NOT appear as an anchor attribute (stripped by sanitiser anyway, so it is dead weight). The Tip text deliberately mentions the literal string in prose, so match it only inside an opening tag. + $summary | Should -Not -Match ']*target="_blank"' } } @@ -15109,7 +15366,7 @@ Describe 'Thin-YAML Step.9: Export-AzLocalFleetHealthStatusReport' { $out | Should -Match 'warning_count=0' } - It 'Critical+Warning mixed fleet emits one testcase per failing check, correct bucket counts, and target=_blank portal links in the markdown summary' { + It 'Critical+Warning mixed fleet emits one testcase per failing check, correct bucket counts, and plain anchor cluster links plus a Ctrl-click tip in the markdown summary' { $env:GITHUB_ACTIONS = 'true' $env:GITHUB_OUTPUT = $script:_s9_ghOutputFile $env:GITHUB_STEP_SUMMARY = $script:_s9_ghSummaryFile @@ -15151,10 +15408,14 @@ Describe 'Thin-YAML Step.9: Export-AzLocalFleetHealthStatusReport' { $xml | Should -Match '' $xml | Should -Match '' $xml | Should -Match '' - # Markdown summary: target=_blank on cluster portal link + # Markdown summary: plain on cluster portal link (sanitiser strips target/rel) $summary = Get-Content -Raw -LiteralPath $script:_s9_ghSummaryFile $summary | Should -Match '### Fleet Health Overview' - $summary | Should -Match 'alpha' + # Ctrl-click tip rendered above the tables + $summary | Should -Match '\*\*Tip:\*\* Hold `Ctrl`.*Cluster links' + $summary | Should -Match 'alpha' + # Regression: target="_blank" must NOT appear as an anchor attribute (stripped by sanitiser anyway). The Tip text mentions the literal string in prose, so match it only inside an opening tag. + $summary | Should -Not -Match ']*target="_blank"' $summary | Should -Match '### Health Check Failures By Reason' $summary | Should -Match '### Detailed Results' # KPI table values @@ -15563,6 +15824,147 @@ Describe 'Thin-YAML Step.5: Export-AzLocalClusterUpdateReadinessReport' { #endregion v0.8.5: Export-AzLocalClusterUpdateReadinessReport +#region v0.8.74: Get-AzLocalClusterReadinessStatus (shared readiness cascade) +Describe 'Private: Get-AzLocalClusterReadinessStatus' { + + It 'Classifies for UpdateState= Health= Prereq= Ready=' -ForEach @( + @{ UpdateState='Failed'; HealthState='Success'; HasPrerequisiteUpdates=''; ReadyForUpdate=$false; Expected='UpdateFailed' } + @{ UpdateState='UpdateFailed'; HealthState='Success'; HasPrerequisiteUpdates=''; ReadyForUpdate=$false; Expected='UpdateFailed' } + @{ UpdateState='NeedsAttention'; HealthState='Success'; HasPrerequisiteUpdates=''; ReadyForUpdate=$false; Expected='UpdateFailed' } + @{ UpdateState='PreparationFailed'; HealthState='Success'; HasPrerequisiteUpdates=''; ReadyForUpdate=$false; Expected='ActionRequired' } + @{ UpdateState='UpToDate'; HealthState='Failure'; HasPrerequisiteUpdates=''; ReadyForUpdate=$false; Expected='HealthFailure' } + @{ UpdateState='UpdateAvailable'; HealthState='Success'; HasPrerequisiteUpdates='12.2510.0.1'; ReadyForUpdate=$false; Expected='SbeBlocked' } + @{ UpdateState='UpdateInProgress'; HealthState='Success'; HasPrerequisiteUpdates=''; ReadyForUpdate=$false; Expected='InProgress' } + @{ UpdateState='PreparationInProgress';HealthState='Success'; HasPrerequisiteUpdates=''; ReadyForUpdate=$false; Expected='InProgress' } + @{ UpdateState='UpdateAvailable'; HealthState='Success'; HasPrerequisiteUpdates=''; ReadyForUpdate=$true; Expected='ReadyForUpdate' } + @{ UpdateState='UpToDate'; HealthState='Success'; HasPrerequisiteUpdates=''; ReadyForUpdate=$false; Expected='UpToDate' } + @{ UpdateState='AppliedSuccessfully'; HealthState='Success'; HasPrerequisiteUpdates=''; ReadyForUpdate=$false; Expected='UpToDate' } + @{ UpdateState='Downloading'; HealthState='Success'; HasPrerequisiteUpdates=''; ReadyForUpdate=$false; Expected='NeedsInvestigation' } + ) { + $global:_grs_row = [pscustomobject]@{ + UpdateState = $UpdateState + HealthState = $HealthState + HasPrerequisiteUpdates = $HasPrerequisiteUpdates + ReadyForUpdate = $ReadyForUpdate + } + $result = InModuleScope AzLocal.UpdateManagement { + Get-AzLocalClusterReadinessStatus -ReadinessRow $global:_grs_row + } + $result | Should -Be $Expected + } + + It 'REGRESSION: AppliedSuccessfully with already-installed updates listed in AllAvailableUpdates is UpToDate (not NeedsInvestigation)' { + # The production bug: a cluster that has applied every update still carries + # the installed package names in AllAvailableUpdates. The cascade must NOT + # require AllAvailableUpdates to be empty. + $global:_grs_row = [pscustomobject]@{ + UpdateState = 'AppliedSuccessfully' + HealthState = 'Success' + HasPrerequisiteUpdates = '' + ReadyForUpdate = $false + AllAvailableUpdates = '12.2605.1003.210; 12.2510.0.999' + } + $result = InModuleScope AzLocal.UpdateManagement { + Get-AzLocalClusterReadinessStatus -ReadinessRow $global:_grs_row + } + $result | Should -Be 'UpToDate' + } + + It 'Priority cascade: UpdateFailed wins over a Failure health state' { + $global:_grs_row = [pscustomobject]@{ + UpdateState = 'UpdateFailed' + HealthState = 'Failure' + HasPrerequisiteUpdates = '12.2510.0.1' + ReadyForUpdate = $false + } + $result = InModuleScope AzLocal.UpdateManagement { + Get-AzLocalClusterReadinessStatus -ReadinessRow $global:_grs_row + } + $result | Should -Be 'UpdateFailed' + } + + It 'Strict-mode-safe when optional properties are absent (returns NeedsInvestigation)' { + $global:_grs_row = [pscustomobject]@{ ClusterName = 'partial' } + $result = InModuleScope AzLocal.UpdateManagement { + Get-AzLocalClusterReadinessStatus -ReadinessRow $global:_grs_row + } + $result | Should -Be 'NeedsInvestigation' + } +} +#endregion v0.8.74: Get-AzLocalClusterReadinessStatus + +#region v0.8.74: Export-AzLocalClusterReadinessGateReport (Step.7 status column) +Describe 'Thin-YAML Step.7: Export-AzLocalClusterReadinessGateReport' { + + BeforeEach { + $script:_s7_savedGhActions = $env:GITHUB_ACTIONS + $script:_s7_savedTfBuild = $env:TF_BUILD + $script:_s7_savedGhOutput = $env:GITHUB_OUTPUT + $script:_s7_savedGhSummary = $env:GITHUB_STEP_SUMMARY + Remove-Item Env:\GITHUB_ACTIONS -ErrorAction SilentlyContinue + Remove-Item Env:\TF_BUILD -ErrorAction SilentlyContinue + Remove-Item Env:\GITHUB_OUTPUT -ErrorAction SilentlyContinue + Remove-Item Env:\GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue + + $script:_s7_outDir = Join-Path -Path $env:TEMP -ChildPath ("s7-out-{0}" -f ([Guid]::NewGuid())) + $script:_s7_ghOutputFile = Join-Path -Path $env:TEMP -ChildPath ("s7-gh-output-{0}" -f ([Guid]::NewGuid())) + $script:_s7_ghSummaryFile = Join-Path -Path $env:TEMP -ChildPath ("s7-gh-summary-{0}.md" -f ([Guid]::NewGuid())) + New-Item -ItemType Directory -Path $script:_s7_outDir -Force | Out-Null + New-Item -ItemType File -Path $script:_s7_ghOutputFile -Force | Out-Null + New-Item -ItemType File -Path $script:_s7_ghSummaryFile -Force | Out-Null + } + + AfterEach { + if ($null -ne $script:_s7_savedGhActions) { $env:GITHUB_ACTIONS = $script:_s7_savedGhActions } else { Remove-Item Env:\GITHUB_ACTIONS -ErrorAction SilentlyContinue } + if ($null -ne $script:_s7_savedTfBuild) { $env:TF_BUILD = $script:_s7_savedTfBuild } else { Remove-Item Env:\TF_BUILD -ErrorAction SilentlyContinue } + if ($null -ne $script:_s7_savedGhOutput) { $env:GITHUB_OUTPUT = $script:_s7_savedGhOutput } else { Remove-Item Env:\GITHUB_OUTPUT -ErrorAction SilentlyContinue } + if ($null -ne $script:_s7_savedGhSummary) { $env:GITHUB_STEP_SUMMARY = $script:_s7_savedGhSummary } else { Remove-Item Env:\GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue } + foreach ($p in @($script:_s7_ghOutputFile, $script:_s7_ghSummaryFile)) { + if ($p -and (Test-Path -LiteralPath $p)) { Remove-Item -LiteralPath $p -Force -ErrorAction SilentlyContinue } + } + if ($script:_s7_outDir -and (Test-Path -LiteralPath $script:_s7_outDir)) { + Remove-Item -LiteralPath $script:_s7_outDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'PassThru splits Ready / UpToDate / NotReady and emits UP_TO_DATE_COUNT output' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s7_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s7_ghSummaryFile + $global:_s7_outDir = $script:_s7_outDir + $global:_s7_readiness = @( + [pscustomobject]@{ ClusterName='ready'; ClusterResourceId='/subscriptions/s1/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/ready' + UpdateState='UpdateAvailable'; HealthState='Success'; ReadyForUpdate=$true; HasPrerequisiteUpdates='' + AllAvailableUpdates='12.2510.0.999'; CurrentVersion='12.2509.0.0'; RecommendedUpdate='12.2510.0.999'; BlockingReasons='' } + [pscustomobject]@{ ClusterName='dallas'; ClusterResourceId='/subscriptions/s1/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/dallas' + UpdateState='AppliedSuccessfully'; HealthState='Success'; ReadyForUpdate=$false; HasPrerequisiteUpdates='' + AllAvailableUpdates='12.2605.1003.210'; CurrentVersion='12.2605.1003.210'; RecommendedUpdate=''; BlockingReasons='' } + [pscustomobject]@{ ClusterName='broken'; ClusterResourceId='/subscriptions/s1/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/broken' + UpdateState='Failed'; HealthState='Failure'; ReadyForUpdate=$false; HasPrerequisiteUpdates='' + AllAvailableUpdates='12.2510.0.999'; CurrentVersion='12.2509.0.0'; RecommendedUpdate='12.2510.0.999'; BlockingReasons='Critical Health Status: Failed' } + ) + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterUpdateReadiness { @($global:_s7_readiness) } + Export-AzLocalClusterReadinessGateReport -UpdateRing 'Wave1' -OutputDirectory $global:_s7_outDir -PassThru + } + $result.TotalCount | Should -Be 3 + $result.ReadyCount | Should -Be 1 + $result.UpToDateCount | Should -Be 1 + $result.NotReadyCount | Should -Be 1 + + $out = Get-Content -LiteralPath $script:_s7_ghOutputFile -Raw + $out | Should -Match 'UP_TO_DATE_COUNT=1' + $out | Should -Match 'READY_COUNT=1' + + $summary = Get-Content -LiteralPath $script:_s7_ghSummaryFile -Raw + $summary | Should -Match '\*\*Up to Date:\*\* 1' + $summary | Should -Match 'Up to Date' + # The Status column header replaced the old binary "Ready?" header. + $summary | Should -Match '\| Status \|' + } +} +#endregion v0.8.74: Export-AzLocalClusterReadinessGateReport + #region v0.8.5: Export-AzLocalFleetConnectivityStatusReport Describe 'Thin-YAML Step.4: Export-AzLocalFleetConnectivityStatusReport' { @@ -16749,8 +17151,102 @@ Describe 'Thin-YAML Step.6: Add-AzLocalNoReadyClustersStepSummary' { $script:S6CmdN.Parameters['PassThru'].ParameterType.Name | Should -Be 'SwitchParameter' } } + + Context 'Rendering (v0.8.74 empty-ring clarity)' { + + BeforeAll { + $script:S6N_PriorGh = $env:GITHUB_ACTIONS + $script:S6N_PriorTf = $env:TF_BUILD + } + + AfterAll { + if ($null -ne $script:S6N_PriorGh) { $env:GITHUB_ACTIONS = $script:S6N_PriorGh } else { Remove-Item Env:GITHUB_ACTIONS -ErrorAction SilentlyContinue } + if ($null -ne $script:S6N_PriorTf) { $env:TF_BUILD = $script:S6N_PriorTf } else { Remove-Item Env:TF_BUILD -ErrorAction SilentlyContinue } + } + + BeforeEach { + # Force the Local host so the summary is written to a temp file we can read back. + Remove-Item Env:GITHUB_ACTIONS -ErrorAction SilentlyContinue + Remove-Item Env:TF_BUILD -ErrorAction SilentlyContinue + $script:S6N_SummaryName = "s6n-noready-{0}.md" -f ([Guid]::NewGuid()) + $script:S6N_SummaryPath = Join-Path -Path $env:TEMP -ChildPath $script:S6N_SummaryName + Remove-Item -LiteralPath $script:S6N_SummaryPath -ErrorAction SilentlyContinue + } + + AfterEach { + if ($script:S6N_SummaryPath -and (Test-Path -LiteralPath $script:S6N_SummaryPath)) { + Remove-Item -LiteralPath $script:S6N_SummaryPath -Force -ErrorAction SilentlyContinue + } + } + + It 'Empty ring renders the "No UpdateRing Scheduled for This Firing" section (NOT the misleading no-clusters-found text)' { + $null = Add-AzLocalNoReadyClustersStepSummary -UpdateRing '' -TotalCount 0 ` + -SummaryFileName $script:S6N_SummaryName -WarningVariable s6nWarn -WarningAction SilentlyContinue + $content = Get-Content -LiteralPath $script:S6N_SummaryPath -Raw + $content | Should -Match 'No UpdateRing Scheduled for This Firing' + $content | Should -Match 'This is expected' + $content | Should -Not -Match "No clusters found with UpdateRing tag value" + } + + It 'Empty ring does NOT emit a warning (expected idle firing, not a fault)' { + $null = Add-AzLocalNoReadyClustersStepSummary -UpdateRing ' ' -TotalCount 0 ` + -SummaryFileName $script:S6N_SummaryName -WarningVariable s6nWarn -WarningAction SilentlyContinue + @($s6nWarn).Count | Should -Be 0 + } + + It 'Non-empty ring with TotalCount=0 still renders "No clusters found with UpdateRing tag value" and warns' { + $null = Add-AzLocalNoReadyClustersStepSummary -UpdateRing 'Wave1' -TotalCount 0 ` + -SummaryFileName $script:S6N_SummaryName -WarningVariable s6nWarn -WarningAction SilentlyContinue + $content = Get-Content -LiteralPath $script:S6N_SummaryPath -Raw + $content | Should -Match 'No Clusters Ready for Update' + $content | Should -Match "No clusters found with UpdateRing tag value 'Wave1'" + @($s6nWarn).Count | Should -BeGreaterThan 0 + } + + It 'Non-empty ring with TotalCount>0 renders the "Found N cluster(s) ... but none are ready" message' { + $null = Add-AzLocalNoReadyClustersStepSummary -UpdateRing 'Prod' -TotalCount 3 ` + -SummaryFileName $script:S6N_SummaryName -WarningVariable s6nWarn -WarningAction SilentlyContinue + $content = Get-Content -LiteralPath $script:S6N_SummaryPath -Raw + $content | Should -Match "Found 3 cluster\(s\) with UpdateRing='Prod', but none are ready" + @($s6nWarn).Count | Should -BeGreaterThan 0 + } + + It 'With Up-to-Date / Not-Ready breakdown renders the outcome table with both counts' { + $null = Add-AzLocalNoReadyClustersStepSummary -UpdateRing 'Prod' -TotalCount 5 -UpToDateCount 3 -NotReadyCount 2 ` + -SummaryFileName $script:S6N_SummaryName -WarningVariable s6nWarn -WarningAction SilentlyContinue + $content = Get-Content -LiteralPath $script:S6N_SummaryPath -Raw + $content | Should -Match '\| Outcome \| Clusters \|' + $content | Should -Match 'Up to Date \(already fully patched - no action needed\) \| 3 \|' + $content | Should -Match 'Not Ready \(needs attention before updating\) \| 2 \|' + $content | Should -Match 'Check Update Readiness' + } + + It 'When all clusters are up to date (NotReadyCount=0) emphasises the healthy steady state' { + $null = Add-AzLocalNoReadyClustersStepSummary -UpdateRing 'Prod' -TotalCount 4 -UpToDateCount 4 -NotReadyCount 0 ` + -SummaryFileName $script:S6N_SummaryName -WarningVariable s6nWarn -WarningAction SilentlyContinue + $content = Get-Content -LiteralPath $script:S6N_SummaryPath -Raw + $content | Should -Match 'All 4 cluster\(s\) tagged UpdateRing=''Prod'' are already up to date' + $content | Should -Match 'healthy steady state' + $content | Should -Match 'Up to Date \(already fully patched - no action needed\) \| 4 \|' + } + + It 'When all clusters are up to date does NOT emit a warning (healthy steady state)' { + $null = Add-AzLocalNoReadyClustersStepSummary -UpdateRing 'Prod' -TotalCount 4 -UpToDateCount 4 -NotReadyCount 0 ` + -SummaryFileName $script:S6N_SummaryName -WarningVariable s6nWarn -WarningAction SilentlyContinue + @($s6nWarn).Count | Should -Be 0 + } + + It 'Empty-string breakdown counts fall back to the generic "Possible reasons" list (no parse throw)' { + { Add-AzLocalNoReadyClustersStepSummary -UpdateRing 'Prod' -TotalCount 3 -UpToDateCount '' -NotReadyCount '' ` + -SummaryFileName $script:S6N_SummaryName -WarningAction SilentlyContinue } | Should -Not -Throw + $content = Get-Content -LiteralPath $script:S6N_SummaryPath -Raw + $content | Should -Match 'Possible reasons:' + $content | Should -Match "Found 3 cluster\(s\) with UpdateRing='Prod', but none are ready" + } + } } + Describe 'Thin-YAML Step.6: Invoke-AzLocalItsmTicketingFromArtifact' { BeforeAll { diff --git a/AzLocal.UpdateManagement/docs/release-history.md b/AzLocal.UpdateManagement/docs/release-history.md index 1c3e68d6..60c2078c 100644 --- a/AzLocal.UpdateManagement/docs/release-history.md +++ b/AzLocal.UpdateManagement/docs/release-history.md @@ -4,7 +4,29 @@ > > **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.73 (the current release)**, see the main [README.md](../README.md#whats-new-in-v0873) `What's New in v0.8.73` section. +> **For v0.8.74 (the current release)**, see the main [README.md](../README.md#whats-new-in-v0874) `What's New in v0.8.74` section. + +--- + +### What's New in v0.8.74 + +v0.8.74 makes the "Up to Date" cluster classification consistent across the Step.5 readiness report, the Step.7 readiness gate, and the Step.9 fleet update-status report. It also corrects the `Progress` column in the Step.7 in-flight monitor and the standalone HTML report's "Recent Update Run History" table, both of which had been reporting a near-constant `1/2 steps` for the whole multi-hour run, and drops the dead `target="_blank"` portal-link attributes that GitHub Actions / Azure DevOps step-summary sanitisers were stripping anyway (replaced with an explicit Ctrl/Cmd/middle-click tip above each affected table). No public API or export-count change (still 60). + +**Fully-patched clusters were mis-classified as "not ready".** A cluster that had successfully installed every required update showed `0` in the Step.5 (`Export-AzLocalClusterUpdateReadinessReport`) "Up to date" summary count and appeared in its "Not-Ready clusters (review first)" table; Step.7 (`Export-AzLocalClusterReadinessGateReport`) rendered it with a no-entry icon in the binary `Ready?` column - both implying failure. The root cause was that the "Up to Date" determination required the `AllAvailableUpdates` collection to be empty, but a cluster that has installed all updates still lists those (now-Installed) package names there. Step.9 already classified these clusters correctly via its priority cascade. + +**`Progress` column always reported the top-level wrapper.** The Step.7 in-flight monitor (`Export-AzLocalUpdateRunMonitorReport`) and the standalone HTML report's `Recent Update Run History` table (`New-AzLocalFleetStatusHtmlReport`, fed by `Get-AzLocalFleetStatusData`) both rendered `Progress` from the top level of `properties.progress.steps`. Azure Local only exposes two coarse wrapper steps there - `Prepare update` (Success) and `Start update` (InProgress for the whole run) - so the column always read `1/2 steps` for ~150-leaf solution updates regardless of real progress. Both paths now walk the full nested step tree via a new private helper (`Get-AzLocalUpdateRunStepStats`) and report leaf-step completion as `M/N steps (P%)` (optionally suffixed with `, K failed`), matching how `CurrentStep` already walks the tree via `Get-DeepestActiveStep`. + +**Portal hyperlinks `target="_blank"` was being silently stripped.** The Cluster / Update hyperlinks rendered in Step.7 (`Export-AzLocalUpdateRunMonitorReport`) in-flight + failed-runs tables, Step.8 (`Export-AzLocalFleetUpdateStatusReport`) Update Run History table, and Step.10 (`Export-AzLocalFleetHealthStatusReport`) Fleet Health Overview / By-Reason / Detailed Results tables always opened in the current tab even though the markdown source set `target="_blank"`. GitHub's GFM sanitiser (and the ADO equivalent) drops the `target` attribute entirely and forces `rel="nofollow"` - confirmed in the rendered HTML of a real GitHub Actions job summary, and documented in [Stack Overflow: open link in new tab with github markdown using target="_blank"](https://stackoverflow.com/questions/41915571/open-link-in-new-tab-with-github-markdown-using-target-blank). The dead attributes are now removed (so the emitted markdown matches the rendered DOM), and each affected table is preceded by an explicit tip: *"Hold `Ctrl` (or `Cmd` on macOS) when clicking - or middle-click - Cluster or Update links to open them in a new tab. (GitHub markdown strips `target="_blank"`.)"* The standalone HTML report (`New-AzLocalFleetStatusHtmlReport`) is unaffected; it is written as a raw `.html` artifact (not a step-summary), so its `target="_blank"` already works as intended in any browser. + +**New shared classifier.** A new private helper, `Get-AzLocalClusterReadinessStatus`, is the single source of truth for the readiness priority cascade (UpdateFailed > ActionRequired > HealthFailure > SbeBlocked > InProgress > ReadyForUpdate > UpToDate > NeedsInvestigation). Step.5, Step.7 and Step.9 (`Export-AzLocalFleetUpdateStatusReport`) now classify clusters identically through this helper; previously each report re-implemented the logic inline and the definitions had drifted. + +**Step.7 reporting changes.** The per-cluster table replaces its binary `Ready?` column with a readable `Status` column (`Ready`, `Up to Date`, `In Progress`, `SBE Prerequisite`, `Health Failure`, `Update Failed`, `Action Required`, `Needs Investigation`), the header line shows a distinct `Up to Date` count, and a new additive `UP_TO_DATE_COUNT` / `UpToDateCount` step output is emitted (`-PassThru` returns `UpToDateCount`). The existing `READY_COUNT` gate semantics are unchanged. + +**Step.5 reporting changes.** The "All clusters detail" table replaces its `Ready` boolean column with the same readable `Status` label, and the "Not-Ready clusters (review first)" table now excludes both Up-to-Date and Ready clusters. + +**Step.7 "No Clusters Ready" summary now explains why.** `Add-AzLocalNoReadyClustersStepSummary` takes new `-UpToDateCount` / `-NotReadyCount` parameters and renders an Up-to-Date vs Not-Ready breakdown table, so an idle apply-updates run makes clear that already-patched clusters are a healthy steady state (and logs an informational notice instead of a warning when every cluster is up to date) and points to the per-cluster `Status` / `Blocking Reasons` detail. Both the GitHub Actions and Azure DevOps `apply-updates` templates wire the two counts through from the readiness gate. + +`GENERATED_AGAINST_MODULE_VERSION` bumped from `0.8.73` to `0.8.74` across all bundled pipeline templates. ---