diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.0_authentication-test.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.0_authentication-test.yml index 402c0fdf..972546a8 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.0_authentication-test.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.0_authentication-test.yml @@ -59,7 +59,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.5' + value: '0.8.6' # 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/Step.1_inventory-clusters.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.1_inventory-clusters.yml index 6413c57e..112369fa 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.1_inventory-clusters.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.1_inventory-clusters.yml @@ -42,7 +42,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.5' + value: '0.8.6' # 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/Step.2_manage-updatering-tags.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.2_manage-updatering-tags.yml index 37a01d1c..d6f47b1e 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.2_manage-updatering-tags.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.2_manage-updatering-tags.yml @@ -45,7 +45,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.5' + value: '0.8.6' # 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/Step.3_apply-updates-schedule-audit.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.3_apply-updates-schedule-audit.yml index 0f543417..b6ef65c6 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.3_apply-updates-schedule-audit.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.3_apply-updates-schedule-audit.yml @@ -86,7 +86,7 @@ parameters: default: false variables: - GENERATED_AGAINST_MODULE_VERSION: '0.8.5' + GENERATED_AGAINST_MODULE_VERSION: '0.8.6' REQUIRED_MODULE_VERSION: '${{ parameters.moduleVersion }}' reportsPath: '$(Build.ArtifactStagingDirectory)/reports' diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.4_fleet-connectivity-status.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.4_fleet-connectivity-status.yml index 0fd95838..146758ef 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.4_fleet-connectivity-status.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.4_fleet-connectivity-status.yml @@ -109,7 +109,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.5' + GENERATED_AGAINST_MODULE_VERSION: '0.8.6' # 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/Step.5_assess-update-readiness.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.5_assess-update-readiness.yml index b84727b3..6a166dd3 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.5_assess-update-readiness.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.5_assess-update-readiness.yml @@ -69,7 +69,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.5' + GENERATED_AGAINST_MODULE_VERSION: '0.8.6' # 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/Step.6_apply-updates.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.6_apply-updates.yml index 5a377587..d1479e36 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.6_apply-updates.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.6_apply-updates.yml @@ -114,7 +114,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.5' + value: '0.8.6' # 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/Step.7_monitor-updates.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.7_monitor-updates.yml index b8b53daf..528c5fb8 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.7_monitor-updates.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.7_monitor-updates.yml @@ -83,7 +83,7 @@ parameters: default: '' variables: - GENERATED_AGAINST_MODULE_VERSION: '0.8.5' + GENERATED_AGAINST_MODULE_VERSION: '0.8.6' REQUIRED_MODULE_VERSION: '${{ parameters.moduleVersion }}' reportsPath: '$(Build.ArtifactStagingDirectory)/reports' diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.8_fleet-update-status.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.8_fleet-update-status.yml index 020da4fc..a6ca0ea8 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.8_fleet-update-status.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.8_fleet-update-status.yml @@ -92,7 +92,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.5' + GENERATED_AGAINST_MODULE_VERSION: '0.8.6' # 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/Step.9_fleet-health-status.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.9_fleet-health-status.yml index b8611cec..a04c783d 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.9_fleet-health-status.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/azure-devops/Step.9_fleet-health-status.yml @@ -104,7 +104,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.5' + GENERATED_AGAINST_MODULE_VERSION: '0.8.6' # 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/Step.0_authentication-test.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.0_authentication-test.yml index 9cace295..2a0f6ac6 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.0_authentication-test.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.0_authentication-test.yml @@ -78,7 +78,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.5' + GENERATED_AGAINST_MODULE_VERSION: '0.8.6' # 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). @@ -199,7 +199,7 @@ jobs: - name: Upload auth report artifact if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: azlocal-step.0-auth-report_${{ steps.artifact-stamp.outputs.timestamp }} path: ./reports/ diff --git a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.1_inventory-clusters.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.1_inventory-clusters.yml index 3a62c9b3..e8024e88 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.1_inventory-clusters.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.1_inventory-clusters.yml @@ -47,7 +47,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.5' + GENERATED_AGAINST_MODULE_VERSION: '0.8.6' # 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/Step.2_manage-updatering-tags.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.2_manage-updatering-tags.yml index bbf815b2..8cdd6600 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.2_manage-updatering-tags.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.2_manage-updatering-tags.yml @@ -64,7 +64,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.5' + GENERATED_AGAINST_MODULE_VERSION: '0.8.6' # 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/Step.3_apply-updates-schedule-audit.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.3_apply-updates-schedule-audit.yml index afd06ce5..49891ebf 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.3_apply-updates-schedule-audit.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.3_apply-updates-schedule-audit.yml @@ -95,7 +95,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.5' + GENERATED_AGAINST_MODULE_VERSION: '0.8.6' 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/Step.4_fleet-connectivity-status.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.4_fleet-connectivity-status.yml index 648986fd..f4119703 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.4_fleet-connectivity-status.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.4_fleet-connectivity-status.yml @@ -115,7 +115,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.5' + GENERATED_AGAINST_MODULE_VERSION: '0.8.6' # 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/Step.5_assess-update-readiness.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.5_assess-update-readiness.yml index 0bd7ac8a..58344219 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.5_assess-update-readiness.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.5_assess-update-readiness.yml @@ -71,7 +71,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.5' + GENERATED_AGAINST_MODULE_VERSION: '0.8.6' # 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/Step.6_apply-updates.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.6_apply-updates.yml index 090fb9a1..e86a6b8e 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.6_apply-updates.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.6_apply-updates.yml @@ -130,7 +130,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.5' + GENERATED_AGAINST_MODULE_VERSION: '0.8.6' # 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/Step.7_monitor-updates.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.7_monitor-updates.yml index 04ce5719..b6129512 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.7_monitor-updates.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.7_monitor-updates.yml @@ -85,7 +85,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.5' + GENERATED_AGAINST_MODULE_VERSION: '0.8.6' # 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/Step.8_fleet-update-status.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.8_fleet-update-status.yml index 1899fbf7..819d3b81 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.8_fleet-update-status.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.8_fleet-update-status.yml @@ -104,7 +104,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.5' + GENERATED_AGAINST_MODULE_VERSION: '0.8.6' # 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/Step.9_fleet-health-status.yml b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.9_fleet-health-status.yml index fda4f171..11665452 100644 --- a/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.9_fleet-health-status.yml +++ b/AzLocal.UpdateManagement/Automation-Pipeline-Examples/github-actions/Step.9_fleet-health-status.yml @@ -115,7 +115,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.5' + GENERATED_AGAINST_MODULE_VERSION: '0.8.6' # 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 36ef40a9..7caff5b8 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.5' + ModuleVersion = '0.8.6' # Supported PSEditions CompatiblePSEditions = @('Desktop', 'Core') @@ -280,19 +280,29 @@ # ReleaseNotes of this module ReleaseNotes = @' -## Version 0.8.5 - New Public cmdlet `Get-AzLocalApplyUpdatesScheduleCycleCalendar` + Step.6 manual schedule-file inputs + Step.3 cycle-calendar regression fix + per-ring cluster-count column - -Step.3 cycle-calendar refactor + regression fix release. One new Public cmdlet, two new Step.6 manual-run inputs. No other public API removed; no parameter changes on existing cmdlets. The v0.8.4 cycle-calendar silent-drop bug in `Test-AzLocalApplyUpdatesScheduleCoverage -View Recommend` is fixed at the architectural level (decoupled from the advisor's findings gate). - -- **NEW Step.6 manual schedule-file inputs.** GH `workflow_dispatch.inputs` gains `use_schedule_file` (choice false/true) + `resolve_for_date_utc` (string, YYYY-MM-DD); ADO `parameters` gains symmetric `useScheduleFile` (boolean) + `resolveForDateUtc` (string). When `use_schedule_file=true` on a manual run, the resolver reads `apply-updates-schedule.yml` and runs `Resolve-AzLocalCurrentUpdateRing -Schedule $cfg -Now $resolveAt` (today UTC, or the operator-supplied date) - exactly as a scheduled run would. Use this to test a schedule change before the next tick, re-run a missed scheduled day, or preview a future cycleWeek/dayOfWeek. `update_ring` is no longer `required: true` - either supply it OR set `use_schedule_file=true`. The resolver throws a helpful error if BOTH are empty. Back-compat: manual runs with `use_schedule_file=false` use the manual ring verbatim (v0.8.4 behaviour). Scheduled firings are unchanged. - -- **NEW Public cmdlet `Get-AzLocalApplyUpdatesScheduleCycleCalendar`.** Projects an `apply-updates-schedule.yml` configuration forward across the calendar - one row per UTC day - and emits either a structured object pipeline (default) or a fully-rendered markdown block (`-AsMarkdown`). Parameters: `-Schedule ` (mandatory), `-StartDate ` (default UTC today), `-Days ` (default = `CycleWeeks * 7`; ValidateRange 1-3650), `-AsMarkdown`, `-IncludePerRingSummary`, `-ClusterRingCounts `. Object output per day: `DateUtc, DayOfWeekName, CycleWeek, CycleWeeksTotal, CycleWeekLabel, IsCycleWrap, Rings, UpdateRingValue, AllowedUpdateVersions, AllowedUpdateVersionsSource, MatchedRowCount, IsDeadDay, Reason`. Re-uses `Resolve-AzLocalCurrentUpdateRing` per-day so cycle-week math, UNION semantics, and AllowedUpdateVersions precedence stay identical to runtime. Variable cycle length safe (4 / 8 / 52 weeks tested) and year-boundary safe (W52 -> W1 wrap, W53 years). -- **REGRESSION FIX: v0.8.4 cycle calendar silently dropped on healthy fleets.** In v0.8.4 the Enhancement B cycle-calendar block lived inside the Recommend snippet builder, AFTER the "any findings?" gate; on a fleet with zero findings the snippet was empty and Step.3 yml `if ($hasIssues -and $reco)` / `if (-not $hasIssues -and $reco)` branches both fire only when `$reco` is non-empty - so the calendar quietly disappeared. The new cmdlet is invoked unconditionally whenever `$scheduleCfg` is parsed, so the calendar always renders. NO Step.3 yml structural change beyond the version bump. -- **NEW: `Clusters in ring(s)` calendar column + `Cluster count` per-ring projection column** (via new `-ClusterRingCounts` hashtable). When supplied, the per-day table gains a 6th column (`3+12 (total: 15)` for UNION rows, `5` for single-ring days, blank for dead days), and the optional `### Per-ring projection` section gains a `Cluster count` column. Lookups are `OrdinalIgnoreCase`. Step.3 builds the map from the live tagged `$clusters` already in scope; the cmdlet itself stays pure (no CSV / Azure I/O). -- **NEW: optional `### Per-ring projection` section (`-IncludePerRingSummary`).** Row per ring with next eligible UTC date + all eligible dates inside the horizon (with cluster count column when `-ClusterRingCounts` is supplied). Step.3 yml passes `-IncludePerRingSummary` so the section always appears. -- **`Test-AzLocalApplyUpdatesScheduleCoverage -View Recommend` Enhancement B now delegates to the new cmdlet.** The v0.8.4 inline cycle-calendar block (~37 lines) is replaced by a single `Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $scheduleCfg -AsMarkdown -IncludePerRingSummary -ClusterRingCounts $ringCountMap` call, wrapped in `try/catch` with a `_Cycle calendar unavailable: ..._` fallback line. -- **Pester suite updates**: drift-sync test bumped to `'0.8.5'`; new drift assertion asserts `Get-AzLocalApplyUpdatesScheduleCycleCalendar` is exported; ~28 new It blocks across two new Describe blocks cover object-pipeline shape, default Days math, day-0 resolution, IsDeadDay, UNION rendering, IsCycleWrap on rollover, 52-week / 8-week / 1-week cycles, multi-cycle horizon, defensive `CycleWeeks=0` throw, markdown heading + 5-column / 6-column header variants, the v0.8.4 silent-drop regression guard, ClusterRingCounts UNION + dead-day rendering, case-insensitive ring lookup, cycle-wrap annotation, year-boundary safety. Expected baseline: 882/0/1 -> ~910/0/1. -- **All 20 bundled `Step.{0..9}.yml` templates** bump `GENERATED_AGAINST_MODULE_VERSION` from `'0.8.4'` to `'0.8.5'`. +## Version 0.8.6 - Step.3 cycle-calendar enrichment (per-day CRON + UpdateStartWindow tag coverage) + six v0.8.5 pipeline regression fixes (Step.0/3/4/6/9) + new Pester static-audit guards + +Adds two opt-in render-time columns to `Get-AzLocalApplyUpdatesScheduleCycleCalendar` (auto-wired by `Export-AzLocalApplyUpdatesScheduleAudit`) AND fixes six production regressions introduced by the v0.8.5 thin-YAML port. New static-source Pester guards block the underlying anti-patterns from shipping again. No public API removed; no parameter changes on existing cmdlets; same module export count as v0.8.5 (55). + +- **FIX Step.0 (`Export-AzLocalAuthValidationReport`)**: `Resource Graph reachability` reported "1 cluster(s) visible" no matter the real fleet size. `Invoke-AzResourceGraphQuery` uses unary-comma `return , $arr.ToArray()`; wrapping it with `@()` collected the outer wrapper and dropped every row past the first. Switched to direct assignment + `$null` -> `@()` guard, plus a defensive `$clusterRows = @($clusterRows)` coerce on the variable so `.Count` is always a valid integer property under strict mode. +- **FIX Step.3 (`Test-AzLocalApplyUpdatesScheduleCoverage`)**: `$allMatched = @($segmentStatuses.MatchingCrons | Select-Object -Unique)` threw "The property 'MatchingCrons' cannot be found on this object." for any audit row where `$r.RequiredCrons` was empty - `$segmentStatuses` stayed `@()` and member-enumeration on an empty array trips strict mode. Guarded the access with `if ($segmentStatuses.Count -gt 0)`. +- **FIX Step.4 (`Get-AzLocalFleetConnectivityStatus`)**: ARB row rendering crashed under `Set-StrictMode -Version Latest` on any RG holding exactly one cluster. The if-as-expression silently unwrapped the single-element `@()` to a scalar PSCustomObject; `$matched.Count` then threw. The v0.8.6-fix1 `[object[]]$matched = if (...)` cast was insufficient on the production fleet (still scalar at runtime). The reliable fix: assign the raw list (or `$null`) to `$matchedList`, then `$matched = @($matchedList)` to coerce scalar -> Object[1] on the *variable* (idempotent for Object[N]). +- **FIX Step.6 (`Get-AzLocalClusterUpdateReadiness`)**: `if ($cluster.NotFound)` at line ~337 threw "The property 'NotFound' cannot be found on this object" because three of the four `$clustersToProcess += @{ ... }` builder paths omitted the key. Added `NotFound = $false` to all three missing-key paths so the strict-mode iteration is always safe. +- **FIX Step.9 (`Export-AzLocalFleetHealthStatusReport`)**: BOTH `$detail = @(Get-AzLocalFleetHealthFailures ...)` and `$overview = @(Get-AzLocalFleetHealthOverview ...)` collapsed the row-set to `Object[1]`; "Found 81 failing check(s)" from the helper became "Found 1" two lines later and Group-Object then crashed with exit code 1. Switched both to direct assignment + `$null` -> `@()` guards. +- **FIX Node.js 20 deprecation**: bumped `actions/upload-artifact@v4` -> `@v6` in `Step.0_authentication-test.yml` (every other Step YAML was already on `@v6`). +- **FIX Step.2 per-cluster `Message` now names the tags that actually changed.** When only a schedule tag (e.g. `UpdateExcluded`) differs between cluster and CSV, the summary used to read "UpdateRing tag updated successfully" - misleading because `UpdateRing` was unchanged. `Set-AzLocalClusterUpdateRingTag` now computes per-tag deltas from `$tagsToMerge.Keys` vs `$currentTags` and writes `Tags updated: UpdateExcluded: 'False' -> 'True'` (same shape for `-WhatIf`). +- **FIX Step.2 summary table split into outcome buckets.** `Set-AzLocalClusterUpdateRingTagFromCsv` previously rendered all clusters (created/updated/no-op/skipped/failed) into one `
` block. The summary now renders three independent collapsible sections, each shown only when non-empty: **"Clusters with Tag Updates Applied (N rows)"** (expanded), **"Clusters Skipped or Failed (N rows)"** (expanded), **"Clusters with No Tag Updates (no-op) (N rows)"** (collapsed). +- **FIX Step.2 `Total clusters processed` count was inflated (`44` vs `20` on a 20-row CSV).** `Set-AzLocalClusterUpdateRingTag` ended with `$results | Format-Table -AutoSize` (no `| Out-Host`), so the formatter wrapper objects leaked into the function's pipeline output and mixed with `return $results` under `-PassThru`. Fixed by piping Format-Table to `Out-Host` + defence-in-depth filter in `Set-AzLocalClusterUpdateRingTagFromCsv` that drops any object without a string `ClusterName` property. +- **NEW Pester regression guards** (11 It blocks across 7 Describe blocks): static-source scans for `@()` wrap on unary-comma helpers (`Invoke-AzResourceGraphQuery`, `Get-AzLocalFleetHealth{Failures,Overview}`, `Read-AzLocalApplyUpdatesYamlCrons`); Step.3 `.MatchingCrons` strict-mode crash; Step.4 `$matched if/else { @() }` branch; Step.6 `NotFound` key on every `$clustersToProcess` builder; Step.0 `$clusterRows` variable-level `@()` coerce; Step.2 `Format-Table | Out-Host`; GHA Node.js 20 artifact-action version deprecation; plus functional spy tests for Export-AzLocalAuthValidationReport + Export-AzLocalFleetHealthStatusReport. +- **NEW `-CronFiringsByDate`** (`[hashtable]`, keys = `yyyy-MM-dd` UTC, values = `[string[]]` of `HH:mm` UTC firing times). Adds centered `Ring CRON Start Time (UTC)
(Step 6 pipeline)` column between `Date (UTC)` and `Day`. Cell rendering: 0 firings -> `_(none)_`; 1-2 -> comma-joined; 3+ -> first 2 + `" (+N)"`; dead day -> `_(none - dead day)_`; missing date key -> blank. +- **NEW `-WindowMatchByRingAndDate`** (`[hashtable[string,hashtable]]`, ring -> date -> `@{ Matching=; Total= }`). Adds `Tag Start Window Match (>=95%)` column AFTER `Eligible rings`. Per-ring line: `` `Ring`: True/False mat/tot (pct%) `` (True iff `Matching/Total >= 0.95`). Rendering: `_(n/a)_` no entry; `_(0 clusters)_` Total=0; `_(n/a - dead day)_` on dead days. +- **Pure render-time contract preserved.** Cmdlet still does zero Azure / file I/O. Both columns opt-in. +- **`Export-AzLocalApplyUpdatesScheduleAudit` auto-wires both.** Cron firings via existing `Read-AzLocalApplyUpdatesYamlCrons` + `ConvertFrom-AzLocalCronExpression` (invalid crons -> no firings for that day). Window-match via `ConvertFrom-AzLocalUpdateWindow` per cluster (when `-ClusterCsvPath` supplied); overnight windows match firings in either the late-evening or early-morning portion via two-case `DayOfWeek` projection. +- **Failure mode is non-fatal.** Enrichment errors degrade to the v0.8.5 calendar; `Write-Warning` surfaces the cause but Step.3 summary continues to render. +- **Pester**: drift bumped to `'0.8.6'`; 12 new It blocks cover backwards compat, single-param paths, both-params 7-column header, `(+N)` suffix at 3+ firings, 95% threshold boundary, `_(0 clusters)_` / `_(n/a)_` rendering, ring + date case-insensitivity, and coexistence with `-ClusterRingCounts`. 2 new Export-* spy tests verify the auto-wire path. PLUS 11 new regression-guard It blocks across 7 Describe blocks. +- **All 20 bundled `Step.{0..9}.yml` templates** bump `GENERATED_AGAINST_MODULE_VERSION` from `'0.8.5'` to `'0.8.6'`. + +## Version 0.8.5 - New Public cmdlet `Get-AzLocalApplyUpdatesScheduleCycleCalendar` + Step.6 manual schedule-file inputs + Step.3 cycle-calendar regression fix + per-ring cluster-count column + full thin-YAML port of all 10 Step pipelines (14 new Public cmdlets). Module export count grows 35 -> 55. See CHANGELOG for the full v0.8.5 entry. ## Version 0.8.3 - Test-AzLocalApplyUpdatesScheduleCoverage Step.3 advisor accuracy + readability fixes: Recommend now diff-prunes against `-PipelineYamlPath`, Step.3 yml `pipeline_path` REQUIRED, Allow-list heading reframed, closing-fence typo fixed diff --git a/AzLocal.UpdateManagement/AzLocal.UpdateManagement.psm1 b/AzLocal.UpdateManagement/AzLocal.UpdateManagement.psm1 index 680721bd..0a428f22 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.5' +$script:ModuleVersion = '0.8.6' $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 d9664363..c33c6081 100644 --- a/AzLocal.UpdateManagement/CHANGELOG.md +++ b/AzLocal.UpdateManagement/CHANGELOG.md @@ -5,6 +5,62 @@ 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.6] - 2026-06-10 + +Step.3 cycle-calendar enrichment release. Adds two opt-in columns to the per-day `## Cycle calendar` markdown table produced by `Get-AzLocalApplyUpdatesScheduleCycleCalendar` and auto-wires them from `Export-AzLocalApplyUpdatesScheduleAudit` so operators can see (a) which Step.6 cron firing times will fire on each calendar day and (b) what fraction of clusters in the ring(s) eligible on that day have an `UpdateStartWindow` tag that actually covers at least one of those firings. **Also fixes six production regressions introduced by the v0.8.5 thin-YAML port (Step.0, Step.3, Step.4 x2, Step.6, Step.9) and adds Pester guards so the same anti-patterns cannot ship again.** No public API removed; no existing parameter changed; no behavioural change on callers that do NOT supply the new dictionaries. Same module export count as v0.8.5 (55). + +### Pipeline regression fixes (v0.8.5 -> v0.8.6) + +All bugs root-cause to three anti-patterns that bit the v0.8.5 thin-YAML port - all now Pester-guarded so any future caller that reintroduces them fails the local test suite: + +- **Step.0 (`Export-AzLocalAuthValidationReport`)**: `$clusterRows = @(Invoke-AzResourceGraphQuery ...)` collapsed every fleet to "1 cluster(s) visible" no matter the real count. `Invoke-AzResourceGraphQuery` uses unary-comma `return , $allRows.ToArray()` to preserve array-shape on direct assignment; wrapping it in `@()` collected the outer single-item wrapper and silently dropped every row past the first. Fixed by switching to direct assignment plus a `$null` -> `@()` guard for the empty-fleet case, AND a defensive `$clusterRows = @($clusterRows)` coerce on the variable so a single-row shape can never short-circuit `.Count` to `$null` under strict mode. +- **Step.3 (`Test-AzLocalApplyUpdatesScheduleCoverage`)**: `$allMatched = @($segmentStatuses.MatchingCrons | Select-Object -Unique)` threw "The property 'MatchingCrons' cannot be found on this object." under `Set-StrictMode -Version Latest`. Two distinct root causes: (a) `$segmentStatuses` can stay `@()` when `$r.RequiredCrons` is empty, and (b) even when `Count > 0`, array-level member-access (`$arr.Prop`) trips strict mode v3+. Fixed by guarding with `if ($segmentStatuses.Count -gt 0)` AND switching to explicit enumeration: `$segmentStatuses | ForEach-Object { $_.MatchingCrons } | Select-Object -Unique`. +- **Step.4 (`Get-AzLocalFleetConnectivityStatus` AND `Export-AzLocalFleetConnectivityStatusReport`)**: ARB row rendering crashed under strict mode on any RG holding exactly one cluster. Two related anti-patterns bit here: + - In `Get-AzLocalFleetConnectivityStatus`, `@($null)` produces an `Object[1]` containing `$null` (NOT an empty array), so `$matched.Count -gt 0` was true and `$_.ClusterName` on the null element threw PropertyNotFound. First fix attempt cast `[object[]]$matched = if (...)` - insufficient on production. Second fix used `$matchedList = if (...) { $clustersByRg[$rg] } else { $null }; $matched = @($matchedList)` - still broken because `@($null)` is `Object[1]`. Final fix (v0.8.6-fix3): explicit branch `if ($clustersByRg.ContainsKey($rg)) { $matched = @($clustersByRg[$rg]) } else { $matched = @() }`. + - In `Export-AzLocalFleetConnectivityStatusReport`, `[string[]]$clusterIdList = if ($r.ClusterId) { ... } else { @() }` had the same scalar-unwrap pattern when `Where-Object` yielded a single string. Fixed via the same explicit if/else with `@()` wrap on the variable. +- **Step.6 (`Get-AzLocalClusterUpdateReadiness`)**: `if ($cluster.NotFound)` at line ~337 threw "The property 'NotFound' cannot be found on this object" because three of the four `$clustersToProcess += @{ ... }` builder paths (ByTag, ByResourceId-found, ByName-found) omitted the `NotFound` key - only the ByResourceId-not-found path set it. Under strict mode v3+, dot-access on a hashtable missing the key throws. Fixed by adding `NotFound = $false` to all three missing-key paths so the iteration is always safe. +- **Step.9 (`Export-AzLocalFleetHealthStatusReport`)**: both `$detail = @(Get-AzLocalFleetHealthFailures ...)` and `$overview = @(Get-AzLocalFleetHealthOverview ...)` collapsed multi-row helper output to `Object[1]`. Same `@()` wrap + unary-comma collision as Step.0. Step.9 had the most visible symptom: "Found 81 failing check(s) across 9 cluster(s)" from the helper, then "Found 1 failing health-check entry/entries" two lines later, followed by exit code 1 when `Group-Object` tried to property-access the wrapper. Fixed by switching both to direct assignment plus `$null` -> `@()` guards. +- **Node.js 20 deprecation warning**: bumped `actions/upload-artifact@v4` -> `@v6` in `Step.0_authentication-test.yml` (every other Step YAML was already on `@v6`). + +### Step.2 UpdateRing tag-management UX fixes (v0.8.6) + +- **Per-cluster `Message` now names the tags that actually changed.** When only a schedule tag (e.g. `UpdateExcluded`) differs between the cluster and the CSV, the success message previously read "UpdateRing tag updated successfully" - misleading because the `UpdateRing` value was unchanged. `Set-AzLocalClusterUpdateRingTag` now computes per-tag deltas (`$tagsToMerge.Keys` vs `$currentTags`) and writes a message of the form `Tags updated: UpdateExcluded: 'False' -> 'True'`. Falls back to the legacy phrasing only when the delta list is empty (e.g. dry-run paths). Same delta list also drives the `-WhatIf` message. +- **Per-cluster table split into outcome buckets in the GitHub Actions / Azure DevOps step summary.** `Set-AzLocalClusterUpdateRingTagFromCsv` previously rendered every cluster (created, updated, no-op, skipped, failed) into one `
` block titled "Per-cluster results (N rows)". On a 44-cluster fleet where only one cluster actually changed (e.g. a single `UpdateExcluded` flip on Seattle), the operator had to scroll through 43 steady-state rows to find the one row that mattered. The summary now renders three independent collapsible sections, each shown only when non-empty: + - **Clusters with Tag Updates Applied (N rows)** - `Status=Success` or `Status=WhatIf`. Defaults to expanded. + - **Clusters Skipped or Failed (N rows)** - `Status=Skipped` or `Status=Failed`. Defaults to expanded. + - **Clusters with No Tag Updates (no-op) (N rows)** - `Status=AlreadyInSync`. Defaults to collapsed. +- **Total clusters processed count fixed (`44` vs `20` on a 20-row CSV).** `Set-AzLocalClusterUpdateRingTag` ended with `$results | Format-Table ... -AutoSize` (no `| Out-Host`), so the formatter wrapper objects (header / row / footer) leaked into the function's pipeline output and mixed with `return $results` under `-PassThru`. The caller's `$results = @(Set-AzLocalClusterUpdateRingTag @applyParams)` then captured ~1.2x as many objects as real cluster rows, inflating Step.2's `Total clusters processed` line (and the `Per-cluster results (N rows)` header) on every run. Fixed by piping Format-Table to `Out-Host`, plus a defence-in-depth filter in `Set-AzLocalClusterUpdateRingTagFromCsv` that drops any object without a string `ClusterName` property before tallying. +- **Pester guards added** for the new split markdown (asserts both bucket headers render with their per-bucket row counts), for the per-tag delta message (static-source assert that `Set-AzLocalClusterUpdateRingTag` builds a `$tagDeltas` list from `$tagsToMerge.Keys` and emits a `Tags : ` prefix), and for the Format-Table `Out-Host` route (regex-asserts `Format-Table ... -AutoSize | Out-Host` is present so the formatter-object leak cannot reappear). + +### New Pester regression guards (catch the bugs the v0.8.5 ship missed) + +- **`v0.8.6 regression guard: @() wrap on unary-comma return helpers`** (4 It blocks, one per helper) - static scan of every Public/Private `.ps1` file looking for `@(\s*\b` patterns. Zero tolerance: any future caller that re-introduces the row-collapse anti-pattern fails the suite. Helpers covered: `Invoke-AzResourceGraphQuery`, `Get-AzLocalFleetHealthFailures`, `Get-AzLocalFleetHealthOverview`, `Read-AzLocalApplyUpdatesYamlCrons`. +- **`v0.8.6 regression guard: Step.3 Test-AzLocalApplyUpdatesScheduleCoverage MatchingCrons strict-mode crash`** - asserts the fixed source uses explicit `$segmentStatuses | ForEach-Object { $_.MatchingCrons }` enumeration (NOT array-level member-access) AND retains the `$segmentStatuses.Count -gt 0` guard. +- **`v0.8.6 regression guard: Step.4 ARB strict-mode .Count crash on single-cluster RG`** - asserts the fixed source uses an explicit `if ($clustersByRg.ContainsKey($rg)) { $matched = @($clustersByRg[$rg]) } else { $matched = @() }` branch (because `@($null)` yields `Object[1]` containing `$null`, NOT an empty array). Blocks both the insufficient v0.8.6-fix1 `[object[]]$matched = if (...)` cast AND the v0.8.6-fix2 `$matchedList = ... else { $null }` pattern. +- **`v0.8.6 regression guard: Step.6 Get-AzLocalClusterUpdateReadiness $cluster.NotFound strict-mode crash`** - parses every `$clustersToProcess += @{ ... }` literal in the source and asserts each one defines a `NotFound` key. Catches future builder paths that forget the field. +- **`v0.8.6 regression guard: Step.0 Export-AzLocalAuthValidationReport defensive @() coerce on $clusterRows`** - asserts the source contains `$clusterRows = @($clusterRows)` after the ARG call so `.Count` is always a valid integer property even when callers mock with a single object. +- **`v0.8.6 regression guard: GitHub Actions Node.js 20 deprecation`** (2 It blocks) - scans every bundled GHA YAML for `actions/upload-artifact@v[1-4]` and `actions/download-artifact@v[1-3]`. Zero tolerance: any future YAML that pins a Node-20 action fails. +- **`v0.8.6 functional regression: Export-AzLocalAuthValidationReport preserves all cluster rows from unary-comma helper`** and the matching block for `Export-AzLocalFleetHealthStatusReport` - source-shape asserts on the fixed assignment lines so a regex regression in the production cmdlet is caught immediately. + +### New optional columns on `Get-AzLocalApplyUpdatesScheduleCycleCalendar -AsMarkdown` + +- **`-CronFiringsByDate `**: a `@{ 'yyyy-MM-dd' = @('HH:mm', ...) }` map of pre-computed UTC firing times. When supplied, a centered "Ring CRON Start Time
(Step 6 pipeline)" column is inserted immediately after "Date (UTC)". Cell renders up to 2 times verbatim; any additional firings collapse into a `(+N)` suffix (e.g. `02:00, 04:00 (+1)` for three firings). Days with no entry render `_(none)_`; dead days render `_(none - dead day)_`. +- **`-WindowMatchByRingAndDate `**: a nested `@{ '' = @{ 'yyyy-MM-dd' = @{ Matching = ; Total = ; Pct = <0..1?> } } }` map. When supplied, a "Tag Start Window Match (>=95%)" column is inserted immediately after "Eligible rings". For multi-ring days the cell shows a per-ring breakdown joined by `; ` (e.g. `` `Cdn`: True 29/30 (97%); `Prod`: False 1/40 (3%) ``). True iff `Matching / Total >= 0.95`; rings absent from the dict on a date render `_(n/a)_`; `Total == 0` renders `_(0 clusters)_`. +- **Pure render-time contract preserved**: `Get-AzLocalApplyUpdatesScheduleCycleCalendar` still does zero Azure / file I/O. Both columns are opt-in - omit either or both params and the v0.8.5 column layout is bit-identical. +- **Compose freely**: `-ClusterRingCounts`, `-CronFiringsByDate`, and `-WindowMatchByRingAndDate` can be supplied independently or together (all three coexist, in the canonical header order documented above). + +### Auto-wiring in `Export-AzLocalApplyUpdatesScheduleAudit` + +- After running the audit / matrix / recommend views, the Step.3 thin-YAML wrapper now builds both dictionaries from data it already has on hand and forwards them into the cycle calendar: + - **`$cronFiringsByDate`** projected from the Step.6 cron triggers via `Read-AzLocalApplyUpdatesYamlCrons -Path $PipelineYamlPath` + `ConvertFrom-AzLocalCronExpression`; unparseable / complex crons are silently skipped (Write-Verbose) so a single malformed entry does not break the calendar. + - **`$windowMatchByRingAndDate`** computed only when `-ClusterCsvPath` is also supplied: the CSV is grouped by `UpdateRing` (case-insensitive), each cluster's `UpdateStartWindow` tag is parsed via `ConvertFrom-AzLocalUpdateWindow`, and a cluster counts as "matching" on a given (ring, date) pair if ANY of its window segments covers at least one cron firing on that date. Overnight windows (e.g. `Mon-Fri_22:00-06:00`) correctly count the spillover into the following day. +- **Failure mode is non-fatal**: any error in the enrichment block degrades gracefully to a calendar without the new columns (matching v0.8.5 behaviour); a `Write-Warning` surfaces the cause but the Step.3 summary continues to render. + +### Tests + +- 12 new It blocks under `Describe 'v0.8.6 Apply-Updates Schedule: Get-AzLocalApplyUpdatesScheduleCycleCalendar - CronFiringsByDate and WindowMatchByRingAndDate columns (markdown)'` cover: backwards compat (no new params -> v0.8.5 5-column header preserved), single-param paths (cron-only / match-only), both-params path with 7-column header, `(+N)` suffix at 3+ firings, `_(none)_` cell when a date has no entry, 95% threshold boundary (95/100 -> True, 94/100 -> False), `_(0 clusters)_` rendering at Total=0, `_(n/a)_` rendering when a ring has no entry on a date, case-insensitivity on both ring keys and date keys, and three-optional-columns coexistence with `-ClusterRingCounts`. +- 2 new integration It blocks appended to `Describe 'Thin-YAML Step.3: Export-AzLocalApplyUpdatesScheduleAudit'` verify the Export wrapper actually forwards `-CronFiringsByDate` when given a real `Step.6_apply-updates.yml` with cron triggers, and additionally forwards `-WindowMatchByRingAndDate` when a `-ClusterCsvPath` is supplied alongside. + ## [0.8.5] - 2026-06-09 Step.3 cycle-calendar refactor + regression fix release, followed by a **full thin-YAML port of all 10 Step pipelines** (GitHub Actions + Azure DevOps - 20 YAML files in total). Adds 15 new Public cmdlets (1 in the original 0.8.5 plus 14 over the thin-YAML port) and two new Step.6 manual-run inputs that let an operator trigger apply-updates by hand BUT resolve UpdateRing + AllowedUpdateVersions from `apply-updates-schedule.yml` exactly as a scheduled run would. No public API removed; no parameter changes on existing cmdlets. The v0.8.4 cycle-calendar silent-drop bug in `Test-AzLocalApplyUpdatesScheduleCoverage -View Recommend` is fixed at the architectural level (decoupled from the advisor's findings gate). **Total module export count grows from 35 (v0.8.4) to 55 (v0.8.5).** diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalApplyUpdatesScheduleAudit.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalApplyUpdatesScheduleAudit.ps1 index 7f87bbbd..ab2446fb 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalApplyUpdatesScheduleAudit.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalApplyUpdatesScheduleAudit.ps1 @@ -557,11 +557,152 @@ function Export-AzLocalApplyUpdatesScheduleAudit { # ---- Cycle calendar - ALWAYS when -SchedulePath supplied -------------- # v0.8.5 fix for the v0.8.4 $hasIssues-gate regression where the # calendar silently disappeared from clean-fleet runs. + # v0.8.6 enrichment: when PipelineYamlPath (always) and ClusterCsvPath + # (optional) are available, build two extra columns: + # * "Ring CRON Start Time (Step 6 pipeline)" - per-day UTC firing + # times projected from Step.6 cron triggers. + # * "Tag Start Window Match (>=95%)" - per (ring, date) pair: do + # >=95% of clusters in the ring have an UpdateStartWindow tag + # that covers AT LEAST ONE Step.6 cron firing on that date? + # Both columns are pure render-time data computed here (Azure I/O + # / file I/O lives in this caller, not the cycle-calendar cmdlet). if ($haveSchedule) { $calendarMd = $null + $cronFiringsByDate = $null + $windowMatchByRingAndDate = $null try { $schedForCalendar = if ($sched) { $sched } else { Get-AzLocalApplyUpdatesScheduleConfig -Path $SchedulePath -ErrorAction Stop } - $calendarMd = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $schedForCalendar -AsMarkdown -IncludePerRingSummary -ErrorAction Stop + + # Build cron firings per UTC date across the cycle-calendar's + # default horizon (CycleWeeks * 7 days, mirroring the cmdlet's + # default $Days). Best-effort: parse failures degrade gracefully + # to "no firings" rather than aborting the whole calendar. + try { + $cronFireRows = New-Object System.Collections.Generic.List[psobject] + # NOTE: Read-AzLocalApplyUpdatesYamlCrons uses unary-comma return; direct assignment only (no @() wrap). + $cronTriggers = Read-AzLocalApplyUpdatesYamlCrons -Path $PipelineYamlPath -ErrorAction Stop + foreach ($ct in $cronTriggers) { + try { + $parsed = ConvertFrom-AzLocalCronExpression -Expression $ct.CronExpression -ErrorAction Stop + if ($parsed.IsValid -and -not $parsed.IsComplex) { + foreach ($ft in @($parsed.FireTimes)) { + $cronFireRows.Add([pscustomobject]@{ + DayOfWeek = $ft.DayOfWeek + Time = $ft.TimeOfDay + }) | Out-Null + } + } + } + catch { + Write-Verbose "Cron parse failed for '$($ct.CronExpression)': $($_.Exception.Message)" + } + } + + $horizonDays = [int]$schedForCalendar.CycleWeeks * 7 + if ($horizonDays -lt 7) { $horizonDays = 7 } + $startDay = [datetime]::SpecifyKind([datetime]::UtcNow.Date, [DateTimeKind]::Utc) + + $cronFiringsByDate = @{} + for ($i = 0; $i -lt $horizonDays; $i++) { + $d = $startDay.AddDays($i) + $dow = $d.DayOfWeek + $fires = @( + $cronFireRows | + Where-Object { $_.DayOfWeek -eq $dow } | + ForEach-Object { ($_.Time).ToString('hh\:mm') } + ) | Sort-Object -Unique + $cronFiringsByDate[$d.ToString('yyyy-MM-dd')] = @($fires) + } + + # Window-match column needs cluster CSV input. + if ($haveCsv) { + $clusters = @(Import-Csv -LiteralPath $ClusterCsvPath -ErrorAction Stop) + + # Pre-parse each cluster's UpdateStartWindow once. + $ringsToClusters = New-Object 'System.Collections.Generic.Dictionary[string,System.Collections.Generic.List[psobject]]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach ($c in $clusters) { + $ring = '' + if ($c.PSObject.Properties.Name -contains 'UpdateRing' -and $null -ne $c.UpdateRing) { + $ring = ([string]$c.UpdateRing).Trim() + } + if ([string]::IsNullOrWhiteSpace($ring)) { continue } + $winStr = '' + if ($c.PSObject.Properties.Name -contains 'UpdateStartWindow' -and $null -ne $c.UpdateStartWindow) { + $winStr = ([string]$c.UpdateStartWindow).Trim() + } + $segments = @() + if (-not [string]::IsNullOrWhiteSpace($winStr)) { + try { $segments = @(ConvertFrom-AzLocalUpdateWindow -WindowString $winStr -ErrorAction Stop) } + catch { $segments = @() } + } + if (-not $ringsToClusters.ContainsKey($ring)) { + $ringsToClusters[$ring] = New-Object System.Collections.Generic.List[psobject] + } + $ringsToClusters[$ring].Add([pscustomobject]@{ Segments = $segments }) | Out-Null + } + + $windowMatchByRingAndDate = @{} + for ($i = 0; $i -lt $horizonDays; $i++) { + $d = $startDay.AddDays($i) + $dow = $d.DayOfWeek + $prevDow = $d.AddDays(-1).DayOfWeek + $dKey = $d.ToString('yyyy-MM-dd') + $firesToday = @( + $cronFireRows | + Where-Object { $_.DayOfWeek -eq $dow } | + ForEach-Object { $_.Time } + ) + if ($firesToday.Count -eq 0) { continue } + foreach ($ring in $ringsToClusters.Keys) { + $clist = @($ringsToClusters[$ring]) + $tot = $clist.Count + $mat = 0 + foreach ($cw in $clist) { + $hit = $false + foreach ($seg in @($cw.Segments)) { + $segDays = @($seg.Days) + if ($seg.Overnight) { + # Two cases for overnight windows: + # (a) opens today before midnight -> fires today >= StartTime, + # (b) opened yesterday and still active -> fires today < EndTime. + foreach ($ft in $firesToday) { + if ($segDays -contains $dow -and $ft -ge $seg.StartTime) { $hit = $true; break } + if ($segDays -contains $prevDow -and $ft -lt $seg.EndTime) { $hit = $true; break } + } + } + else { + if (-not ($segDays -contains $dow)) { continue } + foreach ($ft in $firesToday) { + if ($ft -ge $seg.StartTime -and $ft -lt $seg.EndTime) { $hit = $true; break } + } + } + if ($hit) { break } + } + if ($hit) { $mat++ } + } + if ($tot -gt 0) { + if (-not $windowMatchByRingAndDate.ContainsKey($ring)) { $windowMatchByRingAndDate[$ring] = @{} } + $windowMatchByRingAndDate[$ring][$dKey] = @{ Matching = $mat; Total = $tot } + } + } + } + } + } + catch { + Write-Warning "Failed to build cycle-calendar enrichment columns: $($_.Exception.Message)" + $cronFiringsByDate = $null + $windowMatchByRingAndDate = $null + } + + $calArgs = @{ + Schedule = $schedForCalendar + AsMarkdown = $true + IncludePerRingSummary = $true + ErrorAction = 'Stop' + } + if ($cronFiringsByDate) { $calArgs.CronFiringsByDate = $cronFiringsByDate } + if ($windowMatchByRingAndDate) { $calArgs.WindowMatchByRingAndDate = $windowMatchByRingAndDate } + $calendarMd = Get-AzLocalApplyUpdatesScheduleCycleCalendar @calArgs } catch { $calendarMd = $null diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalAuthValidationReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalAuthValidationReport.ps1 index 8564576d..56468d50 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalAuthValidationReport.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalAuthValidationReport.ps1 @@ -250,12 +250,22 @@ function Export-AzLocalAuthValidationReport { $clusterKql = "resources | where type =~ 'microsoft.azurestackhci/clusters' | project name, resourceGroup, subscriptionId" $clusterRows = @() try { - $clusterRows = @(Invoke-AzResourceGraphQuery -Query $clusterKql -First $MaxClusters -ErrorAction Stop) + # NOTE: Invoke-AzResourceGraphQuery uses unary-comma return (`return , $allRows.ToArray()`). + # Direct assignment only - never @() wrap on the call itself, or the entire row set + # collapses to Object[1] and `Resource Graph reachability` reports `1 cluster(s) visible` + # no matter the real count. + $clusterRows = Invoke-AzResourceGraphQuery -Query $clusterKql -First $MaxClusters -ErrorAction Stop + if ($null -eq $clusterRows) { $clusterRows = @() } } catch { Write-Warning "Cluster ARG query failed: $($_.Exception.Message)" $clusterRows = @() } + # v0.8.6: defensive @() on the VARIABLE (not on the helper call) - idempotent + # on Object[N], turns a bare scalar into Object[1]. Needed when a mock or a + # future helper-return shape change emits a single PSCustomObject; without + # this $clusterRows.Count returns $null under strict mode. + $clusterRows = @($clusterRows) $clusterCount = $clusterRows.Count Write-Host "Clusters visible to the pipeline identity = $clusterCount" if ($clusterCount -gt 0) { diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetConnectivityStatusReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetConnectivityStatusReport.ps1 index a4562a7d..0ae11673 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetConnectivityStatusReport.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetConnectivityStatusReport.ps1 @@ -312,11 +312,16 @@ function Export-AzLocalFleetConnectivityStatusReport { $sev = & $getArbSeverity $r.ArbStatus $portal = "https://portal.azure.com/#@/resource$($r.ArbId)" # ClusterId / ClusterName may be a comma-separated list when the - # ARB's resource group hosts multiple HCI clusters. The [string[]] - # cast is required - without it, when Where-Object yields a single - # scalar, the if-as-expression collapses $clusterIdList to a bare - # [string] and indexing it returns a [char] (no Trim() method). - [string[]]$clusterIdList = if ($r.ClusterId) { ($r.ClusterId -split ',\s*') | Where-Object { $_ } } else { @() } + # ARB's resource group hosts multiple HCI clusters. The explicit + # if/else assignment with @() wrap on the VARIABLE is required - + # `[string[]]$x = if (...) { ... } else { @() }` is NOT sufficient + # under Set-StrictMode -Version Latest when the inner pipeline yields + # a single scalar (v0.8.6-fix3 - same fix pattern as Step.4's $matched). + if ($r.ClusterId) { + $clusterIdList = @(($r.ClusterId -split ',\s*') | Where-Object { $_ }) + } else { + $clusterIdList = @() + } $primaryClusterId = if ($clusterIdList.Count -gt 0) { ([string]$clusterIdList[0]).Trim() } else { '' } $clusterPortal = if ($primaryClusterId) { "https://portal.azure.com/#@/resource$primaryClusterId" } else { '' } $multiClusterNote = if ($clusterIdList.Count -gt 1) { " (multi-cluster RG; $($clusterIdList.Count) clusters)" } else { '' } diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 index 226df40f..c80915ea 100644 --- a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 @@ -283,7 +283,10 @@ function Export-AzLocalFleetHealthStatusReport { if ($Scope -eq 'by-update-ring' -and $UpdateRing) { $argSplat['UpdateRingTag'] = $UpdateRing } Write-Host "Step 1: Collecting fleet health failure rows (Detail view)..." -ForegroundColor Yellow - $detail = @(Get-AzLocalFleetHealthFailures -View Detail @argSplat -ExportPath $detailCsv -PassThru) + # NOTE: Get-AzLocalFleetHealthFailures uses unary-comma return (`return , $output`). + # Direct assignment only - never @() wrap, or the entire row set collapses to Object[1]. + $detail = Get-AzLocalFleetHealthFailures -View Detail @argSplat -ExportPath $detailCsv -PassThru + if ($null -eq $detail) { $detail = @() } if (-not $detail) { $detail = @() } $detail | ConvertTo-Json -Depth 6 | Out-File -FilePath $detailJson -Encoding utf8 Write-Host "Found $($detail.Count) failing health-check entry/entries." -ForegroundColor Green @@ -328,7 +331,10 @@ function Export-AzLocalFleetHealthStatusReport { Write-Host "Step 2: Collecting fleet health overview rows (one per cluster)..." -ForegroundColor Yellow $overviewArgs = @{} if ($Scope -eq 'by-update-ring' -and $UpdateRing) { $overviewArgs['UpdateRingTag'] = $UpdateRing } - $overview = @(Get-AzLocalFleetHealthOverview @overviewArgs -ExportPath $overviewCsv -PassThru) + # NOTE: Get-AzLocalFleetHealthOverview uses unary-comma return (`return , $output`). + # Direct assignment only - never @() wrap, or the entire row set collapses to Object[1]. + $overview = Get-AzLocalFleetHealthOverview @overviewArgs -ExportPath $overviewCsv -PassThru + if ($null -eq $overview) { $overview = @() } if (-not $overview) { $overview = @() } $overview | ConvertTo-Json -Depth 6 | Out-File -FilePath $overviewJson -Encoding utf8 Write-Host "Overview rows: $($overview.Count)." -ForegroundColor Green diff --git a/AzLocal.UpdateManagement/Public/Get-AzLocalApplyUpdatesScheduleCycleCalendar.ps1 b/AzLocal.UpdateManagement/Public/Get-AzLocalApplyUpdatesScheduleCycleCalendar.ps1 index b640aca2..4d2c2693 100644 --- a/AzLocal.UpdateManagement/Public/Get-AzLocalApplyUpdatesScheduleCycleCalendar.ps1 +++ b/AzLocal.UpdateManagement/Public/Get-AzLocalApplyUpdatesScheduleCycleCalendar.ps1 @@ -62,6 +62,53 @@ function Get-AzLocalApplyUpdatesScheduleCycleCalendar { yml) build this dictionary from their cluster CSV or live tag scan; the cmdlet itself does no Azure or CSV I/O. + .PARAMETER CronFiringsByDate + Optional hashtable mapping 'yyyy-MM-dd' (UTC) date strings to a + sorted, deduplicated [string[]] of 'HH:mm' UTC firing times that + the Step.6 apply-updates pipeline cron(s) will produce on that + date. When supplied AND -AsMarkdown is set, the per-day calendar + table gains a centered "Ring CRON Start Time (Step 6 pipeline)" + column immediately after "Date (UTC)". The cell renders up to 2 + firing times verbatim; any additional firings beyond the first 2 + are summarised as "(+N)" (e.g. "02:00, 04:00 (+1)" when there + are 3 firings). Days with no entry in the hashtable, no firings + for the day, or that are "dead days" (no eligible rings) render + as "_(none)_". Pure render-time - this cmdlet does no YAML I/O; + callers (e.g. Export-AzLocalApplyUpdatesScheduleAudit) build the + dictionary from `Read-AzLocalApplyUpdatesYamlCrons` + + `ConvertFrom-AzLocalCronExpression`. + + .PARAMETER WindowMatchByRingAndDate + Optional nested hashtable indicating, per (UpdateRing, UTC date) + pair, what fraction of the clusters tagged with that ring have + an `UpdateStartWindow` tag that covers AT LEAST ONE Step.6 cron + firing on that date. Shape: + + @{ + 'Cdn' = @{ + '2026-06-15' = @{ Matching = 29; Total = 30 } + '2026-06-22' = @{ Matching = 28; Total = 30 } + } + 'Prod' = @{ + '2026-06-15' = @{ Matching = 1; Total = 40 } + } + } + + Ring keys are case-insensitive; the inner per-date hashtable + keys are 'yyyy-MM-dd' UTC; the leaf hashtables MUST include + integer-coercible 'Matching' and 'Total' values. 'Pct' is + computed when not supplied (Matching/Total, with Total=0 short- + circuiting to a "(0 clusters)" cell). When supplied AND + -AsMarkdown is set, the per-day calendar table gains a "Tag + Start Window Match (>=95%)" column immediately after "Eligible + rings". When multiple rings are eligible on a single date, the + cell shows a per-ring breakdown: ``Cdn``: True 29/30 (97%); + ``Prod``: False 1/40 (3%). "True" iff Matching/Total >= 0.95. + Dead-day rows and rings missing from the hashtable render as + "_(n/a)_". Same pure-render contract as -CronFiringsByDate; + callers compute the dictionary from the cluster CSV they already + load and the cron firings already parsed for -CronFiringsByDate. + .OUTPUTS Default: [PSCustomObject[]], one per UTC day, with: DateUtc [datetime] - midnight UTC @@ -116,7 +163,13 @@ function Get-AzLocalApplyUpdatesScheduleCycleCalendar { [switch]$IncludePerRingSummary, [Parameter(Mandatory = $false)] - [hashtable]$ClusterRingCounts + [hashtable]$ClusterRingCounts, + + [Parameter(Mandatory = $false)] + [hashtable]$CronFiringsByDate, + + [Parameter(Mandatory = $false)] + [hashtable]$WindowMatchByRingAndDate ) Set-StrictMode -Version Latest @@ -206,6 +259,69 @@ function Get-AzLocalApplyUpdatesScheduleCycleCalendar { $hasCounts = ($ringCounts.Count -gt 0) } + # Normalise the optional cron-firings-by-date map. Keys are + # 'yyyy-MM-dd' strings; values are sorted, deduplicated 'HH:mm' UTC + # firing times. $hasCronFirings gates the new "Ring CRON Start + # Time" column in the per-day calendar markdown. + $cronFirings = $null + $hasCronFirings = $false + if ($PSBoundParameters.ContainsKey('CronFiringsByDate') -and $CronFiringsByDate) { + $cronFirings = New-Object 'System.Collections.Generic.Dictionary[string,string[]]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach ($k in $CronFiringsByDate.Keys) { + if ($null -eq $k) { continue } + $kn = ([string]$k).Trim() + if ([string]::IsNullOrWhiteSpace($kn)) { continue } + $raw = $CronFiringsByDate[$k] + if ($null -eq $raw) { continue } + $arr = @($raw | ForEach-Object { if ($null -ne $_) { ([string]$_).Trim() } } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($arr.Count -eq 0) { continue } + $sorted = [string[]]($arr | Sort-Object -Unique) + if (-not $cronFirings.ContainsKey($kn)) { $cronFirings[$kn] = $sorted } + } + $hasCronFirings = ($cronFirings.Count -gt 0) + } + + # Normalise the optional window-match map. Outer keys are ring + # names (case-insensitive); inner keys are 'yyyy-MM-dd' UTC dates; + # leaf hashtables MUST include 'Matching' and 'Total' (integers). + # 'Pct' is computed if not supplied. $hasWindowMatch gates the new + # "Tag Start Window Match (>=95%)" column. + $windowMatch = $null + $hasWindowMatch = $false + if ($PSBoundParameters.ContainsKey('WindowMatchByRingAndDate') -and $WindowMatchByRingAndDate) { + $windowMatch = New-Object 'System.Collections.Generic.Dictionary[string,System.Collections.Generic.Dictionary[string,hashtable]]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach ($rk in $WindowMatchByRingAndDate.Keys) { + if ($null -eq $rk) { continue } + $rn = ([string]$rk).Trim() + if ([string]::IsNullOrWhiteSpace($rn)) { continue } + $perDateRaw = $WindowMatchByRingAndDate[$rk] + if ($null -eq $perDateRaw -or -not ($perDateRaw -is [hashtable])) { continue } + $perDate = New-Object 'System.Collections.Generic.Dictionary[string,hashtable]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach ($dk in $perDateRaw.Keys) { + if ($null -eq $dk) { continue } + $dn = ([string]$dk).Trim() + if ([string]::IsNullOrWhiteSpace($dn)) { continue } + $leafRaw = $perDateRaw[$dk] + if ($null -eq $leafRaw -or -not ($leafRaw -is [hashtable])) { continue } + $matchingI = 0 + $totalI = 0 + if ($leafRaw.ContainsKey('Matching')) { [void][int]::TryParse([string]$leafRaw['Matching'], [ref]$matchingI) } + if ($leafRaw.ContainsKey('Total')) { [void][int]::TryParse([string]$leafRaw['Total'], [ref]$totalI) } + $pctD = 0.0 + if ($leafRaw.ContainsKey('Pct') -and $null -ne $leafRaw['Pct']) { + [void][double]::TryParse([string]$leafRaw['Pct'], [ref]$pctD) + } elseif ($totalI -gt 0) { + $pctD = [double]$matchingI / [double]$totalI + } + if (-not $perDate.ContainsKey($dn)) { + $perDate[$dn] = @{ Matching = $matchingI; Total = $totalI; Pct = $pctD } + } + } + if ($perDate.Count -gt 0 -and -not $windowMatch.ContainsKey($rn)) { $windowMatch[$rn] = $perDate } + } + $hasWindowMatch = ($windowMatch.Count -gt 0) + } + # ---- Markdown rendering ---------------------------------------- $sb = New-Object System.Text.StringBuilder $endDate = $start.AddDays($Days - 1) @@ -217,15 +333,34 @@ function Get-AzLocalApplyUpdatesScheduleCycleCalendar { [void]$sb.AppendLine("**Why this is one full cycle.** Your schedule declares ``cycleWeeks: $cycleWeeks``, so the projection covers $($cycleWeeks * 7) day(s). If today is partway through the current cycle (for example week $cycleWeeks of a $cycleWeeks-week cycle), the table starts on today's CycleWeek and rolls through the start of the next cycle - so you can see exactly when each ring will next fire. The ``(cycle wraps)`` annotation marks the row on which the rotation resets to week 1.") [void]$sb.AppendLine() } + # Header pieces: build incrementally so a future 3rd optional + # column slots in cleanly. Order MUST be: + # Date | [Cron firings] | Day | CycleWeek | Eligible rings | [Window match] | [Cluster counts] | AllowedUpdateVersions + $headerCells = New-Object System.Collections.Generic.List[string] + $alignCells = New-Object System.Collections.Generic.List[string] + [void]$headerCells.Add('Date (UTC)'); [void]$alignCells.Add('---') + if ($hasCronFirings) { + [void]$headerCells.Add('Ring CRON Start Time
(Step 6 pipeline)') + [void]$alignCells.Add(':---:') + } + [void]$headerCells.Add('Day'); [void]$alignCells.Add('---') + [void]$headerCells.Add('CycleWeek'); [void]$alignCells.Add('---') + [void]$headerCells.Add('Eligible rings'); [void]$alignCells.Add('---') + if ($hasWindowMatch) { + [void]$headerCells.Add('Tag Start Window Match (>=95%)') + [void]$alignCells.Add('---') + } if ($hasCounts) { - [void]$sb.AppendLine('| Date (UTC) | Day | CycleWeek | Eligible rings | Clusters in ring(s) | AllowedUpdateVersions |') - [void]$sb.AppendLine('|---|---|---|---|---|---|') - } else { - [void]$sb.AppendLine('| Date (UTC) | Day | CycleWeek | Eligible rings | AllowedUpdateVersions |') - [void]$sb.AppendLine('|---|---|---|---|---|') + [void]$headerCells.Add('Clusters in ring(s)') + [void]$alignCells.Add('---') } + [void]$headerCells.Add('AllowedUpdateVersions'); [void]$alignCells.Add('---') + [void]$sb.AppendLine('| ' + ($headerCells -join ' | ') + ' |') + [void]$sb.AppendLine('|' + (($alignCells | ForEach-Object { $_ }) -join '|') + '|') foreach ($r in $rows) { - $ringsCell = if ($r.Rings -and @($r.Rings).Count -gt 0) { + $dateKey = $r.DateUtc.ToString('yyyy-MM-dd') + $isDead = (-not $r.Rings -or @($r.Rings).Count -eq 0) + $ringsCell = if (-not $isDead) { (@($r.Rings) | ForEach-Object { '`' + $_ + '`' }) -join ', ' } else { '_(none - dead day)_' @@ -235,28 +370,82 @@ function Get-AzLocalApplyUpdatesScheduleCycleCalendar { } else { '_(no constraint)_' } - if ($hasCounts) { - $countCell = if ($r.Rings -and @($r.Rings).Count -gt 0) { + $cronCell = $null + if ($hasCronFirings) { + if ($isDead) { + $cronCell = '_(none - dead day)_' + } else { + $fires = @() + if ($cronFirings.ContainsKey($dateKey)) { $fires = @($cronFirings[$dateKey]) } + if ($fires.Count -eq 0) { + $cronCell = '_(none)_' + } elseif ($fires.Count -le 2) { + $cronCell = ($fires -join ', ') + } else { + $extra = $fires.Count - 2 + $cronCell = (($fires | Select-Object -First 2) -join ', ') + " (+$extra)" + } + } + } + $matchCell = $null + if ($hasWindowMatch) { + if ($isDead) { + $matchCell = '_(n/a - dead day)_' + } else { $parts = @() + foreach ($rg in @($r.Rings)) { + $perDate = $null + if ($windowMatch.ContainsKey($rg)) { $perDate = $windowMatch[$rg] } + if ($null -eq $perDate -or -not $perDate.ContainsKey($dateKey)) { + $parts += "``$rg``: _(n/a)_" + continue + } + $leaf = $perDate[$dateKey] + $tot = [int]$leaf['Total'] + $mat = [int]$leaf['Matching'] + if ($tot -le 0) { + $parts += "``$rg``: _(0 clusters)_" + continue + } + $pct = [double]$leaf['Pct'] + $isOk = ($pct -ge 0.95) + $pctTxt = [int][math]::Round($pct * 100) + $parts += "``$rg``: $isOk $mat/$tot ($pctTxt%)" + } + $matchCell = ($parts -join '; ') + } + } + $countCell = $null + if ($hasCounts) { + $countCell = if (-not $isDead) { + $cparts = @() $total = 0 foreach ($rg in @($r.Rings)) { $cnt = 0 if ($ringCounts.ContainsKey($rg)) { $cnt = $ringCounts[$rg] } - $parts += "``$rg``: $cnt" + $cparts += "``$rg``: $cnt" $total += $cnt } if (@($r.Rings).Count -gt 1) { - (($parts -join ', ') + " (total: $total)") + (($cparts -join ', ') + " (total: $total)") } else { - ($parts -join ', ') + ($cparts -join ', ') } } else { '_(0 - dead day)_' } - [void]$sb.AppendLine("| $($r.DateUtc.ToString('yyyy-MM-dd')) | $($r.DayOfWeekName) | $($r.CycleWeekLabel) | $ringsCell | $countCell | $allowCell |") - } else { - [void]$sb.AppendLine("| $($r.DateUtc.ToString('yyyy-MM-dd')) | $($r.DayOfWeekName) | $($r.CycleWeekLabel) | $ringsCell | $allowCell |") } + # Assemble cells in the same order as the header. + $rowCells = New-Object System.Collections.Generic.List[string] + [void]$rowCells.Add($dateKey) + if ($hasCronFirings) { [void]$rowCells.Add($cronCell) } + [void]$rowCells.Add([string]$r.DayOfWeekName) + [void]$rowCells.Add([string]$r.CycleWeekLabel) + [void]$rowCells.Add($ringsCell) + if ($hasWindowMatch) { [void]$rowCells.Add($matchCell) } + if ($hasCounts) { [void]$rowCells.Add($countCell) } + [void]$rowCells.Add($allowCell) + [void]$sb.AppendLine('| ' + ($rowCells -join ' | ') + ' |') } [void]$sb.AppendLine() diff --git a/AzLocal.UpdateManagement/Public/Get-AzLocalClusterUpdateReadiness.ps1 b/AzLocal.UpdateManagement/Public/Get-AzLocalClusterUpdateReadiness.ps1 index e2c1c492..8d347c7f 100644 --- a/AzLocal.UpdateManagement/Public/Get-AzLocalClusterUpdateReadiness.ps1 +++ b/AzLocal.UpdateManagement/Public/Get-AzLocalClusterUpdateReadiness.ps1 @@ -158,6 +158,7 @@ function Get-AzLocalClusterUpdateReadiness { SubscriptionId = $cluster.subscriptionId Tags = $cluster.tags Properties = $cluster.properties + NotFound = $false } } } @@ -197,6 +198,7 @@ function Get-AzLocalClusterUpdateReadiness { SubscriptionId = $cluster.subscriptionId Tags = $cluster.tags Properties = $cluster.properties + NotFound = $false } } else { @@ -249,6 +251,7 @@ function Get-AzLocalClusterUpdateReadiness { SubscriptionId = $cluster.subscriptionId Tags = $cluster.tags Properties = $cluster.properties + NotFound = $false } } else { diff --git a/AzLocal.UpdateManagement/Public/Get-AzLocalFleetConnectivityStatus.ps1 b/AzLocal.UpdateManagement/Public/Get-AzLocalFleetConnectivityStatus.ps1 index d75ecd10..b303c297 100644 --- a/AzLocal.UpdateManagement/Public/Get-AzLocalFleetConnectivityStatus.ps1 +++ b/AzLocal.UpdateManagement/Public/Get-AzLocalFleetConnectivityStatus.ps1 @@ -340,11 +340,21 @@ extensibilityresources $arbRows = @($arbRaw | ForEach-Object { $a = $_ $rg = ([string]$a.resourceGroup).ToLowerInvariant() - $matched = if ($clustersByRg.ContainsKey($rg)) { @($clustersByRg[$rg]) } else { @() } + # v0.8.6-fix3: when the RG has no matching cluster, do NOT use @($null) - + # PowerShell's @() wrap on a $null produces an Object[1] containing $null + # (not an empty array), so $matched.Count is 1 and downstream $_.ClusterName + # fails with PropertyNotFoundException. Branch explicitly on ContainsKey + # and emit @() for the miss case. + if ($clustersByRg.ContainsKey($rg)) { + $matched = @($clustersByRg[$rg]) + } else { + $matched = @() + } + $matchedCount = $matched.Count - $clusterName = if ($matched.Count -gt 0) { ($matched | ForEach-Object { $_.ClusterName }) -join ', ' } else { '(no cluster)' } - $clusterId = if ($matched.Count -gt 0) { ($matched | ForEach-Object { $_.ClusterId }) -join ', ' } else { '' } - $clusterStatus = if ($matched.Count -gt 0) { ($matched | ForEach-Object { $_.ClusterStatus }) -join ', ' } else { '' } + $clusterName = if ($matchedCount -gt 0) { ($matched | ForEach-Object { $_.ClusterName }) -join ', ' } else { '(no cluster)' } + $clusterId = if ($matchedCount -gt 0) { ($matched | ForEach-Object { $_.ClusterId }) -join ', ' } else { '' } + $clusterStatus = if ($matchedCount -gt 0) { ($matched | ForEach-Object { $_.ClusterStatus }) -join ', ' } else { '' } $status = CoerceStr (Get-NestedProp $a 'properties.status') 'Unknown' # Read the extended top-level column first, fall back to systemData.lastModifiedAt diff --git a/AzLocal.UpdateManagement/Public/Set-AzLocalClusterUpdateRingTag.ps1 b/AzLocal.UpdateManagement/Public/Set-AzLocalClusterUpdateRingTag.ps1 index 4a56fd7d..e1e732aa 100644 --- a/AzLocal.UpdateManagement/Public/Set-AzLocalClusterUpdateRingTag.ps1 +++ b/AzLocal.UpdateManagement/Public/Set-AzLocalClusterUpdateRingTag.ps1 @@ -576,6 +576,20 @@ function Set-AzLocalClusterUpdateRingTag { Write-Log -Message " Will default-stamp $($script:UpdateExcludedTagName) tag: 'False' (tag absent on cluster)" -Level Info } + # Compute the actual per-tag deltas so the per-cluster Message in the + # summary names the tags that genuinely changed (not always "UpdateRing"). + # When only a schedule tag (e.g. UpdateExcluded) differs we want the + # message to read "UpdateExcluded: 'False' -> 'True'" rather than the + # misleading "UpdateRing tag updated successfully". + $tagDeltas = New-Object System.Collections.Generic.List[string] + foreach ($tagName in $tagsToMerge.Keys) { + $newVal = [string]$tagsToMerge[$tagName] + $oldVal = if ($currentTags.PSObject.Properties[$tagName]) { [string]$currentTags.$tagName } else { '' } + if ($oldVal -ne $newVal) { + $tagDeltas.Add(("{0}: '{1}' -> '{2}'" -f $tagName, $oldVal, $newVal)) + } + } + # Apply the tag using PATCH against the dedicated tags subresource. # Using /providers/Microsoft.Resources/tags/default (api-version 2021-04-01) # narrows the required RBAC from `microsoft.azurestackhci/clusters/write` @@ -608,7 +622,12 @@ function Set-AzLocalClusterUpdateRingTag { if ($LASTEXITCODE -eq 0) { Write-Log -Message "Successfully $($action.ToLower()) UpdateRing tag" -Level Success $status = "Success" - $message = "UpdateRing tag $($action.ToLower()) successfully" + if ($tagDeltas.Count -gt 0) { + $message = "Tags $($action.ToLower()): " + ($tagDeltas -join '; ') + } + else { + $message = "UpdateRing tag $($action.ToLower()) successfully" + } } else { $scrubbed = ConvertTo-ScrubbedCliOutput -Text ($result | Out-String).Trim() @@ -626,7 +645,12 @@ function Set-AzLocalClusterUpdateRingTag { } else { $status = "WhatIf" - $message = "Would $($action.ToLower()) UpdateRing tag" + if ($tagDeltas.Count -gt 0) { + $message = "Would $($action.ToLower()) tags: " + ($tagDeltas -join '; ') + } + else { + $message = "Would $($action.ToLower()) UpdateRing tag" + } } # Write to CSV @@ -691,9 +715,15 @@ function Set-AzLocalClusterUpdateRingTag { Write-Log -Message "CSV log saved to: $csvLogPath" -Level Info Write-Log -Message "========================================" -Level Header - # Display results table + # Display results table. + # IMPORTANT: pipe to Out-Host so the Format-Table format objects (header / + # row / footer wrappers) never leak into the function's pipeline output. + # Without -Out-Host they would mix with `return $results` under -PassThru + # and the caller's `$results = @(Set-AzLocalClusterUpdateRingTag ...)` would + # see ~1.2x as many objects as real cluster rows, inflating `.Count` in + # downstream summaries (Step.2 v0.8.6 regression). Write-Host "" - $results | Format-Table ClusterName, Action, PreviousTagValue, NewTagValue, Status -AutoSize + $results | Format-Table ClusterName, Action, PreviousTagValue, NewTagValue, Status -AutoSize | Out-Host if ($PassThru) { return $results diff --git a/AzLocal.UpdateManagement/Public/Set-AzLocalClusterUpdateRingTagFromCsv.ps1 b/AzLocal.UpdateManagement/Public/Set-AzLocalClusterUpdateRingTagFromCsv.ps1 index 0248fd00..45e652c8 100644 --- a/AzLocal.UpdateManagement/Public/Set-AzLocalClusterUpdateRingTagFromCsv.ps1 +++ b/AzLocal.UpdateManagement/Public/Set-AzLocalClusterUpdateRingTagFromCsv.ps1 @@ -221,6 +221,14 @@ function Set-AzLocalClusterUpdateRingTagFromCsv { $results = @(Set-AzLocalClusterUpdateRingTag @applyParams) + # Defence-in-depth: filter to only per-cluster result objects so any stray + # Format-Table formatter objects (header / row / footer wrappers) from + # older module versions cannot inflate $results.Count. A genuine result row + # always exposes a ClusterName string property; formatter objects do not. + $results = @($results | Where-Object { + $_ -and $_.PSObject.Properties['ClusterName'] -and $_.ClusterName -is [string] + }) + # ----- 5. JSON sidecar --------------------------------------------- $resultsJsonPath = Join-Path -Path $OutputDirectory -ChildPath $ResultsJsonFileName if ($results.Count -gt 0) { @@ -287,22 +295,57 @@ function Set-AzLocalClusterUpdateRingTagFromCsv { } [void]$sb.AppendLine(("| Failed | {0} |" -f $failed)) if ($total -gt 0) { - [void]$sb.AppendLine('') - [void]$sb.AppendLine(("
Per-cluster results ({0} rows)" -f $total)) - [void]$sb.AppendLine('') - [void]$sb.AppendLine('| Cluster | Action | Previous | New | Status | Message |') - [void]$sb.AppendLine('|---------|--------|----------|-----|--------|---------|') - foreach ($r in $results) { - $cn = ([string]$r.ClusterName) -replace '\|','\|' - $act = ([string]$r.Action) -replace '\|','\|' - $pv = ([string]$r.PreviousTagValue) -replace '\|','\|' - $nv = ([string]$r.NewTagValue) -replace '\|','\|' - $st = ([string]$r.Status) -replace '\|','\|' - $msg = (([string]$r.Message) -replace '\|','\|') -replace '\r?\n',' ' - [void]$sb.AppendLine(("| {0} | {1} | {2} | {3} | {4} | {5} |" -f $cn, $act, $pv, $nv, $st, $msg)) + # Bucket the per-cluster results so the summary UI is easier to scan: + # - "Tag Updates Applied" : rows that actually changed at least one + # managed tag (Status=Success or WhatIf). + # - "No Tag Updates (no-op)" : steady-state rows where every managed + # tag already matched desired state (Status=AlreadyInSync). + # - "Skipped / Failed" : rows that need operator attention. + $appliedRows = @($results | Where-Object { $_.Status -eq 'Success' -or $_.Status -eq 'WhatIf' }) + $noopRows = @($results | Where-Object { $_.Status -eq 'AlreadyInSync' }) + $issueRows = @($results | Where-Object { $_.Status -eq 'Skipped' -or $_.Status -eq 'Failed' }) + + $renderRows = { + param($rows) + [void]$sb.AppendLine('| Cluster | Action | Previous | New | Status | Message |') + [void]$sb.AppendLine('|---------|--------|----------|-----|--------|---------|') + foreach ($r in $rows) { + $cn = ([string]$r.ClusterName) -replace '\|','\|' + $act = ([string]$r.Action) -replace '\|','\|' + $pv = ([string]$r.PreviousTagValue) -replace '\|','\|' + $nv = ([string]$r.NewTagValue) -replace '\|','\|' + $st = ([string]$r.Status) -replace '\|','\|' + $msg = (([string]$r.Message) -replace '\|','\|') -replace '\r?\n',' ' + [void]$sb.AppendLine(("| {0} | {1} | {2} | {3} | {4} | {5} |" -f $cn, $act, $pv, $nv, $st, $msg)) + } + } + + if ($appliedRows.Count -gt 0) { + [void]$sb.AppendLine('') + [void]$sb.AppendLine(("
Clusters with Tag Updates Applied ({0} rows)" -f $appliedRows.Count)) + [void]$sb.AppendLine('') + & $renderRows $appliedRows + [void]$sb.AppendLine('') + [void]$sb.AppendLine('
') + } + + if ($issueRows.Count -gt 0) { + [void]$sb.AppendLine('') + [void]$sb.AppendLine(("
Clusters Skipped or Failed ({0} rows)" -f $issueRows.Count)) + [void]$sb.AppendLine('') + & $renderRows $issueRows + [void]$sb.AppendLine('') + [void]$sb.AppendLine('
') + } + + if ($noopRows.Count -gt 0) { + [void]$sb.AppendLine('') + [void]$sb.AppendLine(("
Clusters with No Tag Updates (no-op) ({0} rows)" -f $noopRows.Count)) + [void]$sb.AppendLine('') + & $renderRows $noopRows + [void]$sb.AppendLine('') + [void]$sb.AppendLine('
') } - [void]$sb.AppendLine('') - [void]$sb.AppendLine('
') } if ($WhatIfPreference) { [void]$sb.AppendLine('') diff --git a/AzLocal.UpdateManagement/Public/Test-AzLocalApplyUpdatesScheduleCoverage.ps1 b/AzLocal.UpdateManagement/Public/Test-AzLocalApplyUpdatesScheduleCoverage.ps1 index 6309a941..055505ff 100644 --- a/AzLocal.UpdateManagement/Public/Test-AzLocalApplyUpdatesScheduleCoverage.ps1 +++ b/AzLocal.UpdateManagement/Public/Test-AzLocalApplyUpdatesScheduleCoverage.ps1 @@ -1136,7 +1136,14 @@ resources $status = if ($coveredCount -eq 0) { 'Uncovered' } elseif ($coveredCount -eq $segmentStatuses.Count) { 'Covered' } else { 'PartiallyCovered' } - $allMatched = @($segmentStatuses.MatchingCrons | Select-Object -Unique) + # v0.8.6-fix3: explicitly enumerate with ForEach-Object instead of + # relying on `$segmentStatuses.MatchingCrons` member-enumeration. + # Under Set-StrictMode -Version Latest, even with .Count > 0, the + # array-level `.MatchingCrons` access throws PropertyNotFound + # ("The property 'MatchingCrons' cannot be found on this object"). + $allMatched = if ($segmentStatuses.Count -gt 0) { + @($segmentStatuses | ForEach-Object { $_.MatchingCrons } | Select-Object -Unique) + } else { @() } $allRequired = ($segmentStatuses | ForEach-Object { $_.RequiredCron }) -join '; ' $issue = switch ($status) { 'Covered' { '' } diff --git a/AzLocal.UpdateManagement/README.md b/AzLocal.UpdateManagement/README.md index f12b4433..0c17913d 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.5 - [Published in PowerShell Gallery](https://www.powershellgallery.com/packages/AzLocal.UpdateManagement/0.8.5) +**Latest Version:** v0.8.6 - [Published in PowerShell Gallery](https://www.powershellgallery.com/packages/AzLocal.UpdateManagement/0.8.6) > 📢 **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.5](#whats-new-in-v085) +- [What's New in v0.8.6](#whats-new-in-v086) - [What's New in v0.8.4](#whats-new-in-v084) - [Files](#files) - [Prerequisites](#prerequisites) @@ -87,22 +87,21 @@ 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.5 +## What's New in v0.8.6 -**New Public cmdlet `Get-AzLocalApplyUpdatesScheduleCycleCalendar` + Step.6 manual schedule-file inputs + Step.3 cycle-calendar regression fix + per-ring cluster-count column + full thin-YAML port across all 10 Step pipelines.** 15 new exported cmdlets (1 in the original v0.8.5 scope plus 14 across the thin-YAML port), two new Step.6 manual-run inputs, ~9,000 lines of inline YAML PowerShell condensed into testable Public cmdlets. No public API removed; no parameter changes on existing cmdlets. **Total exported function count grows from 35 (v0.8.4) to 55 (v0.8.5).** +**Step.3 cycle calendar enrichment: per-day Step.6 CRON firing times + per-(ring, date) `UpdateStartWindow` tag-coverage check (>=95% threshold).** Adds two new optional render-time columns to `Get-AzLocalApplyUpdatesScheduleCycleCalendar` and auto-wires them from `Export-AzLocalApplyUpdatesScheduleAudit` so operators can see, in one table: (a) which Step.6 cron firing times will fire on each calendar day, and (b) what fraction of clusters in the ring(s) eligible on that day have an `UpdateStartWindow` tag that actually covers at least one of those firings. **No public API removed; no existing parameter changed; no behavioural change on callers that do NOT supply the new dictionaries.** Same module export count as v0.8.5 (55). -1. **New Step.6 manual schedule-file inputs.** GH `workflow_dispatch` gains `use_schedule_file` (choice `false`/`true`, default `false`) + `resolve_for_date_utc` (string, default empty, format `YYYY-MM-DD`). ADO `parameters` gains symmetric `useScheduleFile` (boolean) + `resolveForDateUtc` (string). When the operator triggers Step.6 manually with `use_schedule_file=true`, the resolver reads `apply-updates-schedule.yml` and runs `Resolve-AzLocalCurrentUpdateRing -Schedule $cfg -Now $resolveAt` for today UTC (or the operator-supplied date) - **exactly as a scheduled run would**, including AllowedUpdateVersions precedence. Use this to (a) test a schedule change before the next scheduled tick, (b) re-run a missed scheduled day, or (c) preview a future cycleWeek/dayOfWeek. `update_ring` / `updateRing` is no longer marked `required: true` - supply it OR set `use_schedule_file=true`. The resolver throws a helpful error if BOTH are empty. **Back-compat**: manual runs with `use_schedule_file=false` use the manual ring verbatim (v0.8.4 behaviour). Scheduled firings are unchanged. -2. **New Public cmdlet `Get-AzLocalApplyUpdatesScheduleCycleCalendar`.** Projects an `apply-updates-schedule.yml` configuration forward across the calendar - one row per UTC day - and emits either a structured object pipeline (default) or a fully-rendered markdown block (`-AsMarkdown`). Parameters: `-Schedule ` (mandatory; pre-loaded schedule object), `-StartDate ` (default UTC today), `-Days ` (default = `CycleWeeks * 7`, ValidateRange 1-3650), `-AsMarkdown`, `-IncludePerRingSummary`, `-ClusterRingCounts `. Each output row carries `DateUtc, DayOfWeekName, CycleWeek, CycleWeeksTotal, CycleWeekLabel, IsCycleWrap, Rings, UpdateRingValue, AllowedUpdateVersions, AllowedUpdateVersionsSource, MatchedRowCount, IsDeadDay, Reason`. Re-uses `Resolve-AzLocalCurrentUpdateRing` per-day so cycle-week math, UNION semantics, and AllowedUpdateVersions precedence stay identical to runtime - no duplicate ISO-8601 / week-arithmetic logic to drift. Variable cycle length safe (4 / 8 / 52 weeks all tested) and year-boundary safe (W52 -> W1 wrap on the cycle anchor, W53 years). -3. **Regression fix: Step.3 cycle calendar silently dropped in v0.8.4.** Pre-v0.8.5 the Enhancement B cycle-calendar table lived inside `Test-AzLocalApplyUpdatesScheduleCoverage -View Recommend`'s snippet builder, AFTER the "any findings?" gate. On a fleet with **zero** advisor findings the snippet was empty, so the calendar never reached the Step.3 Summary even though `-SchedulePath` was supplied. The Step.3 yml `if ($hasIssues -and $reco)` / `if (-not $hasIssues -and $reco)` branches both fire only when `$reco` is non-empty, so the calendar quietly disappeared on healthy fleets. The new cmdlet is now invoked unconditionally whenever `$scheduleCfg` is parsed, so the cycle calendar always renders in Step.3 - no Step.3 yml structural change beyond the version bump. -4. **New: `Clusters in ring(s)` calendar column + `Cluster count` per-ring projection column** (via new `-ClusterRingCounts` hashtable parameter). When the caller supplies a `@{ Std='5'; Canary='3'; Prod='12' }`-shaped map, the calendar table gains a 6th column showing `5` for single-ring days, `3+12 (total: 15)` for UNION rows, and the optional `### Per-ring projection` section gains a `Cluster count` column. Lookups are `OrdinalIgnoreCase`. Step.3 builds the map from the live tagged `$clusters` already in scope and passes it through - the cmdlet itself stays pure (no CSV / Azure I/O). Operators see at a glance "today fires 8 clusters" / "Friday fires 20". -5. **New: optional `### Per-ring projection` section (`-IncludePerRingSummary`).** Renders a row per ring with the next eligible UTC date + all eligible dates inside the horizon (with cluster count column when `-ClusterRingCounts` is supplied). -6. **`Test-AzLocalApplyUpdatesScheduleCoverage -View Recommend` Enhancement B now delegates to the new cmdlet.** The v0.8.4 inline cycle-calendar block is replaced by a single call to `Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $scheduleCfg -AsMarkdown -IncludePerRingSummary -ClusterRingCounts $ringCountMap`, wrapped in a `try/catch` with a `_Cycle calendar unavailable: ..._` fallback line. Output shape on healthy fleets is preserved + extended (the calendar now always renders, the new 6th cluster-count column appears, the per-ring projection section appears). -7. **Pester suite updates**: drift-sync test bumped to `'0.8.5'`; new drift test asserts `Get-AzLocalApplyUpdatesScheduleCycleCalendar` is exported in `FunctionsToExport`; ~28 new It blocks across two new Describe blocks cover object-pipeline shape, default Days math, day-0 resolution, IsDeadDay, UNION rendering, IsCycleWrap on rollover, 52-week / 8-week / 1-week cycles, multi-cycle horizon, defensive `CycleWeeks=0` throw, markdown heading + 5-column / 6-column header variants, the v0.8.4 silent-drop regression guard, ClusterRingCounts UNION + dead-day rendering, case-insensitive ring lookup, cycle-wrap annotation, year-boundary safety. 12 additional It blocks under `Context 'v0.8.5 Step.6 manual schedule-file input'` cover the new Step.6 GH + ADO yml inputs, resolver decision tree, env-var wiring, `-Now $resolveAt` plumbing, the both-empty throw, and `resolve_for_date_utc` parsing. -8. **Thin-YAML port across all 10 Step pipelines (Step.0 - Step.9).** The multi-hundred-line inline `run: |` PowerShell blocks that previously lived inside each Step's GitHub Actions YAML and Azure DevOps YAML are now condensed into Public cmdlets (14 new exports - one per Step plus shared helpers, with Step.6 splitting across 6 cmdlets). Cumulative reduction: approximately **9,000 lines** removed from `Automation-Pipeline-Examples/`. Step.6 alone goes from 964 -> 480 lines on GH (-484) and 985 -> 440 lines on ADO (-545). **Zero UI / outcome delta**: step output names, artifact contents, JUnit XML, markdown summaries, error messages, and stageDependencies bindings are preserved byte-for-byte. Benefits: **single source of truth** between GH + ADO (a fix lands once and benefits both); **unit-testable** (Pester guards instead of waiting for a pipeline run + artifact inspection); **discoverable** via `Get-Help`; **faster CI signal** (~5min Pester run vs minutes per platform per change). Per-host naming + icon style is owned by the cmdlet, not the YAML: GH uses `UPPER_SNAKE` step outputs + Unicode glyphs, ADO uses `PascalCase` outputs (for `outputs['readiness.ReadyCount']` stageDependencies bindings) + GitHub-Markdown shortcodes. Total module export count grows **35 -> 55**. Local Pester suite: **1069 passed, 0 failed, 1 skipped** (~4m38s). Full breakdown in [CHANGELOG.md](CHANGELOG.md#085---2026-06-09) under "Thin-YAML refactor across all 10 Step pipelines". +1. **New `Get-AzLocalApplyUpdatesScheduleCycleCalendar -CronFiringsByDate`** (`[hashtable]`, keys = `yyyy-MM-dd` UTC, values = `[string[]]` of `HH:mm` UTC firing times). When supplied, the per-day markdown table gains a centered `Ring CRON Start Time (UTC)
(Step 6 pipeline)` column between `Date (UTC)` and `Day`. Cell rendering: 0 firings -> `_(none)_`; 1-2 firings -> comma-joined (e.g. `02:00, 22:00`); 3+ firings -> first 2 + `" (+N)"` (e.g. `02:00, 10:00 (+2)`); dead day (`IsDeadDay`) -> `_(none - dead day)_`; missing date key -> blank cell. +2. **New `Get-AzLocalApplyUpdatesScheduleCycleCalendar -WindowMatchByRingAndDate`** (`[hashtable[string,hashtable]]` keyed first by ring name then by `yyyy-MM-dd` UTC, leaf = `@{ Matching=; Total= }`). When supplied, the per-day table gains a `Tag Start Window Match (>=95%)` column AFTER `Eligible rings`. Each cell lists one line per eligible ring: `` `Ring`: True/False mat/tot (pct%) `` (True iff `Matching/Total >= 0.95`). `_(n/a)_` when a ring has no entry for the date; `_(0 clusters)_` when `Total=0`; `_(n/a - dead day)_` on dead days. +3. **Pure render-time contract preserved.** `Get-AzLocalApplyUpdatesScheduleCycleCalendar` still does zero Azure / file I/O. Both columns are opt-in; omit either or both and the v0.8.5 column layout is bit-identical. +4. **`Export-AzLocalApplyUpdatesScheduleAudit` auto-wires both columns.** Cron firings are derived by parsing `Step.6_apply-updates*.yml` from `-PipelineYamlPath` (uses existing `Read-AzLocalApplyUpdatesYamlCrons` + `ConvertFrom-AzLocalCronExpression`; invalid/complex crons degrade gracefully to no firings for that day). The window-match dict is derived when `-ClusterCsvPath` is supplied, by parsing each cluster's `UpdateStartWindow` tag (via `ConvertFrom-AzLocalUpdateWindow`) against each day's cron firings; overnight windows (e.g. `Mon-Sun_22:00-04:00`) match firings that fall in either the late-evening or early-morning portion correctly via a two-case `DayOfWeek` projection. +5. **95% threshold.** A ring on a given day is True only when `Matching / Total >= 0.95` exact. Below threshold -> False (operators see the actual `mat/tot (pct%)` numbers and can act). +6. **Failure mode is non-fatal.** Any error in the enrichment block degrades gracefully to a calendar without the new columns (matching v0.8.5 behaviour); a `Write-Warning` surfaces the cause but the Step.3 summary continues to render. +7. **Pester suite updates**: drift-sync test bumped to `'0.8.6'`. 12 new It blocks under `Describe 'v0.8.6 Apply-Updates Schedule: Get-AzLocalApplyUpdatesScheduleCycleCalendar - CronFiringsByDate and WindowMatchByRingAndDate columns (markdown)'` cover: backwards compat, single-param paths, both-params path with 7-column header, `(+N)` suffix at 3+ firings, `_(none)_` cell, 95% threshold boundary (95/100 -> True, 94/100 -> False), `_(0 clusters)_` rendering, `_(n/a)_` rendering, case-insensitivity on ring + date keys, and three-optional-columns coexistence with `-ClusterRingCounts`. 2 new Export-* spy tests verify the auto-wire path. -`GENERATED_AGAINST_MODULE_VERSION` bumped from `0.8.4` to `0.8.5` across all 20 bundled `Step.{0..9}.yml` templates. +`GENERATED_AGAINST_MODULE_VERSION` bumped from `0.8.5` to `0.8.6` across all 20 bundled `Step.{0..9}.yml` templates. -See [CHANGELOG.md](CHANGELOG.md#085---2026-06-09) for the full v0.8.5 entry. +See [CHANGELOG.md](CHANGELOG.md#086---2026-06-10) for the full v0.8.6 entry. See [CHANGELOG.md](CHANGELOG.md#085---2026-06-09) for the v0.8.5 entry. ## Files @@ -581,7 +580,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.5** stay above under [`What's New in v0.8.5`](#whats-new-in-v085). +The most recent release notes for **v0.8.6** stay above under [`What's New in v0.8.6`](#whats-new-in-v086). ### What's New in v0.8.4 diff --git a/AzLocal.UpdateManagement/Tests/AzLocal.UpdateManagement.Tests.ps1 b/AzLocal.UpdateManagement/Tests/AzLocal.UpdateManagement.Tests.ps1 index 2c1dc14f..6e112454 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.5' { - $script:ModuleInfo.Version | Should -Be '0.8.5' + It 'Should have version 0.8.6' { + $script:ModuleInfo.Version | Should -Be '0.8.6' } It 'Module version constants are in sync between .psm1 and .psd1' { @@ -1211,6 +1211,25 @@ Describe 'Function: Set-AzLocalClusterUpdateRingTag' { $script:setRingSource | Should -Match 'Force mode enabled but all managed tags already match desired state' $script:setRingSource | Should -Match '-Force PATCH skipped' } + + It 'Per-cluster Message names the tags that actually changed (v0.8.6 regression guard)' { + # v0.8.6 fix: when only a schedule tag (e.g. UpdateExcluded) changed, the + # message previously read "UpdateRing tag updated successfully" which was + # misleading. The success message now includes the actual per-tag deltas + # computed from $currentTags vs $tagsToMerge. + $script:setRingSource | Should -Match '\$tagDeltas\s*=\s*New-Object\s+System\.Collections\.Generic\.List\[string\]' + $script:setRingSource | Should -Match 'foreach\s*\(\s*\$tagName\s+in\s+\$tagsToMerge\.Keys\s*\)' + $script:setRingSource | Should -Match "Tags \$\(\`$action\.ToLower\(\)\): " + } + + It 'Format-Table output is routed to Out-Host so formatter objects do not leak into -PassThru (v0.8.6 regression guard)' { + # v0.8.6 fix: $results | Format-Table -AutoSize was leaking format-table + # wrapper objects (header / row / footer) into the function's pipeline + # output. Caller `$results = @(Set-AzLocal... )` then saw ~1.2x as many + # objects as real cluster rows, inflating Step.2 summary counts (44 vs + # 20 on a 20-row CSV). Fix: pipe Format-Table to Out-Host explicitly. + $script:setRingSource | Should -Match 'Format-Table[^|]+-AutoSize\s*\|\s*Out-Host' + } } } @@ -12729,6 +12748,161 @@ schedule: } } +Describe 'v0.8.6 Apply-Updates Schedule: Get-AzLocalApplyUpdatesScheduleCycleCalendar - CronFiringsByDate and WindowMatchByRingAndDate columns (markdown)' { + BeforeAll { + $script:cccol_tmp = Join-Path $TestDrive 'cycle-cal-cols' + New-Item -ItemType Directory -Path $script:cccol_tmp -Force | Out-Null + + # 1-week cycle: Mon-Fri -> Cdn ring, Sat-Sun -> Prod ring. + $p = Join-Path $script:cccol_tmp 'cycle1.yml' + $body = @' +schemaVersion: 1 +cycleWeeks: 1 +cycleAnchorISOWeek: 1 +cycleAnchorYear: 2026 +schedule: + - weeksInCycle: '*' + daysOfWeek: 'Mon-Fri' + rings: 'Cdn' + - weeksInCycle: '*' + daysOfWeek: 'Sat,Sun' + rings: 'Prod' +'@ + Set-Content -LiteralPath $p -Value $body -Encoding utf8 + $script:cccol_cfg = Get-AzLocalApplyUpdatesScheduleConfig -Path $p + + # Monday of ISO W1 2026 in UTC - identical computation to the + # existing v0.8.5 fixtures so cycle math is deterministic. + $jan4 = [datetime]::new(2026, 1, 4, 0, 0, 0, [DateTimeKind]::Utc) + $script:cccol_wk1Mon = $jan4.AddDays(-1 * ((($jan4.DayOfWeek.value__ + 6) % 7))) + } + + It 'Backwards compatible: when neither new param is supplied, the per-day table still has the v0.8.5 5-column header (no Clusters)' { + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cccol_cfg -StartDate $script:cccol_wk1Mon -Days 7 -AsMarkdown + $md | Should -Match '\| Date \(UTC\) \| Day \| CycleWeek \| Eligible rings \| AllowedUpdateVersions \|' + $md | Should -Not -Match 'Ring CRON Start Time' + $md | Should -Not -Match 'Tag Start Window Match' + } + + It 'CronFiringsByDate alone adds the centered Ring CRON column immediately after Date (UTC)' { + $cron = @{} + for ($i = 0; $i -lt 7; $i++) { + $d = $script:cccol_wk1Mon.AddDays($i).ToString('yyyy-MM-dd') + $cron[$d] = @('02:00') + } + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cccol_cfg -StartDate $script:cccol_wk1Mon -Days 7 -AsMarkdown -CronFiringsByDate $cron + $md | Should -Match '\| Date \(UTC\) \| Ring CRON Start Time
\(Step 6 pipeline\) \| Day \| CycleWeek \| Eligible rings \| AllowedUpdateVersions \|' + $md | Should -Match '\|---\|:---:\|---\|' + $md | Should -Not -Match 'Tag Start Window Match' + } + + It 'WindowMatchByRingAndDate alone adds the Tag Start Window Match column immediately after Eligible rings' { + $wm = @{ Cdn = @{}; Prod = @{} } + for ($i = 0; $i -lt 7; $i++) { + $d = $script:cccol_wk1Mon.AddDays($i) + $key = $d.ToString('yyyy-MM-dd') + if ($d.DayOfWeek -in 'Monday','Tuesday','Wednesday','Thursday','Friday') { + $wm['Cdn'][$key] = @{ Matching = 29; Total = 30 } + } else { + $wm['Prod'][$key] = @{ Matching = 1; Total = 40 } + } + } + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cccol_cfg -StartDate $script:cccol_wk1Mon -Days 7 -AsMarkdown -WindowMatchByRingAndDate $wm + $md | Should -Match '\| Date \(UTC\) \| Day \| CycleWeek \| Eligible rings \| Tag Start Window Match \(>=95%\) \| AllowedUpdateVersions \|' + $md | Should -Not -Match 'Ring CRON Start Time' + # Cdn weekday rows: True 29/30 + $md | Should -Match '`Cdn`: True 29/30 \(97%\)' + # Prod weekend rows: False 1/40 (2%) + $md | Should -Match '`Prod`: False 1/40 \(2%\)' + } + + It 'Both params produce both columns in correct positions (7-column header)' { + $cron = @{}; $wm = @{ Cdn = @{}; Prod = @{} } + for ($i = 0; $i -lt 7; $i++) { + $d = $script:cccol_wk1Mon.AddDays($i) + $key = $d.ToString('yyyy-MM-dd') + $cron[$key] = @('02:00') + if ($d.DayOfWeek -in 'Monday','Tuesday','Wednesday','Thursday','Friday') { + $wm['Cdn'][$key] = @{ Matching = 30; Total = 30 } + } else { + $wm['Prod'][$key] = @{ Matching = 40; Total = 40 } + } + } + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cccol_cfg -StartDate $script:cccol_wk1Mon -Days 7 -AsMarkdown -CronFiringsByDate $cron -WindowMatchByRingAndDate $wm + $md | Should -Match '\| Date \(UTC\) \| Ring CRON Start Time
\(Step 6 pipeline\) \| Day \| CycleWeek \| Eligible rings \| Tag Start Window Match \(>=95%\) \| AllowedUpdateVersions \|' + $md | Should -Match '`Cdn`: True 30/30 \(100%\)' + $md | Should -Match '`Prod`: True 40/40 \(100%\)' + } + + It 'CronFiringsByDate renders 1 firing verbatim, 2 firings joined by comma' { + $cron = @{} + $d0 = $script:cccol_wk1Mon.ToString('yyyy-MM-dd') + $d1 = $script:cccol_wk1Mon.AddDays(1).ToString('yyyy-MM-dd') + $cron[$d0] = @('02:00') + $cron[$d1] = @('02:00','04:00') + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cccol_cfg -StartDate $script:cccol_wk1Mon -Days 2 -AsMarkdown -CronFiringsByDate $cron + $md | Should -Match "\| $d0 \| 02:00 \|" + $md | Should -Match "\| $d1 \| 02:00, 04:00 \|" + $md | Should -Not -Match '\(\+' + } + + It 'CronFiringsByDate with 3+ firings shows first 2 + (+N) suffix' { + $cron = @{} + $d0 = $script:cccol_wk1Mon.ToString('yyyy-MM-dd') + $cron[$d0] = @('02:00','04:00','06:00','08:00') + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cccol_cfg -StartDate $script:cccol_wk1Mon -Days 1 -AsMarkdown -CronFiringsByDate $cron + $md | Should -Match "\| $d0 \| 02:00, 04:00 \(\+2\) \|" + } + + It 'CronFiringsByDate with no entry for a date renders _(none)_ on eligible days' { + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cccol_cfg -StartDate $script:cccol_wk1Mon -Days 1 -AsMarkdown -CronFiringsByDate @{ '9999-01-01' = @('02:00') } + $md | Should -Match '\| _\(none\)_ \|' + } + + It 'WindowMatchByRingAndDate boundary: 95/100 -> True, 94/100 -> False' { + $wm95 = @{ Cdn = @{ ($script:cccol_wk1Mon.ToString('yyyy-MM-dd')) = @{ Matching = 95; Total = 100 } } } + $md95 = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cccol_cfg -StartDate $script:cccol_wk1Mon -Days 1 -AsMarkdown -WindowMatchByRingAndDate $wm95 + $md95 | Should -Match '`Cdn`: True 95/100 \(95%\)' + + $wm94 = @{ Cdn = @{ ($script:cccol_wk1Mon.ToString('yyyy-MM-dd')) = @{ Matching = 94; Total = 100 } } } + $md94 = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cccol_cfg -StartDate $script:cccol_wk1Mon -Days 1 -AsMarkdown -WindowMatchByRingAndDate $wm94 + $md94 | Should -Match '`Cdn`: False 94/100 \(94%\)' + } + + It 'WindowMatchByRingAndDate with Total=0 renders _(0 clusters)_' { + $wm = @{ Cdn = @{ ($script:cccol_wk1Mon.ToString('yyyy-MM-dd')) = @{ Matching = 0; Total = 0 } } } + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cccol_cfg -StartDate $script:cccol_wk1Mon -Days 1 -AsMarkdown -WindowMatchByRingAndDate $wm + $md | Should -Match '`Cdn`: _\(0 clusters\)_' + } + + It 'WindowMatchByRingAndDate with no entry for the ring on that date renders _(n/a)_' { + $wm = @{ Cdn = @{ '9999-01-01' = @{ Matching = 1; Total = 1 } } } + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cccol_cfg -StartDate $script:cccol_wk1Mon -Days 1 -AsMarkdown -WindowMatchByRingAndDate $wm + $md | Should -Match '`Cdn`: _\(n/a\)_' + } + + It 'WindowMatchByRingAndDate is case-insensitive on ring keys' { + $wm = @{ 'cdn' = @{ ($script:cccol_wk1Mon.ToString('yyyy-MM-dd')) = @{ Matching = 10; Total = 10 } } } + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cccol_cfg -StartDate $script:cccol_wk1Mon -Days 1 -AsMarkdown -WindowMatchByRingAndDate $wm + $md | Should -Match '`Cdn`: True 10/10 \(100%\)' + } + + It 'CronFiringsByDate is case-insensitive on date keys (yyyy-MM-dd should not be affected anyway, but explicit guard)' { + $cron = @{ ($script:cccol_wk1Mon.ToString('yyyy-MM-dd').ToUpper()) = @('02:00') } + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cccol_cfg -StartDate $script:cccol_wk1Mon -Days 1 -AsMarkdown -CronFiringsByDate $cron + $md | Should -Match '\| 02:00 \|' + } + + It 'Three optional columns (Cron + Cluster counts + Window match) coexist; Window match sits immediately after Eligible rings' { + $cron = @{ ($script:cccol_wk1Mon.ToString('yyyy-MM-dd')) = @('02:00') } + $rc = @{ Cdn = 30; Prod = 40 } + $wm = @{ Cdn = @{ ($script:cccol_wk1Mon.ToString('yyyy-MM-dd')) = @{ Matching = 30; Total = 30 } } } + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cccol_cfg -StartDate $script:cccol_wk1Mon -Days 1 -AsMarkdown ` + -CronFiringsByDate $cron -ClusterRingCounts $rc -WindowMatchByRingAndDate $wm + $md | Should -Match '\| Date \(UTC\) \| Ring CRON Start Time
\(Step 6 pipeline\) \| Day \| CycleWeek \| Eligible rings \| Tag Start Window Match \(>=95%\) \| Clusters in ring\(s\) \| AllowedUpdateVersions \|' + } +} + #endregion v0.8.5: Get-AzLocalApplyUpdatesScheduleCycleCalendar #region v0.8.5: New-AzLocalPipelineJUnitXml (shared JUnit emitter) @@ -12932,7 +13106,13 @@ Describe 'Thin-YAML Step.0: Export-AzLocalAuthValidationReport' { } InModuleScope AzLocal.UpdateManagement { Mock Install-AzGraphExtension { $true } - Mock Invoke-AzResourceGraphQuery { @($global:_avr_payload.Clusters) } + # v0.8.6: mock must mirror the real Invoke-AzResourceGraphQuery + # contract (`return , $arr` unary-comma return) so callers see + # the array intact when it contains 0 or 1 elements. A naive + # `@($payload)` here would let Pester's pipeline collector unwrap + # the single-element array to a bare PSCustomObject, hiding the + # ARG row-collapse anti-pattern from this unit test. + Mock Invoke-AzResourceGraphQuery { return , $global:_avr_payload.Clusters } Mock Invoke-AzCliJson { param([string[]]$Arguments) $pl = $global:_avr_payload @@ -13568,7 +13748,10 @@ beta,/subscriptions/s1/resourceGroups/rg2/providers/Microsoft.AzureStackHCI/clus $summary | Should -Match '## Step\.2 - UpdateRing Tag Management Summary' $summary | Should -Match '\| Total clusters processed \| 2 \|' $summary | Should -Match 'alpha' - $summary | Should -Match 'Per-cluster results' + # v0.8.6: per-cluster table split into "Tag Updates Applied" + "No Tag Updates (no-op)" + # buckets so operators can scan changed clusters separately from steady-state clusters. + $summary | Should -Match 'Clusters with Tag Updates Applied \(1 rows\)' + $summary | Should -Match 'Clusters with No Tag Updates \(no-op\) \(1 rows\)' } It 'Defaults OutputDirectory to BUILD_ARTIFACTSTAGINGDIRECTORY when TF_BUILD=true' { @@ -15836,6 +16019,162 @@ Describe 'Thin-YAML Step.3: Export-AzLocalApplyUpdatesScheduleAudit' { if (Test-Path -LiteralPath $adoStage) { Remove-Item -LiteralPath $adoStage -Recurse -Force -ErrorAction SilentlyContinue } } } + + It 'v0.8.6: When PipelineYamlPath contains real cron triggers, the cycle calendar is invoked with -CronFiringsByDate populated' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s3_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s3_ghSummaryFile + $env:_S3_OUTDIR = $script:_s3_outDir + $env:_S3_SCHEDULE = $script:_s3_scheduleFile + + # Drop a real Step.6_apply-updates.yml with two cron triggers so + # Read-AzLocalApplyUpdatesYamlCrons + ConvertFrom-AzLocalCronExpression + # produce non-empty FireTimes for the calendar block. + $ghDir = Join-Path $script:_s3_pipelineDir 'github-actions' + New-Item -ItemType Directory -Path $ghDir -Force | Out-Null + @" +on: + schedule: + - cron: '0 2 * * *' + - cron: '30 4 * * 1-5' + workflow_dispatch: +"@ | Set-Content -Path (Join-Path $ghDir 'Step.6_apply-updates.yml') -Encoding ASCII + $env:_S3_PIPELINEDIR = $script:_s3_pipelineDir + + # Schedule has cycleWeeks=1 so the calendar default horizon is 7 days. + $global:_s3_auditRows = @( (New-S3AuditRow -Status 'Covered') ) + $global:_s3_schedule = [pscustomobject]@{ + SchemaVersion = 2 + CycleWeeks = 1 + CycleAnchorISOWeek = 1 + CycleAnchorYear = 2024 + AllowedUpdateVersions = @('Latest') + Schedule = @( + [pscustomobject]@{ + weeksInCycle = '*' + daysOfWeek = 'Mon-Fri' + rings = 'Cdn' + AllowedUpdateVersionsParsed = @() + } + ) + } + # Spy the calendar cmdlet so we can inspect which params it was called with. + $global:_s3_calArgs = $null + $null = InModuleScope AzLocal.UpdateManagement { + Mock Test-AzLocalApplyUpdatesScheduleCoverage { $global:_s3_auditRows } -ParameterFilter { $View -eq 'Audit' } + Mock Test-AzLocalApplyUpdatesScheduleCoverage { } -ParameterFilter { $View -ne 'Audit' } + Mock Get-AzLocalApplyUpdatesScheduleConfig { $global:_s3_schedule } + Mock Get-AzLocalApplyUpdatesScheduleCycleCalendar { + param( + $Schedule, $StartDate, $Days, $AsMarkdown, $IncludePerRingSummary, + $ClusterRingCounts, $CronFiringsByDate, $WindowMatchByRingAndDate + ) + $snap = @{} + foreach ($k in $PSBoundParameters.Keys) { $snap[$k] = $PSBoundParameters[$k] } + $global:_s3_calArgs = $snap + "## Cycle calendar - next 7 day(s) (cycle length: 1 week(s))`n`n*(spied)*" + } + Export-AzLocalApplyUpdatesScheduleAudit ` + -PipelineYamlPath $env:_S3_PIPELINEDIR ` + -SchedulePath $env:_S3_SCHEDULE ` + -OutputDirectory $env:_S3_OUTDIR ` + -PassThru + } + $global:_s3_calArgs | Should -Not -BeNullOrEmpty + $global:_s3_calArgs.ContainsKey('CronFiringsByDate') | Should -BeTrue + $cf = $global:_s3_calArgs['CronFiringsByDate'] + $cf | Should -BeOfType 'hashtable' + $cf.Count | Should -BeGreaterThan 0 + # Every value should be a string[] of HH:mm; '02:00' must appear + # somewhere across the horizon (the '0 2 * * *' trigger fires every day). + $allTimes = @() + foreach ($k in $cf.Keys) { $allTimes += @($cf[$k]) } + ($allTimes | Sort-Object -Unique) | Should -Contain '02:00' + # WindowMatch should be ABSENT because we didn't supply a ClusterCsvPath. + $global:_s3_calArgs.ContainsKey('WindowMatchByRingAndDate') | Should -BeFalse + } + + It 'v0.8.6: When PipelineYamlPath AND ClusterCsvPath are both supplied, the cycle calendar gets both new dicts' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s3_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s3_ghSummaryFile + $env:_S3_OUTDIR = $script:_s3_outDir + $env:_S3_SCHEDULE = $script:_s3_scheduleFile + + $ghDir = Join-Path $script:_s3_pipelineDir 'github-actions' + New-Item -ItemType Directory -Path $ghDir -Force | Out-Null + @" +on: + schedule: + - cron: '0 2 * * *' +"@ | Set-Content -Path (Join-Path $ghDir 'Step.6_apply-updates.yml') -Encoding ASCII + $env:_S3_PIPELINEDIR = $script:_s3_pipelineDir + + # Synthesise a tiny cluster CSV: 2 clusters in 'Cdn' ring, one with + # a window that covers 02:00 UTC, one that does not. + $csvPath = Join-Path $env:TEMP ("s3-clusters-{0}.csv" -f ([Guid]::NewGuid())) + @( + '"ClusterName","UpdateRing","UpdateStartWindow"', + '"c1","Cdn","Mon-Sun_01:00-03:00"', + '"c2","Cdn","Mon-Sun_10:00-12:00"' + ) -join "`r`n" | Set-Content -LiteralPath $csvPath -Encoding ASCII + $env:_S3_CSV = $csvPath + + $global:_s3_auditRows = @( (New-S3AuditRow -Status 'Covered') ) + $global:_s3_schedule = [pscustomobject]@{ + SchemaVersion = 2 + CycleWeeks = 1 + CycleAnchorISOWeek = 1 + CycleAnchorYear = 2024 + AllowedUpdateVersions = @('Latest') + Schedule = @( + [pscustomobject]@{ + weeksInCycle = '*' + daysOfWeek = 'Mon-Sun' + rings = 'Cdn' + AllowedUpdateVersionsParsed = @() + } + ) + } + $global:_s3_calArgs = $null + try { + $null = InModuleScope AzLocal.UpdateManagement { + Mock Test-AzLocalApplyUpdatesScheduleCoverage { $global:_s3_auditRows } -ParameterFilter { $View -eq 'Audit' } + Mock Test-AzLocalApplyUpdatesScheduleCoverage { } -ParameterFilter { $View -ne 'Audit' } + Mock Get-AzLocalApplyUpdatesScheduleConfig { $global:_s3_schedule } + Mock Get-AzLocalApplyUpdatesScheduleCycleCalendar { + param( + $Schedule, $StartDate, $Days, $AsMarkdown, $IncludePerRingSummary, + $ClusterRingCounts, $CronFiringsByDate, $WindowMatchByRingAndDate + ) + $snap = @{} + foreach ($k in $PSBoundParameters.Keys) { $snap[$k] = $PSBoundParameters[$k] } + $global:_s3_calArgs = $snap + "## Cycle calendar - next 7 day(s) (cycle length: 1 week(s))`n`n*(spied)*" + } + Export-AzLocalApplyUpdatesScheduleAudit ` + -PipelineYamlPath $env:_S3_PIPELINEDIR ` + -SchedulePath $env:_S3_SCHEDULE ` + -ClusterCsvPath $env:_S3_CSV ` + -OutputDirectory $env:_S3_OUTDIR ` + -PassThru + } + $global:_s3_calArgs.ContainsKey('CronFiringsByDate') | Should -BeTrue + $global:_s3_calArgs.ContainsKey('WindowMatchByRingAndDate') | Should -BeTrue + $wm = $global:_s3_calArgs['WindowMatchByRingAndDate'] + $wm | Should -BeOfType 'hashtable' + $wm.Keys | Should -Contain 'Cdn' + $cdn = $wm['Cdn'] + $cdn.Keys.Count | Should -BeGreaterThan 0 + $sample = $cdn[($cdn.Keys | Select-Object -First 1)] + $sample['Total'] | Should -Be 2 + # Exactly 1 of the 2 clusters' windows covers 02:00 UTC. + $sample['Matching'] | Should -Be 1 + } + finally { + if (Test-Path -LiteralPath $csvPath) { Remove-Item -LiteralPath $csvPath -Force -ErrorAction SilentlyContinue } + } + } } #endregion v0.8.5: Export-AzLocalApplyUpdatesScheduleAudit @@ -16219,3 +16558,221 @@ Describe 'Thin-YAML Step.6: Invoke-AzLocalItsmTicketingFromArtifact' { } #endregion v0.8.5 Step.6 thin-YAML: Apply-Updates pipeline cmdlets + +# ===================================================================== +# v0.8.6 regression tests for pipeline bugs introduced in v0.8.5 +# ===================================================================== +# +# All four production regressions shipped in v0.8.5 share two root causes: +# (1) `@()` wrap on a helper that uses unary-comma `return , $arr` +# silently collapses the row-set to Object[1] (the outer wrapper) - +# see github-patterns.md in user memory. +# (2) `[type]$x = if (cond) { @(...) } else { @() }` without an explicit +# array cast lets the if-as-expression unwrap a single-element @() +# into a bare scalar; downstream `$x.Count` then throws under +# Set-StrictMode -Version Latest. +# These static-source tests would have caught all four pre-release. + +Describe 'v0.8.6 regression guard: @() wrap on unary-comma return helpers' { + + BeforeAll { + # Helpers whose internal `return , $arr` (or equivalent) MUST be assigned + # directly. Any caller that wraps them in `@()` collapses the row-set to + # Object[1] and silently loses every row beyond the first. See + # github-patterns.md for the full failure-mode explanation. + $script:V086_UnaryCommaHelpers = @( + 'Invoke-AzResourceGraphQuery', + 'Get-AzLocalFleetHealthFailures', + 'Get-AzLocalFleetHealthOverview', + 'Read-AzLocalApplyUpdatesYamlCrons' + ) + $script:V086_ModuleRoot = Split-Path -Parent $PSScriptRoot + $script:V086_PsFiles = Get-ChildItem -Path $script:V086_ModuleRoot -Recurse -Filter '*.ps1' -File | + Where-Object { $_.FullName -notmatch '\\Tests\\' } + } + + It 'no Public/Private .ps1 file wraps with @(...) - silently collapses row-set' -ForEach @( + @{ Helper = 'Invoke-AzResourceGraphQuery' }, + @{ Helper = 'Get-AzLocalFleetHealthFailures' }, + @{ Helper = 'Get-AzLocalFleetHealthOverview' }, + @{ Helper = 'Read-AzLocalApplyUpdatesYamlCrons' } + ) { + $pattern = "@\(\s*$([regex]::Escape($Helper))\b" + $offenders = [System.Collections.Generic.List[string]]::new() + foreach ($file in $script:V086_PsFiles) { + $lines = Get-Content -LiteralPath $file.FullName + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match $pattern) { + $offenders.Add(("{0}:{1}: {2}" -f $file.Name, ($i + 1), $lines[$i].Trim())) + } + } + } + $offenders.Count | Should -Be 0 -Because ( + "wrapping '$Helper' (which uses `return , `$arr` unary-comma return) " + + "with @() silently collapses the row-set to Object[1] containing the " + + "inner array. Found:`n$($offenders -join "`n")" + ) + } +} + +Describe 'v0.8.6 regression guard: Step.4 ARB strict-mode .Count crash on single-cluster RG' { + + It 'Get-AzLocalFleetConnectivityStatus uses explicit if/else with @($clustersByRg[$rg]) (NOT @($null) which yields Object[1] containing $null)' { + $path = Join-Path -Path $PSScriptRoot -ChildPath '..\Public\Get-AzLocalFleetConnectivityStatus.ps1' + $content = Get-Content -LiteralPath $path -Raw + # v0.8.6-fix3: `@($null)` produces an Object[1] containing $null (NOT an empty array), + # so downstream `$matched.Count -gt 0` is true and `$_.ClusterName` on the null element + # throws PropertyNotFound. The reliable pattern is an explicit if/else where the miss + # branch assigns a real empty array (`@()`) and the hit branch wraps the list. + $content | Should -Match '(?s)if\s*\(\s*\$clustersByRg\.ContainsKey\(\$rg\)\s*\)\s*\{\s*\$matched\s*=\s*@\(\$clustersByRg\[\$rg\]\)\s*\}\s*else\s*\{\s*\$matched\s*=\s*@\(\)\s*\}' -Because ( + 'Step.4 must branch explicitly on ContainsKey, with the else branch assigning a literal `@()` ' + + 'to $matched. @($null) does NOT yield an empty array.' + ) + # The OLD broken patterns must be gone. + $content | Should -Not -Match '\[object\[\]\]\$matched\s*=\s*if\s*\(\$clustersByRg' + $content | Should -Not -Match '\$matchedList\s*=\s*if\s*\(\$clustersByRg\.ContainsKey\(\$rg\)\)\s*\{\s*\$clustersByRg\[\$rg\]\s*\}\s*else\s*\{\s*\$null\s*\}' + } +} + +Describe 'v0.8.6 regression guard: Step.6 Get-AzLocalClusterUpdateReadiness $cluster.NotFound strict-mode crash' { + + It 'every "$clustersToProcess += @{ ... }" block sets a NotFound key' { + # Under Set-StrictMode -Version Latest, dot-notation on a hashtable + # missing the requested key throws "The property 'NotFound' cannot be + # found on this object." Every hashtable shape MUST define NotFound or + # the iteration at line ~337 (`if ($cluster.NotFound)`) crashes Step.6. + $path = Join-Path -Path $PSScriptRoot -ChildPath '..\Public\Get-AzLocalClusterUpdateReadiness.ps1' + $content = Get-Content -LiteralPath $path -Raw + # Find every `$clustersToProcess += @{ ... }` block (lazy match, dotall). + $rx = [regex]'(?s)\$clustersToProcess\s*\+=\s*@\{(?.*?)\}' + $rxMatches = $rx.Matches($content) + $rxMatches.Count | Should -BeGreaterThan 0 -Because 'the regression-guarded code shape must be present' + $offenders = [System.Collections.Generic.List[string]]::new() + foreach ($m in $rxMatches) { + if ($m.Groups['body'].Value -notmatch '(?m)^\s*NotFound\s*=') { + $offenders.Add($m.Value.Trim()) + } + } + $offenders.Count | Should -Be 0 -Because ( + "every `$clustersToProcess += @{ } literal must include 'NotFound = `$true' or 'NotFound = `$false' " + + "so the strict-mode dot-access at iteration time cannot throw. Offending block(s):`n$($offenders -join "`n---`n")" + ) + } +} + +Describe 'v0.8.6 regression guard: GitHub Actions Node.js 20 deprecation' { + + BeforeAll { + $script:V086_GhaRoot = Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'Automation-Pipeline-Examples\github-actions' + $script:V086_GhaYamls = if (Test-Path -LiteralPath $script:V086_GhaRoot) { + Get-ChildItem -Path $script:V086_GhaRoot -Filter '*.yml' -File + } else { @() } + } + + It 'no bundled GHA YAML uses actions/upload-artifact@v[1-4] (Node 20 - deprecated)' { + $offenders = [System.Collections.Generic.List[string]]::new() + foreach ($yml in $script:V086_GhaYamls) { + $lines = Get-Content -LiteralPath $yml.FullName + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match 'actions/upload-artifact@v[1-4]\b') { + $offenders.Add(("{0}:{1}: {2}" -f $yml.Name, ($i + 1), $lines[$i].Trim())) + } + } + } + $offenders.Count | Should -Be 0 -Because ( + "actions/upload-artifact v1-v4 run on Node.js 20 which GitHub has scheduled for deprecation. " + + "Bump to @v5 or @v6 to avoid the deprecation warning in production runs. Found:`n$($offenders -join "`n")" + ) + } + + It 'no bundled GHA YAML uses actions/download-artifact@v[1-3] (Node 20 - deprecated)' { + $offenders = [System.Collections.Generic.List[string]]::new() + foreach ($yml in $script:V086_GhaYamls) { + $lines = Get-Content -LiteralPath $yml.FullName + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match 'actions/download-artifact@v[1-3]\b') { + $offenders.Add(("{0}:{1}: {2}" -f $yml.Name, ($i + 1), $lines[$i].Trim())) + } + } + } + $offenders.Count | Should -Be 0 -Because ( + "actions/download-artifact v1-v3 run on Node.js 20. Bump to @v4 or later. " + + "Found:`n$($offenders -join "`n")" + ) + } +} + +Describe 'v0.8.6 functional regression: Export-AzLocalAuthValidationReport preserves all cluster rows from unary-comma helper' { + + It 'Reports the true cluster count (not 1) when Invoke-AzResourceGraphQuery returns 20 rows via unary-comma' { + # Smoke-test the source line shape: Step.0 cluster reachability MUST + # assign Invoke-AzResourceGraphQuery directly (no @() wrap), then + # report $clusterRows.Count. We can't easily mock through the whole + # cmdlet here (it has many dependencies) - instead assert the + # specific source pattern that was buggy in v0.8.5. + $path = Join-Path -Path $PSScriptRoot -ChildPath '..\Public\Export-AzLocalAuthValidationReport.ps1' + $content = Get-Content -LiteralPath $path -Raw + # The fixed line MUST be a direct assignment, NOT @(...). + $content | Should -Match '(?m)^\s*\$clusterRows\s*=\s*Invoke-AzResourceGraphQuery\s+-Query\s+\$clusterKql' -Because ( + 'Step.0 must use direct assignment (`$clusterRows = Invoke-AzResourceGraphQuery ...`) - ' + + 'wrapping with @() collapses 20 clusters to Object[1] and reports "1 cluster(s) visible".' + ) + $content | Should -Not -Match '\$clusterRows\s*=\s*@\(\s*Invoke-AzResourceGraphQuery' + } +} + +Describe 'v0.8.6 functional regression: Export-AzLocalFleetHealthStatusReport preserves all detail rows from unary-comma helper' { + + It 'Step.9 assigns Get-AzLocalFleetHealthFailures directly (no @() wrap that collapses 81 rows to 1)' { + $path = Join-Path -Path $PSScriptRoot -ChildPath '..\Public\Export-AzLocalFleetHealthStatusReport.ps1' + $content = Get-Content -LiteralPath $path -Raw + $content | Should -Match '(?m)^\s*\$detail\s*=\s*Get-AzLocalFleetHealthFailures\s+-View\s+Detail' + $content | Should -Not -Match '\$detail\s*=\s*@\(\s*Get-AzLocalFleetHealthFailures' + } + + It 'Step.9 assigns Get-AzLocalFleetHealthOverview directly (no @() wrap that collapses fleet rows to 1)' { + $path = Join-Path -Path $PSScriptRoot -ChildPath '..\Public\Export-AzLocalFleetHealthStatusReport.ps1' + $content = Get-Content -LiteralPath $path -Raw + $content | Should -Match '(?m)^\s*\$overview\s*=\s*Get-AzLocalFleetHealthOverview\b' + $content | Should -Not -Match '\$overview\s*=\s*@\(\s*Get-AzLocalFleetHealthOverview' + } +} + +Describe 'v0.8.6 regression guard: Step.3 Test-AzLocalApplyUpdatesScheduleCoverage MatchingCrons strict-mode crash' { + + It 'guards $segmentStatuses.MatchingCrons via explicit ForEach-Object enumeration (NOT array-level member-access)' { + # Under Set-StrictMode -Version Latest, `$segmentStatuses.MatchingCrons` member-access + # on an array throws "The property 'MatchingCrons' cannot be found on this object." + # even when Count > 0. The fix uses `$segmentStatuses | ForEach-Object { $_.MatchingCrons }` + # to enumerate explicitly, AND guards with `if ($segmentStatuses.Count -gt 0)` for the + # empty-array case (v0.8.5 Step.3 production regression). + $path = Join-Path -Path $PSScriptRoot -ChildPath '..\Public\Test-AzLocalApplyUpdatesScheduleCoverage.ps1' + $content = Get-Content -LiteralPath $path -Raw + $content | Should -Match '\$segmentStatuses\.Count\s*-gt\s*0' -Because ( + 'the .MatchingCrons access path MUST be guarded by `if ($segmentStatuses.Count -gt 0)`.' + ) + $content | Should -Match '\$segmentStatuses\s*\|\s*ForEach-Object\s*\{\s*\$_\.MatchingCrons\s*\}' -Because ( + 'explicit `ForEach-Object { $_.MatchingCrons }` is required - array-level member-access ' + + '`$segmentStatuses.MatchingCrons` throws PropertyNotFound under strict mode.' + ) + # The unguarded one-liner AND the array-level member-access must be gone. + $content | Should -Not -Match '(?m)^\s+\$allMatched\s*=\s*@\(\$segmentStatuses\.MatchingCrons' + $content | Should -Not -Match '@\(\$segmentStatuses\.MatchingCrons\s*\|\s*Select-Object\s*-Unique\)' + } +} + +Describe 'v0.8.6 regression guard: Step.0 Export-AzLocalAuthValidationReport defensive @() coerce on $clusterRows' { + + It 'coerces $clusterRows = @($clusterRows) before reading .Count so scalar returns become Object[1]' { + # When a downstream Invoke-AzResourceGraphQuery mock or shape change returns + # a single PSCustomObject (instead of Object[1]), $clusterRows.Count returns + # $null under strict mode. The defensive @() on the VARIABLE (not the call) + # is idempotent on Object[N] and safe to apply. + $path = Join-Path -Path $PSScriptRoot -ChildPath '..\Public\Export-AzLocalAuthValidationReport.ps1' + $content = Get-Content -LiteralPath $path -Raw + $content | Should -Match '\$clusterRows\s*=\s*@\(\$clusterRows\)' -Because ( + 'Step.0 must defensively coerce $clusterRows = @($clusterRows) after the ARG call ' + + 'so .Count is always a valid integer property, even when callers (tests) mock with a single object.' + ) + } +}