diff --git a/.github/workflows/AzLocal.UpdateManagement-pester.yml b/.github/workflows/AzLocal.UpdateManagement-pester.yml index 2927c670..61e8c43b 100644 --- a/.github/workflows/AzLocal.UpdateManagement-pester.yml +++ b/.github/workflows/AzLocal.UpdateManagement-pester.yml @@ -154,6 +154,6 @@ jobs: uses: EnricoMi/publish-unit-test-result-action/windows@v2 with: check_name: AzLocal.UpdateManagement Pester - junit_files: AzLocal.UpdateManagement/Tests/TestResults/pester-junit.xml + files: AzLocal.UpdateManagement/Tests/TestResults/pester-junit.xml comment_mode: off ignore_runs: true 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 5d058798..4b995c51 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.79' + GENERATED_AGAINST_MODULE_VERSION: '0.8.80' 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 34eaf84f..8c7dc0a4 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/apply-updates.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/apply-updates.yml @@ -129,7 +129,7 @@ variables: # log if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample -Update. See Automation-Pipeline-Examples/README.md section 5. - name: GENERATED_AGAINST_MODULE_VERSION - value: '0.8.79' + value: '0.8.80' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): queue-time parameter > pipeline variable # 'REQUIRED_MODULE_VERSION' overridden at queue time > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/assess-update-readiness.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/assess-update-readiness.yml index 2fc5799e..a5d307af 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.79' + GENERATED_AGAINST_MODULE_VERSION: '0.8.80' # 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 d78255d5..58fb8981 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.79' + value: '0.8.80' # 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 55732a42..1f1dba5c 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.79' + GENERATED_AGAINST_MODULE_VERSION: '0.8.80' # 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 a3cb197a..e5051c1c 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.79' + GENERATED_AGAINST_MODULE_VERSION: '0.8.80' # 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 12ef9983..2ca68f95 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.79' + GENERATED_AGAINST_MODULE_VERSION: '0.8.80' # 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 676a8c50..26706861 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.79' + value: '0.8.80' # 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 33a9c9a2..d5cc940a 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.79' + value: '0.8.80' # 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 0b9da870..680758cc 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.79' + GENERATED_AGAINST_MODULE_VERSION: '0.8.80' 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 9fffb6d6..0faebff1 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.79' + GENERATED_AGAINST_MODULE_VERSION: '0.8.80' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): queue-time parameter > pipeline variable # 'REQUIRED_MODULE_VERSION' overridden at queue time > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/apply-updates-schedule-audit.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/apply-updates-schedule-audit.yml index b717697c..02d4950c 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.79' + GENERATED_AGAINST_MODULE_VERSION: '0.8.80' 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 25fe94b9..bea73dc6 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/apply-updates.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/apply-updates.yml @@ -148,7 +148,7 @@ env: # this to the version actually installed and to the latest on PSGallery, and emits a # ::notice annotation if the YAML appears stale - prompting you to refresh via # Copy-AzLocalPipelineExample -Update. See Automation-Pipeline-Examples/README.md section 5. - GENERATED_AGAINST_MODULE_VERSION: '0.8.79' + GENERATED_AGAINST_MODULE_VERSION: '0.8.80' # Resolution order for the module version pin (leave all unset to install the latest, # which is the default "fix-forward" behaviour): manual workflow_dispatch input > # repository variable 'REQUIRED_MODULE_VERSION' > empty (latest). diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/assess-update-readiness.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/assess-update-readiness.yml index b3169ca5..e2e018fd 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.79' + GENERATED_AGAINST_MODULE_VERSION: '0.8.80' # 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 f828f6e1..e7edf661 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.79' + GENERATED_AGAINST_MODULE_VERSION: '0.8.80' # 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 820c0615..20e809ac 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.79' + GENERATED_AGAINST_MODULE_VERSION: '0.8.80' # 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 ac5337a0..498c669a 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.79' + GENERATED_AGAINST_MODULE_VERSION: '0.8.80' # 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 a5454471..58395cb3 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.79' + GENERATED_AGAINST_MODULE_VERSION: '0.8.80' # 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 1d2c26bf..84210f7a 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.79' + GENERATED_AGAINST_MODULE_VERSION: '0.8.80' # 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 1cd12d43..441d6053 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.79' + GENERATED_AGAINST_MODULE_VERSION: '0.8.80' # 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 a93e48cc..15cd987f 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.79' + GENERATED_AGAINST_MODULE_VERSION: '0.8.80' # 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 221f6d1e..d13c807b 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.79' + GENERATED_AGAINST_MODULE_VERSION: '0.8.80' # 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 c255d39e..2c2abc46 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.79' + ModuleVersion = '0.8.80' # Supported PSEditions CompatiblePSEditions = @('Desktop', 'Core') @@ -55,6 +55,7 @@ 'Private/Get-AzLocalPipelineId.ps1', 'Private/Get-AzLocalPipelineManifest.ps1', 'Private/Get-AzLocalRunEndTime.ps1', + 'Private/Get-AzLocalUpdateRunHealthEvidence.ps1', 'Private/Get-AzLocalUpdateRunStepStats.ps1', 'Private/Get-CurrentStepPath.ps1', 'Private/Get-DeepestActiveStep.ps1', @@ -309,6 +310,8 @@ # ReleaseNotes of this module ReleaseNotes = @' +## Version 0.8.80 - Minor: pipeline failure-rendering improvements bundled in three additive changes (Q1 / Q2 / Q3) targeting Step.05 / Step.08 / Step.09 / Step.10 step summaries. **Q1 - HealthCheck failure enrichment** (Step.09 `fleet-update-status`): `Get-AzLocalUpdateRunFailures` now attaches a `HealthCheckEvidence` array column (same-cluster Critical `updateSummaries.healthCheckResult` entries within a +/-2h window) to every row whose `ErrorCategory='HealthCheck'`, surfaced as a bulleted list inside the per-row `
` panel of the markdown step summary and as a structured block in JUnit ``. New private helper `Get-AzLocalUpdateRunHealthEvidence` (single ARG hop per HealthCheck-category row, scoped to one cluster id, reuses the existing anti-mv-expand 128-cap projection pattern). New parameter `[bool]$EnrichWithHealthEvidence = $true` lets CSV-only consumers opt out. **Q2 - Title and TargetResourceID columns** on both fleet-wide (`Get-AzLocalFleetHealthFailures` for Step.10) and per-cluster (`Test-AzLocalClusterHealth` for Step.05) health failure outputs: per-check `title` (often more specific than `displayName`, e.g. names the failing node or volume) and full `targetResourceID` (lets renderers deep-link Server/Volume health failures back to the exact ARM resource). `Export-AzLocalFleetHealthStatusReport` adds a Title markdown column and wraps TargetResourceName in a portal hyperlink when TargetResourceID is present. **Q3 - Surface BOTH deepest-step `description` AND `errorMessage`** (Step.08 monitor + Step.09 fleet-update-status): both deepest-error walkers (`Resolve-AzLocalUpdateRunDeepestError` and `Get-DeepestErrorMessage`) now capture step description at the same recursion site as errorMessage. New `DeepestStepDescription` column on `Get-AzLocalUpdateRunFailures`; new `ErrorDescription` field on `Format-AzLocalUpdateRun` / `Get-AzLocalUpdateRuns -PassThru`; renderers combine `description + errorMessage` in markdown failure cells and JUnit bodies. Cross-reference `.NOTES` headers added to the four parallel functions so future maintainers know to keep them in sync. No new exports (count unchanged at 60). All bundled pipeline templates bump `GENERATED_AGAINST_MODULE_VERSION` from `'0.8.79'` to `'0.8.80'`. See CHANGELOG.md for the full v0.8.80 entry. + ## Version 0.8.79 - Patch: operator break-glass override for Step.07. Adds `Force Immediate Update` to `Invoke-AzLocalReadinessGatedClusterUpdate` (`-ForceImmediateUpdate`) and `Start-AzLocalClusterUpdate` (`-IgnoreScheduleTags`), plus matching pipeline parameters `force_immediate_update` (GitHub Actions `workflow_dispatch` input) / `forceImmediateUpdate` (Azure DevOps `parameters:` boolean) both defaulting to `false`. When enabled, the per-cluster Step 3c maintenance-window gate (`UpdateStartWindow` / `UpdateExclusionsWindow` tags) is bypassed and updates start regardless of the current UTC time. Intended for emergency / out-of-window patching driven by an on-call operator. The override is UNREACHABLE from the apply-updates-schedule.yml configuration file - GHA wiring collapses the flag to `'false'` for any non-`workflow_dispatch` trigger (`${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_immediate_update || 'false' }}`), and ADO rechecks `$(Build.Reason) -eq 'Manual'` at runtime before honouring the parameter. A `WARNING:` GUI label is prepended on both pipeline hosts and a high-visibility `::warning::` (GHA) / `##vso[task.logissue type=warning]` (ADO) banner is emitted into the run log when the override fires. No new exports (still 60). All bundled pipeline templates bump `GENERATED_AGAINST_MODULE_VERSION` from `'0.8.78'` to `'0.8.79'`. - **CHANGE (`Start-AzLocalClusterUpdate`)**: new `[switch]$IgnoreScheduleTags` parameter. When set, the entire Step 3c block is skipped (tag lookup + `Test-AzLocalUpdateScheduleAllowed` call) and a per-cluster `Warning` is logged with the bypassed tag values so the override is visible in the transcript. Does NOT bypass any other readiness gate (connectivity, health, sideload, `UpdateExcluded` operator hard-override). diff --git a/AzLocal.UpdateManagement/AzLocal.UpdateManagement.psm1 b/AzLocal.UpdateManagement/AzLocal.UpdateManagement.psm1 index e6af44a7..7ba05cf7 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.79' +$script:ModuleVersion = '0.8.80' $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 c2b035f1..c20d6286 100644 --- a/AzLocal.UpdateManagement/CHANGELOG.md +++ b/AzLocal.UpdateManagement/CHANGELOG.md @@ -5,6 +5,67 @@ 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.80] - 2026-06-16 + +Minor release. Bundles three pipeline-failure-rendering improvements that target the +Step.05 / Step.08 / Step.09 / Step.10 step summaries. All changes are additive and +backward-compatible (no exports renamed, no parameter contracts broken). One new +private helper (`Get-AzLocalUpdateRunHealthEvidence`). + +### Added + +- **Q1 - HealthCheck failure enrichment** (Step.09 `fleet-update-status`): when + `Get-AzLocalUpdateRunFailures` categorises a failed run as + `ErrorCategory='HealthCheck'` (the default), it now attaches a + `HealthCheckEvidence` array column carrying same-cluster Critical + `updateSummaries.healthCheckResult` entries that fired within a +/-2h window + around the run. New private helper `Get-AzLocalUpdateRunHealthEvidence` (one + ARG hop per HealthCheck-category row, scoped to a single cluster id; reuses + the existing anti-mv-expand 128-cap pattern). The fleet-update-status + markdown step summary now renders this evidence as bullets inside the per-row + `
` panel, and the JUnit XML systemOut block lists each evidence + entry's timestamp / title / severity / target resource id. New parameter + `[bool]$EnrichWithHealthEvidence = $true` on `Get-AzLocalUpdateRunFailures` + lets CSV-only consumers opt out of the extra ARG hops. +- **Q2 - Title and TargetResourceID columns** on both fleet-wide and + per-cluster health failure outputs. `Get-AzLocalFleetHealthFailures` (Step.10) + and `Test-AzLocalClusterHealth` (Step.05) now project the per-check `title` + (often more specific than `displayName`, e.g. names the failing node) and the + full `targetResourceID` (the ARM id of the failing component - lets renderers + deep-link Server/Volume health failures back to the exact node or volume). + `Export-AzLocalFleetHealthStatusReport` adds Title as a markdown column and + wraps TargetResourceName in a portal hyperlink when TargetResourceID is + present. `Test-AzLocalClusterHealth` adds Title to the dedup key, the + Format-Table console output, and the JUnit Message field. +- **Q3 - Surface BOTH deepest-step `description` AND `errorMessage`** + (Step.08 monitor + Step.09 fleet-update-status). Previously the two deepest- + error walkers returned only the errorMessage trace and silently dropped the + human-readable step description. Both walkers (`Resolve-AzLocalUpdateRunDeepestError` + and `Get-DeepestErrorMessage`) now capture description at the same recursion + site as errorMessage. `Get-AzLocalUpdateRunFailures` emits a new + `DeepestStepDescription` column; `Format-AzLocalUpdateRun` emits a new + `ErrorDescription` field on its PSCustomObject; the Step.08 and Step.09 + renderers combine description + errorMessage in the markdown failure cell + and in the JUnit body (description heading, then trace). +- Cross-reference `.NOTES` headers added to the four parallel functions + (the two deepest-error walkers and the two healthCheckResult readers) so + future maintainers know to keep them in sync. + +### Changed + +- All bundled pipeline templates bump `GENERATED_AGAINST_MODULE_VERSION` from + `'0.8.79'` to `'0.8.80'`. + +### Notes + +- No new exports (count unchanged at 60). +- Step.07 (`Add-AzLocalApplyUpdatesStepSummary`) was deliberately left out of + the Q1/Q3 wiring because its data source is the live + `Invoke-AzLocalReadinessGatedClusterUpdate` result row (apply-results.json), + not `Get-AzLocalUpdateRunFailures`. Surfacing Q1/Q3 at Step.07 requires a + separate upstream change to the readiness-gated invoker and is tracked for a + follow-up release. + ## [0.8.79] - 2026-06-15 Patch release. Adds an operator-only **Force Immediate Update** break-glass override to the Step.07 apply-updates pipeline that bypasses the per-cluster `UpdateStartWindow` / `UpdateExclusionsWindow` maintenance-window gate. Intended for emergency / out-of-window patching driven by an on-call operator; defaults to OFF; cannot be reached from the scheduled `apply-updates-schedule.yml` configuration file. diff --git a/AzLocal.UpdateManagement/Private/Format-AzLocalUpdateRun.ps1 b/AzLocal.UpdateManagement/Private/Format-AzLocalUpdateRun.ps1 index 10a74150..a65985d4 100644 --- a/AzLocal.UpdateManagement/Private/Format-AzLocalUpdateRun.ps1 +++ b/AzLocal.UpdateManagement/Private/Format-AzLocalUpdateRun.ps1 @@ -41,6 +41,7 @@ function Format-AzLocalUpdateRun { # already shows 'Error', telling the operator a step has errored and the run is stuck. $progressStatus = if ($props.progress -and $props.progress.PSObject.Properties['status']) { [string]$props.progress.status } else { '' } $errorMessage = '' + $errorDescription = '' if ($props.progress -and $props.progress.steps) { $steps = $props.progress.steps # Progress must reflect the DEEP nested step tree, not the top-level wrapper steps. @@ -104,7 +105,12 @@ function Format-AzLocalUpdateRun { # Deepest non-empty errorMessage from any Error/Failed step in the tree (v0.7.96). # Uses a coalesce(e8Msg..e1Msg) recursion so operators see the actual leaf failure # (e.g. CAU exception) rather than the generic parent step name. - $errorMessage = Get-DeepestErrorMessage -Steps $steps + # v0.8.80: also capture the deepest step's `description` (human-readable line) so + # the Step.08 monitor renderer can show BOTH the description and the errorMessage + # trace in the same cell. Previously the description was silently dropped. + $deepest = Get-DeepestErrorMessage -Steps $steps -IncludeDescription + $errorMessage = if ($deepest -is [hashtable]) { [string]$deepest.Msg } else { [string]$deepest } + $errorDescription = if ($deepest -is [hashtable]) { [string]$deepest.Description } else { '' } if ($deepestActive -and $deepestActive.PSObject.Properties['startTimeUtc'] -and $deepestActive.startTimeUtc) { try { $stepStartDt = [datetime]::SpecifyKind(([datetime]$deepestActive.startTimeUtc).ToUniversalTime(), [DateTimeKind]::Utc) @@ -155,6 +161,7 @@ function Format-AzLocalUpdateRun { StepStartTime = $stepStartTimeDisplay StepElapsed = $stepElapsedDisplay ErrorMessage = $errorMessage + ErrorDescription = $errorDescription Location = $props.location } diff --git a/AzLocal.UpdateManagement/Private/Get-AzLocalUpdateRunHealthEvidence.ps1 b/AzLocal.UpdateManagement/Private/Get-AzLocalUpdateRunHealthEvidence.ps1 new file mode 100644 index 00000000..d0f5a863 --- /dev/null +++ b/AzLocal.UpdateManagement/Private/Get-AzLocalUpdateRunHealthEvidence.ps1 @@ -0,0 +1,177 @@ +function Get-AzLocalUpdateRunHealthEvidence { + <# + .SYNOPSIS + Returns same-cluster Critical healthCheckResult entries that occurred + within a +/-2h window of a failed update run, used to enrich + HealthCheck-category failures with the actual blocking checks. + + .DESCRIPTION + When Get-AzLocalUpdateRunFailures categorises a failed run as + ErrorCategory='HealthCheck' the operator typically wants to know + WHICH health check fired. That information is NOT on the update run + itself - it lives on the cluster's `updateSummaries/default` child + resource as `properties.healthCheckResult[]`. This helper queries + Azure Resource Graph for that array (using the same anti-mv-expand + pattern as Get-AzLocalFleetHealthFailures) and filters down to: + + * the supplied ClusterResourceId + * status == 'Failed' + * severity == 'Critical' (Warning/Informational are excluded by + default to keep the evidence list short and triage-focused) + * timestamp within [RunStartTime - WindowBefore, + RunEndTime + WindowAfter] + + Each returned row is a minimal projection (Title, FailureReason, + TargetResourceName, TargetResourceID, Timestamp, Remediation) so + the Step.09 renderer can fold it into a collapsible
+ block without pulling in heavy fields. + + .PARAMETER ClusterResourceId + Full ARM resource id of the cluster the failed run belongs to. + + .PARAMETER RunStartTime + The run's StartedAt (UTC DateTime). Pre-window evidence is + captured back to RunStartTime - WindowBefore. + + .PARAMETER RunEndTime + The run's EndedAt (UTC DateTime). If the run is still in progress + pass [datetime]::UtcNow. + + .PARAMETER WindowBefore + TimeSpan to look back before RunStartTime. Default 2 hours - + covers the readiness check that immediately preceded the run. + + .PARAMETER WindowAfter + TimeSpan to look forward past RunEndTime. Default 1 hour - + catches the post-failure health refresh that the orchestrator + triggers on a failed run. + + .PARAMETER MinSeverity + 'Critical' (default) or 'Warning' to include Warning-severity + entries as well. Informational is never returned. + + .PARAMETER SubscriptionId + Optional subscription scope for the ARG query. Defaults to all + subscriptions the caller can read. + + .OUTPUTS + PSCustomObject[] - one row per matching healthCheckResult entry, + sorted Timestamp descending (most recent first). Returns an + empty array if the cluster doc cannot be found or no rows + match the window/severity filter. + + .NOTES + Author: Neil Bird, Microsoft. + Added: v0.8.80 (HealthCheck failure enrichment) + Module: AzLocal.UpdateManagement (private helper) + Caller: Get-AzLocalUpdateRunFailures -EnrichWithHealthEvidence + + Reuses the same ARG query shape as Get-AzLocalFleetHealthFailures + to dodge the documented mv-expand 128-row cap. Differences: + scoped to a single cluster id, scoped to a time window, returns + a slim projection. + #> + [CmdletBinding()] + [OutputType([System.Collections.IEnumerable])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$ClusterResourceId, + + [Parameter(Mandatory = $true)] + [datetime]$RunStartTime, + + [Parameter(Mandatory = $true)] + [datetime]$RunEndTime, + + [Parameter(Mandatory = $false)] + [timespan]$WindowBefore = ([timespan]::FromHours(2)), + + [Parameter(Mandatory = $false)] + [timespan]$WindowAfter = ([timespan]::FromHours(1)), + + [Parameter(Mandatory = $false)] + [ValidateSet('Critical','Warning')] + [string]$MinSeverity = 'Critical', + + [Parameter(Mandatory = $false)] + [string]$SubscriptionId + ) + + $clusterId = $ClusterResourceId.ToLower() + $windowFrom = $RunStartTime.ToUniversalTime() - $WindowBefore + $windowTo = $RunEndTime.ToUniversalTime() + $WindowAfter + + # KQL: project the raw healthCheckResult array per cluster, scoped to + # the supplied cluster id. We use `parent` id derived from segments + # rather than a `tolower(id) startswith` so the query stays selective. + $kql = @" +extensibilityresources +| where type =~ 'microsoft.azurestackhci/clusters/updatesummaries' +| extend segments = split(id, '/') +| extend + SubscriptionId = tostring(segments[2]), + ResourceGroup = tostring(segments[4]), + ClusterName = tostring(segments[8]) +| extend ClusterResourceId = tolower(strcat('/subscriptions/', SubscriptionId, '/resourceGroups/', ResourceGroup, '/providers/Microsoft.AzureStackHCI/clusters/', ClusterName)) +| where ClusterResourceId == '$clusterId' +| project + ClusterResourceId, + HealthCheckResult = properties.healthCheckResult +"@ + + Write-Verbose "Get-AzLocalUpdateRunHealthEvidence: ARG query for cluster '$clusterId' window [$windowFrom .. $windowTo] severity>=$MinSeverity" + + try { + $rows = if ($SubscriptionId) { + Invoke-AzResourceGraphQuery -Query $kql -SubscriptionId $SubscriptionId + } else { + Invoke-AzResourceGraphQuery -Query $kql + } + } + catch { + Write-Verbose "Get-AzLocalUpdateRunHealthEvidence ARG query failed: $($_.Exception.Message)" + return @() + } + + if (-not $rows -or $rows.Count -eq 0) { return @() } + + $allowedSeverities = if ($MinSeverity -eq 'Warning') { @('Critical','Warning') } else { @('Critical') } + + $evidence = New-Object System.Collections.ArrayList + foreach ($cluster in @($rows)) { + $hcr = $cluster.HealthCheckResult + if (-not $hcr) { continue } + foreach ($hc in @($hcr)) { + $status = "$($hc.status)" + if ($status -ne 'Failed') { continue } + $sev = "$($hc.severity)" + if ($allowedSeverities -notcontains $sev) { continue } + + $ts = $null + if ($hc.timestamp) { + try { $ts = ([datetime]$hc.timestamp).ToUniversalTime() } catch { $ts = $null } + } + if (-not $ts) { continue } + if ($ts -lt $windowFrom -or $ts -gt $windowTo) { continue } + + [void]$evidence.Add([PSCustomObject]@{ + Timestamp = $ts + Severity = $sev + Title = "$($hc.title)" + FailureReason = "$($hc.displayName)" + Description = "$($hc.description)" + Remediation = "$($hc.remediation)" + TargetResourceName = "$($hc.targetResourceName)" + TargetResourceType = "$($hc.targetResourceType)" + TargetResourceID = "$($hc.targetResourceID)" + }) + } + } + + if ($evidence.Count -eq 0) { return @() } + + # Most recent first - operators want to see the latest evidence at the + # top of the collapsed
block. + return @($evidence | Sort-Object -Property Timestamp -Descending) +} diff --git a/AzLocal.UpdateManagement/Private/Get-DeepestErrorMessage.ps1 b/AzLocal.UpdateManagement/Private/Get-DeepestErrorMessage.ps1 index 3fc3f34f..e590480d 100644 --- a/AzLocal.UpdateManagement/Private/Get-DeepestErrorMessage.ps1 +++ b/AzLocal.UpdateManagement/Private/Get-DeepestErrorMessage.ps1 @@ -9,31 +9,64 @@ function Get-DeepestErrorMessage { errorMessage on an Error/Failed-status step. Used by Format-AzLocalUpdateRun in v0.7.96 to surface a dedicated ErrorMessage column so operators can triage failed runs without clicking through to the Azure portal. + + v0.8.80: when -IncludeDescription is supplied the helper returns a hashtable + @{ Msg = ''; Description = '' } instead of a bare string. The description is the + step's `description` field captured at the SAME depth as Msg, so renderers that + want to display both (the human-readable line AND the raw trace) can do so. The + default (bare-string) return shape is preserved for back-compat with the existing + unit tests. .NOTES - Returns an empty string when no error message is found (callers can if-guard cheaply). + Returns an empty string (or @{Msg='';Description=''} with -IncludeDescription) when + no error message is found (callers can if-guard cheaply). + + Consumed by Format-AzLocalUpdateRun -> Get-AzLocalUpdateRuns -> the Step.08 + monitor renderer (Export-AzLocalUpdateRunMonitorReport). + + Parallel walker: Resolve-AzLocalUpdateRunDeepestError (Private/) walks the same + tree shape but returns a richer hashtable, used by Get-AzLocalUpdateRunFailures + for the Step.09 fleet-update-status renderer. If you change the deepest-step + contract here, update Resolve-AzLocalUpdateRunDeepestError too so the two + pipelines stay in sync. #> [CmdletBinding()] [OutputType([string])] + [OutputType([hashtable])] param( [Parameter(Mandatory = $false)] [array]$Steps, [Parameter(Mandatory = $false)] - [int]$MaxDepth = 20 + [int]$MaxDepth = 20, + + [Parameter(Mandatory = $false)] + [switch]$IncludeDescription ) - if (-not $Steps -or $Steps.Count -eq 0 -or $MaxDepth -le 0) { return '' } + $emptyResult = if ($IncludeDescription) { @{ Msg = ''; Description = '' } } else { '' } + if (-not $Steps -or $Steps.Count -eq 0 -or $MaxDepth -le 0) { return $emptyResult } foreach ($step in $Steps) { if ($step.status -notin @('Error', 'Failed')) { continue } if ($step.steps -and $step.steps.Count -gt 0) { - $deeper = Get-DeepestErrorMessage -Steps $step.steps -MaxDepth ($MaxDepth - 1) - if ($deeper) { return $deeper } + $deeper = Get-DeepestErrorMessage -Steps $step.steps -MaxDepth ($MaxDepth - 1) -IncludeDescription:$IncludeDescription + if ($IncludeDescription) { + if ($deeper.Msg) { return $deeper } + } + elseif ($deeper) { return $deeper } } if ($step.PSObject.Properties['errorMessage'] -and $step.errorMessage) { - return [string]$step.errorMessage + $msg = [string]$step.errorMessage + if ($IncludeDescription) { + $desc = '' + if ($step.PSObject.Properties['description'] -and $step.description) { + $desc = [string]$step.description + } + return @{ Msg = $msg; Description = $desc } + } + return $msg } } - return '' + return $emptyResult } diff --git a/AzLocal.UpdateManagement/Private/Resolve-AzLocalUpdateRunDeepestError.ps1 b/AzLocal.UpdateManagement/Private/Resolve-AzLocalUpdateRunDeepestError.ps1 index 93db2d0f..152dfc96 100644 --- a/AzLocal.UpdateManagement/Private/Resolve-AzLocalUpdateRunDeepestError.ps1 +++ b/AzLocal.UpdateManagement/Private/Resolve-AzLocalUpdateRunDeepestError.ps1 @@ -47,6 +47,13 @@ function Resolve-AzLocalUpdateRunDeepestError { (1-based) of the deepest match Msg [string] - the deepest errorMessage text Name [string] - that step's `name` + Description [string] - that step's `description` captured + at the SAME recursion site as Msg + (added v0.8.80 - the deepest step + often has BOTH a human-readable + description and an errorMessage + trace; renderers should display + both, not pick one) FirstDescription [string] - the very first non-empty top-level `description`, used as a fallback when no errorMessage @@ -55,7 +62,17 @@ function Resolve-AzLocalUpdateRunDeepestError { .NOTES Author: Neil Bird, Microsoft. Added: v0.7.76 (P0 ARG mv-expand 128-cap fix) + Updated:v0.8.80 (added Description field at deepest site) Module: AzLocal.UpdateManagement (private helper) + + Consumed by Get-AzLocalUpdateRunFailures (Step.07 verification + + Step.09 fleet-update-status renderer). + + Parallel walker: Get-DeepestErrorMessage (Private/) walks the + same tree shape but is used by Format-AzLocalUpdateRun for the + Get-AzLocalUpdateRuns -PassThru shape (Step.08 monitor). If + you change the deepest-step contract here, also update + Get-DeepestErrorMessage so the two pipelines stay in sync. #> [CmdletBinding()] [OutputType([hashtable])] @@ -75,6 +92,7 @@ function Resolve-AzLocalUpdateRunDeepestError { Depth = 0 Msg = '' Name = '' + Description = '' FirstDescription = '' } @@ -119,6 +137,14 @@ function Resolve-AzLocalUpdateRunDeepestError { $stepName = $null try { $stepName = $step.name } catch { $stepName = $null } $result.Name = if ($stepName) { [string]$stepName } else { '' } + # v0.8.80: capture description at the SAME site as Msg so + # renderers can show both. Previously only FirstDescription + # was captured (top-level only), which silently dropped the + # deepest step's human-readable line when an errorMessage + # was also present (always true for HealthCheck failures). + $stepDesc = $null + try { $stepDesc = $step.description } catch { $stepDesc = $null } + $result.Description = if ($stepDesc) { [string]$stepDesc } else { '' } } } @@ -129,9 +155,10 @@ function Resolve-AzLocalUpdateRunDeepestError { if ($null -ne $childSteps) { $child = Resolve-AzLocalUpdateRunDeepestError -Steps $childSteps -Depth ($Depth + 1) -MaxDepth $MaxDepth if ($child.Depth -gt $result.Depth) { - $result.Depth = $child.Depth - $result.Msg = $child.Msg - $result.Name = $child.Name + $result.Depth = $child.Depth + $result.Msg = $child.Msg + $result.Name = $child.Name + $result.Description = $child.Description } if ($Depth -eq 1 -and -not $result.FirstDescription -and $child.FirstDescription) { $result.FirstDescription = $child.FirstDescription diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 index 95bfd2b1..69989647 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 @@ -362,6 +362,12 @@ function Export-AzLocalFleetHealthStatusReport { $clusterPortalUrl = if ($r.PSObject.Properties.Match('ClusterPortalUrl').Count -gt 0) { [string]$r.ClusterPortalUrl } else { '' } $targetResName = if ($r.PSObject.Properties.Match('TargetResourceName').Count -gt 0){ [string]$r.TargetResourceName }else { '' } $targetResType = if ($r.PSObject.Properties.Match('TargetResourceType').Count -gt 0){ [string]$r.TargetResourceType }else { '' } + # v0.8.80: surface the per-check `title` (often more specific than + # FailureReason/displayName) and `targetResourceID` (the full ARM + # id of the failing component - lets renderers deep-link to the + # exact server / volume / nic that failed). + $titleText = if ($r.PSObject.Properties.Match('Title').Count -gt 0) { [string]$r.Title } else { '' } + $targetResId = if ($r.PSObject.Properties.Match('TargetResourceID').Count -gt 0) { [string]$r.TargetResourceID } else { '' } $msg = "{0}: {1} (last occurred {2:yyyy-MM-ddTHH:mm:ssZ})" -f $r.Severity, $r.FailureReason, $r.LastOccurrence $bodyLines = @( [string]$r.Description @@ -369,8 +375,10 @@ function Export-AzLocalFleetHealthStatusReport { "ResourceGroup: $($r.ResourceGroup)" "SubscriptionId: $($r.SubscriptionId)" ) - if ($targetResName) { $bodyLines += "TargetResourceName: $targetResName" } - if ($targetResType) { $bodyLines += "TargetResourceType: $targetResType" } + if ($titleText) { $bodyLines += "Title: $titleText" } + if ($targetResName) { $bodyLines += "TargetResourceName: $targetResName" } + if ($targetResType) { $bodyLines += "TargetResourceType: $targetResType" } + if ($targetResId) { $bodyLines += "TargetResourceID: $targetResId" } if ($clusterPortalUrl) { $bodyLines += "ClusterPortalUrl: $clusterPortalUrl" } $tc = @{ Name = "{0} :: {1}" -f $r.ClusterName, $r.FailureReason @@ -382,10 +390,12 @@ function Export-AzLocalFleetHealthStatusReport { UpdateName = [string]$r.FailureReason # ITSM dedupe key slot Status = [string]$r.Severity FailureReason = [string]$r.FailureReason + Title = $titleText Severity = [string]$r.Severity ClusterPortalUrl = $clusterPortalUrl TargetResourceName = $targetResName TargetResourceType = $targetResType + TargetResourceID = $targetResId }) Failure = @{ Type = [string]$r.Severity @@ -578,15 +588,28 @@ function Export-AzLocalFleetHealthStatusReport { [void]$md.Add('
') [void]$md.Add(('{0}  ·  {1}  ·  last {2}' -f $clusterCell, $sevTally, $lastOccStr)) [void]$md.Add('') - [void]$md.Add('| Severity | Failure Reason | Failure Remediation | Target Resource Name | Target Resource Type | Last Occurrence | Resource Group |') - [void]$md.Add('|----------|----------------|---------------------|----------------------|----------------------|------------------|----------------|') + [void]$md.Add('| Severity | Failure Reason | Title | Failure Remediation | Target Resource Name | Target Resource Type | Last Occurrence | Resource Group |') + [void]$md.Add('|----------|----------------|-------|---------------------|----------------------|----------------------|------------------|----------------|') 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 } $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)) + # v0.8.80: add per-check `title` (often the most specific + # single field - names the failing node/volume) and wrap + # TargetResourceName in a portal deep-link when the + # corresponding TargetResourceID is present. + $tTitle = if ($r.PSObject.Properties.Match('Title').Count -gt 0) { [string]$r.Title } else { '' } + $tResId = if ($r.PSObject.Properties.Match('TargetResourceID').Count -gt 0) { [string]$r.TargetResourceID } else { '' } + $tNameCell = if ($tResId -and $tName) { + '{1}' -f $tResId, $tName + } elseif ($tResId) { + 'link' -f $tResId + } else { + $tName + } + [void]$md.Add(('| {0} | {1} | {2} | {3} | {4} | {5} | {6} | {7} |' -f $sevTag, $r.FailureReason, $tTitle, $remCell, $tNameCell, $tType, $r.LastOccurrence, $r.ResourceGroup)) } [void]$md.Add('
') [void]$md.Add('') diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetUpdateStatusReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetUpdateStatusReport.ps1 index e1d4c701..9d7a78f4 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetUpdateStatusReport.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetUpdateStatusReport.ps1 @@ -506,7 +506,7 @@ function Export-AzLocalFleetUpdateStatusReport { if ($runHistoryCount -gt 0) { $runFailures | - Select-Object ClusterName, UpdateName, State, Status, CurrentStep, Duration, StartTime, LastUpdated, DeepestStepName, ErrorCategory, DeepestErrMsg, UpdateRunPortalUrl, ClusterResourceId, RunId | + Select-Object ClusterName, UpdateName, State, Status, CurrentStep, Duration, StartTime, LastUpdated, DeepestStepName, DeepestStepDescription, ErrorCategory, DeepestErrMsg, UpdateRunPortalUrl, ClusterResourceId, RunId | Export-Csv -Path $runHistoryCsv -NoTypeInformation -Force $runFailures | ConvertTo-Json -Depth 4 | Out-File -FilePath $runHistoryJson -Encoding utf8 } @@ -659,8 +659,19 @@ function Export-AzLocalFleetUpdateStatusReport { try { $rowSec = [int][math]::Round([double]$f.DurationMinutes * 60, 0) } catch { $rowSec = 0 } if ($rowSec -lt 0) { $rowSec = 0 } } - $errBody = if ($f.DeepestErrMsg) { + # v0.8.80: combine deepest-step description (what was running) + # with errorMessage (why it failed) so the JUnit body shows both. + # Previously DeepestStepDescription was silently dropped because + # the coalesce preferred errorMessage. 4000-char total cap is + # preserved to keep the XML pageable. + $deepDesc = if ($f.PSObject.Properties['DeepestStepDescription']) { [string]$f.DeepestStepDescription } else { '' } + $errBody = if ($deepDesc -and $f.DeepestErrMsg) { + $combined = '{0}{1}{1}Trace: {2}' -f $deepDesc, [System.Environment]::NewLine, $f.DeepestErrMsg + if ($combined.Length -gt 4000) { $combined.Substring(0,4000) + ' ... (truncated)' } else { $combined } + } elseif ($f.DeepestErrMsg) { if ([string]$f.DeepestErrMsg.Length -gt 4000) { ([string]$f.DeepestErrMsg).Substring(0,4000) + ' ... (truncated)' } else { [string]$f.DeepestErrMsg } + } elseif ($deepDesc) { + if ($deepDesc.Length -gt 4000) { $deepDesc.Substring(0,4000) + ' ... (truncated)' } else { $deepDesc } } else { '(no error message captured)' } $sysOutLines = @( @@ -677,6 +688,23 @@ function Export-AzLocalFleetUpdateStatusReport { ) if ($f.StackTracePreview) { $sysOutLines += "Stack Trace Preview: $($f.StackTracePreview)" } + # v0.8.80: surface same-cluster Critical healthCheckResult entries + # for HealthCheck-category failures as a delimited block in systemOut + # so operators can see WHICH check fired without leaving the JUnit + # report. + if ($f.PSObject.Properties['HealthCheckEvidence'] -and $f.HealthCheckEvidence -and @($f.HealthCheckEvidence).Count -gt 0) { + $sysOutLines += '' + $sysOutLines += '---- Same-cluster Critical health check evidence (+/-2h window) ----' + foreach ($ev in @($f.HealthCheckEvidence)) { + $tsText = if ($ev.Timestamp) { ([datetime]$ev.Timestamp).ToString('s') + 'Z' } else { '' } + $titleTxt = if ($ev.Title) { $ev.Title } elseif ($ev.FailureReason) { $ev.FailureReason } else { '(unknown)' } + $targetTxt = if ($ev.TargetResourceName) { $ev.TargetResourceName } else { '-' } + $sysOutLines += ('[{0}] {1} (severity {2}, target {3})' -f $tsText, $titleTxt, $ev.Severity, $targetTxt) + if ($ev.TargetResourceID) { $sysOutLines += (' TargetResourceID: {0}' -f $ev.TargetResourceID) } + if ($ev.Remediation) { $sysOutLines += (' Remediation: {0}' -f $ev.Remediation) } + } + } + $fClusterPortal = if ($f.ClusterResourceId) { "https://portal.azure.com/#@/resource$($f.ClusterResourceId)" } else { '' } $rhTestCases.Add(@{ @@ -932,10 +960,42 @@ function Export-AzLocalFleetUpdateStatusReport { [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) { - $errCell = if ($r.DeepestErrMsg) { - $e = [string]$r.DeepestErrMsg - $e = $e -replace '&','&' -replace '<','<' -replace '>','>' - $e = $e -replace "`r`n",'
' -replace "`n",'
' -replace '\|','\|' + $errCell = if ($r.DeepestErrMsg -or ($r.PSObject.Properties['DeepestStepDescription'] -and $r.DeepestStepDescription)) { + # v0.8.80: surface the deepest step's `description` + # (human-readable line) as a heading inside the panel + # before the trace, so operators read 'what step was + # running' before 'what threw'. Previously the + # description was silently dropped. + $deepDescRaw = if ($r.PSObject.Properties['DeepestStepDescription']) { [string]$r.DeepestStepDescription } else { '' } + $errRaw = [string]$r.DeepestErrMsg + $deepDescHtml = if ($deepDescRaw) { + $d = $deepDescRaw -replace '&','&' -replace '<','<' -replace '>','>' + $d = $d -replace "`r`n",'
' -replace "`n",'
' -replace '\|','\|' + 'Step: ' + $d + '

' + } else { '' } + $eHtml = if ($errRaw) { + $e = $errRaw -replace '&','&' -replace '<','<' -replace '>','>' + $e = $e -replace "`r`n",'
' -replace "`n",'
' -replace '\|','\|' + '' + $e + '' + } else { '(no errorMessage trace)' } + + # v0.8.80: render Q1 same-cluster Critical + # healthCheckResult evidence as an inline list when + # ErrorCategory='HealthCheck'. + $evidenceBlock = '' + if ($r.PSObject.Properties['HealthCheckEvidence'] -and $r.HealthCheckEvidence -and @($r.HealthCheckEvidence).Count -gt 0) { + $evBullets = (@($r.HealthCheckEvidence) | Select-Object -First 10 | ForEach-Object { + $tsTxt = if ($_.Timestamp) { ([datetime]$_.Timestamp).ToString('s') + 'Z' } else { '' } + $titleTxt = if ($_.Title) { $_.Title } elseif ($_.FailureReason) { $_.FailureReason } else { '(unknown)' } + $titleTxt = $titleTxt -replace '&','&' -replace '<','<' -replace '>','>' -replace '\|','\|' + $tgtTxt = if ($_.TargetResourceName) { $_.TargetResourceName } else { '-' } + $tgtTxt = $tgtTxt -replace '&','&' -replace '<','<' -replace '>','>' -replace '\|','\|' + ('• [' + $tsTxt + '] ' + $titleTxt + ' (' + $_.Severity + ', target ' + $tgtTxt + ')') + }) -join '
' + $evCount = @($r.HealthCheckEvidence).Count + $evidenceBlock = '

Same-cluster Critical health check evidence (' + $evCount + ', most recent first, +/-2h window):
' + $evBullets + } + $extraBlock = '' if ($r.EarlierOccurrences -and @($r.EarlierOccurrences).Count -gt 0) { $bullets = (@($r.EarlierOccurrences) | ForEach-Object { @@ -944,7 +1004,7 @@ function Export-AzLocalFleetUpdateStatusReport { }) -join '
' $extraBlock = '

Earlier occurrences with the same error (' + (@($r.EarlierOccurrences).Count) + '):
' + $bullets } - '
Show error
' + $e + '' + $extraBlock + '
' + '
Show error
' + $deepDescHtml + $eHtml + $evidenceBlock + $extraBlock + '
' } else { '_(none)_' } # target="_blank" intentionally omitted: GitHub Actions + ADO step-summary diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalUpdateRunMonitorReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalUpdateRunMonitorReport.ps1 index 27eaadd1..5c62e2a6 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalUpdateRunMonitorReport.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalUpdateRunMonitorReport.ps1 @@ -403,6 +403,7 @@ function Export-AzLocalUpdateRunMonitorReport { Duration = $r.Duration CurrentStepDetail = $r.CurrentStepDetail ErrorMessage = if ($r.PSObject.Properties['ErrorMessage']) { [string]$r.ErrorMessage } else { '' } + ErrorDescription = if ($r.PSObject.Properties['ErrorDescription']) { [string]$r.ErrorDescription } else { '' } } } $rows = @($rows) @@ -428,7 +429,18 @@ function Export-AzLocalUpdateRunMonitorReport { $safeName = ($r.ClusterName -replace '[^A-Za-z0-9_.-]', '_') $caseName = "$safeName - $($r.UpdateName) - $($r.CurrentStep)" if ($r.HasStepError) { - $errSnippet = if ($r.ErrorMessage) { $r.ErrorMessage } else { '(no errorMessage on deepest failed step)' } + # v0.8.80: prefer the deepest step's `description` (human-readable + # line) PLUS the errorMessage trace when both are present, so the + # operator sees WHAT failed and WHY in the same cell. + $errSnippet = if ($r.ErrorDescription -and $r.ErrorMessage) { + '{0} - {1}' -f $r.ErrorDescription, $r.ErrorMessage + } elseif ($r.ErrorMessage) { + $r.ErrorMessage + } elseif ($r.ErrorDescription) { + $r.ErrorDescription + } else { + '(no errorMessage on deepest failed step)' + } $msg = ('Progress status is Error (state still InProgress) - step is stuck. CurrentStep: {0}. StepElapsed: {1}. ErrorMessage: {2}' -f $r.CurrentStep, $r.StepElapsedDisplay, $errSnippet) $testCases.Add(@{ Name = $caseName @@ -466,7 +478,17 @@ function Export-AzLocalUpdateRunMonitorReport { foreach ($r in ($unresolvedFailed | Sort-Object @{Expression='EndTimeUtc';Descending=$true})) { $safeName = ($r.ClusterName -replace '[^A-Za-z0-9_.-]', '_') $caseName = "$safeName - $($r.UpdateName) - FAILED" - $detail = if ($r.ErrorMessage) { $r.ErrorMessage } elseif ($r.CurrentStepDetail) { $r.CurrentStepDetail } else { $r.CurrentStep } + $detail = if ($r.ErrorDescription -and $r.ErrorMessage) { + '{0} - {1}' -f $r.ErrorDescription, $r.ErrorMessage + } elseif ($r.ErrorMessage) { + $r.ErrorMessage + } elseif ($r.ErrorDescription) { + $r.ErrorDescription + } elseif ($r.CurrentStepDetail) { + $r.CurrentStepDetail + } else { + $r.CurrentStep + } $msg = ('Run failed at {0} UTC. Step: {1}. Detail: {2}.' -f $r.EndTimeUtc, $r.CurrentStep, $detail) $testCases.Add(@{ Name = $caseName @@ -582,7 +604,18 @@ function Export-AzLocalUpdateRunMonitorReport { [void]$md.Add('|---------|--------|-------------|-------------|-----------------------|--------|') foreach ($r in ($unresolvedFailed | Sort-Object @{Expression='EndTimeUtc';Descending=$true})) { $cs = if ($r.CurrentStep) { $r.CurrentStep } else { '-' } - $rawDetail = if ($r.ErrorMessage) { [string]$r.ErrorMessage } elseif ($r.CurrentStepDetail) { [string]$r.CurrentStepDetail } else { '' } + # v0.8.80: combine the deepest step's description (the human + # 'what step was running') with its errorMessage (the trace). + # Falls back through ErrorMessage -> ErrorDescription -> CurrentStepDetail. + $rawDetail = if ($r.ErrorDescription -and $r.ErrorMessage) { + ('{0}{1}{1}Trace: {2}' -f $r.ErrorDescription, [System.Environment]::NewLine, $r.ErrorMessage) + } elseif ($r.ErrorMessage) { + [string]$r.ErrorMessage + } elseif ($r.ErrorDescription) { + [string]$r.ErrorDescription + } elseif ($r.CurrentStepDetail) { + [string]$r.CurrentStepDetail + } else { '' } $detailCell = if ([string]::IsNullOrWhiteSpace($rawDetail)) { '_(no error detail)_' } else { $e = $rawDetail -replace '&','&' -replace '<','<' -replace '>','>' $e = $e -replace "`r`n",'
' -replace "`n",'
' -replace '\|','\|' diff --git a/AzLocal.UpdateManagement/Public/Get-AzLocalFleetHealthFailures.ps1 b/AzLocal.UpdateManagement/Public/Get-AzLocalFleetHealthFailures.ps1 index 0795b703..c4cafb24 100644 --- a/AzLocal.UpdateManagement/Public/Get-AzLocalFleetHealthFailures.ps1 +++ b/AzLocal.UpdateManagement/Public/Get-AzLocalFleetHealthFailures.ps1 @@ -217,10 +217,12 @@ extensibilityresources Severity = $sev FailureName = "$($hc.name)" FailureReason = "$($hc.displayName)" + Title = "$($hc.title)" Description = "$($hc.description)" Remediation = "$($hc.remediation)" TargetResourceName = "$($hc.targetResourceName)" TargetResourceType = "$($hc.targetResourceType)" + TargetResourceID = "$($hc.targetResourceID)" LastOccurrence = $ts }) } @@ -316,7 +318,10 @@ $ringFilter # Detail view: pass-through after surfacing severity ordering so that # JUnit and CSV consumers see Critical rows before Warning rows. # v0.7.70 added TargetResourceName, TargetResourceType, ClusterPortalUrl. - $output = @($rows | Select-Object ClusterName, ResourceGroup, SubscriptionId, Severity, FailureReason, FailureName, Description, Remediation, TargetResourceName, TargetResourceType, LastOccurrence, ClusterResourceId, ClusterPortalUrl) + # v0.8.80 added Title, TargetResourceID (the full ARM resource id + # of the failing component; lets renderers deep-link Server/Volume + # health failures back to the exact node or volume in the portal). + $output = @($rows | Select-Object ClusterName, ResourceGroup, SubscriptionId, Severity, FailureReason, FailureName, Title, Description, Remediation, TargetResourceName, TargetResourceType, TargetResourceID, LastOccurrence, ClusterResourceId, ClusterPortalUrl) } # Export if requested. diff --git a/AzLocal.UpdateManagement/Public/Get-AzLocalUpdateRunFailures.ps1 b/AzLocal.UpdateManagement/Public/Get-AzLocalUpdateRunFailures.ps1 index 41155fb9..25738319 100644 --- a/AzLocal.UpdateManagement/Public/Get-AzLocalUpdateRunFailures.ps1 +++ b/AzLocal.UpdateManagement/Public/Get-AzLocalUpdateRunFailures.ps1 @@ -110,10 +110,14 @@ function Get-AzLocalUpdateRunFailures { PSCustomObject[] - Detail view columns: ClusterName, ResourceGroup, SubscriptionId, ClusterResourceId, UpdateName, RunId, State, StartTime, EndTime, DurationMinutes, - DeepestStepDepth (1-8 or 0), DeepestStepName, DeepestErrMsg, - StackTracePreview, ErrorCategory, ProgressJsonBytes, + DeepestStepDepth (1-8 or 0), DeepestStepName, DeepestStepDescription, + DeepestErrMsg, StackTracePreview, ErrorCategory, ProgressJsonBytes, IsUnresolved (only when -OnlyUnresolved is used), - ProgressJson (only when -IncludeRawProgress is used) + ProgressJson (only when -IncludeRawProgress is used), + HealthCheckEvidence (only when ErrorCategory='HealthCheck' and + -EnrichWithHealthEvidence is on (default $true) - an array of + same-cluster Critical healthCheckResult entries from + updateSummaries scoped to a +/-2h window around the run) Summary view columns: ErrorCategory, ClusterCount, FailureCount, AffectedClusters, @@ -187,6 +191,16 @@ function Get-AzLocalUpdateRunFailures { [Parameter(Mandatory = $false)] [string]$ExportPath, + # v0.8.80: when ErrorCategory='HealthCheck' on a Detail row, fetch + # the cluster's `updateSummaries.healthCheckResult` entries that + # fired within a +/-2h window of the run and attach them to the + # output row as `HealthCheckEvidence` (an array of slim projections) + # so renderers can show WHICH check fired. Default $true; pass + # -EnrichWithHealthEvidence:$false to skip the extra ARG hops when + # the caller doesn't need the evidence (e.g. CSV exports). + [Parameter(Mandatory = $false)] + [bool]$EnrichWithHealthEvidence = $true, + [Parameter(Mandatory = $false)] [switch]$PassThru ) @@ -314,6 +328,7 @@ $clusterClause $walk = Resolve-AzLocalUpdateRunDeepestError -Steps $r.ProgressSteps $deepDepth = [int]$walk.Depth $deepName = [string]$walk.Name + $deepDesc = if ($walk.PSObject.Properties['Description'] -and $walk.Description) { [string]$walk.Description } else { '' } $deepMsg = if ($deepDepth -gt 0 -and $walk.Msg) { [string]$walk.Msg } elseif ($walk.FirstDescription) { @@ -338,10 +353,11 @@ $clusterClause # Attach the computed columns. Add-Member -Force overwrites if the # property already exists (defensive against mocks that pre-populate # them). - $r | Add-Member -NotePropertyName 'DeepestStepDepth' -NotePropertyValue $deepDepth -Force - $r | Add-Member -NotePropertyName 'DeepestStepName' -NotePropertyValue $deepName -Force - $r | Add-Member -NotePropertyName 'DeepestErrMsg' -NotePropertyValue $deepMsg -Force - $r | Add-Member -NotePropertyName 'ErrorCategory' -NotePropertyValue $deepCategory -Force + $r | Add-Member -NotePropertyName 'DeepestStepDepth' -NotePropertyValue $deepDepth -Force + $r | Add-Member -NotePropertyName 'DeepestStepName' -NotePropertyValue $deepName -Force + $r | Add-Member -NotePropertyName 'DeepestStepDescription' -NotePropertyValue $deepDesc -Force + $r | Add-Member -NotePropertyName 'DeepestErrMsg' -NotePropertyValue $deepMsg -Force + $r | Add-Member -NotePropertyName 'ErrorCategory' -NotePropertyValue $deepCategory -Force } # Drop the intermediate ProgressSteps column so callers see the @@ -523,6 +539,7 @@ extensibilityresources DurationMinutes = $r.DurationMinutes DeepestStepDepth = $r.DeepestStepDepth DeepestStepName = $r.DeepestStepName + DeepestStepDescription = if ($r.PSObject.Properties['DeepestStepDescription']) { [string]$r.DeepestStepDescription } else { '' } DeepestErrMsg = $r.DeepestErrMsg StackTracePreview = $r.StackTracePreview ErrorCategory = $r.ErrorCategory @@ -533,6 +550,29 @@ extensibilityresources if ($IncludeRawProgress) { $obj | Add-Member -NotePropertyName 'ProgressJson' -NotePropertyValue $r.ProgressJson } + # v0.8.80: fetch same-cluster Critical healthCheckResult entries + # that fired within a +/-2h window of the run, so renderers can + # show WHICH check fired on a HealthCheck-category failure. + # Scoped to ErrorCategory='HealthCheck' to keep ARG query count + # bounded (one extra hop per failed-with-health-cat row, not + # per row). Skipped when -EnrichWithHealthEvidence:$false. + if ($EnrichWithHealthEvidence -and $r.ErrorCategory -eq 'HealthCheck' -and $r.ClusterResourceId -and $r.StartTime -and $r.EndTime) { + try { + $evidenceRows = Get-AzLocalUpdateRunHealthEvidence ` + -ClusterResourceId $r.ClusterResourceId ` + -RunStartTime ([datetime]$r.StartTime) ` + -RunEndTime ([datetime]$r.EndTime) ` + -SubscriptionId:$SubscriptionId + $obj | Add-Member -NotePropertyName 'HealthCheckEvidence' -NotePropertyValue (@($evidenceRows)) + } + catch { + Write-Log -Message "HealthCheck evidence enrichment failed for $($r.ClusterName)/$($r.RunId): $($_.Exception.Message)" -Level Warning + $obj | Add-Member -NotePropertyName 'HealthCheckEvidence' -NotePropertyValue (@()) + } + } + else { + $obj | Add-Member -NotePropertyName 'HealthCheckEvidence' -NotePropertyValue (@()) + } $obj } diff --git a/AzLocal.UpdateManagement/Public/Get-AzLocalUpdateRuns.ps1 b/AzLocal.UpdateManagement/Public/Get-AzLocalUpdateRuns.ps1 index f37a810c..851e3897 100644 --- a/AzLocal.UpdateManagement/Public/Get-AzLocalUpdateRuns.ps1 +++ b/AzLocal.UpdateManagement/Public/Get-AzLocalUpdateRuns.ps1 @@ -464,6 +464,7 @@ $updateNameClause StepStartTime = $formatted.StepStartTime StepElapsed = $formatted.StepElapsed ErrorMessage = $formatted.ErrorMessage + ErrorDescription = if ($formatted.PSObject.Properties['ErrorDescription']) { [string]$formatted.ErrorDescription } else { '' } Location = $formatted.Location } } @@ -499,6 +500,7 @@ $updateNameClause StepStartTime = '' StepElapsed = '' ErrorMessage = '' + ErrorDescription = '' Location = '' }) } diff --git a/AzLocal.UpdateManagement/Public/Test-AzLocalClusterHealth.ps1 b/AzLocal.UpdateManagement/Public/Test-AzLocalClusterHealth.ps1 index 06ea1400..bd5a8168 100644 --- a/AzLocal.UpdateManagement/Public/Test-AzLocalClusterHealth.ps1 +++ b/AzLocal.UpdateManagement/Public/Test-AzLocalClusterHealth.ps1 @@ -300,8 +300,18 @@ function Test-AzLocalClusterHealth { $description = if ($check.description) { $check.description } else { "" } $remediation = if ($check.remediation) { $check.remediation } else { "" } $targetResName = if ($check.targetResourceName) { $check.targetResourceName } else { "" } + # v0.8.80: surface the ARM resource id of the failing + # component (e.g. /.../arcSettings/.../servers/NODE01) + # so renderers can deep-link Server/Volume health + # failures back to the exact node or volume rather + # than just the cluster. Also surface the cluster- + # level `title` field, which is often more specific + # than `displayName` (e.g. 'Disk firmware update required + # on NODE01' vs displayName 'Disk firmware check'). + $title = if ($check.title) { "$($check.title)" } else { "" } + $targetResId = if ($check.targetResourceID) { "$($check.targetResourceID)" } else { "" } $timestamp = if ($check.timestamp) { $check.timestamp } else { "" } - $key = $clusterName + $usSep + $displayName + $usSep + $sev + $usSep + $description + $usSep + $remediation + $usSep + $targetResName + $usSep + $timestamp + $key = $clusterName + $usSep + $displayName + $usSep + $sev + $usSep + $description + $usSep + $remediation + $usSep + $targetResName + $usSep + $title + $usSep + $timestamp if (-not $seenKeys.Add($key)) { Write-Verbose "Suppressing duplicate healthCheckResult row for cluster '$clusterName' check '$displayName' target '$targetResName' timestamp '$timestamp' (ARM upstream duplicate)." continue @@ -310,9 +320,11 @@ function Test-AzLocalClusterHealth { ClusterName = $clusterName CheckName = $displayName Severity = $sev + Title = $title Description = $description Remediation = $remediation TargetResourceName = $targetResName + TargetResourceID = $targetResId Timestamp = $timestamp } } @@ -372,7 +384,7 @@ function Test-AzLocalClusterHealth { if ($allFailures.Count -gt 0) { Write-Log -Message "" -Level Info Write-Log -Message "Health Check Failures:" -Level Header - $allFailures | Format-Table ClusterName, Severity, CheckName, TargetResourceName, Description -AutoSize -Wrap | Out-String -Stream | ForEach-Object { + $allFailures | Format-Table ClusterName, Severity, CheckName, Title, TargetResourceName, Description -AutoSize -Wrap | Out-String -Stream | ForEach-Object { if ($_ -ne "") { Write-Log -Message $_ -Level Info } } @@ -443,9 +455,13 @@ function Test-AzLocalClusterHealth { 'JUnitXml' { $junitResults = $allFailures | ForEach-Object { $junitNodeInfo = if ($_.TargetResourceName) { " (Node: $($_.TargetResourceName))" } else { "" } + # v0.8.80: prefer the per-check `title` (often more + # specific than displayName/CheckName, e.g. names the + # failing node or volume) when present. + $junitTitle = if ($_.PSObject.Properties['Title'] -and $_.Title) { [string]$_.Title } else { [string]$_.CheckName } [PSCustomObject]@{ ClusterName = $_.ClusterName; Status = "Failed" - Message = "$($_.Severity): $($_.CheckName)$junitNodeInfo - $($_.Description)" + Message = "$($_.Severity): $junitTitle$junitNodeInfo - $($_.Description)" UpdateName = $_.CheckName; CurrentState = $_.Severity } } diff --git a/AzLocal.UpdateManagement/README.md b/AzLocal.UpdateManagement/README.md index e431d053..bd56a6bb 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.79 - [Published in PowerShell Gallery](https://www.powershellgallery.com/packages/AzLocal.UpdateManagement/0.8.79) +**Latest Version:** v0.8.80 - [Published in PowerShell Gallery](https://www.powershellgallery.com/packages/AzLocal.UpdateManagement/0.8.80) > 📢 **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.79](#whats-new-in-v0879) +- [What's New in v0.8.80](#whats-new-in-v0880) - [Files](#files) - [Prerequisites](#prerequisites) - [RBAC Requirements](#rbac-requirements) (summary; full reference in [docs/rbac.md](docs/rbac.md)) @@ -86,32 +86,36 @@ 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.79 +## What's New in v0.8.80 -**Patch release. Adds an operator-only "Force Immediate Update" break-glass override** to the Step.07 apply-updates pipeline. Intended for emergency / out-of-window patching driven by an on-call operator; **defaults to OFF**; **cannot be reached from the scheduled `apply-updates-schedule.yml` configuration file**. +**Minor release. Pipeline failure-rendering improvements** bundled as three additive changes targeting the Step.05 / Step.08 / Step.09 / Step.10 step summaries. All changes are backward-compatible. One new private helper (`Get-AzLocalUpdateRunHealthEvidence`). -### What it does +### Q1 - HealthCheck failure enrichment (Step.09 `fleet-update-status`) -When enabled, the per-cluster Step 3c maintenance-window gate (`UpdateStartWindow` / `UpdateExclusionsWindow` tags) is **bypassed** and updates start regardless of the current UTC time. All other readiness gates (connectivity, health, sideload status, `UpdateExcluded` operator hard-override) continue to apply. The override is opt-in per-run on both pipeline hosts: +When `Get-AzLocalUpdateRunFailures` categorises a failed run as `ErrorCategory='HealthCheck'` (the default), it now attaches a `HealthCheckEvidence` array column carrying same-cluster **Critical** `updateSummaries.healthCheckResult` entries that fired within a `+/-2h` window around the run. The Step.09 markdown step summary renders this evidence as bullets inside the per-row `
` panel and the JUnit XML systemOut block lists each evidence entry's timestamp / title / severity / target resource id. New parameter `[bool]$EnrichWithHealthEvidence = $true` lets CSV-only consumers opt out of the extra ARG hops. -- **GitHub Actions:** new `force_immediate_update` `workflow_dispatch` choice input (default `'false'`). Description rendered in the Run Workflow GUI starts with `WARNING:` and explicitly notes `MANUAL RUNS ONLY`. -- **Azure DevOps:** new `forceImmediateUpdate` boolean `parameters:` entry (default `false`). `displayName:` in the Queue Build GUI starts with `WARNING:` and explicitly notes `MANUAL QUEUE ONLY`. -- **Direct cmdlet use:** new `Invoke-AzLocalReadinessGatedClusterUpdate -ForceImmediateUpdate` switch (which forwards `-IgnoreScheduleTags` to `Start-AzLocalClusterUpdate` for every cluster in the readiness CSV). A high-visibility warning banner (`::warning::` on GHA, `##vso[task.logissue type=warning]` on ADO, `Write-Warning` otherwise) is emitted at the top of the run when the override fires. +The new private helper `Get-AzLocalUpdateRunHealthEvidence` performs a single ARG hop per HealthCheck-category row, scoped to one cluster id, reusing the existing anti-mv-expand 128-cap projection pattern. -### Security / anti-injection design +### Q2 - Title and TargetResourceID columns (Step.05 + Step.10) -Two layers of defence prevent a scheduled cron firing from honouring the override: +Both fleet-wide (`Get-AzLocalFleetHealthFailures`, Step.10) and per-cluster (`Test-AzLocalClusterHealth`, Step.05) health failure outputs now project two extra fields off every `properties.healthCheckResult` entry: -1. **GitHub Actions YAML expression collapse**: `${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_immediate_update || 'false' }}` - any `schedule` / `push` / `pull_request` / `repository_dispatch` event sees `'false'` even if a malicious upstream tried to inject the input. -2. **Azure DevOps runtime guard**: the apply step rechecks `$(Build.Reason) -eq 'Manual'` before flipping the switch. Scheduled / CI / PR runs log a `##vso[task.logissue type=warning]` explaining the parameter was ignored. +- **`Title`** - the per-check title (often more specific than `displayName`, e.g. names the failing node or volume). +- **`TargetResourceID`** - full ARM id of the failing component; lets renderers deep-link Server / Volume health failures back to the exact node or volume. -The override is **deliberately not surfaced** by `New-AzLocalApplyUpdatesScheduleConfig` / `Resolve-AzLocalPipelineUpdateRing` / `Get-AzLocalApplyUpdatesScheduleAudit` - there is no path to enable it from `apply-updates-schedule.yml`. A Pester sweep asserts this. +`Export-AzLocalFleetHealthStatusReport` adds Title as a markdown column and wraps TargetResourceName in a portal hyperlink when TargetResourceID is present. `Test-AzLocalClusterHealth` adds Title to the dedup composite key, the `Format-Table` console output, and the JUnit `Message` field. -The `UpdateExcluded` operator hard-override (`AzureLocalManagement.UpdateExcluded=true`) is INTENTIONALLY still respected by `Force Immediate Update` - the break-glass override is about time-window gates, not per-cluster opt-outs. If a cluster must be force-included despite an `UpdateExcluded` tag, remove the tag first. +### Q3 - Surface BOTH deepest-step `description` AND `errorMessage` (Step.08 + Step.09) -`GENERATED_AGAINST_MODULE_VERSION` bumped from `0.8.78` to `0.8.79` across all bundled pipeline templates. +The two deepest-error walkers (`Resolve-AzLocalUpdateRunDeepestError` and `Get-DeepestErrorMessage`) now capture step `description` at the same recursion site as `errorMessage` (previously description was silently dropped via coalesce). New `DeepestStepDescription` column on `Get-AzLocalUpdateRunFailures`; new `ErrorDescription` field on `Format-AzLocalUpdateRun` / `Get-AzLocalUpdateRuns -PassThru`. The Step.08 (`Export-AzLocalUpdateRunMonitorReport`) and Step.09 (`Export-AzLocalFleetUpdateStatusReport`) renderers combine `description + errorMessage` in markdown failure cells and JUnit bodies. Cross-reference `.NOTES` headers added to the four parallel functions so future maintainers know to keep them in sync. -See [CHANGELOG.md](CHANGELOG.md#0879---2026-06-15) for the full v0.8.79 entry. See [`What's New in v0.8.78`](docs/release-history.md#whats-new-in-v0878) in the Release History for the previous release. +### Notes + +- **No new exports** (count unchanged at 60). +- **Step.07** (`Add-AzLocalApplyUpdatesStepSummary`) was deliberately left out of the Q1 / Q3 wiring because its data source is the live `Invoke-AzLocalReadinessGatedClusterUpdate` result row (apply-results.json), not `Get-AzLocalUpdateRunFailures`. Surfacing Q1 / Q3 at Step.07 requires a separate upstream change and is tracked for a follow-up release. +- **`GENERATED_AGAINST_MODULE_VERSION`** bumped from `0.8.79` to `0.8.80` across all bundled pipeline templates. + +See [CHANGELOG.md](CHANGELOG.md#0880---2026-06-16) for the full v0.8.80 entry. See [`What's New in v0.8.79`](docs/release-history.md#whats-new-in-v0879) in the Release History for the previous release. ## Files @@ -590,7 +594,7 @@ 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.79** stay above under [`What's New in v0.8.79`](#whats-new-in-v0879). +The most recent release notes for **v0.8.80** stay above under [`What's New in v0.8.80`](#whats-new-in-v0880). ### What's New in v0.8.78 diff --git a/AzLocal.UpdateManagement/Tests/AzLocal.UpdateManagement.Tests.ps1 b/AzLocal.UpdateManagement/Tests/AzLocal.UpdateManagement.Tests.ps1 index f90208e6..689d353f 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.79' { - $script:ModuleInfo.Version | Should -Be '0.8.79' + It 'Should have version 0.8.80' { + $script:ModuleInfo.Version | Should -Be '0.8.80' } It 'Module version constants are in sync between .psm1 and .psd1' { @@ -8460,8 +8460,19 @@ schedule: # At least one row should reference Ring1 and one Ring2 $md | Should -Match '`Ring1`' $md | Should -Match '`Ring2`' - # At least one row should be marked as cycle-wrap (week 1 immediately after week 2 OR vice versa within 14 days) - $md | Should -Match '_\(cycle wraps\)_' + # Cycle-wrap marker only appears when the 14-day projection + # crosses the cycle boundary. With cycleWeeks=2 and a 14-day + # window starting on day-1 of the cycle the window ends exactly + # at the boundary - no wrap row is emitted. Compute today's + # day-in-cycle from the anchor and only assert when wrap is + # expected. + $anchor = [datetime]'2024-01-01' # ISO week 1, 2024 (Monday) + $todayUtc = [datetime]::UtcNow.Date + $daysSinceAnchor = ($todayUtc - $anchor).Days + $dayInCycle = $daysSinceAnchor % 14 + if ($dayInCycle -ne 0) { + $md | Should -Match '_\(cycle wraps\)_' + } } } @@ -18412,3 +18423,249 @@ Describe 'Sideload (v0.8.7): Add-AzLocalSideloadStepSummary' { } #endregion On-Prem Sideloading Automation (v0.8.7) + +#region v0.8.80 Pipeline-failure rendering improvements (Q1 / Q2 / Q3) + +Describe 'v0.8.80 Q3: Get-DeepestErrorMessage -IncludeDescription' { + + It 'Returns a string by default (back-compat)' { + InModuleScope AzLocal.UpdateManagement { + $steps = @( + [PSCustomObject]@{ + name = 'L1'; status = 'Failed'; description = 'L1 desc'; errorMessage = 'L1 trace' + } + ) + $r = Get-DeepestErrorMessage -Steps $steps + $r | Should -BeOfType ([string]) + $r | Should -Be 'L1 trace' + } + } + + It 'Returns a hashtable with Msg + Description when -IncludeDescription is set' { + InModuleScope AzLocal.UpdateManagement { + $steps = @( + [PSCustomObject]@{ + name = 'L1'; status = 'Failed'; description = 'outer desc'; errorMessage = 'outer trace' + steps = @( + [PSCustomObject]@{ + name = 'L2'; status = 'Error'; description = 'inner desc'; errorMessage = 'inner trace' + } + ) + } + ) + $r = Get-DeepestErrorMessage -Steps $steps -IncludeDescription + $r | Should -BeOfType ([hashtable]) + $r.Msg | Should -Be 'inner trace' + $r.Description | Should -Be 'inner desc' + } + } + + It 'Returns hashtable with empty string fields when no failed step is found (-IncludeDescription)' { + InModuleScope AzLocal.UpdateManagement { + $r = Get-DeepestErrorMessage -Steps @() -IncludeDescription + $r | Should -BeOfType ([hashtable]) + $r.Msg | Should -Be '' + $r.Description | Should -Be '' + } + } + + It 'Should NOT throw on a Failed step that lacks a description property (strict-mode guard)' { + InModuleScope AzLocal.UpdateManagement { + $steps = @( + [PSCustomObject]@{ + name = 'L1'; status = 'Failed'; errorMessage = 'no desc here' + } + ) + { Get-DeepestErrorMessage -Steps $steps -IncludeDescription } | Should -Not -Throw + $r = Get-DeepestErrorMessage -Steps $steps -IncludeDescription + $r.Msg | Should -Be 'no desc here' + $r.Description | Should -Be '' + } + } +} + +Describe 'v0.8.80 Q3: Resolve-AzLocalUpdateRunDeepestError captures Description' { + + It 'Should emit a Description field on the result hashtable' { + InModuleScope AzLocal.UpdateManagement { + $steps = @( + [PSCustomObject]@{ + name = 'Outer'; status = 'Failed'; description = 'outer desc'; errorMessage = 'outer trace' + steps = @( + [PSCustomObject]@{ + name = 'Deepest'; status = 'Error'; description = 'deepest desc'; errorMessage = 'deepest trace' + } + ) + } + ) + $r = Resolve-AzLocalUpdateRunDeepestError -Steps $steps + $r | Should -BeOfType ([hashtable]) + $r.ContainsKey('Description') | Should -BeTrue + $r.Description | Should -Be 'deepest desc' + $r.Msg | Should -Be 'deepest trace' + } + } +} + +Describe 'v0.8.80 Q3: Format-AzLocalUpdateRun emits ErrorDescription' { + + It 'Should always emit an ErrorDescription property (empty string when no error)' { + InModuleScope AzLocal.UpdateManagement { + $run = [PSCustomObject]@{ + name = '2026-01-01T00:00:00Z' + properties = [PSCustomObject]@{ + state = 'Succeeded' + timeStarted = (Get-Date).ToUniversalTime().ToString('o') + lastUpdatedTime = (Get-Date).ToUniversalTime().ToString('o') + steps = @() + } + } + $out = Format-AzLocalUpdateRun -Run $run + $out.PSObject.Properties.Name | Should -Contain 'ErrorDescription' + $out.PSObject.Properties.Name | Should -Contain 'ErrorMessage' + } + } + + It 'Should populate ErrorDescription from the deepest Failed step description' { + InModuleScope AzLocal.UpdateManagement { + $run = [PSCustomObject]@{ + name = '2026-01-01T00:00:00Z' + properties = [PSCustomObject]@{ + state = 'Failed' + timeStarted = (Get-Date).ToUniversalTime().ToString('o') + lastUpdatedTime = (Get-Date).ToUniversalTime().ToString('o') + progress = [PSCustomObject]@{ + steps = @( + [PSCustomObject]@{ + name = 'Stage'; status = 'Failed'; description = 'Outer stage desc'; errorMessage = 'outer trace' + steps = @( + [PSCustomObject]@{ + name = 'Inner'; status = 'Error'; description = 'Inner step desc'; errorMessage = 'inner trace' + } + ) + } + ) + } + } + } + $out = Format-AzLocalUpdateRun -Run $run + $out.ErrorDescription | Should -Be 'Inner step desc' + $out.ErrorMessage | Should -Be 'inner trace' + } + } +} + +Describe 'v0.8.80 Q3: Get-AzLocalUpdateRuns -PassThru exposes ErrorDescription' { + + It 'Should declare ErrorDescription on the live-row PSCustomObject shape (Add-Member contract)' { + # Read from the dev-tree source (NOT a possibly-stale PSGallery + # copy of the module). + $src = Get-Content "$PSScriptRoot/../Public/Get-AzLocalUpdateRuns.ps1" -Raw + $src | Should -Match 'ErrorDescription\s*=' + } +} + +Describe 'v0.8.80 Q3: Get-AzLocalUpdateRunFailures exposes DeepestStepDescription' { + + It 'Should declare DeepestStepDescription column in the Detail Select projection' { + $src = Get-Content "$PSScriptRoot/../Public/Get-AzLocalUpdateRunFailures.ps1" -Raw + $src | Should -Match 'DeepestStepDescription' + } + + It 'Should declare EnrichWithHealthEvidence parameter (defaults true) for Q1 opt-out' { + $command = Get-Command Get-AzLocalUpdateRunFailures + $command.Parameters.Keys | Should -Contain 'EnrichWithHealthEvidence' + $command.Parameters['EnrichWithHealthEvidence'].ParameterType.FullName | Should -Be 'System.Boolean' + } +} + +Describe 'v0.8.80 Q1: Get-AzLocalUpdateRunHealthEvidence helper' { + + It 'Should be a private (non-exported) helper function' { + InModuleScope AzLocal.UpdateManagement { + $command = Get-Command Get-AzLocalUpdateRunHealthEvidence -ErrorAction SilentlyContinue + $command | Should -Not -BeNullOrEmpty + } + # Should NOT be exported as a public cmdlet + (Get-Command Get-AzLocalUpdateRunHealthEvidence -ErrorAction SilentlyContinue -Module AzLocal.UpdateManagement | Where-Object { $_.CommandType -eq 'Function' -and $_.Visibility -eq 'Public' }) | Should -BeNullOrEmpty + } + + It 'Should declare WindowBefore / WindowAfter / MinSeverity parameters' { + InModuleScope AzLocal.UpdateManagement { + $command = Get-Command Get-AzLocalUpdateRunHealthEvidence + $command.Parameters.Keys | Should -Contain 'ClusterResourceId' + $command.Parameters.Keys | Should -Contain 'RunStartTime' + $command.Parameters.Keys | Should -Contain 'RunEndTime' + $command.Parameters.Keys | Should -Contain 'WindowBefore' + $command.Parameters.Keys | Should -Contain 'WindowAfter' + $command.Parameters.Keys | Should -Contain 'MinSeverity' + } + } + + It 'Should return @() (empty) when the underlying ARG query throws' { + InModuleScope AzLocal.UpdateManagement { + Mock Invoke-AzResourceGraphQuery { throw 'simulated ARG failure' } + # Wrap with @() because the function returns an empty pipeline + # on the throw-path; assignment receives $null without @(). + $r = @(Get-AzLocalUpdateRunHealthEvidence ` + -ClusterResourceId '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/c1' ` + -RunStartTime (Get-Date).AddHours(-1) ` + -RunEndTime (Get-Date) 6>$null) + $r.Count | Should -Be 0 + } + } +} + +Describe 'v0.8.80 Q2: Get-AzLocalFleetHealthFailures exposes Title + TargetResourceID' { + + It 'Should declare Title in the Detail Select projection source' { + $src = Get-Content "$PSScriptRoot/../Public/Get-AzLocalFleetHealthFailures.ps1" -Raw + $src | Should -Match 'Title\s*=' + $src | Should -Match 'TargetResourceID' + } +} + +Describe 'v0.8.80 Q2: Test-AzLocalClusterHealth exposes Title + TargetResourceID' { + + It 'Should declare Title + TargetResourceID in the failure PSCustomObject source' { + $src = Get-Content "$PSScriptRoot/../Public/Test-AzLocalClusterHealth.ps1" -Raw + $src | Should -Match 'Title\s*=' + $src | Should -Match 'TargetResourceID' + } +} + +Describe 'v0.8.80 Q3: Export-AzLocalUpdateRunMonitorReport renders description + trace' { + + It 'Source includes ErrorDescription in row schema (Q3 wiring)' { + $src = Get-Content "$PSScriptRoot/../Public/Export-AzLocalUpdateRunMonitorReport.ps1" -Raw + $src | Should -Match 'ErrorDescription' + } +} + +Describe 'v0.8.80 Q1+Q3: Export-AzLocalFleetUpdateStatusReport renders evidence + description' { + + It 'Source includes DeepestStepDescription in the CSV projection (Q3 wiring)' { + $src = Get-Content "$PSScriptRoot/../Public/Export-AzLocalFleetUpdateStatusReport.ps1" -Raw + $src | Should -Match 'DeepestStepDescription' + } + + It 'Source includes HealthCheckEvidence rendering (Q1 wiring)' { + $src = Get-Content "$PSScriptRoot/../Public/Export-AzLocalFleetUpdateStatusReport.ps1" -Raw + $src | Should -Match 'HealthCheckEvidence' + } +} + +Describe 'v0.8.80 Q2: Export-AzLocalFleetHealthStatusReport renders Title + portal deep-link' { + + It 'Source includes Title column in markdown header (Q2 wiring)' { + $src = Get-Content "$PSScriptRoot/../Public/Export-AzLocalFleetHealthStatusReport.ps1" -Raw + $src | Should -Match '\|\s*Title\s*\|' + } + + It 'Source includes portal deep-link wrapper for TargetResourceID' { + $src = Get-Content "$PSScriptRoot/../Public/Export-AzLocalFleetHealthStatusReport.ps1" -Raw + $src | Should -Match 'portal\.azure\.com/#@/resource' + } +} + +#endregion v0.8.80 Pipeline-failure rendering improvements (Q1 / Q2 / Q3) diff --git a/AzLocal.UpdateManagement/Tests/test-run-timings.csv b/AzLocal.UpdateManagement/Tests/test-run-timings.csv index d3db1402..35fd150a 100644 --- a/AzLocal.UpdateManagement/Tests/test-run-timings.csv +++ b/AzLocal.UpdateManagement/Tests/test-run-timings.csv @@ -14,3 +14,4 @@ TimestampUtc,ModuleVersion,Total,Passed,Failed,Skipped,WallClockSeconds,PesterDu "2026-06-14T11:51:58Z","0.8.78","1189","1150","0","1","49.95","48.7","3.74","Invoke-Tests.ps1","5.1.26100.8655" "2026-06-14T18:27:18Z","0.8.79","1200","1160","1","1","86.13","84.83","9.85","Invoke-Tests.ps1","5.1.26100.8655" "2026-06-14T18:29:15Z","0.8.79","1200","1161","0","1","46.34","45.08","2.96","Invoke-Tests.ps1","5.1.26100.8655" +"2026-06-15T10:31:58Z","0.8.80","1220","1176","5","1","185.27","181.31","18.22","Invoke-Tests.ps1","5.1.26100.8730" diff --git a/AzLocal.UpdateManagement/docs/release-history.md b/AzLocal.UpdateManagement/docs/release-history.md index fc296401..6517b72e 100644 --- a/AzLocal.UpdateManagement/docs/release-history.md +++ b/AzLocal.UpdateManagement/docs/release-history.md @@ -4,7 +4,19 @@ > > **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.79 (the current release)**, see the main [README.md](../README.md#whats-new-in-v0879) `What's New in v0.8.79` section. +> **For v0.8.80 (the current release)**, see the main [README.md](../README.md#whats-new-in-v0880) `What's New in v0.8.80` section. + +--- + +### What's New in v0.8.79 + +v0.8.79 was a patch release that added an operator-only **Force Immediate Update** break-glass override to the Step.07 apply-updates pipeline. Intended for emergency / out-of-window patching driven by an on-call operator; defaults to OFF; unreachable from the scheduled `apply-updates-schedule.yml` configuration file. + +When enabled, the per-cluster Step 3c maintenance-window gate (`UpdateStartWindow` / `UpdateExclusionsWindow` tags) is bypassed and updates start regardless of the current UTC time. All other readiness gates (connectivity, health, sideload status, `UpdateExcluded` operator hard-override) continue to apply. The override is opt-in per-run on both pipeline hosts: a `force_immediate_update` `workflow_dispatch` choice input on GitHub Actions (default `'false'`, description starts with `WARNING:` and notes `MANUAL RUNS ONLY`), and a `forceImmediateUpdate` boolean `parameters:` entry on Azure DevOps (default `false`, `displayName:` starts with `WARNING:` and notes `MANUAL QUEUE ONLY`). The cmdlet-side surface is a new `Invoke-AzLocalReadinessGatedClusterUpdate -ForceImmediateUpdate` switch which forwards `-IgnoreScheduleTags` to `Start-AzLocalClusterUpdate` for every cluster in the readiness CSV. A high-visibility warning banner is emitted at the top of the run when the override fires. + +Two layers of defence prevent a scheduled cron firing from honouring the override: (1) GitHub Actions YAML expression collapse `${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_immediate_update || 'false' }}` ensures any `schedule` / `push` / `pull_request` / `repository_dispatch` event sees `'false'`; (2) Azure DevOps runtime guard rechecks `$(Build.Reason) -eq 'Manual'` before flipping the switch. The override is deliberately not surfaced by `New-AzLocalApplyUpdatesScheduleConfig` / `Resolve-AzLocalPipelineUpdateRing` / `Get-AzLocalApplyUpdatesScheduleAudit` - there is no path to enable it from `apply-updates-schedule.yml` (a Pester sweep asserts this). The `UpdateExcluded` operator hard-override (`AzureLocalManagement.UpdateExcluded=true`) is INTENTIONALLY still respected by `Force Immediate Update`. + +`GENERATED_AGAINST_MODULE_VERSION` bumped from `0.8.78` to `0.8.79` across all bundled pipeline templates. No new exports (still 60). ---