diff --git a/.github/workflows/AzLocal.UpdateManagement-pester.yml b/.github/workflows/AzLocal.UpdateManagement-pester.yml new file mode 100644 index 00000000..2927c670 --- /dev/null +++ b/.github/workflows/AzLocal.UpdateManagement-pester.yml @@ -0,0 +1,159 @@ +name: AzLocal.UpdateManagement - Pester + +# Runs the AzLocal.UpdateManagement Pester unit suite on every PR + push to main +# that touches the module. Live-tagged Azure integration tests are excluded +# (they need az login + the AdaptiveCloudLab subscription and are gated by +# -IncludeLive in the local Invoke-Tests.ps1). +# +# Runner choice: windows-latest is required so the tests exercise the same +# Windows PowerShell 5.1 + .NET 4.x surface that the published module targets. +# Cost mitigation: path-filtered to AzLocal.UpdateManagement/** only, and only +# fires on the three useful PR activities (opened, synchronize, reopened). + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'AzLocal.UpdateManagement/**' + - '.github/workflows/AzLocal.UpdateManagement-pester.yml' + push: + branches: [main] + paths: + - 'AzLocal.UpdateManagement/**' + - '.github/workflows/AzLocal.UpdateManagement-pester.yml' + workflow_dispatch: + +concurrency: + group: azlocal-updatemanagement-pester-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + checks: write + pull-requests: write + +jobs: + pester: + name: Pester (Windows PowerShell 5.1) + runs-on: windows-latest + timeout-minutes: 20 + + defaults: + run: + shell: powershell + working-directory: AzLocal.UpdateManagement + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Show PowerShell + Pester baseline + run: | + $PSVersionTable | Format-List | Out-String | Write-Host + Get-Module Pester -ListAvailable | Select-Object Name, Version, Path | Format-Table -AutoSize | Out-String | Write-Host + + - name: Install Pester 5.x + run: | + $needed = $true + $installed = Get-Module Pester -ListAvailable | Where-Object { $_.Version -ge [version]'5.0.0' } | Sort-Object Version -Descending | Select-Object -First 1 + if ($installed) { + Write-Host "Pester $($installed.Version) already available at $($installed.Path)" + $needed = $false + } + if ($needed) { + Write-Host 'Installing Pester 5.x from PSGallery (CurrentUser scope)' + Install-Module Pester -MinimumVersion 5.5.0 -Force -SkipPublisherCheck -Scope CurrentUser + } + Import-Module Pester -MinimumVersion 5.0.0 -Force + (Get-Module Pester).Version | Write-Host + + - name: Install powershell-yaml + # Required by one Describe block ('ITSM: Get-AzLocalItsmConfig normalises + # non-Hashtable YAML dictionaries') which Mock-overrides ConvertFrom-Yaml. + # Mock can only intercept commands that exist, so the source module must + # be present even though no actual YAML parsing happens at test time. + run: | + if (-not (Get-Module powershell-yaml -ListAvailable)) { + Write-Host 'Installing powershell-yaml from PSGallery (CurrentUser scope)' + Install-Module powershell-yaml -Force -SkipPublisherCheck -Scope CurrentUser + } else { + Write-Host 'powershell-yaml already available' + } + (Get-Module powershell-yaml -ListAvailable | Select-Object -First 1).Version | Write-Host + + - name: Import module (smoke test) + run: | + Get-Module AzLocal.UpdateManagement -All | Remove-Module -Force -ErrorAction SilentlyContinue + Import-Module .\AzLocal.UpdateManagement.psd1 -Force -ErrorAction Stop + $m = Get-Module AzLocal.UpdateManagement + Write-Host "Loaded module $($m.Name) $($m.Version) with $($m.ExportedFunctions.Count) exported functions" + + - name: Run Pester (unit suite, Live excluded) + run: | + New-Item -ItemType Directory -Path Tests\TestResults -Force | Out-Null + Get-Module AzLocal.UpdateManagement -All | Remove-Module -Force -ErrorAction SilentlyContinue + $config = New-PesterConfiguration + $config.Run.Path = '.\Tests' + $config.Run.PassThru = $true + $config.Run.Exit = $false + $config.Filter.ExcludeTag = @('Live') + $config.TestResult.Enabled = $true + $config.TestResult.OutputPath = 'Tests\TestResults\pester-junit.xml' + # JUnitXml is supported by Pester 5.4+ and is consumable by + # dorny/test-reporter with reporter=jest-junit below. + $config.TestResult.OutputFormat = 'JUnitXml' + $config.Output.Verbosity = 'Normal' + $result = Invoke-Pester -Configuration $config + "Passed=$($result.PassedCount) Failed=$($result.FailedCount) Skipped=$($result.SkippedCount) Duration=$($result.Duration)" | Tee-Object -FilePath Tests\TestResults\summary.txt + if ($result.FailedCount -gt 0) { + $result.Failed | ForEach-Object { "FAIL: $($_.ExpandedPath) :: $($_.ErrorRecord.Exception.Message)" } | Out-File Tests\TestResults\failures.txt + Write-Error "Pester reported $($result.FailedCount) failing test(s)." + exit 1 + } + + - name: Strip UTF-8 BOM from JUnit XML + # Windows PowerShell 5.1's UTF-8 writer (which Pester uses for the + # JUnit output) emits a 3-byte BOM. The EnricoMi publish-test-results + # action's lxml parser rejects it with: "Start tag expected, '<' not + # found, line 1, column 1" / "missing toplevel element". Re-write the + # file without BOM (read with BOM-aware UTF-8, write with BOM-less + # UTF-8). Idempotent: if the file is already BOM-less this is a no-op. + if: always() + run: | + $xml = 'Tests\TestResults\pester-junit.xml' + if (Test-Path -LiteralPath $xml) { + $bytes = [System.IO.File]::ReadAllBytes($xml) + $hasBom = ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) + if ($hasBom) { + $text = [System.IO.File]::ReadAllText($xml, [System.Text.UTF8Encoding]::new($true)) + [System.IO.File]::WriteAllText($xml, $text, [System.Text.UTF8Encoding]::new($false)) + Write-Host "Stripped UTF-8 BOM from $xml (was $($bytes.Length) bytes, now $((Get-Item -LiteralPath $xml).Length) bytes)" + } else { + Write-Host "$xml has no BOM; nothing to strip." + } + } else { + Write-Host "$xml not found; skipping BOM strip." + } + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: pester-results-${{ github.run_id }} + path: AzLocal.UpdateManagement/Tests/TestResults/ + if-no-files-found: warn + retention-days: 14 + + - name: Publish test report (PR check) + # EnricoMi/publish-unit-test-result-action understands Pester's + # JUnitXml output natively. dorny/test-reporter was rejected because + # its 'jest-junit' parser threw `stackTrace.split is not a function` + # on Pester's stackTrace shape (Pester serialises it as a multi-line + # string array, not the single-string shape jest produces). + if: always() && github.event_name == 'pull_request' + uses: EnricoMi/publish-unit-test-result-action/windows@v2 + with: + check_name: AzLocal.UpdateManagement Pester + junit_files: AzLocal.UpdateManagement/Tests/TestResults/pester-junit.xml + comment_mode: off + ignore_runs: true 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 1ef5dabf..402c0fdf 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.4' + value: '0.8.5' # 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). @@ -83,13 +83,15 @@ stages: steps: - task: PowerShell@2 displayName: 'Install AzLocal.UpdateManagement from PSGallery' - # Step.0 itself does not call the module (the auth/RBAC/ARG probes - # below use raw az CLI), but installing the module here gives operators - # an early, consistent drift signal *before* the rest of the seven - # pipelines run. If this step warns that the YAML is stale or the - # installed module is older than expected, refresh the YAMLs via - # Copy-AzLocalPipelineExample -Platform AzureDevOps -Update and re-run - - # same pattern as Step.1+. + # Step.0 itself relies on the module (Export-AzLocalAuthValidationReport + # runs the four auth/RBAC/ARG probes); installing here also gives + # operators an early, consistent drift signal *before* the rest of + # the seven pipelines run. If the banner reports the YAML is stale + # or the installed module is older than expected, refresh the + # YAMLs via Copy-AzLocalPipelineExample -Platform AzureDevOps -Update + # and re-run. + # v0.8.5 thin-YAML: drift detection + banner + step outputs are all + # produced by Add-AzLocalPipelineVersionBanner (Public cmdlet). env: REQUIRED_MODULE_VERSION: $(REQUIRED_MODULE_VERSION) GENERATED_AGAINST_MODULE_VERSION: $(GENERATED_AGAINST_MODULE_VERSION) @@ -107,51 +109,16 @@ stages: } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force - # Get-Module can return multiple entries when nested modules are loaded - pick the highest version. - $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on agent : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - Write-Host "" - Get-Module AzLocal.UpdateManagement | Format-List Name, Version, Path - # Drift signal 0: the module installed at runtime is OLDER than the version this YAML was generated against. Cmdlets, parameters, or output schemas referenced by this YAML may not exist in the installed module - emitted as a warning because this is the most likely cause of a hard runtime failure later in the job. - if ($installed -lt $generated) { - Write-Host "##vso[task.logissue type=warning]AzLocal.UpdateManagement v$installed is OLDER than the version this pipeline YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Platform AzureDevOps -Update'." - } - if ($installed -gt $generated) { - $msg = "Pipeline YAML was generated against AzLocal.UpdateManagement v$generated but the agent installed v$installed. Pipeline steps may have been improved in later releases - to refresh, re-run 'Copy-AzLocalPipelineExample -Destination .\pipelines -Platform AzureDevOps -Update' (you will be prompted per file; add -Confirm:`$false to bypass). Pipeline YAMLs are under git so 'git diff' shows exactly what changed before commit." - Write-Host "##vso[task.logissue type=warning]$msg" - } - if ($latest -and ($latest -gt $installed)) { - $msg = "AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed. Review the module CHANGELOG before bumping REQUIRED_MODULE_VERSION (or clear the pin to install the latest automatically)." - Write-Host "##vso[task.logissue type=warning]$msg" - } - - # v0.8.4 - one-line version banner uploaded to the build run - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), - # the module version actually loaded on the agent, the PSGallery - # latest, and the REQUIRED_MODULE_VERSION pin status are visible - # at the top of every run summary without scrolling the agent - # log. Same data is also echoed to the console above. - $pin = if ($env:REQUIRED_MODULE_VERSION) { "pinned to v$($env:REQUIRED_MODULE_VERSION)" } else { 'latest (fix-forward)' } - $latestStr = if ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } - $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } - elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } - elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } - else { 'in sync' } - $bannerPath = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'module-version-banner.md' - "_Pipeline YAML v$generated | Module v$installed installed ($pin) | PSGallery latest $latestStr | $verdict_" | Out-File -FilePath $bannerPath -Encoding utf8 - Write-Host "##vso[task.uploadsummary]$bannerPath" - - # Persist the three versions so the report step (below) can include them - # in the JUnit XML + markdown summary alongside the auth/RBAC checks. - Write-Host "##vso[task.setvariable variable=installedModuleVersion;isOutput=false]$installed" - Write-Host "##vso[task.setvariable variable=generatedAgainstVersion;isOutput=false]$generated" - Write-Host "##vso[task.setvariable variable=latestOnPsGallery;isOutput=false]$(if ($latest) { $latest } else { '' })" + $banner = Add-AzLocalPipelineVersionBanner ` + -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION ` + -PinnedVersion $env:REQUIRED_MODULE_VERSION ` + -PassThru + # Forward to ADO job-scoped variables so the report step (below) can + # surface the three versions in its JUnit + markdown summary. + $latestStr = if ($banner.LatestOnPSGallery) { $banner.LatestOnPSGallery } else { '' } + Write-Host "##vso[task.setvariable variable=installedModuleVersion;isOutput=false]$($banner.InstalledVersion)" + Write-Host "##vso[task.setvariable variable=generatedAgainstVersion;isOutput=false]$($banner.GeneratedAgainstVersion)" + Write-Host "##vso[task.setvariable variable=latestOnPsGallery;isOutput=false]$latestStr" - task: AzureCLI@2 displayName: 'Collect Authentication and Subscription Scope Report' @@ -159,145 +126,24 @@ stages: azureSubscription: 'AzureLocal-ServiceConnection' # Update if your service connection has a different name. scriptType: 'pscore' scriptLocation: 'inlineScript' + # v0.8.5 thin-YAML: the ~200-line inline run block (az probes, + # XML scaffolding, markdown summary, ##vso writes) has been + # condensed into the Public cmdlet Export-AzLocalAuthValidationReport. + # The cmdlet performs all four probes (az account show / role + # assignment list / az account list / Resource Graph query), + # writes the JUnit XML + subscriptions.json + subscriptions.csv, + # uploads the markdown summary via ##vso[task.uploadsummary], + # and emits the three pipeline variables (subscription_count, + # cluster_count, auth_valid) via Set-AzLocalPipelineOutput. inlineScript: | $ErrorActionPreference = 'Stop' + Import-Module AzLocal.UpdateManagement -Force $reportDir = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'auth-report' - New-Item -ItemType Directory -Path $reportDir -Force | Out-Null - - Write-Host '--- 1. az account show (proves service-connection auth) ---' - $accountJson = az account show -o json - if ($LASTEXITCODE -ne 0) { throw "az account show failed (exit $LASTEXITCODE)" } - $account = $accountJson | ConvertFrom-Json - $account | Format-List name, id, tenantId, user - - Write-Host '' - Write-Host '--- 2. role assignments for the service connection identity (proves RBAC grant) ---' - $appId = az account show --query user.name -o tsv - Write-Host "Service connection identity (appId): $appId" - az role assignment list ` - --assignee $appId ` - --all ` - -o table - - Write-Host '' - Write-Host '--- 3. Subscription Scope: enumerating all subscriptions visible to the pipeline identity ---' - $subsJson = az account list --refresh --query "[].{name:name, subscriptionId:id, tenantId:tenantId, state:state}" -o json - if ($LASTEXITCODE -ne 0) { throw "az account list failed (exit $LASTEXITCODE)" } - $subs = @($subsJson | ConvertFrom-Json | Sort-Object name) - $subCount = $subs.Count - Write-Host "Count of subscriptions accessible = $subCount" - $subs | Format-Table @{N='#';E={[array]::IndexOf($subs,$_)+1}}, name, subscriptionId, state -AutoSize - - $subs | ConvertTo-Json -Depth 4 | Out-File "$reportDir/subscriptions.json" -Encoding utf8 - $subs | Select-Object name, subscriptionId, tenantId, state | - Export-Csv "$reportDir/subscriptions.csv" -NoTypeInformation -Encoding utf8 - - Write-Host '' - Write-Host '--- 4. Resource Graph query (proves cluster reachability) ---' - az extension add --name resource-graph --yes --only-show-errors | Out-Null - $clusterJson = az graph query ` - -q "resources | where type =~ 'microsoft.azurestackhci/clusters' | project name, resourceGroup, subscriptionId" ` - --first 1000 ` - -o json - $clusterRows = @() - if ($LASTEXITCODE -eq 0 -and $clusterJson) { - $parsed = $clusterJson | ConvertFrom-Json - if ($parsed -is [System.Collections.IEnumerable] -and -not ($parsed -is [string])) { - $clusterRows = @($parsed) - } elseif ($parsed.data) { - $clusterRows = @($parsed.data) - } - } - $clusterCount = $clusterRows.Count - Write-Host "Clusters visible to the pipeline identity = $clusterCount" - if ($clusterCount -gt 0) { - $clusterRows | Select-Object -First 10 | Format-Table -AutoSize - } - - # ------------------------------------------------------------------ - # Emit JUnit XML - # ------------------------------------------------------------------ - $now = Get-Date -Format 'yyyy-MM-ddTHH:mm:ss' - function ConvertTo-XmlSafe([string]$s) { - if ($null -eq $s) { return '' } - $s -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"' - } - - $xml = [System.Text.StringBuilder]::new() - [void]$xml.AppendLine('') - [void]$xml.AppendLine('') - - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(' ') - - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(" ") - $i = 0 - foreach ($s in $subs) { - $i++ - $nm = ConvertTo-XmlSafe $s.name - $sid = ConvertTo-XmlSafe $s.subscriptionId - $st = ConvertTo-XmlSafe $s.state - [void]$xml.AppendLine(" ") - } - [void]$xml.AppendLine(' ') - - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(' ') - - # Suite 4: Module Version Drift (informational - never fails) - $installedVersion = "$env:INSTALLEDMODULEVERSION" - $generatedVersion = "$env:GENERATEDAGAINSTVERSION" - $latestVersion = "$env:LATESTONPSGALLERY" - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(' ') - - [void]$xml.AppendLine('') - $xmlPath = "$reportDir/auth-report.xml" - [System.IO.File]::WriteAllText($xmlPath, $xml.ToString(), [System.Text.UTF8Encoding]::new($false)) - Write-Host "JUnit XML written to: $xmlPath" - - # ------------------------------------------------------------------ - # Emit markdown summary for the ADO run Summary tab - # ------------------------------------------------------------------ - $md = [System.Text.StringBuilder]::new() - [void]$md.AppendLine('## Step.0 - Authentication Validation and Subscription Scope Report') - [void]$md.AppendLine('') - [void]$md.AppendLine('| Check | Result |') - [void]$md.AppendLine('|---|---|') - [void]$md.AppendLine("| Authentication (Workload Identity Federation) | :white_check_mark: working |") - [void]$md.AppendLine("| Default Subscription | $($account.name) (``$($account.id)``) |") - [void]$md.AppendLine("| Tenant | ``$($account.tenantId)`` |") - [void]$md.AppendLine("| Pipeline identity (appId) | ``$($account.user.name)`` |") - [void]$md.AppendLine("| Resource Graph reachability | :white_check_mark: $clusterCount cluster(s) visible |") - [void]$md.AppendLine("| AzLocal.UpdateManagement (installed) | ``$installedVersion`` |") - [void]$md.AppendLine("| AzLocal.UpdateManagement (YAML generated against) | ``$generatedVersion`` |") - [void]$md.AppendLine("| AzLocal.UpdateManagement (latest on PSGallery) | ``$(if ($latestVersion) { $latestVersion } else { '(lookup failed)' })`` |") - [void]$md.AppendLine('') - [void]$md.AppendLine("### Count of subscriptions accessible = $subCount") - [void]$md.AppendLine('') - [void]$md.AppendLine('| # | Subscription Name | Subscription ID | Tenant ID | State |') - [void]$md.AppendLine('|---|---|---|---|---|') - $i = 0 - foreach ($s in $subs) { - $i++ - [void]$md.AppendLine("| $i | $($s.name) | ``$($s.subscriptionId)`` | ``$($s.tenantId)`` | $($s.state) |") - } - [void]$md.AppendLine('') - [void]$md.AppendLine("*Generated at $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss UTC')*") - $summaryPath = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'auth-report-summary.md' - $md.ToString() | Out-File -FilePath $summaryPath -Encoding utf8 - Write-Host "##vso[task.uploadsummary]$summaryPath" - - Write-Host "##vso[task.setvariable variable=subscriptionCount;isOutput=false]$subCount" - Write-Host "##vso[task.setvariable variable=clusterCount;isOutput=false]$clusterCount" + Export-AzLocalAuthValidationReport ` + -ReportDirectory $reportDir ` + -InstalledModuleVersion "$(installedModuleVersion)" ` + -GeneratedAgainstVersion "$(generatedAgainstVersion)" ` + -LatestOnPSGallery "$(latestOnPsGallery)" - task: PublishTestResults@2 displayName: 'Publish Authentication Validation JUnit Diagnostics' 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 2a69a78c..6413c57e 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.4' + value: '0.8.5' # 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). @@ -74,6 +74,8 @@ stages: - task: PowerShell@2 displayName: 'Install AzLocal.UpdateManagement from PSGallery' + # v0.8.5 thin-YAML: drift detection + banner + step outputs are all + # produced by Add-AzLocalPipelineVersionBanner (Public cmdlet). env: REQUIRED_MODULE_VERSION: $(REQUIRED_MODULE_VERSION) GENERATED_AGAINST_MODULE_VERSION: $(GENERATED_AGAINST_MODULE_VERSION) @@ -91,196 +93,35 @@ stages: } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force - # Get-Module can return multiple entries when nested modules are loaded - pick the highest version. - $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on agent : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - Write-Host "" - Get-Module AzLocal.UpdateManagement | Format-List Name, Version, Path - # Drift signal 0: the module installed at runtime is OLDER than the version this YAML was generated against. Cmdlets, parameters, or output schemas referenced by this YAML may not exist in the installed module - emitted as a warning because this is the most likely cause of a hard runtime failure later in the job. - if ($installed -lt $generated) { - Write-Host "##vso[task.logissue type=warning]AzLocal.UpdateManagement v$installed is OLDER than the version this pipeline YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Platform AzureDevOps -Update'." - } - if ($installed -gt $generated) { - $msg = "Pipeline YAML was generated against AzLocal.UpdateManagement v$generated but the agent installed v$installed. Pipeline steps may have been improved in later releases - to refresh, re-run 'Copy-AzLocalPipelineExample -Destination .\pipelines -Platform AzureDevOps -Update' (you will be prompted per file; add -Confirm:`$false to bypass). Pipeline YAMLs are under git so 'git diff' shows exactly what changed before commit." - Write-Host "##vso[task.logissue type=warning]$msg" - } - if ($latest -and ($latest -gt $installed)) { - $msg = "AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed. Review the module CHANGELOG before bumping REQUIRED_MODULE_VERSION (or clear the pin to install the latest automatically)." - Write-Host "##vso[task.logissue type=warning]$msg" - } + $banner = Add-AzLocalPipelineVersionBanner ` + -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION ` + -PinnedVersion $env:REQUIRED_MODULE_VERSION ` + -PassThru + # Forward to ADO job-scoped variables so the inventory step (below) + # can stamp the installed module version into README_Instructions.txt. + Write-Host "##vso[task.setvariable variable=installedModuleVersion;isOutput=false]$($banner.InstalledVersion)" - # v0.8.4 - one-line version banner uploaded to the build run - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), - # the module version actually loaded on the agent, the PSGallery - # latest, and the REQUIRED_MODULE_VERSION pin status are visible - # at the top of every run summary without scrolling the agent - # log. Same data is also echoed to the console above. - $pin = if ($env:REQUIRED_MODULE_VERSION) { "pinned to v$($env:REQUIRED_MODULE_VERSION)" } else { 'latest (fix-forward)' } - $latestStr = if ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } - $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } - elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } - elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } - else { 'in sync' } - $bannerPath = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'module-version-banner.md' - "_Pipeline YAML v$generated | Module v$installed installed ($pin) | PSGallery latest $latestStr | $verdict_" | Out-File -FilePath $bannerPath -Encoding utf8 - Write-Host "##vso[task.uploadsummary]$bannerPath" - - task: AzureCLI@2 displayName: 'Run Cluster Inventory' inputs: azureSubscription: 'AzureLocal-ServiceConnection' # Update with your service connection name scriptType: 'pscore' scriptLocation: 'inlineScript' + # v0.8.5 thin-YAML: the inline run block (inventory query, dual + # CSV/JSON export, canonical CSV copy, README_Instructions.txt, + # per-pipeline summary, separate 'Generate Summary' task) has + # been condensed into the Public cmdlet + # Invoke-AzLocalClusterInventory. The cmdlet writes the four + # artifacts to -OutputDirectory, emits the markdown summary via + # ##vso[task.uploadsummary], and sets the four step outputs. inlineScript: | + $ErrorActionPreference = 'Stop' Import-Module AzLocal.UpdateManagement -Force - - # Create output directory - $outputDir = "$(Build.ArtifactStagingDirectory)" - - $timestamp = Get-Date -Format "yyyyMMdd_HHmmss" - $csvPath = Join-Path $outputDir "ClusterInventory_$timestamp.csv" - $jsonPath = Join-Path $outputDir "ClusterInventory_$timestamp.json" - - # Add subscription filter if provided - $subFilter = "${{ parameters.subscriptionFilter }}" - $subscriptionParam = @{} - if ($subFilter -and $subFilter -ne '') { - $subscriptionParam['SubscriptionId'] = $subFilter - Write-Host "Filtering to subscription: $subFilter" - } else { - Write-Host "Querying all accessible subscriptions" - } - - # Export to CSV (for Excel workflow) - Get-AzLocalClusterInventory @subscriptionParam -ExportPath $csvPath - - # Export to JSON (for CI/CD integrations and dashboards) - $inventory = Get-AzLocalClusterInventory @subscriptionParam -ExportPath $jsonPath -PassThru - - # Output summary - Write-Host "" - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "Inventory Complete" -ForegroundColor Cyan - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "Total Clusters: $($inventory.Count)" - Write-Host "CSV exported to: $csvPath" - Write-Host "JSON exported to: $jsonPath" - - # ---- Canonical no-timestamp copy expected by 'Manage UpdateRing Tags' ---- - $canonicalCsvPath = Join-Path $outputDir "ClusterUpdateRings.csv" - Copy-Item -Path $csvPath -Destination $canonicalCsvPath -Force - Write-Host "Canonical copy created: $canonicalCsvPath (edit this one)" - - # ---- README_Instructions.txt explaining what to do with the CSV ---- - $installedVersion = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generatedUtc = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'") - $readmePath = Join-Path $outputDir "README_Instructions.txt" - $readme = @' - ======================================================================== - Cluster Inventory - Next Steps - AzLocal.UpdateManagement (UpdateRing tagging workflow) - ======================================================================== - - This ZIP contains the cluster inventory generated by the - 'Inventory Azure Local Clusters' pipeline. Use it to assign each - cluster to an update wave (UpdateRing tag), then commit the result - back into your repository so the 'Manage UpdateRing Tags' pipeline - can apply the tags to Azure. - - ------------------------------------------------------------------------ - FILES IN THIS ARTIFACT - ------------------------------------------------------------------------ - - ClusterUpdateRings.csv - <-- EDIT THIS FILE. Canonical name expected by the 'Manage - UpdateRing Tags' pipeline's default csvFilePath parameter - ('config/ClusterUpdateRings.csv'). - - ClusterInventory_.csv - Audit / historical copy of this run's inventory. Do NOT edit - - keep it so you can diff future inventory runs against this - snapshot. - - ClusterInventory_.json - Machine-readable copy for dashboards / custom integrations. - - README_Instructions.txt - This file. - - ------------------------------------------------------------------------ - STEP 1 - Open ClusterUpdateRings.csv in Excel - ------------------------------------------------------------------------ - - - For each cluster row, fill in the 'UpdateRing' column with the - wave name you want that cluster to belong to. Examples: - Canary, Pilot, Production - (or your own scheme, e.g. Wave1, Wave2, Wave3 / Dev, Test, Prod) - - Leave the 'UpdateRing' column EMPTY for any cluster you do not - want this run to tag - those rows will be skipped. - - Save as CSV (UTF-8). Do not change other columns or column order. - - Do NOT rename the file. Keep it as 'ClusterUpdateRings.csv' so - the 'Manage UpdateRing Tags' pipeline's default path picks it up. + Invoke-AzLocalClusterInventory ` + -OutputDirectory "$(Build.ArtifactStagingDirectory)" ` + -SubscriptionFilter "${{ parameters.subscriptionFilter }}" ` + -InstalledModuleVersion "$(installedModuleVersion)" - ------------------------------------------------------------------------ - STEP 2 - Commit ClusterUpdateRings.csv into your ops repository - ------------------------------------------------------------------------ - - - Recommended path: config/ClusterUpdateRings.csv - - Commit and push (PR review optional but recommended). - - The repo path you commit it to is what you will pass as - 'csvFilePath' on the next run of the 'Manage UpdateRing Tags' - pipeline. - - ------------------------------------------------------------------------ - STEP 3 - Run the 'Manage UpdateRing Tags' pipeline - ------------------------------------------------------------------------ - - - Open Azure DevOps Pipelines and trigger 'Manage UpdateRing Tags' - via 'Run pipeline'. - - Set 'csvFilePath' to the path you committed in STEP 2 - (default is 'config/ClusterUpdateRings.csv'). - - RECOMMENDED FIRST RUN: leave 'dryRun' = true. The pipeline - will preview which tags would be set/changed without modifying - any Azure resources. - - Once the preview looks correct, re-run with 'dryRun' = false - to apply the tags. Use 'force' = true only if you need to - overwrite existing UpdateRing values already set on a cluster. - - ------------------------------------------------------------------------ - DOCUMENTATION - ------------------------------------------------------------------------ - - Pipeline examples README (online): - https://github.com/NeilBird/Azure-Local/blob/main/AzLocal.UpdateManagement/Automation-Pipeline-Examples/README.md - - Module README (online): - https://github.com/NeilBird/Azure-Local/blob/main/AzLocal.UpdateManagement/README.md - - Module on the PowerShell Gallery: - https://www.powershellgallery.com/packages/AzLocal.UpdateManagement - - Generated by AzLocal.UpdateManagement v__MODULE_VERSION__ - Run timestamp (UTC): __TIMESTAMP__ - ======================================================================== - '@ - $readme = $readme.Replace('__MODULE_VERSION__', "$installedVersion").Replace('__TIMESTAMP__', $generatedUtc) - Set-Content -Path $readmePath -Value $readme -Encoding ASCII - Write-Host "Instructions written to: $readmePath" - - # Generate summary for pipeline - $withTag = ($inventory | Where-Object { $_.HasUpdateRingTag -eq 'Yes' }).Count - $withoutTag = ($inventory | Where-Object { $_.HasUpdateRingTag -eq 'No' }).Count - - Write-Host "##vso[task.setvariable variable=TotalClusters]$($inventory.Count)" - Write-Host "##vso[task.setvariable variable=WithTag]$withTag" - Write-Host "##vso[task.setvariable variable=WithoutTag]$withoutTag" - # compute a UTC timestamp variable so every downloadable artifact name is unique per run. - pwsh: | $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss') @@ -296,29 +137,4 @@ stages: PathtoPublish: '$(Build.ArtifactStagingDirectory)' ArtifactName: 'azlocal-step.1-cluster-inventory_$(stamp.artifactStamp)' publishLocation: 'Container' - - - task: PowerShell@2 - displayName: 'Generate Summary' - inputs: - targetType: 'inline' - script: | - Write-Host "##vso[task.uploadsummary]$(Build.ArtifactStagingDirectory)/summary.md" - - $summary = @" - # Cluster Inventory Summary - - | Metric | Value | - |--------|-------| - | Total Clusters | $(TotalClusters) | - | With UpdateRing Tag | $(WithTag) | - | Without UpdateRing Tag | $(WithoutTag) | - - ## Next Steps - - 1. Download the CSV artifact from this build - 2. Open in Excel and set UpdateRing values for clusters - 3. Run the "Manage UpdateRing Tags" pipeline with the updated CSV - "@ - - $summary | Out-File "$(Build.ArtifactStagingDirectory)/summary.md" -Encoding UTF8 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 2c341022..37a01d1c 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.4' + value: '0.8.5' # 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). @@ -68,105 +68,36 @@ stages: - task: PowerShell@2 displayName: 'Validate CSV File Exists' + # v0.8.5 thin-YAML: the full TWO-STAGE operator workflow guidance + # is now emitted by Set-AzLocalClusterUpdateRingTagFromCsv when + # the Apply task runs. This task still fails fast (and surfaces + # the path) before we spend time installing the module. inputs: targetType: 'inline' script: | $csvPath = Join-Path "$(System.DefaultWorkingDirectory)" "${{ parameters.csvFilePath }}" - if (-not (Test-Path $csvPath)) { Write-Host "##vso[task.logissue type=error]CSV file not found: ${{ parameters.csvFilePath }}" + Write-Host "The full TWO-STAGE operator workflow guidance will be emitted by" + Write-Host "Set-AzLocalClusterUpdateRingTagFromCsv when the Apply task runs." Write-Host "" - Write-Host "===========================================================================" -ForegroundColor Yellow - Write-Host " UpdateRing tagging is a TWO-STAGE pipeline. You must run the inventory" -ForegroundColor Yellow - Write-Host " pipeline first, edit the CSV, commit it, then re-run THIS pipeline." -ForegroundColor Yellow - Write-Host "===========================================================================" -ForegroundColor Yellow + Write-Host "Quick recap:" + Write-Host " 1. Run Step.1 (Inventory) and download/edit/commit the CSV." + Write-Host " 2. Re-run this Step.2 with csvFilePath pointing to the committed CSV." Write-Host "" - Write-Host "STEP 1 - Generate the cluster inventory (one-off, then on demand)" -ForegroundColor Cyan - Write-Host " a. Open Azure DevOps Pipelines in this project." - Write-Host " b. Run the pipeline named 'Inventory Azure Local Clusters'" - Write-Host " (file: azure-pipelines/Step.1_inventory-clusters.yml or similar)." - Write-Host " c. Wait for the run to finish successfully." - Write-Host " d. On the completed run summary, open the published artifact" - Write-Host " named 'cluster-inventory' and download it." - Write-Host " e. Extract it locally. You will get 'ClusterUpdateRings.csv' (the" - Write-Host " one to edit), a timestamped audit copy, a .json copy, and a" - Write-Host " 'README_Instructions.txt' file that mirrors these instructions." - Write-Host "" - Write-Host "STEP 2 - Edit the CSV in Excel" -ForegroundColor Cyan - Write-Host " a. Open 'ClusterUpdateRings.csv' in Excel." - Write-Host " b. For each cluster row, fill in the 'UpdateRing' column with the wave" - Write-Host " name you want that cluster to belong to. Examples:" - Write-Host " Canary, Pilot, Production" - Write-Host " (or your own scheme, e.g. Wave1, Wave2, Wave3 / Dev, Test, Prod)." - Write-Host " c. Leave the 'UpdateRing' column EMPTY for any cluster you do not want" - Write-Host " this run to tag - those rows will be skipped." - Write-Host " d. Save as CSV (UTF-8). Do not change other columns or column order." - Write-Host " e. Do NOT rename the file - keep it as 'ClusterUpdateRings.csv' so" - Write-Host " the default 'csvFilePath' below picks it up." - Write-Host "" - Write-Host "STEP 3 - Commit the edited CSV to this repository" -ForegroundColor Cyan - Write-Host " a. Copy the edited CSV into a stable location in this repo, for example:" - Write-Host " config/ClusterUpdateRings.csv" - Write-Host " b. Commit and push it (PR review optional but recommended)." - Write-Host " c. The path you commit it to is what you will pass as 'csvFilePath'" - Write-Host " on the next run of this pipeline." - Write-Host "" - Write-Host "STEP 4 - Re-run THIS pipeline ('Manage UpdateRing Tags')" -ForegroundColor Cyan - Write-Host " a. Click 'Run pipeline' on this pipeline." - Write-Host " b. Set 'csvFilePath' to the path you committed in step 3" - Write-Host " (default is 'config/ClusterUpdateRings.csv')." - Write-Host " c. RECOMMENDED FIRST RUN: leave 'dryRun' = true to preview which tags" - Write-Host " would be set/changed, without modifying any Azure resources." - Write-Host " d. Once the preview looks correct, re-run with 'dryRun' = false to" - Write-Host " apply the tags. Use 'force' = true only if you need to overwrite" - Write-Host " existing UpdateRing values already set on a cluster." - Write-Host "" - Write-Host "Documentation:" -ForegroundColor Yellow + Write-Host "Docs:" Write-Host " AzLocal.UpdateManagement/README.md - 'Update Rings' section" Write-Host " AzLocal.UpdateManagement/Automation-Pipeline-Examples/README.md" - Write-Host "" - Write-Host "The path you supplied ('${{ parameters.csvFilePath }}') does not exist" -ForegroundColor Red - Write-Host "in the checked-out branch of this repository. Did you forget to commit" -ForegroundColor Red - Write-Host "the CSV, point at the wrong branch on 'Run pipeline', or mistype it?" -ForegroundColor Red exit 1 } - Write-Host "Found CSV file: $csvPath" Write-Host "##vso[task.setvariable variable=CsvPath]$csvPath" - - - task: PowerShell@2 - displayName: 'Validate CSV File' - inputs: - targetType: 'inline' - script: | - $csvPath = "$(CsvPath)" - $data = Import-Csv $csvPath - - Write-Host "CSV File: $csvPath" - Write-Host "Total Rows: $($data.Count)" - - # Check required columns - $requiredColumns = @('ResourceId', 'UpdateRing') - $columns = $data[0].PSObject.Properties.Name - - foreach ($col in $requiredColumns) { - if ($col -notin $columns) { - Write-Error "Required column '$col' not found in CSV" - exit 1 - } - } - - # Count rows with UpdateRing values - $rowsWithValues = ($data | Where-Object { $_.UpdateRing -and $_.UpdateRing.Trim() -ne '' }).Count - Write-Host "Rows with UpdateRing values: $rowsWithValues" - - if ($rowsWithValues -eq 0) { - Write-Error "No rows have UpdateRing values set. Please edit the CSV and set UpdateRing values before running this pipeline." - exit 1 - } - + - task: PowerShell@2 displayName: 'Install AzLocal.UpdateManagement from PSGallery' + name: moduleVersionStep + # v0.8.5 thin-YAML: drift detection + banner + step outputs are + # all produced by Add-AzLocalPipelineVersionBanner (Public cmdlet). env: REQUIRED_MODULE_VERSION: $(REQUIRED_MODULE_VERSION) GENERATED_AGAINST_MODULE_VERSION: $(GENERATED_AGAINST_MODULE_VERSION) @@ -184,102 +115,38 @@ stages: } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force - $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on agent : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - Write-Host "" - Get-Module AzLocal.UpdateManagement | Format-List Name, Version, Path - # Drift signal 0: the module installed at runtime is OLDER than the version this YAML was generated against. Cmdlets, parameters, or output schemas referenced by this YAML may not exist in the installed module - emitted as a warning because this is the most likely cause of a hard runtime failure later in the job. - if ($installed -lt $generated) { - Write-Host "##vso[task.logissue type=warning]AzLocal.UpdateManagement v$installed is OLDER than the version this pipeline YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Platform AzureDevOps -Update'." - } - if ($installed -gt $generated) { - $msg = "Pipeline YAML was generated against AzLocal.UpdateManagement v$generated but the agent installed v$installed. Pipeline steps may have been improved in later releases - to refresh, re-run 'Copy-AzLocalPipelineExample -Destination .\pipelines -Platform AzureDevOps -Update' (you will be prompted per file; add -Confirm:`$false to bypass). Pipeline YAMLs are under git so 'git diff' shows exactly what changed before commit." - Write-Host "##vso[task.logissue type=warning]$msg" - } - if ($latest -and ($latest -gt $installed)) { - $msg = "AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed. Review the module CHANGELOG before bumping REQUIRED_MODULE_VERSION (or clear the pin to install the latest automatically)." - Write-Host "##vso[task.logissue type=warning]$msg" - } + Add-AzLocalPipelineVersionBanner ` + -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION ` + -PinnedVersion $env:REQUIRED_MODULE_VERSION - # v0.8.4 - one-line version banner uploaded to the build run - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), - # the module version actually loaded on the agent, the PSGallery - # latest, and the REQUIRED_MODULE_VERSION pin status are visible - # at the top of every run summary without scrolling the agent - # log. Same data is also echoed to the console above. - $pin = if ($env:REQUIRED_MODULE_VERSION) { "pinned to v$($env:REQUIRED_MODULE_VERSION)" } else { 'latest (fix-forward)' } - $latestStr = if ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } - $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } - elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } - elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } - else { 'in sync' } - $bannerPath = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'module-version-banner.md' - "_Pipeline YAML v$generated | Module v$installed installed ($pin) | PSGallery latest $latestStr | $verdict_" | Out-File -FilePath $bannerPath -Encoding utf8 - Write-Host "##vso[task.uploadsummary]$bannerPath" - - task: AzureCLI@2 displayName: 'Apply UpdateRing Tags' + # v0.8.5 thin-YAML: the inline Set-AzLocalClusterUpdateRingTag + # invocation, JSON sidecar write, and Generate Summary task have + # been condensed into the Public cmdlet + # Set-AzLocalClusterUpdateRingTagFromCsv. The cmdlet writes + # UpdateRingTag_Results.json into -OutputDirectory, emits the + # markdown step summary via ##vso[task.uploadsummary], and + # surfaces the eight pipeline variables (total_count, + # created_count, updated_count, already_in_sync_count, + # skipped_count, failed_count, whatif_count, results_json_path). inputs: azureSubscription: 'AzureLocal-ServiceConnection' # Update with your service connection name scriptType: 'pscore' scriptLocation: 'inlineScript' inlineScript: | Import-Module AzLocal.UpdateManagement -Force - - $csvPath = "$(CsvPath)" $forceOverwrite = [System.Convert]::ToBoolean("${{ parameters.forceOverwrite }}") - $dryRun = [System.Convert]::ToBoolean("${{ parameters.dryRun }}") - - # Create output directory for logs - $outputDir = "$(Build.ArtifactStagingDirectory)" - - # Build parameters + $dryRun = [System.Convert]::ToBoolean("${{ parameters.dryRun }}") $params = @{ - InputCsvPath = $csvPath - LogFolderPath = $outputDir - } - - if ($forceOverwrite) { - $params['Force'] = $true - Write-Host "Force mode enabled - will overwrite existing tags" - } - - if ($dryRun) { - $params['WhatIf'] = $true - Write-Host "##vso[task.logissue type=warning]DRY RUN MODE - No changes will be applied" + InputCsvPath = "$(CsvPath)" + OutputDirectory = "$(Build.ArtifactStagingDirectory)" + InstalledModuleVersion = "$(moduleVersionStep.installed_module_version)" } - - Write-Host "" - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "Applying UpdateRing Tags" -ForegroundColor Cyan - Write-Host "========================================" -ForegroundColor Cyan + if ($forceOverwrite) { $params['Force'] = $true } + if ($dryRun) { $params['WhatIf'] = $true } + Set-AzLocalClusterUpdateRingTagFromCsv @params - # Run tag management. -PassThru returns the per-cluster results so - # the Summary task can build a breakdown table rather than parroting - # only the pipeline inputs back at the operator. - $results = Set-AzLocalClusterUpdateRingTag @params -PassThru - - # Persist results to a JSON sidecar so the Summary task (separate - # PowerShell process) can read them. Lives in the artifact staging - # dir that gets published. - $resultsJsonPath = Join-Path $outputDir 'UpdateRingTag_Results.json' - if ($null -ne $results) { - @($results) | ConvertTo-Json -Depth 5 | Out-File -FilePath $resultsJsonPath -Encoding utf8 - Write-Host "Wrote $($results.Count) per-cluster result row(s) to: $resultsJsonPath" - } else { - '[]' | Out-File -FilePath $resultsJsonPath -Encoding utf8 - Write-Host "No per-cluster results returned (empty CSV?). Wrote empty array to: $resultsJsonPath" - } - - Write-Host "" - Write-Host "Tag management complete" - # compute a UTC timestamp variable so every downloadable artifact name is unique per run. - pwsh: | $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss') @@ -295,82 +162,4 @@ stages: PathtoPublish: '$(Build.ArtifactStagingDirectory)' ArtifactName: 'azlocal-step.2-updatering-tag-logs_$(stamp.artifactStamp)' publishLocation: 'Container' - - - task: PowerShell@2 - displayName: 'Generate Summary' - inputs: - targetType: 'inline' - script: | - $dryRun = [System.Convert]::ToBoolean("${{ parameters.dryRun }}") - $forceOverwrite = [System.Convert]::ToBoolean("${{ parameters.forceOverwrite }}") - - $lines = New-Object System.Collections.Generic.List[string] - [void]$lines.Add('# UpdateRing Tag Management Summary') - [void]$lines.Add('') - [void]$lines.Add('| Setting | Value |') - [void]$lines.Add('|---------|-------|') - [void]$lines.Add("| Dry Run | $dryRun |") - [void]$lines.Add("| Force Overwrite | $forceOverwrite |") - - # Per-cluster results breakdown - read the JSON sidecar the apply task wrote. - # See AzLocal.UpdateManagement Set-AzLocalClusterUpdateRingTag -PassThru schema. - $resultsJsonPath = Join-Path '$(Build.ArtifactStagingDirectory)' 'UpdateRingTag_Results.json' - if (Test-Path $resultsJsonPath) { - $results = @(Get-Content -Raw -Path $resultsJsonPath | ConvertFrom-Json) - $total = $results.Count - $created = @($results | Where-Object { $_.Action -eq 'Created' -and $_.Status -eq 'Success' }).Count - $updated = @($results | Where-Object { $_.Action -eq 'Updated' -and $_.Status -eq 'Success' }).Count - $alreadyInSync = @($results | Where-Object { $_.Status -eq 'AlreadyInSync' }).Count - $skipped = @($results | Where-Object { $_.Status -eq 'Skipped' }).Count - $failed = @($results | Where-Object { $_.Status -eq 'Failed' }).Count - $whatIf = @($results | Where-Object { $_.Status -eq 'WhatIf' }).Count - - [void]$lines.Add('') - [void]$lines.Add('## Result breakdown') - [void]$lines.Add('') - [void]$lines.Add('| Outcome | Count |') - [void]$lines.Add('|---------|------:|') - [void]$lines.Add("| Total clusters processed | $total |") - [void]$lines.Add("| Tags created | $created |") - [void]$lines.Add("| Tags updated | $updated |") - [void]$lines.Add("| Already in sync (no-op) | $alreadyInSync |") - [void]$lines.Add("| Skipped (UpdateRing differs, no -Force) | $skipped |") - if ($whatIf -gt 0) { - [void]$lines.Add("| WhatIf (dry-run preview) | $whatIf |") - } - [void]$lines.Add("| Failed | $failed |") - - if ($total -gt 0) { - [void]$lines.Add('') - [void]$lines.Add("
Per-cluster results ($total rows)") - [void]$lines.Add('') - [void]$lines.Add('| Cluster | Action | Previous | New | Status | Message |') - [void]$lines.Add('|---------|--------|----------|-----|--------|---------|') - foreach ($r in $results) { - $cn = ("$($r.ClusterName)") -replace '\|','\|' - $act = ("$($r.Action)") -replace '\|','\|' - $pv = ("$($r.PreviousTagValue)") -replace '\|','\|' - $nv = ("$($r.NewTagValue)") -replace '\|','\|' - $st = ("$($r.Status)") -replace '\|','\|' - $msg = ("$($r.Message)") -replace '\|','\|' -replace '\r?\n',' ' - [void]$lines.Add("| $cn | $act | $pv | $nv | $st | $msg |") - } - [void]$lines.Add('') - [void]$lines.Add('
') - } - } else { - [void]$lines.Add('') - [void]$lines.Add("_No per-cluster results JSON found at $resultsJsonPath - apply step may have failed before producing results._") - } - - if ($dryRun) { - [void]$lines.Add('') - [void]$lines.Add('**This was a dry run. No changes were applied.**') - [void]$lines.Add('') - [void]$lines.Add("Re-run the pipeline with 'Preview changes' set to false to apply changes.") - } - - $summaryPath = '$(Build.ArtifactStagingDirectory)/summary.md' - $lines -join [Environment]::NewLine | Out-File $summaryPath -Encoding UTF8 - Write-Host "##vso[task.uploadsummary]$summaryPath" 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 8489c323..0f543417 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.4' + GENERATED_AGAINST_MODULE_VERSION: '0.8.5' REQUIRED_MODULE_VERSION: '${{ parameters.moduleVersion }}' reportsPath: '$(Build.ArtifactStagingDirectory)/reports' @@ -105,6 +105,9 @@ stages: - task: PowerShell@2 displayName: 'Install AzLocal.UpdateManagement from PSGallery' + # v0.8.5 thin-YAML: drift detection + banner + output variables are + # all produced by Add-AzLocalPipelineVersionBanner (Public cmdlet). + name: moduleVersion env: REQUIRED_MODULE_VERSION: $(REQUIRED_MODULE_VERSION) GENERATED_AGAINST_MODULE_VERSION: $(GENERATED_AGAINST_MODULE_VERSION) @@ -122,44 +125,37 @@ stages: } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force - $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on agent : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - # Drift signal 0: the module installed at runtime is OLDER than the version this YAML was generated against. Cmdlets, parameters, or output schemas referenced by this YAML may not exist in the installed module - emitted as a warning because this is the most likely cause of a hard runtime failure later in the job. - if ($installed -lt $generated) { - Write-Host "##vso[task.logissue type=warning]AzLocal.UpdateManagement v$installed is OLDER than the version this pipeline YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Platform AzureDevOps -Update'." - } - if ($installed -gt $generated) { - Write-Host "##vso[task.logissue type=warning]Pipeline YAML was generated against AzLocal.UpdateManagement v$generated but the agent installed v$installed. Refresh via 'Copy-AzLocalPipelineExample -Platform AzureDevOps -Update'." - } - if ($latest -and ($latest -gt $installed)) { - Write-Host "##vso[task.logissue type=warning]AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed." - } - - # v0.8.4 - one-line version banner uploaded to the build run - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), - # the module version actually loaded on the agent, the PSGallery - # latest, and the REQUIRED_MODULE_VERSION pin status are visible - # at the top of every run summary without scrolling the agent - # log. Same data is also echoed to the console above. - $pin = if ($env:REQUIRED_MODULE_VERSION) { "pinned to v$($env:REQUIRED_MODULE_VERSION)" } else { 'latest (fix-forward)' } - $latestStr = if ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } - $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } - elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } - elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } - else { 'in sync' } - $bannerPath = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'module-version-banner.md' - "_Pipeline YAML v$generated | Module v$installed installed ($pin) | PSGallery latest $latestStr | $verdict_" | Out-File -FilePath $bannerPath -Encoding utf8 - Write-Host "##vso[task.uploadsummary]$bannerPath" + Add-AzLocalPipelineVersionBanner ` + -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION ` + -PinnedVersion $env:REQUIRED_MODULE_VERSION - task: AzureCLI@2 displayName: 'Run Schedule Coverage Audit' + # v0.8.5 thin-YAML: the inline inlineScript body (~210-line audit + # block + ~185-line summary block: 3 calls to + # Test-AzLocalApplyUpdatesScheduleCoverage, inline JUnit + # StringBuilder, 12-row markdown summary, schedule-row detail tables, + # allow-list coverage v1/v2 sections, cycle calendar render, and 12 + # output variables) is now the Public cmdlet + # Export-AzLocalApplyUpdatesScheduleAudit. The cmdlet writes the 4 + # $(reportsPath)/schedule-coverage-* artifacts, pushes the markdown + # summary via ##vso[task.uploadsummary], and sets the 12 lowercase + # output variables on the ``audit`` task via Set-AzLocalPipelineOutput. + # **v0.8.5 fix**: the cycle calendar now renders UNCONDITIONALLY + # whenever schedulePath is supplied, eliminating the v0.8.4 + # ``$hasIssues``-gate regression that silently dropped the calendar + # on clean-fleet runs. name: audit + env: + INPUT_PIPELINE_PATH: ${{ parameters.pipelinePath }} + INPUT_SCHEDULE_PATH: ${{ parameters.schedulePath }} + INPUT_LEAD_TIME_MINUTES: ${{ parameters.leadTimeMinutes }} + INPUT_FIRES_PER_WINDOW: ${{ parameters.firesPerWindow }} + INPUT_INCLUDE_UNTAGGED: ${{ lower(parameters.includeUntagged) }} + INPUT_CLUSTER_CSV_PATH: ${{ parameters.clusterCsvPath }} + INPUT_DEBUG: ${{ lower(parameters.debug) }} + INSTALLED_MODULE_VERSION: $(moduleVersion.installed_module_version) + REPORTS_PATH: $(reportsPath) inputs: # Replace with your service connection name azureSubscription: 'AzureLocal-ServiceConnection' # Update with your service connection name @@ -170,209 +166,30 @@ stages: az extension add --name resource-graph --yes Import-Module AzLocal.UpdateManagement -Force - $outputDir = "$(reportsPath)" - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - - $pipelinePath = "${{ parameters.pipelinePath }}" - $schedulePath = "${{ parameters.schedulePath }}" - $leadTime = [int]"${{ parameters.leadTimeMinutes }}" - $firesPerWin = [int]"${{ parameters.firesPerWindow }}" - $includeUnt = ${{ lower(parameters.includeUntagged) }} - $clusterCsv = "${{ parameters.clusterCsvPath }}" - $debug = ('${{ parameters.debug }}' -ieq 'true') - - # Debug toggle: flips Verbose/Debug preferences and emits a - # one-shot environment snapshot so operators can self-triage without - # needing a code change. Default OFF to keep normal runs concise. Note: - # Azure DevOps also honours queue-time variable system.debug=true as a - # built-in mechanism; either flips on detailed agent logging. - if ($debug) { + if ($env:INPUT_DEBUG -eq 'true') { $VerbosePreference = 'Continue' $DebugPreference = 'Continue' Write-Host "##[group]Debug environment" Write-Host "PSVersion : $($PSVersionTable.PSVersion)" Write-Host "PSEdition : $($PSVersionTable.PSEdition)" - Write-Host "OS : $($PSVersionTable.OS)" $mod = Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1 Write-Host "AzLocal.UpdateManagement : v$($mod.Version) (path: $($mod.Path))" - Write-Host "VerbosePreference : $VerbosePreference" - Write-Host "DebugPreference : $DebugPreference" Write-Host "##[endgroup]" } - # v0.8.3: pipelinePath is REQUIRED so Recommend can diff its - # proposed crons against what's already in Step.6 and only emit a - # snippet for the truly missing entries. schedulePath stays optional - # (it drives the ring-file two-way diff). ADO parameters: blocks have - # no 'required:' keyword, so validate at runtime instead. - $havePipeline = -not [string]::IsNullOrWhiteSpace($pipelinePath) - $haveSchedule = -not [string]::IsNullOrWhiteSpace($schedulePath) -and (Test-Path -LiteralPath $schedulePath) - # v0.8.4: clusterCsvPath is optional. If the file is missing on the agent - # we skip the NoWindowTag remediation section quietly (vs throwing) so the - # pipeline keeps emitting the legacy advisor output for repos that don't - # version-control a cluster CSV yet. - $haveCsv = -not [string]::IsNullOrWhiteSpace($clusterCsv) -and (Test-Path -LiteralPath $clusterCsv) - if (-not $havePipeline) { - throw "pipelinePath parameter is required. Set it to the folder containing Step.6_apply-updates.yml (default '.azure-pipelines' matches the standard consumer layout). The Recommend view diffs its proposed crons against this file - leaving it empty would make every recommendation a false positive." - } - if (-not (Test-Path -LiteralPath $pipelinePath)) { - $candidates = @('.azure-pipelines', 'pipelines', '.github/workflows', '.') | Where-Object { Test-Path -LiteralPath $_ } - $hint = if ($candidates) { "Common locations that DO exist in this repo: $($candidates -join ', '). Re-queue the pipeline and override the 'pipelinePath' parameter." } else { "No common pipeline folders (.azure-pipelines, pipelines, .github/workflows, repo root) were found - commit your Step.6_apply-updates.yml first, or set 'pipelinePath' to wherever you keep it." } - throw "PipelineYamlPath '$pipelinePath' does not exist on the agent. $hint" - } - - Write-Host "========================================" - Write-Host "Apply-Updates Schedule Coverage Audit" - Write-Host "========================================" - Write-Host "PipelineYamlPath : $(if ($pipelinePath) { $pipelinePath } else { '(skipped - pipelinePath empty)' })" - Write-Host "SchedulePath : $(if ($haveSchedule) { $schedulePath } else { '(skipped - schedulePath empty or file missing)' })" - Write-Host "LeadTimeMinutes : $leadTime" - Write-Host "FiresPerWindow : $firesPerWin (1 = opening edge only, 2 = belt-and-braces opening + mid-window retry)" - Write-Host "IncludeUntagged : $includeUnt" - Write-Host "ClusterCsvPath : $(if ($haveCsv) { $clusterCsv } else { '(skipped - clusterCsvPath empty or file missing)' })" - Write-Host "" - - $auditCsv = Join-Path $outputDir 'schedule-coverage-audit.csv' - $recoMd = Join-Path $outputDir 'schedule-coverage-recommend.md' - $matrixCsv = Join-Path $outputDir 'schedule-coverage-matrix.csv' - - # v0.7.74: pin -Platform AzureDevOps so the Recommend snippet ONLY - # emits the ADO `schedules:` block (not the GitHub Actions `schedule:` - # block, which would be irrelevant noise in an ADO pipeline summary). - # The cmdlet's default is 'Both' (intentional for ad-hoc operator use), - # so the pipeline yml has to specify which platform it's running on. - $auditArgs = @{ - View = 'Audit' - LeadTimeMinutes = $leadTime - RecommendFiresPerWindow = $firesPerWin - ExportPath = $auditCsv - Platform = 'AzureDevOps' - PassThru = $true - } - if ($havePipeline) { $auditArgs.PipelineYamlPath = $pipelinePath } - if ($haveSchedule) { $auditArgs.SchedulePath = $schedulePath } - if ($includeUnt) { $auditArgs.IncludeUntagged = $true } - if ($haveCsv) { $auditArgs.ClusterCsvPath = $clusterCsv } - $auditResult = Test-AzLocalApplyUpdatesScheduleCoverage @auditArgs - if (-not $auditResult) { $auditResult = @() } - - Test-AzLocalApplyUpdatesScheduleCoverage -View Matrix -LeadTimeMinutes $leadTime -ExportPath $matrixCsv | Out-Null - # v0.8.3: pass -PipelineYamlPath so Recommend diff-prunes crons - # already present in Step.6 and only emits the truly missing ones. - # v0.8.4: also pass -SchedulePath (so the Cycle calendar + - # Configured exclusion windows informational sections render) and - # -ClusterCsvPath (so the NoWindowTag remediation section renders). - $recoArgs = @{ - View = 'Recommend' - LeadTimeMinutes = $leadTime - RecommendFiresPerWindow = $firesPerWin + $params = @{ + PipelineYamlPath = $env:INPUT_PIPELINE_PATH + LeadTimeMinutes = [int]$env:INPUT_LEAD_TIME_MINUTES + RecommendFiresPerWindow = [int]$env:INPUT_FIRES_PER_WINDOW Platform = 'AzureDevOps' - ExportPath = $recoMd - } - if ($havePipeline) { $recoArgs.PipelineYamlPath = $pipelinePath } - if ($haveSchedule) { $recoArgs.SchedulePath = $schedulePath } - if ($haveCsv) { $recoArgs.ClusterCsvPath = $clusterCsv } - Test-AzLocalApplyUpdatesScheduleCoverage @recoArgs | Out-Null - - $covered = @($auditResult | Where-Object Status -eq 'Covered') - $uncovered = @($auditResult | Where-Object Status -eq 'Uncovered') - $partial = @($auditResult | Where-Object Status -eq 'PartiallyCovered') - $malformed = @($auditResult | Where-Object Status -eq 'MalformedTag') - $unparseable = @($auditResult | Where-Object Status -eq 'UnparseableCron') - # -IncludeUntagged bucket: clusters with no UpdateStartWindow tag. - $noWindowTag = @($auditResult | Where-Object Status -eq 'NoWindowTag') - # schedule-file two-way diff buckets. - $ringMissing = @($auditResult | Where-Object Status -eq 'RingMissingFromSchedule') - $ringOrphan = @($auditResult | Where-Object Status -eq 'RingOrphanedInSchedule') - # v0.7.92: same-ring mixed-window detector. Informational - the runtime - # gate reads per-cluster tags so updates still fire, but surfaces a - # ring whose clusters disagree on UpdateStartWindow value so the - # operator can decide to standardise or split the ring. - $ringMixed = @($auditResult | Where-Object Status -eq 'RingMixedWindows') - - function Convert-XmlEscape([string]$s) { - if ($null -eq $s) { return '' } - ($s.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"').Replace("'",''')) - } - function Write-Suite { - param( - [System.Text.StringBuilder]$Sb, - [string]$SuiteName, - [object[]]$Rows, - [string]$EmptyPlaceholderName - ) - $failures = @($Rows | Where-Object { $_.Status -ne 'Covered' }).Count - [void]$Sb.AppendLine((" " -f (Convert-XmlEscape $SuiteName), $Rows.Count, $failures)) - foreach ($row in $Rows) { - $tcName = ("{0} / {1}" -f $row.UpdateRing, ($(if ($row.UpdateStartWindow) { $row.UpdateStartWindow } else { '(no window)' }))) - [void]$Sb.AppendLine((" " -f (Convert-XmlEscape $tcName))) - if ($row.Status -ne 'Covered') { - $issue = Convert-XmlEscape $row.Issue - $reco = Convert-XmlEscape $row.Recommendation - [void]$Sb.AppendLine((" Recommendation: {2}{3}Cluster count: {4}" -f $row.Status, $issue, $reco, [Environment]::NewLine, $row.ClusterCount)) - } - [void]$Sb.AppendLine(' ') - } - if ($Rows.Count -eq 0 -and $EmptyPlaceholderName) { - [void]$Sb.AppendLine((" " -f (Convert-XmlEscape $EmptyPlaceholderName))) - } - [void]$Sb.AppendLine(' ') - } - - # two elements. Schedule (ring-file gap) FIRST - - # higher blast radius; Cron coverage SECOND. - $scheduleRows = @($auditResult | Where-Object { $_.Section -eq 'Schedule' }) - $cronRows = @($auditResult | Where-Object { $_.Section -ne 'Schedule' }) - - $xmlPath = Join-Path $outputDir 'schedule-coverage-audit.xml' - $sb = New-Object System.Text.StringBuilder - [void]$sb.AppendLine('') - [void]$sb.AppendLine('') - if ($haveSchedule -or $scheduleRows.Count -gt 0) { - Write-Suite -Sb $sb ` - -SuiteName 'Apply-Updates Schedule Coverage - Schedule (ring diff)' ` - -Rows $scheduleRows ` - -EmptyPlaceholderName 'Schedule and fleet ring sets match - no gaps.' - } - if ($cronRows.Count -gt 0 -or -not $haveSchedule) { - Write-Suite -Sb $sb ` - -SuiteName 'Apply-Updates Schedule Coverage - Cron coverage' ` - -Rows $cronRows ` - -EmptyPlaceholderName 'No tagged clusters found - nothing to audit' - } - [void]$sb.AppendLine('') - [System.IO.File]::WriteAllText($xmlPath, $sb.ToString(), [System.Text.UTF8Encoding]::new($false)) - - "##vso[task.setvariable variable=totalRows;isOutput=true]$($auditResult.Count)" | Write-Host - "##vso[task.setvariable variable=covered;isOutput=true]$($covered.Count)" | Write-Host - "##vso[task.setvariable variable=uncovered;isOutput=true]$($uncovered.Count)" | Write-Host - "##vso[task.setvariable variable=partial;isOutput=true]$($partial.Count)" | Write-Host - "##vso[task.setvariable variable=malformed;isOutput=true]$($malformed.Count)" | Write-Host - "##vso[task.setvariable variable=unparseable;isOutput=true]$($unparseable.Count)" | Write-Host - "##vso[task.setvariable variable=noWindowTag;isOutput=true]$($noWindowTag.Count)" | Write-Host - "##vso[task.setvariable variable=ringMissing;isOutput=true]$($ringMissing.Count)" | Write-Host - "##vso[task.setvariable variable=ringOrphan;isOutput=true]$($ringOrphan.Count)" | Write-Host - "##vso[task.setvariable variable=ringMixed;isOutput=true]$($ringMixed.Count)" | Write-Host - "##vso[task.setvariable variable=haveSchedule;isOutput=true]$([int]$haveSchedule)" | Write-Host - # v0.7.90: surface the schedule file path to the summary step - # so it can re-load the schedule for the allow-list coverage - # section (separate pwsh process - module + file have to be - # re-loaded). Empty when -SchedulePath was not supplied. - if ($haveSchedule) { - "##vso[task.setvariable variable=schedulePath;isOutput=true]$schedulePath" | Write-Host + OutputDirectory = $env:REPORTS_PATH + InstalledModuleVersion = $env:INSTALLED_MODULE_VERSION } + if ($env:INPUT_SCHEDULE_PATH) { $params['SchedulePath'] = $env:INPUT_SCHEDULE_PATH } + if ($env:INPUT_CLUSTER_CSV_PATH) { $params['ClusterCsvPath'] = $env:INPUT_CLUSTER_CSV_PATH } + if ($env:INPUT_INCLUDE_UNTAGGED -eq 'true') { $params['IncludeUntagged'] = $true } - Write-Host "" - Write-Host "Audit complete:" - Write-Host " Covered : $($covered.Count)" - Write-Host " Uncovered : $($uncovered.Count)" - Write-Host " PartiallyCovered : $($partial.Count)" - Write-Host " MalformedTag : $($malformed.Count)" - Write-Host " UnparseableCron : $($unparseable.Count)" - Write-Host " NoWindowTag : $($noWindowTag.Count)" - Write-Host " RingMissingFromSchedule : $($ringMissing.Count)" - Write-Host " RingOrphanedInSchedule : $($ringOrphan.Count)" - Write-Host " RingMixedWindows : $($ringMixed.Count)" + Export-AzLocalApplyUpdatesScheduleAudit @params # compute a UTC timestamp variable so every downloadable artifact name is unique per run. - pwsh: | @@ -391,191 +208,6 @@ stages: ArtifactName: 'azlocal-step.3-apply-updates-schedule-audit-report_$(stamp.artifactStamp)' publishLocation: 'Container' - - pwsh: | - $total = "$(audit.totalRows)" - $covered = "$(audit.covered)" - $uncovered = "$(audit.uncovered)" - $partial = "$(audit.partial)" - $malformed = "$(audit.malformed)" - $unparseable = "$(audit.unparseable)" - $noWindowTag = "$(audit.noWindowTag)" - $ringMissing = "$(audit.ringMissing)" - $ringOrphan = "$(audit.ringOrphan)" - $ringMixed = "$(audit.ringMixed)" - $haveSchedule = ("$(audit.haveSchedule)" -eq '1') - $schedulePath = "$(audit.schedulePath)" - - Write-Host "##vso[task.uploadsummary]Schedule Coverage: $covered covered, $uncovered uncovered, $partial partial, $malformed malformed, $unparseable unparseable, $noWindowTag no-window-tag, $ringMissing missing-from-schedule, $ringOrphan orphan-in-schedule, $ringMixed mixed-windows" - - $rowsPath = Join-Path '$(reportsPath)' 'schedule-coverage-audit.csv' - $recoPath = Join-Path '$(reportsPath)' 'schedule-coverage-recommend.md' - $rows = if (Test-Path $rowsPath) { @(Import-Csv -Path $rowsPath) } else { @() } - $reco = if (Test-Path $recoPath) { Get-Content -Path $recoPath -Raw } else { '' } - - # when there is at least one issue row, surface the action-required - # markdown at the TOP of the summary so operators can act without scrolling past - # the detail table. The Recommend output now ships its own '## Action required - ...' - # headings (one per kind of fix), numbered '(1 of N)' when more than one applies, - # so we inline it as-is rather than wrapping it in our own code fence. - $hasIssues = (([int]$uncovered) + ([int]$partial) + ([int]$malformed) + ([int]$unparseable) + ([int]$noWindowTag) + ([int]$ringMissing) + ([int]$ringOrphan)) -gt 0 - - $md = @" - ## Apply-Updates Schedule Coverage Audit - - | Metric | Count | - |--------|-------| - | **(Ring, Window) pairs audited** | $total | - | **Covered** | $covered | - | **Uncovered** | $uncovered | - | **PartiallyCovered** | $partial | - | **MalformedTag** | $malformed | - | **UnparseableCron** | $unparseable | - | **NoWindowTag** | $noWindowTag | - | **RingMissingFromSchedule** | $ringMissing | - | **RingOrphanedInSchedule** | $ringOrphan | - | **RingMixedWindows** | $ringMixed | - - "@ - - if ($hasIssues -and $reco) { - $md += "`n" + $reco + "`n" - } - - # split Audit Detail into two sub-tables. Schedule rows - # (RingMissingFromSchedule / RingOrphanedInSchedule) come FIRST - - # higher blast radius because a missing-from-schedule ring means - # apply-updates NEVER fires for those clusters. The Schedule - # sub-table is rendered whenever -SchedulePath was supplied (so - # operators see the "no gaps" confirmation) OR whenever any - # Schedule-section rows are present (defensive fallback in case - # $haveSchedule didn't make it across step scopes). - $scheduleRowsMd = @($rows | Where-Object { $_.Section -eq 'Schedule' }) - $cronRowsMd = @($rows | Where-Object { $_.Section -ne 'Schedule' }) - - function Add-DetailTable { - param([string]$Heading, [object[]]$DetailRows, [string]$EmptyText) - $body = "`n$Heading`n" - if ($DetailRows.Count -eq 0) { - $body += "`n*$EmptyText*`n" - return $body - } - $body += "`n| Status | UpdateRing | UpdateStartWindow | Clusters | Required Cron (UTC) | Recommendation |`n" - $body += "|--------|------------|--------------|---------:|----------------------|----------------|`n" - foreach ($r in ($DetailRows | Select-Object -First 100)) { - $body += ('| {0} | {1} | {2} | {3} | {4} | {5} |' -f $r.Status, $r.UpdateRing, $r.UpdateStartWindow, $r.ClusterCount, $r.RequiredCronUTC, $r.Recommendation) + "`n" - } - if ($DetailRows.Count -gt 100) { - $body += "`n*Showing first 100 of $($DetailRows.Count); see ``schedule-coverage-audit.csv`` artifact for the full list.*`n" - } - return $body - } - - if ($haveSchedule -or $scheduleRowsMd.Count -gt 0) { - $md += "`n### Audit Detail - Schedule (ring-file gap)`n" - $md += "`n> **Higher blast radius.** A ``RingMissingFromSchedule`` row means apply-updates will NEVER fire on those clusters until you either add the ring to ``apply-updates-schedule.yml`` or retag them onto an existing scheduled ring.`n" - $md += (Add-DetailTable -Heading '' -DetailRows $scheduleRowsMd -EmptyText 'Schedule and fleet ring sets match - no gaps.') - } - - # v0.7.90: Allow-list coverage section. When -SchedulePath was - # supplied, re-load the schedule file (the summary step is a - # separate pwsh process so we have to import the module + read - # the schedule here too) and surface the effective - # 'allowedUpdateVersions' for each schedule row. For schema v1 - # we just nudge the operator to migrate. For schema v2 we - # render a per-row table and recommend per-ring override - # snippets for rows that inherit the top-level default. Wrapped - # in try/catch so a malformed schedule never blocks the summary. - if ($haveSchedule -and -not [string]::IsNullOrWhiteSpace($schedulePath) -and (Test-Path -LiteralPath $schedulePath)) { - try { - Import-Module AzLocal.UpdateManagement -Force -ErrorAction Stop - $sched = Get-AzLocalApplyUpdatesScheduleConfig -Path $schedulePath -ErrorAction Stop - } catch { - $sched = $null - $md += "`n### Allow-list coverage`n`n*Could not load schedule file ``$schedulePath`` for allow-list analysis: $($_.Exception.Message)*`n" - } - if ($sched) { - $md += "`n### Allow-list coverage (schema v$($sched.SchemaVersion))`n" - if ($sched.SchemaVersion -lt 2) { - $md += "`n*This schedule is on schema v1. Schema v2 adds ``allowedUpdateVersions`` for fleet-wide + per-ring update allow-lists (e.g. enforce a 'minimum updates' policy on Prod). Migrate the file with:*`n" - $md += "`n``````powershell`n" - $md += "Update-AzLocalApplyUpdatesScheduleConfig -Path '$schedulePath' -SchemaMigrate`n" - $md += "```````n" - } else { - $topAllow = @($sched.AllowedUpdateVersions) - $topIsLatest = ($topAllow.Count -eq 1 -and $topAllow[0] -eq 'Latest') - if ($topIsLatest) { - $topLabel = "``Latest`` _(no constraint - install the latest Ready update on each cluster)_" - } else { - $topLabel = "``$($topAllow -join '; ')`` _(explicit allow-list - clusters only install matching updates)_" - } - $md += "`n**Top-level fleet default:** $topLabel`n" - - $md += "`n| Row | weeksInCycle | daysOfWeek | rings | Effective allowedUpdateVersions |`n" - $md += "|----:|--------------|------------|-------|---------------------------------|`n" - $rowsMissingOverride = New-Object System.Collections.Generic.List[object] - $rowIdx = 0 - foreach ($r in @($sched.Schedule)) { - $rowIdx++ - $rowAllow = @() - if ($r.PSObject.Properties.Match('AllowedUpdateVersionsParsed').Count -and $r.AllowedUpdateVersionsParsed) { - $rowAllow = @($r.AllowedUpdateVersionsParsed) - } - if ($rowAllow.Count -gt 0) { - if ($rowAllow.Count -eq 1 -and $rowAllow[0] -eq 'Latest') { - $effLabel = "``Latest`` _(row override: no constraint)_" - } else { - $effLabel = "``$($rowAllow -join '; ')`` _(row override)_" - } - } else { - if ($topIsLatest) { - $effLabel = "``Latest`` _(inherits top-level - no constraint)_" - } else { - $effLabel = "``$($topAllow -join '; ')`` _(inherits top-level)_" - } - $rowsMissingOverride.Add([pscustomobject]@{ - Row = $rowIdx - WeeksInCycle = $r.weeksInCycle - DaysOfWeek = $r.daysOfWeek - Rings = $r.rings - }) - } - $md += ('| {0} | {1} | {2} | {3} | {4} |' -f $rowIdx, $r.weeksInCycle, $r.daysOfWeek, $r.rings, $effLabel) + "`n" - } - - if ($rowsMissingOverride.Count -gt 0) { - $md += "`n*$($rowsMissingOverride.Count) row(s) inherit the top-level allow-list. This is fine when you want each ring to install the latest Ready update as soon as it is available.*`n" - $md += "`n### Optional - pin a ring to a specific update in ``$schedulePath```n" - $md += "`n*Only needed if you want to PIN a ring to a specific update (e.g. keep Prod on the latest feature drop only - YY04 / YY10 - and skip cumulative updates between feature drops). This is NOT a fix for the cron-coverage or ring-diff sections above - those have their own 'Action required' blocks.* Add ``allowedUpdateVersions:`` to the row that covers the ring you want to pin. Use ``Get-AzLocalAvailableUpdates`` (or the Azure portal -> Azure Local -> Updates) to find valid update names / version strings.`n" - $md += "`n``````yaml`n" - $md += "- weeksInCycle: '*'`n" - $md += " daysOfWeek: 'Tue,Wed,Thu'`n" - $md += " rings: 'Prod'`n" - $md += " allowedUpdateVersions: 'Solution12.2604.1003.1005;Solution12.2610.1003.XX'`n" - $md += "```````n" - } else { - $md += "`n*Every schedule row has an explicit ``allowedUpdateVersions:`` override - no inheritance from the top-level default.*`n" - } - } - } - } - - $md += (Add-DetailTable -Heading '### Audit Detail - Cron coverage (Uncovered / Partial / Malformed first)' -DetailRows $cronRowsMd -EmptyText 'No tagged clusters found - nothing to audit.') - - # When there are no issues, still print the recommendation block at the bottom for - # operators who want the canonical list as a reference. When there are issues we - # already printed it at the top so skip the duplicate. - if (-not $hasIssues -and $reco) { - $md += "`n### Reference - Recommended schedule (copy into Step.6_apply-updates.yml)`n" - $md += "`n" + $reco + "`n" - } - - $summaryFile = Join-Path '$(reportsPath)' 'schedule-coverage-summary.md' - $md | Out-File -FilePath $summaryFile -Encoding utf8 - Write-Host "##vso[task.uploadsummary]$summaryFile" - Write-Host $md - displayName: 'Display Schedule Coverage Summary' - condition: always() - - task: PublishTestResults@2 displayName: 'Publish Schedule Coverage as Test Results' condition: always() 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 4eb4dc86..0fd95838 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 @@ -38,6 +38,13 @@ # # AUTHENTICATION: # Uses Workload Identity Federation (OIDC) - recommended for secretless authentication. +# +# v0.8.5 thin-YAML: the inline inlineScript body (5 ARG queries via +# Get-AzLocalFleetConnectivityStatus + per-scope severity classification + +# ~200-line inline JUnit StringBuilder + markdown summary render + 12 output +# variables) is now the Export-AzLocalFleetConnectivityStatusReport Public +# cmdlet. This yml is condensed to a few lines per step; the full workload +# (and its Pester tests) live in the module. # Pipeline display name carries the same Step.N - prefix as the filename so the # ADO pipelines list (sorts by `name:` field) lists pipelines in execution order. @@ -102,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.4' + GENERATED_AGAINST_MODULE_VERSION: '0.8.5' # 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). @@ -132,6 +139,9 @@ stages: - task: PowerShell@2 displayName: 'Install AzLocal.UpdateManagement from PSGallery' + # v0.8.5 thin-YAML: drift detection + banner + output variables are + # all produced by Add-AzLocalPipelineVersionBanner (Public cmdlet). + name: moduleVersion env: REQUIRED_MODULE_VERSION: $(REQUIRED_MODULE_VERSION) GENERATED_AGAINST_MODULE_VERSION: $(GENERATED_AGAINST_MODULE_VERSION) @@ -149,44 +159,27 @@ stages: } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force - $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on agent : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - Write-Host "" - if ($installed -lt $generated) { - Write-Host "##vso[task.logissue type=warning]AzLocal.UpdateManagement v$installed is OLDER than the version this pipeline YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Platform AzureDevOps -Update'." - } - if ($installed -gt $generated) { - Write-Host "##vso[task.logissue type=warning]Pipeline YAML was generated against AzLocal.UpdateManagement v$generated but the agent installed v$installed. Refresh via 'Copy-AzLocalPipelineExample -Platform AzureDevOps -Update'." - } - if ($latest -and ($latest -gt $installed)) { - Write-Host "##vso[task.logissue type=warning]AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed." - } - - # v0.8.4 - one-line version banner uploaded to the build run - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), - # the module version actually loaded on the agent, the PSGallery - # latest, and the REQUIRED_MODULE_VERSION pin status are visible - # at the top of every run summary without scrolling the agent - # log. Same data is also echoed to the console above. - $pin = if ($env:REQUIRED_MODULE_VERSION) { "pinned to v$($env:REQUIRED_MODULE_VERSION)" } else { 'latest (fix-forward)' } - $latestStr = if ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } - $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } - elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } - elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } - else { 'in sync' } - $bannerPath = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'module-version-banner.md' - "_Pipeline YAML v$generated | Module v$installed installed ($pin) | PSGallery latest $latestStr | $verdict_" | Out-File -FilePath $bannerPath -Encoding utf8 - Write-Host "##vso[task.uploadsummary]$bannerPath" + Add-AzLocalPipelineVersionBanner ` + -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION ` + -PinnedVersion $env:REQUIRED_MODULE_VERSION - task: AzureCLI@2 displayName: 'Collect Fleet Connectivity Data' + # v0.8.5 thin-YAML: the inline inlineScript body (5 ARG queries via + # Get-AzLocalFleetConnectivityStatus + per-scope severity classification + + # ~200-line inline JUnit StringBuilder + markdown summary render + 12 + # output variables, named uppercase CLUSTER_TOTAL etc.) has been + # condensed into the Public cmdlet + # Export-AzLocalFleetConnectivityStatusReport. The cmdlet writes + # $(reportsPath)/{7-csv,7-json,fleet-connectivity-status.xml}, emits the + # markdown summary via task.uploadsummary (using the shared + # New-AzLocalFleetConnectivityStatusSummary renderer), and sets the 12 + # lowercase output variables on the `collect` task. name: collect + env: + INPUT_SUBSCRIPTION_FILTER: ${{ parameters.subscriptionFilter }} + INSTALLED_MODULE_VERSION: $(moduleVersion.installed_module_version) + REPORTS_PATH: $(reportsPath) inputs: # Replace with your service connection name azureSubscription: 'AzureLocal-ServiceConnection' # Update with your service connection name @@ -196,235 +189,12 @@ stages: $ErrorActionPreference = 'Stop' az extension add --name resource-graph --yes Import-Module AzLocal.UpdateManagement -Force - - $outputDir = "$(reportsPath)" - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - - # Optional subscription filter - passed as SubscriptionId to the module - # cmdlet. Leave empty to query every subscription visible to the agent. - $invokeArgs = @{ ExportPath = $outputDir; PassThru = $true } - $subFilterRaw = "${{ parameters.subscriptionFilter }}" - if ($subFilterRaw) { - $subList = $subFilterRaw -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } - if ($subList -and $subList.Count -gt 0) { - $invokeArgs['SubscriptionId'] = $subList[0] - Write-Host "Subscription filter active (first sub): $($subList[0])" - } - } - if (-not $invokeArgs.ContainsKey('SubscriptionId')) { - Write-Host "Subscription filter empty - scanning all subscriptions visible to the federated identity." - } - - Write-Host "========================================" - Write-Host "Fleet Connectivity Status Collection" - Write-Host "========================================" - - # Collect all four connectivity scopes via the module cmdlet. - # Runs 5 ARG queries, processes results client-side, and exports - # 7 CSV + 7 JSON files to $outputDir. - $data = Get-AzLocalFleetConnectivityStatus @invokeArgs - $clusterRows = $data.ClusterRows - $arcSummary = $data.ArcSummary - $arcRows = $data.NonConnectedMachines - $nicRows = $data.NicIssues - $nicAllRows = $data.NicAll - $nicStats = $data.NicStats - $arbRows = $data.ArbRows - - # Severity classifiers - same as GH twin - function Convert-XmlEscape([string]$s) { - if ($null -eq $s) { return '' } - ($s.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"').Replace("'",''')) - } - function Get-ClusterSeverity { param($s) switch -Regex ($s) { '^(Connected|ConnectedRecently)$' { 'Pass'; break } '^(Disconnected|Expired|Offline|Error)$' { 'Critical'; break } default { 'Warning' } } } - function Get-ArcSeverity { param($s) switch -Regex ($s) { '^(Connected)$' { 'Pass'; break } '^(Disconnected|Expired|Offline|Error)$' { 'Critical'; break } default { 'Warning' } } } - function Get-NicSeverity { param($s) switch -Regex ($s) { '^(Connected|Up)$' { 'Pass'; break } '^(Disconnected|Down|Disabled)$' { 'Critical'; break } default { 'Warning' } } } - function Get-ArbSeverity { param($s) switch -Regex ($s) { '^(Running)$' { 'Pass'; break } '^(Offline|Failed)$' { 'Critical'; break } default { 'Warning' } } } - - $clusterFail = @($clusterRows | Where-Object { (Get-ClusterSeverity $_.ConnectivityStatus) -ne 'Pass' }) - $arcFail = @($arcRows | Where-Object { (Get-ArcSeverity $_.AgentStatus) -ne 'Pass' }) - $nicFail = @($nicRows | Where-Object { (Get-NicSeverity $_.NicStatus) -ne 'Pass' }) - $arbFail = @($arbRows | Where-Object { (Get-ArbSeverity $_.ArbStatus) -ne 'Pass' }) - $totalFailures = $clusterFail.Count + $arcFail.Count + $nicFail.Count + $arbFail.Count - $criticalCount = @($clusterFail | Where-Object { (Get-ClusterSeverity $_.ConnectivityStatus) -eq 'Critical' }).Count ` - + @($arcFail | Where-Object { (Get-ArcSeverity $_.AgentStatus) -eq 'Critical' }).Count ` - + @($nicFail | Where-Object { (Get-NicSeverity $_.NicStatus) -eq 'Critical' }).Count ` - + @($arbFail | Where-Object { (Get-ArbSeverity $_.ArbStatus) -eq 'Critical' }).Count - $warningCount = $totalFailures - $criticalCount - - $xmlPath = Join-Path $outputDir 'fleet-connectivity-status.xml' - $sb = New-Object System.Text.StringBuilder - [void]$sb.AppendLine('') - [void]$sb.AppendFormat('' + "`n", $totalFailures) - - function Add-Suite { - param([string]$Name, [array]$Failures, [scriptblock]$GetCase, [System.Text.StringBuilder]$Sb) - if (-not $Failures -or $Failures.Count -eq 0) { return } - [void]$Sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $Name), $Failures.Count) - foreach ($r in $Failures) { - $c = & $GetCase $r - [void]$Sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $c.Classname), (Convert-XmlEscape $c.Name)) - [void]$Sb.AppendLine(' ') - foreach ($k in $c.Properties.Keys) { - [void]$Sb.AppendFormat(' ' + "`n", $k, (Convert-XmlEscape ([string]$c.Properties[$k]))) - } - [void]$Sb.AppendLine(' ') - [void]$Sb.AppendFormat(' {2}' + "`n", (Convert-XmlEscape $c.Severity), (Convert-XmlEscape $c.Message), (Convert-XmlEscape $c.Body)) - [void]$Sb.AppendLine(' ') - } - [void]$Sb.AppendLine(' ') - } - - Add-Suite -Name 'Cluster Connectivity' -Failures $clusterFail -Sb $sb -GetCase { - param($r) - $sev = Get-ClusterSeverity $r.ConnectivityStatus - $portal = "https://portal.azure.com/#@/resource$($r.ClusterId)" - return [PSCustomObject]@{ - Classname = $r.ClusterName - Name = "Cluster '$($r.ClusterName)' :: ConnectivityStatus=$($r.ConnectivityStatus)" - Severity = $sev - Message = "Cluster connectivity is '$($r.ConnectivityStatus)' (cluster.status='$($r.ClusterStatus)')" - Body = "ResourceGroup: $($r.ResourceGroup)`nSubscriptionId: $($r.SubscriptionId)`nClusterPortalUrl: $portal" - Properties = @{ - ClusterName = $r.ClusterName - ClusterResourceId = $r.ClusterId - UpdateName = "ClusterConnectivity=$($r.ConnectivityStatus)" - Status = $sev - FailureReason = "Cluster connectivity '$($r.ConnectivityStatus)'" - Severity = $sev - ClusterPortalUrl = $portal - TargetResourceName = $r.ClusterName - TargetResourceType = 'microsoft.azurestackhci/clusters' - } - } - } - - Add-Suite -Name 'Arc Agent Connectivity' -Failures $arcFail -Sb $sb -GetCase { - param($r) - $sev = Get-ArcSeverity $r.AgentStatus - $portal = "https://portal.azure.com/#@/resource$($r.MachineId)" - return [PSCustomObject]@{ - Classname = $r.ClusterName - Name = "Machine '$($r.NodeName)' :: Status=$($r.AgentStatus)" - Severity = $sev - Message = "Machine '$($r.NodeName)' has Arc agent status '$($r.AgentStatus)' (last status change: $($r.LastStatusChange))" - Body = ("ClusterName: $($r.ClusterName)`n" + - "AgentStatus: $($r.AgentStatus)`n" + - "OS SKU: $($r.OsSku)`n" + - "OS Version: $($r.OsVersion)`n" + - "Cluster Version: $($r.ClusterVersion)`n" + - "AgentVersion: $($r.AgentVersion)`n" + - "Disconnected Since (lastStatusChange): $($r.LastStatusChange)`n" + - "ResourceGroup: $($r.ResourceGroup)`n" + - "MachineId: $($r.MachineId)") - Properties = @{ - ClusterName = $r.ClusterName - ClusterResourceId = $r.ClusterId - UpdateName = "ArcAgent=$($r.AgentStatus) [$($r.NodeName)]" - Status = $sev - FailureReason = "Arc agent '$($r.AgentStatus)' on machine '$($r.NodeName)'" - Severity = $sev - ClusterPortalUrl = $portal - TargetResourceName = $r.NodeName - TargetResourceType = 'microsoft.hybridcompute/machines' - } - } - } - - Add-Suite -Name 'Physical NIC Status' -Failures $nicFail -Sb $sb -GetCase { - param($r) - $sev = Get-NicSeverity $r.NicStatus - $portal = "https://portal.azure.com/#@/resource$($r.MachineId)" - return [PSCustomObject]@{ - Classname = $r.ClusterName - Name = "NIC '$($r.NicName)' on '$($r.NodeName)' :: NicStatus=$($r.NicStatus)" - Severity = $sev - Message = "Physical NIC '$($r.NicName)' on node '$($r.NodeName)' reports status '$($r.NicStatus)'" - Body = "ClusterName: $($r.ClusterName)`nInterface: $($r.InterfaceDescription)`nDriverVersion: $($r.DriverVersion)`nIp4Address: $($r.Ip4Address)`nMachineId: $($r.MachineId)" - Properties = @{ - ClusterName = $r.ClusterName - ClusterResourceId = '' - UpdateName = "PhysicalNic=$($r.NicStatus) [$($r.NodeName)/$($r.NicName)]" - Status = $sev - FailureReason = "Physical NIC '$($r.NicName)' status '$($r.NicStatus)' on node '$($r.NodeName)'" - Severity = $sev - ClusterPortalUrl = $portal - TargetResourceName = "$($r.NodeName)/$($r.NicName)" - TargetResourceType = 'microsoft.azurestackhci/edgedevices/nicDetails' - } - } - } - - Add-Suite -Name 'Azure Resource Bridge' -Failures $arbFail -Sb $sb -GetCase { - param($r) - $sev = Get-ArbSeverity $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 (multi- - # cluster-per-RG collapse). Use the first cluster for the - # portal URL and keep the full list in the body / properties. - # NOTE: 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 { @() } - $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 { '' } - return [PSCustomObject]@{ - Classname = if ($r.ClusterName) { $r.ClusterName } else { '(no cluster mapping)' } - Name = "ARB '$($r.ArbName)' :: ArbStatus=$($r.ArbStatus)" - Severity = $sev - Message = "Azure Resource Bridge '$($r.ArbName)' is '$($r.ArbStatus)' (days since lastModified=$($r.DaysSinceLastModified))$multiClusterNote" - Body = "ClusterName: $($r.ClusterName)`nArbId: $($r.ArbId)`nLastModified: $($r.LastModified)`nDaysSinceLastModified: $($r.DaysSinceLastModified)`nClusterPortalUrl: $clusterPortal" - Properties = @{ - ClusterName = if ($r.ClusterName) { $r.ClusterName } else { '' } - ClusterResourceId = if ($primaryClusterId) { $primaryClusterId } else { '' } - UpdateName = "ARB=$($r.ArbStatus) [$($r.ArbName)]" - Status = $sev - FailureReason = "ARB '$($r.ArbName)' status '$($r.ArbStatus)'$multiClusterNote" - Severity = $sev - ClusterPortalUrl = $clusterPortal - TargetResourceName = $r.ArbName - TargetResourceType = 'microsoft.resourceconnector/appliances' - } - } - } - - if ($totalFailures -eq 0) { - [void]$sb.AppendLine(' ') - [void]$sb.AppendLine(' ') - [void]$sb.AppendLine(' ') + $params = @{ + OutputDirectory = $env:REPORTS_PATH + InstalledModuleVersion = $env:INSTALLED_MODULE_VERSION } - [void]$sb.AppendLine('') - $sb.ToString() | Out-File -FilePath $xmlPath -Encoding utf8 - - # Pipeline output variables consumed by the markdown summary task below. - # Arc totals come from the status-summary histogram so the markdown - # table can show healthy + . - $arcTotalMachines = ($arcSummary | Measure-Object -Property Count -Sum).Sum - if ($null -eq $arcTotalMachines) { $arcTotalMachines = 0 } - Write-Host "##vso[task.setvariable variable=clusterTotal;isOutput=true]$($clusterRows.Count)" - Write-Host "##vso[task.setvariable variable=clusterFail;isOutput=true]$($clusterFail.Count)" - Write-Host "##vso[task.setvariable variable=arcTotal;isOutput=true]$arcTotalMachines" - Write-Host "##vso[task.setvariable variable=arcFail;isOutput=true]$($arcRows.Count)" - Write-Host "##vso[task.setvariable variable=nicTotal;isOutput=true]$($nicRows.Count)" - Write-Host "##vso[task.setvariable variable=nicFail;isOutput=true]$($nicFail.Count)" - Write-Host "##vso[task.setvariable variable=nicAllTotal;isOutput=true]$($nicAllRows.Count)" - Write-Host "##vso[task.setvariable variable=arbTotal;isOutput=true]$($arbRows.Count)" - Write-Host "##vso[task.setvariable variable=arbFail;isOutput=true]$($arbFail.Count)" - Write-Host "##vso[task.setvariable variable=totalFailures;isOutput=true]$totalFailures" - Write-Host "##vso[task.setvariable variable=criticalCount;isOutput=true]$criticalCount" - Write-Host "##vso[task.setvariable variable=warningCount;isOutput=true]$warningCount" - - Write-Host "" - Write-Host "Fleet Connectivity Collection complete:" - Write-Host " Clusters : $($clusterRows.Count) total ($($clusterFail.Count) failing)" - Write-Host " Arc agents: $arcTotalMachines machine(s) across $($arcSummary.Count) distinct status value(s); $($arcRows.Count) NOT 'Connected'" - Write-Host " Physical NIC issues: $($nicRows.Count) (Disconnected with non-APIPA IP - 'Up' adapters and adapters without a real IP are filtered as noise)" - Write-Host " NIC inventory (full): $($nicAllRows.Count) total NICs across $($nicStats.Count) NicType+NicStatus groups" - Write-Host " ARB appliances: $($arbRows.Count) total ($($arbFail.Count) failing)" - Write-Host " TOTAL FAILURES: $totalFailures (Critical=$criticalCount, Warning=$warningCount)" + if ($env:INPUT_SUBSCRIPTION_FILTER) { $params['SubscriptionFilter'] = $env:INPUT_SUBSCRIPTION_FILTER } + Export-AzLocalFleetConnectivityStatusReport @params - pwsh: | $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss') @@ -442,54 +212,9 @@ stages: ArtifactName: 'azlocal-step.4-fleet-connectivity-status-report_$(stamp.artifactStamp)' publishLocation: 'Container' - # Create the markdown summary FIRST (before PublishTestResults below) so the - # uploaded summary appears at the top of the pipeline run extensions view. - # NOTE: step outputs from `collect.*` are surfaced via step-level `env:` (NOT - # `$(collect.*)` macro substitutions inside the `pwsh:` body). ADO does not enforce - # GitHub's 21,000-char expression cap, but we keep the env-var pattern in lock-step - # with the GitHub Actions copy so future authors don't accidentally re-inline a - # large-and-growing summary block that would breach the GH cap when ported. - - pwsh: | - # Build the markdown step summary by calling the module renderer - # function. All long-form content (markdown tables, the - # 'How to interpret + act on a non-zero reconciliation' subsection, - # KQL examples, etc.) lives in the function body, NOT inline here. - # The lock-step env: pattern below mirrors the GitHub Actions twin - # for ease of management (single mental model across pipelines). - $counts = @{ - ClusterTotal = $env:CLUSTER_TOTAL - ClusterFail = $env:CLUSTER_FAIL - ArcTotal = $env:ARC_TOTAL - ArcFail = $env:ARC_FAIL - NicTotal = $env:NIC_TOTAL - NicFail = $env:NIC_FAIL - ArbTotal = $env:ARB_TOTAL - ArbFail = $env:ARB_FAIL - TotalFailures = $env:TOTAL_FAILURES - CriticalCount = $env:CRITICAL_COUNT - WarningCount = $env:WARNING_COUNT - } - $mdPath = Join-Path -Path '$(reportsPath)' -ChildPath 'fleet-connectivity-summary.md' - New-AzLocalFleetConnectivityStatusSummary -ReportsPath '$(reportsPath)' -Counts $counts -OutputPath $mdPath | Out-Null - Write-Host "##vso[task.uploadsummary]$mdPath" - displayName: 'Create Fleet Connectivity Summary' - condition: always() - env: - CLUSTER_TOTAL: $(collect.clusterTotal) - CLUSTER_FAIL: $(collect.clusterFail) - ARC_TOTAL: $(collect.arcTotal) - ARC_FAIL: $(collect.arcFail) - NIC_TOTAL: $(collect.nicTotal) - NIC_FAIL: $(collect.nicFail) - ARB_TOTAL: $(collect.arbTotal) - ARB_FAIL: $(collect.arbFail) - TOTAL_FAILURES: $(collect.totalFailures) - CRITICAL_COUNT: $(collect.criticalCount) - WARNING_COUNT: $(collect.warningCount) - - # Publish JUnit test results AFTER the markdown summary so the summary - # appears at the top of the pipeline run extensions view. Mirrors the - # GitHub Actions twin (dorny/test-reporter@v3 there; PublishTestResults@2 here). + # Publish JUnit test results AFTER the cmdlet (which writes the markdown + # summary first). Mirrors the GitHub Actions twin + # (dorny/test-reporter@v3 there; PublishTestResults@2 here). - task: PublishTestResults@2 displayName: 'Publish Fleet Connectivity JUnit Diagnostics' condition: always() @@ -594,4 +319,3 @@ stages: testRunTitle: 'ITSM Tickets (Step.4) - $(Build.BuildNumber)' mergeTestResults: false failTaskOnFailedTests: false - 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 5eea2ddd..b84727b3 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 @@ -18,11 +18,17 @@ # ready will simply no-op there too. # # If you want a hard pass/fail signal for a chained gate, read the output variables -# (NOT_READY, CRITICAL_FAILURES) from a downstream pipeline via a pipeline resource and -# apply your own tolerance threshold there. +# (not_ready, critical_failures - lowercase since v0.8.5) from a downstream pipeline via +# a pipeline resource and apply your own tolerance threshold there. # # Remediation of Critical health failures is out of scope for this module - see the module # README "Assess Readiness and Health BEFORE Applying Updates" section for pointers. +# +# v0.8.5 thin-YAML: the inline run body (inventory + scope-param construction + +# Get-AzLocalClusterUpdateReadiness + Test-AzLocalClusterHealth + combined JUnit XML +# merge + 8-section markdown summary + output variables) is now the +# Export-AzLocalClusterUpdateReadinessReport Public cmdlet. This yml is condensed +# to a few lines per step; the full workload (and its Pester tests) live in the module. # BEGIN-AZLOCAL-CUSTOMIZE:schedule-triggers # Content between BEGIN/END markers is preserved by Update-AzLocalPipelineExample @@ -63,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.4' + GENERATED_AGAINST_MODULE_VERSION: '0.8.5' # 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). @@ -85,6 +91,9 @@ stages: - task: PowerShell@2 displayName: 'Install AzLocal.UpdateManagement from PSGallery' + # v0.8.5 thin-YAML: drift detection + banner + output variables are + # all produced by Add-AzLocalPipelineVersionBanner (Public cmdlet). + name: moduleVersion env: REQUIRED_MODULE_VERSION: $(REQUIRED_MODULE_VERSION) GENERATED_AGAINST_MODULE_VERSION: $(GENERATED_AGAINST_MODULE_VERSION) @@ -102,309 +111,44 @@ stages: } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force - $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on agent : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - Write-Host "" - Get-Module AzLocal.UpdateManagement | Format-List Name, Version, Path - # Drift signal 0: the module installed at runtime is OLDER than the version this YAML was generated against. Cmdlets, parameters, or output schemas referenced by this YAML may not exist in the installed module - emitted as a warning because this is the most likely cause of a hard runtime failure later in the job. - if ($installed -lt $generated) { - Write-Host "##vso[task.logissue type=warning]AzLocal.UpdateManagement v$installed is OLDER than the version this pipeline YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Platform AzureDevOps -Update'." - } - if ($installed -gt $generated) { - $msg = "Pipeline YAML was generated against AzLocal.UpdateManagement v$generated but the agent installed v$installed. Pipeline steps may have been improved in later releases - to refresh, re-run 'Copy-AzLocalPipelineExample -Destination .\pipelines -Platform AzureDevOps -Update' (you will be prompted per file; add -Confirm:`$false to bypass). Pipeline YAMLs are under git so 'git diff' shows exactly what changed before commit." - Write-Host "##vso[task.logissue type=warning]$msg" - } - if ($latest -and ($latest -gt $installed)) { - $msg = "AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed. Review the module CHANGELOG before bumping REQUIRED_MODULE_VERSION (or clear the pin to install the latest automatically)." - Write-Host "##vso[task.logissue type=warning]$msg" - } - - # v0.8.4 - one-line version banner uploaded to the build run - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), - # the module version actually loaded on the agent, the PSGallery - # latest, and the REQUIRED_MODULE_VERSION pin status are visible - # at the top of every run summary without scrolling the agent - # log. Same data is also echoed to the console above. - $pin = if ($env:REQUIRED_MODULE_VERSION) { "pinned to v$($env:REQUIRED_MODULE_VERSION)" } else { 'latest (fix-forward)' } - $latestStr = if ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } - $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } - elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } - elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } - else { 'in sync' } - $bannerPath = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'module-version-banner.md' - "_Pipeline YAML v$generated | Module v$installed installed ($pin) | PSGallery latest $latestStr | $verdict_" | Out-File -FilePath $bannerPath -Encoding utf8 - Write-Host "##vso[task.uploadsummary]$bannerPath" + Add-AzLocalPipelineVersionBanner ` + -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION ` + -PinnedVersion $env:REQUIRED_MODULE_VERSION - task: AzureCLI@2 displayName: 'Run readiness + blocking health checks' + # v0.8.5 thin-YAML: the inline run block (inventory + scope param + # construction + Get-AzLocalClusterUpdateReadiness CSV/XML + + # Test-AzLocalClusterHealth -BlockingOnly CSV/XML + combined JUnit + # merge + 8-section markdown step summary + the two output + # variables (NOT_READY / CRITICAL_FAILURES, now lowercase + # not_ready / critical_failures) has been condensed into the + # Public cmdlet Export-AzLocalClusterUpdateReadinessReport. The + # cmdlet writes $(artifactsPath)/{readiness,health-blocking,assess-readiness}.{csv,xml}, + # emits the markdown summary via task.uploadsummary, and sets the + # two output variables. + name: gate + env: + INPUT_SCOPE: ${{ parameters.scope }} + INPUT_UPDATE_RING: ${{ parameters.updateRing }} + INSTALLED_MODULE_VERSION: $(moduleVersion.installed_module_version) + ARTIFACTS_PATH: $(artifactsPath) inputs: # Replace with your service connection name azureSubscription: 'AzureLocal-ServiceConnection' # Update with your service connection name scriptType: 'pscore' scriptLocation: 'inlineScript' inlineScript: | + $ErrorActionPreference = 'Stop' az extension add --name resource-graph --yes - Import-Module AzLocal.UpdateManagement -Force - - $scope = "${{ parameters.scope }}" - $updateRing = "${{ parameters.updateRing }}" - - $outputDir = "$(artifactsPath)" - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - - $readinessCsv = Join-Path $outputDir 'readiness.csv' - $readinessXml = Join-Path $outputDir 'readiness.xml' - $healthCsv = Join-Path $outputDir 'health-blocking.csv' - $healthXml = Join-Path $outputDir 'health-blocking.xml' - - # Always fetch inventory so we can build a ResourceId -> UpdateRing - # map for the per-ring pivot in the markdown summary (cheap ARG round-trip). - $inventory = Get-AzLocalClusterInventory -PassThru - $ringByResourceId = @{} - if ($inventory) { - foreach ($inv in $inventory) { - $ringValue = if ($inv.UpdateRing) { [string]$inv.UpdateRing } else { '(no ring tag)' } - $ringByResourceId[$inv.ResourceId] = $ringValue - } - } - - $scopeParams = @{} - if ($scope -eq 'by-update-ring' -and $updateRing) { - $scopeParams['ScopeByUpdateRingTag'] = $true - $scopeParams['UpdateRingValue'] = $updateRing - Write-Host "Scope: UpdateRing = $updateRing" - } else { - Write-Host "Scope: all clusters (via inventory)" - if (-not $inventory -or $inventory.Count -eq 0) { - Write-Warning 'No clusters found in inventory.' - Write-Host "##vso[task.setvariable variable=NOT_READY;isOutput=true]0" - Write-Host "##vso[task.setvariable variable=CRITICAL_FAILURES;isOutput=true]0" - exit 0 - } - $scopeParams['ClusterResourceIds'] = @($inventory | Select-Object -ExpandProperty ResourceId) - } - - Write-Host '' - Write-Host '========================================' -ForegroundColor Cyan - Write-Host 'Step 1: Readiness (Get-AzLocalClusterUpdateReadiness)' -ForegroundColor Cyan - Write-Host '========================================' -ForegroundColor Cyan - - $readiness = Get-AzLocalClusterUpdateReadiness @scopeParams ` - -ExportPath $readinessCsv ` - -PassThru - - $null = Get-AzLocalClusterUpdateReadiness @scopeParams ` - -ExportPath $readinessXml - - # v0.7.99: 3-bucket model matches Get-AzLocalClusterUpdateReadiness Summary. - # UpToDate clusters are no longer rolled into NotReady - they're now a distinct bucket. - $readyForUpdate = @($readiness | Where-Object { $_.ReadyForUpdate -eq $true }).Count - $upToDate = @($readiness | Where-Object { - $_.ReadyForUpdate -ne $true -and - $_.UpdateState -in @('UpToDate', 'AppliedSuccessfully') -and - [string]::IsNullOrEmpty([string]$_.AllAvailableUpdates) - }).Count - $total = @($readiness).Count - $notReady = $total - $readyForUpdate - $upToDate - - Write-Host '' - Write-Host "Total clusters in scope: $total" - Write-Host "Ready for update : $readyForUpdate" - Write-Host "Up to date : $upToDate" - Write-Host "Not ready for update : $notReady" - - Write-Host '' - Write-Host '========================================' -ForegroundColor Cyan - Write-Host 'Step 2: Blocking health (Test-AzLocalClusterHealth -BlockingOnly)' -ForegroundColor Cyan - Write-Host '========================================' -ForegroundColor Cyan - - $health = Test-AzLocalClusterHealth @scopeParams ` - -BlockingOnly ` - -ExportPath $healthCsv ` - -PassThru - - $null = Test-AzLocalClusterHealth @scopeParams ` - -BlockingOnly ` - -ExportPath $healthXml - - # Merge readiness + blocking-health JUnit XMLs into a single combined - # report (assess-readiness.xml) so operators get one Tests-tab entry - # instead of two. The individual XMLs still publish below as - # [JUnit Debug] entries for parity / drill-down. - $combinedXml = Join-Path $outputDir 'assess-readiness.xml' - try { - $readinessDoc = [xml](Get-Content -LiteralPath $readinessXml -Raw) - $healthDoc = [xml](Get-Content -LiteralPath $healthXml -Raw) - $combinedDoc = New-Object System.Xml.XmlDocument - $declaration = $combinedDoc.CreateXmlDeclaration('1.0', 'utf-8', $null) - $combinedDoc.AppendChild($declaration) | Out-Null - $rootElement = $combinedDoc.CreateElement('testsuites') - $rootElement.SetAttribute('name', 'Update Readiness Assessment') - $combinedDoc.AppendChild($rootElement) | Out-Null - foreach ($srcDoc in @($readinessDoc, $healthDoc)) { - $suites = if ($srcDoc.DocumentElement.LocalName -eq 'testsuites') { - $srcDoc.DocumentElement.SelectNodes('testsuite') - } else { - ,$srcDoc.DocumentElement - } - foreach ($suite in $suites) { - $imported = $combinedDoc.ImportNode($suite, $true) - $rootElement.AppendChild($imported) | Out-Null - } - } - $combinedDoc.Save($combinedXml) - Write-Host "Combined JUnit report: $combinedXml" - } - catch { - Write-Warning "Failed to build combined JUnit report: $($_.Exception.Message)" - } - - # Test-AzLocalClusterHealth -PassThru returns one row per cluster - # (shape: ClusterName, HealthState, Passed, CriticalCount, WarningCount, Failures) - # NOT a flat list of finding rows. Aggregate from CriticalCount / Failures rather - # than filtering on a non-existent Severity property (which silently returned 0). - $criticalSum = ($health | Measure-Object -Property CriticalCount -Sum).Sum - $critical = if ($criticalSum) { [int]$criticalSum } else { 0 } - $clustersWithCritical = @($health | Where-Object { [int]$_.CriticalCount -gt 0 }).Count - - Write-Host '' - Write-Host "Critical findings : $critical" - Write-Host "Clusters with Critical : $clustersWithCritical" - - Write-Host "##vso[task.setvariable variable=NOT_READY;isOutput=true]$notReady" - Write-Host "##vso[task.setvariable variable=CRITICAL_FAILURES;isOutput=true]$clustersWithCritical" - - # Azure DevOps summary markdown - audit-order layout: - # 1. Header tile (one-line status + scope) - # 2. Action banner (Action required / All clear) - # 3. Summary counts - # 4. Not-Ready cluster table (blocking findings first) - # 5. Critical-health cluster table (blocking findings, second priority) - # 6. Per-UpdateRing pivot (only shown when >1 ring in scope) - # 7. All-clusters detail table - # 8. Cross-links to upstream/downstream pipelines - $summaryPath = Join-Path $outputDir 'summary.md' - $summaryLines = New-Object 'System.Collections.Generic.List[string]' - $summaryLines.Add('# Update Readiness Assessment') - $summaryLines.Add('') - - # 1. Header tile (ASCII safe - no emoji) - $scopeLabel = $scope - if ($updateRing) { $scopeLabel = "$scope (UpdateRing = $updateRing)" } - $statusWord = if ($notReady -gt 0 -or $clustersWithCritical -gt 0) { 'ATTENTION' } else { 'OK' } - $summaryLines.Add("**[$statusWord]** $total cluster(s) assessed | $readyForUpdate Ready for Update | $upToDate Up to Date | $notReady Not Ready for Update | $clustersWithCritical with Critical health failures | Scope: $scopeLabel") - $summaryLines.Add('') - - # 2. Action banner - if ($notReady -gt 0 -or $clustersWithCritical -gt 0) { - $summaryLines.Add("**Action required**: $notReady cluster(s) not ready and/or $clustersWithCritical cluster(s) with Critical health failures. Review the **Not-Ready** and **Critical-health** sections below first; the CSV artifacts in azlocal-step.5-readiness-assessment-report_* carry the full per-finding detail. Remediate (hardware vendor SBE / firmware / cluster health) before or alongside the next apply-updates run. **The healthy clusters are safe to proceed** - Step.6_apply-updates.yml is per-cluster scoped.") - } else { - $summaryLines.Add('**All clear**: every cluster in scope is ready for update. Safe to proceed with apply-updates for this ring.') - } - $summaryLines.Add('') - - # 3. Summary counts - $summaryLines.Add('## Summary counts') - $summaryLines.Add('') - $summaryLines.Add('| Metric | Count |') - $summaryLines.Add('|--------|-------|') - $summaryLines.Add("| Total clusters in scope | $total |") - $summaryLines.Add("| Ready for update | $readyForUpdate |") - $summaryLines.Add("| Up to date | $upToDate |") - $summaryLines.Add("| Not ready for update | $notReady |") - $summaryLines.Add("| Clusters with Critical health failures | $clustersWithCritical |") - $summaryLines.Add("| Total Critical findings | $critical |") - $summaryLines.Add('') - - # 4. Not-Ready cluster table (blocking findings first) - $notReadyRows = @($readiness | Where-Object { $_.ReadyForUpdate -ne $true }) - if ($notReadyRows.Count -gt 0) { - $summaryLines.Add('## Not-Ready clusters (review first)') - $summaryLines.Add('') - $summaryLines.Add('| Cluster | UpdateRing | Current version | Update state | Health | Blocking reasons |') - $summaryLines.Add('|---------|------------|-----------------|--------------|--------|------------------|') - foreach ($r in ($notReadyRows | Sort-Object @{Expression={ if ($ringByResourceId.ContainsKey($_.ClusterResourceId)) { $ringByResourceId[$_.ClusterResourceId] } else { 'zzz' } }}, ClusterName)) { - $ring = if ($ringByResourceId.ContainsKey($r.ClusterResourceId)) { $ringByResourceId[$r.ClusterResourceId] } else { '-' } - $cv = if ($r.CurrentVersion) { $r.CurrentVersion } else { '-' } - $br = if ($r.BlockingReasons) { $r.BlockingReasons } else { '-' } - $summaryLines.Add("| $($r.ClusterName) | $ring | $cv | $($r.UpdateState) | $($r.HealthState) | $br |") - } - $summaryLines.Add('') - } - - # 5. Critical-health cluster table - $criticalRows = @($health | Where-Object { [int]$_.CriticalCount -gt 0 }) - if ($criticalRows.Count -gt 0) { - $summaryLines.Add('## Critical-health clusters') - $summaryLines.Add('') - $summaryLines.Add('_Cross-link: see **Step.4_fleet-connectivity-status** for connectivity-class failures and **Step.9_fleet-health-status** for the broader Critical/Warning catalog._') - $summaryLines.Add('') - $summaryLines.Add('| Cluster | UpdateRing | Health state | Critical | Warning |') - $summaryLines.Add('|---------|------------|--------------|----------|---------|') - foreach ($r in ($criticalRows | Sort-Object @{Expression={[int]$_.CriticalCount}; Descending=$true}, ClusterName)) { - $invMatch = $inventory | Where-Object { $_.ClusterName -eq $r.ClusterName } | Select-Object -First 1 - $ring = if ($invMatch -and $invMatch.UpdateRing) { $invMatch.UpdateRing } else { '-' } - $summaryLines.Add("| $($r.ClusterName) | $ring | $($r.HealthState) | $($r.CriticalCount) | $($r.WarningCount) |") - } - $summaryLines.Add('') - } - - # 6. Per-UpdateRing pivot (only shown when more than one ring in scope) - $ringGroups = $readiness | Group-Object @{Expression={ if ($ringByResourceId.ContainsKey($_.ClusterResourceId)) { $ringByResourceId[$_.ClusterResourceId] } else { '(no ring tag)' } }} | Sort-Object Name - if (@($ringGroups).Count -gt 1) { - $summaryLines.Add('## Per UpdateRing breakdown') - $summaryLines.Add('') - $summaryLines.Add('| UpdateRing | Total | Ready for Update | Up to Date | Not Ready for Update |') - $summaryLines.Add('|------------|-------|------------------|------------|----------------------|') - foreach ($g in $ringGroups) { - $gReady = @($g.Group | Where-Object { $_.ReadyForUpdate -eq $true }).Count - $gUpToDate = @($g.Group | Where-Object { - $_.ReadyForUpdate -ne $true -and - $_.UpdateState -in @('UpToDate', 'AppliedSuccessfully') -and - [string]::IsNullOrEmpty([string]$_.AllAvailableUpdates) - }).Count - $gNotReady = $g.Count - $gReady - $gUpToDate - $summaryLines.Add("| $($g.Name) | $($g.Count) | $gReady | $gUpToDate | $gNotReady |") - } - $summaryLines.Add('') + $params = @{ + Scope = if ($env:INPUT_SCOPE) { $env:INPUT_SCOPE } else { 'all' } + OutputDirectory = $env:ARTIFACTS_PATH + InstalledModuleVersion = $env:INSTALLED_MODULE_VERSION } - - # 7. All-clusters detail table (sorted by UpdateRing then ClusterName) - if ($total -gt 0) { - $summaryLines.Add('## All clusters detail') - $summaryLines.Add('') - $summaryLines.Add('| Cluster | UpdateRing | Current version | Current SBE version | Update state | Health | Ready | Recommended update |') - $summaryLines.Add('|---------|------------|-----------------|---------------------|--------------|--------|-------|--------------------|') - foreach ($r in ($readiness | Sort-Object @{Expression={ if ($ringByResourceId.ContainsKey($_.ClusterResourceId)) { $ringByResourceId[$_.ClusterResourceId] } else { 'zzz' } }}, ClusterName)) { - $ring = if ($ringByResourceId.ContainsKey($r.ClusterResourceId)) { $ringByResourceId[$r.ClusterResourceId] } else { '-' } - $cv = if ($r.CurrentVersion) { $r.CurrentVersion } else { '-' } - $csv = if ($r.PSObject.Properties['CurrentSbeVersion'] -and $r.CurrentSbeVersion) { $r.CurrentSbeVersion } else { '-' } - $ru = if ($r.RecommendedUpdate) { $r.RecommendedUpdate } else { '-' } - $summaryLines.Add("| $($r.ClusterName) | $ring | $cv | $csv | $($r.UpdateState) | $($r.HealthState) | $($r.ReadyForUpdate) | $ru |") - } - $summaryLines.Add('') - } - - # 8. Cross-links to other pipelines - $summaryLines.Add('## Cross-link to other pipelines') - $summaryLines.Add('') - $summaryLines.Add('- **Step.4_fleet-connectivity-status** - root-cause Disconnected / Offline / partial-connectivity findings on the Not-Ready and Critical-health rows above.') - $summaryLines.Add('- **Step.6_apply-updates** - apply updates to the Ready clusters in this ring (manual run, or wait for the scheduled trigger).') - $summaryLines.Add('- **Step.7_monitor-updates** - tail in-flight runs once Step.6 has started (auto-trigger on Step.6 completion, or manual).') - $summaryLines.Add('- **Step.9_fleet-health-status** - broader Critical / Warning health catalog across the whole fleet (not just blocking-only).') - $summaryLines.Add('') - $summaryLines.Add('_Note: the **Update Readiness Assessment** entry in the Tests tab is the merged combined view; the [JUnit Debug] entries are diagnostic mirrors for CI/test tooling._') - - ($summaryLines -join [Environment]::NewLine) | Out-File -FilePath $summaryPath -Encoding utf8 - Write-Host "##vso[task.uploadsummary]$summaryPath" - name: gate + if ($env:INPUT_UPDATE_RING) { $params['UpdateRing'] = $env:INPUT_UPDATE_RING } + Export-AzLocalClusterUpdateReadinessReport @params - task: PublishTestResults@2 displayName: 'Publish Update Readiness Assessment (combined)' @@ -451,4 +195,3 @@ stages: inputs: targetPath: '$(artifactsPath)' artifact: 'azlocal-step.5-readiness-assessment-report_$(stamp.artifactStamp)' - 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 d2581bda..5a377587 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 @@ -49,10 +49,26 @@ parameters: # or '***' (three stars - deliberate, not a typo) to match every cluster that HAS the UpdateRing tag set. # The 'values:' enum was removed in to allow these new forms; type=string # still gives users a free-text editor in the ADO run dialog. - displayName: "UpdateRing tag value (single 'Wave1', list 'Prod;Ring2', or '***' for ALL)" + # v0.8.5: ignored when useScheduleFile=true (the resolver reads the schedule file instead). + displayName: "UpdateRing tag value (single 'Wave1', list 'Prod;Ring2', or '***' for ALL). Ignored when useScheduleFile=true." type: string default: 'Wave1' - + + # v0.8.5 - manual override that runs the ring/AllowedUpdateVersions resolver against + # apply-updates-schedule.yml exactly as a scheduled firing would. 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 via the + # resolveForDateUtc parameter below. + - name: useScheduleFile + displayName: 'Resolve UpdateRing & AllowedUpdateVersions from apply-updates-schedule.yml (as a scheduled run would). Overrides updateRing.' + type: boolean + default: false + + - name: resolveForDateUtc + displayName: 'Only used when useScheduleFile=true. Date (UTC, YYYY-MM-DD) to resolve the schedule for. Empty = today UTC. Useful for previewing a future cycleWeek/dayOfWeek.' + type: string + default: '' + - name: updateName displayName: 'Specific update version (leave empty for latest ready update)' type: string @@ -98,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.4' + value: '0.8.5' # 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). @@ -127,11 +143,11 @@ stages: jobs: - job: ReadinessCheck displayName: 'Assess Cluster Readiness' - + steps: - checkout: self displayName: 'Checkout Repository' - + - task: AzureCLI@2 displayName: 'Install Resource Graph Extension' inputs: @@ -140,7 +156,8 @@ stages: scriptLocation: 'inlineScript' inlineScript: | az extension add --name resource-graph --yes - + + # v0.8.5 thin-YAML - install + drift detection + version banner via shared helper. - task: PowerShell@2 displayName: 'Install AzLocal.UpdateManagement from PSGallery' env: @@ -152,110 +169,36 @@ stages: script: | $ErrorActionPreference = 'Stop' $installArgs = @{ Name = 'AzLocal.UpdateManagement'; Scope = 'CurrentUser'; Force = $true; AllowClobber = $true } - if ($env:REQUIRED_MODULE_VERSION) { - $installArgs.RequiredVersion = $env:REQUIRED_MODULE_VERSION - Write-Host "REQUIRED_MODULE_VERSION is set - pinning install to v$($env:REQUIRED_MODULE_VERSION)." - } else { - Write-Host "REQUIRED_MODULE_VERSION is empty - installing the latest version from PSGallery (default fix-forward behaviour)." - } + if ($env:REQUIRED_MODULE_VERSION) { $installArgs.RequiredVersion = $env:REQUIRED_MODULE_VERSION } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force - $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on agent : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - Write-Host "" - Get-Module AzLocal.UpdateManagement | Format-List Name, Version, Path - # Drift signal 0: the module installed at runtime is OLDER than the version this YAML was generated against. Cmdlets, parameters, or output schemas referenced by this YAML may not exist in the installed module - emitted as a warning because this is the most likely cause of a hard runtime failure later in the job. - if ($installed -lt $generated) { - Write-Host "##vso[task.logissue type=warning]AzLocal.UpdateManagement v$installed is OLDER than the version this pipeline YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Platform AzureDevOps -Update'." - } - if ($installed -gt $generated) { - $msg = "Pipeline YAML was generated against AzLocal.UpdateManagement v$generated but the agent installed v$installed. Pipeline steps may have been improved in later releases - to refresh, re-run 'Copy-AzLocalPipelineExample -Destination .\pipelines -Platform AzureDevOps -Update' (you will be prompted per file; add -Confirm:`$false to bypass). Pipeline YAMLs are under git so 'git diff' shows exactly what changed before commit." - Write-Host "##vso[task.logissue type=warning]$msg" - } - if ($latest -and ($latest -gt $installed)) { - $msg = "AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed. Review the module CHANGELOG before bumping REQUIRED_MODULE_VERSION (or clear the pin to install the latest automatically)." - Write-Host "##vso[task.logissue type=warning]$msg" - } + Add-AzLocalPipelineVersionBanner -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION - # v0.8.4 - one-line version banner uploaded to the build run - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), - # the module version actually loaded on the agent, the PSGallery - # latest, and the REQUIRED_MODULE_VERSION pin status are visible - # at the top of every run summary without scrolling the agent - # log. Same data is also echoed to the console above. - $pin = if ($env:REQUIRED_MODULE_VERSION) { "pinned to v$($env:REQUIRED_MODULE_VERSION)" } else { 'latest (fix-forward)' } - $latestStr = if ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } - $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } - elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } - elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } - else { 'in sync' } - $bannerPath = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'module-version-banner.md' - "_Pipeline YAML v$generated | Module v$installed installed ($pin) | PSGallery latest $latestStr | $verdict_" | Out-File -FilePath $bannerPath -Encoding utf8 - Write-Host "##vso[task.uploadsummary]$bannerPath" - - # Resolve the UpdateRing for THIS firing. - # - $(Build.Reason) in 'Manual','IndividualCI','BatchedCI','PullRequest','Manual' - # -> use the operator's parameter input verbatim (back-compat). - # - $(Build.Reason) = 'Schedule' - # -> read apply-updates-schedule.yml and run the resolver. - # The resolved value is exported as a task output variable - # (resolveRing.RESOLVED_UPDATE_RING) for consumption later in this job - # and via stageDependencies in the ApplyUpdates stage. + # v0.8.5 thin-YAML - Resolve UpdateRing (+ AllowedUpdateVersions) from + # schedule file (or pass manual input through verbatim). Emits two task + # output variables (resolveRing.RESOLVED_UPDATE_RING / + # resolveRing.RESOLVED_ALLOWED_UPDATE_VERSIONS) for stageDependencies binding. - task: PowerShell@2 displayName: 'Resolve UpdateRing from schedule' name: resolveRing env: MANUAL_UPDATE_RING: ${{ parameters.updateRing }} + USE_SCHEDULE_FILE: ${{ parameters.useScheduleFile }} + RESOLVE_FOR_DATE_UTC: ${{ parameters.resolveForDateUtc }} APPLY_UPDATES_SCHEDULE_PATH: $(APPLY_UPDATES_SCHEDULE_PATH) BUILD_REASON: $(Build.Reason) inputs: targetType: 'inline' pwsh: true script: | - $ErrorActionPreference = 'Stop' Import-Module AzLocal.UpdateManagement -Force - $resolved = '' - $resolvedAllow = '' - if ($env:BUILD_REASON -ne 'Schedule') { - $resolved = $env:MANUAL_UPDATE_RING - Write-Host "Build.Reason='$($env:BUILD_REASON)' - using manual parameter UpdateRing='$resolved' (schedule file ignored). AllowedUpdateVersions is not applied for manual runs - the cmdlet's default 'latest Ready update' (or -updateName parameter) is used." - } - else { - $schedulePath = $env:APPLY_UPDATES_SCHEDULE_PATH - if (-not $schedulePath) { $schedulePath = './apply-updates-schedule.yml' } - Write-Host "Build.Reason='Schedule' - resolving UpdateRing from schedule file '$schedulePath' (UTC now)." - if (-not (Test-Path -LiteralPath $schedulePath)) { - throw "apply-updates-schedule.yml not found at '$schedulePath'. The apply-updates pipeline was triggered by a scheduled run but no schedule file is present. Generate a STRAWMAN from your fleet via: New-AzLocalApplyUpdatesScheduleConfig -OutputPath '$schedulePath' (then review and UNCOMMENT at least one row before re-running). Override APPLY_UPDATES_SCHEDULE_PATH at queue time if you keep the schedule elsewhere." - } - $cfg = Get-AzLocalApplyUpdatesScheduleConfig -Path $schedulePath - $decision = Resolve-AzLocalCurrentUpdateRing -Schedule $cfg - Write-Host "Resolver decision: $($decision.Reason)" - if ($decision.Rings -and $decision.Rings.Count -gt 0) { - $resolved = $decision.UpdateRingValue - Write-Host "Resolved UpdateRing='$resolved' (cycleWeek=$($decision.CycleWeek), dayOfWeek=$($decision.DayOfWeekName))." - if ($decision.AllowedUpdateVersions -and $decision.AllowedUpdateVersions.Count -gt 0) { - $resolvedAllow = $decision.AllowedUpdateVersionsValue - Write-Host "Resolved AllowedUpdateVersions ($($decision.AllowedUpdateVersionsSource)): $resolvedAllow" - } - else { - Write-Host "Resolved AllowedUpdateVersions: - apply-updates will install the latest Ready update on each cluster." - } - } - else { - Write-Host "##vso[task.logissue type=warning]No UpdateRing scheduled for today: $($decision.Reason)" - Write-Host "Setting resolved UpdateRing to empty - the readiness task will report ReadyCount=0 and the ApplyUpdates stage will be skipped cleanly." - $resolved = '' - } - } - Write-Host "##vso[task.setvariable variable=RESOLVED_UPDATE_RING;isOutput=true]$resolved" - Write-Host "##vso[task.setvariable variable=RESOLVED_ALLOWED_UPDATE_VERSIONS;isOutput=true]$resolvedAllow" - + $params = @{ ManualUpdateRing = $env:MANUAL_UPDATE_RING; ResolveForDateUtc = $env:RESOLVE_FOR_DATE_UTC } + if ($env:USE_SCHEDULE_FILE -eq 'True' -or $env:USE_SCHEDULE_FILE -eq 'true') { $params['UseScheduleFile'] = $true } + Resolve-AzLocalPipelineUpdateRing @params + + # v0.8.5 thin-YAML - readiness gate query + step outputs (PascalCase + # on ADO so stageDependencies binding works) + per-cluster markdown + # summary uploaded as stage Build Summary. - task: AzureCLI@2 displayName: 'Check Cluster Readiness' name: readiness @@ -272,89 +215,8 @@ stages: scriptLocation: 'inlineScript' inlineScript: | Import-Module AzLocal.UpdateManagement -Force - - $updateRing = $env:RESOLVED_UPDATE_RING - - # empty UpdateRing means the schedule resolver found - # no row matching the current UTC day. Report zero clusters - # and exit 0 - the ApplyUpdates stage is gated on ReadyCount>0 - # and will skip cleanly. - if ([string]::IsNullOrWhiteSpace($updateRing)) { - Write-Host "No UpdateRing scheduled for this firing - skipping readiness check." - Write-Host "##vso[task.setvariable variable=TotalCount;isOutput=true]0" - Write-Host "##vso[task.setvariable variable=ReadyCount;isOutput=true]0" - Write-Host "##vso[task.setvariable variable=NotReadyCount;isOutput=true]0" - exit 0 - } - - Write-Host "Checking readiness for clusters with UpdateRing = '$updateRing'" - - # Create output directory - $outputDir = "$(Build.ArtifactStagingDirectory)" - $csvPath = Join-Path $outputDir "readiness-report.csv" - - # Run readiness check - $results = Get-AzLocalClusterUpdateReadiness ` - -ScopeByUpdateRingTag ` - -UpdateRingValue $updateRing ` - -ExportPath $csvPath ` - -PassThru - - $totalCount = $results.Count - $readyCount = ($results | Where-Object { $_.ReadyForUpdate -eq $true }).Count - $notReadyCount = $totalCount - $readyCount - - Write-Host "" - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "Readiness Summary" -ForegroundColor Cyan - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "Total Clusters: $totalCount" - Write-Host "Ready for Update: $readyCount" - Write-Host "Not Ready: $notReadyCount" - - # Set output variables - Write-Host "##vso[task.setvariable variable=TotalCount;isOutput=true]$totalCount" - Write-Host "##vso[task.setvariable variable=ReadyCount;isOutput=true]$readyCount" - Write-Host "##vso[task.setvariable variable=NotReadyCount;isOutput=true]$notReadyCount" - - if ($readyCount -eq 0) { - Write-Host "##vso[task.logissue type=warning]No clusters are ready for updates in ring '$updateRing'" - } + Export-AzLocalClusterReadinessGateReport -UpdateRing $env:RESOLVED_UPDATE_RING -OutputDirectory "$(Build.ArtifactStagingDirectory)" - # v0.8.4 - Enhancement D: per-cluster readiness markdown table - # uploaded as the stage's Build Summary - surfaces which - # clusters were assessed, their state, and why each cluster - # was/wasn't handed to ApplyUpdates without log-diving. - $sb = New-Object System.Text.StringBuilder - [void]$sb.AppendLine("# Cluster Readiness ($updateRing)") - [void]$sb.AppendLine() - [void]$sb.AppendLine("**Total:** $totalCount  |  **Ready:** $readyCount  |  **Not Ready:** $notReadyCount") - [void]$sb.AppendLine() - [void]$sb.AppendLine('| Cluster | Current Version | Update State | Health | Ready? | Recommended Update | Blocking Reasons |') - [void]$sb.AppendLine('|---|---|---|---|---|---|---|') - $rowCap = 100 - $rendered = 0 - foreach ($r in ($results | Sort-Object @{Expression={[bool]$_.ReadyForUpdate}; Descending=$true}, ClusterName)) { - if ($rendered -ge $rowCap) { break } - $readyIcon = if ($r.ReadyForUpdate -eq $true) { '✅' } else { '⛔' } - $hSt = "$($r.HealthState)" - $hCell = switch -Regex ($hSt) { '^Success$' { "✅ $hSt" } '^Warning$' { "⚠️ $hSt" } '^Failure$' { "❌ $hSt" } default { $hSt } } - $blocking = "$($r.BlockingReasons)" - if ($blocking.Length -gt 200) { $blocking = $blocking.Substring(0, 197) + '...' } - $blocking = $blocking -replace '\|','\|' -replace '\r?\n',' ' - $reco = if ($r.RecommendedUpdate) { '`' + $r.RecommendedUpdate + '`' } else { '-' } - $curr = if ($r.CurrentVersion) { '`' + $r.CurrentVersion + '`' } else { '-' } - [void]$sb.AppendLine("| ``$($r.ClusterName)`` | $curr | $($r.UpdateState) | $hCell | $readyIcon | $reco | $blocking |") - $rendered++ - } - if ($totalCount -gt $rowCap) { - [void]$sb.AppendLine() - [void]$sb.AppendLine("_Showing first $rowCap of $totalCount clusters. Download the readiness-report.csv artifact for the full list._") - } - $readinessSummaryPath = Join-Path $outputDir 'readiness-summary.md' - $sb.ToString() | Out-File -FilePath $readinessSummaryPath -Encoding UTF8 -Force - Write-Host "##vso[task.uploadsummary]$readinessSummaryPath" - # compute a UTC timestamp variable so every downloadable artifact name is unique per run. - pwsh: | $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss') @@ -376,7 +238,7 @@ stages: displayName: 'Apply Updates' dependsOn: CheckReadiness condition: and(succeeded(), gt(dependencies.CheckReadiness.outputs['ReadinessCheck.readiness.ReadyCount'], 0)) - + # Optional: Add environment with approval for production rings # Uncomment the following lines to enable manual approval # variables: @@ -385,7 +247,7 @@ stages: # value: 'Production' # ${{ else }}: # value: 'Development' - + jobs: # Optional: Manual validation job for production # - job: WaitForApproval @@ -397,12 +259,12 @@ stages: # inputs: # notifyUsers: 'your-approvers-email@company.com' # instructions: 'Please review the readiness report and approve to proceed with Production updates.' - + - job: ApplyClusterUpdates displayName: 'Apply Cluster Updates' # dependsOn: WaitForApproval # condition: or(ne('${{ parameters.updateRing }}', 'Production'), succeeded('WaitForApproval')) - + variables: readyCount: $[ stageDependencies.CheckReadiness.ReadinessCheck.outputs['readiness.ReadyCount'] ] totalCount: $[ stageDependencies.CheckReadiness.ReadinessCheck.outputs['readiness.TotalCount'] ] @@ -418,11 +280,11 @@ stages: # schema-v2 'allowedUpdateVersions' field. Empty when build was not a # Schedule firing or when no allow-list is configured. resolvedAllowedUpdateVersions: $[ stageDependencies.CheckReadiness.ReadinessCheck.outputs['resolveRing.RESOLVED_ALLOWED_UPDATE_VERSIONS'] ] - + steps: - checkout: self displayName: 'Checkout Repository' - + - task: AzureCLI@2 displayName: 'Install Resource Graph Extension' inputs: @@ -431,59 +293,27 @@ stages: scriptLocation: 'inlineScript' inlineScript: | az extension add --name resource-graph --yes - + + # v0.8.5 thin-YAML - apply-updates stage re-installs the module (each + # agent run is fresh). The version-banner upload is deliberately + # skipped here (already emitted by the CheckReadiness install step) + # to avoid duplicating the banner in the rendered run summary. - task: PowerShell@2 displayName: 'Install AzLocal.UpdateManagement from PSGallery' env: REQUIRED_MODULE_VERSION: $(REQUIRED_MODULE_VERSION) - GENERATED_AGAINST_MODULE_VERSION: $(GENERATED_AGAINST_MODULE_VERSION) inputs: targetType: 'inline' pwsh: true script: | $ErrorActionPreference = 'Stop' $installArgs = @{ Name = 'AzLocal.UpdateManagement'; Scope = 'CurrentUser'; Force = $true; AllowClobber = $true } - if ($env:REQUIRED_MODULE_VERSION) { - $installArgs.RequiredVersion = $env:REQUIRED_MODULE_VERSION - Write-Host "REQUIRED_MODULE_VERSION is set - pinning install to v$($env:REQUIRED_MODULE_VERSION)." - } else { - Write-Host "REQUIRED_MODULE_VERSION is empty - installing the latest version from PSGallery (default fix-forward behaviour)." - } + if ($env:REQUIRED_MODULE_VERSION) { $installArgs.RequiredVersion = $env:REQUIRED_MODULE_VERSION } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on agent : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - Write-Host "" - Get-Module AzLocal.UpdateManagement | Format-List Name, Version, Path - # Drift signal 0: the module installed at runtime is OLDER than the version this YAML was generated against. Cmdlets, parameters, or output schemas referenced by this YAML may not exist in the installed module - emitted as a warning because this is the most likely cause of a hard runtime failure later in the job. - if ($installed -lt $generated) { - Write-Host "##vso[task.logissue type=warning]AzLocal.UpdateManagement v$installed is OLDER than the version this pipeline YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Platform AzureDevOps -Update'." - } - if ($installed -gt $generated) { - $msg = "Pipeline YAML was generated against AzLocal.UpdateManagement v$generated but the agent installed v$installed. Pipeline steps may have been improved in later releases - to refresh, re-run 'Copy-AzLocalPipelineExample -Destination .\pipelines -Platform AzureDevOps -Update' (you will be prompted per file; add -Confirm:`$false to bypass). Pipeline YAMLs are under git so 'git diff' shows exactly what changed before commit." - Write-Host "##vso[task.logissue type=warning]$msg" - } - if ($latest -and ($latest -gt $installed)) { - $msg = "AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed. Review the module CHANGELOG before bumping REQUIRED_MODULE_VERSION (or clear the pin to install the latest automatically)." - Write-Host "##vso[task.logissue type=warning]$msg" - } + Write-Host "Installed AzLocal.UpdateManagement v$installed on ApplyUpdates stage agent." - # v0.8.4 - one-line version banner uploaded to the build run - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), - # the module version actually loaded on the agent, the PSGallery - # latest, and the REQUIRED_MODULE_VERSION pin status are visible - # at the top of every run summary without scrolling the agent - # log. Same data is also echoed to the console above. Emitted - # from the check-readiness install step in the previous stage; - # this apply-updates install step deliberately skips the upload - # to avoid duplicating the banner in the rendered summary. - # Download the readiness CSV produced by the CheckReadiness stage # so the apply step operates on the readiness gate's decision rather than # re-discovering clusters by tag. Apply still re-validates each cluster @@ -496,7 +326,13 @@ stages: # readinessArtifactStamp variable mapped from the CheckReadiness stage. artifact: 'azlocal-step.6-apply-updates-readiness-report_$(readinessArtifactStamp)' path: '$(Build.ArtifactStagingDirectory)' - + + # v0.8.5 thin-YAML - readiness-gated apply via shared helper. Validates + # CSV (requires ClusterResourceId column, v0.7.62+), fans out to + # Start-AzLocalClusterUpdate, emits 7 PascalCase task output variables + # (Succeeded, Skipped, Failed, HealthBlocked, ScheduleBlocked, + # SideloadedBlocked, ExcludedByTag) for stageDependencies / summary + # binding, and writes apply-results.json next to update-results.xml. - task: AzureCLI@2 displayName: 'Apply Updates' name: applyUpdates @@ -520,137 +356,16 @@ stages: scriptLocation: 'inlineScript' inlineScript: | Import-Module AzLocal.UpdateManagement -Force - - $updateRing = $env:RESOLVED_UPDATE_RING - $updateName = "${{ parameters.updateName }}" - $dryRun = [System.Convert]::ToBoolean("${{ parameters.dryRun }}") - $allowedUpdateVersions = @() - if (-not [string]::IsNullOrWhiteSpace($env:RESOLVED_ALLOWED_UPDATE_VERSIONS)) { - $allowedUpdateVersions = @($env:RESOLVED_ALLOWED_UPDATE_VERSIONS -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) - } - - # Create output directory - $outputDir = "$(Build.ArtifactStagingDirectory)" - $junitPath = Join-Path $outputDir "update-results.xml" - - # Read the readiness CSV from the CheckReadiness stage and - # extract the ARM resource IDs of clusters marked ReadyForUpdate=True. - # The ClusterResourceId column was added to - # Get-AzLocalClusterUpdateReadiness output in specifically - # to make this hand-off explicit and exact (no second tag query, - # no drift between the two stages' scopes). - $readinessCsv = Join-Path $outputDir 'readiness-report.csv' - if (-not (Test-Path $readinessCsv)) { - throw "Readiness CSV not found at '$readinessCsv'. The CheckReadiness stage did not publish a readiness-report artifact - cannot determine which clusters to apply." - } - $readinessRows = @(Import-Csv -Path $readinessCsv) - if ($readinessRows.Count -gt 0 -and -not ($readinessRows[0].PSObject.Properties.Name -contains 'ClusterResourceId')) { - throw "Readiness CSV at '$readinessCsv' is missing the 'ClusterResourceId' column. This column was added in AzLocal.UpdateManagement v0.7.62. Re-run CheckReadiness with v0.7.62+ or refresh this pipeline YAML via Copy-AzLocalPipelineExample -Update." - } - $readyResourceIds = @($readinessRows | - Where-Object { $_.ReadyForUpdate -eq 'True' -and $_.ClusterResourceId } | - ForEach-Object { $_.ClusterResourceId }) - Write-Host "Readiness CSV: $($readinessRows.Count) row(s), $($readyResourceIds.Count) marked ReadyForUpdate=True." - if ($readyResourceIds.Count -eq 0) { - Write-Host "##vso[task.logissue type=warning]Readiness CSV reports zero clusters with ReadyForUpdate=True - nothing to apply." - Write-Host "##vso[task.setvariable variable=Succeeded;isOutput=true]0" - Write-Host "##vso[task.setvariable variable=Skipped;isOutput=true]0" - Write-Host "##vso[task.setvariable variable=Failed;isOutput=true]0" - Write-Host "##vso[task.setvariable variable=HealthBlocked;isOutput=true]0" - Write-Host "##vso[task.setvariable variable=ScheduleBlocked;isOutput=true]0" - Write-Host "##vso[task.setvariable variable=SideloadedBlocked;isOutput=true]0" - Write-Host "##vso[task.setvariable variable=ExcludedByTag;isOutput=true]0" - exit 0 - } - - # Build parameters - use -ClusterResourceIds (the readiness gate's - # decision) rather than -ScopeByUpdateRingTag (which would re-discover - # the full ring and force apply to re-evaluate already-blocked clusters). $params = @{ - ClusterResourceIds = $readyResourceIds - Force = $true - LogFolderPath = $outputDir - ExportResultsPath = $junitPath - } - - if ($updateName -and $updateName -ne '') { - $params['UpdateName'] = $updateName - Write-Host "Applying specific update: $updateName" + ReadinessCsvPath = "$(Build.ArtifactStagingDirectory)/readiness-report.csv" + UpdateRing = $env:RESOLVED_UPDATE_RING + UpdateName = "${{ parameters.updateName }}" + AllowedUpdateVersions = $env:RESOLVED_ALLOWED_UPDATE_VERSIONS + OutputDirectory = "$(Build.ArtifactStagingDirectory)" } + if ([System.Convert]::ToBoolean("${{ parameters.dryRun }}")) { $params['DryRun'] = $true } + Invoke-AzLocalReadinessGatedClusterUpdate @params - if ($allowedUpdateVersions.Count -gt 0) { - $params['AllowedUpdateVersions'] = $allowedUpdateVersions - Write-Host "AllowedUpdateVersions allow-list (schema v2): [$($allowedUpdateVersions -join ', ')]. Clusters with no Ready update matching the list will be skipped with status 'NotInAllowList'." - } - - if ($dryRun) { - $params['WhatIf'] = $true - Write-Host "##vso[task.logissue type=warning]DRY RUN MODE - No updates will be applied" - } - - Write-Host "" - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "Applying Updates to UpdateRing: $updateRing" -ForegroundColor Cyan - Write-Host " Clusters (from readiness CSV): $($readyResourceIds.Count)" -ForegroundColor Cyan - Write-Host "========================================" -ForegroundColor Cyan - - # Run updates - $results = Start-AzLocalClusterUpdate @params -PassThru - - Write-Host "" - Write-Host "Update operation complete" - - # Count results - $succeeded = ($results | Where-Object { $_.Status -eq 'Started' -or $_.Status -eq 'Success' -or $_.Status -eq 'UpdateStarted' }).Count - $skipped = ($results | Where-Object { $_.Status -in @('Skipped', 'NotReady', 'NoUpdatesAvailable', 'NoReadyUpdates', 'NotFound', 'UpdateNotFound', 'NotInAllowList') }).Count - $failed = ($results | Where-Object { $_.Status -in @('Failed', 'Error') }).Count - $healthBlocked = ($results | Where-Object { $_.Status -eq 'HealthCheckBlocked' }).Count - $scheduleBlocked = ($results | Where-Object { $_.Status -eq 'ScheduleBlocked' }).Count - $sideloadedBlocked = ($results | Where-Object { $_.Status -eq 'SideloadedBlocked' }).Count - $excludedByTag = ($results | Where-Object { $_.Status -eq 'ExcludedByTag' }).Count - - Write-Host "##vso[task.setvariable variable=Succeeded;isOutput=true]$succeeded" - Write-Host "##vso[task.setvariable variable=Skipped;isOutput=true]$skipped" - Write-Host "##vso[task.setvariable variable=Failed;isOutput=true]$failed" - Write-Host "##vso[task.setvariable variable=HealthBlocked;isOutput=true]$healthBlocked" - Write-Host "##vso[task.setvariable variable=ScheduleBlocked;isOutput=true]$scheduleBlocked" - Write-Host "##vso[task.setvariable variable=SideloadedBlocked;isOutput=true]$sideloadedBlocked" - Write-Host "##vso[task.setvariable variable=ExcludedByTag;isOutput=true]$excludedByTag" - - # v0.8.4 - Enhancement D: persist per-cluster apply results to JSON so - # the downstream Generate Summary step can render a markdown table without - # depending on the (truncated, JSON-unfriendly) task output variable stream. - $applyJsonPath = Join-Path $outputDir 'apply-results.json' - @($results) | Select-Object ClusterName, Status, UpdateName, Duration, Message | - ConvertTo-Json -Depth 4 | - Out-File -FilePath $applyJsonPath -Encoding utf8 -Force - Write-Host "Wrote per-cluster apply results to $applyJsonPath" - - if ($failed -gt 0) { - # Per-cluster failures are expected in large fleets (day-to-day environmental - # issues on a small subset of clusters). Log as a warning so the partial - # success for the healthy clusters is not lost. Only promote to a hard error - # when the entire batch failed AND nothing started - that indicates a systemic - # issue (auth, module, subscription scope) worth failing the stage on. - if ($succeeded -eq 0 -and $skipped -eq 0 -and $healthBlocked -eq 0 -and $scheduleBlocked -eq 0 -and $sideloadedBlocked -eq 0 -and $excludedByTag -eq 0) { - Write-Host "##vso[task.logissue type=error]All $failed cluster(s) in scope failed to start updates. Review the Azure Local portal, cluster health, and the published update-results.xml for per-cluster detail." - } else { - Write-Host "##vso[task.logissue type=warning]$failed cluster(s) failed to start updates. $succeeded succeeded, $skipped skipped, $healthBlocked health-blocked, $scheduleBlocked schedule-blocked, $sideloadedBlocked sideloaded-blocked, $excludedByTag excluded-by-tag. See update-results.xml for per-cluster detail." - } - } - if ($healthBlocked -gt 0) { - Write-Host "##vso[task.logissue type=warning]$healthBlocked cluster(s) blocked by critical health check failures" - } - if ($scheduleBlocked -gt 0) { - Write-Host "##vso[task.logissue type=warning]$scheduleBlocked cluster(s) blocked by maintenance schedule (outside UpdateStartWindow or in UpdateExclusionsWindow period)" - } - if ($sideloadedBlocked -gt 0) { - Write-Host "##vso[task.logissue type=warning]$sideloadedBlocked cluster(s) blocked by UpdateSideloaded=False - operator must stage the sideloaded payload and flip the tag (or run Reset-AzLocalSideloadedTag) before updates can proceed" - } - if ($excludedByTag -gt 0) { - Write-Host "##vso[task.logissue type=warning]$excludedByTag cluster(s) excluded by UpdateExcluded=True operator override - flip the UpdateExcluded tag to False (Azure portal or 'az tag update') once the cluster should rejoin automation" - } - # compute a UTC timestamp variable so both update-logs and itsm-results share a unique per-run suffix. - pwsh: | $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss') @@ -666,7 +381,7 @@ stages: PathtoPublish: '$(Build.ArtifactStagingDirectory)' ArtifactName: 'azlocal-step.6-apply-updates-logs_$(stamp.artifactStamp)' publishLocation: 'Container' - + - task: PublishTestResults@2 displayName: 'Publish Update JUnit Diagnostics' condition: always() @@ -674,7 +389,7 @@ stages: testResultsFormat: 'JUnit' testResultsFiles: '$(Build.ArtifactStagingDirectory)/update-results.xml' testRunTitle: '[JUnit Debug] Azure Local Cluster Updates - ${{ parameters.updateRing }}' - + # -------------------------------------------------------------- # ITSM Connector (ServiceNow) # Fully opt-in. Runs AFTER PublishTestResults so the apply stage @@ -693,6 +408,10 @@ stages: Install-Module powershell-yaml -Scope CurrentUser -Force -AllowClobber } + # v0.8.5 thin-YAML - ITSM ticketing via Invoke-AzLocalItsmTicketingFromArtifact. + # Wrapper auto-populates RunMetadata from ADO env vars and short-circuits + # if ConfigPath or InputArtifactPath are missing (same behaviour as the + # prior inline block). - task: AzureCLI@2 displayName: 'Raise ITSM tickets' name: itsm @@ -714,41 +433,14 @@ stages: scriptLocation: 'inlineScript' inlineScript: | Import-Module AzLocal.UpdateManagement -Force - - $configPath = "${{ parameters.itsmConfigPath }}" - $dryRun = [System.Convert]::ToBoolean("${{ parameters.itsmDryRun }}") - $force = [System.Convert]::ToBoolean("${{ parameters.itsmForceCreate }}") - - if (-not (Test-Path $configPath)) { - Write-Host "##vso[task.logissue type=warning]ITSM config not found at '$configPath' - skipping ticket creation." - exit 0 - } - - $cfg = Get-AzLocalItsmConfig -Path $configPath - - $junitInput = "$(Build.ArtifactStagingDirectory)/update-results.xml" - if (-not (Test-Path $junitInput)) { - Write-Host "##vso[task.logissue type=warning]No update-results.xml found at '$junitInput' - skipping ticket creation." - exit 0 - } - $params = @{ - InputArtifactPath = $junitInput - Config = $cfg - RunMetadata = @{ - Platform = 'azure-devops' - RunId = $env:BUILD_BUILDID - RunUrl = "$($env:SYSTEM_COLLECTIONURI)$($env:SYSTEM_TEAMPROJECT)/_build/results?buildId=$($env:BUILD_BUILDID)" - Branch = $env:BUILD_SOURCEBRANCH - } - DryRun = $dryRun - ForceCreate = $force - ExportPath = "$(Build.ArtifactStagingDirectory)/itsm-results.csv" - ExportJUnitPath = "$(Build.ArtifactStagingDirectory)/itsm-results.xml" + ConfigPath = "${{ parameters.itsmConfigPath }}" + InputArtifactPath = "$(Build.ArtifactStagingDirectory)/update-results.xml" + ExportDirectory = "$(Build.ArtifactStagingDirectory)" } - - $results = New-AzLocalIncident @params - $results | Format-Table ClusterName, Action, TicketId, Severity -AutoSize + if ([System.Convert]::ToBoolean("${{ parameters.itsmDryRun }}")) { $params['DryRun'] = $true } + if ([System.Convert]::ToBoolean("${{ parameters.itsmForceCreate }}")) { $params['ForceCreate'] = $true } + Invoke-AzLocalItsmTicketingFromArtifact @params - task: PublishBuildArtifacts@1 displayName: 'Publish ITSM Artefacts' @@ -768,168 +460,78 @@ stages: testRunTitle: 'ITSM Tickets - ${{ parameters.updateRing }}' failTaskOnFailedTests: false + # v0.8.5 thin-YAML - Update Application Summary via shared helper. + # Reads apply-results.json + readiness-report.csv from the artifact + # staging directory and renders the readiness KPI table, results KPI + # table, cluster-actions table, skipped-at-readiness-gate table, and + # Actions Required bullets. ADO icon style = GitHub-Markdown shortcodes. - task: PowerShell@2 displayName: 'Generate Summary' env: RESOLVED_UPDATE_RING: $(resolvedUpdateRing) inputs: targetType: 'inline' + pwsh: true script: | - $updateRing = $env:RESOLVED_UPDATE_RING - $dryRun = [System.Convert]::ToBoolean("${{ parameters.dryRun }}") - $readyCount = "$(readyCount)" - $totalCount = "$(totalCount)" - - $summary = @" - # Update Application Summary - - **Target UpdateRing:** $updateRing - - ## Readiness - - | Metric | Count | - |--------|-------| - | Total Clusters | $totalCount | - | Ready for Update | $readyCount | - "@ - - if ($dryRun) { - $summary += "`n`n**This was a dry run. No updates were applied.**" - $summary += "`n`nRe-run the pipeline with 'Preview' set to false to apply updates." - } - else { - # Add Actions Required section for non-dry-run - $actionsRequired = @() - $healthBlocked = "$(applyUpdates.HealthBlocked)" - $scheduleBlocked = "$(applyUpdates.ScheduleBlocked)" - $sideloadedBlocked = "$(applyUpdates.SideloadedBlocked)" - $excludedByTag = "$(applyUpdates.ExcludedByTag)" - $failed = "$(applyUpdates.Failed)" - - if ([int]$failed -gt 0) { $actionsRequired += "- **$failed cluster(s) failed** - Review pipeline logs and cluster health in Azure Portal" } - if ([int]$healthBlocked -gt 0) { $actionsRequired += "- **$healthBlocked cluster(s) blocked by health checks** - Resolve critical health failures before retrying" } - if ([int]$scheduleBlocked -gt 0) { $actionsRequired += "- **$scheduleBlocked cluster(s) outside maintenance window** - Updates will proceed when the cluster's UpdateStartWindow schedule allows, or re-run during the maintenance window" } - if ([int]$sideloadedBlocked -gt 0) { $actionsRequired += "- **$sideloadedBlocked cluster(s) blocked by UpdateSideloaded=False** - Operator must stage the sideloaded payload and flip the tag to True (or run Reset-AzLocalSideloadedTag) before updates can proceed" } - if ([int]$excludedByTag -gt 0) { $actionsRequired += "- **$excludedByTag cluster(s) excluded by UpdateExcluded=True operator override** - Flip the cluster's UpdateExcluded tag to False (Azure portal or 'az tag update') once the cluster should rejoin automation" } - - if ($actionsRequired.Count -gt 0) { - $summary += "`n`n## Actions Required`n" - $summary += ($actionsRequired -join "`n") - } - } - - # v0.8.4 - Enhancement D: per-cluster action table from apply-results.json - # (written by the apply-updates step) - shows operators exactly what - # happened to each cluster handed to apply, without log-diving. - $applyJson = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'apply-results.json' - if (Test-Path $applyJson) { - $applyRows = @(Get-Content -Raw -Path $applyJson | ConvertFrom-Json) - if ($applyRows.Count -gt 0) { - $startedStates = @('UpdateStarted','Started','Success') - $blockedStates = @('HealthCheckBlocked','ScheduleBlocked','SideloadedBlocked','ExcludedByTag','NotConnected') - $failedStates = @('Failed','Error','NotFound') - $summary += "`n`n### Cluster Actions ($($applyRows.Count) cluster(s))`n`n" - $summary += "| Cluster | Status | Update | Duration | Message |`n" - $summary += "|---|---|---|---|---|`n" - foreach ($r in ($applyRows | Sort-Object Status, ClusterName)) { - $st = "$($r.Status)" - $icon = if ($startedStates -contains $st) { ':white_check_mark:' } elseif ($failedStates -contains $st) { ':x:' } elseif ($blockedStates -contains $st) { ':no_entry:' } else { ':next_track_button:' } - $upd = if ($r.UpdateName) { '`' + $r.UpdateName + '`' } else { '-' } - $dur = if ($r.Duration) { "$($r.Duration)" } else { '-' } - $msg = "$($r.Message)" - if ($msg.Length -gt 180) { $msg = $msg.Substring(0, 177) + '...' } - $msg = $msg -replace '\|','\|' -replace '\r?\n',' ' - $summary += "| ``$($r.ClusterName)`` | $icon $st | $upd | $dur | $msg |`n" - } - } - } - - # v0.8.4 - Enhancement D: per-cluster table for clusters NOT handed to - # apply (filtered out by the readiness gate). Reads readiness-report.csv - # downloaded from the CheckReadiness stage artifact at the start of this job. - $readinessCsvPath = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'readiness-report.csv' - if (Test-Path $readinessCsvPath) { - $blockedRows = @(Import-Csv -Path $readinessCsvPath | Where-Object { $_.ReadyForUpdate -ne 'True' }) - if ($blockedRows.Count -gt 0) { - $summary += "`n`n### Clusters Skipped at Readiness Gate ($($blockedRows.Count) cluster(s))`n`n" - $summary += "_These clusters were assessed by Check Update Readiness but not handed to Apply Updates. Resolve the listed blocking reasons (or wait for them to clear) then re-run the pipeline._`n`n" - $summary += "| Cluster | Update State | Health | Current Version | Recommended | Blocking Reasons |`n" - $summary += "|---|---|---|---|---|---|`n" - $skipCap = 100 - $skipShown = 0 - foreach ($b in ($blockedRows | Sort-Object UpdateState, ClusterName)) { - if ($skipShown -ge $skipCap) { break } - $blocking = "$($b.BlockingReasons)" - if ($blocking.Length -gt 200) { $blocking = $blocking.Substring(0, 197) + '...' } - $blocking = $blocking -replace '\|','\|' -replace '\r?\n',' ' - $reco = if ($b.RecommendedUpdate) { '`' + $b.RecommendedUpdate + '`' } else { '-' } - $curr = if ($b.CurrentVersion) { '`' + $b.CurrentVersion + '`' } else { '-' } - $hSt = "$($b.HealthState)" - $hCell = switch -Regex ($hSt) { '^Success$' { ":white_check_mark: $hSt" } '^Warning$' { ":warning: $hSt" } '^Failure$' { ":x: $hSt" } default { $hSt } } - $summary += "| ``$($b.ClusterName)`` | $($b.UpdateState) | $hCell | $curr | $reco | $blocking |`n" - $skipShown++ - } - if ($blockedRows.Count -gt $skipCap) { - $summary += "`n_Showing first $skipCap of $($blockedRows.Count) skipped clusters. Download the readiness-report artifact for the full list._`n" - } - } - } - - $summary | Out-File "$(Build.ArtifactStagingDirectory)/summary.md" -Encoding UTF8 - Write-Host "##vso[task.uploadsummary]$(Build.ArtifactStagingDirectory)/summary.md" + Import-Module AzLocal.UpdateManagement -Force + $params = @{ + UpdateRing = $env:RESOLVED_UPDATE_RING + TotalCount = "$(totalCount)" + ReadyCount = "$(readyCount)" + Succeeded = "$(applyUpdates.Succeeded)" + Skipped = "$(applyUpdates.Skipped)" + Failed = "$(applyUpdates.Failed)" + HealthBlocked = "$(applyUpdates.HealthBlocked)" + ScheduleBlocked = "$(applyUpdates.ScheduleBlocked)" + SideloadedBlocked = "$(applyUpdates.SideloadedBlocked)" + ExcludedByTag = "$(applyUpdates.ExcludedByTag)" + ApplyResultsJsonPath = "$(Build.ArtifactStagingDirectory)/apply-results.json" + ReadinessCsvPath = "$(Build.ArtifactStagingDirectory)/readiness-report.csv" + } + if ([System.Convert]::ToBoolean("${{ parameters.dryRun }}")) { $params['DryRun'] = $true } + Add-AzLocalApplyUpdatesStepSummary @params # Stage 3: Handle no ready clusters - stage: NoClustersReady displayName: 'No Clusters Ready' dependsOn: CheckReadiness condition: and(succeeded(), eq(dependencies.CheckReadiness.outputs['ReadinessCheck.readiness.ReadyCount'], 0)) - + jobs: - job: ReportNoReadyClusters displayName: 'Report Status' - + variables: totalCount: $[ stageDependencies.CheckReadiness.ReadinessCheck.outputs['readiness.TotalCount'] ] # report the resolved ring (not the raw parameter input) so # scheduled runs surface the actual ring the resolver picked. resolvedUpdateRing: $[ stageDependencies.CheckReadiness.ReadinessCheck.outputs['resolveRing.RESOLVED_UPDATE_RING'] ] - + steps: + # Module install is required so the no-clusters-ready summary helper is + # available on this stage's agent. + - task: PowerShell@2 + displayName: 'Install AzLocal.UpdateManagement from PSGallery' + env: + REQUIRED_MODULE_VERSION: $(REQUIRED_MODULE_VERSION) + inputs: + targetType: 'inline' + pwsh: true + script: | + $ErrorActionPreference = 'Stop' + $installArgs = @{ Name = 'AzLocal.UpdateManagement'; Scope = 'CurrentUser'; Force = $true; AllowClobber = $true } + if ($env:REQUIRED_MODULE_VERSION) { $installArgs.RequiredVersion = $env:REQUIRED_MODULE_VERSION } + Install-Module @installArgs + Import-Module AzLocal.UpdateManagement -Force + + # v0.8.5 thin-YAML - No-clusters-ready summary via shared helper. - task: PowerShell@2 displayName: 'Generate Report' env: RESOLVED_UPDATE_RING: $(resolvedUpdateRing) inputs: targetType: 'inline' + pwsh: true script: | - $updateRing = $env:RESOLVED_UPDATE_RING - $totalCount = "$(totalCount)" - - Write-Host "##vso[task.logissue type=warning]No clusters are ready for updates in ring '$updateRing'" - - $summary = @" - # No Clusters Ready for Update - - **Target UpdateRing:** $updateRing - "@ - - if ($totalCount -eq 0) { - $summary += "`n`n No clusters found with UpdateRing tag value '$updateRing'" - } else { - $summary += @" - - Found $totalCount cluster(s) with UpdateRing='$updateRing', but none are ready for updates. - - **Possible reasons:** - - Clusters may already be up to date - - Updates may be in progress - - Clusters may have health check failures - - Download the readiness report artifact for details. - "@ - } - - $summary | Out-File "$(Build.ArtifactStagingDirectory)/summary.md" -Encoding UTF8 -Force - New-Item -ItemType Directory -Path "$(Build.ArtifactStagingDirectory)" -Force | Out-Null - Write-Host "##vso[task.uploadsummary]$(Build.ArtifactStagingDirectory)/summary.md" - + Import-Module AzLocal.UpdateManagement -Force + Add-AzLocalNoReadyClustersStepSummary -UpdateRing $env:RESOLVED_UPDATE_RING -TotalCount "$(totalCount)" 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 96c44d08..b8b53daf 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.4' + GENERATED_AGAINST_MODULE_VERSION: '0.8.5' REQUIRED_MODULE_VERSION: '${{ parameters.moduleVersion }}' reportsPath: '$(Build.ArtifactStagingDirectory)/reports' @@ -101,7 +101,10 @@ stages: displayName: 'Checkout repository' - task: PowerShell@2 + name: moduleVersionStep displayName: 'Install AzLocal.UpdateManagement from PSGallery' + # v0.8.5 thin-YAML: drift detection + banner + step outputs are all + # produced by Add-AzLocalPipelineVersionBanner (Public cmdlet). env: REQUIRED_MODULE_VERSION: $(REQUIRED_MODULE_VERSION) GENERATED_AGAINST_MODULE_VERSION: $(GENERATED_AGAINST_MODULE_VERSION) @@ -119,507 +122,42 @@ stages: } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force - $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on agent : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - Write-Host "" - Get-Module AzLocal.UpdateManagement | Format-List Name, Version, Path - if ($installed -lt $generated) { - Write-Host "##vso[task.logissue type=warning]AzLocal.UpdateManagement v$installed is OLDER than the version this pipeline YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Platform AzureDevOps -Update'." - } - if ($installed -gt $generated) { - $msg = "Pipeline YAML was generated against AzLocal.UpdateManagement v$generated but the agent installed v$installed. Pipeline steps may have been improved in later releases - to refresh, re-run 'Copy-AzLocalPipelineExample -Destination .\pipelines -Platform AzureDevOps -Update' (you will be prompted per file; add -Confirm:`$false to bypass). Pipeline YAMLs are under git so 'git diff' shows exactly what changed before commit." - Write-Host "##vso[task.logissue type=warning]$msg" - } - if ($latest -and ($latest -gt $installed)) { - $msg = "AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed. Review the module CHANGELOG before bumping REQUIRED_MODULE_VERSION (or clear the pin to install the latest automatically)." - Write-Host "##vso[task.logissue type=warning]$msg" - } - - # v0.8.4 - one-line version banner uploaded to the build run - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), - # the module version actually loaded on the agent, the PSGallery - # latest, and the REQUIRED_MODULE_VERSION pin status are visible - # at the top of every run summary without scrolling the agent - # log. Same data is also echoed to the console above. - $pin = if ($env:REQUIRED_MODULE_VERSION) { "pinned to v$($env:REQUIRED_MODULE_VERSION)" } else { 'latest (fix-forward)' } - $latestStr = if ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } - $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } - elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } - elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } - else { 'in sync' } - $bannerPath = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'module-version-banner.md' - "_Pipeline YAML v$generated | Module v$installed installed ($pin) | PSGallery latest $latestStr | $verdict_" | Out-File -FilePath $bannerPath -Encoding utf8 - Write-Host "##vso[task.uploadsummary]$bannerPath" + Add-AzLocalPipelineVersionBanner ` + -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION ` + -PinnedVersion $env:REQUIRED_MODULE_VERSION - task: AzureCLI@2 name: snapshot displayName: 'Snapshot in-flight update runs' + # v0.8.5 thin-YAML: the inline run block (cluster scope query via + # Get-AzLocalUpdateRuns -Latest -PassThru, severity classification, + # per-cluster CSV, JUnit XML, the six pipeline variables, and the + # markdown step summary with status badge + in-flight + unresolved- + # failed tables) has been condensed into the Public cmdlet + # Export-AzLocalUpdateRunMonitorReport. The cmdlet writes + # $(reportsPath)/update-monitor.csv + .xml, uploads the markdown + # summary via ##vso[task.uploadsummary], and sets the six pipeline + # variables (in_flight, long_running, long_running_step, + # step_errored, recent_failures, unresolved_failures). inputs: - # Replace with your service connection name - azureSubscription: 'AzureLocal-ServiceConnection' + azureSubscription: 'AzureLocal-ServiceConnection' # Update with your service connection name scriptType: 'pscore' scriptLocation: 'inlineScript' inlineScript: | az extension add --name resource-graph --yes Import-Module AzLocal.UpdateManagement -Force - - $scope = "${{ parameters.scope }}" - $updateRing = "${{ parameters.updateRing }}" - $thresholdHoursRaw = "${{ parameters.longRunningThresholdHours }}" - $stepThresholdHoursRaw = "${{ parameters.longRunningStepHours }}" - $failureWindowHoursRaw = "${{ parameters.recentFailureWindowHours }}" - $criticalElapsedDaysRaw = "${{ parameters.criticalElapsedDays }}" - # v0.7.96 thresholds: per-step elapsed is the PRIMARY stuck-run signal (2h default). - # Overall-elapsed is a belt-and-braces backstop (24h default, demoted from 6h - 6h was a - # false-positive magnet on legitimate large-node-count runs). - $stepThresholdHours = 2 - if ($stepThresholdHoursRaw) { - [int]$parsedStep = 0 - if ([int]::TryParse($stepThresholdHoursRaw, [ref]$parsedStep) -and $parsedStep -gt 0) { $stepThresholdHours = $parsedStep } - } - $thresholdHours = 24 - if ($thresholdHoursRaw) { - [int]$parsed = 0 - if ([int]::TryParse($thresholdHoursRaw, [ref]$parsed) -and $parsed -gt 0) { $thresholdHours = $parsed } - } - $failureWindowHours = 24 - if ($failureWindowHoursRaw) { - [int]$parsedRf = 0 - if ([int]::TryParse($failureWindowHoursRaw, [ref]$parsedRf) -and $parsedRf -ge 0) { $failureWindowHours = $parsedRf } - } - # CRITICAL tier for overall-elapsed (v0.7.99+). Pure visual escalation - JUnit + counts - # are unaffected. Runs older than $criticalElapsedDays days get a :rotating_light: run - # chip; older than 2x that get :skull:. Defaults: 3 days CRIT, 6 days SKULL. - $criticalElapsedDays = 3 - if ($criticalElapsedDaysRaw) { - [int]$parsedCe = 0 - if ([int]::TryParse($criticalElapsedDaysRaw, [ref]$parsedCe) -and $parsedCe -gt 0) { $criticalElapsedDays = $parsedCe } - } - $thresholdSpan = [TimeSpan]::FromHours($thresholdHours) - $stepThresholdSpan = [TimeSpan]::FromHours($stepThresholdHours) - $stepCritSpan = [TimeSpan]::FromHours($stepThresholdHours * 2) - $criticalElapsedSpan = [TimeSpan]::FromDays($criticalElapsedDays) - $skullElapsedSpan = [TimeSpan]::FromDays($criticalElapsedDays * 2) - Write-Host ("Thresholds: per-step={0}h (warn) / {1}h (crit), overall={2}h (warn) / {3}d (crit) / {4}d (skull), recent-failure-window={5}h" -f $stepThresholdHours, ($stepThresholdHours * 2), $thresholdHours, $criticalElapsedDays, ($criticalElapsedDays * 2), $failureWindowHours) - - $outputDir = "$(reportsPath)" - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - $monitorCsv = Join-Path $outputDir 'update-monitor.csv' - $monitorXml = Join-Path $outputDir 'update-monitor.xml' - $monitorMd = Join-Path $outputDir 'update-monitor.md' - - # -PassThru is REQUIRED: without it Get-AzLocalUpdateRuns only writes formatted output to - # the host and returns nothing to the pipeline, silently producing an empty $runs and a - # 0/0/0 monitor snapshot even when there are in-flight runs. -SkipSideloadedReset is set - # because this is a read-only observability pipeline and must not mutate cluster tags. - $runs = @() - if ($scope -eq 'by-update-ring' -and $updateRing) { - Write-Host "Scope: UpdateRing = $updateRing" - $runs = @(Get-AzLocalUpdateRuns -ScopeByUpdateRingTag -UpdateRingValue $updateRing -Latest -PassThru -SkipSideloadedReset) - } else { - Write-Host "Scope: all clusters (via inventory)" - $inventory = Get-AzLocalClusterInventory -PassThru - if (-not $inventory -or $inventory.Count -eq 0) { - Write-Warning 'No clusters found in inventory.' - Write-Host "##vso[task.setvariable variable=inFlight;isOutput=true]0" - Write-Host "##vso[task.setvariable variable=longRunning;isOutput=true]0" - Write-Host "##vso[task.setvariable variable=longRunningStep;isOutput=true]0" - Write-Host "##vso[task.setvariable variable=stepErrored;isOutput=true]0" - Write-Host "##vso[task.setvariable variable=recentFailures;isOutput=true]0" - Write-Host "##vso[task.setvariable variable=unresolvedFailures;isOutput=true]0" - '' | Set-Content -Path $monitorCsv -Encoding utf8 - exit 0 - } - $resourceIds = @($inventory | Select-Object -ExpandProperty ResourceId) - $runs = @(Get-AzLocalUpdateRuns -ClusterResourceIds $resourceIds -Latest -PassThru -SkipSideloadedReset) - } - - $now = (Get-Date).ToUniversalTime() - $failureWindowStart = if ($failureWindowHours -gt 0) { $now.AddHours(-$failureWindowHours) } else { [datetime]::MaxValue } - $rows = foreach ($r in $runs) { - $startDt = $null - if ($r.StartTime) { - [datetime]$tmp = [datetime]::MinValue - if ([datetime]::TryParse([string]$r.StartTime, [ref]$tmp)) { - $startDt = [datetime]::SpecifyKind($tmp, [DateTimeKind]::Utc) - } - } - $endDt = $null - if ($r.EndTime) { - [datetime]$tmpE = [datetime]::MinValue - if ([datetime]::TryParse([string]$r.EndTime, [ref]$tmpE)) { - $endDt = [datetime]::SpecifyKind($tmpE, [DateTimeKind]::Utc) - } - } - $stepStartDt = $null - if ($r.PSObject.Properties['StepStartTime'] -and $r.StepStartTime) { - [datetime]$tmpS = [datetime]::MinValue - if ([datetime]::TryParse([string]$r.StepStartTime, [ref]$tmpS)) { - $stepStartDt = [datetime]::SpecifyKind($tmpS, [DateTimeKind]::Utc) - } - } - $elapsed = if ($startDt) { $now - $startDt } else { $null } - $elapsedDisplay = if ($elapsed) { - if ($elapsed.TotalDays -ge 1) { ('{0}d {1}h {2}m' -f [int]$elapsed.TotalDays, $elapsed.Hours, $elapsed.Minutes) } - elseif ($elapsed.TotalHours -ge 1) { ('{0}h {1}m' -f [int]$elapsed.TotalHours, $elapsed.Minutes) } - else { ('{0}m' -f [int]$elapsed.TotalMinutes) } - } else { '' } - $stepElapsed = if ($stepStartDt -and $r.State -eq 'InProgress') { $now - $stepStartDt } else { $null } - $stepElapsedHoursVal = if ($stepElapsed) { [math]::Round($stepElapsed.TotalHours, 2) } else { '' } - $stepElapsedDisplay = if ($r.PSObject.Properties['StepElapsed']) { [string]$r.StepElapsed } else { '' } - if ([string]::IsNullOrWhiteSpace($stepElapsedDisplay) -and $stepElapsed) { - $stepElapsedDisplay = if ($stepElapsed.TotalHours -ge 1) { ('{0}h {1}m' -f [int]$stepElapsed.TotalHours, $stepElapsed.Minutes) } else { ('{0}m' -f [int]$stepElapsed.TotalMinutes) } - } - $exceeds = if ($elapsed -and ($r.State -eq 'InProgress')) { $elapsed -gt $thresholdSpan } else { $false } - $exceedsStep = if ($stepElapsed -and ($r.State -eq 'InProgress')) { $stepElapsed -gt $stepThresholdSpan } else { $false } - $isRecentFailure = if ($failureWindowHours -gt 0 -and $r.State -eq 'Failed' -and $endDt) { $endDt -gt $failureWindowStart } else { $false } - # Unresolved failure (v0.7.96): -Latest semantics mean any latest run with State=='Failed' - # is unresolved by definition (no later Succeeded run for the same cluster). Surfaced - # ALWAYS regardless of failureWindowHours so operators don't miss an old-but-still-broken - # cluster. JUnit still emits the per-cluster failure as 'RecentFailure' for backwards- - # compatible parsers. - $isUnresolvedFailure = ($r.State -eq 'Failed') - # Step-error signal (v0.7.96): properties.progress.status can be 'Error' even while - # properties.state stays 'InProgress'. This is the canonical "step has errored and the - # run is stuck" signal (e.g. Arizona's 18d-stuck "Start update" step). Independent of - # per-step elapsed - the two sources cross-confirm a hang. - $progressStatus = if ($r.PSObject.Properties['Status']) { [string]$r.Status } else { '' } - $hasStepError = ($progressStatus -eq 'Error' -and $r.State -eq 'InProgress') - # Portal links (v0.7.96): cluster -> .../updates blade; update run -> the - # AzureStackHCI portal extension SingleInstanceHistoryDetails ReactView. - # URL pattern verified against the Azure portal Update Manager view so - # the rendered links match what the portal builds itself. - $clusterPortalUrl = if ($r.ClusterResourceId) { 'https://portal.azure.com/#@/resource' + [string]$r.ClusterResourceId + '/updates' } else { '' } - $updateRunPortalUrl = '' - if ($r.PSObject.Properties['RunResourceId'] -and $r.RunResourceId -and $r.ClusterResourceId) { - $rm = [regex]::Match([string]$r.RunResourceId, '/updates/([^/]+)/updateRuns/([^/]+)$') - if ($rm.Success) { - $encClusterId = ((([string]$r.ClusterResourceId) -replace '/', '%2F') -replace ' ', '%20') - $updateRunPortalUrl = 'https://portal.azure.com/#view/Microsoft_AzureStackHCI_PortalExtension/SingleInstanceHistoryDetails.ReactView/resourceId/' + $encClusterId + '/updateName/' + $rm.Groups[1].Value + '/updateRunName/' + $rm.Groups[2].Value + '/refresh~/false' - } - } - # Severity classification (v0.7.98). Three independent signals + a numeric score: - # StepSeverity : none -> warn (>stepThresholdHours) -> crit (>2x stepThresholdHours) - # RunSeverity : none -> warn (>thresholdHours) -> crit (>criticalElapsedDays days) -> skull (>2x days) - # StateIcon / StatusIcon : at-a-glance scan icons for the markdown cells - # SeverityScore : single numeric sort key combining ALL signals - replaces the - # priority-collapsed sort tuple so a 19-day-stuck step-errored row - # no longer hides behind a single "step errored" chip. - $stepSeverity = 'none' - if ($stepElapsed -and $r.State -eq 'InProgress') { - if ($stepElapsed -gt $stepCritSpan) { $stepSeverity = 'crit' } - elseif ($stepElapsed -gt $stepThresholdSpan) { $stepSeverity = 'warn' } - } - $runSeverity = 'none' - if ($elapsed -and $r.State -eq 'InProgress') { - if ($elapsed -gt $skullElapsedSpan) { $runSeverity = 'skull' } - elseif ($elapsed -gt $criticalElapsedSpan) { $runSeverity = 'crit' } - elseif ($elapsed -gt $thresholdSpan) { $runSeverity = 'warn' } - } - $stateIcon = switch ($r.State) { - 'InProgress' { ':large_blue_circle:' } - 'Succeeded' { ':large_green_circle:' } - 'Failed' { ':red_circle:' } - 'NotStarted' { ':white_circle:' } - default { ':grey_question:' } - } - $statusIcon = switch ($progressStatus) { - 'Success' { ':white_check_mark:' } - 'Error' { ':x:' } - 'InProgress' { ':hourglass_flowing_sand:' } - 'Skipped' { ':no_entry:' } - 'Cancelled' { ':fast_forward:' } - default { '' } - } - $chipList = New-Object 'System.Collections.Generic.List[string]' - if ($hasStepError) { $chipList.Add(':rotating_light: step errored') | Out-Null } - if ($stepSeverity -eq 'crit') { $chipList.Add((':rotating_light: step >{0}h' -f ($stepThresholdHours * 2))) | Out-Null } - elseif ($stepSeverity -eq 'warn') { $chipList.Add((':warning: step >{0}h' -f $stepThresholdHours)) | Out-Null } - if ($runSeverity -eq 'skull') { $chipList.Add((':skull: run >{0}d' -f ($criticalElapsedDays * 2))) | Out-Null } - elseif ($runSeverity -eq 'crit') { $chipList.Add((':rotating_light: run >{0}d' -f $criticalElapsedDays)) | Out-Null } - elseif ($runSeverity -eq 'warn') { $chipList.Add((':warning: run >{0}h' -f $thresholdHours)) | Out-Null } - if ($chipList.Count -eq 0 -and $r.State -eq 'InProgress') { $chipList.Add('within') | Out-Null } - $flagDisplay = ($chipList -join '
') - $severityScore = 0.0 - if ($hasStepError) { $severityScore += 1000 } - if ($r.State -eq 'Failed') { $severityScore += 800 } - if ($runSeverity -eq 'skull') { $severityScore += 500 } - elseif ($runSeverity -eq 'crit') { $severityScore += 300 } - elseif ($runSeverity -eq 'warn') { $severityScore += 50 } - if ($stepSeverity -eq 'crit') { $severityScore += 200 } - elseif ($stepSeverity -eq 'warn') { $severityScore += 30 } - $elapsedHoursForScore = if ($elapsed) { $elapsed.TotalHours } else { 0 } - $severityScore += [math]::Round($elapsedHoursForScore / 24, 2) - # v0.7.98: surface a meaningful per-row JUnit time= so the Tests tab + Test Reporter - # widgets stop rendering "0ms" totals. For in-flight rows we report how long the run - # has been going (now - startDt). For unresolved-failed rows with both start and end - # timestamps we report the actual run duration (end - start). Anything else falls - # back to 0 (no usable timestamps). - $runDurationSeconds = 0 - if ($r.State -ne 'InProgress' -and $startDt -and $endDt) { - $runDurationSeconds = [int][math]::Round(($endDt - $startDt).TotalSeconds, 0) - } elseif ($elapsed) { - $runDurationSeconds = [int][math]::Round($elapsed.TotalSeconds, 0) - } - if ($runDurationSeconds -lt 0) { $runDurationSeconds = 0 } - [PSCustomObject]@{ - ClusterName = $r.ClusterName - ClusterPortalUrl = $clusterPortalUrl - UpdateName = $r.UpdateName - UpdateRunPortalUrl = $updateRunPortalUrl - State = $r.State - Status = $progressStatus - CurrentStep = $r.CurrentStep - Progress = $r.Progress - StartTimeUtc = if ($startDt) { $startDt.ToString('yyyy-MM-dd HH:mm') } else { '' } - EndTimeUtc = if ($endDt) { $endDt.ToString('yyyy-MM-dd HH:mm') } else { '' } - ElapsedDisplay = $elapsedDisplay - ElapsedHours = if ($elapsed) { [math]::Round($elapsed.TotalHours, 2) } else { '' } - StepStartTimeUtc = if ($stepStartDt) { $stepStartDt.ToString('yyyy-MM-dd HH:mm') } else { '' } - StepElapsedDisplay = $stepElapsedDisplay - StepElapsedHours = $stepElapsedHoursVal - ExceedsThreshold = $exceeds - ExceedsStepThreshold = $exceedsStep - HasStepError = $hasStepError - IsRecentFailure = $isRecentFailure - IsUnresolvedFailure = $isUnresolvedFailure - ThresholdHours = $thresholdHours - StepThresholdHours = $stepThresholdHours - CriticalElapsedDays = $criticalElapsedDays - StepSeverity = $stepSeverity - RunSeverity = $runSeverity - StateIcon = $stateIcon - StatusIcon = $statusIcon - Flags = $flagDisplay - SeverityScore = $severityScore - RunDurationSeconds = $runDurationSeconds - RunId = $r.RunId - RunResourceId = if ($r.PSObject.Properties['RunResourceId']) { $r.RunResourceId } else { '' } - ClusterResourceId = $r.ClusterResourceId - Duration = $r.Duration - CurrentStepDetail = $r.CurrentStepDetail - ErrorMessage = if ($r.PSObject.Properties['ErrorMessage']) { [string]$r.ErrorMessage } else { '' } - } - } - - $inFlight = @($rows | Where-Object { $_.State -eq 'InProgress' }) - $longRunning = @($inFlight | Where-Object { $_.ExceedsThreshold }) - $longRunningStep = @($inFlight | Where-Object { $_.ExceedsStepThreshold }) - $stepErrored = @($inFlight | Where-Object { $_.HasStepError }) - $recentlyFailed = @($rows | Where-Object { $_.IsRecentFailure }) - $unresolvedFailed = @($rows | Where-Object { $_.IsUnresolvedFailure }) - - if ($rows -and $rows.Count -gt 0) { - $rows | Sort-Object @{Expression='SeverityScore';Descending=$true}, ClusterName | Export-Csv -Path $monitorCsv -NoTypeInformation -Encoding utf8 - } else { - '' | Set-Content -Path $monitorCsv -Encoding utf8 - } - - # JUnit XML - in-flight + unresolved-failed test cases. Failure types (v0.7.96): - # StepError - HIGHEST PRIORITY: properties.progress.status == 'Error' while State is InProgress - # (canonical "step has errored, run is stuck" signal - independent of elapsed time) - # LongRunningStep - current step elapsed exceeds per-step threshold - # LongRunningOverall - BACKSTOP: overall run elapsed exceeds overall threshold - # RecentFailure - run in state Failed (kept name for backwards-compatible parsers; - # emitted for ALL latest-Failed runs, not just within failureWindowHours) - $xmlLines = New-Object 'System.Collections.Generic.List[string]' - $xmlLines.Add('') - # v0.7.99: testsuite/testsuites time="0". Per-testcase time= IS still surfaced (gives - # the ADO Tests tab meaningful per-row durations), but summing wall-clock ages across - # 5 unrelated stuck runs (e.g. ~88 days) is misleading at the suite header. - $xmlLines.Add('') - $tests = $inFlight.Count + $unresolvedFailed.Count - $failures = @($inFlight | Where-Object { $_.HasStepError -or $_.ExceedsStepThreshold -or $_.ExceedsThreshold }).Count + $unresolvedFailed.Count - $xmlLines.Add((' ' -f $tests, $failures)) - foreach ($r in ($inFlight | Sort-Object @{Expression='SeverityScore';Descending=$true}, ClusterName)) { - $safeName = ($r.ClusterName -replace '[^A-Za-z0-9_.-]', '_') - $caseName = "$safeName - $($r.UpdateName) - $($r.CurrentStep)" - $caseNameXml = [System.Security.SecurityElement]::Escape($caseName) - if ($r.HasStepError) { - $errSnippet = if ($r.ErrorMessage) { $r.ErrorMessage } else { '(no errorMessage on deepest failed step)' } - $msg = ('Progress status is Error (state still InProgress) - step is stuck. CurrentStep: {0}. StepElapsed: {1}. ErrorMessage: {2}' -f $r.CurrentStep, $r.StepElapsedDisplay, $errSnippet) - $msgXml = [System.Security.SecurityElement]::Escape($msg) - $xmlLines.Add((' ' -f $caseNameXml, $r.RunDurationSeconds)) - $xmlLines.Add((' {0}' -f $msgXml)) - $xmlLines.Add(' ') - } elseif ($r.ExceedsStepThreshold) { - $msg = ('Current step elapsed {0} exceeds per-step threshold of {1}h. CurrentStep: {2}. Overall elapsed: {3}. Progress: {4}.' -f $r.StepElapsedDisplay, $r.StepThresholdHours, $r.CurrentStep, $r.ElapsedDisplay, $r.Progress) - $msgXml = [System.Security.SecurityElement]::Escape($msg) - $xmlLines.Add((' ' -f $caseNameXml, $r.RunDurationSeconds)) - $xmlLines.Add((' {0}' -f $msgXml)) - $xmlLines.Add(' ') - } elseif ($r.ExceedsThreshold) { - $msg = ('Overall elapsed {0} exceeds backstop threshold of {1}h (current step within {2}h per-step budget). CurrentStep: {3}. Progress: {4}.' -f $r.ElapsedDisplay, $r.ThresholdHours, $r.StepThresholdHours, $r.CurrentStep, $r.Progress) - $msgXml = [System.Security.SecurityElement]::Escape($msg) - $xmlLines.Add((' ' -f $caseNameXml, $r.RunDurationSeconds)) - $xmlLines.Add((' {0}' -f $msgXml)) - $xmlLines.Add(' ') - } else { - $xmlLines.Add((' ' -f $caseNameXml, $r.RunDurationSeconds)) - } - } - foreach ($r in ($unresolvedFailed | Sort-Object @{Expression='EndTimeUtc';Descending=$true})) { - $safeName = ($r.ClusterName -replace '[^A-Za-z0-9_.-]', '_') - $caseName = "$safeName - $($r.UpdateName) - FAILED" - $caseNameXml = [System.Security.SecurityElement]::Escape($caseName) - $detail = if ($r.ErrorMessage) { $r.ErrorMessage } elseif ($r.CurrentStepDetail) { $r.CurrentStepDetail } else { $r.CurrentStep } - $msg = ('Run failed at {0} UTC. Step: {1}. Detail: {2}.' -f $r.EndTimeUtc, $r.CurrentStep, $detail) - $msgXml = [System.Security.SecurityElement]::Escape($msg) - $xmlLines.Add((' ' -f $caseNameXml, $r.RunDurationSeconds)) - $xmlLines.Add((' {0}' -f $msgXml)) - $xmlLines.Add(' ') - } - $xmlLines.Add(' ') - $xmlLines.Add('') - ($xmlLines -join [Environment]::NewLine) | Set-Content -Path $monitorXml -Encoding utf8 - - Write-Host "##vso[task.setvariable variable=inFlight;isOutput=true]$($inFlight.Count)" - Write-Host "##vso[task.setvariable variable=longRunning;isOutput=true]$($longRunning.Count)" - Write-Host "##vso[task.setvariable variable=longRunningStep;isOutput=true]$($longRunningStep.Count)" - Write-Host "##vso[task.setvariable variable=stepErrored;isOutput=true]$($stepErrored.Count)" - Write-Host "##vso[task.setvariable variable=recentFailures;isOutput=true]$($recentlyFailed.Count)" - Write-Host "##vso[task.setvariable variable=unresolvedFailures;isOutput=true]$($unresolvedFailed.Count)" - - # Markdown summary (uploaded via task.uploadsummary below) - $md = New-Object 'System.Collections.Generic.List[string]' - $md.Add('## In-Flight Update Monitor') - $md.Add('') - # v0.7.98 fleet status badge: rolls up the worst signal across in-flight rows so the - # operator gets a single at-a-glance verdict above the metrics table. - $hasCritical = ($stepErrored.Count -gt 0) -or (@($inFlight | Where-Object { $_.RunSeverity -eq 'skull' -or $_.RunSeverity -eq 'crit' -or $_.StepSeverity -eq 'crit' }).Count -gt 0) - $hasWarn = (@($inFlight | Where-Object { $_.StepSeverity -eq 'warn' -or $_.RunSeverity -eq 'warn' }).Count -gt 0) - if ($hasCritical) { - $crits = @() - if ($stepErrored.Count -gt 0) { $crits += "$($stepErrored.Count) stuck step error(s)" } - $skullCount = @($inFlight | Where-Object { $_.RunSeverity -eq 'skull' }).Count - $critRunCount = @($inFlight | Where-Object { $_.RunSeverity -eq 'crit' }).Count - $critStepCount = @($inFlight | Where-Object { $_.StepSeverity -eq 'crit' }).Count - if ($skullCount -gt 0) { $crits += "$skullCount run(s) > $($criticalElapsedDays * 2)d" } - if ($critRunCount -gt 0) { $crits += "$critRunCount run(s) > ${criticalElapsedDays}d" } - if ($critStepCount -gt 0) { $crits += "$critStepCount step(s) > $($stepThresholdHours * 2)h" } - $md.Add((':red_circle: **Fleet Status: CRITICAL** - ' + ($crits -join ', '))) - } elseif ($hasWarn) { - $warnCount = @($inFlight | Where-Object { $_.StepSeverity -eq 'warn' -or $_.RunSeverity -eq 'warn' }).Count - $md.Add((":yellow_circle: **Fleet Status: WARN** - $warnCount long-running run(s) within tolerance")) - } elseif ($inFlight.Count -gt 0) { - $md.Add((":green_circle: **Fleet Status: HEALTHY** - $($inFlight.Count) in-flight run(s), all within tolerance")) - } elseif ($unresolvedFailed.Count -gt 0) { - $md.Add((":red_circle: **Fleet Status: FAILED** - $($unresolvedFailed.Count) unresolved-failed run(s) (no in-flight activity)")) - } else { - $md.Add(':white_check_mark: **Fleet Status: IDLE** - no in-flight runs, no unresolved failures') - } - $md.Add('') - $scopeLabel = if ($scope -eq 'by-update-ring' -and $updateRing) { "by-update-ring (UpdateRing = $updateRing)" } else { 'all clusters' } - $md.Add("**Scope**: $scopeLabel - **Per-step**: ${stepThresholdHours}h warn / $($stepThresholdHours * 2)h crit - **Overall**: ${thresholdHours}h warn / ${criticalElapsedDays}d crit / $($criticalElapsedDays * 2)d skull - **Recent-failure window**: ${failureWindowHours}h - **Snapshot (UTC)**: $($now.ToString('yyyy-MM-dd HH:mm'))") - $md.Add('') - $md.Add('**Legend**: :large_blue_circle: InProgress · :large_green_circle: Succeeded · :red_circle: Failed · :white_circle: NotStarted · :white_check_mark: success · :hourglass_flowing_sand: in-progress · :x: error · :warning: warn tier · :rotating_light: critical tier · :skull: extreme (>2x critical)') - $md.Add('') - $md.Add('| Metric | Count |') - $md.Add('|--------|-------|') - $md.Add("| Clusters scoped | $($rows.Count) |") - $md.Add("| Update runs in flight | $($inFlight.Count) |") - $md.Add("| Step errored (progress.status == 'Error', state still InProgress) | $($stepErrored.Count) |") - $md.Add("| Step elapsed > ${stepThresholdHours}h (primary) | $($longRunningStep.Count) |") - $md.Add("| Overall elapsed > ${thresholdHours}h (backstop) | $($longRunning.Count) |") - $md.Add("| Unresolved-failed runs (latest run is Failed) | $($unresolvedFailed.Count) |") - if ($failureWindowHours -gt 0) { - $md.Add("| Recently-failed runs (last ${failureWindowHours}h) | $($recentlyFailed.Count) |") - } - $md.Add('') - if ($inFlight.Count -gt 0) { - $md.Add('### In-flight runs (sorted by composite severity score, worst first)') - $md.Add('') - $md.Add('| Cluster | State | Status | Current Step | Progress | Step Elapsed | Run Elapsed | Flags |') - $md.Add('|---------|-------|--------|--------------|----------|--------------|-------------|-------|') - foreach ($r in ($inFlight | Sort-Object @{Expression='SeverityScore';Descending=$true}, ClusterName)) { - $cs = if ($r.CurrentStep) { $r.CurrentStep } else { '-' } - $pg = if ($r.Progress) { $r.Progress } else { '-' } - $stepEl = if ($r.StepElapsedDisplay) { $r.StepElapsedDisplay } else { '-' } - $runEl = if ($r.ElapsedDisplay) { $r.ElapsedDisplay } else { '-' } - $stateCell = if ($r.StateIcon) { "$($r.StateIcon) $($r.State)" } else { [string]$r.State } - $statusCell = if (-not $r.Status) { '-' } - elseif ($r.StatusIcon) { "$($r.StatusIcon) $($r.Status)" } - else { [string]$r.Status } - $stepElPrefix = switch ($r.StepSeverity) { - 'crit' { ':rotating_light: ' } - 'warn' { ':warning: ' } - default { '' } - } - $runElPrefix = switch ($r.RunSeverity) { - 'skull' { ':skull: ' } - 'crit' { ':rotating_light: ' } - 'warn' { ':warning: ' } - default { '' } - } - $stepElCell = $stepElPrefix + $stepEl - $runElCell = $runElPrefix + $runEl - # target=_blank so links open in a new tab (preserves the pipeline summary tab). - $clusterCell = if ($r.ClusterPortalUrl) { '' + $r.ClusterName + '' } else { $r.ClusterName } - if ($r.UpdateRunPortalUrl) { - $clusterCell = $clusterCell + '
' + $r.UpdateName + '' - } else { - $clusterCell = $clusterCell + '
' + $r.UpdateName + '' - } - # Collapsible verbose error inline in Current Step cell when present. - if ($r.HasStepError -and $r.ErrorMessage) { - $rawErr = $r.ErrorMessage - if ($rawErr.Length -gt 1500) { $rawErr = $rawErr.Substring(0, 1500) + ' ...' } - $errEsc = ($rawErr -replace '\|', '\|') -replace '\r?\n', ' ' - $cs = $cs + '
Verbose error' + $errEsc + '
' - } - $md.Add("| $clusterCell | $stateCell | $statusCell | $cs | $pg | $stepElCell | $runElCell | $($r.Flags) |") - } - $md.Add('') - } else { - $md.Add('### No update runs currently in flight') - $md.Add('') - $md.Add("No clusters in scope have a latest run in state ``InProgress``. To verify scope, see the artifact CSV (``update-monitor.csv``).") - $md.Add('') - } - if ($unresolvedFailed.Count -gt 0) { - $md.Add('### Failed runs (latest run is Failed, unresolved - shown regardless of age)') - $md.Add('') - $md.Add('| Cluster | Update | Ended (UTC) | Failed Step | Error / Detail | Recent |') - $md.Add('|---------|--------|-------------|-------------|----------------|--------|') - foreach ($r in ($unresolvedFailed | Sort-Object @{Expression='EndTimeUtc';Descending=$true})) { - $cs = if ($r.CurrentStep) { $r.CurrentStep } else { '-' } - $rawDetail = if ($r.ErrorMessage) { $r.ErrorMessage } elseif ($r.CurrentStepDetail) { $r.CurrentStepDetail } else { '-' } - if ($rawDetail.Length -gt 220) { $rawDetail = $rawDetail.Substring(0, 220) + ' ...' } - $detail = ($rawDetail -replace '\|', '\|') -replace '\r?\n', ' ' - $recentTag = if ($r.IsRecentFailure) { ":fire: last ${failureWindowHours}h" } else { '-' } - $clusterCell = if ($r.ClusterPortalUrl) { '' + $r.ClusterName + '' } else { $r.ClusterName } - $updateCell = if ($r.UpdateRunPortalUrl) { '' + $r.UpdateName + '' } else { $r.UpdateName } - $md.Add("| $clusterCell | $updateCell | $($r.EndTimeUtc) | $cs | $detail | $recentTag |") - } - $md.Add('') - } - if (($stepErrored.Count + $longRunningStep.Count + $longRunning.Count + $unresolvedFailed.Count) -gt 0) { - $md.Add('> **Action required.** One or more update runs have errored, hit a threshold, or have an unresolved Failed latest run. Common causes (consult the Azure Local Update Manager portal + the cluster activity log for the affected cluster(s)):') - $md.Add('>') - $md.Add('> - Health check failures (storage / network / cluster service) blocking the run from progressing') - $md.Add('> - Node drain stuck (VM live-migration timeout, anti-affinity blocking move)') - $md.Add('> - Sideloaded payload / pre-staged content mismatch on one or more nodes') - $md.Add('> - ARB (Arc Resource Bridge) connectivity loss or extension-version drift') - $md.Add('>') - $md.Add('> Troubleshooting guides:') - $md.Add('> - **Microsoft Learn:** [Troubleshoot update failures (Azure Local 23H2)](https://learn.microsoft.com/azure/azure-local/update/update-troubleshooting-23h2#troubleshoot-update-failures)') - $md.Add('> - **GitHub TSG:** [Azure/AzureLocal-Supportability/TSG/Update](https://github.com/Azure/AzureLocal-Supportability/tree/main/TSG/Update)') - $md.Add('>') - $md.Add('> The Tests tab shows the same rows as JUnit failures (`StepError`, `LongRunningStep`, `LongRunningOverall`, `RecentFailure`).') - $md.Add('') - } elseif ($inFlight.Count -gt 0) { - $md.Add("> **All in-flight runs are healthy (no step errors, per-step <=${stepThresholdHours}h, overall <=${thresholdHours}h) and no unresolved failures.**") - $md.Add('') - } - $md.Add('_Source data: `Get-AzLocalUpdateRuns -Latest -PassThru`. JUnit emitted as `update-monitor.xml`; full per-cluster rows in `update-monitor.csv`._') - ($md -join [Environment]::NewLine) | Set-Content -Path $monitorMd -Encoding utf8 - Write-Host "##vso[task.uploadsummary]$monitorMd" + $params = @{ + Scope = "${{ parameters.scope }}" + OutputDirectory = "$(reportsPath)" + InstalledModuleVersion = "$(moduleVersionStep.installed_module_version)" + } + if ("${{ parameters.updateRing }}") { $params['UpdateRing'] = "${{ parameters.updateRing }}" } + [int]$parsed = 0 + if ([int]::TryParse("${{ parameters.longRunningStepHours }}", [ref]$parsed) -and $parsed -gt 0) { $params['LongRunningStepHours'] = $parsed } + if ([int]::TryParse("${{ parameters.longRunningThresholdHours }}", [ref]$parsed) -and $parsed -gt 0) { $params['LongRunningThresholdHours'] = $parsed } + if ([int]::TryParse("${{ parameters.recentFailureWindowHours }}", [ref]$parsed) -and $parsed -ge 0) { $params['RecentFailureWindowHours'] = $parsed } + if ([int]::TryParse("${{ parameters.criticalElapsedDays }}", [ref]$parsed) -and $parsed -gt 0) { $params['CriticalElapsedDays'] = $parsed } + Export-AzLocalUpdateRunMonitorReport @params # compute a UTC timestamp variable so every downloadable artifact name is unique per run. - pwsh: | 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 231cc409..020da4fc 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.4' + GENERATED_AGAINST_MODULE_VERSION: '0.8.5' # 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). @@ -130,6 +130,9 @@ stages: - task: PowerShell@2 displayName: 'Install AzLocal.UpdateManagement from PSGallery' + # v0.8.5 thin-YAML: drift detection + banner + step outputs are all + # produced by Add-AzLocalPipelineVersionBanner (Public cmdlet). + name: moduleVersion env: REQUIRED_MODULE_VERSION: $(REQUIRED_MODULE_VERSION) GENERATED_AGAINST_MODULE_VERSION: $(GENERATED_AGAINST_MODULE_VERSION) @@ -147,690 +150,40 @@ stages: } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force - $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on agent : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - Write-Host "" - Get-Module AzLocal.UpdateManagement | Format-List Name, Version, Path - # Drift signal 0: the module installed at runtime is OLDER than the version this YAML was generated against. Cmdlets, parameters, or output schemas referenced by this YAML may not exist in the installed module - emitted as a warning because this is the most likely cause of a hard runtime failure later in the job. - if ($installed -lt $generated) { - Write-Host "##vso[task.logissue type=warning]AzLocal.UpdateManagement v$installed is OLDER than the version this pipeline YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Platform AzureDevOps -Update'." - } - if ($installed -gt $generated) { - $msg = "Pipeline YAML was generated against AzLocal.UpdateManagement v$generated but the agent installed v$installed. Pipeline steps may have been improved in later releases - to refresh, re-run 'Copy-AzLocalPipelineExample -Destination .\pipelines -Platform AzureDevOps -Update' (you will be prompted per file; add -Confirm:`$false to bypass). Pipeline YAMLs are under git so 'git diff' shows exactly what changed before commit." - Write-Host "##vso[task.logissue type=warning]$msg" - } - if ($latest -and ($latest -gt $installed)) { - $msg = "AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed. Review the module CHANGELOG before bumping REQUIRED_MODULE_VERSION (or clear the pin to install the latest automatically)." - Write-Host "##vso[task.logissue type=warning]$msg" - } - - # v0.8.4 - one-line version banner uploaded to the build run - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), - # the module version actually loaded on the agent, the PSGallery - # latest, and the REQUIRED_MODULE_VERSION pin status are visible - # at the top of every run summary without scrolling the agent - # log. Same data is also echoed to the console above. - $pin = if ($env:REQUIRED_MODULE_VERSION) { "pinned to v$($env:REQUIRED_MODULE_VERSION)" } else { 'latest (fix-forward)' } - $latestStr = if ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } - $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } - elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } - elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } - else { 'in sync' } - $bannerPath = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'module-version-banner.md' - "_Pipeline YAML v$generated | Module v$installed installed ($pin) | PSGallery latest $latestStr | $verdict_" | Out-File -FilePath $bannerPath -Encoding utf8 - Write-Host "##vso[task.uploadsummary]$bannerPath" + Add-AzLocalPipelineVersionBanner ` + -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION ` + -PinnedVersion $env:REQUIRED_MODULE_VERSION # Use Azure CLI task with Workload Identity Federation (OIDC) - task: AzureCLI@2 displayName: 'Collect Fleet Update Status' + # v0.8.5 thin-YAML: the inline run block (cluster inventory, readiness + # cascade, version distribution with Microsoft-manifest support window, + # 3-suite JUnit XML, supplementary CSV/JSON exports, run history collection, + # 22 step outputs, and the full markdown step summary uploaded via + # ##vso[task.uploadsummary]) has been condensed into the Public cmdlet + # Export-AzLocalFleetUpdateStatusReport. The cmdlet writes + # $(reportsPath)/*.{json,csv,xml} for downstream Publish + ITSM steps. + name: collect + env: + INPUT_SCOPE: ${{ parameters.scope }} + INPUT_UPDATE_RING: ${{ parameters.updateRing }} + INPUT_INCLUDE_UPDATE_RUNS: ${{ parameters.includeUpdateRuns }} + INSTALLED_MODULE_VERSION: $(moduleVersion.installed_module_version) inputs: - # Replace with your service connection name azureSubscription: 'AzureLocal-ServiceConnection' # Update with your service connection name scriptType: 'pscore' scriptLocation: 'inlineScript' inlineScript: | - # Install resource-graph extension - az extension add --name resource-graph --yes - - # Import module + $ErrorActionPreference = 'Stop' Import-Module AzLocal.UpdateManagement -Force - - # Create output directory - $outputDir = "$(reportsPath)" - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - - $scope = "${{ parameters.scope }}" - $updateRing = "${{ parameters.updateRing }}" - $includeRuns = [System.Convert]::ToBoolean("${{ parameters.includeUpdateRuns }}") - - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "Fleet Update Status Collection" -ForegroundColor Cyan - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "Scope: $scope" - if ($updateRing) { Write-Host "UpdateRing Filter: $updateRing" } - Write-Host "Include Update Runs: $includeRuns" - Write-Host "" - - # Step 1: Get cluster inventory - Write-Host "Step 1: Getting cluster inventory..." -ForegroundColor Yellow - - $inventoryCsv = Join-Path $outputDir "cluster-inventory.csv" - $inventory = Get-AzLocalClusterInventory -ExportPath $inventoryCsv -PassThru - - if (-not $inventory -or $inventory.Count -eq 0) { - Write-Warning "No clusters found in inventory" - exit 0 - } - - Write-Host "Found $($inventory.Count) total cluster(s)" -ForegroundColor Green - - # Step 2: Get readiness status - Write-Host "" - Write-Host "Step 2: Checking update readiness..." -ForegroundColor Yellow - - $readinessParams = @{} - - if ($scope -eq 'by-update-ring' -and $updateRing) { - $readinessParams['ScopeByUpdateRingTag'] = $true - $readinessParams['UpdateRingValue'] = $updateRing - } - else { - $resourceIds = $inventory | Select-Object -ExpandProperty ResourceId - $readinessParams['ClusterResourceIds'] = $resourceIds - } - - # Export to multiple formats - $readinessCsv = Join-Path $outputDir "readiness-status.csv" - $readinessJson = Join-Path $outputDir "readiness-status.json" - $readinessXml = Join-Path $outputDir "readiness-status.xml" - - $readiness = Get-AzLocalClusterUpdateReadiness @readinessParams -ExportPath $readinessCsv -PassThru - - # Export to JSON - $hasPrerequisite = @($readiness | Where-Object { $_.HasPrerequisiteUpdates -ne "" }).Count - $readinessExport = @{ - Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss UTC" - TotalClusters = $readiness.Count - Summary = @{ - # v0.7.99: 3-bucket model (ReadyForUpdate / UpToDate / NotReadyForUpdate) - # matches the renamed Get-AzLocalClusterUpdateReadiness Summary buckets. - # NotReadyForUpdate now excludes both InProgress AND UpToDate clusters. - ReadyForUpdate = @($readiness | Where-Object { $_.ReadyForUpdate -eq $true }).Count - UpToDate = @($readiness | Where-Object { - $_.ReadyForUpdate -ne $true -and - $_.UpdateState -in @('UpToDate','AppliedSuccessfully') -and - [string]::IsNullOrEmpty([string]$_.AllAvailableUpdates) - }).Count - InProgress = @($readiness | Where-Object { $_.UpdateState -in @("UpdateInProgress","PreparationInProgress") }).Count - HealthFailures = @($readiness | Where-Object { $_.HealthState -eq "Failure" }).Count - UpdateFailures = @($readiness | Where-Object { $_.UpdateState -in @("Failed","UpdateFailed","NeedsAttention") }).Count - ActionRequired = @($readiness | Where-Object { $_.UpdateState -eq "PreparationFailed" }).Count - HasPrerequisite = $hasPrerequisite - NotReadyForUpdate = @($readiness | Where-Object { - $_.ReadyForUpdate -ne $true -and - ($_.UpdateState -notin @("UpdateInProgress","PreparationInProgress")) -and - -not ($_.UpdateState -in @('UpToDate','AppliedSuccessfully') -and [string]::IsNullOrEmpty([string]$_.AllAvailableUpdates)) - }).Count - } - Clusters = $readiness - } - $readinessExport | ConvertTo-Json -Depth 10 | Out-File $readinessJson -Encoding UTF8 - - # Step 3: Generate JUnit XML for Azure DevOps Test tab - Write-Host "" - Write-Host "Step 3: Generating JUnit XML report..." -ForegroundColor Yellow - - $timestamp = Get-Date -Format "yyyy-MM-ddTHH:mm:ss" - $totalTests = $readiness.Count - - # Critical Health Status (this is what the JUnit XML pass/fail represents): - # FAILED = HealthState=Failure OR UpdateState in (Failed,UpdateFailed,NeedsAttention,PreparationFailed) - # OR SBE prerequisite blocked - # PASSED = everything else - # v0.7.96: PreparationFailed + NeedsAttention previously fell into the 'Other' bucket and - # were invisible to operators. They are now first-class failure signals (matches the - # Azure portal Update Manager 'state' filter). - $failureStates = @('Failed','UpdateFailed','NeedsAttention','PreparationFailed') - $failures = @($readiness | Where-Object { $_.HealthState -eq "Failure" -or ($_.UpdateState -in $failureStates) -or $_.HasPrerequisiteUpdates -ne "" }).Count - $criticalHealthPassed = $totalTests - $failures - - # Mutually-exclusive primary status buckets so the summary table rows - # always sum to Total Clusters. Priority cascade (first match wins): - # UpdateFailed > ActionRequired > HealthFailure > SbeBlocked > InProgress > ReadyForUpdate > UpToDate > Other - # v0.7.96 vocab (sourced from properties.state of the updatesummaries ARM resource): - # AppliedSuccessfully / UpToDate -> Up to date (success) - # UpdateAvailable + ReadyForUpdate -> Ready for update - # UpdateInProgress / PreparationInProgress -> In progress - # UpdateFailed / Failed / NeedsAttention -> Update failed (terminal failure, retry path) - # PreparationFailed -> Action required (operator must remediate before retry) - $stUpdateFailed = 0 - $stActionRequired = 0 - $stHealthFailure = 0 - $stSbeBlocked = 0 - $stInProgress = 0 - $stReadyForUpdate = 0 - $stUpToDate = 0 - $stOther = 0 - foreach ($c in $readiness) { - if ($c.UpdateState -in @('Failed','UpdateFailed','NeedsAttention')) { $stUpdateFailed++ } - elseif ($c.UpdateState -eq 'PreparationFailed') { $stActionRequired++ } - elseif ($c.HealthState -eq 'Failure') { $stHealthFailure++ } - elseif ($c.HasPrerequisiteUpdates) { $stSbeBlocked++ } - elseif ($c.UpdateState -in @('UpdateInProgress','PreparationInProgress')) { $stInProgress++ } - elseif ($c.ReadyForUpdate -eq $true) { $stReadyForUpdate++ } - elseif ($c.UpdateState -in @('UpToDate','AppliedSuccessfully')) { $stUpToDate++ } - else { $stOther++ } - } - - # Legacy variable names (kept for backwards-compatible pipeline outputs) - $inProgress = $stInProgress - $ready = $stReadyForUpdate - $upToDate = $stUpToDate - - # Fleet Version Distribution - pivot clusters by CurrentVersion - # so the JUnit XML (and the markdown summary) lead with "what versions - # are presently installed across the fleet, and what % of clusters - # are on each one". One row per distinct CurrentVersion, sorted by - # cluster count (descending). SupportStatus is a fleet-derived heuristic: - # Supported = YYMM in top-6 most-recent distinct YYMM values across the fleet - # Unsupported = parseable YYMM older than the top-6 - # Unknown = CurrentVersion empty / malformed / no YYMM token - # YYMM token must match regex '^[0-9]{4}$'. Always cross-check against - # the Microsoft Azure Local lifecycle docs before operator action. - $versionGroups = @($readiness | Group-Object { - if ([string]::IsNullOrWhiteSpace($_.CurrentVersion)) { '(unknown)' } else { [string]$_.CurrentVersion } - } | Sort-Object Count -Descending) - $versionDistribution = @($versionGroups | ForEach-Object { - $clusterNames = @($_.Group | Sort-Object ClusterName | ForEach-Object { [string]$_.ClusterName }) - [PSCustomObject]@{ - Version = $_.Name - Count = $_.Count - Percentage = if ($totalTests -gt 0) { [math]::Round(($_.Count / $totalTests) * 100, 1) } else { 0 } - Clusters = ($clusterNames -join ', ') - } - }) - $distinctVersions = $versionDistribution.Count - $mostCommonVersion = if ($distinctVersions -gt 0) { $versionDistribution[0].Version } else { '(none)' } - $mostCommonVersionPct = if ($distinctVersions -gt 0) { $versionDistribution[0].Percentage } else { 0 } - - # Compute the rolling 6-month YYMM support window used by SupportStatus. - # Preferred anchor: the latest released Azure Local solution version from the - # Microsoft public catalog (https://aka.ms/AzureEdgeUpdates), via the - # Get-AzLocalLatestSolutionVersion cmdlet. Fallback: top-6 distinct YYMM - # values observed across the fleet. As soon as Microsoft publishes any release - # with a newer YYMM (e.g. 2604), the window slides forward and older YYMM - # values (e.g. 2510) fall out, even if no cluster has installed the new release. - $supportSource = 'fleet-observed' - $latestReleasedYymm = '' - $latestReleasedVersion = '' - $manifestFetchedAt = '' - $manifestUrl = 'https://aka.ms/AzureEdgeUpdates' - $manifestError = '' - try { - Write-Host "Querying $manifestUrl for the latest released Azure Local solution version..." - $manifestProbe = Get-AzLocalLatestSolutionVersion -ErrorAction Stop - $supportedYymms = @($manifestProbe.SupportedYYMMs) - $supportSource = 'Microsoft manifest' - $latestReleasedYymm = [string]$manifestProbe.LatestYYMM - $latestReleasedVersion = [string]$manifestProbe.LatestVersion - $manifestFetchedAt = $manifestProbe.ManifestFetchedAt.ToString('o') - Write-Host ("Latest released solution version: {0} (YYMM={1}); support window anchored on Microsoft manifest." -f $latestReleasedVersion, $latestReleasedYymm) - } catch { - $manifestError = $_.Exception.Message - Write-Host "##vso[task.logissue type=warning]Failed to query $manifestUrl - falling back to fleet-observed top-6 YYMM window. Error: $manifestError" - $observedYymms = @($versionDistribution | ForEach-Object { - $parts = ([string]$_.Version) -split '\.' - if ($parts.Count -ge 2) { $parts[1] } else { '' } - } | Where-Object { $_ -match '^[0-9]{4}$' } | Sort-Object -Unique) - $supportedYymms = @($observedYymms | Sort-Object -Descending | Select-Object -First 6) - } - $supportedCount = 0 - $unsupportedCount = 0 - $unknownVersionCount = 0 - foreach ($v in $versionDistribution) { - $parts = ([string]$v.Version) -split '\.' - $yymm = if ($parts.Count -ge 2) { $parts[1] } else { '' } - $supportStatus = if ($yymm -match '^[0-9]{4}$') { - if ($supportedYymms -contains $yymm) { 'Supported' } else { 'Unsupported' } - } else { 'Unknown' } - $v | Add-Member -MemberType NoteProperty -Name Yymm -Value $yymm -Force - $v | Add-Member -MemberType NoteProperty -Name SupportStatus -Value $supportStatus -Force - switch ($supportStatus) { - 'Supported' { $supportedCount += [int]$v.Count } - 'Unsupported' { $unsupportedCount += [int]$v.Count } - 'Unknown' { $unknownVersionCount += [int]$v.Count } - } - } - $supportedYymmWindow = if ($supportedYymms.Count -gt 0) { ($supportedYymms -join ',') } else { '(none)' } - - Write-Host "" - Write-Host "Fleet Version Distribution ($distinctVersions distinct version(s) across $totalTests cluster(s)):" -ForegroundColor Cyan - Write-Host ("Support source: {0}" -f $supportSource) - if ($latestReleasedYymm) { - Write-Host ("Latest released YYMM (Microsoft manifest): {0} ({1})" -f $latestReleasedYymm, $latestReleasedVersion) - } - Write-Host ("Supported YYMM window (top {0}): {1}" -f $supportedYymms.Count, $supportedYymmWindow) - foreach ($v in $versionDistribution) { - Write-Host (" {0,-30} {1,5} cluster(s) ({2,5}%) [{3}]" -f $v.Version, $v.Count, $v.Percentage, $v.SupportStatus) - } - - $xmlContent = @" - - - - - - - - - - - - - - - - - - - - - "@ - foreach ($v in $versionDistribution) { - $vNameEsc = ([string]$v.Version) -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"' - $vTestName = "Version-$vNameEsc" - $vClustersEsc = ([string]$v.Clusters) -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"' - $vSystemOut = "Version: $($v.Version)`nYYMM: $($v.Yymm)`nSupportStatus: $($v.SupportStatus)`nClusters: $($v.Count)`nPercentage: $($v.Percentage)%`nClusterNames: $($v.Clusters)" - $vSystemOut = $vSystemOut -replace '&','&' -replace '<','<' -replace '>','>' - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - } - $xmlContent += " `n" - - $xmlContent += @" - - - - - - - - - - - - - - - - - "@ - - # bucket clusters into 'failed' vs 'passed' BEFORE emission so the Azure DevOps - # Tests tab renders all failed clusters first (in the run summary, in source order). - # Within each bucket clusters are sorted by ClusterName for stable, scannable output. - $failedClusters = @() - $passedClusters = @() - foreach ($cluster in $readiness) { - if ($cluster.HealthState -eq "Failure" -or ($cluster.UpdateState -in $failureStates) -or ($cluster.HasPrerequisiteUpdates -ne "")) { - $failedClusters += $cluster - } else { - $passedClusters += $cluster - } - } - $orderedClusters = @($failedClusters | Sort-Object ClusterName) + @($passedClusters | Sort-Object ClusterName) - - foreach ($cluster in $orderedClusters) { - $clusterName = $cluster.ClusterName -replace '&', '&' -replace '<', '<' -replace '>', '>' - $testName = "UpdateStatus-$clusterName" - $className = "AzureLocalFleetUpdateStatus.$($cluster.ResourceGroup)" - - $status = "passed" - $failureMessage = "" - $failureType = "UpdateFailure" - - if ($cluster.HealthState -eq "Failure" -or ($cluster.UpdateState -in $failureStates)) { - $status = "failed" - $failureType = if ($cluster.UpdateState -eq 'PreparationFailed') { "PreparationFailed" } else { "UpdateFailure" } - $failureMessage = "UpdateState: $($cluster.UpdateState), Health: $($cluster.HealthState)" - if ($cluster.HealthCheckFailures) { - $failureMessage += ", Issues: $($cluster.HealthCheckFailures -replace '&', '&' -replace '<', '<' -replace '>', '>')" - } - } - elseif ($cluster.HasPrerequisiteUpdates -ne "") { - $status = "failed" - $failureType = "HasPrerequisite" - $failureMessage = "Updates blocked by SBE prerequisite: $($cluster.HasPrerequisiteUpdates -replace '&', '&' -replace '<', '<' -replace '>', '>')" - if ($cluster.SBEDependency) { - $failureMessage += " - Vendor: $($cluster.SBEDependency -replace '&', '&' -replace '<', '<' -replace '>', '>')" - } - } - - $systemOut = @" - Cluster: $($cluster.ClusterName) - Resource Group: $($cluster.ResourceGroup) - Subscription: $($cluster.SubscriptionId) - Update State: $($cluster.UpdateState) - Health State: $($cluster.HealthState) - Ready for Update: $($cluster.ReadyForUpdate) - Has Prerequisite Updates: $($cluster.HasPrerequisiteUpdates) - SBE Dependency: $($cluster.SBEDependency) - Recommended Update: $($cluster.RecommendedUpdate) - "@ - $systemOut = $systemOut -replace '&', '&' -replace '<', '<' -replace '>', '>' - - # per-testcase for the ITSM connector. The dedupe - # key in New-AzLocalIncident is SHA256(ClusterResourceId|UpdateName|Category); - # without these properties the connector reports Action='Skipped' - # with "Row missing ClusterResourceId or UpdateName". Status is - # mirrored so the trigger-matrix evaluator can pick the right - # severity/category for fleet-update-status rows. - $clusterResIdEsc = ([string]$cluster.ResourceId) -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"' - $recoUpdEsc = ([string]$cluster.RecommendedUpdate) -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"' - $clusterPortalEsc = if ($cluster.ResourceId) { "https://portal.azure.com/#@/resource$($cluster.ResourceId)" -replace '&','&' -replace '"','"' } else { '' } - $statusValue = if ($status -eq "failed") { $failureType } else { 'Success' } - - $xmlContent += @" - - - - - - - - - - - "@ - - if ($status -eq "failed") { - $xmlContent += @" - - $failureMessage - "@ - } - - $xmlContent += @" - - - "@ - } - - $xmlContent += @" - - - "@ - - # --------------------------------------------------------------- - # '📜 Update Run History and Error Details' testsuite. - # ARG-first Update Run History projection (fleet-scale) - # table. Each Failed/unresolved update run becomes a testcase with - # the verbose error details (DeepestErrMsg) as the failure body - # and Status / CurrentStep / Duration / PortalLink in system-out. - # The cmdlet's -OnlyUnresolved filter dedupes against any later - # Succeeded run on the same (cluster, update) pair (dedup aligned with - # default UX. - # --------------------------------------------------------------- - Write-Host "" - Write-Host "Step 3b: Collecting Update Run History + Verbose Error Details (ARG-first, fleet-scale)..." -ForegroundColor Yellow - $runFailures = @() - try { - $runFailures = @(Get-AzLocalUpdateRunFailures -State Failed -OnlyUnresolved -Since (Get-Date).AddDays(-30)) - } - catch { - Write-Warning "Get-AzLocalUpdateRunFailures threw: $($_.Exception.Message). Continuing with empty run-history section." - $runFailures = @() - } - # v0.7.90 sort key alignment: order by StartTime DESC, then ClusterName ASC - # so CSV / JSON / JUnit / markdown / base64 blob all share the same - # canonical ordering (latest run first, ties broken alphabetically). - $runFailures = @($runFailures | Sort-Object @{Expression='StartTime';Descending=$true}, @{Expression='ClusterName';Descending=$false}) - $runHistoryCount = $runFailures.Count - Write-Host "Found $runHistoryCount unresolved Failed update run(s)." -ForegroundColor $(if ($runHistoryCount -gt 0) { 'Yellow' } else { 'Green' }) - - # Export run-history rows as CSV + JSON for the artifact bundle. - $runHistoryCsv = Join-Path $outputDir "update-run-history.csv" - $runHistoryJson = Join-Path $outputDir "update-run-history.json" - if ($runHistoryCount -gt 0) { - $runFailures | - Select-Object ClusterName, UpdateName, State, Status, CurrentStep, Duration, StartTime, LastUpdated, DeepestStepName, ErrorCategory, DeepestErrMsg, UpdateRunPortalUrl, ClusterResourceId, RunId | - Export-Csv -Path $runHistoryCsv -NoTypeInformation -Force - $runFailures | ConvertTo-Json -Depth 4 | Out-File $runHistoryJson -Encoding UTF8 - } - - # Build the run-history testsuite using string concat (avoids YAML/here-string nesting issues). - # v0.7.98: sum the per-row duration so the Update Run History testsuite reports a - # meaningful time= (was time="0"). Get-AzLocalUpdateRunFailures exposes DurationMinutes - # (numeric, from KQL datetime_diff('minute', EndTime, StartTime)). Convert to seconds and - # treat null/missing as 0. - $runHistoryTotalSeconds = 0 - foreach ($f in $runFailures) { - if ($f.PSObject.Properties['DurationMinutes'] -and $null -ne $f.DurationMinutes) { - [int]$rowSec = [int][math]::Round([double]$f.DurationMinutes * 60, 0) - if ($rowSec -lt 0) { $rowSec = 0 } - $runHistoryTotalSeconds += $rowSec - } - } - $xmlContent += "`n `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - - foreach ($f in $runFailures) { - $fcName = ([string]$f.ClusterName) -replace '&','&' -replace '<','<' -replace '>','>' - $fuName = ([string]$f.UpdateName) -replace '&','&' -replace '<','<' -replace '>','>' - $fStatus = ([string]$f.Status) -replace '&','&' -replace '<','<' -replace '>','>' - $fCurStep = ([string]$f.CurrentStep) -replace '&','&' -replace '<','<' -replace '>','>' - $fDuration = [string]$f.Duration - $fStart = [string]$f.StartTime - $fLast = [string]$f.LastUpdated - $fCategory = [string]$f.ErrorCategory - $fPortal = [string]$f.UpdateRunPortalUrl - $fErr = if ($f.DeepestErrMsg) { - if ($f.DeepestErrMsg.Length -gt 4000) { $f.DeepestErrMsg.Substring(0,4000) + ' ... (truncated)' } else { $f.DeepestErrMsg } - } else { '(no error message captured)' } - $fStackEsc = ([string]$f.StackTracePreview) -replace '&','&' -replace '<','<' -replace '>','>' - - $tcName = "$fcName - $fuName" - $tcClass = "UpdateRunHistory" - $tcSysOut = "Cluster: $fcName`nUpdate: $fuName`nUpdate State: Failed`nStatus: $fStatus`nCurrent Step: $fCurStep`nDuration: $fDuration`nTime Started: $fStart`nLast Updated: $fLast`nError Category: $fCategory`nPortal Link: $fPortal" - if ($f.StackTracePreview) { $tcSysOut += "`nStack Trace Preview: $fStackEsc" } - $tcSysOut = $tcSysOut -replace '&','&' -replace '<','<' -replace '>','>' - # v0.7.98: per-testcase time = run duration in seconds (DurationMinutes * 60). 0 only - # when StartTime/EndTime were null (rare; KQL leaves DurationMinutes null in that case). - [int]$tcSeconds = 0 - if ($f.PSObject.Properties['DurationMinutes'] -and $null -ne $f.DurationMinutes) { - $tcSeconds = [int][math]::Round([double]$f.DurationMinutes * 60, 0) - if ($tcSeconds -lt 0) { $tcSeconds = 0 } - } - - $xmlContent += "`n `n" - # properties for the ITSM connector dedupe key plus extended context - # (UpdateRunPortalUrl, ClusterPortalUrl, CurrentStep, - # Duration, ErrorCategory) so the optional incident-body template - # rendered by New-AzLocalIncident can deep-link straight into - # the SingleInstanceHistoryDetails ReactView for this failed run. - $fClusterResIdEsc = ([string]$f.ClusterResourceId) -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"' - $fClusterPortalEsc = if ($f.ClusterResourceId) { "https://portal.azure.com/#@/resource$($f.ClusterResourceId)" -replace '&','&' -replace '"','"' } else { '' } - $fPortalEsc = $fPortal -replace '&','&' -replace '"','"' - $fStatusEsc = ([string]$f.Status) -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"' - $fCurStepAttr = $fCurStep -replace '"','"' - $fDurationAttr = $fDuration -replace '"','"' - $fCategoryAttr = $fCategory -replace '"','"' - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - } - $xmlContent += " `n" - - $xmlContent += @" - - "@ - - $xmlContent | Out-File $readinessXml -Encoding UTF8 - Write-Host "JUnit XML saved to: $readinessXml" -ForegroundColor Green - - # Step 4: Collect additional fleet data using fleet-wide capabilities - # NOTE: Readiness (Step 2) already fetches summaries and available updates internally. - # Steps 4a/4b re-export this data in dedicated CSV files for analysis. - # Use ClusterResourceIds (not ClusterNames) to avoid redundant name-to-ID resolution. - - # Build resource ID list for downstream calls (avoids per-cluster name resolution) - $fleetResourceIds = @() - if ($scope -eq 'by-update-ring' -and $updateRing) { - # For tag-based scope, get IDs from readiness results - $fleetResourceIds = @($readiness | Where-Object { $_.SubscriptionId -and $_.ResourceGroup -and $_.ClusterName } | ForEach-Object { - "/subscriptions/$($_.SubscriptionId)/resourceGroups/$($_.ResourceGroup)/providers/Microsoft.AzureStackHCI/clusters/$($_.ClusterName)" - }) - } - else { - $fleetResourceIds = @($inventory | Select-Object -ExpandProperty ResourceId) - } - - # Step 4a: Get update summary across fleet - Write-Host "" - Write-Host "Step 4a: Collecting fleet update summaries..." -ForegroundColor Yellow - - $summaryCsv = Join-Path $outputDir "update-summaries.csv" - $summaries = Get-AzLocalUpdateSummary -ClusterResourceIds $fleetResourceIds -ExportPath $summaryCsv -PassThru - Write-Host "Update summaries collected for $($summaries.Count) cluster(s)" -ForegroundColor Green - - # Step 4b: Get available updates across fleet - Write-Host "" - Write-Host "Step 4b: Collecting available updates..." -ForegroundColor Yellow - - $availableCsv = Join-Path $outputDir "available-updates.csv" - $available = Get-AzLocalAvailableUpdates -ClusterResourceIds $fleetResourceIds -ExportPath $availableCsv -PassThru - Write-Host "Found $($available.Count) available update(s) across fleet" -ForegroundColor Green - - # Step 4c: Optionally collect recent update runs using fleet-wide query - if ($includeRuns) { - Write-Host "" - Write-Host "Step 4c: Collecting recent update run history..." -ForegroundColor Yellow - - $runsCsv = Join-Path $outputDir "update-runs.csv" - $allRuns = Get-AzLocalUpdateRuns -ClusterResourceIds $fleetResourceIds -Latest -ExportPath $runsCsv -PassThru - Write-Host "Update runs collected for $($allRuns.Count) cluster(s)" -ForegroundColor Green - - # Display summary - $succeeded = @($allRuns | Where-Object { $_.State -eq 'Succeeded' }).Count - $inProgressRuns = @($allRuns | Where-Object { $_.State -eq 'InProgress' }).Count - $failedRuns = @($allRuns | Where-Object { $_.State -eq 'Failed' }).Count - Write-Host " Succeeded: $succeeded, In Progress: $inProgressRuns, Failed: $failedRuns" - } - - # Step 5: Output summary - Write-Host "" - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "Fleet Status Summary" -ForegroundColor Cyan - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "Total Clusters: $totalTests" - Write-Host "" - Write-Host "Critical Health Status (matches JUnit XML pass/fail):" -ForegroundColor White - Write-Host " Passed: $criticalHealthPassed" -ForegroundColor Green - Write-Host " Failed: $failures" -ForegroundColor $(if ($failures -gt 0) { 'Red' } else { 'Green' }) - Write-Host "" - Write-Host "Primary Status (each cluster counted once; rows sum to Total):" -ForegroundColor White - Write-Host " Up to Date: $stUpToDate" -ForegroundColor Green - Write-Host " Ready for Update: $stReadyForUpdate" -ForegroundColor Cyan - Write-Host " Update In Progress: $stInProgress" -ForegroundColor Yellow - Write-Host " SBE Prerequisite Blocked: $stSbeBlocked" -ForegroundColor $(if ($stSbeBlocked -gt 0) { 'Yellow' } else { 'Green' }) - Write-Host " Health Failure: $stHealthFailure" -ForegroundColor $(if ($stHealthFailure -gt 0) { 'Red' } else { 'Green' }) - Write-Host " Update Failed (Failed/UpdateFailed/NeedsAttention): $stUpdateFailed" -ForegroundColor $(if ($stUpdateFailed -gt 0) { 'Red' } else { 'Green' }) - Write-Host " Action Required (PreparationFailed): $stActionRequired" -ForegroundColor $(if ($stActionRequired -gt 0) { 'Red' } else { 'Green' }) - Write-Host " Needs Investigation: $stOther" -ForegroundColor $(if ($stOther -gt 0) { 'Yellow' } else { 'Green' }) - - # Set pipeline variables - Write-Host "##vso[task.setvariable variable=totalClusters;isOutput=true]$totalTests" - Write-Host "##vso[task.setvariable variable=criticalHealthPassed;isOutput=true]$criticalHealthPassed" - Write-Host "##vso[task.setvariable variable=criticalHealthFailed;isOutput=true]$failures" - Write-Host "##vso[task.setvariable variable=upToDate;isOutput=true]$stUpToDate" - Write-Host "##vso[task.setvariable variable=inProgress;isOutput=true]$stInProgress" - Write-Host "##vso[task.setvariable variable=ready;isOutput=true]$stReadyForUpdate" - Write-Host "##vso[task.setvariable variable=sbeBlocked;isOutput=true]$stSbeBlocked" - Write-Host "##vso[task.setvariable variable=healthFailure;isOutput=true]$stHealthFailure" - Write-Host "##vso[task.setvariable variable=updateFailed;isOutput=true]$stUpdateFailed" - Write-Host "##vso[task.setvariable variable=actionRequired;isOutput=true]$stActionRequired" - Write-Host "##vso[task.setvariable variable=needsInvestigation;isOutput=true]$stOther" - Write-Host "##vso[task.setvariable variable=failures;isOutput=true]$failures" - Write-Host "##vso[task.setvariable variable=hasPrerequisite;isOutput=true]$hasPrerequisite" - Write-Host "##vso[task.setvariable variable=runHistoryCount;isOutput=true]$runHistoryCount" - - # persist version-distribution rows as a base64 JSON blob so - # the 'Display Status Summary' step can render the markdown table - # (with SupportStatus) without re-reading the CSV. - if ($distinctVersions -gt 0) { - $vTop = $versionDistribution | Select-Object Version, Yymm, SupportStatus, Count, Percentage, Clusters - $vJson = $vTop | ConvertTo-Json -Depth 3 -Compress - $vB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($vJson)) - Write-Host "##vso[task.setvariable variable=versionDistB64;isOutput=true]$vB64" - Write-Host "##vso[task.setvariable variable=versionDistCount;isOutput=true]$distinctVersions" - Write-Host "##vso[task.setvariable variable=supportedYymmWindow;isOutput=true]$supportedYymmWindow" - Write-Host "##vso[task.setvariable variable=supportedClusters;isOutput=true]$supportedCount" - Write-Host "##vso[task.setvariable variable=unsupportedClusters;isOutput=true]$unsupportedCount" - Write-Host "##vso[task.setvariable variable=unknownVersionClusters;isOutput=true]$unknownVersionCount" - Write-Host "##vso[task.setvariable variable=supportSource;isOutput=true]$supportSource" - Write-Host "##vso[task.setvariable variable=latestReleasedYymm;isOutput=true]$latestReleasedYymm" - Write-Host "##vso[task.setvariable variable=latestReleasedVersion;isOutput=true]$latestReleasedVersion" - } else { - Write-Host "##vso[task.setvariable variable=versionDistB64;isOutput=true]" - Write-Host "##vso[task.setvariable variable=versionDistCount;isOutput=true]0" - Write-Host "##vso[task.setvariable variable=supportedYymmWindow;isOutput=true]" - Write-Host "##vso[task.setvariable variable=supportedClusters;isOutput=true]0" - Write-Host "##vso[task.setvariable variable=unsupportedClusters;isOutput=true]0" - Write-Host "##vso[task.setvariable variable=unknownVersionClusters;isOutput=true]0" - Write-Host "##vso[task.setvariable variable=supportSource;isOutput=true]$supportSource" - Write-Host "##vso[task.setvariable variable=latestReleasedYymm;isOutput=true]$latestReleasedYymm" - Write-Host "##vso[task.setvariable variable=latestReleasedVersion;isOutput=true]$latestReleasedVersion" - } - - # persist update-run-history rows as a base64 JSON blob so the - # 'Display Status Summary' step can render the markdown table without - # re-querying ARG. Truncated to top 25 failures to keep the variable size sane. - # include ClusterPortalUrl so the markdown table can render - # Cluster Name as a deep link into the Azure portal cluster blade. - if ($runHistoryCount -gt 0) { - $top = $runFailures | Select-Object -First 25 ClusterName, ClusterPortalUrl, UpdateName, State, Status, CurrentStep, Duration, StartTime, LastUpdated, DeepestErrMsg, UpdateRunPortalUrl - $json = $top | ConvertTo-Json -Depth 4 -Compress - $b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json)) - Write-Host "##vso[task.setvariable variable=runHistoryB64;isOutput=true]$b64" - } else { - Write-Host "##vso[task.setvariable variable=runHistoryB64;isOutput=true]" - } - - if ($failures -gt 0) { - Write-Warning " $failures cluster(s) have update failures or health issues!" + $params = @{ + Scope = if ($env:INPUT_SCOPE) { $env:INPUT_SCOPE } else { 'all' } + IncludeUpdateRuns = ($env:INPUT_INCLUDE_UPDATE_RUNS -ne 'false') + InstalledModuleVersion = $env:INSTALLED_MODULE_VERSION } - name: collect + if ($env:INPUT_UPDATE_RING) { $params['UpdateRing'] = $env:INPUT_UPDATE_RING } + Export-AzLocalFleetUpdateStatusReport @params # compute a UTC timestamp variable so every downloadable artifact name is unique per run. - pwsh: | @@ -853,245 +206,6 @@ stages: ArtifactName: 'azlocal-step.8-fleet-update-status-report_$(stamp.artifactStamp)' publishLocation: 'Container' - # Create summary in pipeline - - pwsh: | - $total = "$(collect.totalClusters)" - $chPassed = "$(collect.criticalHealthPassed)" - $chFailed = "$(collect.criticalHealthFailed)" - $upToDate = "$(collect.upToDate)" - $inProgress = "$(collect.inProgress)" - $ready = "$(collect.ready)" - $sbeBlocked = "$(collect.sbeBlocked)" - $healthFailure = "$(collect.healthFailure)" - $updateFailed = "$(collect.updateFailed)" - $actionRequired = "$(collect.actionRequired)" - $needsInvestigation = "$(collect.needsInvestigation)" - $failures = "$(collect.failures)" - $hasPrerequisite = "$(collect.hasPrerequisite)" - $versionDistB64 = "$(collect.versionDistB64)" - $versionDistCount = "$(collect.versionDistCount)" - $supportedYymmWin = "$(collect.supportedYymmWindow)" - $supportedClusters = "$(collect.supportedClusters)" - $unsupportedClusters = "$(collect.unsupportedClusters)" - $unknownVersionClusters = "$(collect.unknownVersionClusters)" - $supportSource = "$(collect.supportSource)" - $latestReleasedYymm = "$(collect.latestReleasedYymm)" - $latestReleasedVersion = "$(collect.latestReleasedVersion)" - $generatedUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss UTC') - - Write-Host "##vso[task.uploadsummary]Fleet Status: $total clusters, $chPassed healthy / $chFailed failed (CriticalHealthStatus)" - - # Render the Overall Fleet Update Status (Version Distribution) - # table FIRST so it leads the summary, matching the position of the - # 'Fleet Version Distribution' testsuite at the top of the JUnit XML. - $versionSection = '' - if ($versionDistCount -and [int]$versionDistCount -gt 0 -and $versionDistB64) { - try { - $vRows = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($versionDistB64)) | ConvertFrom-Json - $versionSection += "### Overall Fleet Update Status (Version Distribution)`n`n" - $versionSection += "_One row per distinct ``YYMM`` (year-month) derived from ``Get-AzLocalClusterUpdateReadiness.CurrentVersion``. The **Update Versions** column lists the distinct full versions installed within that YYMM with `` x `` per line. Sorted by Version (YYMM) ascending - oldest at top. Percentage is share of the $total cluster(s) in scope._`n`n" - if ($supportSource -eq 'Microsoft manifest' -and $latestReleasedYymm) { - $versionSection += "_``Support`` uses the **rolling 6-month YYMM window anchored on the Microsoft public catalog** (aka.ms/AzureEdgeUpdates). Latest released YYMM = **$latestReleasedYymm** (``$latestReleasedVersion``); supported window = ``$supportedYymmWin``. As soon as Microsoft publishes a release with a newer YYMM, the window slides forward and the oldest in-window YYMM falls out._`n`n" - $versionSection += "_**Supported** = YYMM is inside the manifest-anchored window; **Unsupported** = older YYMM; **Unknown** = ``CurrentVersion`` empty or not in ``.YYMM.X.X`` form. Always cross-check against the Azure Local lifecycle cadence and the Azure Local release information before taking action._`n`n" - } else { - $versionSection += "_``Support`` falls back to a **fleet-observed heuristic** (aka.ms/AzureEdgeUpdates was unreachable from the agent). **Supported** = YYMM is in the top-6 most-recent distinct YYMM values observed across the fleet (``$supportedYymmWin``); **Unsupported** = older YYMM; **Unknown** = ``CurrentVersion`` empty or not in ``.YYMM.X.X`` form. Always cross-check against the Azure Local lifecycle cadence and the Azure Local release information before taking action._`n`n" - } - $versionSection += "| Version | Update Versions | Support | Cluster Count | Percentage | Clusters (first 15 shown only) |`n" - $versionSection += "|---------|-----------------|---------|---------------|------------|----------------------------------|`n" - # Group rows by Yymm so the table summarises by year-month with a - # nested list of full versions installed within each YYMM. Known - # YYMM values sort ascending (oldest at top per operator request); - # malformed/empty YYMM rows render last under '(unknown)'. - $vRowsAll = @($vRows) - $yymmGroups = @($vRowsAll | Group-Object -Property Yymm) - $knownGroups = @($yymmGroups | Where-Object { $_.Name -match '^[0-9]{4}$' } | Sort-Object Name) - $unknownGroups = @($yymmGroups | Where-Object { -not ($_.Name -match '^[0-9]{4}$') }) - $orderedGroups = @($knownGroups) + @($unknownGroups) - foreach ($g in $orderedGroups) { - $yymmDisplay = if ([string]::IsNullOrWhiteSpace($g.Name)) { '(unknown)' } else { [string]$g.Name } - # Within a YYMM, newest patch level first (descending full version). - $groupRows = @($g.Group | Sort-Object -Property Version -Descending) - $totalCount = ($groupRows | Measure-Object -Property Count -Sum).Sum - $totalPct = [math]::Round((($groupRows | Measure-Object -Property Percentage -Sum).Sum), 1) - # All clusters with the same YYMM share the same SupportStatus - # (SupportStatus is derived from YYMM only) so picking the first - # row's value is safe. - $supportStatusForYymm = [string]$groupRows[0].SupportStatus - $supportEmoji = switch ($supportStatusForYymm) { - 'Supported' { '✅ Supported' } - 'Unsupported' { '⚠️ Unsupported' } - default { '❓ Unknown' } - } - # Build the multi-line 'Update Versions' cell. Each line is - # ' x '. Markdown tables render '
' - # as a line break inside a cell. - $updateVersionsCell = (($groupRows | ForEach-Object { - '{0} x {1}' -f $_.Version, $_.Count - }) -join '
') - # Union of cluster names across versions in this YYMM. Each - # cluster appears in exactly one CurrentVersion row, so a - # simple concat + sort -Unique is sufficient. - $clusterNames = @() - foreach ($r in $groupRows) { - if ($r.Clusters) { - $clusterNames += @($r.Clusters -split '; ' | Where-Object { $_ }) - } - } - $clusterNames = @($clusterNames | Sort-Object -Unique) - $clCell = if ($clusterNames.Count -le 15) { - $clusterNames -join '; ' - } else { - (($clusterNames | Select-Object -First 15) -join '; ') + (' ... (+{0} more)' -f ($clusterNames.Count - 15)) - } - $clCell = $clCell -replace '\|','\|' - $versionSection += ('| {0} | {1} | {2} | {3} | {4}% | {5} |' -f $yymmDisplay, $updateVersionsCell, $supportEmoji, $totalCount, $totalPct, $clCell) + "`n" - } - $versionSection += "`n_Support roll-up: Supported = $supportedClusters cluster(s), Unsupported = $unsupportedClusters, Unknown = $unknownVersionClusters. Anchor source: ``$supportSource``._`n" - $versionSection += "_Note: the **Clusters (first 15 shown only)** column is intentionally truncated for readability. Download ``readiness-status.csv`` from artifacts to see the full cluster-name list._`n`n" - } catch { - Write-Warning "Failed to render Fleet Version Distribution markdown table: $($_.Exception.Message)" - $versionSection = '' - } - } - - $summary = @" - ## Fleet Update Status Summary _(generated $generatedUtc)_ - - $versionSection - ### Critical Health Status (matches JUnit XML pass/fail) - - | Metric | Count | Status | - |--------|-------|--------| - | **Passed** (healthy, no failures, not SBE-blocked) | $chPassed | ✅ | - | **Failed** (HealthState=Failure OR UpdateState=Failed OR SBE prerequisite blocked) | $chFailed | $(if ([int]$chFailed -gt 0) { '❌' } else { '✅' }) | - - ### Primary Status (each cluster counted once; rows sum to Total Clusters) - - | Metric | Count | Status | - |--------|-------|--------| - | **Total Clusters** | $total | ℹ️ | - | **Up to Date** | $upToDate | ✅ | - | **Ready for Update** | $ready | 🟢 | - | **Update In Progress** | $inProgress | 🔄 | - | **SBE Prerequisite Blocked** | $sbeBlocked | $(if ([int]$sbeBlocked -gt 0) { '🟡' } else { '✅' }) | - | **Health Failure** (HealthState=Failure) | $healthFailure | $(if ([int]$healthFailure -gt 0) { '❌' } else { '✅' }) | - | **Update Failed** (Failed / UpdateFailed / NeedsAttention) | $updateFailed | $(if ([int]$updateFailed -gt 0) { '❌' } else { '✅' }) | - | **Action Required** (PreparationFailed) | $actionRequired | $(if ([int]$actionRequired -gt 0) { '❌' } else { '✅' }) | - | **Needs Investigation** | $needsInvestigation | $(if ([int]$needsInvestigation -gt 0) { '⚠️' } else { '✅' }) | - - Priority cascade used to bucket each cluster exactly once: - Update Failed > Action Required > Health Failure > SBE Prerequisite Blocked > Update In Progress > Ready for Update > Up to Date > Needs Investigation. - "@ - - # Actions Required section - $actionsRequired = @() - if ([int]$updateFailed -gt 0) { $actionsRequired += "- **$updateFailed cluster(s) have UpdateState in (Failed, UpdateFailed, NeedsAttention)** - Review the most recent update run in Azure Portal and the ``update-runs.csv`` artifact for the failure detail." } - if ([int]$actionRequired -gt 0) { $actionsRequired += "- **$actionRequired cluster(s) have UpdateState=PreparationFailed** - Update preparation hit a blocker before the run started. Inspect the cluster activity log (most common: prerequisite-check failure, missing SBE content, ARB extension drift). Resolve before re-arming the update." } - if ([int]$healthFailure -gt 0) { $actionsRequired += "- **$healthFailure cluster(s) have HealthState=Failure** - Resolve critical health check issues in Azure Portal before retrying any updates." } - if ([int]$hasPrerequisite -gt 0) { $actionsRequired += "- **$hasPrerequisite cluster(s) blocked by SBE prerequisite** - Install the required Solution Builder Extension (SBE) update from the hardware vendor. See ``available-updates.csv`` for vendor details." } - - if ($actionsRequired.Count -gt 0) { - $summary += "`n`n### Actions Required`n`n" - $summary += ($actionsRequired -join "`n") - } - - # '📜 Update Run History and Error Details' markdown table - # provides an ARG-first fleet-scale view of recent unresolved failures. Rendered only when there - # are unresolved failed runs. Reads the base64 JSON blob set by the - # collect step so we don't re-query ARG. - # Cluster Name renders as a portal hyperlink (per-row - # ClusterPortalUrl from ARG) so the operator can jump straight to the - # cluster blade. Verbose Error Details renders inside an inline - #
......
block so the full - # parser/orchestrator stack is preserved (no 250-char truncation) but - # the table stays scannable - rows expand on click. HTML-special chars - # in the error text are escaped to keep the renderer honest. - $runHistoryCount = "$(collect.runHistoryCount)" - $runHistoryB64 = "$(collect.runHistoryB64)" - if ($runHistoryCount -and ([int]$runHistoryCount) -gt 0 -and $runHistoryB64) { - try { - $rows = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($runHistoryB64)) | ConvertFrom-Json - # v0.7.90 dedupe: collapse back-to-back identical failures so a - # cluster with N re-runs of the same update that all hit the - # same root-cause message shows ONE canonical row (latest run) - # plus an 'Earlier failures' block inside the
. Group - # key = (ClusterName, UpdateName, DeepestErrMsg). Each group's - # canonical row is the LATEST StartTime; the other rows are - # summarised in a small 'Earlier occurrences' bullet list - # appended to the verbose-error
block. This keeps the - # table scannable on fleets where the same step keeps failing. - $groups = @($rows) | Group-Object -Property @{ - Expression = { "$($_.ClusterName)|$($_.UpdateName)|$($_.DeepestErrMsg)" } - } - $renderRows = @() - foreach ($g in $groups) { - $ordered = @($g.Group | Sort-Object @{ Expression = 'StartTime'; Descending = $true }) - $latest = $ordered[0] - $extras = @($ordered | Select-Object -Skip 1) - $latest | Add-Member -NotePropertyName 'EarlierOccurrences' -NotePropertyValue $extras -Force - $renderRows += $latest - } - # Preserve top-of-table = latest run across the whole fleet - $renderRows = @($renderRows | Sort-Object @{Expression='StartTime';Descending=$true}, @{Expression='ClusterName';Descending=$false}) - $totalRows = (@($rows)).Count - $groupCount = $renderRows.Count - $dedupSuffix = if ($groupCount -lt $totalRows) { " (collapsed $totalRows run(s) into $groupCount unique failure group(s); older same-error reruns are listed inside each row's 'Show error' panel)" } else { '' } - $summary += "`n`n### 📜 Update Run History and Error Details`n`n" - $summary += "_ARG-first, fleet-scale failure-detail view. Shows up to 25 most recent unresolved Failed update runs (last 30 days). Source cmdlet: ``Get-AzLocalUpdateRunFailures -State Failed -OnlyUnresolved``.$dedupSuffix._`n`n" - $summary += "| Cluster Name | Update Name | Update State | Status | Current Step | Verbose Error Details | Duration | Time Started | Last Updated |`n" - $summary += "|---|---|---|---|---|---|---|---|---|`n" - foreach ($r in $renderRows) { - $errCell = if ($r.DeepestErrMsg) { - $e = [string]$r.DeepestErrMsg - # HTML-encode so '<', '>', '&' in stack traces don't get - # parsed as tags by the renderer; escape pipes so the - # table row stays intact; collapse newlines to
so - # multi-line stack traces remain readable inside the - # collapsible block. - $e = $e -replace '&','&' -replace '<','<' -replace '>','>' - $e = $e -replace "`r`n",'
' -replace "`n",'
' -replace '\|','\|' - $extraBlock = '' - if ($r.EarlierOccurrences -and @($r.EarlierOccurrences).Count -gt 0) { - $bullets = (@($r.EarlierOccurrences) | ForEach-Object { - $dur = if ($_.Duration) { " (Duration: $($_.Duration))" } else { '' } - "• Started $($_.StartTime), Last updated $($_.LastUpdated)$dur" - }) -join '
' - $extraBlock = '

Earlier occurrences with the same error (' + (@($r.EarlierOccurrences).Count) + '):
' + $bullets - } - '
Show error
' + $e + '' + $extraBlock + '
' - } else { '_(none)_' } - # Render hyperlinks as HTML so they - # open in a new tab and don't navigate the operator away - # from the pipeline run page (applies to both portal deep - # links and Microsoft Learn references throughout this - # summary). rel="noopener noreferrer" is standard hardening. - $clusterCell = if ($r.ClusterPortalUrl) { '{1}' -f $r.ClusterPortalUrl, $r.ClusterName } else { $r.ClusterName } - $updCell = if ($r.UpdateRunPortalUrl) { '{1}' -f $r.UpdateRunPortalUrl, $r.UpdateName } else { $r.UpdateName } - $summary += "| $clusterCell | $updCell | $($r.State) | $($r.Status) | $($r.CurrentStep) | $errCell | $($r.Duration) | $($r.StartTime) | $($r.LastUpdated) |`n" - } - } catch { - Write-Warning "Failed to render Update Run History markdown table: $($_.Exception.Message)" - } - } - - $summary += @" - - ### Reports Available in Artifacts - - ``readiness-status.csv`` - Detailed cluster status - - ``readiness-status.json`` - Machine-readable format - - ``readiness-status.xml`` - JUnit XML (see Tests tab; now includes the '📜 Update Run History and Error Details' suite) - - ``cluster-inventory.csv`` - Full cluster inventory - - ``update-summaries.csv`` - Fleet-wide update state summaries - - ``available-updates.csv`` - All available updates across fleet - - ``update-runs.csv`` - Recent update run history - - ``update-run-history.csv`` - Verbose error details for unresolved Failed update runs (ARG-first, fleet-scale) - - ``update-run-history.json`` - Same as above in JSON form for integrations - "@ - - Write-Host $summary - displayName: 'Display Status Summary' - condition: always() - # Publish JUnit test results LAST so the Test Results tab is populated # but the markdown summary above is the first thing visible on the run # extensions view. failTaskOnFailedTests is left $false because cluster 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 12c06eae..b8611cec 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.4' + GENERATED_AGAINST_MODULE_VERSION: '0.8.5' # 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). @@ -142,6 +142,9 @@ stages: - task: PowerShell@2 displayName: 'Install AzLocal.UpdateManagement from PSGallery' + # v0.8.5 thin-YAML: drift detection + banner + step outputs are all + # produced by Add-AzLocalPipelineVersionBanner (Public cmdlet). + name: moduleVersion env: REQUIRED_MODULE_VERSION: $(REQUIRED_MODULE_VERSION) GENERATED_AGAINST_MODULE_VERSION: $(GENERATED_AGAINST_MODULE_VERSION) @@ -159,46 +162,27 @@ stages: } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force - $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on agent : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - Write-Host "" - # Drift signal 0: the module installed at runtime is OLDER than the version this YAML was generated against. Cmdlets, parameters, or output schemas referenced by this YAML may not exist in the installed module - emitted as a warning because this is the most likely cause of a hard runtime failure later in the job. - if ($installed -lt $generated) { - Write-Host "##vso[task.logissue type=warning]AzLocal.UpdateManagement v$installed is OLDER than the version this pipeline YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Platform AzureDevOps -Update'." - } - if ($installed -gt $generated) { - Write-Host "##vso[task.logissue type=warning]Pipeline YAML was generated against AzLocal.UpdateManagement v$generated but the agent installed v$installed. Refresh via 'Copy-AzLocalPipelineExample -Platform AzureDevOps -Update'." - } - if ($latest -and ($latest -gt $installed)) { - Write-Host "##vso[task.logissue type=warning]AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed." - } + Add-AzLocalPipelineVersionBanner ` + -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION ` + -PinnedVersion $env:REQUIRED_MODULE_VERSION - # v0.8.4 - one-line version banner uploaded to the build run - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), - # the module version actually loaded on the agent, the PSGallery - # latest, and the REQUIRED_MODULE_VERSION pin status are visible - # at the top of every run summary without scrolling the agent - # log. Same data is also echoed to the console above. - $pin = if ($env:REQUIRED_MODULE_VERSION) { "pinned to v$($env:REQUIRED_MODULE_VERSION)" } else { 'latest (fix-forward)' } - $latestStr = if ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } - $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } - elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } - elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } - else { 'in sync' } - $bannerPath = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'module-version-banner.md' - "_Pipeline YAML v$generated | Module v$installed installed ($pin) | PSGallery latest $latestStr | $verdict_" | Out-File -FilePath $bannerPath -Encoding utf8 - Write-Host "##vso[task.uploadsummary]$bannerPath" - task: AzureCLI@2 - displayName: 'Collect Fleet Health Failures' + displayName: 'Collect Fleet Health Status' + # v0.8.5 thin-YAML: the inline run block (Get-AzLocalFleetHealthFailures + # Detail view + in-process Group-Object summary roll-up + + # Get-AzLocalFleetHealthOverview + 2-suite JUnit XML emission + + # 4-section markdown step summary written via ##vso[task.uploadsummary] + + # 8 step outputs) has been condensed into the Public cmdlet + # Export-AzLocalFleetHealthStatusReport. The cmdlet writes + # $(reportsPath)/*.{json,csv,xml} for downstream Publish + ITSM steps. + name: collect + env: + INPUT_SCOPE: ${{ parameters.scope }} + INPUT_UPDATE_RING: ${{ parameters.updateRing }} + INPUT_SEVERITY: ${{ parameters.severity }} + INSTALLED_MODULE_VERSION: $(moduleVersion.installed_module_version) inputs: - # Replace with your service connection name azureSubscription: 'AzureLocal-ServiceConnection' # Update with your service connection name scriptType: 'pscore' scriptLocation: 'inlineScript' @@ -206,198 +190,15 @@ stages: $ErrorActionPreference = 'Stop' az extension add --name resource-graph --yes Import-Module AzLocal.UpdateManagement -Force - - $outputDir = "$(reportsPath)" - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - - $scope = "${{ parameters.scope }}" - $ring = "${{ parameters.updateRing }}" - $severity = "${{ parameters.severity }}" - - Write-Host "========================================" - Write-Host "Fleet Health Status Collection" - Write-Host "========================================" - Write-Host "Scope : $scope" - Write-Host "Severity : $severity" - if ($scope -eq 'by-update-ring' -and $ring) { Write-Host "UpdateRing: $ring" } - Write-Host "" - - $argSplat = @{ Severity = $severity } - if ($scope -eq 'by-update-ring' -and $ring) { $argSplat.UpdateRingTag = $ring } - - # Pull DETAIL view once. Both the JUnit emitter below and the markdown - # "Detailed Results" table consume this same in-memory collection. - $detailCsv = Join-Path $outputDir 'fleet-health-detail.csv' - $detailJson = Join-Path $outputDir 'fleet-health-detail.json' - $detail = Get-AzLocalFleetHealthFailures -View Detail @argSplat -ExportPath $detailCsv -PassThru - if (-not $detail) { $detail = @() } - $detail | ConvertTo-Json -Depth 6 | Out-File -FilePath $detailJson -Encoding utf8 - - # SUMMARY view (grouped by FailureReason, Severity) - aggregated in-process. - # AffectedClusters uses '; ' separator (matches cmdlet); adds positionally-paired - # AffectedClusterPortalUrls so the markdown task below can zip them into hyperlinks. - # Severity sorts FIRST (Critical before Warning) regardless of cluster count. - $summary = @() - if ($detail.Count -gt 0) { - $summary = @( - $detail | Group-Object -Property FailureReason, Severity | ForEach-Object { - $first = $_.Group | Select-Object -First 1 - $clusterList = @($_.Group | Select-Object -ExpandProperty ClusterName -Unique | Sort-Object) - $clusterPortalUrls = @( - foreach ($cn in $clusterList) { - $portalRow = $_.Group | Where-Object { $_.ClusterName -eq $cn } | Select-Object -First 1 - if ($portalRow -and $portalRow.ClusterPortalUrl) { $portalRow.ClusterPortalUrl } else { '' } - } - ) - $latest = ($_.Group | Measure-Object -Property LastOccurrence -Maximum).Maximum - [PSCustomObject]@{ - FailureReason = $first.FailureReason - Severity = $first.Severity - ClusterCount = $clusterList.Count - FailureCount = $_.Group.Count - AffectedClusters = ($clusterList -join '; ') - AffectedClusterPortalUrls = ($clusterPortalUrls -join '; ') - LatestOccurrence = $latest - Description = $first.Description - Remediation = $first.Remediation - } - } | Sort-Object @{Expression={ if($_.Severity -eq 'Critical'){1}elseif($_.Severity -eq 'Warning'){2}else{3} };Descending=$false}, - @{Expression={$_.ClusterCount};Descending=$true}, - @{Expression={$_.FailureCount};Descending=$true} - ) - } - $summaryCsv = Join-Path $outputDir 'fleet-health-summary.csv' - $summaryJson = Join-Path $outputDir 'fleet-health-summary.json' - $summary | Export-Csv -Path $summaryCsv -NoTypeInformation -Force - $summary | ConvertTo-Json -Depth 6 | Out-File -FilePath $summaryJson -Encoding utf8 - - # ------------------------------------------------------------------ - # Emit JUnit XML pivoted by Severity -> FailureReason -> Cluster. - # ------------------------------------------------------------------ - $xmlPath = Join-Path $outputDir 'fleet-health-status.xml' - $criticalDetail = @($detail | Where-Object { $_.Severity -eq 'Critical' }) - $warningDetail = @($detail | Where-Object { $_.Severity -eq 'Warning' }) - $totalClusters = @($detail | Select-Object -ExpandProperty ClusterName -Unique).Count - $totalFailures = $detail.Count - $criticalCount = $criticalDetail.Count - $warningCount = $warningDetail.Count - - function Convert-XmlEscape([string]$s) { - if ($null -eq $s) { return '' } - ($s.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"').Replace("'",''')) - } - - $sb = New-Object System.Text.StringBuilder - [void]$sb.AppendLine('') - [void]$sb.AppendFormat('' + "`n", $totalFailures, $totalFailures) - foreach ($severityLabel in @('Critical','Warning')) { - $rows = if ($severityLabel -eq 'Critical') { $criticalDetail } else { $warningDetail } - if (-not $rows -or $rows.Count -eq 0) { continue } - $suiteName = "[JUnit Debug] $severityLabel Health Failures" - [void]$sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $suiteName), $rows.Count) - foreach ($r in $rows) { - $caseName = "{0} :: {1}" -f $r.ClusterName, $r.FailureReason - [void]$sb.AppendFormat(' ' + "`n", - (Convert-XmlEscape $r.ClusterName), - (Convert-XmlEscape $caseName)) - # per-testcase for the ITSM connector. - # The dedupe key is SHA256(ClusterResourceId | UpdateName | Category). - # Step.9 is not about updates - so we stuff FailureReason into the - # UpdateName slot. That gives one ticket per (cluster, failing health - # check) pair, which is the intended granularity for fleet-health. - $clusterResId = if ($r.PSObject.Properties.Match('ClusterResourceId').Count -gt 0) { [string]$r.ClusterResourceId } else { '' } - $clusterPortalUrl = if ($r.PSObject.Properties.Match('ClusterPortalUrl').Count -gt 0) { [string]$r.ClusterPortalUrl } else { '' } - $targetResName = if ($r.PSObject.Properties.Match('TargetResourceName').Count -gt 0) { [string]$r.TargetResourceName } else { '' } - $targetResType = if ($r.PSObject.Properties.Match('TargetResourceType').Count -gt 0) { [string]$r.TargetResourceType } else { '' } - [void]$sb.AppendLine(' ') - [void]$sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $r.ClusterName)) - [void]$sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $clusterResId)) - [void]$sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $r.FailureReason)) - [void]$sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $r.Severity)) - [void]$sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $r.FailureReason)) - [void]$sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $r.Severity)) - [void]$sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $clusterPortalUrl)) - [void]$sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $targetResName)) - [void]$sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $targetResType)) - [void]$sb.AppendLine(' ') - $msg = "{0}: {1} (last occurred {2:yyyy-MM-ddTHH:mm:ssZ})" -f $r.Severity, $r.FailureReason, $r.LastOccurrence - # failure body adds TargetResourceName, TargetResourceType, and ClusterPortalUrl - # so reviewers reading the JUnit XML can click straight to the cluster blade in the - # Azure portal and see which specific resource failed. - $bodyLines = @( - $r.Description, - $r.Remediation, - "ResourceGroup: $($r.ResourceGroup)", - "SubscriptionId: $($r.SubscriptionId)" - ) - if ($r.PSObject.Properties.Match('TargetResourceName').Count -gt 0 -and $r.TargetResourceName) { - $bodyLines += "TargetResourceName: $($r.TargetResourceName)" - } - if ($r.PSObject.Properties.Match('TargetResourceType').Count -gt 0 -and $r.TargetResourceType) { - $bodyLines += "TargetResourceType: $($r.TargetResourceType)" - } - if ($r.PSObject.Properties.Match('ClusterPortalUrl').Count -gt 0 -and $r.ClusterPortalUrl) { - $bodyLines += "ClusterPortalUrl: $($r.ClusterPortalUrl)" - } - $body = $bodyLines -join "`n" - [void]$sb.AppendFormat(' {2}' + "`n", - (Convert-XmlEscape $r.Severity), - (Convert-XmlEscape $msg), - (Convert-XmlEscape $body)) - [void]$sb.AppendLine(' ') - } - [void]$sb.AppendLine(' ') - } - if ($totalFailures -eq 0) { - [void]$sb.AppendLine(' ') - [void]$sb.AppendLine(' ') - [void]$sb.AppendLine(' ') + $params = @{ + Scope = if ($env:INPUT_SCOPE) { $env:INPUT_SCOPE } else { 'all' } + Severity = if ($env:INPUT_SEVERITY) { $env:INPUT_SEVERITY } else { 'All' } + OutputDirectory = "$(reportsPath)" + InstalledModuleVersion = $env:INSTALLED_MODULE_VERSION } - [void]$sb.AppendLine('') - $sb.ToString() | Out-File -FilePath $xmlPath -Encoding utf8 - - # ------------------------------------------------------------------ - # Fleet Health Overview (one row per cluster, ARG-first projection - # of cluster + updateSummaries for fleet-scale performance). Separate cmdlet from - # the failures view above so a fleet with zero failing checks still gets - # an overview-level rollup (last-checked age, current version, SBE - # version, Azure connection, node count). - # ------------------------------------------------------------------ - $overviewArgs = @{} - if ($scope -eq 'by-update-ring' -and $ring) { $overviewArgs.UpdateRingTag = $ring } - $overviewCsv = Join-Path $outputDir 'fleet-health-overview.csv' - $overviewJson = Join-Path $outputDir 'fleet-health-overview.json' - $overview = Get-AzLocalFleetHealthOverview @overviewArgs -ExportPath $overviewCsv -PassThru - if (-not $overview) { $overview = @() } - $overview | ConvertTo-Json -Depth 6 | Out-File -FilePath $overviewJson -Encoding utf8 - - # Persist counts for the summary task below. - "##vso[task.setvariable variable=totalClusters;isOutput=true]$totalClusters" | Write-Host - "##vso[task.setvariable variable=totalFailures;isOutput=true]$totalFailures" | Write-Host - "##vso[task.setvariable variable=criticalCount;isOutput=true]$criticalCount" | Write-Host - "##vso[task.setvariable variable=warningCount;isOutput=true]$warningCount" | Write-Host - "##vso[task.setvariable variable=distinctReasons;isOutput=true]$($summary.Count)" | Write-Host - "##vso[task.setvariable variable=overviewRows;isOutput=true]$($overview.Count)" | Write-Host + if ($env:INPUT_UPDATE_RING) { $params['UpdateRing'] = $env:INPUT_UPDATE_RING } + Export-AzLocalFleetHealthStatusReport @params - # surface fleet-wide totals so the summary table can report - # Total / Healthy / Unhealthy (rather than only the affected/unhealthy - # subset). 'Affected' in the failures view = clusters with at least one - # failing health check = unhealthy. Force [int] so .Count is reliable - # whether @() is 0/1/N elements (PowerShell scalar-unwrap guard). - $healthyClusters = [int](@($overview | Where-Object { $_.HealthStatus -eq 'Healthy' }).Count) - $totalInSub = [int](@($overview).Count) - "##vso[task.setvariable variable=healthyClusters;isOutput=true]$healthyClusters" | Write-Host - "##vso[task.setvariable variable=totalInSub;isOutput=true]$totalInSub" | Write-Host - - Write-Host "" - Write-Host "Fleet Health Collection complete:" - Write-Host " Total clusters in scope : $totalInSub" - Write-Host " Healthy clusters : $healthyClusters" - Write-Host " Unhealthy clusters : $totalClusters" - Write-Host " Total failing checks : $totalFailures (Critical=$criticalCount, Warning=$warningCount)" - Write-Host " Distinct failure reasons: $($summary.Count)" - Write-Host " Overview rows : $($overview.Count)" - name: collect # compute a UTC timestamp variable so every downloadable artifact name is unique per run. - pwsh: | @@ -419,224 +220,6 @@ stages: ArtifactName: 'azlocal-step.9-fleet-health-status-report_$(stamp.artifactStamp)' publishLocation: 'Container' - # Create the markdown summary FIRST (before PublishTestResults below) so the - # uploaded summary appears at the top of the pipeline run extensions view. - - pwsh: | - $total = "$(collect.totalClusters)" - $failures = "$(collect.totalFailures)" - $crit = "$(collect.criticalCount)" - $warn = "$(collect.warningCount)" - $reasons = "$(collect.distinctReasons)" - $ovRows = "$(collect.overviewRows)" - # fleet-wide totals from Get-AzLocalFleetHealthOverview. - $healthy = "$(collect.healthyClusters)" - $totalInSub = "$(collect.totalInSub)" - - Write-Host "##vso[task.uploadsummary]Fleet Health: $healthy of $totalInSub clusters Healthy, $total Unhealthy ($failures failing check(s); $crit Critical / $warn Warning); $reasons reason(s); overview rows = $ovRows" - - $summaryPath = Join-Path '$(reportsPath)' 'fleet-health-summary.csv' - $detailPath = Join-Path '$(reportsPath)' 'fleet-health-detail.csv' - $overviewPath = Join-Path '$(reportsPath)' 'fleet-health-overview.csv' - $summaryRows = if (Test-Path $summaryPath) { @(Import-Csv -Path $summaryPath) } else { @() } - $detailRows = if (Test-Path $detailPath) { @(Import-Csv -Path $detailPath) } else { @() } - $overviewRows = if (Test-Path $overviewPath) { @(Import-Csv -Path $overviewPath) } else { @() } - - $md = @" - ## Fleet Health Status Summary - - | Metric | Count | - |--------|-------| - | **Total Clusters in Subscription** | $totalInSub | - | **Healthy Clusters** | $healthy | - | **Unhealthy Clusters** | $total | - | **Total Failing Checks** | $failures | - | **Critical** | $crit | - | **Warning** | $warn | - | **Distinct Failure Reasons** | $reasons | - - > _**Healthy** / **Unhealthy** count clusters via ``Get-AzLocalFleetHealthOverview``; **Unhealthy** = at least one Critical or Warning health-check failure. **Total Failing Checks** counts individual failing checks (one cluster can contribute multiple)._ - "@ - - # Fleet Health Overview (fleet rollup, one row per cluster) - rendered - # immediately below the KPI summary table so administrators see the - # per-cluster context (Health status, Update Status, current version, - # Azure connection state, last-check timestamp + age) BEFORE diving - # into the failure breakdown. Sourced from Get-AzLocalFleetHealthOverview. - # The 'Health Check Age (days)' column surfaces clusters whose 24-hour - # health check has not run in a while (negative = no LastChecked at all). - $md += "`n### Fleet Health Overview (fleet rollup)`n" - if ($overviewRows.Count -eq 0) { - $md += "`n*No clusters returned from Get-AzLocalFleetHealthOverview.*`n" - } else { - $md += "`n| Cluster | Health | Update Status | Current Version | SBE Version | Azure Connection | Last Checked | Health Check Age (days) | Node Count |`n" - $md += "|---------|--------|---------------|------------------|--------------|------------------|---------------|--------------------------|------------|`n" - foreach ($o in ($overviewRows | Select-Object -First 100)) { - # Use HTML anchor with target="_blank" so clicking a portal - # link opens in a new tab and the operator does not lose the - # pipeline run page they are reviewing. rel="noopener noreferrer" - # is the standard hardening recommendation when opening a new - # tab from rendered output. - $clusterCell = if ($o.ClusterPortalUrl) { - '{0}' -f $o.ClusterName, $o.ClusterPortalUrl - } else { - $o.ClusterName - } - # replace bracketed labels with emoji icons (icon + text) so the - # at-a-glance Health column is visually scannable. Label is retained for - # greppability. Uses literal Unicode glyphs so GH + ADO render identically. - $healthTag = switch ($o.HealthStatus) { - 'Healthy' { "$([char]0x2705) Healthy" } # white heavy check mark - 'Critical' { "$([char]0x274C) Critical" } # cross mark - 'Warning' { "$([char]0x26A0)$([char]0xFE0F) Warning" } # warning sign + VS16 - 'In progress' { "$([char]0x23F3) In progress" } # hourglass with flowing sand - 'Health check failed' { "$([char]0x274C) Failed" } - default { '[' + $o.HealthStatus + ']' } - } - $md += ('| {0} | {1} | {2} | {3} | {4} | {5} | {6} | {7} | {8} |' -f $clusterCell, $healthTag, $o.UpdateStatus, $o.CurrentVersion, $o.SbeVersion, $o.AzureConnection, $o.LastChecked, $o.HealthResultsAgeDays, $o.NodeCount) + "`n" - } - if ($overviewRows.Count -gt 100) { - $md += "`n*Showing first 100 clusters of $($overviewRows.Count); see ``fleet-health-overview.csv`` artifact for the full list.*`n" - } - } - - $md += "`n### Health Check Failures By Reason (most widespread first)`n" - - if ($summaryRows.Count -eq 0) { - $md += "`n`n*No Critical or Warning health-check failures across the fleet.*`n" - } else { - $md += "`n`n| Severity | Failure Reason | Cluster Count | Failure Count | Affected Clusters | Latest |`n" - $md += "|----------|----------------|---------------|---------------|--------------------|--------|`n" - foreach ($r in ($summaryRows | Select-Object -First 25)) { - $sevTag = if ($r.Severity -eq 'Critical') { '[Critical]' } else { '[Warning]' } - # zip AffectedClusters with AffectedClusterPortalUrls to render markdown - # hyperlinks. Cap at first 10 names then '... (+N more)' so widespread failures stay - # readable; full list is in the CSV artifact. - # keep @() OUTSIDE the 'if' so single-element arrays do not unwrap to a - # String (PowerShell scalar-unwrap bug). Without this, a row with one cluster - # rendered as the first character of the cluster name only. - $names = @(if ($r.AffectedClusters) { $r.AffectedClusters -split '; ' } else { @() }) - $urls = @(if ($r.AffectedClusterPortalUrls) { $r.AffectedClusterPortalUrls -split '; ' } else { @() }) - $linkedParts = for ($i = 0; $i -lt $names.Count; $i++) { - $n = $names[$i] - $u = if ($i -lt $urls.Count) { $urls[$i] } else { '' } - # HTML anchor with target="_blank" so portal links open in a - # new tab and don't navigate the operator away from the - # pipeline run page. - if ($u) { '{0}' -f $n, $u } else { $n } - } - $clList = if ($linkedParts.Count -le 10) { - $linkedParts -join ', ' - } else { - (($linkedParts | Select-Object -First 10) -join ', ') + (' ... (+{0} more)' -f ($linkedParts.Count - 10)) - } - $md += ('| {0} | {1} | {2} | {3} | {4} | {5} |' -f $sevTag, $r.FailureReason, $r.ClusterCount, $r.FailureCount, $clList, $r.LatestOccurrence) + "`n" - } - if ($summaryRows.Count -gt 25) { - $md += "`n*Showing top 25 failure reasons of $($summaryRows.Count); see ``fleet-health-summary.csv`` artifact for the full list.*`n" - } - } - - $md += "`n### Detailed Results (per-cluster, per-failure)`n" - - if ($detailRows.Count -eq 0) { - $md += "`n*No detail rows.*`n" - } else { - # collapsible per-cluster view: one
block per cluster. The - # carries the cluster name (linkified to portal), a severity tally - # ('[Critical] x N · [Warning] x M', non-zero tiers only, Critical first) - # and the most-recent LastOccurrence across all that cluster's rows. The - # expanded body holds the existing per-failure table. - # - # Cluster ordering: CriticalCount desc, then WarningCount desc, then - # most-recent LastOccurrence desc (worst-cluster-first). Within a cluster, - # rows sort Critical-first then LastOccurrence desc. - # - # Cap is per-CLUSTER (not per-row) so no cluster's failures are silently - # truncated. CSV artifact always has every row. - $md += "`n*Click a cluster to expand its failing health checks. Worst-affected clusters appear first.*`n" - - $detailByCluster = @( - $detailRows | Group-Object -Property ClusterName | ForEach-Object { - $rows = @($_.Group) - $crit = @($rows | Where-Object { $_.Severity -eq 'Critical' }).Count - $warn = @($rows | Where-Object { $_.Severity -eq 'Warning' }).Count - $portalRow = $rows | Where-Object { - $_.PSObject.Properties.Match('ClusterPortalUrl').Count -gt 0 -and $_.ClusterPortalUrl - } | Select-Object -First 1 - $lastOcc = ($rows | Measure-Object -Property LastOccurrence -Maximum).Maximum - [PSCustomObject]@{ - ClusterName = $_.Name - ClusterPortalUrl = if ($portalRow) { [string]$portalRow.ClusterPortalUrl } else { '' } - CriticalCount = $crit - WarningCount = $warn - LastOccurrence = $lastOcc - Rows = @( - $rows | Sort-Object @{Expression={ if($_.Severity -eq 'Critical'){1}else{2} };Descending=$false}, - @{Expression={$_.LastOccurrence};Descending=$true} - ) - } - } | Sort-Object @{Expression={$_.CriticalCount};Descending=$true}, - @{Expression={$_.WarningCount};Descending=$true}, - @{Expression={$_.LastOccurrence};Descending=$true} - ) - - $totalClusters = $detailByCluster.Count - $clustersToShow = @($detailByCluster | Select-Object -First 100) - - foreach ($cl in $clustersToShow) { - $sevParts = @() - if ($cl.CriticalCount -gt 0) { $sevParts += ('[Critical] x {0}' -f $cl.CriticalCount) } - if ($cl.WarningCount -gt 0) { $sevParts += ('[Warning] x {0}' -f $cl.WarningCount) } - $sevTally = $sevParts -join '  ·  ' - - # target="_blank" so clicking the cluster name in the - # opens the portal in a new tab and leaves the pipeline run open. - $clusterCell = if ($cl.ClusterPortalUrl) { - '{1}' -f $cl.ClusterPortalUrl, $cl.ClusterName - } else { - $cl.ClusterName - } - $lastOccStr = if ($cl.LastOccurrence) { ('{0:yyyy-MM-ddTHH:mm:ssZ}' -f $cl.LastOccurrence) } else { '-' } - - $md += "`n
`n" - $md += "$clusterCell  ·  $sevTally  ·  last $lastOccStr`n`n" - $md += "| Severity | Failure Reason | Failure Remediation | Target Resource Name | Target Resource Type | Last Occurrence | Resource Group |`n" - $md += "|----------|----------------|---------------------|----------------------|----------------------|------------------|----------------|`n" - foreach ($r in $cl.Rows) { - $sevTag = if ($r.Severity -eq 'Critical') { '[Critical]' } else { '[Warning]' } - $rem = if ($r.PSObject.Properties.Match('Remediation').Count -gt 0) { [string]$r.Remediation } else { '' } - # Remediation links open in a new tab so the operator can - # read the Microsoft Learn / docs page without losing the - # pipeline run. - $remCell = if ($rem -and $rem.StartsWith('https://')) { 'link' -f $rem } else { $rem } - $tName = if ($r.PSObject.Properties.Match('TargetResourceName').Count -gt 0) { [string]$r.TargetResourceName } else { '' } - $tType = if ($r.PSObject.Properties.Match('TargetResourceType').Count -gt 0) { [string]$r.TargetResourceType } else { '' } - $md += ('| {0} | {1} | {2} | {3} | {4} | {5} | {6} |' -f $sevTag, $r.FailureReason, $remCell, $tName, $tType, $r.LastOccurrence, $r.ResourceGroup) + "`n" - } - $md += "`n
`n" - } - - if ($totalClusters -gt 100) { - $md += "`n*Showing first 100 clusters of $totalClusters; see ``fleet-health-detail.csv`` artifact for the full list.*`n" - } - } - - $md += @" - - ### Reports Available in Artifacts - - ``fleet-health-detail.csv`` - one row per (cluster, failing health check) - - ``fleet-health-summary.csv`` - one row per (FailureReason, Severity); ordered Critical-first, then by ClusterCount desc - - ``fleet-health-overview.csv`` - one row per cluster (ARG-first fleet health summary) - - ``fleet-health-detail.json`` / ``fleet-health-summary.json`` / ``fleet-health-overview.json`` - machine-readable - - ``fleet-health-status.xml`` - JUnit XML (see Tests tab) - - _Note: test sections prefixed **[JUnit Debug]** in the Tests tab are a diagnostic mirror of the tables above (for CI tooling/ITSM integration). For primary readability, use this summary and the CSV artifacts._ - "@ - - Write-Host $md - displayName: 'Display Fleet Health Summary' - condition: always() - # Publish JUnit test results LAST so the Test Results tab is populated but the # markdown summary above is the first thing visible on the run extensions view. - task: PublishTestResults@2 @@ -750,4 +333,3 @@ stages: testResultsFiles: '$(reportsPath)/itsm-results.xml' testRunTitle: 'ITSM Tickets (Step.9) - $(Build.BuildNumber)' failTaskOnFailedTests: false - 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 6e24c53d..9cace295 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.4' + GENERATED_AGAINST_MODULE_VERSION: '0.8.5' # 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). @@ -136,13 +136,16 @@ jobs: subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} - name: Install AzLocal.UpdateManagement from PSGallery - # Step.0 itself does not call the module (the auth/RBAC/ARG probes below use - # raw az CLI), but installing the module here gives operators an early, - # consistent drift signal *before* the rest of the seven pipelines run. - # If this step warns that the YAML is stale or the installed module is - # older than expected, refresh the YAMLs via Copy-AzLocalPipelineExample - # and re-run - same pattern as Step.1+. + # Step.0 itself relies on the module (Export-AzLocalAuthValidationReport + # runs the four auth/RBAC/ARG probes); installing here also gives + # operators an early, consistent drift signal *before* the rest of + # the seven pipelines run. If the banner reports the YAML is stale + # or the installed module is older than expected, refresh the YAMLs + # via Copy-AzLocalPipelineExample -Update and re-run. + # v0.8.5 thin-YAML: drift detection + banner + step outputs are all + # produced by Add-AzLocalPipelineVersionBanner (Public cmdlet). shell: pwsh + id: module-version run: | $ErrorActionPreference = 'Stop' $installArgs = @{ Name = 'AzLocal.UpdateManagement'; Scope = 'CurrentUser'; Force = $true; AllowClobber = $true } @@ -154,56 +157,9 @@ jobs: } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force - # Get-Module can return multiple entries when nested modules are loaded - pick the highest version. - $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on runner : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - Write-Host "" - Get-Module AzLocal.UpdateManagement | Format-List Name, Version, Path - # Drift signal 0: the module installed at runtime is OLDER than the version this YAML was generated against. Cmdlets, parameters, or output schemas referenced by this YAML may not exist in the installed module - emitted as a warning because this is the most likely cause of a hard runtime failure later in the job. - if ($installed -lt $generated) { - $msg = "AzLocal.UpdateManagement v$installed is OLDER than the version this workflow YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Update'." - Write-Host "::warning title=AzLocal.UpdateManagement is older than workflow YAML expects::$msg" - } - # Drift signal 1: the YAML in this repo is older than the module that was installed. - if ($installed -gt $generated) { - $msg = "Workflow YAML was generated against AzLocal.UpdateManagement v$generated but the runner installed v$installed. Pipeline steps may have been improved in later releases - to refresh, re-run 'Copy-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Update' (you will be prompted per file; add -Confirm:`$false to bypass). Pipeline YAMLs are under git so 'git diff' shows exactly what changed before commit." - Write-Host "::notice title=Workflow YAML may be stale::$msg" - } - # Drift signal 2: the user pinned (or PSGallery shipped a release mid-run) and the install is behind the latest. - if ($latest -and ($latest -gt $installed)) { - $msg = "AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed. Review the module CHANGELOG before bumping REQUIRED_MODULE_VERSION (or clear the pin to install the latest automatically)." - Write-Host "::notice title=Newer AzLocal.UpdateManagement version available on PSGallery::$msg" - } - - # v0.8.4 - one-line version banner emitted to the rendered Step - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), the - # module version actually loaded on the runner, the PSGallery latest, - # and the REQUIRED_MODULE_VERSION pin status are visible at the top - # of every run summary without scrolling the agent log. Same data is - # also echoed to the console above as 'Module version summary'. - $pin = if ($env:REQUIRED_MODULE_VERSION) { "pinned to v$($env:REQUIRED_MODULE_VERSION)" } else { 'latest (fix-forward)' } - $latestStr = if ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } - $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } - elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } - elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } - else { 'in sync' } - if ($env:GITHUB_STEP_SUMMARY) { - "_Pipeline YAML v$generated | Module v$installed installed ($pin) | PSGallery latest $latestStr | $verdict_" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - } - - # Persist the three versions so the report step (below) can include them - # in the JUnit XML + markdown summary alongside the auth/RBAC checks. - "installed_module_version=$installed" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "generated_against_version=$generated" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "latest_on_psgallery=$(if ($latest) { $latest } else { '' })" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - id: module-version + Add-AzLocalPipelineVersionBanner ` + -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION ` + -PinnedVersion $env:REQUIRED_MODULE_VERSION - name: Collect Authentication and Subscription Scope Report id: report @@ -213,151 +169,23 @@ jobs: INSTALLED_MODULE_VERSION: ${{ steps.module-version.outputs.installed_module_version }} GENERATED_AGAINST_VERSION: ${{ steps.module-version.outputs.generated_against_version }} LATEST_ON_PSGALLERY: ${{ steps.module-version.outputs.latest_on_psgallery }} + # v0.8.5 thin-YAML: the ~200-line inline run block (az probes, + # XML scaffolding, markdown summary, step-output writes) has been + # condensed into the Public cmdlet Export-AzLocalAuthValidationReport. + # The cmdlet performs all four probes (az account show / role + # assignment list / az account list / Resource Graph query), + # writes the JUnit XML + subscriptions.json + subscriptions.csv, + # emits the markdown step summary, and writes the three step + # outputs (subscription_count, cluster_count, auth_valid). run: | $ErrorActionPreference = 'Stop' - $reportDir = './reports' - New-Item -ItemType Directory -Path $reportDir -Force | Out-Null - - Write-Host '--- 1. az account show (proves OIDC token exchange + sub-id secret) ---' - $accountJson = az account show -o json - if ($LASTEXITCODE -ne 0) { throw "az account show failed (exit $LASTEXITCODE)" } - $account = $accountJson | ConvertFrom-Json - $account | Format-List name, id, tenantId, user - - Write-Host '' - Write-Host '--- 2. role assignments for AZURE_CLIENT_ID (proves RBAC grant) ---' - az role assignment list ` - --assignee $env:AZURE_CLIENT_ID ` - --all ` - -o table - - Write-Host '' - Write-Host '--- 3. Subscription Scope: enumerating all subscriptions visible to the pipeline identity ---' - # --refresh forces az to ask the server for the current sub list rather than using - # the cached tenant slice from `az login`. This is what makes the count authoritative. - $subsJson = az account list --refresh --query "[].{name:name, subscriptionId:id, tenantId:tenantId, state:state}" -o json - if ($LASTEXITCODE -ne 0) { throw "az account list failed (exit $LASTEXITCODE)" } - $subs = @($subsJson | ConvertFrom-Json | Sort-Object name) - $subCount = $subs.Count - Write-Host "Count of subscriptions accessible = $subCount" - $subs | Format-Table @{N='#';E={[array]::IndexOf($subs,$_)+1}}, name, subscriptionId, state -AutoSize - - # Persist machine-readable copies for the artifact upload. - $subs | ConvertTo-Json -Depth 4 | Out-File "$reportDir/subscriptions.json" -Encoding utf8 - $subs | Select-Object name, subscriptionId, tenantId, state | - Export-Csv "$reportDir/subscriptions.csv" -NoTypeInformation -Encoding utf8 - - Write-Host '' - Write-Host '--- 4. Resource Graph query (proves cluster reachability) ---' - az extension add --name resource-graph --yes --only-show-errors | Out-Null - $clusterJson = az graph query ` - -q "resources | where type =~ 'microsoft.azurestackhci/clusters' | project name, resourceGroup, subscriptionId" ` - --first 1000 ` - -o json - $clusterRows = @() - if ($LASTEXITCODE -eq 0 -and $clusterJson) { - $parsed = $clusterJson | ConvertFrom-Json - # `az graph query -o json` returns either a top-level array or a {data: [...]} object - # depending on CLI version - normalise. - if ($parsed -is [System.Collections.IEnumerable] -and -not ($parsed -is [string])) { - $clusterRows = @($parsed) - } elseif ($parsed.data) { - $clusterRows = @($parsed.data) - } - } - $clusterCount = $clusterRows.Count - Write-Host "Clusters visible to the pipeline identity = $clusterCount" - if ($clusterCount -gt 0) { - $clusterRows | Select-Object -First 10 | Format-Table -AutoSize - } - - # ------------------------------------------------------------------ - # Emit JUnit XML - # ------------------------------------------------------------------ - $now = Get-Date -Format 'yyyy-MM-ddTHH:mm:ss' - function ConvertTo-XmlSafe([string]$s) { - if ($null -eq $s) { return '' } - $s -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"' - } - - $xml = [System.Text.StringBuilder]::new() - [void]$xml.AppendLine('') - [void]$xml.AppendLine('') - - # Suite 1: Authentication - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(' ') - - # Suite 2: Subscription Scope - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(" ") - $i = 0 - foreach ($s in $subs) { - $i++ - $nm = ConvertTo-XmlSafe $s.name - $sid = ConvertTo-XmlSafe $s.subscriptionId - $st = ConvertTo-XmlSafe $s.state - [void]$xml.AppendLine(" ") - } - [void]$xml.AppendLine(' ') - - # Suite 3: Resource Graph Reachability - $rgTests = 1 - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(' ') - - # Suite 4: Module Version Drift (informational - never fails) - $installedVersion = $env:INSTALLED_MODULE_VERSION - $generatedVersion = $env:GENERATED_AGAINST_VERSION - $latestVersion = $env:LATEST_ON_PSGALLERY - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(" ") - [void]$xml.AppendLine(' ') - - [void]$xml.AppendLine('') - $xmlPath = "$reportDir/auth-report.xml" - [System.IO.File]::WriteAllText($xmlPath, $xml.ToString(), [System.Text.UTF8Encoding]::new($false)) - Write-Host "JUnit XML written to: $xmlPath" - - # ------------------------------------------------------------------ - # Emit GITHUB_STEP_SUMMARY markdown - # ------------------------------------------------------------------ - $md = [System.Text.StringBuilder]::new() - [void]$md.AppendLine('## Step.0 - Authentication Validation and Subscription Scope Report') - [void]$md.AppendLine('') - [void]$md.AppendLine('| Check | Result |') - [void]$md.AppendLine('|---|---|') - [void]$md.AppendLine("| Authentication (OIDC) | :white_check_mark: working |") - [void]$md.AppendLine("| Default Subscription | $($account.name) (``$($account.id)``) |") - [void]$md.AppendLine("| Tenant | ``$($account.tenantId)`` |") - [void]$md.AppendLine("| Pipeline identity (appId) | ``$($account.user.name)`` |") - [void]$md.AppendLine("| Resource Graph reachability | :white_check_mark: $clusterCount cluster(s) visible |") - [void]$md.AppendLine("| AzLocal.UpdateManagement (installed) | ``$installedVersion`` |") - [void]$md.AppendLine("| AzLocal.UpdateManagement (YAML generated against) | ``$generatedVersion`` |") - [void]$md.AppendLine("| AzLocal.UpdateManagement (latest on PSGallery) | ``$(if ($latestVersion) { $latestVersion } else { '(lookup failed)' })`` |") - [void]$md.AppendLine('') - [void]$md.AppendLine("### Count of subscriptions accessible = $subCount") - [void]$md.AppendLine('') - [void]$md.AppendLine('| # | Subscription Name | Subscription ID | Tenant ID | State |') - [void]$md.AppendLine('|---|---|---|---|---|') - $i = 0 - foreach ($s in $subs) { - $i++ - [void]$md.AppendLine("| $i | $($s.name) | ``$($s.subscriptionId)`` | ``$($s.tenantId)`` | $($s.state) |") - } - [void]$md.AppendLine('') - [void]$md.AppendLine("*Generated at $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss UTC')*") - $md.ToString() | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - - # Surface the count as a step output so downstream steps / jobs can consume it. - "subscription_count=$subCount" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "cluster_count=$clusterCount" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + Import-Module AzLocal.UpdateManagement -Force + Export-AzLocalAuthValidationReport ` + -AzureClientId $env:AZURE_CLIENT_ID ` + -ReportDirectory './reports' ` + -InstalledModuleVersion $env:INSTALLED_MODULE_VERSION ` + -GeneratedAgainstVersion $env:GENERATED_AGAINST_VERSION ` + -LatestOnPSGallery $env:LATEST_ON_PSGALLERY - name: Compute Artifact Timestamp id: artifact-stamp 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 3ddb82ac..3a62c9b3 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.4' + GENERATED_AGAINST_MODULE_VERSION: '0.8.5' # 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). @@ -104,7 +104,10 @@ jobs: az extension add --name resource-graph --yes - name: Install AzLocal.UpdateManagement from PSGallery + # v0.8.5 thin-YAML: drift detection + banner + step outputs are all + # produced by Add-AzLocalPipelineVersionBanner (Public cmdlet). shell: pwsh + id: module-version run: | $ErrorActionPreference = 'Stop' $installArgs = @{ Name = 'AzLocal.UpdateManagement'; Scope = 'CurrentUser'; Force = $true; AllowClobber = $true } @@ -116,198 +119,32 @@ jobs: } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force - # Get-Module can return multiple entries when nested modules are loaded - pick the highest version. - $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on runner : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - Write-Host "" - Get-Module AzLocal.UpdateManagement | Format-List Name, Version, Path - # Drift signal 0: the module installed at runtime is OLDER than the version this YAML was generated against. Cmdlets, parameters, or output schemas referenced by this YAML may not exist in the installed module - emitted as a warning because this is the most likely cause of a hard runtime failure later in the job. - if ($installed -lt $generated) { - $msg = "AzLocal.UpdateManagement v$installed is OLDER than the version this workflow YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Update'." - Write-Host "::warning title=AzLocal.UpdateManagement is older than workflow YAML expects::$msg" - } - # Drift signal 1: the YAML in this repo is older than the module that was installed. - if ($installed -gt $generated) { - $msg = "Workflow YAML was generated against AzLocal.UpdateManagement v$generated but the runner installed v$installed. Pipeline steps may have been improved in later releases - to refresh, re-run 'Copy-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Update' (you will be prompted per file; add -Confirm:`$false to bypass). Pipeline YAMLs are under git so 'git diff' shows exactly what changed before commit." - Write-Host "::notice title=Workflow YAML may be stale::$msg" - } - # Drift signal 2: the user pinned (or PSGallery shipped a release mid-run) and the install is behind the latest. - if ($latest -and ($latest -gt $installed)) { - $msg = "AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed. Review the module CHANGELOG before bumping REQUIRED_MODULE_VERSION (or clear the pin to install the latest automatically)." - Write-Host "::notice title=Newer AzLocal.UpdateManagement version available on PSGallery::$msg" - } + Add-AzLocalPipelineVersionBanner ` + -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION ` + -PinnedVersion $env:REQUIRED_MODULE_VERSION - # v0.8.4 - one-line version banner emitted to the rendered Step - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), the - # module version actually loaded on the runner, the PSGallery latest, - # and the REQUIRED_MODULE_VERSION pin status are visible at the top - # of every run summary without scrolling the agent log. Same data is - # also echoed to the console above as 'Module version summary'. - $pin = if ($env:REQUIRED_MODULE_VERSION) { "pinned to v$($env:REQUIRED_MODULE_VERSION)" } else { 'latest (fix-forward)' } - $latestStr = if ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } - $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } - elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } - elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } - else { 'in sync' } - if ($env:GITHUB_STEP_SUMMARY) { - "_Pipeline YAML v$generated | Module v$installed installed ($pin) | PSGallery latest $latestStr | $verdict_" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - } - - name: Run Cluster Inventory + id: inventory shell: pwsh env: INPUT_SUBSCRIPTION_FILTER: ${{ github.event.inputs.subscription_filter }} + INSTALLED_MODULE_VERSION: ${{ steps.module-version.outputs.installed_module_version }} + # v0.8.5 thin-YAML: the inline run block (inventory query, dual + # CSV/JSON export, canonical CSV copy, README_Instructions.txt, + # step outputs, separate "Summary" step) has been condensed into + # the Public cmdlet Invoke-AzLocalClusterInventory. The cmdlet + # writes the four artifacts to -OutputDirectory, emits the + # markdown step summary via GITHUB_STEP_SUMMARY, and sets the + # four step outputs (cluster_count, with_tag_count, + # without_tag_count, csv_path). run: | + $ErrorActionPreference = 'Stop' Import-Module AzLocal.UpdateManagement -Force - - # Create output directory - $outputDir = "./artifacts" - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - - $timestamp = Get-Date -Format "yyyyMMdd_HHmmss" - $csvPath = Join-Path $outputDir "ClusterInventory_$timestamp.csv" - $jsonPath = Join-Path $outputDir "ClusterInventory_$timestamp.json" - - # Add subscription filter if provided - $subFilter = $env:INPUT_SUBSCRIPTION_FILTER - $subscriptionParam = @{} - if ($subFilter -and $subFilter -ne '') { - $subscriptionParam['SubscriptionId'] = $subFilter - Write-Host "Filtering to subscription: $subFilter" - } else { - Write-Host "Querying all accessible subscriptions" - } - - # Export to CSV (for Excel workflow) - Get-AzLocalClusterInventory @subscriptionParam -ExportPath $csvPath - - # Export to JSON (for CI/CD integrations and dashboards) - $inventory = Get-AzLocalClusterInventory @subscriptionParam -ExportPath $jsonPath -PassThru - - # Output summary - Write-Host "" - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "Inventory Complete" -ForegroundColor Cyan - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "Total Clusters: $($inventory.Count)" - Write-Host "CSV exported to: $csvPath" - Write-Host "JSON exported to: $jsonPath" - - # ---- Canonical no-timestamp copy expected by the 'Manage UpdateRing Tags' workflow ---- - # The timestamped CSV is retained for audit / longitudinal comparison. - $canonicalCsvPath = Join-Path $outputDir "ClusterUpdateRings.csv" - Copy-Item -Path $csvPath -Destination $canonicalCsvPath -Force - Write-Host "Canonical copy created: $canonicalCsvPath (edit this one)" - - # ---- README_Instructions.txt explaining what to do with the CSV ---- - $installedVersion = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generatedUtc = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'") - $readmePath = Join-Path $outputDir "README_Instructions.txt" - $readme = @' - ======================================================================== - Cluster Inventory - Next Steps - AzLocal.UpdateManagement (UpdateRing tagging workflow) - ======================================================================== - - This ZIP contains the cluster inventory generated by the - 'Inventory Azure Local Clusters' workflow. Use it to assign each - cluster to an update wave (UpdateRing tag), then commit the result - back into your repository so the 'Manage UpdateRing Tags' workflow - can apply the tags to Azure. - - ------------------------------------------------------------------------ - FILES IN THIS ARTIFACT - ------------------------------------------------------------------------ - - ClusterUpdateRings.csv - <-- EDIT THIS FILE. Canonical name expected by the 'Manage - UpdateRing Tags' workflow's default csv_file_path input - ('config/ClusterUpdateRings.csv'). - - ClusterInventory_.csv - Audit / historical copy of this run's inventory. Do NOT edit - - keep it so you can diff future inventory runs against this - snapshot. - - ClusterInventory_.json - Machine-readable copy for dashboards / custom integrations. - - README_Instructions.txt - This file. - - ------------------------------------------------------------------------ - STEP 1 - Open ClusterUpdateRings.csv in Excel - ------------------------------------------------------------------------ + Invoke-AzLocalClusterInventory ` + -OutputDirectory './artifacts' ` + -SubscriptionFilter $env:INPUT_SUBSCRIPTION_FILTER ` + -InstalledModuleVersion $env:INSTALLED_MODULE_VERSION - - For each cluster row, fill in the 'UpdateRing' column with the - wave name you want that cluster to belong to. Examples: - Canary, Pilot, Production - (or your own scheme, e.g. Wave1, Wave2, Wave3 / Dev, Test, Prod) - - Leave the 'UpdateRing' column EMPTY for any cluster you do not - want this run to tag - those rows will be skipped. - - Save as CSV (UTF-8). Do not change other columns or column order. - - Do NOT rename the file. Keep it as 'ClusterUpdateRings.csv' so - the 'Manage UpdateRing Tags' workflow's default path picks it up. - - ------------------------------------------------------------------------ - STEP 2 - Commit ClusterUpdateRings.csv into your ops repository - ------------------------------------------------------------------------ - - - Recommended path: config/ClusterUpdateRings.csv - - Commit and push (PR review optional but recommended). - - The repo path you commit it to is what you will pass as - 'csv_file_path' on the next run of the 'Manage UpdateRing Tags' - workflow. - - ------------------------------------------------------------------------ - STEP 3 - Run the 'Manage UpdateRing Tags' workflow - ------------------------------------------------------------------------ - - - Open the Actions tab and trigger 'Manage UpdateRing Tags' via - 'Run workflow'. - - Set 'csv_file_path' to the path you committed in STEP 2 - (default is 'config/ClusterUpdateRings.csv'). - - RECOMMENDED FIRST RUN: leave 'dry_run' = true. The workflow - will preview which tags would be set/changed without modifying - any Azure resources. - - Once the preview looks correct, re-run with 'dry_run' = false - to apply the tags. Use 'force' = true only if you need to - overwrite existing UpdateRing values already set on a cluster. - - ------------------------------------------------------------------------ - DOCUMENTATION - ------------------------------------------------------------------------ - - Pipeline examples README (online): - https://github.com/NeilBird/Azure-Local/blob/main/AzLocal.UpdateManagement/Automation-Pipeline-Examples/README.md - - Module README (online): - https://github.com/NeilBird/Azure-Local/blob/main/AzLocal.UpdateManagement/README.md - - Module on the PowerShell Gallery: - https://www.powershellgallery.com/packages/AzLocal.UpdateManagement - - Generated by AzLocal.UpdateManagement v__MODULE_VERSION__ - Run timestamp (UTC): __TIMESTAMP__ - ======================================================================== - '@ - $readme = $readme.Replace('__MODULE_VERSION__', "$installedVersion").Replace('__TIMESTAMP__', $generatedUtc) - Set-Content -Path $readmePath -Value $readme -Encoding ASCII - Write-Host "Instructions written to: $readmePath" - - # Set output for next steps - echo "CSV_PATH=$csvPath" >> $env:GITHUB_OUTPUT - echo "JSON_PATH=$jsonPath" >> $env:GITHUB_OUTPUT - echo "CLUSTER_COUNT=$($inventory.Count)" >> $env:GITHUB_OUTPUT - id: inventory - - name: Compute Artifact Timestamp id: artifact-stamp shell: pwsh @@ -325,39 +162,6 @@ jobs: path: | ./artifacts/*.csv ./artifacts/*.json + ./artifacts/README_Instructions.txt retention-days: 30 - - - name: Summary - shell: pwsh - run: | - $csvPath = "${{ steps.inventory.outputs.CSV_PATH }}" - if (Test-Path $csvPath) { - $data = Import-Csv $csvPath - - "## Cluster Inventory Summary" >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - "| Metric | Value |" >> $env:GITHUB_STEP_SUMMARY - "|--------|-------|" >> $env:GITHUB_STEP_SUMMARY - "| Total Clusters | $($data.Count) |" >> $env:GITHUB_STEP_SUMMARY - - $withTag = ($data | Where-Object { $_.HasUpdateRingTag -eq 'Yes' }).Count - $withoutTag = ($data | Where-Object { $_.HasUpdateRingTag -eq 'No' }).Count - "| With UpdateRing Tag | $withTag |" >> $env:GITHUB_STEP_SUMMARY - "| Without UpdateRing Tag | $withoutTag |" >> $env:GITHUB_STEP_SUMMARY - - "" >> $env:GITHUB_STEP_SUMMARY - "### UpdateRing Distribution" >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - - $rings = $data | Where-Object { $_.UpdateRing } | Group-Object UpdateRing - if ($rings) { - "| UpdateRing | Count |" >> $env:GITHUB_STEP_SUMMARY - "|------------|-------|" >> $env:GITHUB_STEP_SUMMARY - foreach ($ring in $rings) { - "| $($ring.Name) | $($ring.Count) |" >> $env:GITHUB_STEP_SUMMARY - } - } else { - "No UpdateRing tags found." >> $env:GITHUB_STEP_SUMMARY - } - } 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 bf8d4df2..bbf815b2 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.4' + GENERATED_AGAINST_MODULE_VERSION: '0.8.5' # 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). @@ -119,102 +119,35 @@ jobs: shell: pwsh env: INPUT_CSV_FILE_PATH: ${{ github.event.inputs.csv_file_path }} + # v0.8.5 thin-YAML: the existence + missing-file operator messaging is + # now produced by Set-AzLocalClusterUpdateRingTagFromCsv itself. This + # step still runs first to fail fast (and to surface the path) before + # we spend time installing the module. run: | $csvPath = $env:INPUT_CSV_FILE_PATH - if (-not (Test-Path $csvPath)) { Write-Host "ERROR: CSV file not found at path: $csvPath" -ForegroundColor Red + Write-Host "The full TWO-STAGE operator workflow guidance will be emitted by" + Write-Host "Set-AzLocalClusterUpdateRingTagFromCsv when the Apply step runs." Write-Host "" - Write-Host "===========================================================================" -ForegroundColor Yellow - Write-Host " UpdateRing tagging is a TWO-STAGE workflow. You must run the inventory" -ForegroundColor Yellow - Write-Host " workflow first, edit the CSV, commit it, then re-run THIS workflow." -ForegroundColor Yellow - Write-Host "===========================================================================" -ForegroundColor Yellow + Write-Host "Quick recap:" + Write-Host " 1. Run Step.1 (Inventory) and download/edit/commit the CSV." + Write-Host " 2. Re-run this Step.2 with csv_file_path pointing to the committed CSV." Write-Host "" - Write-Host "STEP 1 - Generate the cluster inventory (one-off, then on demand)" -ForegroundColor Cyan - Write-Host " a. Go to the 'Actions' tab in this repository." - Write-Host " b. Open the workflow named 'Inventory Azure Local Clusters'" - Write-Host " (file: .github/workflows/Step.1_inventory-clusters.yml)." - Write-Host " c. Click 'Run workflow' and wait for the run to finish successfully." - Write-Host " d. On the completed run page, scroll to the 'Artifacts' section and" - Write-Host " download the artifact named 'cluster-inventory'." - Write-Host " e. Unzip it locally. You will get 'ClusterUpdateRings.csv' (the one" - Write-Host " to edit), a timestamped audit copy, a .json copy, and a" - Write-Host " 'README_Instructions.txt' file that mirrors these instructions." - Write-Host "" - Write-Host "STEP 2 - Edit the CSV in Excel" -ForegroundColor Cyan - Write-Host " a. Open 'ClusterUpdateRings.csv' in Excel." - Write-Host " b. For each cluster row, fill in the 'UpdateRing' column with the wave" - Write-Host " name you want that cluster to belong to. Examples:" - Write-Host " Canary, Pilot, Production" - Write-Host " (or your own scheme, e.g. Wave1, Wave2, Wave3 / Dev, Test, Prod)." - Write-Host " c. Leave the 'UpdateRing' column EMPTY for any cluster you do not want" - Write-Host " this run to tag - those rows will be skipped." - Write-Host " d. Save as CSV (UTF-8). Do not change other columns or column order." - Write-Host " e. Do NOT rename the file - keep it as 'ClusterUpdateRings.csv' so" - Write-Host " the default 'csv_file_path' below picks it up." - Write-Host "" - Write-Host "STEP 3 - Commit the edited CSV to this repository" -ForegroundColor Cyan - Write-Host " a. Copy the edited CSV into a stable location in this repo, for example:" - Write-Host " config/ClusterUpdateRings.csv" - Write-Host " b. Commit and push it (PR review optional but recommended)." - Write-Host " c. The path you commit it to is what you will pass as 'csv_file_path'" - Write-Host " on the next run of this workflow." - Write-Host "" - Write-Host "STEP 4 - Re-run THIS workflow ('Manage UpdateRing Tags')" -ForegroundColor Cyan - Write-Host " a. Click 'Run workflow' on this workflow." - Write-Host " b. Set 'csv_file_path' to the path you committed in step 3" - Write-Host " (default is 'config/ClusterUpdateRings.csv')." - Write-Host " c. RECOMMENDED FIRST RUN: leave 'dry_run' = true to preview which tags" - Write-Host " would be set/changed, without modifying any Azure resources." - Write-Host " d. Once the preview looks correct, re-run with 'dry_run' = false to" - Write-Host " apply the tags. Use 'force' = true only if you need to overwrite" - Write-Host " existing UpdateRing values already set on a cluster." - Write-Host "" - Write-Host "Documentation:" -ForegroundColor Yellow + Write-Host "Docs:" Write-Host " AzLocal.UpdateManagement/README.md - 'Update Rings' section" Write-Host " AzLocal.UpdateManagement/Automation-Pipeline-Examples/README.md" - Write-Host "" - Write-Host "The path you supplied ('$csvPath') does not exist in the checked-out" -ForegroundColor Red - Write-Host "branch of this repository. Did you forget to commit the CSV, point at" -ForegroundColor Red - Write-Host "the wrong branch on 'Run workflow', or mistype the path?" -ForegroundColor Red exit 1 } - Write-Host "Found CSV file: $csvPath" echo "CSV_PATH=$csvPath" >> $env:GITHUB_OUTPUT id: csv-path - - - name: Validate CSV File - shell: pwsh - run: | - $csvPath = "${{ steps.csv-path.outputs.CSV_PATH }}" - $data = Import-Csv $csvPath - - Write-Host "CSV File: $csvPath" - Write-Host "Total Rows: $($data.Count)" - - # Check required columns - $requiredColumns = @('ResourceId', 'UpdateRing') - $columns = $data[0].PSObject.Properties.Name - - foreach ($col in $requiredColumns) { - if ($col -notin $columns) { - Write-Error "Required column '$col' not found in CSV" - exit 1 - } - } - - # Count rows with UpdateRing values - $rowsWithValues = ($data | Where-Object { $_.UpdateRing -and $_.UpdateRing.Trim() -ne '' }).Count - Write-Host "Rows with UpdateRing values: $rowsWithValues" - - if ($rowsWithValues -eq 0) { - Write-Warning "No rows have UpdateRing values set. Please edit the CSV and set UpdateRing values before running this pipeline." - exit 1 - } - + - name: Install AzLocal.UpdateManagement from PSGallery + # v0.8.5 thin-YAML: drift detection + banner + step outputs are all + # produced by Add-AzLocalPipelineVersionBanner (Public cmdlet). shell: pwsh + id: module-version run: | $ErrorActionPreference = 'Stop' $installArgs = @{ Name = 'AzLocal.UpdateManagement'; Scope = 'CurrentUser'; Force = $true; AllowClobber = $true } @@ -226,105 +159,42 @@ jobs: } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force - $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on runner : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - Write-Host "" - Get-Module AzLocal.UpdateManagement | Format-List Name, Version, Path - # Drift signal 0: the module installed at runtime is OLDER than the version this YAML was generated against. Cmdlets, parameters, or output schemas referenced by this YAML may not exist in the installed module - emitted as a warning because this is the most likely cause of a hard runtime failure later in the job. - if ($installed -lt $generated) { - $msg = "AzLocal.UpdateManagement v$installed is OLDER than the version this workflow YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Update'." - Write-Host "::warning title=AzLocal.UpdateManagement is older than workflow YAML expects::$msg" - } - if ($installed -gt $generated) { - $msg = "Workflow YAML was generated against AzLocal.UpdateManagement v$generated but the runner installed v$installed. Pipeline steps may have been improved in later releases - to refresh, re-run 'Copy-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Update' (you will be prompted per file; add -Confirm:`$false to bypass). Pipeline YAMLs are under git so 'git diff' shows exactly what changed before commit." - Write-Host "::notice title=Workflow YAML may be stale::$msg" - } - if ($latest -and ($latest -gt $installed)) { - $msg = "AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed. Review the module CHANGELOG before bumping REQUIRED_MODULE_VERSION (or clear the pin to install the latest automatically)." - Write-Host "::notice title=Newer AzLocal.UpdateManagement version available on PSGallery::$msg" - } + Add-AzLocalPipelineVersionBanner ` + -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION ` + -PinnedVersion $env:REQUIRED_MODULE_VERSION - # v0.8.4 - one-line version banner emitted to the rendered Step - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), the - # module version actually loaded on the runner, the PSGallery latest, - # and the REQUIRED_MODULE_VERSION pin status are visible at the top - # of every run summary without scrolling the agent log. Same data is - # also echoed to the console above as 'Module version summary'. - $pin = if ($env:REQUIRED_MODULE_VERSION) { "pinned to v$($env:REQUIRED_MODULE_VERSION)" } else { 'latest (fix-forward)' } - $latestStr = if ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } - $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } - elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } - elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } - else { 'in sync' } - if ($env:GITHUB_STEP_SUMMARY) { - "_Pipeline YAML v$generated | Module v$installed installed ($pin) | PSGallery latest $latestStr | $verdict_" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - } - - name: Apply UpdateRing Tags + id: apply-tags shell: pwsh env: - INPUT_FORCE_OVERWRITE: ${{ github.event.inputs.force_overwrite }} - INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }} + INPUT_CSV_FILE_PATH: ${{ steps.csv-path.outputs.CSV_PATH }} + INPUT_FORCE_OVERWRITE: ${{ github.event.inputs.force_overwrite }} + INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }} + INSTALLED_MODULE_VERSION: ${{ steps.module-version.outputs.installed_module_version }} + # v0.8.5 thin-YAML: the inline run block (CSV column validation, + # Set-AzLocalClusterUpdateRingTag invocation, JSON sidecar write, + # outcome tallies, and the GITHUB_STEP_SUMMARY breakdown) has been + # condensed into the Public cmdlet + # Set-AzLocalClusterUpdateRingTagFromCsv. The cmdlet writes + # UpdateRingTag_Results.json to -OutputDirectory, emits the markdown + # step summary via GITHUB_STEP_SUMMARY, and sets the eight step + # outputs (total_count, created_count, updated_count, + # already_in_sync_count, skipped_count, failed_count, whatif_count, + # results_json_path). run: | $ErrorActionPreference = 'Stop' Import-Module AzLocal.UpdateManagement -Force - - $csvPath = "${{ steps.csv-path.outputs.CSV_PATH }}" - $forceOverwrite = $env:INPUT_FORCE_OVERWRITE -eq "true" - $dryRun = $env:INPUT_DRY_RUN -eq "true" - - # Create output directory for logs - $outputDir = "./artifacts" - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - - # Build parameters + $forceOverwrite = $env:INPUT_FORCE_OVERWRITE -eq 'true' + $dryRun = $env:INPUT_DRY_RUN -eq 'true' $params = @{ - InputCsvPath = $csvPath - LogFolderPath = $outputDir - } - - if ($forceOverwrite) { - $params['Force'] = $true - Write-Host "Force mode enabled - will overwrite existing tags" - } - - if ($dryRun) { - $params['WhatIf'] = $true - Write-Host "DRY RUN MODE - No changes will be applied" - } - - Write-Host "" - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "Applying UpdateRing Tags" -ForegroundColor Cyan - Write-Host "========================================" -ForegroundColor Cyan - - # Run tag management. -PassThru returns the per-cluster results so the - # Summary step can build a breakdown table rather than parroting only - # the workflow inputs back at the operator. - $results = Set-AzLocalClusterUpdateRingTag @params -PassThru - - # Persist results to a JSON sidecar so the Summary step (separate shell) - # can read them. Lives in the same artifacts folder that gets uploaded. - $resultsJsonPath = Join-Path $outputDir 'UpdateRingTag_Results.json' - if ($null -ne $results) { - @($results) | ConvertTo-Json -Depth 5 | Out-File -FilePath $resultsJsonPath -Encoding utf8 - Write-Host "Wrote $($results.Count) per-cluster result row(s) to: $resultsJsonPath" - } else { - '[]' | Out-File -FilePath $resultsJsonPath -Encoding utf8 - Write-Host "No per-cluster results returned (empty CSV?). Wrote empty array to: $resultsJsonPath" + InputCsvPath = $env:INPUT_CSV_FILE_PATH + OutputDirectory = './artifacts' + InstalledModuleVersion = $env:INSTALLED_MODULE_VERSION } + if ($forceOverwrite) { $params['Force'] = $true } + if ($dryRun) { $params['WhatIf'] = $true } + Set-AzLocalClusterUpdateRingTagFromCsv @params - Write-Host "" - Write-Host "Tag management complete" - id: apply-tags - - name: Compute Artifact Timestamp id: artifact-stamp shell: pwsh @@ -341,77 +211,4 @@ jobs: name: azlocal-step.2-updatering-tag-logs_${{ steps.artifact-stamp.outputs.timestamp }} path: ./artifacts/* retention-days: 30 - - - name: Summary - shell: pwsh - env: - INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }} - INPUT_FORCE_OVERWRITE: ${{ github.event.inputs.force_overwrite }} - run: | - "## UpdateRing Tag Management Summary" >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - - $dryRun = $env:INPUT_DRY_RUN -eq "true" - $forceOverwrite = $env:INPUT_FORCE_OVERWRITE -eq "true" - - "| Setting | Value |" >> $env:GITHUB_STEP_SUMMARY - "|---------|-------|" >> $env:GITHUB_STEP_SUMMARY - "| Dry Run | $dryRun |" >> $env:GITHUB_STEP_SUMMARY - "| Force Overwrite | $forceOverwrite |" >> $env:GITHUB_STEP_SUMMARY - - # Per-cluster results breakdown - read the JSON sidecar the apply step wrote. - # See AzLocal.UpdateManagement Set-AzLocalClusterUpdateRingTag -PassThru schema. - $resultsJsonPath = './artifacts/UpdateRingTag_Results.json' - if (Test-Path $resultsJsonPath) { - $results = @(Get-Content -Raw -Path $resultsJsonPath | ConvertFrom-Json) - $total = $results.Count - $created = @($results | Where-Object { $_.Action -eq 'Created' -and $_.Status -eq 'Success' }).Count - $updated = @($results | Where-Object { $_.Action -eq 'Updated' -and $_.Status -eq 'Success' }).Count - $alreadyInSync = @($results | Where-Object { $_.Status -eq 'AlreadyInSync' }).Count - $skipped = @($results | Where-Object { $_.Status -eq 'Skipped' }).Count - $failed = @($results | Where-Object { $_.Status -eq 'Failed' }).Count - $whatIf = @($results | Where-Object { $_.Status -eq 'WhatIf' }).Count - - "" >> $env:GITHUB_STEP_SUMMARY - "### Result breakdown" >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - "| Outcome | Count |" >> $env:GITHUB_STEP_SUMMARY - "|---------|------:|" >> $env:GITHUB_STEP_SUMMARY - "| Total clusters processed | $total |" >> $env:GITHUB_STEP_SUMMARY - "| Tags created | $created |" >> $env:GITHUB_STEP_SUMMARY - "| Tags updated | $updated |" >> $env:GITHUB_STEP_SUMMARY - "| Already in sync (no-op) | $alreadyInSync |" >> $env:GITHUB_STEP_SUMMARY - "| Skipped (UpdateRing differs, no -Force) | $skipped |" >> $env:GITHUB_STEP_SUMMARY - if ($whatIf -gt 0) { - "| WhatIf (dry-run preview) | $whatIf |" >> $env:GITHUB_STEP_SUMMARY - } - "| Failed | $failed |" >> $env:GITHUB_STEP_SUMMARY - - if ($total -gt 0) { - "" >> $env:GITHUB_STEP_SUMMARY - "
Per-cluster results ($total rows)" >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - "| Cluster | Action | Previous | New | Status | Message |" >> $env:GITHUB_STEP_SUMMARY - "|---------|--------|----------|-----|--------|---------|" >> $env:GITHUB_STEP_SUMMARY - foreach ($r in $results) { - $cn = ($r.ClusterName | ForEach-Object { "$_" }) -replace '\|','\|' - $act = ($r.Action | ForEach-Object { "$_" }) -replace '\|','\|' - $pv = ($r.PreviousTagValue | ForEach-Object { "$_" }) -replace '\|','\|' - $nv = ($r.NewTagValue | ForEach-Object { "$_" }) -replace '\|','\|' - $st = ($r.Status | ForEach-Object { "$_" }) -replace '\|','\|' - $msg = ($r.Message | ForEach-Object { "$_" }) -replace '\|','\|' -replace '\r?\n',' ' - "| $cn | $act | $pv | $nv | $st | $msg |" >> $env:GITHUB_STEP_SUMMARY - } - "" >> $env:GITHUB_STEP_SUMMARY - "
" >> $env:GITHUB_STEP_SUMMARY - } - } else { - "" >> $env:GITHUB_STEP_SUMMARY - "_No per-cluster results JSON found at $resultsJsonPath - apply step may have failed before producing results._" >> $env:GITHUB_STEP_SUMMARY - } - - if ($dryRun) { - "" >> $env:GITHUB_STEP_SUMMARY - "**This was a dry run. No changes were applied.**" >> $env:GITHUB_STEP_SUMMARY - } 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 80afb121..afd06ce5 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.4' + GENERATED_AGAINST_MODULE_VERSION: '0.8.5' 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, @@ -115,6 +115,24 @@ jobs: id-token: write contents: read checks: write # Required for test reporter + outputs: + # v0.8.5 thin-YAML: keys align byte-for-byte with the lowercase output + # names emitted by Export-AzLocalApplyUpdatesScheduleAudit (via + # Set-AzLocalPipelineOutput). ADO is case-sensitive on task.setvariable + # lookups; GH is case-insensitive but lowercase is the canonical v0.8.5+ + # convention. + total_rows: ${{ steps.audit.outputs.total_rows }} + covered: ${{ steps.audit.outputs.covered }} + uncovered: ${{ steps.audit.outputs.uncovered }} + partial: ${{ steps.audit.outputs.partial }} + malformed: ${{ steps.audit.outputs.malformed }} + unparseable: ${{ steps.audit.outputs.unparseable }} + no_window_tag: ${{ steps.audit.outputs.no_window_tag }} + ring_missing: ${{ steps.audit.outputs.ring_missing }} + ring_orphan: ${{ steps.audit.outputs.ring_orphan }} + ring_mixed: ${{ steps.audit.outputs.ring_mixed }} + have_schedule: ${{ steps.audit.outputs.have_schedule }} + schedule_path: ${{ steps.audit.outputs.schedule_path }} steps: - name: Checkout repository @@ -141,7 +159,12 @@ jobs: az extension add --name resource-graph --yes - name: Install AzLocal.UpdateManagement from PSGallery + # v0.8.5 thin-YAML: the drift-detection + version-banner block is + # produced by Add-AzLocalPipelineVersionBanner (Public cmdlet); the + # cmdlet writes the version banner to GITHUB_STEP_SUMMARY and emits + # the installed_module_version step output consumed by the next step. shell: pwsh + id: module-version run: | $ErrorActionPreference = 'Stop' $installArgs = @{ Name = 'AzLocal.UpdateManagement'; Scope = 'CurrentUser'; Force = $true; AllowClobber = $true } @@ -153,46 +176,27 @@ jobs: } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force - $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on runner : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - # Drift signal 0: the module installed at runtime is OLDER than the version this YAML was generated against. Cmdlets, parameters, or output schemas referenced by this YAML may not exist in the installed module - emitted as a warning because this is the most likely cause of a hard runtime failure later in the job. - if ($installed -lt $generated) { - $msg = "AzLocal.UpdateManagement v$installed is OLDER than the version this workflow YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Update'." - Write-Host "::warning title=AzLocal.UpdateManagement is older than workflow YAML expects::$msg" - } - if ($installed -gt $generated) { - $msg = "Workflow YAML was generated against AzLocal.UpdateManagement v$generated but the runner installed v$installed. Refresh via 'Copy-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Update'." - Write-Host "::notice title=Workflow YAML may be stale::$msg" - } - if ($latest -and ($latest -gt $installed)) { - $msg = "AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed." - Write-Host "::notice title=Newer AzLocal.UpdateManagement version available on PSGallery::$msg" - } - - # v0.8.4 - one-line version banner emitted to the rendered Step - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), the - # module version actually loaded on the runner, the PSGallery latest, - # and the REQUIRED_MODULE_VERSION pin status are visible at the top - # of every run summary without scrolling the agent log. Same data is - # also echoed to the console above as 'Module version summary'. - $pin = if ($env:REQUIRED_MODULE_VERSION) { "pinned to v$($env:REQUIRED_MODULE_VERSION)" } else { 'latest (fix-forward)' } - $latestStr = if ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } - $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } - elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } - elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } - else { 'in sync' } - if ($env:GITHUB_STEP_SUMMARY) { - "_Pipeline YAML v$generated | Module v$installed installed ($pin) | PSGallery latest $latestStr | $verdict_" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - } + Add-AzLocalPipelineVersionBanner ` + -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION ` + -PinnedVersion $env:REQUIRED_MODULE_VERSION - name: Run Schedule Coverage Audit + # v0.8.5 thin-YAML: the inline body (~220-line 'Run Schedule + # Coverage Audit' + ~210-line 'Create Schedule Coverage Summary' + # blocks: 3 calls to Test-AzLocalApplyUpdatesScheduleCoverage, + # inline JUnit StringBuilder, 12-row markdown summary, schedule-row + # detail tables, allow-list coverage v1/v2 sections, cycle calendar + # render, and 12 step outputs) is now the Public cmdlet + # Export-AzLocalApplyUpdatesScheduleAudit. The cmdlet writes the + # 4 ./reports/schedule-coverage-* artifacts, pushes the markdown + # summary via GITHUB_STEP_SUMMARY, and sets the 12 lowercase step + # outputs (total_rows, covered, uncovered, partial, malformed, + # unparseable, no_window_tag, ring_missing, ring_orphan, ring_mixed, + # have_schedule, schedule_path) via Set-AzLocalPipelineOutput. + # **v0.8.5 fix**: the cycle calendar now renders UNCONDITIONALLY + # whenever -SchedulePath is supplied, eliminating the v0.8.4 + # ``$hasIssues``-gate regression that silently dropped the calendar + # on clean-fleet runs. shell: pwsh id: audit env: @@ -203,234 +207,40 @@ jobs: INPUT_INCLUDE_UNTAGGED: ${{ github.event.inputs.include_untagged || 'false' }} INPUT_CLUSTER_CSV_PATH: ${{ github.event.inputs.cluster_csv_path || 'config/ClusterUpdateRings.csv' }} INPUT_DEBUG: ${{ github.event.inputs.debug || 'false' }} + INSTALLED_MODULE_VERSION: ${{ steps.module-version.outputs.installed_module_version }} run: | $ErrorActionPreference = 'Stop' Import-Module AzLocal.UpdateManagement -Force - # Debug toggle: flips Verbose/Debug preferences and emits a - # one-shot environment snapshot so operators can self-triage without - # needing a code change. Default OFF to keep normal runs concise. if ($env:INPUT_DEBUG -eq 'true') { $VerbosePreference = 'Continue' $DebugPreference = 'Continue' Write-Host "::group::Debug environment" Write-Host "PSVersion : $($PSVersionTable.PSVersion)" Write-Host "PSEdition : $($PSVersionTable.PSEdition)" - Write-Host "OS : $($PSVersionTable.OS)" $mod = Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1 Write-Host "AzLocal.UpdateManagement : v$($mod.Version) (path: $($mod.Path))" - Write-Host "VerbosePreference : $VerbosePreference" - Write-Host "DebugPreference : $DebugPreference" Write-Host "::endgroup::" } - $outputDir = "./reports" - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - - $pipelinePath = $env:INPUT_PIPELINE_PATH - $schedulePath = $env:INPUT_SCHEDULE_PATH - $leadTime = [int]$env:INPUT_LEAD_TIME_MINUTES - $firesPerWin = [int]$env:INPUT_FIRES_PER_WINDOW - $includeUnt = ($env:INPUT_INCLUDE_UNTAGGED -eq 'true') - $clusterCsv = $env:INPUT_CLUSTER_CSV_PATH - - # v0.8.3: pipeline_path is REQUIRED so Recommend can diff its - # proposed crons against what's already in Step.6 and only emit a - # snippet for the truly missing entries. Schedule_path stays optional - # (it drives the ring-file two-way diff). Defence-in-depth: even - # though the workflow_dispatch input is required:true, validate at - # runtime as well in case the workflow is invoked via the schedule - # trigger (which uses the input defaults). - $havePipeline = -not [string]::IsNullOrWhiteSpace($pipelinePath) - $haveSchedule = -not [string]::IsNullOrWhiteSpace($schedulePath) -and (Test-Path -LiteralPath $schedulePath) - # v0.8.4: cluster_csv_path is optional. If the file is missing on the runner - # we skip the NoWindowTag remediation section quietly (vs throwing) so the - # workflow keeps emitting the legacy advisor output for repos that don't - # version-control a cluster CSV yet. - $haveCsv = -not [string]::IsNullOrWhiteSpace($clusterCsv) -and (Test-Path -LiteralPath $clusterCsv) - if (-not $havePipeline) { - throw "pipeline_path is required. Set it to the folder containing Step.6_apply-updates.yml (default '.github/workflows' matches the standard consumer layout). The Recommend view diffs its proposed crons against this file - leaving it empty would make every recommendation a false positive." - } - if (-not (Test-Path -LiteralPath $pipelinePath)) { - $candidates = @('.github/workflows', '.azure-pipelines', 'pipelines', '.') | Where-Object { Test-Path -LiteralPath $_ } - $hint = if ($candidates) { "Common locations that DO exist in this repo: $($candidates -join ', '). Re-run via 'Run workflow' on the Actions tab and set the 'pipeline_path' input." } else { "No common pipeline folders (.github/workflows, .azure-pipelines, pipelines, repo root) were found in this repo - commit your Step.6_apply-updates.yml first, or set 'pipeline_path' to wherever you keep it." } - throw "PipelineYamlPath '$pipelinePath' does not exist on the runner. $hint" - } - - Write-Host "========================================" - Write-Host "Apply-Updates Schedule Coverage Audit" - Write-Host "========================================" - Write-Host "PipelineYamlPath : $(if ($pipelinePath) { $pipelinePath } else { '(skipped - pipeline_path empty)' })" - Write-Host "SchedulePath : $(if ($haveSchedule) { $schedulePath } else { '(skipped - schedule_path empty or file missing)' })" - Write-Host "LeadTimeMinutes : $leadTime" - Write-Host "FiresPerWindow : $firesPerWin (1 = opening edge only, 2 = belt-and-braces opening + mid-window retry)" - Write-Host "IncludeUntagged : $includeUnt" - Write-Host "ClusterCsvPath : $(if ($haveCsv) { $clusterCsv } else { '(skipped - cluster_csv_path empty or file missing)' })" - Write-Host "" - - $auditCsv = Join-Path $outputDir 'schedule-coverage-audit.csv' - $recoMd = Join-Path $outputDir 'schedule-coverage-recommend.md' - $matrixCsv = Join-Path $outputDir 'schedule-coverage-matrix.csv' - - # v0.7.74: pin -Platform GitHubActions so the Recommend snippet ONLY - # emits the GitHub Actions cron block (not the ADO `schedules:` block, - # which would be irrelevant noise in a GitHub Actions Step Summary). - # The cmdlet's default is 'Both' (intentional for ad-hoc operator use), - # so the pipeline yml has to specify which platform it's running on. - $auditArgs = @{ - View = 'Audit' - LeadTimeMinutes = $leadTime - RecommendFiresPerWindow = $firesPerWin - ExportPath = $auditCsv - Platform = 'GitHubActions' - PassThru = $true - } - if ($havePipeline) { $auditArgs.PipelineYamlPath = $pipelinePath } - if ($haveSchedule) { $auditArgs.SchedulePath = $schedulePath } - if ($includeUnt) { $auditArgs.IncludeUntagged = $true } - if ($haveCsv) { $auditArgs.ClusterCsvPath = $clusterCsv } - $audit = Test-AzLocalApplyUpdatesScheduleCoverage @auditArgs - if (-not $audit) { $audit = @() } - - # Matrix view (CSV only). - Test-AzLocalApplyUpdatesScheduleCoverage -View Matrix -LeadTimeMinutes $leadTime -ExportPath $matrixCsv | Out-Null - - # Recommend view (markdown snippet) - GitHub Actions only. - # v0.8.3: pass -PipelineYamlPath so Recommend diff-prunes crons - # already present in Step.6 and only emits the truly missing ones. - # v0.8.4: also pass -SchedulePath (so the Cycle calendar + - # Configured exclusion windows informational sections render) and - # -ClusterCsvPath (so the NoWindowTag remediation section renders). - $recoArgs = @{ - View = 'Recommend' - LeadTimeMinutes = $leadTime - RecommendFiresPerWindow = $firesPerWin + $params = @{ + PipelineYamlPath = $env:INPUT_PIPELINE_PATH + LeadTimeMinutes = [int]$env:INPUT_LEAD_TIME_MINUTES + RecommendFiresPerWindow = [int]$env:INPUT_FIRES_PER_WINDOW Platform = 'GitHubActions' - ExportPath = $recoMd - } - if ($havePipeline) { $recoArgs.PipelineYamlPath = $pipelinePath } - if ($haveSchedule) { $recoArgs.SchedulePath = $schedulePath } - if ($haveCsv) { $recoArgs.ClusterCsvPath = $clusterCsv } - Test-AzLocalApplyUpdatesScheduleCoverage @recoArgs | Out-Null - - $covered = @($audit | Where-Object Status -eq 'Covered') - $uncovered = @($audit | Where-Object Status -eq 'Uncovered') - $partial = @($audit | Where-Object Status -eq 'PartiallyCovered') - $malformed = @($audit | Where-Object Status -eq 'MalformedTag') - $unparseable = @($audit | Where-Object Status -eq 'UnparseableCron') - # -IncludeUntagged bucket: clusters with no UpdateStartWindow tag. - $noWindowTag = @($audit | Where-Object Status -eq 'NoWindowTag') - # schedule-file two-way diff buckets. - $ringMissing = @($audit | Where-Object Status -eq 'RingMissingFromSchedule') - $ringOrphan = @($audit | Where-Object Status -eq 'RingOrphanedInSchedule') - # v0.7.92: same-ring mixed-window detector. Informational - the runtime - # gate reads per-cluster tags so updates still fire, but surfaces a - # ring whose clusters disagree on UpdateStartWindow value so the - # operator can decide to standardise or split the ring. - $ringMixed = @($audit | Where-Object Status -eq 'RingMixedWindows') - - # ------------------------------------------------------------------ - # JUnit XML: two elements so the test reporter - # tree separates schedule-file gap rows (Schedule section) from cron - # coverage rows (Cron section). Schedule gaps have higher blast - # radius (apply-updates NEVER fires for those rings), so the suite - # comes FIRST. Uncovered, PartiallyCovered, MalformedTag, - # UnparseableCron, RingMissingFromSchedule, RingOrphanedInSchedule - # all become ; Covered passes. - # ------------------------------------------------------------------ - function Convert-XmlEscape([string]$s) { - if ($null -eq $s) { return '' } - ($s.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"').Replace("'",''')) - } - function Write-Suite { - param( - [System.Text.StringBuilder]$Sb, - [string]$SuiteName, - [object[]]$Rows, - [string]$EmptyPlaceholderName - ) - $failures = @($Rows | Where-Object { $_.Status -ne 'Covered' }).Count - [void]$Sb.AppendLine((" " -f (Convert-XmlEscape $SuiteName), $Rows.Count, $failures)) - foreach ($row in $Rows) { - $tcName = ("{0} / {1}" -f $row.UpdateRing, ($(if ($row.UpdateStartWindow) { $row.UpdateStartWindow } else { '(no window)' }))) - [void]$Sb.AppendLine((" " -f (Convert-XmlEscape $tcName))) - if ($row.Status -ne 'Covered') { - $issue = Convert-XmlEscape $row.Issue - $reco = Convert-XmlEscape $row.Recommendation - [void]$Sb.AppendLine((" Recommendation: {2}{3}Cluster count: {4}" -f $row.Status, $issue, $reco, [Environment]::NewLine, $row.ClusterCount)) - } - [void]$Sb.AppendLine(' ') - } - if ($Rows.Count -eq 0 -and $EmptyPlaceholderName) { - [void]$Sb.AppendLine((" " -f (Convert-XmlEscape $EmptyPlaceholderName))) - } - [void]$Sb.AppendLine(' ') - } - - $scheduleRows = @($audit | Where-Object { $_.Section -eq 'Schedule' }) - $cronRows = @($audit | Where-Object { $_.Section -ne 'Schedule' }) - - $xmlPath = Join-Path $outputDir 'schedule-coverage-audit.xml' - $sb = New-Object System.Text.StringBuilder - [void]$sb.AppendLine('') - [void]$sb.AppendLine('') - # only emit the Schedule suite when -SchedulePath was actually - # supplied (i.e. there is at least one Schedule row OR the operator - # provided a schedule file). Otherwise the suite would render as - # "no tests found" with no actionable signal. - if ($haveSchedule -or $scheduleRows.Count -gt 0) { - Write-Suite -Sb $sb ` - -SuiteName 'Apply-Updates Schedule Coverage - Schedule (ring diff)' ` - -Rows $scheduleRows ` - -EmptyPlaceholderName 'Schedule and fleet ring sets match - no gaps.' + OutputDirectory = './reports' + InstalledModuleVersion = $env:INSTALLED_MODULE_VERSION } - if ($cronRows.Count -gt 0 -or -not $haveSchedule) { - Write-Suite -Sb $sb ` - -SuiteName 'Apply-Updates Schedule Coverage - Cron coverage' ` - -Rows $cronRows ` - -EmptyPlaceholderName 'No tagged clusters found - nothing to audit' - } - [void]$sb.AppendLine('') - [System.IO.File]::WriteAllText($xmlPath, $sb.ToString(), [System.Text.UTF8Encoding]::new($false)) + if ($env:INPUT_SCHEDULE_PATH) { $params['SchedulePath'] = $env:INPUT_SCHEDULE_PATH } + if ($env:INPUT_CLUSTER_CSV_PATH) { $params['ClusterCsvPath'] = $env:INPUT_CLUSTER_CSV_PATH } + if ($env:INPUT_INCLUDE_UNTAGGED -eq 'true') { $params['IncludeUntagged'] = $true } - # Step outputs for the summary step. - "TOTAL_ROWS=$($audit.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "COVERED=$($covered.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "UNCOVERED=$($uncovered.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "PARTIAL=$($partial.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "MALFORMED=$($malformed.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "UNPARSEABLE=$($unparseable.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "NO_WINDOW_TAG=$($noWindowTag.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "RING_MISSING=$($ringMissing.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "RING_ORPHAN=$($ringOrphan.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "RING_MIXED=$($ringMixed.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - # v0.7.90: surface whether -SchedulePath was supplied + its value - # so the summary step (separate step scope) can render the - # Schedule (ring-file gap) detail table and the new allow-list - # coverage section without re-deriving from CSV rows. - "HAVE_SCHEDULE=$([string]$haveSchedule)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - if ($haveSchedule) { - "SCHEDULE_PATH=$schedulePath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - } - - Write-Host "" - Write-Host "Audit complete:" - Write-Host " Covered : $($covered.Count)" - Write-Host " Uncovered : $($uncovered.Count)" - Write-Host " PartiallyCovered : $($partial.Count)" - Write-Host " MalformedTag : $($malformed.Count)" - Write-Host " UnparseableCron : $($unparseable.Count)" - Write-Host " NoWindowTag : $($noWindowTag.Count)" - Write-Host " RingMissingFromSchedule : $($ringMissing.Count)" - Write-Host " RingOrphanedInSchedule : $($ringOrphan.Count)" - Write-Host " RingMixedWindows : $($ringMixed.Count)" + Export-AzLocalApplyUpdatesScheduleAudit @params - name: Compute Artifact Timestamp if: always() id: artifact-stamp shell: pwsh - # every downloadable artifact gets a UTC timestamp suffix so multiple runs on - # the same day produce distinct zip names. UTC keeps Azure DevOps + GitHub Actions in sync. run: | $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss') "timestamp=$stamp" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append @@ -444,207 +254,6 @@ jobs: path: ./reports/ retention-days: 90 - - name: Create Schedule Coverage Summary - if: always() - shell: pwsh - run: | - $total = "${{ steps.audit.outputs.TOTAL_ROWS }}" - $covered = "${{ steps.audit.outputs.COVERED }}" - $uncovered = "${{ steps.audit.outputs.UNCOVERED }}" - $partial = "${{ steps.audit.outputs.PARTIAL }}" - $malformed = "${{ steps.audit.outputs.MALFORMED }}" - $unparseable = "${{ steps.audit.outputs.UNPARSEABLE }}" - $noWindowTag = "${{ steps.audit.outputs.NO_WINDOW_TAG }}" - $ringMissing = "${{ steps.audit.outputs.RING_MISSING }}" - $ringOrphan = "${{ steps.audit.outputs.RING_ORPHAN }}" - $ringMixed = "${{ steps.audit.outputs.RING_MIXED }}" - # v0.7.90: pulled forward from the audit step so this summary step - # can render the Schedule (ring-file gap) detail table and the - # allow-list coverage section. Without this, $haveSchedule was - # undefined here (separate step scope) and the Schedule diff - # table silently disappeared from the GitHub Step Summary. - $haveSchedule = ("${{ steps.audit.outputs.HAVE_SCHEDULE }}" -eq 'True') - $schedulePath = "${{ steps.audit.outputs.SCHEDULE_PATH }}" - - $rows = @() - if (Test-Path './reports/schedule-coverage-audit.csv') { - $rows = @(Import-Csv -Path './reports/schedule-coverage-audit.csv') - } - $reco = '' - if (Test-Path './reports/schedule-coverage-recommend.md') { - $reco = Get-Content -Path './reports/schedule-coverage-recommend.md' -Raw - } - - # when there is at least one issue row, surface the action-required - # markdown at the TOP of the summary so operators can act without scrolling past the - # detail table. The Recommend output now ships its own '## Action required - ...' - # headings (one per kind of fix), numbered '(1 of N)' when more than one applies, - # so we inline it as-is rather than wrapping it in our own code fence. - $hasIssues = (([int]$uncovered) + ([int]$partial) + ([int]$malformed) + ([int]$unparseable) + ([int]$noWindowTag) + ([int]$ringMissing) + ([int]$ringOrphan)) -gt 0 - - $md = @" - ## Apply-Updates Schedule Coverage Audit - - | Metric | Count | - |--------|-------| - | **(Ring, Window) pairs audited** | $total | - | **Covered** | $covered | - | **Uncovered** | $uncovered | - | **PartiallyCovered** | $partial | - | **MalformedTag** | $malformed | - | **UnparseableCron** | $unparseable | - | **NoWindowTag** | $noWindowTag | - | **RingMissingFromSchedule** | $ringMissing | - | **RingOrphanedInSchedule** | $ringOrphan | - | **RingMixedWindows** | $ringMixed | - - "@ - - if ($hasIssues -and $reco) { - $md += "`n" + $reco + "`n" - } - - # split Audit Detail into two sub-tables. Schedule rows - # (RingMissingFromSchedule / RingOrphanedInSchedule) come FIRST - - # higher blast radius because a missing-from-schedule ring means - # apply-updates NEVER fires for those clusters. The Schedule - # sub-table is rendered whenever -SchedulePath was supplied (so - # operators see the "no gaps" confirmation) OR whenever any - # Schedule-section rows are present (defensive fallback in case - # $haveSchedule didn't make it across step scopes). - $scheduleRowsMd = @($rows | Where-Object { $_.Section -eq 'Schedule' }) - $cronRowsMd = @($rows | Where-Object { $_.Section -ne 'Schedule' }) - - function Add-DetailTable { - param([string]$Heading, [object[]]$DetailRows, [string]$EmptyText) - $body = "`n$Heading`n" - if ($DetailRows.Count -eq 0) { - $body += "`n*$EmptyText*`n" - return $body - } - $body += "`n| Status | UpdateRing | UpdateStartWindow | Clusters | Required Cron (UTC) | Recommendation |`n" - $body += "|--------|------------|--------------|---------:|----------------------|----------------|`n" - foreach ($r in ($DetailRows | Select-Object -First 100)) { - $body += ('| {0} | {1} | {2} | {3} | {4} | {5} |' -f $r.Status, $r.UpdateRing, $r.UpdateStartWindow, $r.ClusterCount, $r.RequiredCronUTC, $r.Recommendation) + "`n" - } - if ($DetailRows.Count -gt 100) { - $body += "`n*Showing first 100 of $($DetailRows.Count); see ``schedule-coverage-audit.csv`` artifact for the full list.*`n" - } - return $body - } - - if ($haveSchedule -or $scheduleRowsMd.Count -gt 0) { - $md += "`n### Audit Detail - Schedule (ring-file gap)`n" - $md += "`n> **Higher blast radius.** A ``RingMissingFromSchedule`` row means apply-updates will NEVER fire on those clusters until you either add the ring to ``apply-updates-schedule.yml`` or retag them onto an existing scheduled ring.`n" - $md += (Add-DetailTable -Heading '' -DetailRows $scheduleRowsMd -EmptyText 'Schedule and fleet ring sets match - no gaps.') - } - - # v0.7.90: Allow-list coverage section. When -SchedulePath was - # supplied, re-load the schedule file (the summary step is a - # separate pwsh process so we have to import the module + read - # the schedule here too) and surface the effective - # 'allowedUpdateVersions' for each schedule row. For schema v1 - # we just nudge the operator to migrate. For schema v2 we - # render a per-row table and recommend per-ring override - # snippets for rows that inherit the top-level default. Wrapped - # in try/catch so a malformed schedule never blocks the summary. - if ($haveSchedule -and -not [string]::IsNullOrWhiteSpace($schedulePath) -and (Test-Path -LiteralPath $schedulePath)) { - try { - Import-Module AzLocal.UpdateManagement -Force -ErrorAction Stop - $sched = Get-AzLocalApplyUpdatesScheduleConfig -Path $schedulePath -ErrorAction Stop - } catch { - $sched = $null - $md += "`n### Allow-list coverage`n`n*Could not load schedule file ``$schedulePath`` for allow-list analysis: $($_.Exception.Message)*`n" - } - if ($sched) { - $md += "`n### Allow-list coverage (schema v$($sched.SchemaVersion))`n" - if ($sched.SchemaVersion -lt 2) { - $md += "`n*This schedule is on schema v1. Schema v2 adds ``allowedUpdateVersions`` for fleet-wide + per-ring update allow-lists (e.g. enforce a 'minimum updates' policy on Prod). Migrate the file with:*`n" - $md += "`n``````powershell`n" - $md += "Update-AzLocalApplyUpdatesScheduleConfig -Path '$schedulePath' -SchemaMigrate`n" - $md += "```````n" - } else { - $topAllow = @($sched.AllowedUpdateVersions) - $topIsLatest = ($topAllow.Count -eq 1 -and $topAllow[0] -eq 'Latest') - if ($topIsLatest) { - $topLabel = "``Latest`` _(no constraint - install the latest Ready update on each cluster)_" - } else { - $topLabel = "``$($topAllow -join '; ')`` _(explicit allow-list - clusters only install matching updates)_" - } - $md += "`n**Top-level fleet default:** $topLabel`n" - - $md += "`n| Row | weeksInCycle | daysOfWeek | rings | Effective allowedUpdateVersions |`n" - $md += "|----:|--------------|------------|-------|---------------------------------|`n" - $rowsMissingOverride = New-Object System.Collections.Generic.List[object] - $rowIdx = 0 - foreach ($r in @($sched.Schedule)) { - $rowIdx++ - $rowAllow = @() - if ($r.PSObject.Properties.Match('AllowedUpdateVersionsParsed').Count -and $r.AllowedUpdateVersionsParsed) { - $rowAllow = @($r.AllowedUpdateVersionsParsed) - } - if ($rowAllow.Count -gt 0) { - if ($rowAllow.Count -eq 1 -and $rowAllow[0] -eq 'Latest') { - $effLabel = "``Latest`` _(row override: no constraint)_" - } else { - $effLabel = "``$($rowAllow -join '; ')`` _(row override)_" - } - } else { - if ($topIsLatest) { - $effLabel = "``Latest`` _(inherits top-level - no constraint)_" - } else { - $effLabel = "``$($topAllow -join '; ')`` _(inherits top-level)_" - } - $rowsMissingOverride.Add([pscustomobject]@{ - Row = $rowIdx - WeeksInCycle = $r.weeksInCycle - DaysOfWeek = $r.daysOfWeek - Rings = $r.rings - }) - } - $md += ('| {0} | {1} | {2} | {3} | {4} |' -f $rowIdx, $r.weeksInCycle, $r.daysOfWeek, $r.rings, $effLabel) + "`n" - } - - if ($rowsMissingOverride.Count -gt 0) { - $md += "`n*$($rowsMissingOverride.Count) row(s) inherit the top-level allow-list. This is fine when you want each ring to install the latest Ready update as soon as it is available.*`n" - $md += "`n### Optional - pin a ring to a specific update in ``$schedulePath```n" - $md += "`n*Only needed if you want to PIN a ring to a specific update (e.g. keep Prod on the latest feature drop only - YY04 / YY10 - and skip cumulative updates between feature drops). This is NOT a fix for the cron-coverage or ring-diff sections above - those have their own 'Action required' blocks.* Add ``allowedUpdateVersions:`` to the row that covers the ring you want to pin. Use ``Get-AzLocalAvailableUpdates`` (or the Azure portal -> Azure Local -> Updates) to find valid update names / version strings.`n" - $md += "`n``````yaml`n" - $md += "- weeksInCycle: '*'`n" - $md += " daysOfWeek: 'Tue,Wed,Thu'`n" - $md += " rings: 'Prod'`n" - $md += " allowedUpdateVersions: 'Solution12.2604.1003.1005;Solution12.2610.1003.XX'`n" - $md += "```````n" - } else { - $md += "`n*Every schedule row has an explicit ``allowedUpdateVersions:`` override - no inheritance from the top-level default.*`n" - } - } - } - } - - $md += (Add-DetailTable -Heading '### Audit Detail - Cron coverage (Uncovered / Partial / Malformed first)' -DetailRows $cronRowsMd -EmptyText 'No tagged clusters found - nothing to audit.') - - # When there are no issues, still print the recommendation block at the bottom for - # operators who want the canonical cron list as a reference. When there are issues - # we already printed it at the top so skip the duplicate. - if (-not $hasIssues -and $reco) { - $md += "`n### Reference - Recommended schedule (copy into Step.6_apply-updates.yml)`n" - $md += "`n" + $reco + "`n" - } - - $md += @" - - ### Reports Available - - ``schedule-coverage-audit.csv`` - one row per (UpdateRing, UpdateStartWindow) pair - - ``schedule-coverage-matrix.csv`` - full inventory + required cron per row - - ``schedule-coverage-recommend.md`` - ready-to-paste cron block (GH + ADO) - - ``schedule-coverage-audit.xml`` - JUnit XML for CI/CD visualisation - - *Generated at $(Get-Date -Format "yyyy-MM-dd HH:mm:ss UTC")* - "@ - - $md | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - - name: Publish Test Results uses: dorny/test-reporter@v3 if: always() 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 2cdf3a44..648986fd 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 @@ -39,6 +39,12 @@ # AUTHENTICATION: # Uses OpenID Connect (OIDC) - recommended for secretless authentication # See: https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure-openid-connect +# +# v0.8.5 thin-YAML: the inline run: | body (data collection + +# severity classification + JUnit XML generation + markdown summary + +# step outputs) is now the Export-AzLocalFleetConnectivityStatusReport +# Public cmdlet. This yml is condensed to a few lines per step; the +# full workload (and its Pester tests) live in the module. # Workflow name carries the same Step.N - prefix as the filename so the GitHub # Actions sidebar (which sorts workflows alphabetically by this `name:` field) @@ -109,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.4' + GENERATED_AGAINST_MODULE_VERSION: '0.8.5' # 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). @@ -132,6 +138,24 @@ jobs: id-token: write contents: read checks: write # Required for test reporter + outputs: + # v0.8.5 thin-YAML: keys align byte-for-byte with the lowercase output + # names emitted by Export-AzLocalFleetConnectivityStatusReport (via + # Set-AzLocalPipelineOutput). ADO is case-sensitive on task.setvariable + # lookups; GH is case-insensitive but lowercase is the canonical v0.8.5+ + # convention. + cluster_total: ${{ steps.fleet-connectivity.outputs.cluster_total }} + cluster_fail: ${{ steps.fleet-connectivity.outputs.cluster_fail }} + arc_total: ${{ steps.fleet-connectivity.outputs.arc_total }} + arc_fail: ${{ steps.fleet-connectivity.outputs.arc_fail }} + nic_total: ${{ steps.fleet-connectivity.outputs.nic_total }} + nic_fail: ${{ steps.fleet-connectivity.outputs.nic_fail }} + nic_all_total: ${{ steps.fleet-connectivity.outputs.nic_all_total }} + arb_total: ${{ steps.fleet-connectivity.outputs.arb_total }} + arb_fail: ${{ steps.fleet-connectivity.outputs.arb_fail }} + total_failures: ${{ steps.fleet-connectivity.outputs.total_failures }} + critical_count: ${{ steps.fleet-connectivity.outputs.critical_count }} + warning_count: ${{ steps.fleet-connectivity.outputs.warning_count }} steps: - name: Checkout repository @@ -155,11 +179,13 @@ jobs: - name: Install Azure CLI Resource Graph Extension shell: pwsh - run: | - az extension add --name resource-graph --yes + run: az extension add --name resource-graph --yes - name: Install AzLocal.UpdateManagement from PSGallery + # v0.8.5 thin-YAML: drift detection + banner + step outputs are all + # produced by Add-AzLocalPipelineVersionBanner (Public cmdlet). shell: pwsh + id: module-version run: | $ErrorActionPreference = 'Stop' $installArgs = @{ Name = 'AzLocal.UpdateManagement'; Scope = 'CurrentUser'; Force = $true; AllowClobber = $true } @@ -171,299 +197,36 @@ jobs: } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force - $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on runner : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - Write-Host "" - if ($installed -lt $generated) { - $msg = "AzLocal.UpdateManagement v$installed is OLDER than the version this workflow YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Update'." - Write-Host "::warning title=AzLocal.UpdateManagement is older than workflow YAML expects::$msg" - } - if ($installed -gt $generated) { - $msg = "Workflow YAML was generated against AzLocal.UpdateManagement v$generated but the runner installed v$installed. Refresh via 'Copy-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Update'." - Write-Host "::notice title=Workflow YAML may be stale::$msg" - } - if ($latest -and ($latest -gt $installed)) { - $msg = "AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed." - Write-Host "::notice title=Newer AzLocal.UpdateManagement version available on PSGallery::$msg" - } - - # v0.8.4 - one-line version banner emitted to the rendered Step - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), the - # module version actually loaded on the runner, the PSGallery latest, - # and the REQUIRED_MODULE_VERSION pin status are visible at the top - # of every run summary without scrolling the agent log. Same data is - # also echoed to the console above as 'Module version summary'. - $pin = if ($env:REQUIRED_MODULE_VERSION) { "pinned to v$($env:REQUIRED_MODULE_VERSION)" } else { 'latest (fix-forward)' } - $latestStr = if ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } - $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } - elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } - elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } - else { 'in sync' } - if ($env:GITHUB_STEP_SUMMARY) { - "_Pipeline YAML v$generated | Module v$installed installed ($pin) | PSGallery latest $latestStr | $verdict_" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - } + Add-AzLocalPipelineVersionBanner ` + -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION ` + -PinnedVersion $env:REQUIRED_MODULE_VERSION - name: Collect Fleet Connectivity Data + # v0.8.5 thin-YAML: the inline run body (5 ARG queries via + # Get-AzLocalFleetConnectivityStatus + per-scope severity + # classification + ~200-line inline JUnit StringBuilder + + # markdown summary render + 12 step outputs) has been condensed + # into the Public cmdlet Export-AzLocalFleetConnectivityStatusReport. + # The cmdlet writes ./reports/{7-csv,7-json,fleet-connectivity-status.xml}, + # emits the markdown summary via GITHUB_STEP_SUMMARY (using the + # shared New-AzLocalFleetConnectivityStatusSummary renderer), and + # sets the 12 lowercase step outputs (cluster_total, cluster_fail, + # arc_total, arc_fail, nic_total, nic_fail, nic_all_total, + # arb_total, arb_fail, total_failures, critical_count, warning_count). shell: pwsh id: fleet-connectivity env: INPUT_SUBSCRIPTION_FILTER: ${{ github.event.inputs.subscription_filter }} + INSTALLED_MODULE_VERSION: ${{ steps.module-version.outputs.installed_module_version }} run: | $ErrorActionPreference = 'Stop' - - $outputDir = "./reports" - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - - # Optional subscription filter - passed as SubscriptionId to the module - # cmdlet. Leave empty to query every subscription visible to the runner. - $invokeArgs = @{ ExportPath = $outputDir; PassThru = $true } - $subFilterRaw = $env:INPUT_SUBSCRIPTION_FILTER - if ($subFilterRaw) { - $subList = $subFilterRaw -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } - if ($subList -and $subList.Count -gt 0) { - $invokeArgs['SubscriptionId'] = $subList[0] - Write-Host "Subscription filter active (first sub): $($subList[0])" - } - } - if (-not $invokeArgs.ContainsKey('SubscriptionId')) { - Write-Host "Subscription filter empty - scanning all subscriptions visible to the federated identity." - } - - Write-Host "========================================" - Write-Host "Fleet Connectivity Status Collection" - Write-Host "========================================" - - # Collect all four connectivity scopes via the module cmdlet. - # Runs 5 ARG queries, processes results client-side, and exports - # 7 CSV + 7 JSON files to $outputDir. - $data = Get-AzLocalFleetConnectivityStatus @invokeArgs - $clusterRows = $data.ClusterRows - $arcSummary = $data.ArcSummary - $arcRows = $data.NonConnectedMachines - $nicRows = $data.NicIssues - $nicAllRows = $data.NicAll - $nicStats = $data.NicStats - $arbRows = $data.ArbRows - - Write-Host "Fleet Connectivity Collection complete:" - Write-Host " Clusters : $($clusterRows.Count) total" - Write-Host " Arc agents: $($arcSummary.Count) distinct status value(s); $($arcRows.Count) NOT 'Connected'" - Write-Host " Physical NIC issues: $($nicRows.Count) (Disconnected with non-APIPA IP)" - Write-Host " NIC inventory (full): $($nicAllRows.Count) total NICs across $($nicStats.Count) NicType+NicStatus groups" - Write-Host " ARB appliances: $($arbRows.Count)" - - # ------------------------------------------------------------------ - # JUnit XML pivoted by Severity -> Scope -> Resource. Critical = - # confirmed disconnected/offline. Warning = expired/partial/unknown. - # Passing testcases are emitted for the "all green" case so dorny - # renders a green banner rather than an empty file. - # ------------------------------------------------------------------ - function Convert-XmlEscape([string]$s) { - if ($null -eq $s) { return '' } - ($s.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"').Replace("'",''')) - } - - # Severity classifiers (case-insensitive). "Connected", "ConnectedRecently", - # "Running", "Up" = healthy. Disconnected / Offline / Expired = Critical. - # Anything else (Unknown / NotSpecified / NotConnected with no - # lastStatusChange) = Warning. - function Get-ClusterSeverity { param($s) switch -Regex ($s) { '^(Connected|ConnectedRecently)$' { 'Pass'; break } '^(Disconnected|Expired|Offline|Error)$' { 'Critical'; break } default { 'Warning' } } } - function Get-ArcSeverity { param($s) switch -Regex ($s) { '^(Connected)$' { 'Pass'; break } '^(Disconnected|Expired|Offline|Error)$' { 'Critical'; break } default { 'Warning' } } } - function Get-NicSeverity { param($s) switch -Regex ($s) { '^(Connected|Up)$' { 'Pass'; break } '^(Disconnected|Down|Disabled)$' { 'Critical'; break } default { 'Warning' } } } - function Get-ArbSeverity { param($s) switch -Regex ($s) { '^(Running)$' { 'Pass'; break } '^(Offline|Failed)$' { 'Critical'; break } default { 'Warning' } } } - - $clusterFail = @($clusterRows | Where-Object { (Get-ClusterSeverity $_.ConnectivityStatus) -ne 'Pass' }) - $arcFail = @($arcRows | Where-Object { (Get-ArcSeverity $_.AgentStatus) -ne 'Pass' }) - $nicFail = @($nicRows | Where-Object { (Get-NicSeverity $_.NicStatus) -ne 'Pass' }) - $arbFail = @($arbRows | Where-Object { (Get-ArbSeverity $_.ArbStatus) -ne 'Pass' }) - - $totalFailures = $clusterFail.Count + $arcFail.Count + $nicFail.Count + $arbFail.Count - $criticalCount = @($clusterFail | Where-Object { (Get-ClusterSeverity $_.ConnectivityStatus) -eq 'Critical' }).Count ` - + @($arcFail | Where-Object { (Get-ArcSeverity $_.AgentStatus) -eq 'Critical' }).Count ` - + @($nicFail | Where-Object { (Get-NicSeverity $_.NicStatus) -eq 'Critical' }).Count ` - + @($arbFail | Where-Object { (Get-ArbSeverity $_.ArbStatus) -eq 'Critical' }).Count - $warningCount = $totalFailures - $criticalCount - - $xmlPath = Join-Path $outputDir 'fleet-connectivity-status.xml' - $sb = New-Object System.Text.StringBuilder - [void]$sb.AppendLine('') - [void]$sb.AppendFormat('' + "`n", $totalFailures) - - function Add-Suite { - param([string]$Name, [array]$Failures, [scriptblock]$GetCase, [System.Text.StringBuilder]$Sb) - if (-not $Failures -or $Failures.Count -eq 0) { return } - [void]$Sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $Name), $Failures.Count) - foreach ($r in $Failures) { - $c = & $GetCase $r - [void]$Sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $c.Classname), (Convert-XmlEscape $c.Name)) - [void]$Sb.AppendLine(' ') - foreach ($k in $c.Properties.Keys) { - [void]$Sb.AppendFormat(' ' + "`n", $k, (Convert-XmlEscape ([string]$c.Properties[$k]))) - } - [void]$Sb.AppendLine(' ') - [void]$Sb.AppendFormat(' {2}' + "`n", (Convert-XmlEscape $c.Severity), (Convert-XmlEscape $c.Message), (Convert-XmlEscape $c.Body)) - [void]$Sb.AppendLine(' ') - } - [void]$Sb.AppendLine(' ') - } - - Add-Suite -Name 'Cluster Connectivity' -Failures $clusterFail -Sb $sb -GetCase { - param($r) - $sev = Get-ClusterSeverity $r.ConnectivityStatus - $portal = "https://portal.azure.com/#@/resource$($r.ClusterId)" - return [PSCustomObject]@{ - Classname = $r.ClusterName - Name = "Cluster '$($r.ClusterName)' :: ConnectivityStatus=$($r.ConnectivityStatus)" - Severity = $sev - Message = "Cluster connectivity is '$($r.ConnectivityStatus)' (cluster.status='$($r.ClusterStatus)')" - Body = "ResourceGroup: $($r.ResourceGroup)`nSubscriptionId: $($r.SubscriptionId)`nClusterPortalUrl: $portal" - Properties = @{ - ClusterName = $r.ClusterName - ClusterResourceId = $r.ClusterId - UpdateName = "ClusterConnectivity=$($r.ConnectivityStatus)" - Status = $sev - FailureReason = "Cluster connectivity '$($r.ConnectivityStatus)'" - Severity = $sev - ClusterPortalUrl = $portal - TargetResourceName = $r.ClusterName - TargetResourceType = 'microsoft.azurestackhci/clusters' - } - } - } - - Add-Suite -Name 'Arc Agent Connectivity' -Failures $arcFail -Sb $sb -GetCase { - param($r) - $sev = Get-ArcSeverity $r.AgentStatus - $portal = "https://portal.azure.com/#@/resource$($r.MachineId)" - return [PSCustomObject]@{ - Classname = $r.ClusterName - Name = "Machine '$($r.NodeName)' :: Status=$($r.AgentStatus)" - Severity = $sev - Message = "Machine '$($r.NodeName)' has Arc agent status '$($r.AgentStatus)' (last status change: $($r.LastStatusChange))" - Body = ("ClusterName: $($r.ClusterName)`n" + - "AgentStatus: $($r.AgentStatus)`n" + - "OS SKU: $($r.OsSku)`n" + - "OS Version: $($r.OsVersion)`n" + - "Cluster Version: $($r.ClusterVersion)`n" + - "AgentVersion: $($r.AgentVersion)`n" + - "Disconnected Since (lastStatusChange): $($r.LastStatusChange)`n" + - "ResourceGroup: $($r.ResourceGroup)`n" + - "MachineId: $($r.MachineId)") - Properties = @{ - ClusterName = $r.ClusterName - ClusterResourceId = $r.ClusterId - UpdateName = "ArcAgent=$($r.AgentStatus) [$($r.NodeName)]" - Status = $sev - FailureReason = "Arc agent '$($r.AgentStatus)' on machine '$($r.NodeName)'" - Severity = $sev - ClusterPortalUrl = $portal - TargetResourceName = $r.NodeName - TargetResourceType = 'microsoft.hybridcompute/machines' - } - } - } - - Add-Suite -Name 'Physical NIC Status' -Failures $nicFail -Sb $sb -GetCase { - param($r) - $sev = Get-NicSeverity $r.NicStatus - $portal = "https://portal.azure.com/#@/resource$($r.MachineId)" - return [PSCustomObject]@{ - Classname = $r.ClusterName - Name = "NIC '$($r.NicName)' on '$($r.NodeName)' :: NicStatus=$($r.NicStatus)" - Severity = $sev - Message = "Physical NIC '$($r.NicName)' on node '$($r.NodeName)' reports status '$($r.NicStatus)'" - Body = "ClusterName: $($r.ClusterName)`nInterface: $($r.InterfaceDescription)`nDriverVersion: $($r.DriverVersion)`nIp4Address: $($r.Ip4Address)`nMachineId: $($r.MachineId)" - Properties = @{ - ClusterName = $r.ClusterName - ClusterResourceId = '' - UpdateName = "PhysicalNic=$($r.NicStatus) [$($r.NodeName)/$($r.NicName)]" - Status = $sev - FailureReason = "Physical NIC '$($r.NicName)' status '$($r.NicStatus)' on node '$($r.NodeName)'" - Severity = $sev - ClusterPortalUrl = $portal - TargetResourceName = "$($r.NodeName)/$($r.NicName)" - TargetResourceType = 'microsoft.azurestackhci/edgedevices/nicDetails' - } - } - } - - Add-Suite -Name 'Azure Resource Bridge' -Failures $arbFail -Sb $sb -GetCase { - param($r) - $sev = Get-ArbSeverity $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 (multi-cluster- - # per-RG collapse). Use the first cluster for the portal URL and - # keep the full list in the body / properties. - # NOTE: 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 { @() } - $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 { '' } - return [PSCustomObject]@{ - Classname = if ($r.ClusterName) { $r.ClusterName } else { '(no cluster mapping)' } - Name = "ARB '$($r.ArbName)' :: ArbStatus=$($r.ArbStatus)" - Severity = $sev - Message = "Azure Resource Bridge '$($r.ArbName)' is '$($r.ArbStatus)' (days since lastModified=$($r.DaysSinceLastModified))$multiClusterNote" - Body = "ClusterName: $($r.ClusterName)`nArbId: $($r.ArbId)`nLastModified: $($r.LastModified)`nDaysSinceLastModified: $($r.DaysSinceLastModified)`nClusterPortalUrl: $clusterPortal" - Properties = @{ - ClusterName = if ($r.ClusterName) { $r.ClusterName } else { '' } - ClusterResourceId = if ($primaryClusterId) { $primaryClusterId } else { '' } - UpdateName = "ARB=$($r.ArbStatus) [$($r.ArbName)]" - Status = $sev - FailureReason = "ARB '$($r.ArbName)' status '$($r.ArbStatus)'$multiClusterNote" - Severity = $sev - ClusterPortalUrl = $clusterPortal - TargetResourceName = $r.ArbName - TargetResourceType = 'microsoft.resourceconnector/appliances' - } - } - } - - if ($totalFailures -eq 0) { - [void]$sb.AppendLine(' ') - [void]$sb.AppendLine(' ') - [void]$sb.AppendLine(' ') + Import-Module AzLocal.UpdateManagement -Force + $params = @{ + OutputDirectory = './reports' + InstalledModuleVersion = $env:INSTALLED_MODULE_VERSION } - [void]$sb.AppendLine('') - $sb.ToString() | Out-File -FilePath $xmlPath -Encoding utf8 - - # Step outputs consumed by the markdown summary step below. - # Arc totals come from the status-summary histogram so the markdown - # table can show healthy + . - $arcTotalMachines = ($arcSummary | Measure-Object -Property Count -Sum).Sum - if ($null -eq $arcTotalMachines) { $arcTotalMachines = 0 } - "CLUSTER_TOTAL=$($clusterRows.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "CLUSTER_FAIL=$($clusterFail.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "ARC_TOTAL=$arcTotalMachines" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "ARC_FAIL=$($arcRows.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "NIC_TOTAL=$($nicRows.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "NIC_FAIL=$($nicFail.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "NIC_ALL_TOTAL=$($nicAllRows.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "ARB_TOTAL=$($arbRows.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "ARB_FAIL=$($arbFail.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "TOTAL_FAILURES=$totalFailures" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "CRITICAL_COUNT=$criticalCount" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "WARNING_COUNT=$warningCount" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - - Write-Host "" - Write-Host "Fleet Connectivity Collection complete:" - Write-Host " Clusters : $($clusterRows.Count) total ($($clusterFail.Count) failing)" - Write-Host " Arc agents: $arcTotalMachines machine(s) across $($arcSummary.Count) distinct status value(s); $($arcRows.Count) NOT 'Connected'" - Write-Host " Physical NIC issues: $($nicRows.Count) (Disconnected with non-APIPA IP - 'Up' adapters and adapters without a real IP are filtered as noise)" - Write-Host " NIC inventory (full): $($nicAllRows.Count) total NICs across $($nicStats.Count) NicType+NicStatus groups" - Write-Host " ARB appliances: $($arbRows.Count) total ($($arbFail.Count) failing)" - Write-Host " TOTAL FAILURES: $totalFailures (Critical=$criticalCount, Warning=$warningCount)" + if ($env:INPUT_SUBSCRIPTION_FILTER) { $params['SubscriptionFilter'] = $env:INPUT_SUBSCRIPTION_FILTER } + Export-AzLocalFleetConnectivityStatusReport @params - name: Compute Artifact Timestamp if: always() @@ -475,61 +238,16 @@ jobs: Write-Host "Artifact timestamp: $stamp" - name: Upload Fleet Connectivity Reports + if: always() uses: actions/upload-artifact@v6 with: name: azlocal-step.4-fleet-connectivity-status-report_${{ steps.artifact-stamp.outputs.timestamp }} path: ./reports/ retention-days: 90 - # Create the markdown summary FIRST so it appears at the top of the GitHub - # Actions run summary view. Publish Test Results (dorny) runs AFTER. - - name: Create Fleet Connectivity Summary - if: always() - shell: pwsh - # NOTE: step outputs are surfaced via step-level `env:` (NOT `${{ }}` inside the - # `run:` body) so GitHub Actions does NOT treat the whole `run:` block as a single - # expression. The `run:` body has no `${{ }}` substitutions, so it is plain content - # and is NOT bound by the 21,000-char expression-length cap that bit v0.7.85. - env: - CLUSTER_TOTAL: ${{ steps.fleet-connectivity.outputs.CLUSTER_TOTAL }} - CLUSTER_FAIL: ${{ steps.fleet-connectivity.outputs.CLUSTER_FAIL }} - ARC_TOTAL: ${{ steps.fleet-connectivity.outputs.ARC_TOTAL }} - ARC_FAIL: ${{ steps.fleet-connectivity.outputs.ARC_FAIL }} - NIC_TOTAL: ${{ steps.fleet-connectivity.outputs.NIC_TOTAL }} - NIC_FAIL: ${{ steps.fleet-connectivity.outputs.NIC_FAIL }} - ARB_TOTAL: ${{ steps.fleet-connectivity.outputs.ARB_TOTAL }} - ARB_FAIL: ${{ steps.fleet-connectivity.outputs.ARB_FAIL }} - TOTAL_FAILURES: ${{ steps.fleet-connectivity.outputs.TOTAL_FAILURES }} - CRITICAL_COUNT: ${{ steps.fleet-connectivity.outputs.CRITICAL_COUNT }} - WARNING_COUNT: ${{ steps.fleet-connectivity.outputs.WARNING_COUNT }} - run: | - # Build the markdown step summary by calling the module renderer - # function. All long-form content (markdown tables, the - # 'How to interpret + act on a non-zero reconciliation' subsection, - # KQL examples, etc.) lives in the function body, NOT inline here. - # This keeps the `run:` block far below GitHub's 21,000-char - # expression-length cap (which the v0.7.85 prose growth tripped - # in v0.7.86) AND gives both this pipeline and the ADO twin a - # single source of truth for the summary layout. - $counts = @{ - ClusterTotal = $env:CLUSTER_TOTAL - ClusterFail = $env:CLUSTER_FAIL - ArcTotal = $env:ARC_TOTAL - ArcFail = $env:ARC_FAIL - NicTotal = $env:NIC_TOTAL - NicFail = $env:NIC_FAIL - ArbTotal = $env:ARB_TOTAL - ArbFail = $env:ARB_FAIL - TotalFailures = $env:TOTAL_FAILURES - CriticalCount = $env:CRITICAL_COUNT - WarningCount = $env:WARNING_COUNT - } - $md = New-AzLocalFleetConnectivityStatusSummary -ReportsPath './reports' -Counts $counts - $md | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - - # Publish JUnit test results AFTER the markdown summary so the summary - # tables are the first thing visible. list-suites/list-tests=failed keeps - # the expansion compact for large fleets. + # Publish JUnit test results AFTER the cmdlet (which writes the markdown + # summary first) so the summary tables are the first thing visible. + # list-suites/list-tests=failed keeps the expansion compact for large fleets. - name: Publish JUnit Diagnostic Results uses: dorny/test-reporter@v3 if: always() @@ -626,4 +344,3 @@ jobs: path: ./reports/itsm-results.xml reporter: java-junit continue-on-error: true - 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 35037386..0bd7ac8a 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 @@ -25,6 +25,12 @@ # # Remediation of Critical health failures is out of scope for this module - see the module # README "Assess Readiness and Health BEFORE Applying Updates" section for pointers. +# +# v0.8.5 thin-YAML: the inline run: | body (inventory + scope-param construction + +# Get-AzLocalClusterUpdateReadiness + Test-AzLocalClusterHealth + combined JUnit XML +# merge + 8-section markdown summary + step outputs) is now the +# Export-AzLocalClusterUpdateReadinessReport Public cmdlet. This yml is condensed +# to a few lines per step; the full workload (and its Pester tests) live in the module. # Workflow name carries the same Step.N - prefix as the filename so the GitHub # Actions sidebar (which sorts workflows alphabetically by this `name:` field) @@ -65,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.4' + GENERATED_AGAINST_MODULE_VERSION: '0.8.5' # 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). @@ -89,8 +95,13 @@ jobs: contents: read checks: write outputs: - not_ready: ${{ steps.gate.outputs.NOT_READY }} - critical_failures: ${{ steps.gate.outputs.CRITICAL_FAILURES }} + # v0.8.5 thin-YAML: keys align byte-for-byte with the lowercase output + # names emitted by Export-AzLocalClusterUpdateReadinessReport (which uses + # Set-AzLocalPipelineOutput under the hood). ADO is case-sensitive on + # task.setvariable lookups; GH is case-insensitive but the lowercase + # form is the canonical one used by every v0.8.5+ Step.* cmdlet. + not_ready: ${{ steps.gate.outputs.not_ready }} + critical_failures: ${{ steps.gate.outputs.critical_failures }} steps: - name: Checkout repository @@ -116,7 +127,10 @@ jobs: run: az extension add --name resource-graph --yes - name: Install AzLocal.UpdateManagement from PSGallery + # v0.8.5 thin-YAML: drift detection + banner + step outputs are all + # produced by Add-AzLocalPipelineVersionBanner (Public cmdlet). shell: pwsh + id: module-version run: | $ErrorActionPreference = 'Stop' $installArgs = @{ Name = 'AzLocal.UpdateManagement'; Scope = 'CurrentUser'; Force = $true; AllowClobber = $true } @@ -128,310 +142,37 @@ jobs: } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force - $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on runner : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - Write-Host "" - Get-Module AzLocal.UpdateManagement | Format-List Name, Version, Path - # Drift signal 0: the module installed at runtime is OLDER than the version this YAML was generated against. Cmdlets, parameters, or output schemas referenced by this YAML may not exist in the installed module - emitted as a warning because this is the most likely cause of a hard runtime failure later in the job. - if ($installed -lt $generated) { - $msg = "AzLocal.UpdateManagement v$installed is OLDER than the version this workflow YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Update'." - Write-Host "::warning title=AzLocal.UpdateManagement is older than workflow YAML expects::$msg" - } - if ($installed -gt $generated) { - $msg = "Workflow YAML was generated against AzLocal.UpdateManagement v$generated but the runner installed v$installed. Pipeline steps may have been improved in later releases - to refresh, re-run 'Copy-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Update' (you will be prompted per file; add -Confirm:`$false to bypass). Pipeline YAMLs are under git so 'git diff' shows exactly what changed before commit." - Write-Host "::notice title=Workflow YAML may be stale::$msg" - } - if ($latest -and ($latest -gt $installed)) { - $msg = "AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed. Review the module CHANGELOG before bumping REQUIRED_MODULE_VERSION (or clear the pin to install the latest automatically)." - Write-Host "::notice title=Newer AzLocal.UpdateManagement version available on PSGallery::$msg" - } - - # v0.8.4 - one-line version banner emitted to the rendered Step - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), the - # module version actually loaded on the runner, the PSGallery latest, - # and the REQUIRED_MODULE_VERSION pin status are visible at the top - # of every run summary without scrolling the agent log. Same data is - # also echoed to the console above as 'Module version summary'. - $pin = if ($env:REQUIRED_MODULE_VERSION) { "pinned to v$($env:REQUIRED_MODULE_VERSION)" } else { 'latest (fix-forward)' } - $latestStr = if ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } - $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } - elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } - elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } - else { 'in sync' } - if ($env:GITHUB_STEP_SUMMARY) { - "_Pipeline YAML v$generated | Module v$installed installed ($pin) | PSGallery latest $latestStr | $verdict_" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - } + Add-AzLocalPipelineVersionBanner ` + -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION ` + -PinnedVersion $env:REQUIRED_MODULE_VERSION - name: Run readiness + blocking health checks + # v0.8.5 thin-YAML: the inline run block (inventory + scope param + # construction + Get-AzLocalClusterUpdateReadiness CSV/XML + + # Test-AzLocalClusterHealth -BlockingOnly CSV/XML + combined JUnit + # merge + 8-section markdown step summary + the two step outputs + # (NOT_READY / CRITICAL_FAILURES, now lowercase not_ready / + # critical_failures) has been condensed into the Public cmdlet + # Export-AzLocalClusterUpdateReadinessReport. The cmdlet writes + # ./artifacts/{readiness,health-blocking,assess-readiness}.{csv,xml}, + # emits the markdown summary via GITHUB_STEP_SUMMARY, and sets the + # two step outputs. id: gate shell: pwsh env: INPUT_SCOPE: ${{ github.event.inputs.scope }} INPUT_UPDATE_RING: ${{ github.event.inputs.update_ring }} + INSTALLED_MODULE_VERSION: ${{ steps.module-version.outputs.installed_module_version }} run: | + $ErrorActionPreference = 'Stop' Import-Module AzLocal.UpdateManagement -Force - - $scope = $env:INPUT_SCOPE - $updateRing = $env:INPUT_UPDATE_RING - - $outputDir = './artifacts' - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - - $readinessCsv = Join-Path $outputDir 'readiness.csv' - $readinessXml = Join-Path $outputDir 'readiness.xml' - $healthCsv = Join-Path $outputDir 'health-blocking.csv' - $healthXml = Join-Path $outputDir 'health-blocking.xml' - - # Always fetch inventory so we can build a ResourceId -> UpdateRing map - # for the per-ring pivot in the markdown summary (cheap ARG round-trip). - $inventory = Get-AzLocalClusterInventory -PassThru - $ringByResourceId = @{} - if ($inventory) { - foreach ($inv in $inventory) { - $ringValue = if ($inv.UpdateRing) { [string]$inv.UpdateRing } else { '(no ring tag)' } - $ringByResourceId[$inv.ResourceId] = $ringValue - } + $params = @{ + Scope = if ($env:INPUT_SCOPE) { $env:INPUT_SCOPE } else { 'all' } + OutputDirectory = './artifacts' + InstalledModuleVersion = $env:INSTALLED_MODULE_VERSION } - - $scopeParams = @{} - if ($scope -eq 'by-update-ring' -and $updateRing) { - $scopeParams['ScopeByUpdateRingTag'] = $true - $scopeParams['UpdateRingValue'] = $updateRing - Write-Host "Scope: UpdateRing = $updateRing" - } else { - Write-Host "Scope: all clusters (via inventory)" - if (-not $inventory -or $inventory.Count -eq 0) { - Write-Warning 'No clusters found in inventory.' - "NOT_READY=0" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "CRITICAL_FAILURES=0" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - exit 0 - } - $scopeParams['ClusterResourceIds'] = @($inventory | Select-Object -ExpandProperty ResourceId) - } - - Write-Host '' - Write-Host '========================================' -ForegroundColor Cyan - Write-Host 'Step 1: Readiness (Get-AzLocalClusterUpdateReadiness)' -ForegroundColor Cyan - Write-Host '========================================' -ForegroundColor Cyan - - # CSV for humans - $readiness = Get-AzLocalClusterUpdateReadiness @scopeParams ` - -ExportPath $readinessCsv ` - -PassThru - - # JUnit XML for the test reporter (ExportPath .xml auto-detects JUnitXml) - $null = Get-AzLocalClusterUpdateReadiness @scopeParams ` - -ExportPath $readinessXml - - # v0.7.99: 3-bucket model matches Get-AzLocalClusterUpdateReadiness Summary. - # UpToDate clusters are no longer rolled into NotReady - they're now a distinct bucket. - $readyForUpdate = @($readiness | Where-Object { $_.ReadyForUpdate -eq $true }).Count - $upToDate = @($readiness | Where-Object { - $_.ReadyForUpdate -ne $true -and - $_.UpdateState -in @('UpToDate', 'AppliedSuccessfully') -and - [string]::IsNullOrEmpty([string]$_.AllAvailableUpdates) - }).Count - $total = @($readiness).Count - $notReady = $total - $readyForUpdate - $upToDate - - Write-Host '' - Write-Host "Total clusters in scope: $total" - Write-Host "Ready for update : $readyForUpdate" - Write-Host "Up to date : $upToDate" - Write-Host "Not ready for update : $notReady" - - Write-Host '' - Write-Host '========================================' -ForegroundColor Cyan - Write-Host 'Step 2: Blocking health (Test-AzLocalClusterHealth -BlockingOnly)' -ForegroundColor Cyan - Write-Host '========================================' -ForegroundColor Cyan - - # Reuse the same scope. -UpdateSummary skip-fetch is not available here because the - # per-cluster summaries are private to Get-AzLocalClusterUpdateReadiness; a fresh - # call is cheap and keeps the function self-contained. - $health = Test-AzLocalClusterHealth @scopeParams ` - -BlockingOnly ` - -ExportPath $healthCsv ` - -PassThru - - $null = Test-AzLocalClusterHealth @scopeParams ` - -BlockingOnly ` - -ExportPath $healthXml - - # Merge readiness + blocking-health JUnit XMLs into a single combined - # report (assess-readiness.xml) so operators get one Checks-tab entry - # instead of two. The individual XMLs are still uploaded as artifacts - # and still published as [JUnit Debug] entries below for parity. - $combinedXml = Join-Path $outputDir 'assess-readiness.xml' - try { - $readinessDoc = [xml](Get-Content -LiteralPath $readinessXml -Raw) - $healthDoc = [xml](Get-Content -LiteralPath $healthXml -Raw) - $combinedDoc = New-Object System.Xml.XmlDocument - $declaration = $combinedDoc.CreateXmlDeclaration('1.0', 'utf-8', $null) - $combinedDoc.AppendChild($declaration) | Out-Null - $rootElement = $combinedDoc.CreateElement('testsuites') - $rootElement.SetAttribute('name', 'Update Readiness Assessment') - $combinedDoc.AppendChild($rootElement) | Out-Null - foreach ($srcDoc in @($readinessDoc, $healthDoc)) { - $suites = if ($srcDoc.DocumentElement.LocalName -eq 'testsuites') { - $srcDoc.DocumentElement.SelectNodes('testsuite') - } else { - ,$srcDoc.DocumentElement - } - foreach ($suite in $suites) { - $imported = $combinedDoc.ImportNode($suite, $true) - $rootElement.AppendChild($imported) | Out-Null - } - } - $combinedDoc.Save($combinedXml) - Write-Host "Combined JUnit report: $combinedXml" - } - catch { - Write-Warning "Failed to build combined JUnit report: $($_.Exception.Message)" - } - - # Test-AzLocalClusterHealth -PassThru returns one row per cluster - # (shape: ClusterName, HealthState, Passed, CriticalCount, WarningCount, Failures) - # NOT a flat list of finding rows. Aggregate from CriticalCount / Failures rather - # than filtering on a non-existent Severity property (which silently returned 0). - $criticalSum = ($health | Measure-Object -Property CriticalCount -Sum).Sum - $critical = if ($criticalSum) { [int]$criticalSum } else { 0 } - $clustersWithCritical = @($health | Where-Object { [int]$_.CriticalCount -gt 0 }).Count - - Write-Host '' - Write-Host "Critical findings : $critical" - Write-Host "Clusters with Critical : $clustersWithCritical" - - "NOT_READY=$notReady" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "CRITICAL_FAILURES=$clustersWithCritical" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - - # GitHub step summary - audit-order layout: - # 1. Header tile (one-line status + scope) - # 2. Action banner (Action required / All clear) - # 3. Summary counts - # 4. Not-Ready cluster table (blocking findings first) - # 5. Critical-health cluster table (blocking findings, second priority) - # 6. Per-UpdateRing pivot (only shown when >1 ring in scope) - # 7. All-clusters detail table - # 8. Cross-links to upstream/downstream pipelines - $lines = New-Object 'System.Collections.Generic.List[string]' - $lines.Add('## Update Readiness Assessment') - $lines.Add('') - - # 1. Header tile (one-line status, no emoji - ASCII safe) - $scopeLabel = $scope - if ($updateRing) { $scopeLabel = "$scope (UpdateRing = $updateRing)" } - $statusWord = if ($notReady -gt 0 -or $clustersWithCritical -gt 0) { 'ATTENTION' } else { 'OK' } - $lines.Add("**[$statusWord]** $total cluster(s) assessed | $readyForUpdate Ready for Update | $upToDate Up to Date | $notReady Not Ready for Update | $clustersWithCritical with Critical health failures | Scope: $scopeLabel") - $lines.Add('') - - # 2. Action banner - if ($notReady -gt 0 -or $clustersWithCritical -gt 0) { - $lines.Add("> **Action required**: $notReady cluster(s) not ready and/or $clustersWithCritical cluster(s) with Critical health failures. Review the **Not-Ready** and **Critical-health** sections below first; the CSV artifacts in `azlocal-step.5-readiness-assessment-report_*` carry the full per-finding detail. Remediate (hardware vendor SBE / firmware / cluster health) before or alongside the next apply-updates run. **The healthy clusters are safe to proceed** - Step.6_apply-updates.yml is per-cluster scoped.") - } else { - $lines.Add('> **All clear**: every cluster in scope is ready for update. Safe to proceed with Step.6_apply-updates.yml for this ring.') - } - $lines.Add('') - - # 3. Summary counts - $lines.Add('### Summary counts') - $lines.Add('') - $lines.Add('| Metric | Count |') - $lines.Add('|--------|-------|') - $lines.Add("| Total clusters in scope | $total |") - $lines.Add("| Ready for update | $readyForUpdate |") - $lines.Add("| Up to date | $upToDate |") - $lines.Add("| Not ready for update | $notReady |") - $lines.Add("| Clusters with Critical health failures | $clustersWithCritical |") - $lines.Add("| Total Critical findings | $critical |") - $lines.Add('') - - # 4. Not-Ready cluster table (blocking findings first) - $notReadyRows = @($readiness | Where-Object { $_.ReadyForUpdate -ne $true }) - if ($notReadyRows.Count -gt 0) { - $lines.Add('### Not-Ready clusters (review first)') - $lines.Add('') - $lines.Add('| Cluster | UpdateRing | Current version | Update state | Health | Blocking reasons |') - $lines.Add('|---------|------------|-----------------|--------------|--------|------------------|') - foreach ($r in ($notReadyRows | Sort-Object @{Expression={ if ($ringByResourceId.ContainsKey($_.ClusterResourceId)) { $ringByResourceId[$_.ClusterResourceId] } else { 'zzz' } }}, ClusterName)) { - $ring = if ($ringByResourceId.ContainsKey($r.ClusterResourceId)) { $ringByResourceId[$r.ClusterResourceId] } else { '-' } - $cv = if ($r.CurrentVersion) { $r.CurrentVersion } else { '-' } - $br = if ($r.BlockingReasons) { $r.BlockingReasons } else { '-' } - $lines.Add("| $($r.ClusterName) | $ring | $cv | $($r.UpdateState) | $($r.HealthState) | $br |") - } - $lines.Add('') - } - - # 5. Critical-health cluster table - $criticalRows = @($health | Where-Object { [int]$_.CriticalCount -gt 0 }) - if ($criticalRows.Count -gt 0) { - $lines.Add('### Critical-health clusters') - $lines.Add('') - $lines.Add('_Cross-link: see **Step.4_fleet-connectivity-status** for connectivity-class failures and **Step.9_fleet-health-status** for the broader Critical/Warning catalog._') - $lines.Add('') - $lines.Add('| Cluster | UpdateRing | Health state | Critical | Warning |') - $lines.Add('|---------|------------|--------------|----------|---------|') - foreach ($r in ($criticalRows | Sort-Object @{Expression={[int]$_.CriticalCount}; Descending=$true}, ClusterName)) { - $invMatch = $inventory | Where-Object { $_.ClusterName -eq $r.ClusterName } | Select-Object -First 1 - $ring = if ($invMatch -and $invMatch.UpdateRing) { $invMatch.UpdateRing } else { '-' } - $lines.Add("| $($r.ClusterName) | $ring | $($r.HealthState) | $($r.CriticalCount) | $($r.WarningCount) |") - } - $lines.Add('') - } - - # 6. Per-UpdateRing pivot (only shown when more than one ring in scope) - $ringGroups = $readiness | Group-Object @{Expression={ if ($ringByResourceId.ContainsKey($_.ClusterResourceId)) { $ringByResourceId[$_.ClusterResourceId] } else { '(no ring tag)' } }} | Sort-Object Name - if (@($ringGroups).Count -gt 1) { - $lines.Add('### Per UpdateRing breakdown') - $lines.Add('') - $lines.Add('| UpdateRing | Total | Ready for Update | Up to Date | Not Ready for Update |') - $lines.Add('|------------|-------|------------------|------------|----------------------|') - foreach ($g in $ringGroups) { - $gReady = @($g.Group | Where-Object { $_.ReadyForUpdate -eq $true }).Count - $gUpToDate = @($g.Group | Where-Object { - $_.ReadyForUpdate -ne $true -and - $_.UpdateState -in @('UpToDate', 'AppliedSuccessfully') -and - [string]::IsNullOrEmpty([string]$_.AllAvailableUpdates) - }).Count - $gNotReady = $g.Count - $gReady - $gUpToDate - $lines.Add("| $($g.Name) | $($g.Count) | $gReady | $gUpToDate | $gNotReady |") - } - $lines.Add('') - } - - # 7. All-clusters detail table (kept for completeness; sorted by UpdateRing then ClusterName) - if ($total -gt 0) { - $lines.Add('### All clusters detail') - $lines.Add('') - $lines.Add('| Cluster | UpdateRing | Current version | Current SBE version | Update state | Health | Ready | Recommended update |') - $lines.Add('|---------|------------|-----------------|---------------------|--------------|--------|-------|--------------------|') - foreach ($r in ($readiness | Sort-Object @{Expression={ if ($ringByResourceId.ContainsKey($_.ClusterResourceId)) { $ringByResourceId[$_.ClusterResourceId] } else { 'zzz' } }}, ClusterName)) { - $ring = if ($ringByResourceId.ContainsKey($r.ClusterResourceId)) { $ringByResourceId[$r.ClusterResourceId] } else { '-' } - $cv = if ($r.CurrentVersion) { $r.CurrentVersion } else { '-' } - $csv = if ($r.PSObject.Properties['CurrentSbeVersion'] -and $r.CurrentSbeVersion) { $r.CurrentSbeVersion } else { '-' } - $ru = if ($r.RecommendedUpdate) { $r.RecommendedUpdate } else { '-' } - $lines.Add("| $($r.ClusterName) | $ring | $cv | $csv | $($r.UpdateState) | $($r.HealthState) | $($r.ReadyForUpdate) | $ru |") - } - $lines.Add('') - } - - # 8. Cross-links to other pipelines - $lines.Add('### Cross-link to other pipelines') - $lines.Add('') - $lines.Add('- **Step.4_fleet-connectivity-status** - root-cause Disconnected / Offline / partial-connectivity findings on the Not-Ready and Critical-health rows above.') - $lines.Add('- **Step.6_apply-updates** - apply updates to the Ready clusters in this ring (manual workflow_dispatch, or wait for the scheduled cron firing).') - $lines.Add('- **Step.7_monitor-updates** - tail in-flight runs once Step.6 has started (auto-trigger on Step.6 completion, or manual).') - $lines.Add('- **Step.9_fleet-health-status** - broader Critical / Warning health catalog across the whole fleet (not just blocking-only).') - $lines.Add('') - $lines.Add('_Note: the **Update Readiness Assessment** entry in the Checks tab is the merged combined view; the [JUnit Debug] entries are diagnostic mirrors for CI/test tooling._') - - ($lines -join [Environment]::NewLine) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + if ($env:INPUT_UPDATE_RING) { $params['UpdateRing'] = $env:INPUT_UPDATE_RING } + Export-AzLocalClusterUpdateReadinessReport @params - name: Compute Artifact Timestamp if: always() @@ -484,4 +225,3 @@ jobs: list-suites: failed list-tests: failed continue-on-error: true - 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 06ef6006..090fb9a1 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 @@ -53,9 +53,27 @@ on: update_ring: # accepts a single ring (Wave1), a semicolon-delimited list (Prod;Ring2), # or '***' (three stars - deliberate, not a typo) to match every cluster that HAS the UpdateRing tag set. - description: "UpdateRing tag value (e.g. 'Wave1', 'Prod;Ring2', or '***' for ALL clusters)." - required: true + # v0.8.5: no longer required - leave empty AND set use_schedule_file=true to resolve from apply-updates-schedule.yml instead. + description: "UpdateRing tag value (e.g. 'Wave1', 'Prod;Ring2', or '***' for ALL clusters). Ignored when use_schedule_file=true." + required: false default: 'Wave1' + # v0.8.5 - manual override that runs the ring/AllowedUpdateVersions resolver + # against apply-updates-schedule.yml exactly as a scheduled firing would. + # Use this to (a) test a schedule change before the next cron tick, (b) re-run + # a missed scheduled day, or (c) preview a future cycleWeek/dayOfWeek via the + # resolve_for_date_utc input below. + use_schedule_file: + description: 'Resolve UpdateRing & AllowedUpdateVersions from apply-updates-schedule.yml (as a scheduled run would). Overrides update_ring.' + required: false + default: 'false' + type: choice + options: + - 'false' + - 'true' + resolve_for_date_utc: + description: "Only used when use_schedule_file=true. Date (UTC, YYYY-MM-DD) to resolve the schedule for. Empty = today UTC. Useful for previewing a future cycleWeek/dayOfWeek." + required: false + default: '' update_name: description: 'Specific update version to apply (leave empty for latest ready update)' required: false @@ -112,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.4' + GENERATED_AGAINST_MODULE_VERSION: '0.8.5' # 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). @@ -170,11 +188,11 @@ jobs: # apply step passes this via -AllowedUpdateVersions; when empty, the # cmdlet falls back to its default "install latest Ready update". resolved_allowed_update_versions: ${{ steps.resolve-ring.outputs.RESOLVED_ALLOWED_UPDATE_VERSIONS }} - + steps: - name: Checkout repository uses: actions/checkout@v5 - + # OPTION 1: OpenID Connect (OIDC) - RECOMMENDED # No client secret required - uses federated credentials - name: Azure CLI Login (OIDC) @@ -191,215 +209,56 @@ jobs: # the per-row `subscriptionId` returned by ARG). # Set it via: gh variable set AZURE_SUBSCRIPTION_ID --body subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} - + # OPTION 2: Client Secret - LEGACY (uncomment if OIDC not available) # - name: Azure CLI Login (Client Secret) # uses: azure/login@v3 # with: # creds: '{"clientId":"${{ secrets.AZURE_CLIENT_ID }}","clientSecret":"${{ secrets.AZURE_CLIENT_SECRET }}","subscriptionId":"${{ vars.AZURE_SUBSCRIPTION_ID }}","tenantId":"${{ vars.AZURE_TENANT_ID }}"}' - + - name: Install Azure CLI Resource Graph Extension shell: pwsh run: | az extension add --name resource-graph --yes - + + # v0.8.5 thin-YAML - install + drift detection + version banner via shared helper. - name: Install AzLocal.UpdateManagement from PSGallery shell: pwsh + id: module-version run: | $ErrorActionPreference = 'Stop' $installArgs = @{ Name = 'AzLocal.UpdateManagement'; Scope = 'CurrentUser'; Force = $true; AllowClobber = $true } - if ($env:REQUIRED_MODULE_VERSION) { - $installArgs.RequiredVersion = $env:REQUIRED_MODULE_VERSION - Write-Host "REQUIRED_MODULE_VERSION is set - pinning install to v$($env:REQUIRED_MODULE_VERSION)." - } else { - Write-Host "REQUIRED_MODULE_VERSION is empty - installing the latest version from PSGallery (default fix-forward behaviour)." - } + if ($env:REQUIRED_MODULE_VERSION) { $installArgs.RequiredVersion = $env:REQUIRED_MODULE_VERSION } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force - # Get-Module can return multiple entries when nested modules are loaded - pick the highest version. - $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on runner : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - Write-Host "" - Get-Module AzLocal.UpdateManagement | Format-List Name, Version, Path - # Drift signal 0: the module installed at runtime is OLDER than the version this YAML was generated against. Cmdlets, parameters, or output schemas referenced by this YAML may not exist in the installed module - emitted as a warning because this is the most likely cause of a hard runtime failure later in the job. - if ($installed -lt $generated) { - $msg = "AzLocal.UpdateManagement v$installed is OLDER than the version this workflow YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Update'." - Write-Host "::warning title=AzLocal.UpdateManagement is older than workflow YAML expects::$msg" - } - # Drift signal 1: the YAML in this repo is older than the module that was installed. - if ($installed -gt $generated) { - $msg = "Workflow YAML was generated against AzLocal.UpdateManagement v$generated but the runner installed v$installed. Pipeline steps may have been improved in later releases - to refresh, re-run 'Copy-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Update' (you will be prompted per file; add -Confirm:`$false to bypass). Pipeline YAMLs are under git so 'git diff' shows exactly what changed before commit." - Write-Host "::notice title=Workflow YAML may be stale::$msg" - } - # Drift signal 2: the user pinned (or PSGallery shipped a release mid-run) and the install is behind the latest. - if ($latest -and ($latest -gt $installed)) { - $msg = "AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed. Review the module CHANGELOG before bumping REQUIRED_MODULE_VERSION (or clear the pin to install the latest automatically)." - Write-Host "::notice title=Newer AzLocal.UpdateManagement version available on PSGallery::$msg" - } + Add-AzLocalPipelineVersionBanner -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION - # v0.8.4 - one-line version banner emitted to the rendered Step - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), the - # module version actually loaded on the runner, the PSGallery latest, - # and the REQUIRED_MODULE_VERSION pin status are visible at the top - # of every run summary without scrolling the agent log. Same data is - # also echoed to the console above as 'Module version summary'. - # Emitted from the check-readiness install step only (the apply-updates - # install step in the same workflow deliberately skips this to avoid - # duplicating the banner in the rendered summary). - $pin = if ($env:REQUIRED_MODULE_VERSION) { "pinned to v$($env:REQUIRED_MODULE_VERSION)" } else { 'latest (fix-forward)' } - $latestStr = if ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } - $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } - elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } - elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } - else { 'in sync' } - if ($env:GITHUB_STEP_SUMMARY) { - "_Pipeline YAML v$generated | Module v$installed installed ($pin) | PSGallery latest $latestStr | $verdict_" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - } - - # Resolve the UpdateRing for THIS firing. - # - workflow_dispatch -> use the operator's manual input verbatim (back-compat). - # - schedule (cron) -> read apply-updates-schedule.yml and run the resolver. - # The resolved value is exported as both an env var (for later steps in this - # job) and a step output (for cross-job consumption by apply-updates). + # v0.8.5 thin-YAML - Resolve UpdateRing (+ AllowedUpdateVersions) from + # schedule file (or pass manual input through verbatim). - name: Resolve UpdateRing from schedule id: resolve-ring shell: pwsh env: INPUT_UPDATE_RING: ${{ github.event.inputs.update_ring }} - TRIGGER_EVENT: ${{ github.event_name }} + USE_SCHEDULE_FILE: ${{ github.event.inputs.use_schedule_file }} + RESOLVE_FOR_DATE_UTC: ${{ github.event.inputs.resolve_for_date_utc }} run: | Import-Module AzLocal.UpdateManagement -Force - $resolved = '' - $resolvedAllow = '' - if ($env:TRIGGER_EVENT -eq 'workflow_dispatch') { - $resolved = $env:INPUT_UPDATE_RING - Write-Host "Trigger='workflow_dispatch' - using manual input UpdateRing='$resolved' (schedule file ignored). AllowedUpdateVersions is not applied for manual runs - the cmdlet's default 'latest Ready update' (or -update_name input) is used." - } - else { - $schedulePath = $env:APPLY_UPDATES_SCHEDULE_PATH - if (-not $schedulePath) { $schedulePath = './.github/apply-updates-schedule.yml' } - Write-Host "Trigger='$($env:TRIGGER_EVENT)' - resolving UpdateRing from schedule file '$schedulePath' (UTC now)." - if (-not (Test-Path -LiteralPath $schedulePath)) { - throw "apply-updates-schedule.yml not found at '$schedulePath'. The apply-updates pipeline was triggered by a schedule event but no schedule file is present. Generate a STRAWMAN from your fleet via: New-AzLocalApplyUpdatesScheduleConfig -OutputPath '$schedulePath' (then review and UNCOMMENT at least one row before re-running). Set repository variable APPLY_UPDATES_SCHEDULE_PATH to point elsewhere if you keep the schedule outside .github/." - } - $cfg = Get-AzLocalApplyUpdatesScheduleConfig -Path $schedulePath - $decision = Resolve-AzLocalCurrentUpdateRing -Schedule $cfg - Write-Host "Resolver decision: $($decision.Reason)" - if ($decision.Rings -and $decision.Rings.Count -gt 0) { - $resolved = $decision.UpdateRingValue - Write-Host "Resolved UpdateRing='$resolved' (cycleWeek=$($decision.CycleWeek), dayOfWeek=$($decision.DayOfWeekName))." - if ($decision.AllowedUpdateVersions -and $decision.AllowedUpdateVersions.Count -gt 0) { - $resolvedAllow = $decision.AllowedUpdateVersionsValue - Write-Host "Resolved AllowedUpdateVersions ($($decision.AllowedUpdateVersionsSource)): $resolvedAllow" - } - else { - Write-Host "Resolved AllowedUpdateVersions: - apply-updates will install the latest Ready update on each cluster." - } - } - else { - Write-Host "::notice title=No UpdateRing scheduled for today::$($decision.Reason)" - Write-Host "Setting resolved UpdateRing to empty - check-readiness will report ready_count=0 and apply-updates will be skipped cleanly." - $resolved = '' - } - } - # Bridge: env var for later steps in this job + step output for cross-job consumption. - "RESOLVED_UPDATE_RING=$resolved" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "RESOLVED_UPDATE_RING=$resolved" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "RESOLVED_ALLOWED_UPDATE_VERSIONS=$resolvedAllow" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "RESOLVED_ALLOWED_UPDATE_VERSIONS=$resolvedAllow" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - + $params = @{ ManualUpdateRing = $env:INPUT_UPDATE_RING; ResolveForDateUtc = $env:RESOLVE_FOR_DATE_UTC } + if ($env:USE_SCHEDULE_FILE -eq 'true') { $params['UseScheduleFile'] = $true } + Resolve-AzLocalPipelineUpdateRing @params + + # v0.8.5 thin-YAML - readiness gate query + step outputs + per-cluster + # markdown summary. - name: Check Cluster Readiness shell: pwsh id: readiness env: - INPUT_UPDATE_RING: ${{ env.RESOLVED_UPDATE_RING }} + INPUT_UPDATE_RING: ${{ steps.resolve-ring.outputs.RESOLVED_UPDATE_RING }} run: | Import-Module AzLocal.UpdateManagement -Force - - $updateRing = $env:INPUT_UPDATE_RING - - # empty UpdateRing means the schedule resolver found no row - # matching the current UTC day. Report zero clusters and exit 0 - the - # downstream apply-updates job is gated on ready_count > 0 and will - # skip cleanly. - if ([string]::IsNullOrWhiteSpace($updateRing)) { - Write-Host "No UpdateRing scheduled for this firing - skipping readiness check." - New-Item -ItemType Directory -Path "./artifacts" -Force | Out-Null - echo "READY_COUNT=0" >> $env:GITHUB_OUTPUT - echo "TOTAL_COUNT=0" >> $env:GITHUB_OUTPUT - exit 0 - } - - Write-Host "Checking readiness for clusters with UpdateRing = '$updateRing'" - - # Create output directory - $outputDir = "./artifacts" - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - - $csvPath = Join-Path $outputDir "readiness-report.csv" - - # Run readiness check - $results = Get-AzLocalClusterUpdateReadiness ` - -ScopeByUpdateRingTag ` - -UpdateRingValue $updateRing ` - -ExportPath $csvPath ` - -PassThru - - $totalCount = $results.Count - $readyCount = ($results | Where-Object { $_.ReadyForUpdate -eq $true }).Count - - Write-Host "" - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "Readiness Summary" -ForegroundColor Cyan - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "Total Clusters: $totalCount" - Write-Host "Ready for Update: $readyCount" - Write-Host "Not Ready: $($totalCount - $readyCount)" - - echo "READY_COUNT=$readyCount" >> $env:GITHUB_OUTPUT - echo "TOTAL_COUNT=$totalCount" >> $env:GITHUB_OUTPUT - - # v0.8.4 - Enhancement D: per-cluster readiness markdown table. - # Surfaces *which* clusters were assessed, their state, and why each - # cluster was/wasn't handed to apply-updates - directly in the Step - # Summary so operators don't have to dig into raw agent logs. - if ($env:GITHUB_STEP_SUMMARY) { - $notReadyCount = $totalCount - $readyCount - "## Cluster Readiness ($updateRing)" >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - "**Total:** $totalCount  |  **Ready:** $readyCount  |  **Not Ready:** $notReadyCount" >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - "| Cluster | Current Version | Update State | Health | Ready? | Recommended Update | Blocking Reasons |" >> $env:GITHUB_STEP_SUMMARY - "|---|---|---|---|---|---|---|" >> $env:GITHUB_STEP_SUMMARY - $rowCap = 100 - $rendered = 0 - foreach ($r in ($results | Sort-Object @{Expression={[bool]$_.ReadyForUpdate}; Descending=$true}, ClusterName)) { - if ($rendered -ge $rowCap) { break } - $readyIcon = if ($r.ReadyForUpdate -eq $true) { '✅' } else { '⛔' } - $healthVal = "$($r.HealthState)" - $healthCell = switch -Regex ($healthVal) { '^Success$' { "✅ $healthVal" } '^Warning$' { "⚠️ $healthVal" } '^Failure$' { "❌ $healthVal" } default { $healthVal } } - $blocking = "$($r.BlockingReasons)" - if ($blocking.Length -gt 200) { $blocking = $blocking.Substring(0, 197) + '...' } - $blocking = $blocking -replace '\|','\|' -replace '\r?\n',' ' - $reco = if ($r.RecommendedUpdate) { '`' + $r.RecommendedUpdate + '`' } else { '-' } - $curr = if ($r.CurrentVersion) { '`' + $r.CurrentVersion + '`' } else { '-' } - "| ``$($r.ClusterName)`` | $curr | $($r.UpdateState) | $healthCell | $readyIcon | $reco | $blocking |" >> $env:GITHUB_STEP_SUMMARY - $rendered++ - } - if ($totalCount -gt $rowCap) { - "" >> $env:GITHUB_STEP_SUMMARY - "_Showing first $rowCap of $totalCount clusters. Download the readiness-report.csv artifact for the full list._" >> $env:GITHUB_STEP_SUMMARY - } - "" >> $env:GITHUB_STEP_SUMMARY - } - + Export-AzLocalClusterReadinessGateReport -UpdateRing $env:INPUT_UPDATE_RING -OutputDirectory './artifacts' + - name: Compute Artifact Timestamp if: always() id: artifact-stamp @@ -430,11 +289,11 @@ jobs: needs: check-readiness # Only run if there are clusters ready for update if: ${{ needs.check-readiness.outputs.ready_count > 0 }} - + steps: - name: Checkout repository uses: actions/checkout@v5 - + # OPTION 1: OpenID Connect (OIDC) - RECOMMENDED - name: Azure CLI Login (OIDC) uses: azure/login@v3 @@ -450,35 +309,33 @@ jobs: # the per-row `subscriptionId` returned by ARG). # Set it via: gh variable set AZURE_SUBSCRIPTION_ID --body subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} - + # OPTION 2: Client Secret - LEGACY (uncomment if OIDC not available) # - name: Azure CLI Login (Client Secret) # uses: azure/login@v3 # with: # creds: '{"clientId":"${{ secrets.AZURE_CLIENT_ID }}","clientSecret":"${{ secrets.AZURE_CLIENT_SECRET }}","subscriptionId":"${{ vars.AZURE_SUBSCRIPTION_ID }}","tenantId":"${{ vars.AZURE_TENANT_ID }}"}' - + - name: Install Azure CLI Resource Graph Extension shell: pwsh run: | az extension add --name resource-graph --yes - + + # v0.8.5 thin-YAML - apply-updates job re-installs the module (each runs-on + # is a fresh runner). Version-banner upload is deliberately skipped on this + # second install (already emitted by check-readiness) to avoid duplicating + # the banner in the rendered run summary. - name: Install AzLocal.UpdateManagement from PSGallery shell: pwsh run: | $ErrorActionPreference = 'Stop' $installArgs = @{ Name = 'AzLocal.UpdateManagement'; Scope = 'CurrentUser'; Force = $true; AllowClobber = $true } - if ($env:REQUIRED_MODULE_VERSION) { - $installArgs.RequiredVersion = $env:REQUIRED_MODULE_VERSION - Write-Host "REQUIRED_MODULE_VERSION is set - pinning install to v$($env:REQUIRED_MODULE_VERSION)." - } else { - Write-Host "REQUIRED_MODULE_VERSION is empty - installing the latest version from PSGallery (default fix-forward behaviour)." - } + if ($env:REQUIRED_MODULE_VERSION) { $installArgs.RequiredVersion = $env:REQUIRED_MODULE_VERSION } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version Write-Host "Installed AzLocal.UpdateManagement v$installed on apply-updates job runner." - Get-Module AzLocal.UpdateManagement | Format-List Name, Version, Path - + # Consume the readiness CSV produced by check-readiness instead of # re-discovering clusters by tag. Apply Updates now operates ONLY on the # clusters that the readiness gate marked ReadyForUpdate=True (gated by @@ -494,7 +351,9 @@ jobs: # don't collide. name: ${{ needs.check-readiness.outputs.readiness_artifact }} path: ./artifacts - + + # v0.8.5 thin-YAML - readiness-gated apply: validates CSV, fans out to + # Start-AzLocalClusterUpdate, emits 7 step outputs, writes apply-results.json. - name: Apply Updates shell: pwsh id: apply-updates @@ -513,115 +372,16 @@ jobs: INPUT_ALLOWED_UPDATE_VERSIONS: ${{ needs.check-readiness.outputs.resolved_allowed_update_versions }} run: | Import-Module AzLocal.UpdateManagement -Force - - $updateRing = $env:INPUT_UPDATE_RING - $updateName = $env:INPUT_UPDATE_NAME - $dryRun = $env:INPUT_DRY_RUN -eq "true" - $allowedUpdateVersions = @() - if (-not [string]::IsNullOrWhiteSpace($env:INPUT_ALLOWED_UPDATE_VERSIONS)) { - $allowedUpdateVersions = @($env:INPUT_ALLOWED_UPDATE_VERSIONS -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) - } - - # Create output directory - $outputDir = "./artifacts" - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - - $timestamp = Get-Date -Format "yyyyMMdd_HHmmss" - $junitPath = Join-Path $outputDir "update-results.xml" - - # Read the readiness CSV from the check-readiness job and extract - # the ARM resource IDs of clusters marked ReadyForUpdate=True. The - # ClusterResourceId column was added to Get-AzLocalClusterUpdateReadiness - # output in specifically to make this hand-off explicit and - # exact (no second tag query, no drift between the two jobs' scopes). - $readinessCsv = Join-Path $outputDir 'readiness-report.csv' - if (-not (Test-Path $readinessCsv)) { - throw "Readiness CSV not found at '$readinessCsv'. The check-readiness job did not upload a readiness-report artifact - cannot determine which clusters to apply." - } - $readinessRows = @(Import-Csv -Path $readinessCsv) - if ($readinessRows.Count -gt 0 -and -not ($readinessRows[0].PSObject.Properties.Name -contains 'ClusterResourceId')) { - throw "Readiness CSV at '$readinessCsv' is missing the 'ClusterResourceId' column. This column was added in AzLocal.UpdateManagement v0.7.62. Re-run check-readiness with v0.7.62+ or refresh this workflow YAML via Copy-AzLocalPipelineExample -Update." - } - # CSV booleans render as the strings 'True'/'False'. - $readyResourceIds = @($readinessRows | - Where-Object { $_.ReadyForUpdate -eq 'True' -and $_.ClusterResourceId } | - ForEach-Object { $_.ClusterResourceId }) - Write-Host "Readiness CSV: $($readinessRows.Count) row(s), $($readyResourceIds.Count) marked ReadyForUpdate=True." - if ($readyResourceIds.Count -eq 0) { - Write-Host "::warning::Readiness CSV reports zero clusters with ReadyForUpdate=True - nothing to apply." - echo "SUCCEEDED=0" >> $env:GITHUB_OUTPUT - echo "SKIPPED=0" >> $env:GITHUB_OUTPUT - echo "FAILED=0" >> $env:GITHUB_OUTPUT - echo "HEALTH_BLOCKED=0" >> $env:GITHUB_OUTPUT - echo "SCHEDULE_BLOCKED=0" >> $env:GITHUB_OUTPUT - echo "SIDELOADED_BLOCKED=0" >> $env:GITHUB_OUTPUT - echo "EXCLUDED_BY_TAG=0" >> $env:GITHUB_OUTPUT - exit 0 - } - - # Build parameters - use -ClusterResourceIds (the readiness gate's - # decision) rather than -ScopeByUpdateRingTag (which would re-discover - # the full ring and force apply to re-evaluate already-blocked clusters). $params = @{ - ClusterResourceIds = $readyResourceIds - Force = $true - LogFolderPath = $outputDir - ExportResultsPath = $junitPath - } - - if ($updateName -and $updateName -ne '') { - $params['UpdateName'] = $updateName - Write-Host "Applying specific update: $updateName" + ReadinessCsvPath = './artifacts/readiness-report.csv' + UpdateRing = $env:INPUT_UPDATE_RING + UpdateName = $env:INPUT_UPDATE_NAME + AllowedUpdateVersions = $env:INPUT_ALLOWED_UPDATE_VERSIONS + OutputDirectory = './artifacts' } + if ($env:INPUT_DRY_RUN -eq 'true') { $params['DryRun'] = $true } + Invoke-AzLocalReadinessGatedClusterUpdate @params - if ($allowedUpdateVersions.Count -gt 0) { - $params['AllowedUpdateVersions'] = $allowedUpdateVersions - Write-Host "AllowedUpdateVersions allow-list (schema v2): [$($allowedUpdateVersions -join ', ')]. Clusters with no Ready update matching the list will be skipped with status 'NotInAllowList'." - } - - if ($dryRun) { - $params['WhatIf'] = $true - Write-Host "DRY RUN MODE - No updates will be applied" - } - - Write-Host "" - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "Applying Updates to UpdateRing: $updateRing" -ForegroundColor Cyan - Write-Host " Clusters (from readiness CSV): $($readyResourceIds.Count)" -ForegroundColor Cyan - Write-Host "========================================" -ForegroundColor Cyan - - # Run updates - $results = Start-AzLocalClusterUpdate @params -PassThru - - Write-Host "" - Write-Host "Update operation complete" - - # Count results - $succeeded = ($results | Where-Object { $_.Status -eq 'Started' -or $_.Status -eq 'Success' -or $_.Status -eq 'UpdateStarted' }).Count - $skipped = ($results | Where-Object { $_.Status -in @('Skipped', 'NotReady', 'NoUpdatesAvailable', 'NoReadyUpdates', 'NotFound', 'UpdateNotFound', 'NotInAllowList') }).Count - $failed = ($results | Where-Object { $_.Status -in @('Failed', 'Error') }).Count - $healthBlocked = ($results | Where-Object { $_.Status -eq 'HealthCheckBlocked' }).Count - $scheduleBlocked = ($results | Where-Object { $_.Status -eq 'ScheduleBlocked' }).Count - $sideloadedBlocked = ($results | Where-Object { $_.Status -eq 'SideloadedBlocked' }).Count - $excludedByTag = ($results | Where-Object { $_.Status -eq 'ExcludedByTag' }).Count - - echo "SUCCEEDED=$succeeded" >> $env:GITHUB_OUTPUT - echo "SKIPPED=$skipped" >> $env:GITHUB_OUTPUT - echo "FAILED=$failed" >> $env:GITHUB_OUTPUT - echo "HEALTH_BLOCKED=$healthBlocked" >> $env:GITHUB_OUTPUT - echo "SCHEDULE_BLOCKED=$scheduleBlocked" >> $env:GITHUB_OUTPUT - echo "SIDELOADED_BLOCKED=$sideloadedBlocked" >> $env:GITHUB_OUTPUT - echo "EXCLUDED_BY_TAG=$excludedByTag" >> $env:GITHUB_OUTPUT - - # v0.8.4 - Enhancement D: persist per-cluster apply results to JSON so - # the downstream Summary step can render a markdown table without - # depending on the (truncated, JSON-unfriendly) step output stream. - $applyJsonPath = Join-Path $outputDir 'apply-results.json' - @($results) | Select-Object ClusterName, Status, UpdateName, Duration, Message | - ConvertTo-Json -Depth 4 | - Out-File -FilePath $applyJsonPath -Encoding utf8 -Force - Write-Host "Wrote per-cluster apply results to $applyJsonPath" - - name: Compute Artifact Timestamp if: always() id: artifact-stamp @@ -639,7 +399,7 @@ jobs: name: azlocal-step.6-apply-updates-logs_${{ steps.artifact-stamp.outputs.timestamp }} path: ./artifacts/* retention-days: 30 - + - name: Publish JUnit Diagnostic Results uses: dorny/test-reporter@v3 if: always() @@ -650,7 +410,7 @@ jobs: list-suites: failed list-tests: failed continue-on-error: true - + # ---------------------------------------------------------------------- # ITSM Connector (ServiceNow) # @@ -671,6 +431,8 @@ jobs: Install-Module powershell-yaml -Scope CurrentUser -Force -AllowClobber } + # v0.8.5 thin-YAML - ITSM ticketing via Invoke-AzLocalItsmTicketingFromArtifact. + # Wrapper auto-populates RunMetadata from GitHub env vars. - name: Raise ITSM tickets if: ${{ github.event.inputs.raise_itsm_ticket == 'true' }} shell: pwsh @@ -691,41 +453,14 @@ jobs: INPUT_ITSM_FORCE_CREATE: ${{ github.event.inputs.itsm_force_create }} run: | Import-Module AzLocal.UpdateManagement -Force - - $configPath = $env:INPUT_ITSM_CONFIG_PATH - $dryRun = $env:INPUT_ITSM_DRY_RUN -eq 'true' - $force = $env:INPUT_ITSM_FORCE_CREATE -eq 'true' - - if (-not (Test-Path $configPath)) { - Write-Host "::warning::ITSM config not found at '$configPath' - skipping ticket creation." - exit 0 - } - - $cfg = Get-AzLocalItsmConfig -Path $configPath - - $junitInput = './artifacts/update-results.xml' - if (-not (Test-Path $junitInput)) { - Write-Host "::warning::No update-results.xml found at '$junitInput' - skipping ticket creation." - exit 0 - } - $params = @{ - InputArtifactPath = $junitInput - Config = $cfg - RunMetadata = @{ - Platform = 'github' - RunId = $env:GITHUB_RUN_ID - RunUrl = "$env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY/actions/runs/$env:GITHUB_RUN_ID" - Branch = $env:GITHUB_REF - } - DryRun = $dryRun - ForceCreate = $force - ExportPath = './artifacts/itsm-results.csv' - ExportJUnitPath = './artifacts/itsm-results.xml' + ConfigPath = $env:INPUT_ITSM_CONFIG_PATH + InputArtifactPath = './artifacts/update-results.xml' + ExportDirectory = './artifacts' } - - $results = New-AzLocalIncident @params - $results | Format-Table ClusterName, Action, TicketId, Severity -AutoSize + if ($env:INPUT_ITSM_DRY_RUN -eq 'true') { $params['DryRun'] = $true } + if ($env:INPUT_ITSM_FORCE_CREATE -eq 'true') { $params['ForceCreate'] = $true } + Invoke-AzLocalItsmTicketingFromArtifact @params - name: Upload ITSM Artefacts if: ${{ github.event.inputs.raise_itsm_ticket == 'true' }} @@ -745,135 +480,30 @@ jobs: reporter: java-junit continue-on-error: true + # v0.8.5 thin-YAML - Update Application Summary via shared helper. - name: Summary shell: pwsh env: INPUT_UPDATE_RING: ${{ needs.check-readiness.outputs.resolved_update_ring }} INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }} run: | - $updateRing = $env:INPUT_UPDATE_RING - $dryRun = $env:INPUT_DRY_RUN -eq "true" - $succeeded = "${{ steps.apply-updates.outputs.SUCCEEDED }}" - $skipped = "${{ steps.apply-updates.outputs.SKIPPED }}" - $failed = "${{ steps.apply-updates.outputs.FAILED }}" - $healthBlocked = "${{ steps.apply-updates.outputs.HEALTH_BLOCKED }}" - $scheduleBlocked = "${{ steps.apply-updates.outputs.SCHEDULE_BLOCKED }}" - $sideloadedBlocked = "${{ steps.apply-updates.outputs.SIDELOADED_BLOCKED }}" - $excludedByTag = "${{ steps.apply-updates.outputs.EXCLUDED_BY_TAG }}" - $readyCount = "${{ needs.check-readiness.outputs.ready_count }}" - $totalCount = "${{ needs.check-readiness.outputs.total_count }}" - - "## Update Application Summary" >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - "**Target UpdateRing:** $updateRing" >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - - if ($dryRun) { - " **This was a dry run. No updates were applied.**" >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - } - - "### Readiness" >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - "| Metric | Count |" >> $env:GITHUB_STEP_SUMMARY - "|--------|-------|" >> $env:GITHUB_STEP_SUMMARY - "| Total Clusters | $totalCount |" >> $env:GITHUB_STEP_SUMMARY - "| Ready for Update | $readyCount |" >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - - "### Results" >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - "| Status | Count |" >> $env:GITHUB_STEP_SUMMARY - "|--------|-------|" >> $env:GITHUB_STEP_SUMMARY - "| Updates Started | $succeeded |" >> $env:GITHUB_STEP_SUMMARY - "| Skipped | $skipped |" >> $env:GITHUB_STEP_SUMMARY - "| Failed | $failed |" >> $env:GITHUB_STEP_SUMMARY - "| Health Blocked | $healthBlocked |" >> $env:GITHUB_STEP_SUMMARY - "| Schedule Blocked | $scheduleBlocked |" >> $env:GITHUB_STEP_SUMMARY - "| Sideloaded Blocked | $sideloadedBlocked |" >> $env:GITHUB_STEP_SUMMARY - "| Excluded By Tag | $excludedByTag |" >> $env:GITHUB_STEP_SUMMARY - - # v0.8.4 - Enhancement D: per-cluster action table from apply-results.json - # (written by the apply-updates step) - shows operators exactly what - # happened to each cluster handed to apply, without log-diving. - $applyJson = './artifacts/apply-results.json' - if (Test-Path $applyJson) { - $applyRows = @(Get-Content -Raw -Path $applyJson | ConvertFrom-Json) - if ($applyRows.Count -gt 0) { - "" >> $env:GITHUB_STEP_SUMMARY - "### Cluster Actions ($($applyRows.Count) cluster(s))" >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - "| Cluster | Status | Update | Duration | Message |" >> $env:GITHUB_STEP_SUMMARY - "|---|---|---|---|---|" >> $env:GITHUB_STEP_SUMMARY - $startedStates = @('UpdateStarted','Started','Success') - $blockedStates = @('HealthCheckBlocked','ScheduleBlocked','SideloadedBlocked','ExcludedByTag','NotConnected') - $failedStates = @('Failed','Error','NotFound') - foreach ($r in ($applyRows | Sort-Object Status, ClusterName)) { - $st = "$($r.Status)" - $icon = if ($startedStates -contains $st) { '✅' } elseif ($failedStates -contains $st) { '❌' } elseif ($blockedStates -contains $st) { '⛔' } else { '⏭️' } - $upd = if ($r.UpdateName) { '`' + $r.UpdateName + '`' } else { '-' } - $dur = if ($r.Duration) { "$($r.Duration)" } else { '-' } - $msg = "$($r.Message)" - if ($msg.Length -gt 180) { $msg = $msg.Substring(0, 177) + '...' } - $msg = $msg -replace '\|','\|' -replace '\r?\n',' ' - "| ``$($r.ClusterName)`` | $icon $st | $upd | $dur | $msg |" >> $env:GITHUB_STEP_SUMMARY - } - "" >> $env:GITHUB_STEP_SUMMARY - } - } - - # v0.8.4 - Enhancement D: per-cluster table for clusters NOT handed to - # apply (filtered out by the readiness gate). Reads readiness-report.csv - # written by the check-readiness job. - $readinessCsvPath = './artifacts/readiness-report.csv' - if (Test-Path $readinessCsvPath) { - $blockedRows = @(Import-Csv -Path $readinessCsvPath | Where-Object { $_.ReadyForUpdate -ne 'True' }) - if ($blockedRows.Count -gt 0) { - "" >> $env:GITHUB_STEP_SUMMARY - "### Clusters Skipped at Readiness Gate ($($blockedRows.Count) cluster(s))" >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - "_These clusters were assessed by Check Update Readiness but not handed to Apply Updates. Resolve the listed blocking reasons (or wait for them to clear) then re-run the workflow._" >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - "| Cluster | Update State | Health | Current Version | Recommended | Blocking Reasons |" >> $env:GITHUB_STEP_SUMMARY - "|---|---|---|---|---|---|" >> $env:GITHUB_STEP_SUMMARY - $skipCap = 100 - $skipShown = 0 - foreach ($b in ($blockedRows | Sort-Object UpdateState, ClusterName)) { - if ($skipShown -ge $skipCap) { break } - $blocking = "$($b.BlockingReasons)" - if ($blocking.Length -gt 200) { $blocking = $blocking.Substring(0, 197) + '...' } - $blocking = $blocking -replace '\|','\|' -replace '\r?\n',' ' - $reco = if ($b.RecommendedUpdate) { '`' + $b.RecommendedUpdate + '`' } else { '-' } - $curr = if ($b.CurrentVersion) { '`' + $b.CurrentVersion + '`' } else { '-' } - $hSt = "$($b.HealthState)" - $hCell = switch -Regex ($hSt) { '^Success$' { "✅ $hSt" } '^Warning$' { "⚠️ $hSt" } '^Failure$' { "❌ $hSt" } default { $hSt } } - "| ``$($b.ClusterName)`` | $($b.UpdateState) | $hCell | $curr | $reco | $blocking |" >> $env:GITHUB_STEP_SUMMARY - $skipShown++ - } - if ($blockedRows.Count -gt $skipCap) { - "" >> $env:GITHUB_STEP_SUMMARY - "_Showing first $skipCap of $($blockedRows.Count) skipped clusters. Download the readiness-report.csv artifact for the full list._" >> $env:GITHUB_STEP_SUMMARY - } - "" >> $env:GITHUB_STEP_SUMMARY - } - } - - # Actions Required section - summarize what needs manual attention - $actionsNeeded = @() - if ([int]$failed -gt 0) { $actionsNeeded += "- **$failed cluster(s) failed** - Review pipeline logs and cluster health in Azure Portal" } - if ([int]$healthBlocked -gt 0) { $actionsNeeded += "- **$healthBlocked cluster(s) blocked by health checks** - Resolve critical health failures before retrying" } - if ([int]$scheduleBlocked -gt 0) { $actionsNeeded += "- **$scheduleBlocked cluster(s) outside maintenance window** - Updates will proceed when the cluster's UpdateStartWindow schedule allows, or re-run during the maintenance window" } - if ([int]$sideloadedBlocked -gt 0) { $actionsNeeded += "- **$sideloadedBlocked cluster(s) blocked by UpdateSideloaded=False** - Operator must stage the sideloaded payload and flip the tag to True (or run Reset-AzLocalSideloadedTag) before updates can proceed" } - if ([int]$excludedByTag -gt 0) { $actionsNeeded += "- **$excludedByTag cluster(s) excluded by UpdateExcluded=True operator override** - Flip the cluster's UpdateExcluded tag to False (Azure portal or 'az tag update') once the cluster should rejoin automation" } - - if ($actionsNeeded.Count -gt 0) { - "" >> $env:GITHUB_STEP_SUMMARY - "### Actions Required" >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - foreach ($action in $actionsNeeded) { - $action >> $env:GITHUB_STEP_SUMMARY - } + Import-Module AzLocal.UpdateManagement -Force + $params = @{ + UpdateRing = $env:INPUT_UPDATE_RING + TotalCount = "${{ needs.check-readiness.outputs.total_count }}" + ReadyCount = "${{ needs.check-readiness.outputs.ready_count }}" + Succeeded = "${{ steps.apply-updates.outputs.SUCCEEDED }}" + Skipped = "${{ steps.apply-updates.outputs.SKIPPED }}" + Failed = "${{ steps.apply-updates.outputs.FAILED }}" + HealthBlocked = "${{ steps.apply-updates.outputs.HEALTH_BLOCKED }}" + ScheduleBlocked = "${{ steps.apply-updates.outputs.SCHEDULE_BLOCKED }}" + SideloadedBlocked = "${{ steps.apply-updates.outputs.SIDELOADED_BLOCKED }}" + ExcludedByTag = "${{ steps.apply-updates.outputs.EXCLUDED_BY_TAG }}" + ApplyResultsJsonPath = './artifacts/apply-results.json' + ReadinessCsvPath = './artifacts/readiness-report.csv' } + if ($env:INPUT_DRY_RUN -eq 'true') { $params['DryRun'] = $true } + Add-AzLocalApplyUpdatesStepSummary @params # Handle case when no clusters are ready no-clusters-ready: @@ -881,33 +511,24 @@ jobs: runs-on: windows-latest needs: check-readiness if: ${{ needs.check-readiness.outputs.ready_count == 0 }} - + steps: + # Module install is required so the no-clusters-ready summary helper is + # available on this job's runner. + - name: Install AzLocal.UpdateManagement from PSGallery + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $installArgs = @{ Name = 'AzLocal.UpdateManagement'; Scope = 'CurrentUser'; Force = $true; AllowClobber = $true } + if ($env:REQUIRED_MODULE_VERSION) { $installArgs.RequiredVersion = $env:REQUIRED_MODULE_VERSION } + Install-Module @installArgs + Import-Module AzLocal.UpdateManagement -Force + + # v0.8.5 thin-YAML - No-clusters-ready summary via shared helper. - name: Report No Ready Clusters shell: pwsh env: INPUT_UPDATE_RING: ${{ needs.check-readiness.outputs.resolved_update_ring }} run: | - $updateRing = $env:INPUT_UPDATE_RING - $totalCount = "${{ needs.check-readiness.outputs.total_count }}" - - "## No Clusters Ready for Update" >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - "**Target UpdateRing:** $updateRing" >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - - if ($totalCount -eq 0) { - "No clusters found with UpdateRing tag value '$updateRing'" >> $env:GITHUB_STEP_SUMMARY - } else { - "Found $totalCount cluster(s) with UpdateRing='$updateRing', but none are ready for updates." >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - "Possible reasons:" >> $env:GITHUB_STEP_SUMMARY - "- Clusters may already be up to date" >> $env:GITHUB_STEP_SUMMARY - "- Updates may be in progress" >> $env:GITHUB_STEP_SUMMARY - "- Clusters may have health check failures" >> $env:GITHUB_STEP_SUMMARY - "" >> $env:GITHUB_STEP_SUMMARY - "Download the readiness report artifact for details." >> $env:GITHUB_STEP_SUMMARY - } - - Write-Warning "No clusters ready for update in ring '$updateRing'" - + Import-Module AzLocal.UpdateManagement -Force + Add-AzLocalNoReadyClustersStepSummary -UpdateRing $env:INPUT_UPDATE_RING -TotalCount "${{ needs.check-readiness.outputs.total_count }}" 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 7a0e2995..04ce5719 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.4' + GENERATED_AGAINST_MODULE_VERSION: '0.8.5' # 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). @@ -109,12 +109,17 @@ jobs: contents: read checks: write outputs: - in_flight: ${{ steps.snapshot.outputs.IN_FLIGHT }} - long_running: ${{ steps.snapshot.outputs.LONG_RUNNING }} - long_running_step: ${{ steps.snapshot.outputs.LONG_RUNNING_STEP }} - step_errored: ${{ steps.snapshot.outputs.STEP_ERRORED }} - recent_failures: ${{ steps.snapshot.outputs.RECENT_FAILURES }} - unresolved_failures: ${{ steps.snapshot.outputs.UNRESOLVED_FAILURES }} + # v0.8.5 thin-YAML: keys align byte-for-byte with the lowercase output + # names emitted by Export-AzLocalUpdateRunMonitorReport (which uses + # Set-AzLocalPipelineOutput under the hood). ADO is case-sensitive on + # task.setvariable lookups; GH is case-insensitive but the lowercase + # form is the canonical one used by every v0.8.5+ Step.* cmdlet. + in_flight: ${{ steps.snapshot.outputs.in_flight }} + long_running: ${{ steps.snapshot.outputs.long_running }} + long_running_step: ${{ steps.snapshot.outputs.long_running_step }} + step_errored: ${{ steps.snapshot.outputs.step_errored }} + recent_failures: ${{ steps.snapshot.outputs.recent_failures }} + unresolved_failures: ${{ steps.snapshot.outputs.unresolved_failures }} steps: - name: Checkout repository @@ -140,7 +145,10 @@ jobs: run: az extension add --name resource-graph --yes - name: Install AzLocal.UpdateManagement from PSGallery + # v0.8.5 thin-YAML: drift detection + banner + step outputs are all + # produced by Add-AzLocalPipelineVersionBanner (Public cmdlet). shell: pwsh + id: module-version run: | $ErrorActionPreference = 'Stop' $installArgs = @{ Name = 'AzLocal.UpdateManagement'; Scope = 'CurrentUser'; Force = $true; AllowClobber = $true } @@ -152,47 +160,21 @@ jobs: } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force - $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on runner : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - Write-Host "" - Get-Module AzLocal.UpdateManagement | Format-List Name, Version, Path - if ($installed -lt $generated) { - $msg = "AzLocal.UpdateManagement v$installed is OLDER than the version this workflow YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Update'." - Write-Host "::warning title=AzLocal.UpdateManagement is older than workflow YAML expects::$msg" - } - if ($installed -gt $generated) { - $msg = "Workflow YAML was generated against AzLocal.UpdateManagement v$generated but the runner installed v$installed. Pipeline steps may have been improved in later releases - to refresh, re-run 'Copy-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Update' (you will be prompted per file; add -Confirm:`$false to bypass). Pipeline YAMLs are under git so 'git diff' shows exactly what changed before commit." - Write-Host "::notice title=Workflow YAML may be stale::$msg" - } - if ($latest -and ($latest -gt $installed)) { - $msg = "AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed. Review the module CHANGELOG before bumping REQUIRED_MODULE_VERSION (or clear the pin to install the latest automatically)." - Write-Host "::notice title=Newer AzLocal.UpdateManagement version available on PSGallery::$msg" - } - - # v0.8.4 - one-line version banner emitted to the rendered Step - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), the - # module version actually loaded on the runner, the PSGallery latest, - # and the REQUIRED_MODULE_VERSION pin status are visible at the top - # of every run summary without scrolling the agent log. Same data is - # also echoed to the console above as 'Module version summary'. - $pin = if ($env:REQUIRED_MODULE_VERSION) { "pinned to v$($env:REQUIRED_MODULE_VERSION)" } else { 'latest (fix-forward)' } - $latestStr = if ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } - $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } - elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } - elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } - else { 'in sync' } - if ($env:GITHUB_STEP_SUMMARY) { - "_Pipeline YAML v$generated | Module v$installed installed ($pin) | PSGallery latest $latestStr | $verdict_" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - } + Add-AzLocalPipelineVersionBanner ` + -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION ` + -PinnedVersion $env:REQUIRED_MODULE_VERSION - name: Snapshot in-flight update runs + # v0.8.5 thin-YAML: the inline run block (cluster scope query + # via Get-AzLocalUpdateRuns -Latest -PassThru, severity + # classification, per-cluster CSV, JUnit XML, the six step + # outputs, and the markdown step summary with status badge + + # in-flight + unresolved-failed tables) has been condensed into + # the Public cmdlet Export-AzLocalUpdateRunMonitorReport. The + # cmdlet writes ./reports/update-monitor.csv + .xml, emits the + # markdown summary via GITHUB_STEP_SUMMARY, and sets the six + # step outputs (in_flight, long_running, long_running_step, + # step_errored, recent_failures, unresolved_failures). id: snapshot shell: pwsh env: @@ -202,470 +184,22 @@ jobs: INPUT_STEP_THRESHOLD_HOURS: ${{ github.event.inputs.long_running_step_hours }} INPUT_RECENT_FAILURE_WINDOW_HOURS: ${{ github.event.inputs.recent_failure_window_hours }} INPUT_CRITICAL_ELAPSED_DAYS: ${{ github.event.inputs.critical_elapsed_days }} + INSTALLED_MODULE_VERSION: ${{ steps.module-version.outputs.installed_module_version }} run: | + $ErrorActionPreference = 'Stop' Import-Module AzLocal.UpdateManagement -Force - - $scope = if ($env:INPUT_SCOPE) { $env:INPUT_SCOPE } else { 'all' } - $updateRing = $env:INPUT_UPDATE_RING - - # Threshold parse with safe defaults (v0.7.96). - # Per-step elapsed is the PRIMARY stuck-run signal (2h default). Overall-elapsed is a - # belt-and-braces backstop (24h default, demoted from 6h in v0.7.96 - 6h was a false- - # positive magnet on legitimate large-node-count runs). - $stepThresholdHours = 2 - if ($env:INPUT_STEP_THRESHOLD_HOURS) { - [int]$parsedStep = 0 - if ([int]::TryParse($env:INPUT_STEP_THRESHOLD_HOURS, [ref]$parsedStep) -and $parsedStep -gt 0) { $stepThresholdHours = $parsedStep } - } - $thresholdHours = 24 - if ($env:INPUT_THRESHOLD_HOURS) { - [int]$parsed = 0 - if ([int]::TryParse($env:INPUT_THRESHOLD_HOURS, [ref]$parsed) -and $parsed -gt 0) { $thresholdHours = $parsed } - } - $failureWindowHours = 24 - if ($env:INPUT_RECENT_FAILURE_WINDOW_HOURS) { - [int]$parsedRf = 0 - if ([int]::TryParse($env:INPUT_RECENT_FAILURE_WINDOW_HOURS, [ref]$parsedRf) -and $parsedRf -ge 0) { $failureWindowHours = $parsedRf } - } - # CRITICAL tier for overall-elapsed (v0.7.99+). Pure visual escalation - JUnit + counts - # are unaffected. Runs older than $criticalElapsedDays days get a :rotating_light: run - # chip; older than 2x that get :skull:. Defaults: 3 days CRIT, 6 days SKULL. - $criticalElapsedDays = 3 - if ($env:INPUT_CRITICAL_ELAPSED_DAYS) { - [int]$parsedCe = 0 - if ([int]::TryParse($env:INPUT_CRITICAL_ELAPSED_DAYS, [ref]$parsedCe) -and $parsedCe -gt 0) { $criticalElapsedDays = $parsedCe } - } - $thresholdSpan = [TimeSpan]::FromHours($thresholdHours) - $stepThresholdSpan = [TimeSpan]::FromHours($stepThresholdHours) - $stepCritSpan = [TimeSpan]::FromHours($stepThresholdHours * 2) - $criticalElapsedSpan = [TimeSpan]::FromDays($criticalElapsedDays) - $skullElapsedSpan = [TimeSpan]::FromDays($criticalElapsedDays * 2) - Write-Host ("Thresholds: per-step={0}h (warn) / {1}h (crit), overall={2}h (warn) / {3}d (crit) / {4}d (skull), recent-failure-window={5}h" -f $stepThresholdHours, ($stepThresholdHours * 2), $thresholdHours, $criticalElapsedDays, ($criticalElapsedDays * 2), $failureWindowHours) - - $outputDir = './reports' - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - $monitorCsv = Join-Path $outputDir 'update-monitor.csv' - $monitorXml = Join-Path $outputDir 'update-monitor.xml' - - # Build cluster scope. -Latest returns one row per cluster. - # -PassThru is REQUIRED: without it Get-AzLocalUpdateRuns only writes formatted output to - # the host and returns nothing to the pipeline, silently producing an empty $runs and a - # 0/0/0 monitor snapshot even when there are in-flight runs. -SkipSideloadedReset is set - # because this is a read-only observability pipeline and must not mutate cluster tags. - $runs = @() - if ($scope -eq 'by-update-ring' -and $updateRing) { - Write-Host "Scope: UpdateRing = $updateRing" - $runs = @(Get-AzLocalUpdateRuns -ScopeByUpdateRingTag -UpdateRingValue $updateRing -Latest -PassThru -SkipSideloadedReset) - } else { - Write-Host "Scope: all clusters (via inventory)" - $inventory = Get-AzLocalClusterInventory -PassThru - if (-not $inventory -or $inventory.Count -eq 0) { - Write-Warning 'No clusters found in inventory.' - "IN_FLIGHT=0" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "LONG_RUNNING=0" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "LONG_RUNNING_STEP=0" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "STEP_ERRORED=0" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "RECENT_FAILURES=0" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "UNRESOLVED_FAILURES=0" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - # Emit an empty CSV so the artifact upload step has something to attach. - '' | Set-Content -Path $monitorCsv -Encoding utf8 - exit 0 - } - $resourceIds = @($inventory | Select-Object -ExpandProperty ResourceId) - $runs = @(Get-AzLocalUpdateRuns -ClusterResourceIds $resourceIds -Latest -PassThru -SkipSideloadedReset) - } - - # Project + enrich. Parse StartTime + EndTime + StepStartTime (all "yyyy-MM-dd HH:mm" - # from Format-AzLocalUpdateRun) back to [datetime] for live elapsed + window comparison. - $now = (Get-Date).ToUniversalTime() - $failureWindowStart = if ($failureWindowHours -gt 0) { $now.AddHours(-$failureWindowHours) } else { [datetime]::MaxValue } - $rows = foreach ($r in $runs) { - $startDt = $null - if ($r.StartTime) { - [datetime]$tmp = [datetime]::MinValue - if ([datetime]::TryParse([string]$r.StartTime, [ref]$tmp)) { - # StartTime as formatted by Format-AzLocalUpdateRun is UTC (ARM authoritative). - $startDt = [datetime]::SpecifyKind($tmp, [DateTimeKind]::Utc) - } - } - $endDt = $null - if ($r.EndTime) { - [datetime]$tmpE = [datetime]::MinValue - if ([datetime]::TryParse([string]$r.EndTime, [ref]$tmpE)) { - $endDt = [datetime]::SpecifyKind($tmpE, [DateTimeKind]::Utc) - } - } - $stepStartDt = $null - if ($r.PSObject.Properties['StepStartTime'] -and $r.StepStartTime) { - [datetime]$tmpS = [datetime]::MinValue - if ([datetime]::TryParse([string]$r.StepStartTime, [ref]$tmpS)) { - $stepStartDt = [datetime]::SpecifyKind($tmpS, [DateTimeKind]::Utc) - } - } - $elapsed = if ($startDt) { $now - $startDt } else { $null } - $elapsedDisplay = if ($elapsed) { - if ($elapsed.TotalDays -ge 1) { ('{0}d {1}h {2}m' -f [int]$elapsed.TotalDays, $elapsed.Hours, $elapsed.Minutes) } - elseif ($elapsed.TotalHours -ge 1) { ('{0}h {1}m' -f [int]$elapsed.TotalHours, $elapsed.Minutes) } - else { ('{0}m' -f [int]$elapsed.TotalMinutes) } - } else { '' } - # Per-step elapsed - live-compute against $now for InProgress runs so the row - # always reflects the snapshot moment (matches the Format-AzLocalUpdateRun - # StepElapsed string but as a comparable TimeSpan). - $stepElapsed = if ($stepStartDt -and $r.State -eq 'InProgress') { $now - $stepStartDt } else { $null } - $stepElapsedHoursVal = if ($stepElapsed) { [math]::Round($stepElapsed.TotalHours, 2) } else { '' } - $stepElapsedDisplay = if ($r.PSObject.Properties['StepElapsed']) { [string]$r.StepElapsed } else { '' } - if ([string]::IsNullOrWhiteSpace($stepElapsedDisplay) -and $stepElapsed) { - $stepElapsedDisplay = if ($stepElapsed.TotalHours -ge 1) { ('{0}h {1}m' -f [int]$stepElapsed.TotalHours, $stepElapsed.Minutes) } else { ('{0}m' -f [int]$stepElapsed.TotalMinutes) } - } - $exceeds = if ($elapsed -and ($r.State -eq 'InProgress')) { $elapsed -gt $thresholdSpan } else { $false } - $exceedsStep = if ($stepElapsed -and ($r.State -eq 'InProgress')) { $stepElapsed -gt $stepThresholdSpan } else { $false } - $isRecentFailure = if ($failureWindowHours -gt 0 -and $r.State -eq 'Failed' -and $endDt) { $endDt -gt $failureWindowStart } else { $false } - # Unresolved failure (v0.7.96): -Latest semantics mean any latest run with State=='Failed' - # is unresolved by definition (no later Succeeded run for the same cluster). Surfaced - # ALWAYS regardless of failureWindowHours so operators don't miss an old-but-still-broken - # cluster. JUnit still emits the per-cluster failure as 'RecentFailure' for backwards- - # compatible parsers. - $isUnresolvedFailure = ($r.State -eq 'Failed') - # Step-error signal (v0.7.96): properties.progress.status can be 'Error' even while - # properties.state stays 'InProgress'. This is the canonical "step has errored and the - # run is stuck" signal (e.g. Arizona's 18d-stuck "Start update" step). Independent of - # per-step elapsed - the two sources cross-confirm a hang. - $progressStatus = if ($r.PSObject.Properties['Status']) { [string]$r.Status } else { '' } - $hasStepError = ($progressStatus -eq 'Error' -and $r.State -eq 'InProgress') - # Portal links (v0.7.96): cluster -> .../updates blade; update run -> the - # AzureStackHCI portal extension SingleInstanceHistoryDetails ReactView. - # URL pattern verified against the Azure portal Update Manager view so - # the rendered links match what the portal builds itself. - $clusterPortalUrl = if ($r.ClusterResourceId) { 'https://portal.azure.com/#@/resource' + [string]$r.ClusterResourceId + '/updates' } else { '' } - $updateRunPortalUrl = '' - if ($r.PSObject.Properties['RunResourceId'] -and $r.RunResourceId -and $r.ClusterResourceId) { - $rm = [regex]::Match([string]$r.RunResourceId, '/updates/([^/]+)/updateRuns/([^/]+)$') - if ($rm.Success) { - $encClusterId = ((([string]$r.ClusterResourceId) -replace '/', '%2F') -replace ' ', '%20') - $updateRunPortalUrl = 'https://portal.azure.com/#view/Microsoft_AzureStackHCI_PortalExtension/SingleInstanceHistoryDetails.ReactView/resourceId/' + $encClusterId + '/updateName/' + $rm.Groups[1].Value + '/updateRunName/' + $rm.Groups[2].Value + '/refresh~/false' - } - } - # Severity classification (v0.7.98). Three independent signals + a numeric score: - # StepSeverity : none -> warn (>stepThresholdHours) -> crit (>2x stepThresholdHours) - # RunSeverity : none -> warn (>thresholdHours) -> crit (>criticalElapsedDays days) -> skull (>2x days) - # StateIcon / StatusIcon : at-a-glance scan icons (rendered into the markdown cells) - # SeverityScore : single numeric sort key combining ALL signals - replaces the - # priority-collapsed sort tuple so a 19-day-stuck step-errored row - # beats a 3h step-elapsed warn (was previously hidden because the - # Flag column collapsed to one chip and lost the run-elapsed signal). - $stepSeverity = 'none' - if ($stepElapsed -and $r.State -eq 'InProgress') { - if ($stepElapsed -gt $stepCritSpan) { $stepSeverity = 'crit' } - elseif ($stepElapsed -gt $stepThresholdSpan) { $stepSeverity = 'warn' } - } - $runSeverity = 'none' - if ($elapsed -and $r.State -eq 'InProgress') { - if ($elapsed -gt $skullElapsedSpan) { $runSeverity = 'skull' } - elseif ($elapsed -gt $criticalElapsedSpan) { $runSeverity = 'crit' } - elseif ($elapsed -gt $thresholdSpan) { $runSeverity = 'warn' } - } - $stateIcon = switch ($r.State) { - 'InProgress' { ':large_blue_circle:' } - 'Succeeded' { ':large_green_circle:' } - 'Failed' { ':red_circle:' } - 'NotStarted' { ':white_circle:' } - default { ':grey_question:' } - } - $statusIcon = switch ($progressStatus) { - 'Success' { ':white_check_mark:' } - 'Error' { ':x:' } - 'InProgress' { ':hourglass_flowing_sand:' } - 'Skipped' { ':no_entry:' } - 'Cancelled' { ':fast_forward:' } - default { '' } - } - # Chip stack - shows ALL signals that apply (no priority-collapse). Pipe-escaped for - # markdown table safety; rendered as
-separated chips inside one Flag cell. - $chipList = New-Object 'System.Collections.Generic.List[string]' - if ($hasStepError) { $chipList.Add(':rotating_light: step errored') | Out-Null } - if ($stepSeverity -eq 'crit') { $chipList.Add((':rotating_light: step >{0}h' -f ($stepThresholdHours * 2))) | Out-Null } - elseif ($stepSeverity -eq 'warn') { $chipList.Add((':warning: step >{0}h' -f $stepThresholdHours)) | Out-Null } - if ($runSeverity -eq 'skull') { $chipList.Add((':skull: run >{0}d' -f ($criticalElapsedDays * 2))) | Out-Null } - elseif ($runSeverity -eq 'crit') { $chipList.Add((':rotating_light: run >{0}d' -f $criticalElapsedDays)) | Out-Null } - elseif ($runSeverity -eq 'warn') { $chipList.Add((':warning: run >{0}h' -f $thresholdHours)) | Out-Null } - if ($chipList.Count -eq 0 -and $r.State -eq 'InProgress') { $chipList.Add('within') | Out-Null } - $flagDisplay = ($chipList -join '
') - # SeverityScore - bigger = worse. StepError tops the scale; Failed-latest is just - # below; skull > crit > warn; tiebreaker is elapsed hours / 24 (days). All Within - # rows tie at 0 + days; ClusterName is the secondary alphabetical sort. - $severityScore = 0.0 - if ($hasStepError) { $severityScore += 1000 } - if ($r.State -eq 'Failed') { $severityScore += 800 } - if ($runSeverity -eq 'skull') { $severityScore += 500 } - elseif ($runSeverity -eq 'crit') { $severityScore += 300 } - elseif ($runSeverity -eq 'warn') { $severityScore += 50 } - if ($stepSeverity -eq 'crit') { $severityScore += 200 } - elseif ($stepSeverity -eq 'warn') { $severityScore += 30 } - $elapsedHoursForScore = if ($elapsed) { $elapsed.TotalHours } else { 0 } - $severityScore += [math]::Round($elapsedHoursForScore / 24, 2) - # v0.7.98: surface a meaningful per-row JUnit time= so the Test Reporter - # widget stops rendering "0ms" totals. For in-flight rows we report how long - # the run has been going (now - startDt). For unresolved-failed rows with both - # start and end timestamps we report the actual run duration (end - start). - # Anything else falls back to 0 (no usable timestamps). - $runDurationSeconds = 0 - if ($r.State -ne 'InProgress' -and $startDt -and $endDt) { - $runDurationSeconds = [int][math]::Round(($endDt - $startDt).TotalSeconds, 0) - } elseif ($elapsed) { - $runDurationSeconds = [int][math]::Round($elapsed.TotalSeconds, 0) - } - if ($runDurationSeconds -lt 0) { $runDurationSeconds = 0 } - [PSCustomObject]@{ - ClusterName = $r.ClusterName - ClusterPortalUrl = $clusterPortalUrl - UpdateName = $r.UpdateName - UpdateRunPortalUrl = $updateRunPortalUrl - State = $r.State - Status = $progressStatus - CurrentStep = $r.CurrentStep - Progress = $r.Progress - StartTimeUtc = if ($startDt) { $startDt.ToString('yyyy-MM-dd HH:mm') } else { '' } - EndTimeUtc = if ($endDt) { $endDt.ToString('yyyy-MM-dd HH:mm') } else { '' } - ElapsedDisplay = $elapsedDisplay - ElapsedHours = if ($elapsed) { [math]::Round($elapsed.TotalHours, 2) } else { '' } - StepStartTimeUtc = if ($stepStartDt) { $stepStartDt.ToString('yyyy-MM-dd HH:mm') } else { '' } - StepElapsedDisplay = $stepElapsedDisplay - StepElapsedHours = $stepElapsedHoursVal - ExceedsThreshold = $exceeds - ExceedsStepThreshold = $exceedsStep - HasStepError = $hasStepError - IsRecentFailure = $isRecentFailure - IsUnresolvedFailure = $isUnresolvedFailure - ThresholdHours = $thresholdHours - StepThresholdHours = $stepThresholdHours - CriticalElapsedDays = $criticalElapsedDays - StepSeverity = $stepSeverity - RunSeverity = $runSeverity - StateIcon = $stateIcon - StatusIcon = $statusIcon - Flags = $flagDisplay - SeverityScore = $severityScore - RunDurationSeconds = $runDurationSeconds - RunId = $r.RunId - RunResourceId = if ($r.PSObject.Properties['RunResourceId']) { $r.RunResourceId } else { '' } - ClusterResourceId = $r.ClusterResourceId - Duration = $r.Duration - CurrentStepDetail = $r.CurrentStepDetail - ErrorMessage = if ($r.PSObject.Properties['ErrorMessage']) { [string]$r.ErrorMessage } else { '' } - } - } - - $inFlight = @($rows | Where-Object { $_.State -eq 'InProgress' }) - $longRunning = @($inFlight | Where-Object { $_.ExceedsThreshold }) - $longRunningStep = @($inFlight | Where-Object { $_.ExceedsStepThreshold }) - $stepErrored = @($inFlight | Where-Object { $_.HasStepError }) - $recentlyFailed = @($rows | Where-Object { $_.IsRecentFailure }) - $unresolvedFailed = @($rows | Where-Object { $_.IsUnresolvedFailure }) - - # CSV (always emit, even if empty, so the artifact step has a file) - if ($rows -and $rows.Count -gt 0) { - $rows | Sort-Object @{Expression='SeverityScore';Descending=$true}, ClusterName | Export-Csv -Path $monitorCsv -NoTypeInformation -Encoding utf8 - } else { - '' | Set-Content -Path $monitorCsv -Encoding utf8 - } - - # JUnit XML - one per in-flight cluster + one per unresolved-failed cluster. - # Failure types (v0.7.96): - # StepError - HIGHEST PRIORITY: properties.progress.status == 'Error' while State is InProgress - # (canonical "step has errored, run is stuck" signal - independent of elapsed time) - # LongRunningStep - current step elapsed exceeds per-step threshold - # LongRunningOverall - BACKSTOP: overall run elapsed exceeds overall threshold - # RecentFailure - run is in state Failed (kept name for backwards-compatible parsers; - # emitted for ALL latest-Failed runs, not just those within failureWindowHours) - # Empty suite (no in-flight + no unresolved failures) still emits valid XML so the publish step works. - $xmlLines = New-Object 'System.Collections.Generic.List[string]' - $xmlLines.Add('') - # v0.7.99: testsuite/testsuites time="0". Per-testcase time= IS still surfaced (gives - # the Test Reporter widget meaningful per-row durations), but summing wall-clock ages - # across 5 unrelated stuck runs (e.g. ~88 days) is misleading at the suite header. - $xmlLines.Add('') - $tests = $inFlight.Count + $unresolvedFailed.Count - # A single run can trigger ANY combination of StepError + LongRunningStep + LongRunningOverall - # - we emit the highest-priority match as ONE failure per testcase, so $failures matches the - # testcase count of actually-failing rows (not the union of failure flags). - $failures = @($inFlight | Where-Object { $_.HasStepError -or $_.ExceedsStepThreshold -or $_.ExceedsThreshold }).Count + $unresolvedFailed.Count - $xmlLines.Add((' ' -f $tests, $failures)) - foreach ($r in ($inFlight | Sort-Object @{Expression='SeverityScore';Descending=$true}, ClusterName)) { - $safeName = ($r.ClusterName -replace '[^A-Za-z0-9_.-]', '_') - $caseName = "$safeName - $($r.UpdateName) - $($r.CurrentStep)" - $caseNameXml = [System.Security.SecurityElement]::Escape($caseName) - if ($r.HasStepError) { - $errSnippet = if ($r.ErrorMessage) { $r.ErrorMessage } else { '(no errorMessage on deepest failed step)' } - $msg = ('Progress status is Error (state still InProgress) - step is stuck. CurrentStep: {0}. StepElapsed: {1}. ErrorMessage: {2}' -f $r.CurrentStep, $r.StepElapsedDisplay, $errSnippet) - $msgXml = [System.Security.SecurityElement]::Escape($msg) - $xmlLines.Add((' ' -f $caseNameXml, $r.RunDurationSeconds)) - $xmlLines.Add((' {0}' -f $msgXml)) - $xmlLines.Add(' ') - } elseif ($r.ExceedsStepThreshold) { - $msg = ('Current step elapsed {0} exceeds per-step threshold of {1}h. CurrentStep: {2}. Overall elapsed: {3}. Progress: {4}.' -f $r.StepElapsedDisplay, $r.StepThresholdHours, $r.CurrentStep, $r.ElapsedDisplay, $r.Progress) - $msgXml = [System.Security.SecurityElement]::Escape($msg) - $xmlLines.Add((' ' -f $caseNameXml, $r.RunDurationSeconds)) - $xmlLines.Add((' {0}' -f $msgXml)) - $xmlLines.Add(' ') - } elseif ($r.ExceedsThreshold) { - $msg = ('Overall elapsed {0} exceeds backstop threshold of {1}h (current step within {2}h per-step budget). CurrentStep: {3}. Progress: {4}.' -f $r.ElapsedDisplay, $r.ThresholdHours, $r.StepThresholdHours, $r.CurrentStep, $r.Progress) - $msgXml = [System.Security.SecurityElement]::Escape($msg) - $xmlLines.Add((' ' -f $caseNameXml, $r.RunDurationSeconds)) - $xmlLines.Add((' {0}' -f $msgXml)) - $xmlLines.Add(' ') - } else { - $xmlLines.Add((' ' -f $caseNameXml, $r.RunDurationSeconds)) - } - } - foreach ($r in ($unresolvedFailed | Sort-Object @{Expression='EndTimeUtc';Descending=$true})) { - $safeName = ($r.ClusterName -replace '[^A-Za-z0-9_.-]', '_') - $caseName = "$safeName - $($r.UpdateName) - FAILED" - $caseNameXml = [System.Security.SecurityElement]::Escape($caseName) - $detail = if ($r.ErrorMessage) { $r.ErrorMessage } elseif ($r.CurrentStepDetail) { $r.CurrentStepDetail } else { $r.CurrentStep } - $msg = ('Run failed at {0} UTC. Step: {1}. Detail: {2}.' -f $r.EndTimeUtc, $r.CurrentStep, $detail) - $msgXml = [System.Security.SecurityElement]::Escape($msg) - $xmlLines.Add((' ' -f $caseNameXml, $r.RunDurationSeconds)) - $xmlLines.Add((' {0}' -f $msgXml)) - $xmlLines.Add(' ') - } - $xmlLines.Add(' ') - $xmlLines.Add('') - ($xmlLines -join [Environment]::NewLine) | Set-Content -Path $monitorXml -Encoding utf8 - - # Job outputs - "IN_FLIGHT=$($inFlight.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "LONG_RUNNING=$($longRunning.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "LONG_RUNNING_STEP=$($longRunningStep.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "STEP_ERRORED=$($stepErrored.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "RECENT_FAILURES=$($recentlyFailed.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "UNRESOLVED_FAILURES=$($unresolvedFailed.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - - # Markdown step summary - $md = New-Object 'System.Collections.Generic.List[string]' - $md.Add('## In-Flight Update Monitor') - $md.Add('') - # Top-of-summary status badge (v0.7.98) - single at-a-glance verdict for the whole - # scope. Critical when ANY stuck-step error, skull/crit run-elapsed, or unresolved - # Failed latest; Warn when any warn-tier signal; Healthy otherwise. - $hasCritical = ($stepErrored.Count -gt 0) -or $unresolvedFailed.Count -gt 0 -or @($inFlight | Where-Object { $_.RunSeverity -eq 'skull' -or $_.RunSeverity -eq 'crit' -or $_.StepSeverity -eq 'crit' }).Count -gt 0 - $hasWarn = @($inFlight | Where-Object { $_.StepSeverity -eq 'warn' -or $_.RunSeverity -eq 'warn' }).Count -gt 0 - $statusBadge = if ($hasCritical) { - $crits = @() - if ($stepErrored.Count -gt 0) { $crits += "$($stepErrored.Count) stuck step error(s)" } - $skullCount = @($inFlight | Where-Object { $_.RunSeverity -eq 'skull' }).Count - $critRunCount = @($inFlight | Where-Object { $_.RunSeverity -eq 'crit' }).Count - $critStepCount = @($inFlight | Where-Object { $_.StepSeverity -eq 'crit' }).Count - if ($skullCount -gt 0) { $crits += "$skullCount run(s) > $($criticalElapsedDays * 2)d" } - if ($critRunCount -gt 0) { $crits += "$critRunCount run(s) > ${criticalElapsedDays}d" } - if ($critStepCount -gt 0){ $crits += "$critStepCount step(s) > $($stepThresholdHours * 2)h" } - if ($unresolvedFailed.Count -gt 0) { $crits += "$($unresolvedFailed.Count) unresolved failure(s)" } - ':red_circle: **Fleet Status: CRITICAL** - ' + ($crits -join ', ') - } elseif ($hasWarn) { - $warnCount = @($inFlight | Where-Object { $_.StepSeverity -eq 'warn' -or $_.RunSeverity -eq 'warn' }).Count - ":yellow_circle: **Fleet Status: WARN** - $warnCount long-running run(s)" - } elseif ($inFlight.Count -gt 0) { - ":green_circle: **Fleet Status: HEALTHY** - $($inFlight.Count) in-flight run(s) within all thresholds" - } else { - ':white_circle: **Fleet Status: IDLE** - no update runs currently in flight' - } - $md.Add($statusBadge) - $md.Add('') - $scopeLabel = if ($scope -eq 'by-update-ring' -and $updateRing) { "by-update-ring (UpdateRing = $updateRing)" } else { 'all clusters' } - $md.Add("**Scope**: $scopeLabel - **Per-step warn/crit**: ${stepThresholdHours}h / $($stepThresholdHours * 2)h - **Overall warn/crit/skull**: ${thresholdHours}h / ${criticalElapsedDays}d / $($criticalElapsedDays * 2)d - **Recent-failure window**: ${failureWindowHours}h - **Snapshot (UTC)**: $($now.ToString('yyyy-MM-dd HH:mm'))") - $md.Add('') - $md.Add('| Metric | Count |') - $md.Add('|--------|-------|') - $md.Add("| Clusters scoped | $($rows.Count) |") - $md.Add("| Update runs in flight | $($inFlight.Count) |") - $md.Add("| Step errored (progress.status == 'Error', state still InProgress) | $($stepErrored.Count) |") - $md.Add("| Step elapsed > ${stepThresholdHours}h (primary) | $($longRunningStep.Count) |") - $md.Add("| Overall elapsed > ${thresholdHours}h (backstop) | $($longRunning.Count) |") - $md.Add("| Unresolved-failed runs (latest run is Failed) | $($unresolvedFailed.Count) |") - if ($failureWindowHours -gt 0) { - $md.Add("| Recently-failed runs (last ${failureWindowHours}h) | $($recentlyFailed.Count) |") - } - $md.Add('') - if ($inFlight.Count -gt 0) { - $md.Add('### In-flight runs (sorted by severity score, worst first)') - $md.Add('') - $md.Add('| Cluster | Update | State | Progress Status | Current Step | Progress | Step Started (UTC) | Step Elapsed | Run Started (UTC) | Run Elapsed | Flags |') - $md.Add('|---------|--------|-------|-----------------|--------------|----------|--------------------|--------------|-------------------|-------------|-------|') - foreach ($r in ($inFlight | Sort-Object @{Expression='SeverityScore';Descending=$true}, ClusterName)) { - $cs = if ($r.CurrentStep) { $r.CurrentStep } else { '-' } - $pg = if ($r.Progress) { $r.Progress } else { '-' } - $stepStart = if ($r.StepStartTimeUtc) { $r.StepStartTimeUtc } else { '-' } - # Per-cell icons (v0.7.98). State + Status get inline icons; Step/Run elapsed - # cells get a severity prefix so the worst rows are scannable without reading - # the Flags column. Avoid nested inline-if expressions - they parse but at - # runtime PowerShell treats the inner (if ...) as a command call ("'if' is not - # recognized as the name of a cmdlet"). Use elseif chains instead. - $stateCell = if ($r.StateIcon) { "$($r.StateIcon) $($r.State)" } else { [string]$r.State } - $statusCell = if (-not $r.Status) { '-' } - elseif ($r.StatusIcon) { "$($r.StatusIcon) $($r.Status)" } - else { [string]$r.Status } - $stepElPrefix = switch ($r.StepSeverity) { 'crit' { ':rotating_light: ' } 'warn' { ':warning: ' } default { '' } } - $runElPrefix = switch ($r.RunSeverity) { 'skull' { ':skull: ' } 'crit' { ':rotating_light: ' } 'warn' { ':warning: ' } default { '' } } - $stepEl = if ($r.StepElapsedDisplay) { $stepElPrefix + $r.StepElapsedDisplay } else { '-' } - $runEl = if ($r.ElapsedDisplay) { $runElPrefix + $r.ElapsedDisplay } else { '-' } - $flagCell = if ($r.Flags) { $r.Flags } else { '-' } - # target=_blank so links open in a new tab (preserves the workflow summary tab). - $clusterCell = if ($r.ClusterPortalUrl) { '' + $r.ClusterName + '' } else { $r.ClusterName } - $updateCell = if ($r.UpdateRunPortalUrl) { '' + $r.UpdateName + '' } else { $r.UpdateName } - $md.Add("| $clusterCell | $updateCell | $stateCell | $statusCell | $cs | $pg | $stepStart | $stepEl | $($r.StartTimeUtc) | $runEl | $flagCell |") - } - $md.Add('') - } else { - $md.Add('### No update runs currently in flight') - $md.Add('') - $md.Add("No clusters in scope have a latest run in state ``InProgress``. To verify scope, see the artifact CSV (``update-monitor.csv``).") - $md.Add('') - } - if ($unresolvedFailed.Count -gt 0) { - $md.Add('### Failed runs (latest run is Failed, unresolved - shown regardless of age)') - $md.Add('') - $md.Add('| Cluster | Update | Ended (UTC) | Failed Step | Verbose Error Details | Recent |') - $md.Add('|---------|--------|-------------|-------------|-----------------------|--------|') - foreach ($r in ($unresolvedFailed | Sort-Object @{Expression='EndTimeUtc';Descending=$true})) { - $cs = if ($r.CurrentStep) { $r.CurrentStep } else { '-' } - # Verbose error column (v0.7.98) - mirror Step.8 pattern: full error body inside - # a
collapsible so the row stays compact but operators can expand for - # the entire stack trace. HTML-encode <, >, & so embedded markup doesn't render - # as tags; escape pipes; collapse newlines to
so multi-line traces stay - # readable inside the panel. CurrentStepDetail is used when ErrorMessage is - # empty (older runs without the deep-error projection). - $rawDetail = if ($r.ErrorMessage) { [string]$r.ErrorMessage } elseif ($r.CurrentStepDetail) { [string]$r.CurrentStepDetail } else { '' } - $detailCell = if ([string]::IsNullOrWhiteSpace($rawDetail)) { '_(no error detail)_' } else { - $e = $rawDetail -replace '&','&' -replace '<','<' -replace '>','>' - $e = $e -replace "`r`n",'
' -replace "`n",'
' -replace '\|','\|' - '
Show error
' + $e + '
' - } - $recentTag = if ($r.IsRecentFailure) { ":fire: last ${failureWindowHours}h" } else { '-' } - $clusterCell = if ($r.ClusterPortalUrl) { '' + $r.ClusterName + '' } else { $r.ClusterName } - $updateCell = if ($r.UpdateRunPortalUrl) { '' + $r.UpdateName + '' } else { $r.UpdateName } - $md.Add("| $clusterCell | $updateCell | $($r.EndTimeUtc) | $cs | $detailCell | $recentTag |") - } - $md.Add('') - } - if (($stepErrored.Count + $longRunningStep.Count + $longRunning.Count + $unresolvedFailed.Count) -gt 0) { - $md.Add('> **Action required.** One or more update runs have errored, hit a threshold, or have an unresolved Failed latest run. Common causes (consult the Azure Local Update Manager portal + the cluster activity log for the affected cluster(s)):') - $md.Add('>') - $md.Add('> - Health check failures (storage / network / cluster service) blocking the run from progressing') - $md.Add('> - Node drain stuck (VM live-migration timeout, anti-affinity blocking move)') - $md.Add('> - Sideloaded payload / pre-staged content mismatch on one or more nodes') - $md.Add('> - ARB (Arc Resource Bridge) connectivity loss or extension-version drift') - $md.Add('>') - $md.Add('> Troubleshooting guides:') - $md.Add('> - **Microsoft Learn:** [Troubleshoot update failures (Azure Local 23H2)](https://learn.microsoft.com/azure/azure-local/update/update-troubleshooting-23h2#troubleshoot-update-failures)') - $md.Add('> - **GitHub TSG:** [Azure/AzureLocal-Supportability/TSG/Update](https://github.com/Azure/AzureLocal-Supportability/tree/main/TSG/Update)') - $md.Add('>') - $md.Add('> The Checks tab shows the same rows as JUnit failures (`StepError`, `LongRunningStep`, `LongRunningOverall`, `RecentFailure`).') - $md.Add('') - } elseif ($inFlight.Count -gt 0) { - $md.Add("> **All in-flight runs are healthy (no step errors, per-step <=${stepThresholdHours}h, overall <=${thresholdHours}h) and no unresolved failures.**") - $md.Add('') - } - $md.Add('_Source data: `Get-AzLocalUpdateRuns -Latest -PassThru`. JUnit emitted as `./reports/update-monitor.xml`; full per-cluster rows in `./reports/update-monitor.csv`._') - ($md -join [Environment]::NewLine) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + $params = @{ + Scope = if ($env:INPUT_SCOPE) { $env:INPUT_SCOPE } else { 'all' } + OutputDirectory = './reports' + InstalledModuleVersion = $env:INSTALLED_MODULE_VERSION + } + if ($env:INPUT_UPDATE_RING) { $params['UpdateRing'] = $env:INPUT_UPDATE_RING } + [int]$parsed = 0 + if ([int]::TryParse([string]$env:INPUT_STEP_THRESHOLD_HOURS, [ref]$parsed) -and $parsed -gt 0) { $params['LongRunningStepHours'] = $parsed } + if ([int]::TryParse([string]$env:INPUT_THRESHOLD_HOURS, [ref]$parsed) -and $parsed -gt 0) { $params['LongRunningThresholdHours'] = $parsed } + if ([int]::TryParse([string]$env:INPUT_RECENT_FAILURE_WINDOW_HOURS, [ref]$parsed) -and $parsed -ge 0) { $params['RecentFailureWindowHours'] = $parsed } + if ([int]::TryParse([string]$env:INPUT_CRITICAL_ELAPSED_DAYS, [ref]$parsed) -and $parsed -gt 0) { $params['CriticalElapsedDays'] = $parsed } + Export-AzLocalUpdateRunMonitorReport @params - name: Compute Artifact Timestamp if: always() 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 ece492a3..1899fbf7 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.4' + GENERATED_AGAINST_MODULE_VERSION: '0.8.5' # 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). @@ -154,7 +154,10 @@ jobs: az extension add --name resource-graph --yes - name: Install AzLocal.UpdateManagement from PSGallery + # v0.8.5 thin-YAML: drift detection + banner + step outputs are all + # produced by Add-AzLocalPipelineVersionBanner (Public cmdlet). shell: pwsh + id: module-version run: | $ErrorActionPreference = 'Stop' $installArgs = @{ Name = 'AzLocal.UpdateManagement'; Scope = 'CurrentUser'; Force = $true; AllowClobber = $true } @@ -166,700 +169,36 @@ jobs: } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force - $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on runner : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - Write-Host "" - Get-Module AzLocal.UpdateManagement | Format-List Name, Version, Path - # Drift signal 0: the module installed at runtime is OLDER than the version this YAML was generated against. Cmdlets, parameters, or output schemas referenced by this YAML may not exist in the installed module - emitted as a warning because this is the most likely cause of a hard runtime failure later in the job. - if ($installed -lt $generated) { - $msg = "AzLocal.UpdateManagement v$installed is OLDER than the version this workflow YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Update'." - Write-Host "::warning title=AzLocal.UpdateManagement is older than workflow YAML expects::$msg" - } - if ($installed -gt $generated) { - $msg = "Workflow YAML was generated against AzLocal.UpdateManagement v$generated but the runner installed v$installed. Pipeline steps may have been improved in later releases - to refresh, re-run 'Copy-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Update' (you will be prompted per file; add -Confirm:`$false to bypass). Pipeline YAMLs are under git so 'git diff' shows exactly what changed before commit." - Write-Host "::notice title=Workflow YAML may be stale::$msg" - } - if ($latest -and ($latest -gt $installed)) { - $msg = "AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed. Review the module CHANGELOG before bumping REQUIRED_MODULE_VERSION (or clear the pin to install the latest automatically)." - Write-Host "::notice title=Newer AzLocal.UpdateManagement version available on PSGallery::$msg" - } - - # v0.8.4 - one-line version banner emitted to the rendered Step - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), the - # module version actually loaded on the runner, the PSGallery latest, - # and the REQUIRED_MODULE_VERSION pin status are visible at the top - # of every run summary without scrolling the agent log. Same data is - # also echoed to the console above as 'Module version summary'. - $pin = if ($env:REQUIRED_MODULE_VERSION) { "pinned to v$($env:REQUIRED_MODULE_VERSION)" } else { 'latest (fix-forward)' } - $latestStr = if ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } - $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } - elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } - elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } - else { 'in sync' } - if ($env:GITHUB_STEP_SUMMARY) { - "_Pipeline YAML v$generated | Module v$installed installed ($pin) | PSGallery latest $latestStr | $verdict_" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - } + Add-AzLocalPipelineVersionBanner ` + -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION ` + -PinnedVersion $env:REQUIRED_MODULE_VERSION - name: Collect Fleet Update Status - shell: pwsh + # v0.8.5 thin-YAML: the inline run block (cluster inventory, readiness + # cascade, version distribution with Microsoft-manifest support window, + # 3-suite JUnit XML, supplementary CSV/JSON exports, run history collection, + # 22 step outputs, and the full markdown step summary written to + # GITHUB_STEP_SUMMARY) has been condensed into the Public cmdlet + # Export-AzLocalFleetUpdateStatusReport. The cmdlet writes + # ./reports/*.{json,csv,xml} for the upload-artifact step and sets all + # 22 step outputs consumed by downstream jobs (e.g. ITSM connector). id: fleet-status + shell: pwsh env: INPUT_SCOPE: ${{ github.event.inputs.scope || 'all' }} INPUT_UPDATE_RING: ${{ github.event.inputs.update_ring }} INPUT_INCLUDE_UPDATE_RUNS: ${{ github.event.inputs.include_update_runs || 'true' }} + INSTALLED_MODULE_VERSION: ${{ steps.module-version.outputs.installed_module_version }} run: | + $ErrorActionPreference = 'Stop' Import-Module AzLocal.UpdateManagement -Force - - # Create output directory - $outputDir = "./reports" - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - - $scope = $env:INPUT_SCOPE - $updateRing = $env:INPUT_UPDATE_RING - $includeRuns = $env:INPUT_INCLUDE_UPDATE_RUNS -eq 'true' - - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "Fleet Update Status Collection" -ForegroundColor Cyan - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "Scope: $scope" - if ($updateRing) { Write-Host "UpdateRing Filter: $updateRing" } - Write-Host "Include Update Runs: $includeRuns" - Write-Host "" - - # Step 1: Get cluster inventory to find all clusters - Write-Host "Step 1: Getting cluster inventory..." -ForegroundColor Yellow - - $inventoryCsv = Join-Path $outputDir "cluster-inventory.csv" - $inventory = Get-AzLocalClusterInventory -ExportPath $inventoryCsv -PassThru - - if (-not $inventory -or $inventory.Count -eq 0) { - Write-Warning "No clusters found in inventory" - exit 0 - } - - Write-Host "Found $($inventory.Count) total cluster(s)" -ForegroundColor Green - - # Step 2: Get readiness status for targeted clusters - Write-Host "" - Write-Host "Step 2: Checking update readiness..." -ForegroundColor Yellow - - $readinessParams = @{} - - if ($scope -eq 'by-update-ring' -and $updateRing) { - $readinessParams['ScopeByUpdateRingTag'] = $true - $readinessParams['UpdateRingValue'] = $updateRing - } - else { - # Get all cluster resource IDs from inventory - $resourceIds = $inventory | Select-Object -ExpandProperty ResourceId - $readinessParams['ClusterResourceIds'] = $resourceIds - } - - # Export to multiple formats - $readinessCsv = Join-Path $outputDir "readiness-status.csv" - $readinessJson = Join-Path $outputDir "readiness-status.json" - $readinessXml = Join-Path $outputDir "readiness-status.xml" - - $readiness = Get-AzLocalClusterUpdateReadiness @readinessParams -ExportPath $readinessCsv -PassThru - - # Also export to JSON - $hasPrerequisite = @($readiness | Where-Object { $_.HasPrerequisiteUpdates -ne "" }).Count - $readinessExport = @{ - Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss UTC" - TotalClusters = $readiness.Count - Summary = @{ - # v0.7.99: 3-bucket model (ReadyForUpdate / UpToDate / NotReadyForUpdate) - # matches the renamed Get-AzLocalClusterUpdateReadiness Summary buckets. - # NotReadyForUpdate now excludes both InProgress AND UpToDate clusters. - ReadyForUpdate = ($readiness | Where-Object { $_.ReadyForUpdate -eq $true }).Count - UpToDate = ($readiness | Where-Object { - $_.ReadyForUpdate -ne $true -and - $_.UpdateState -in @('UpToDate','AppliedSuccessfully') -and - [string]::IsNullOrEmpty([string]$_.AllAvailableUpdates) - }).Count - InProgress = ($readiness | Where-Object { $_.UpdateState -in @("UpdateInProgress","PreparationInProgress") }).Count - HealthFailures = ($readiness | Where-Object { $_.HealthState -eq "Failure" }).Count - UpdateFailures = ($readiness | Where-Object { $_.UpdateState -in @("Failed","UpdateFailed","NeedsAttention") }).Count - ActionRequired = ($readiness | Where-Object { $_.UpdateState -eq "PreparationFailed" }).Count - HasPrerequisite = $hasPrerequisite - NotReadyForUpdate = ($readiness | Where-Object { - $_.ReadyForUpdate -ne $true -and - ($_.UpdateState -notin @("UpdateInProgress","PreparationInProgress")) -and - -not ($_.UpdateState -in @('UpToDate','AppliedSuccessfully') -and [string]::IsNullOrEmpty([string]$_.AllAvailableUpdates)) - }).Count - } - Clusters = $readiness - } - $readinessExport | ConvertTo-Json -Depth 10 | Out-File $readinessJson -Encoding UTF8 - - # Step 3: Generate JUnit XML for CI/CD visualization - Write-Host "" - Write-Host "Step 3: Generating JUnit XML report..." -ForegroundColor Yellow - - # Build JUnit XML manually for more control over test case details - $timestamp = Get-Date -Format "yyyy-MM-ddTHH:mm:ss" - $totalTests = $readiness.Count - - # Critical Health Status (this is what the JUnit XML pass/fail represents): - # FAILED = HealthState=Failure OR UpdateState in (Failed,UpdateFailed,NeedsAttention,PreparationFailed) - # OR SBE prerequisite blocked - # PASSED = everything else - # v0.7.96: PreparationFailed + NeedsAttention previously fell into the 'Other' bucket and - # were invisible to operators. They are now first-class failure signals (matches the - # Azure portal Update Manager 'state' filter). - $failureStates = @('Failed','UpdateFailed','NeedsAttention','PreparationFailed') - $failures = @($readiness | Where-Object { $_.HealthState -eq "Failure" -or ($_.UpdateState -in $failureStates) -or $_.HasPrerequisiteUpdates -ne "" }).Count - $criticalHealthPassed = $totalTests - $failures - - # Mutually-exclusive primary status buckets so the summary table rows - # always sum to Total Clusters. Priority cascade (first match wins): - # UpdateFailed > ActionRequired > HealthFailure > SbeBlocked > InProgress > ReadyForUpdate > UpToDate > Other - # v0.7.96 vocab (sourced from properties.state of the updatesummaries ARM resource): - # AppliedSuccessfully / UpToDate -> Up to date (success) - # UpdateAvailable + ReadyForUpdate -> Ready for update - # UpdateInProgress / PreparationInProgress -> In progress - # UpdateFailed / Failed / NeedsAttention -> Update failed (terminal failure, retry path) - # PreparationFailed -> Action required (operator must remediate before retry) - $stUpdateFailed = 0 - $stActionRequired = 0 - $stHealthFailure = 0 - $stSbeBlocked = 0 - $stInProgress = 0 - $stReadyForUpdate = 0 - $stUpToDate = 0 - $stOther = 0 - foreach ($c in $readiness) { - if ($c.UpdateState -in @('Failed','UpdateFailed','NeedsAttention')) { $stUpdateFailed++ } - elseif ($c.UpdateState -eq 'PreparationFailed') { $stActionRequired++ } - elseif ($c.HealthState -eq 'Failure') { $stHealthFailure++ } - elseif ($c.HasPrerequisiteUpdates) { $stSbeBlocked++ } - elseif ($c.UpdateState -in @('UpdateInProgress','PreparationInProgress')) { $stInProgress++ } - elseif ($c.ReadyForUpdate -eq $true) { $stReadyForUpdate++ } - elseif ($c.UpdateState -in @('UpToDate','AppliedSuccessfully')) { $stUpToDate++ } - else { $stOther++ } - } - - # Legacy variable names (kept for backwards-compatible step outputs) - $inProgress = $stInProgress - $ready = $stReadyForUpdate - $upToDate = $stUpToDate - - # Fleet Version Distribution - pivot clusters by CurrentVersion - # so the JUnit XML (and the markdown summary) lead with "what versions - # are presently installed across the fleet, and what % of clusters - # are on each one". One row per distinct CurrentVersion, sorted by - # cluster count (descending). Used by both the JUnit emitter below - # (first testsuite) and the 'Create Status Summary' markdown step. - $versionGroups = @($readiness | Group-Object { - if ([string]::IsNullOrWhiteSpace($_.CurrentVersion)) { '(unknown)' } else { [string]$_.CurrentVersion } - } | Sort-Object Count -Descending) - $versionDistribution = @($versionGroups | ForEach-Object { - $clusterNames = @($_.Group | Sort-Object ClusterName | ForEach-Object { [string]$_.ClusterName }) - [PSCustomObject]@{ - Version = $_.Name - Count = $_.Count - Percentage = if ($totalTests -gt 0) { [math]::Round(($_.Count / $totalTests) * 100, 1) } else { 0 } - Clusters = ($clusterNames -join ', ') - } - }) - $distinctVersions = $versionDistribution.Count - $mostCommonVersion = if ($distinctVersions -gt 0) { $versionDistribution[0].Version } else { '(none)' } - $mostCommonVersionPct = if ($distinctVersions -gt 0) { $versionDistribution[0].Percentage } else { 0 } - - # Compute the "supported YYMM window" used by the SupportStatus column. - # Azure Local solution-bundle releases use the format .YYMM.X.X - # (YYMM = YY 20-99, MM 01-12; validated by regex '^[0-9]{4}$'). The rolling - # support window is the most-recent N YYMM values inclusive. - # - # ANCHOR PRIORITY: - # 1) Microsoft manifest (preferred): Get-AzLocalLatestSolutionVersion - # queries the unauthenticated public catalog at https://aka.ms/AzureEdgeUpdates, - # takes the highest YYMM across ApplicableUpdate.UpdateInfo + - # PackageMetadata.ServicesUpdates.Update.UpdateInfo, and steps back - # 6 calendar months (e.g. 2604 -> 2604,2603,2602,2601,2512,2511). - # As soon as Microsoft publishes any 2604 release, 2510 drops out of - # the window even if NO cluster in the fleet has installed 2604 yet. - # 2) Fleet-observed fallback (when the manifest is unreachable): - # top-6 distinct YYMM values seen in the fleet's own CurrentVersion - # values. This is the GA behaviour preserved as a safety net. - # - # Supported = YYMM is in the anchor window - # Unsupported = YYMM is parseable but older than the anchor window - # Unknown = CurrentVersion is empty / malformed / has no YYMM token - # - # Cross-check with the Microsoft Azure Local lifecycle docs before any - # operator action. - $supportSource = 'fleet-observed' - $latestReleasedYymm = '' - $latestReleasedVersion = '' - $manifestFetchedAt = '' - $manifestUrl = 'https://aka.ms/AzureEdgeUpdates' - $manifestError = '' - try { - Write-Host "Querying $manifestUrl for the latest released Azure Local solution version..." - $manifestProbe = Get-AzLocalLatestSolutionVersion -ErrorAction Stop - $supportedYymms = @($manifestProbe.SupportedYYMMs) - $supportSource = 'Microsoft manifest' - $latestReleasedYymm = [string]$manifestProbe.LatestYYMM - $latestReleasedVersion = [string]$manifestProbe.LatestVersion - $manifestFetchedAt = $manifestProbe.ManifestFetchedAt.ToString('o') - Write-Host ("Latest released solution version: {0} (YYMM={1}); support window anchored on Microsoft manifest." -f $latestReleasedVersion, $latestReleasedYymm) -ForegroundColor Green - } catch { - $manifestError = $_.Exception.Message - Write-Host "::warning::Failed to query $manifestUrl - falling back to fleet-observed top-6 YYMM window. Error: $manifestError" - $observedYymms = @($versionDistribution | ForEach-Object { - $parts = ([string]$_.Version) -split '\.' - if ($parts.Count -ge 2) { $parts[1] } else { '' } - } | Where-Object { $_ -match '^[0-9]{4}$' } | Sort-Object -Unique) - $supportedYymms = @($observedYymms | Sort-Object -Descending | Select-Object -First 6) - } - $supportedCount = 0 - $unsupportedCount = 0 - $unknownVersionCount = 0 - foreach ($v in $versionDistribution) { - $parts = ([string]$v.Version) -split '\.' - $yymm = if ($parts.Count -ge 2) { $parts[1] } else { '' } - $supportStatus = if ($yymm -match '^[0-9]{4}$') { - if ($supportedYymms -contains $yymm) { 'Supported' } else { 'Unsupported' } - } else { 'Unknown' } - $v | Add-Member -MemberType NoteProperty -Name Yymm -Value $yymm -Force - $v | Add-Member -MemberType NoteProperty -Name SupportStatus -Value $supportStatus -Force - switch ($supportStatus) { - 'Supported' { $supportedCount += [int]$v.Count } - 'Unsupported' { $unsupportedCount += [int]$v.Count } - 'Unknown' { $unknownVersionCount += [int]$v.Count } - } - } - $supportedYymmWindow = if ($supportedYymms.Count -gt 0) { ($supportedYymms -join ',') } else { '(none)' } - - Write-Host "" - Write-Host "Fleet Version Distribution ($distinctVersions distinct version(s) across $totalTests cluster(s)):" -ForegroundColor Cyan - Write-Host ("Support source: {0}" -f $supportSource) - if ($latestReleasedYymm) { - Write-Host ("Latest released YYMM (Microsoft manifest): {0} ({1})" -f $latestReleasedYymm, $latestReleasedVersion) - } - Write-Host ("Supported YYMM window (top {0}): {1}" -f $supportedYymms.Count, $supportedYymmWindow) - foreach ($v in $versionDistribution) { - Write-Host (" {0,-30} {1,5} cluster(s) ({2,5}%) [{3}]" -f $v.Version, $v.Count, $v.Percentage, $v.SupportStatus) - } - - # Build XML using string concatenation (avoids YAML parser issues with here-strings) - $xmlContent = '' + "`n" - $xmlContent += '' + "`n" - - # Emit Fleet Version Distribution as the FIRST testsuite so - # JUnit consumers (dorny/test-reporter, Azure DevOps Test tab, etc.) - # render the "what versions are installed across the fleet" overview - # ahead of the per-cluster Critical Health Status testsuite. - # Each testcase = one distinct CurrentVersion (all marked passed). - # Per-testcase carry Count / Percentage / Clusters so - # downstream consumers can read them programmatically. - $xmlContent += " " + "`n" - $xmlContent += ' ' + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " ','>' -replace '`"','"')`"/>" + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $latestReleasedVersionEsc = ([string]$latestReleasedVersion) -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"' - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += ' ' + "`n" - foreach ($v in $versionDistribution) { - $vNameEsc = ([string]$v.Version) -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"' - $vTestName = "Version-$vNameEsc" - $vClustersEsc = ([string]$v.Clusters) -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"' - $vSystemOut = "Version: $($v.Version)`nYYMM: $($v.Yymm)`nSupportStatus: $($v.SupportStatus)`nClusters: $($v.Count)`nPercentage: $($v.Percentage)%`nClusterNames: $($v.Clusters)" - $vSystemOut = $vSystemOut -replace '&','&' -replace '<','<' -replace '>','>' - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - } - $xmlContent += " `n" - - $xmlContent += " " + "`n" - $xmlContent += ' ' + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += " " + "`n" - $xmlContent += ' ' + "`n" - - # bucket clusters into 'failed' vs 'passed' BEFORE emission so dorny/test-reporter - # renders all failed clusters first (in the run summary, in source order). Within each bucket - # clusters are sorted by ClusterName for stable, scannable output. - $failedClusters = @() - $passedClusters = @() - foreach ($cluster in $readiness) { - if ($cluster.HealthState -eq "Failure" -or ($cluster.UpdateState -in $failureStates) -or ($cluster.HasPrerequisiteUpdates -ne "")) { - $failedClusters += $cluster - } else { - $passedClusters += $cluster - } - } - $orderedClusters = @($failedClusters | Sort-Object ClusterName) + @($passedClusters | Sort-Object ClusterName) - - foreach ($cluster in $orderedClusters) { - $clusterName = $cluster.ClusterName -replace '&', '&' -replace '<', '<' -replace '>', '>' - $testName = "UpdateStatus-$clusterName" - $className = "AzureLocalFleetUpdateStatus.$($cluster.ResourceGroup)" - - # Determine test outcome - $status = "passed" - $failureMessage = "" - $failureType = "" - - if ($cluster.HealthState -eq "Failure" -or ($cluster.UpdateState -in $failureStates)) { - $status = "failed" - $failureType = if ($cluster.UpdateState -eq 'PreparationFailed') { "PreparationFailed" } else { "UpdateFailure" } - $failureMessage = "UpdateState: $($cluster.UpdateState), Health: $($cluster.HealthState)" - if ($cluster.HealthCheckFailures) { - $failureMessage += ", Issues: $($cluster.HealthCheckFailures -replace '&', '&' -replace '<', '<' -replace '>', '>')" - } - } - elseif ($cluster.HasPrerequisiteUpdates -ne "") { - $status = "failed" - $failureType = "HasPrerequisite" - $failureMessage = "Updates blocked by SBE prerequisite: $($cluster.HasPrerequisiteUpdates -replace '&', '&' -replace '<', '<' -replace '>', '>')" - if ($cluster.SBEDependency) { - $failureMessage += " - Vendor: $($cluster.SBEDependency -replace '&', '&' -replace '<', '<' -replace '>', '>')" - } - } - - # Build system-out with cluster details (using string concat to avoid YAML parser issues) - $systemOut = "Cluster: $($cluster.ClusterName)`n" - $systemOut += "Resource Group: $($cluster.ResourceGroup)`n" - $systemOut += "Subscription: $($cluster.SubscriptionId)`n" - $systemOut += "Update State: $($cluster.UpdateState)`n" - $systemOut += "Health State: $($cluster.HealthState)`n" - $systemOut += "Ready for Update: $($cluster.ReadyForUpdate)`n" - $systemOut += "All Available Updates: $($cluster.AllAvailableUpdates)`n" - $systemOut += "Ready Updates: $($cluster.ReadyUpdates)`n" - $systemOut += "Has Prerequisite Updates: $($cluster.HasPrerequisiteUpdates)`n" - $systemOut += "SBE Dependency: $($cluster.SBEDependency)`n" - $systemOut += "Recommended Update: $($cluster.RecommendedUpdate)" - $systemOut = $systemOut -replace '&', '&' -replace '<', '<' -replace '>', '>' - - $xmlContent += "`n `n" - # per-testcase for the ITSM connector. The - # dedupe key in New-AzLocalIncident is SHA256(ClusterResourceId|UpdateName|Category); - # without these three properties on every testcase the connector - # reports Action='Skipped' with "Row missing ClusterResourceId or UpdateName". - # Status is mirrored so the trigger-matrix evaluator can pick the - # right severity/category for fleet-update-status rows. - $clusterResIdEsc = ([string]$cluster.ResourceId) -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"' - $recoUpdEsc = ([string]$cluster.RecommendedUpdate) -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"' - $clusterPortalEsc = if ($cluster.ResourceId) { "https://portal.azure.com/#@/resource$($cluster.ResourceId)" -replace '&','&' -replace '"','"' } else { '' } - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - - if ($status -eq "failed") { - $xmlContent += " $failureMessage`n" - } - - $xmlContent += " `n" - } - - $xmlContent += " `n" - - # --------------------------------------------------------------- - # '📜 Update Run History and Error Details' testsuite. - # ARG-first Update Run History projection (fleet-scale) - # table. Each Failed/unresolved update run becomes a testcase with - # the verbose error details (DeepestErrMsg) as the failure body and - # Status / CurrentStep / Duration / PortalLink in system-out. The - # cmdlet's -OnlyUnresolved filter dedupes against any later Succeeded - # run on the same (cluster, update) pair (dedup aligned with the Azure portal update-runs view). - # --------------------------------------------------------------- - Write-Host "" - Write-Host "Step 3b: Collecting Update Run History + Verbose Error Details (ARG-first, fleet-scale)..." -ForegroundColor Yellow - $runFailures = @() - try { - $runFailures = @(Get-AzLocalUpdateRunFailures -State Failed -OnlyUnresolved -Since (Get-Date).AddDays(-30)) - } - catch { - Write-Warning "Get-AzLocalUpdateRunFailures threw: $($_.Exception.Message). Continuing with empty run-history section." - $runFailures = @() - } - # v0.7.90 sort key alignment: order by StartTime DESC, then ClusterName ASC - # so CSV / JSON / JUnit / markdown / base64 blob all share the same - # canonical ordering (latest run first, ties broken alphabetically). - $runFailures = @($runFailures | Sort-Object @{Expression='StartTime';Descending=$true}, @{Expression='ClusterName';Descending=$false}) - $runHistoryCount = $runFailures.Count - Write-Host "Found $runHistoryCount unresolved Failed update run(s)." -ForegroundColor $(if ($runHistoryCount -gt 0) { 'Yellow' } else { 'Green' }) - - # Export run-history rows as CSV + JSON for the artifact bundle. - $runHistoryCsv = Join-Path $outputDir "update-run-history.csv" - $runHistoryJson = Join-Path $outputDir "update-run-history.json" - if ($runHistoryCount -gt 0) { - $runFailures | - Select-Object ClusterName, UpdateName, State, Status, CurrentStep, Duration, StartTime, LastUpdated, DeepestStepName, ErrorCategory, DeepestErrMsg, UpdateRunPortalUrl, ClusterResourceId, RunId | - Export-Csv -Path $runHistoryCsv -NoTypeInformation -Force - $runFailures | ConvertTo-Json -Depth 4 | Out-File $runHistoryJson -Encoding UTF8 - } - - # v0.7.98: sum the per-row duration so the Update Run History testsuite reports a - # meaningful time= (was time="0"). Get-AzLocalUpdateRunFailures exposes DurationMinutes - # (numeric, from KQL datetime_diff('minute', EndTime, StartTime)). Convert to seconds and - # treat null/missing as 0. - $runHistoryTotalSeconds = 0 - foreach ($f in $runFailures) { - if ($f.PSObject.Properties['DurationMinutes'] -and $null -ne $f.DurationMinutes) { - [int]$rowSec = [int][math]::Round([double]$f.DurationMinutes * 60, 0) - if ($rowSec -lt 0) { $rowSec = 0 } - $runHistoryTotalSeconds += $rowSec - } - } - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - - foreach ($f in $runFailures) { - $fcName = ([string]$f.ClusterName) -replace '&','&' -replace '<','<' -replace '>','>' - $fuName = ([string]$f.UpdateName) -replace '&','&' -replace '<','<' -replace '>','>' - $fStatus = ([string]$f.Status) -replace '&','&' -replace '<','<' -replace '>','>' - $fCurStep = ([string]$f.CurrentStep) -replace '&','&' -replace '<','<' -replace '>','>' - $fDuration = [string]$f.Duration - $fStart = [string]$f.StartTime - $fLast = [string]$f.LastUpdated - $fCategory = [string]$f.ErrorCategory - $fPortal = [string]$f.UpdateRunPortalUrl - $fErr = if ($f.DeepestErrMsg) { - if ($f.DeepestErrMsg.Length -gt 4000) { $f.DeepestErrMsg.Substring(0,4000) + ' ... (truncated)' } else { $f.DeepestErrMsg } - } else { '(no error message captured)' } - $fErrEsc = $fErr -replace '&','&' -replace '<','<' -replace '>','>' - $fStackEsc = ([string]$f.StackTracePreview) -replace '&','&' -replace '<','<' -replace '>','>' - - $tcName = "$fcName - $fuName" - $tcClass = "UpdateRunHistory" - $tcSysOut = "Cluster: $fcName`nUpdate: $fuName`nUpdate State: Failed`nStatus: $fStatus`nCurrent Step: $fCurStep`nDuration: $fDuration`nTime Started: $fStart`nLast Updated: $fLast`nError Category: $fCategory`nPortal Link: $fPortal" - if ($f.StackTracePreview) { $tcSysOut += "`nStack Trace Preview: $fStackEsc" } - $tcSysOut = $tcSysOut -replace '&','&' -replace '<','<' -replace '>','>' - # v0.7.98: per-testcase time = run duration in seconds (DurationMinutes * 60). 0 only - # when StartTime/EndTime were null (rare; KQL leaves DurationMinutes null in that case). - [int]$tcSeconds = 0 - if ($f.PSObject.Properties['DurationMinutes'] -and $null -ne $f.DurationMinutes) { - $tcSeconds = [int][math]::Round([double]$f.DurationMinutes * 60, 0) - if ($tcSeconds -lt 0) { $tcSeconds = 0 } - } - - $xmlContent += "`n `n" - # properties for the ITSM connector dedupe key plus extended context - # (UpdateRunPortalUrl, ClusterPortalUrl, CurrentStep, - # Duration, ErrorCategory) so the optional incident-body template - # rendered by New-AzLocalIncident can deep-link straight into - # the SingleInstanceHistoryDetails ReactView for this failed run. - $fClusterResIdEsc = ([string]$f.ClusterResourceId) -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"' - $fClusterPortalEsc = if ($f.ClusterResourceId) { "https://portal.azure.com/#@/resource$($f.ClusterResourceId)" -replace '&','&' -replace '"','"' } else { '' } - $fPortalEsc = $fPortal -replace '&','&' -replace '"','"' - $fStatusEsc = ([string]$f.Status) -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"' - $fCurStepAttr = $fCurStep -replace '"','"' - $fDurationAttr = $fDuration -replace '"','"' - $fCategoryAttr = $fCategory -replace '"','"' - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - $xmlContent += " `n" - } - - $xmlContent += " `n" - $xmlContent += "`n" - - $xmlContent | Out-File $readinessXml -Encoding UTF8 - Write-Host "JUnit XML saved to: $readinessXml" -ForegroundColor Green - - # Step 4: Collect additional fleet data using fleet-wide capabilities - # NOTE: Readiness (Step 2) already fetches summaries and available updates internally. - # Steps 4a/4b re-export this data in dedicated CSV files for analysis. - # Use ClusterResourceIds (not ClusterNames) to avoid redundant name-to-ID resolution. - - # Build resource ID list for downstream calls (avoids per-cluster name resolution) - $fleetResourceIds = @() - if ($scope -eq 'by-update-ring' -and $updateRing) { - # For tag-based scope, get IDs from readiness results - $fleetResourceIds = @($readiness | Where-Object { $_.SubscriptionId -and $_.ResourceGroup -and $_.ClusterName } | ForEach-Object { - "/subscriptions/$($_.SubscriptionId)/resourceGroups/$($_.ResourceGroup)/providers/Microsoft.AzureStackHCI/clusters/$($_.ClusterName)" - }) - } - else { - $fleetResourceIds = @($inventory | Select-Object -ExpandProperty ResourceId) - } - - # Step 4a: Get update summary across fleet - Write-Host "" - Write-Host "Step 4a: Collecting fleet update summaries..." -ForegroundColor Yellow - - $summaryCsv = Join-Path $outputDir "update-summaries.csv" - $summaries = Get-AzLocalUpdateSummary -ClusterResourceIds $fleetResourceIds -ExportPath $summaryCsv -PassThru - Write-Host "Update summaries collected for $($summaries.Count) cluster(s)" -ForegroundColor Green - - # Step 4b: Get available updates across fleet - Write-Host "" - Write-Host "Step 4b: Collecting available updates..." -ForegroundColor Yellow - - $availableCsv = Join-Path $outputDir "available-updates.csv" - $available = Get-AzLocalAvailableUpdates -ClusterResourceIds $fleetResourceIds -ExportPath $availableCsv -PassThru - Write-Host "Found $($available.Count) available update(s) across fleet" -ForegroundColor Green - - # Step 4c: Optionally collect recent update runs using fleet-wide query - if ($includeRuns) { - Write-Host "" - Write-Host "Step 4c: Collecting recent update run history..." -ForegroundColor Yellow - - $runsCsv = Join-Path $outputDir "update-runs.csv" - $allRuns = Get-AzLocalUpdateRuns -ClusterResourceIds $fleetResourceIds -Latest -ExportPath $runsCsv -PassThru - Write-Host "Update runs collected for $($allRuns.Count) cluster(s)" -ForegroundColor Green - - # Display summary - $succeeded = @($allRuns | Where-Object { $_.State -eq 'Succeeded' }).Count - $inProgressRuns = @($allRuns | Where-Object { $_.State -eq 'InProgress' }).Count - $failedRuns = @($allRuns | Where-Object { $_.State -eq 'Failed' }).Count - Write-Host " Succeeded: $succeeded, In Progress: $inProgressRuns, Failed: $failedRuns" - } - - # Step 5: Output summary - Write-Host "" - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "Fleet Status Summary" -ForegroundColor Cyan - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "Total Clusters: $totalTests" - Write-Host "" - Write-Host "Critical Health Status (matches JUnit XML pass/fail):" -ForegroundColor White - Write-Host " Passed: $criticalHealthPassed" -ForegroundColor Green - Write-Host " Failed: $failures" -ForegroundColor $(if ($failures -gt 0) { 'Red' } else { 'Green' }) - Write-Host "" - Write-Host "Primary Status (each cluster counted once; rows sum to Total):" -ForegroundColor White - Write-Host " Up to Date: $stUpToDate" -ForegroundColor Green - Write-Host " Ready for Update: $stReadyForUpdate" -ForegroundColor Cyan - Write-Host " Update In Progress: $stInProgress" -ForegroundColor Yellow - Write-Host " SBE Prerequisite Blocked: $stSbeBlocked" -ForegroundColor $(if ($stSbeBlocked -gt 0) { 'Yellow' } else { 'Green' }) - Write-Host " Health Failure: $stHealthFailure" -ForegroundColor $(if ($stHealthFailure -gt 0) { 'Red' } else { 'Green' }) - Write-Host " Update Failed (Failed/UpdateFailed/NeedsAttention): $stUpdateFailed" -ForegroundColor $(if ($stUpdateFailed -gt 0) { 'Red' } else { 'Green' }) - Write-Host " Action Required (PreparationFailed): $stActionRequired" -ForegroundColor $(if ($stActionRequired -gt 0) { 'Red' } else { 'Green' }) - Write-Host " Needs Investigation: $stOther" -ForegroundColor $(if ($stOther -gt 0) { 'Yellow' } else { 'Green' }) - - # Set output variables for downstream steps - echo "TOTAL_CLUSTERS=$totalTests" >> $env:GITHUB_OUTPUT - echo "CRITICAL_HEALTH_PASSED=$criticalHealthPassed" >> $env:GITHUB_OUTPUT - echo "CRITICAL_HEALTH_FAILED=$failures" >> $env:GITHUB_OUTPUT - echo "UP_TO_DATE=$stUpToDate" >> $env:GITHUB_OUTPUT - echo "IN_PROGRESS=$stInProgress" >> $env:GITHUB_OUTPUT - echo "READY=$stReadyForUpdate" >> $env:GITHUB_OUTPUT - echo "SBE_BLOCKED=$stSbeBlocked" >> $env:GITHUB_OUTPUT - echo "HEALTH_FAILURE=$stHealthFailure" >> $env:GITHUB_OUTPUT - echo "UPDATE_FAILED=$stUpdateFailed" >> $env:GITHUB_OUTPUT - echo "ACTION_REQUIRED=$stActionRequired" >> $env:GITHUB_OUTPUT - echo "NEEDS_INVESTIGATION=$stOther" >> $env:GITHUB_OUTPUT - echo "FAILURES=$failures" >> $env:GITHUB_OUTPUT - echo "HAS_PREREQUISITE=$hasPrerequisite" >> $env:GITHUB_OUTPUT - echo "RUN_HISTORY_COUNT=$runHistoryCount" >> $env:GITHUB_OUTPUT - - # persist version-distribution rows as a base64 JSON blob so - # the 'Create Status Summary' step can render the markdown table - # (with SupportStatus) without re-reading the CSV. - if ($distinctVersions -gt 0) { - $vTop = $versionDistribution | Select-Object Version, Yymm, SupportStatus, Count, Percentage, Clusters - $vJson = $vTop | ConvertTo-Json -Depth 3 -Compress - $vB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($vJson)) - echo "VERSION_DIST_B64=$vB64" >> $env:GITHUB_OUTPUT - echo "VERSION_DIST_COUNT=$distinctVersions" >> $env:GITHUB_OUTPUT - echo "SUPPORTED_YYMM_WINDOW=$supportedYymmWindow" >> $env:GITHUB_OUTPUT - echo "SUPPORTED_CLUSTERS=$supportedCount" >> $env:GITHUB_OUTPUT - echo "UNSUPPORTED_CLUSTERS=$unsupportedCount" >> $env:GITHUB_OUTPUT - echo "UNKNOWN_VERSION_CLUSTERS=$unknownVersionCount" >> $env:GITHUB_OUTPUT - echo "SUPPORT_SOURCE=$supportSource" >> $env:GITHUB_OUTPUT - echo "LATEST_RELEASED_YYMM=$latestReleasedYymm" >> $env:GITHUB_OUTPUT - echo "LATEST_RELEASED_VERSION=$latestReleasedVersion" >> $env:GITHUB_OUTPUT - } else { - echo "VERSION_DIST_B64=" >> $env:GITHUB_OUTPUT - echo "VERSION_DIST_COUNT=0" >> $env:GITHUB_OUTPUT - echo "SUPPORTED_YYMM_WINDOW=" >> $env:GITHUB_OUTPUT - echo "SUPPORTED_CLUSTERS=0" >> $env:GITHUB_OUTPUT - echo "UNSUPPORTED_CLUSTERS=0" >> $env:GITHUB_OUTPUT - echo "UNKNOWN_VERSION_CLUSTERS=0" >> $env:GITHUB_OUTPUT - echo "SUPPORT_SOURCE=$supportSource" >> $env:GITHUB_OUTPUT - echo "LATEST_RELEASED_YYMM=$latestReleasedYymm" >> $env:GITHUB_OUTPUT - echo "LATEST_RELEASED_VERSION=$latestReleasedVersion" >> $env:GITHUB_OUTPUT - } - - # persist update-run-history rows as a base64 JSON blob so the - # 'Create Status Summary' step can render the markdown table without - # re-querying ARG. Truncated to top 25 failures to keep the workflow - # output env-var size sane. - # include ClusterPortalUrl so the markdown table can render - # Cluster Name as a deep link into the Azure portal cluster blade. - if ($runHistoryCount -gt 0) { - $top = $runFailures | Select-Object -First 25 ClusterName, ClusterPortalUrl, UpdateName, State, Status, CurrentStep, Duration, StartTime, LastUpdated, DeepestErrMsg, UpdateRunPortalUrl - $json = $top | ConvertTo-Json -Depth 4 -Compress - $b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json)) - echo "RUN_HISTORY_B64=$b64" >> $env:GITHUB_OUTPUT - } else { - echo "RUN_HISTORY_B64=" >> $env:GITHUB_OUTPUT - } - - # Fail the workflow if there are health failures - if ($failures -gt 0) { - Write-Host "" - Write-Warning " $failures cluster(s) have update failures or health issues!" - Write-Host "Check the detailed reports for more information." - # Note: We don't exit 1 here to allow reports to be uploaded + $params = @{ + Scope = if ($env:INPUT_SCOPE) { $env:INPUT_SCOPE } else { 'all' } + IncludeUpdateRuns = ($env:INPUT_INCLUDE_UPDATE_RUNS -ne 'false') + InstalledModuleVersion = $env:INSTALLED_MODULE_VERSION } + if ($env:INPUT_UPDATE_RING) { $params['UpdateRing'] = $env:INPUT_UPDATE_RING } + Export-AzLocalFleetUpdateStatusReport @params - name: Compute Artifact Timestamp if: always() @@ -879,230 +218,6 @@ jobs: path: ./reports/ retention-days: 90 - # Create the markdown summary FIRST so it appears at the top of the - # GitHub Actions run summary view. The Publish Test Results step (dorny) - # writes its testsuite expansion AFTER, and is configured below to only - # render failed suites/tests so large fleets (200+ clusters) do not - # produce a noisy expanded section. - - name: Create Status Summary - if: always() - shell: pwsh - run: | - $total = "${{ steps.fleet-status.outputs.TOTAL_CLUSTERS }}" - $chPassed = "${{ steps.fleet-status.outputs.CRITICAL_HEALTH_PASSED }}" - $chFailed = "${{ steps.fleet-status.outputs.CRITICAL_HEALTH_FAILED }}" - $upToDate = "${{ steps.fleet-status.outputs.UP_TO_DATE }}" - $inProgress = "${{ steps.fleet-status.outputs.IN_PROGRESS }}" - $ready = "${{ steps.fleet-status.outputs.READY }}" - $sbeBlocked = "${{ steps.fleet-status.outputs.SBE_BLOCKED }}" - $healthFailure = "${{ steps.fleet-status.outputs.HEALTH_FAILURE }}" - $updateFailed = "${{ steps.fleet-status.outputs.UPDATE_FAILED }}" - $actionRequired = "${{ steps.fleet-status.outputs.ACTION_REQUIRED }}" - $needsInvestigation = "${{ steps.fleet-status.outputs.NEEDS_INVESTIGATION }}" - $hasPrerequisite = "${{ steps.fleet-status.outputs.HAS_PREREQUISITE }}" - $versionDistB64 = "${{ steps.fleet-status.outputs.VERSION_DIST_B64 }}" - $versionDistCount = "${{ steps.fleet-status.outputs.VERSION_DIST_COUNT }}" - $supportedYymmWin = "${{ steps.fleet-status.outputs.SUPPORTED_YYMM_WINDOW }}" - $supportedClusters = "${{ steps.fleet-status.outputs.SUPPORTED_CLUSTERS }}" - $unsupportedClusters = "${{ steps.fleet-status.outputs.UNSUPPORTED_CLUSTERS }}" - $unknownVersionClusters = "${{ steps.fleet-status.outputs.UNKNOWN_VERSION_CLUSTERS }}" - $supportSource = "${{ steps.fleet-status.outputs.SUPPORT_SOURCE }}" - $latestReleasedYymm = "${{ steps.fleet-status.outputs.LATEST_RELEASED_YYMM }}" - $latestReleasedVersion = "${{ steps.fleet-status.outputs.LATEST_RELEASED_VERSION }}" - $generatedUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss UTC') - - # Render the Overall Fleet Update Status (Version Distribution) - # table FIRST so it leads the summary, matching the position of the - # 'Fleet Version Distribution' testsuite at the top of the JUnit XML. - $versionSection = '' - if ($versionDistCount -and [int]$versionDistCount -gt 0 -and $versionDistB64) { - try { - $vRows = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($versionDistB64)) | ConvertFrom-Json - $versionSection += "### Overall Fleet Update Status (Version Distribution)`n`n" - $versionSection += "_One row per distinct ``YYMM`` (year-month) derived from ``Get-AzLocalClusterUpdateReadiness.CurrentVersion``. The **Update Versions** column lists the distinct full versions installed within that YYMM with `` x `` per line. Sorted by Version (YYMM) ascending - oldest at top. Percentage is share of the $total cluster(s) in scope._`n`n" - if ($supportSource -eq 'Microsoft manifest' -and $latestReleasedYymm) { - $versionSection += "_``Support`` uses the **rolling 6-month YYMM window anchored on the Microsoft public catalog** (aka.ms/AzureEdgeUpdates). Latest released YYMM = **$latestReleasedYymm** (``$latestReleasedVersion``); supported window = ``$supportedYymmWin``. As soon as Microsoft publishes a release with a newer YYMM, the window slides forward and the oldest in-window YYMM falls out._`n`n" - $versionSection += "_**Supported** = YYMM is inside the manifest-anchored window; **Unsupported** = older YYMM; **Unknown** = ``CurrentVersion`` empty or not in ``.YYMM.X.X`` form. Always cross-check against the Azure Local lifecycle cadence and the Azure Local release information before taking action._`n`n" - } else { - $versionSection += "_``Support`` falls back to a **fleet-observed heuristic** (aka.ms/AzureEdgeUpdates was unreachable from the runner). **Supported** = YYMM is in the top-6 most-recent distinct YYMM values observed across the fleet (``$supportedYymmWin``); **Unsupported** = older YYMM; **Unknown** = ``CurrentVersion`` empty or not in ``.YYMM.X.X`` form. Always cross-check against the Azure Local lifecycle cadence and the Azure Local release information before taking action._`n`n" - } - $versionSection += "| Version | Update Versions | Support | Cluster Count | Percentage | Clusters (first 15 shown only) |`n" - $versionSection += "|---------|-----------------|---------|---------------|------------|----------------------------------|`n" - # Group rows by Yymm so the table summarises by year-month with a - # nested list of full versions installed within each YYMM. Known - # YYMM values sort ascending (oldest at top per operator request); - # malformed/empty YYMM rows render last under '(unknown)'. - $vRowsAll = @($vRows) - $yymmGroups = @($vRowsAll | Group-Object -Property Yymm) - $knownGroups = @($yymmGroups | Where-Object { $_.Name -match '^[0-9]{4}$' } | Sort-Object Name) - $unknownGroups = @($yymmGroups | Where-Object { -not ($_.Name -match '^[0-9]{4}$') }) - $orderedGroups = @($knownGroups) + @($unknownGroups) - foreach ($g in $orderedGroups) { - $yymmDisplay = if ([string]::IsNullOrWhiteSpace($g.Name)) { '(unknown)' } else { [string]$g.Name } - # Within a YYMM, newest patch level first (descending full version). - $groupRows = @($g.Group | Sort-Object -Property Version -Descending) - $totalCount = ($groupRows | Measure-Object -Property Count -Sum).Sum - $totalPct = [math]::Round((($groupRows | Measure-Object -Property Percentage -Sum).Sum), 1) - # All clusters with the same YYMM share the same SupportStatus - # (SupportStatus is derived from YYMM only) so picking the first - # row's value is safe. - $supportStatusForYymm = [string]$groupRows[0].SupportStatus - $supportEmoji = switch ($supportStatusForYymm) { - 'Supported' { '✅ Supported' } - 'Unsupported' { '⚠️ Unsupported' } - default { '❓ Unknown' } - } - # Build the multi-line 'Update Versions' cell. Each line is - # ' x '. Markdown tables render '
' - # as a line break inside a cell. - $updateVersionsCell = (($groupRows | ForEach-Object { - '{0} x {1}' -f $_.Version, $_.Count - }) -join '
') - # Union of cluster names across versions in this YYMM. Each - # cluster appears in exactly one CurrentVersion row, so a - # simple concat + sort -Unique is sufficient. - $clusterNames = @() - foreach ($r in $groupRows) { - if ($r.Clusters) { - $clusterNames += @($r.Clusters -split '; ' | Where-Object { $_ }) - } - } - $clusterNames = @($clusterNames | Sort-Object -Unique) - $clCell = if ($clusterNames.Count -le 15) { - $clusterNames -join '; ' - } else { - (($clusterNames | Select-Object -First 15) -join '; ') + (' ... (+{0} more)' -f ($clusterNames.Count - 15)) - } - $clCell = $clCell -replace '\|','\|' - $versionSection += ('| {0} | {1} | {2} | {3} | {4}% | {5} |' -f $yymmDisplay, $updateVersionsCell, $supportEmoji, $totalCount, $totalPct, $clCell) + "`n" - } - $versionSection += "`n_Support roll-up: Supported = $supportedClusters cluster(s), Unsupported = $unsupportedClusters, Unknown = $unknownVersionClusters. Anchor source: ``$supportSource``._`n" - $versionSection += "_Note: the **Clusters (first 15 shown only)** column is intentionally truncated for readability. Download ``readiness-status.csv`` from artifacts to see the full cluster-name list._`n`n" - } catch { - Write-Warning "Failed to render Fleet Version Distribution markdown table: $($_.Exception.Message)" - $versionSection = '' - } - } - - $summary = @" - ## Fleet Update Status Summary _(generated $generatedUtc)_ - - $versionSection - ### Critical Health Status (matches JUnit XML pass/fail) - - | Metric | Count | Status | - |--------|-------|--------| - | **Passed** (healthy, no failures, not SBE-blocked) | $chPassed | ✅ | - | **Failed** (HealthState=Failure OR UpdateState=Failed OR SBE prerequisite blocked) | $chFailed | $(if ([int]$chFailed -gt 0) { '❌' } else { '✅' }) | - - ### Primary Status (each cluster counted once; rows sum to Total Clusters) - - | Metric | Count | Status | - |--------|-------|--------| - | **Total Clusters** | $total | ℹ️ | - | **Up to Date** | $upToDate | ✅ | - | **Ready for Update** | $ready | 🟢 | - | **Update In Progress** | $inProgress | 🔄 | - | **SBE Prerequisite Blocked** | $sbeBlocked | $(if ([int]$sbeBlocked -gt 0) { '🟡' } else { '✅' }) | - | **Health Failure** (HealthState=Failure) | $healthFailure | $(if ([int]$healthFailure -gt 0) { '❌' } else { '✅' }) | - | **Update Failed** (Failed / UpdateFailed / NeedsAttention) | $updateFailed | $(if ([int]$updateFailed -gt 0) { '❌' } else { '✅' }) | - | **Action Required** (PreparationFailed) | $actionRequired | $(if ([int]$actionRequired -gt 0) { '❌' } else { '✅' }) | - | **Needs Investigation** | $needsInvestigation | $(if ([int]$needsInvestigation -gt 0) { '⚠️' } else { '✅' }) | - - Priority cascade used to bucket each cluster exactly once: - Update Failed > Action Required > Health Failure > SBE Prerequisite Blocked > Update In Progress > Ready for Update > Up to Date > Needs Investigation. - "@ - - # Actions Required section - $actions = @() - if ([int]$updateFailed -gt 0) { $actions += "- **$updateFailed cluster(s) have UpdateState in (Failed, UpdateFailed, NeedsAttention)** - Review the most recent update run in Azure Portal and the ``update-runs.csv`` artifact for the failure detail." } - if ([int]$actionRequired -gt 0) { $actions += "- **$actionRequired cluster(s) have UpdateState=PreparationFailed** - Update preparation hit a blocker before the run started. Inspect the cluster activity log (most common: prerequisite-check failure, missing SBE content, ARB extension drift). Resolve before re-arming the update." } - if ([int]$healthFailure -gt 0) { $actions += "- **$healthFailure cluster(s) have HealthState=Failure** - Resolve critical health check issues in Azure Portal before retrying any updates." } - if ([int]$hasPrerequisite -gt 0) { $actions += "- **$hasPrerequisite cluster(s) blocked by SBE prerequisite** - Install the required Solution Builder Extension (SBE) update from the hardware vendor. See ``available-updates.csv`` for vendor details (Publisher, Family)." } - - if ($actions.Count -gt 0) { - $summary += "`n`n### Actions Required`n`n" - $summary += ($actions -join "`n") - } - - # Update Run History table: ARG-first view of unresolved failures. - # Cluster/Update cells link to portal; Error Details are collapsed - # behind
with HTML-escaping so stack traces stay intact. - $runHistoryCount = "${{ steps.fleet-status.outputs.RUN_HISTORY_COUNT }}" - $runHistoryB64 = "${{ steps.fleet-status.outputs.RUN_HISTORY_B64 }}" - if ($runHistoryCount -and ([int]$runHistoryCount) -gt 0 -and $runHistoryB64) { - try { - $rows = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($runHistoryB64)) | ConvertFrom-Json - # v0.7.90 dedupe: collapse same-error reruns - group by - # (ClusterName, UpdateName, DeepestErrMsg); canonical row = latest - # StartTime; older N-1 rows listed inside the
panel. - $groups = @($rows) | Group-Object -Property @{ - Expression = { "$($_.ClusterName)|$($_.UpdateName)|$($_.DeepestErrMsg)" } - } - $renderRows = @() - foreach ($g in $groups) { - $ordered = @($g.Group | Sort-Object @{ Expression = 'StartTime'; Descending = $true }) - $latest = $ordered[0] - $extras = @($ordered | Select-Object -Skip 1) - $latest | Add-Member -NotePropertyName 'EarlierOccurrences' -NotePropertyValue $extras -Force - $renderRows += $latest - } - # Preserve top-of-table = latest run across the whole fleet - $renderRows = @($renderRows | Sort-Object @{Expression='StartTime';Descending=$true}, @{Expression='ClusterName';Descending=$false}) - $totalRows = (@($rows)).Count - $groupCount = $renderRows.Count - $dedupSuffix = if ($groupCount -lt $totalRows) { " (collapsed $totalRows run(s) into $groupCount unique failure group(s); older same-error reruns are listed inside each row's 'Show error' panel)" } else { '' } - $summary += "`n`n### 📜 Update Run History and Error Details`n`n" - $summary += "_ARG-first, fleet-scale failure-detail view. Shows up to 25 most recent unresolved Failed update runs (last 30 days). Source cmdlet: ``Get-AzLocalUpdateRunFailures -State Failed -OnlyUnresolved``.$dedupSuffix._`n`n" - $summary += "| Cluster Name | Update Name | Update State | Status | Current Step | Verbose Error Details | Duration | Time Started | Last Updated |`n" - $summary += "|---|---|---|---|---|---|---|---|---|`n" - foreach ($r in $renderRows) { - $errCell = if ($r.DeepestErrMsg) { - $e = [string]$r.DeepestErrMsg - # HTML-encode so '<', '>', '&' in stack traces don't get - # parsed as tags by the renderer; escape pipes so the - # table row stays intact; collapse newlines to
so - # multi-line stack traces remain readable inside the - # collapsible block. - $e = $e -replace '&','&' -replace '<','<' -replace '>','>' - $e = $e -replace "`r`n",'
' -replace "`n",'
' -replace '\|','\|' - $extraBlock = '' - if ($r.EarlierOccurrences -and @($r.EarlierOccurrences).Count -gt 0) { - $bullets = (@($r.EarlierOccurrences) | ForEach-Object { - $dur = if ($_.Duration) { " (Duration: $($_.Duration))" } else { '' } - "• Started $($_.StartTime), Last updated $($_.LastUpdated)$dur" - }) -join '
' - $extraBlock = '

Earlier occurrences with the same error (' + (@($r.EarlierOccurrences).Count) + '):
' + $bullets - } - '
Show error
' + $e + '' + $extraBlock + '
' - } else { '_(none)_' } - $clusterCell = if ($r.ClusterPortalUrl) { '{1}' -f $r.ClusterPortalUrl, $r.ClusterName } else { $r.ClusterName } - $updCell = if ($r.UpdateRunPortalUrl) { '{1}' -f $r.UpdateRunPortalUrl, $r.UpdateName } else { $r.UpdateName } - $summary += "| $clusterCell | $updCell | $($r.State) | $($r.Status) | $($r.CurrentStep) | $errCell | $($r.Duration) | $($r.StartTime) | $($r.LastUpdated) |`n" - } - } catch { - Write-Warning "Failed to render Update Run History markdown table: $($_.Exception.Message)" - } - } - - $summary += @" - - ### Reports Available - - ``readiness-status.csv`` - Detailed cluster status for spreadsheet analysis - - ``readiness-status.json`` - Machine-readable format for integrations - - ``readiness-status.xml`` - JUnit XML for CI/CD visualization (now includes the '📜 Update Run History and Error Details' suite) - - ``cluster-inventory.csv`` - Full cluster inventory - - ``update-summaries.csv`` - Fleet-wide update state summaries - - ``available-updates.csv`` - All available updates across fleet - - ``update-runs.csv`` - Recent update run history (if enabled) - - ``update-run-history.csv`` - Verbose error details for unresolved Failed update runs (ARG-first, fleet-scale) - - ``update-run-history.json`` - Same as above in JSON form for integrations - - *Generated at $(Get-Date -Format "yyyy-MM-dd HH:mm:ss UTC")* - "@ - - $summary | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - # Publish JUnit test results AFTER the markdown summary so the "Fleet # Update Status Summary" tables are the first thing visible on the run # page. dorny renders its testsuite expansion below; list-suites and 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 9f3741ca..fda4f171 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.4' + GENERATED_AGAINST_MODULE_VERSION: '0.8.5' # 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). @@ -165,7 +165,10 @@ jobs: az extension add --name resource-graph --yes - name: Install AzLocal.UpdateManagement from PSGallery + # v0.8.5 thin-YAML: drift detection + banner + step outputs are all + # produced by Add-AzLocalPipelineVersionBanner (Public cmdlet). shell: pwsh + id: module-version run: | $ErrorActionPreference = 'Stop' $installArgs = @{ Name = 'AzLocal.UpdateManagement'; Scope = 'CurrentUser'; Force = $true; AllowClobber = $true } @@ -177,258 +180,36 @@ jobs: } Install-Module @installArgs Import-Module AzLocal.UpdateManagement -Force - $installed = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version - $generated = [version]$env:GENERATED_AGAINST_MODULE_VERSION - $latest = (Find-Module -Name AzLocal.UpdateManagement -Repository PSGallery -ErrorAction SilentlyContinue).Version - Write-Host "" - Write-Host "Module version summary" - Write-Host " Installed on runner : $installed" - Write-Host " YAML generated against : $generated" - Write-Host " Latest on PSGallery : $(if ($latest) { $latest } else { '(lookup failed - check network)' })" - Write-Host "" - # Drift signal 0: the module installed at runtime is OLDER than the version this YAML was generated against. Cmdlets, parameters, or output schemas referenced by this YAML may not exist in the installed module - emitted as a warning because this is the most likely cause of a hard runtime failure later in the job. - if ($installed -lt $generated) { - $msg = "AzLocal.UpdateManagement v$installed is OLDER than the version this workflow YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Update'." - Write-Host "::warning title=AzLocal.UpdateManagement is older than workflow YAML expects::$msg" - } - if ($installed -gt $generated) { - $msg = "Workflow YAML was generated against AzLocal.UpdateManagement v$generated but the runner installed v$installed. Refresh via 'Copy-AzLocalPipelineExample -Destination .\.github\workflows -Platform GitHub -Update'." - Write-Host "::notice title=Workflow YAML may be stale::$msg" - } - if ($latest -and ($latest -gt $installed)) { - $msg = "AzLocal.UpdateManagement v$latest is available on PSGallery; this run installed v$installed." - Write-Host "::notice title=Newer AzLocal.UpdateManagement version available on PSGallery::$msg" - } - - # v0.8.4 - one-line version banner emitted to the rendered Step - # Summary so the YAML version (GENERATED_AGAINST_MODULE_VERSION), the - # module version actually loaded on the runner, the PSGallery latest, - # and the REQUIRED_MODULE_VERSION pin status are visible at the top - # of every run summary without scrolling the agent log. Same data is - # also echoed to the console above as 'Module version summary'. - $pin = if ($env:REQUIRED_MODULE_VERSION) { "pinned to v$($env:REQUIRED_MODULE_VERSION)" } else { 'latest (fix-forward)' } - $latestStr = if ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } - $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } - elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } - elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } - else { 'in sync' } - if ($env:GITHUB_STEP_SUMMARY) { - "_Pipeline YAML v$generated | Module v$installed installed ($pin) | PSGallery latest $latestStr | $verdict_" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - } - - - name: Collect Fleet Health Failures - shell: pwsh + Add-AzLocalPipelineVersionBanner ` + -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION ` + -PinnedVersion $env:REQUIRED_MODULE_VERSION + + - name: Collect Fleet Health Status + # v0.8.5 thin-YAML: the inline run block (Get-AzLocalFleetHealthFailures + # Detail view + in-process Group-Object summary roll-up + + # Get-AzLocalFleetHealthOverview + 2-suite JUnit XML emission + + # 4-section markdown step summary written to GITHUB_STEP_SUMMARY + + # 8 step outputs) has been condensed into the Public cmdlet + # Export-AzLocalFleetHealthStatusReport. The cmdlet writes + # ./reports/*.{json,csv,xml} for the upload-artifact step and sets the + # 8 step outputs consumed by downstream jobs (e.g. ITSM connector). id: fleet-health + shell: pwsh env: INPUT_SCOPE: ${{ github.event.inputs.scope || 'all' }} INPUT_UPDATE_RING: ${{ github.event.inputs.update_ring }} INPUT_SEVERITY: ${{ github.event.inputs.severity || 'All' }} + INSTALLED_MODULE_VERSION: ${{ steps.module-version.outputs.installed_module_version }} run: | $ErrorActionPreference = 'Stop' Import-Module AzLocal.UpdateManagement -Force - - $outputDir = "./reports" - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - - $scope = $env:INPUT_SCOPE - $ring = $env:INPUT_UPDATE_RING - $severity = $env:INPUT_SEVERITY - - Write-Host "========================================" - Write-Host "Fleet Health Status Collection" - Write-Host "========================================" - Write-Host "Scope : $scope" - Write-Host "Severity : $severity" - if ($scope -eq 'by-update-ring' -and $ring) { Write-Host "UpdateRing: $ring" } - Write-Host "" - - $argSplat = @{ Severity = $severity } - if ($scope -eq 'by-update-ring' -and $ring) { $argSplat.UpdateRingTag = $ring } - - # Pull DETAIL view once. Both the JUnit emitter below and the markdown - # "Detailed Results" table consume this same in-memory collection. The - # SUMMARY view is computed in-process via Group-Object on the same data, - # so this entire pipeline issues at most TWO ARG queries (one for - # health-check rows; one optional cluster-tag filter inside the module). - $detailCsv = Join-Path $outputDir 'fleet-health-detail.csv' - $detailJson = Join-Path $outputDir 'fleet-health-detail.json' - $detail = Get-AzLocalFleetHealthFailures -View Detail @argSplat -ExportPath $detailCsv -PassThru - if (-not $detail) { $detail = @() } - # Also write the JSON copy directly from the in-memory array (no second ARG call). - $detail | ConvertTo-Json -Depth 6 | Out-File -FilePath $detailJson -Encoding utf8 - - # SUMMARY view (grouped by FailureReason, Severity). Re-aggregates the - # detail rows in PowerShell to avoid a second ARG query. - # AffectedClusters uses '; ' separator (matches cmdlet output), and - # AffectedClusterPortalUrls is the positionally-paired list of portal - # links so the markdown renderer can zip them into hyperlinks. - # Severity sorts FIRST (Critical before Warning) regardless of cluster - # count - matches the cmdlet ordering. - $summary = @() - if ($detail.Count -gt 0) { - $summary = @( - $detail | Group-Object -Property FailureReason, Severity | ForEach-Object { - $first = $_.Group | Select-Object -First 1 - $clusterList = @($_.Group | Select-Object -ExpandProperty ClusterName -Unique | Sort-Object) - $clusterPortalUrls = @( - foreach ($cn in $clusterList) { - $portalRow = $_.Group | Where-Object { $_.ClusterName -eq $cn } | Select-Object -First 1 - if ($portalRow -and $portalRow.ClusterPortalUrl) { $portalRow.ClusterPortalUrl } else { '' } - } - ) - $latest = ($_.Group | Measure-Object -Property LastOccurrence -Maximum).Maximum - [PSCustomObject]@{ - FailureReason = $first.FailureReason - Severity = $first.Severity - ClusterCount = $clusterList.Count - FailureCount = $_.Group.Count - AffectedClusters = ($clusterList -join '; ') - AffectedClusterPortalUrls = ($clusterPortalUrls -join '; ') - LatestOccurrence = $latest - Description = $first.Description - Remediation = $first.Remediation - } - } | Sort-Object @{Expression={ if($_.Severity -eq 'Critical'){1}elseif($_.Severity -eq 'Warning'){2}else{3} };Descending=$false}, - @{Expression={$_.ClusterCount};Descending=$true}, - @{Expression={$_.FailureCount};Descending=$true} - ) - } - $summaryCsv = Join-Path $outputDir 'fleet-health-summary.csv' - $summaryJson = Join-Path $outputDir 'fleet-health-summary.json' - $summary | Export-Csv -Path $summaryCsv -NoTypeInformation -Force - $summary | ConvertTo-Json -Depth 6 | Out-File -FilePath $summaryJson -Encoding utf8 - - # ------------------------------------------------------------------ - # Emit JUnit XML pivoted by Severity -> FailureReason -> Cluster, so - # dorny test-reporter shows a two-level tree (Critical/Warning suite, - # each FailureReason as a test, each affected cluster in ). - # ------------------------------------------------------------------ - $xmlPath = Join-Path $outputDir 'fleet-health-status.xml' - $criticalDetail = @($detail | Where-Object { $_.Severity -eq 'Critical' }) - $warningDetail = @($detail | Where-Object { $_.Severity -eq 'Warning' }) - $totalClusters = @($detail | Select-Object -ExpandProperty ClusterName -Unique).Count - $totalFailures = $detail.Count - $criticalCount = $criticalDetail.Count - $warningCount = $warningDetail.Count - - function Convert-XmlEscape([string]$s) { - if ($null -eq $s) { return '' } - ($s.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"').Replace("'",''')) - } - - $sb = New-Object System.Text.StringBuilder - [void]$sb.AppendLine('') - [void]$sb.AppendFormat('' + "`n", $totalFailures, $totalFailures) - foreach ($severityLabel in @('Critical','Warning')) { - $rows = if ($severityLabel -eq 'Critical') { $criticalDetail } else { $warningDetail } - if (-not $rows -or $rows.Count -eq 0) { continue } - $suiteName = "[JUnit Debug] $severityLabel Health Failures" - [void]$sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $suiteName), $rows.Count) - foreach ($r in $rows) { - $caseName = "{0} :: {1}" -f $r.ClusterName, $r.FailureReason - [void]$sb.AppendFormat(' ' + "`n", - (Convert-XmlEscape $r.ClusterName), - (Convert-XmlEscape $caseName)) - # per-testcase for the ITSM connector. - # The dedupe key is SHA256(ClusterResourceId | UpdateName | Category). - # Step.9 is not about updates - so we stuff FailureReason into the - # UpdateName slot. That gives one ticket per (cluster, failing health - # check) pair, which is the intended granularity for fleet-health. - $clusterResId = if ($r.PSObject.Properties.Match('ClusterResourceId').Count -gt 0) { [string]$r.ClusterResourceId } else { '' } - $clusterPortalUrl = if ($r.PSObject.Properties.Match('ClusterPortalUrl').Count -gt 0) { [string]$r.ClusterPortalUrl } else { '' } - $targetResName = if ($r.PSObject.Properties.Match('TargetResourceName').Count -gt 0) { [string]$r.TargetResourceName } else { '' } - $targetResType = if ($r.PSObject.Properties.Match('TargetResourceType').Count -gt 0) { [string]$r.TargetResourceType } else { '' } - [void]$sb.AppendLine(' ') - [void]$sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $r.ClusterName)) - [void]$sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $clusterResId)) - [void]$sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $r.FailureReason)) - [void]$sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $r.Severity)) - [void]$sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $r.FailureReason)) - [void]$sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $r.Severity)) - [void]$sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $clusterPortalUrl)) - [void]$sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $targetResName)) - [void]$sb.AppendFormat(' ' + "`n", (Convert-XmlEscape $targetResType)) - [void]$sb.AppendLine(' ') - $msg = "{0}: {1} (last occurred {2:yyyy-MM-ddTHH:mm:ssZ})" -f $r.Severity, $r.FailureReason, $r.LastOccurrence - # failure body adds TargetResourceName, TargetResourceType, and ClusterPortalUrl - # so reviewers reading the JUnit XML (or dorny's expansion) can click straight to the - # cluster blade in the Azure portal and see which specific resource failed. - $bodyLines = @( - $r.Description, - $r.Remediation, - "ResourceGroup: $($r.ResourceGroup)", - "SubscriptionId: $($r.SubscriptionId)" - ) - if ($r.PSObject.Properties.Match('TargetResourceName').Count -gt 0 -and $r.TargetResourceName) { - $bodyLines += "TargetResourceName: $($r.TargetResourceName)" - } - if ($r.PSObject.Properties.Match('TargetResourceType').Count -gt 0 -and $r.TargetResourceType) { - $bodyLines += "TargetResourceType: $($r.TargetResourceType)" - } - if ($r.PSObject.Properties.Match('ClusterPortalUrl').Count -gt 0 -and $r.ClusterPortalUrl) { - $bodyLines += "ClusterPortalUrl: $($r.ClusterPortalUrl)" - } - $body = $bodyLines -join "`n" - [void]$sb.AppendFormat(' {2}' + "`n", - (Convert-XmlEscape $r.Severity), - (Convert-XmlEscape $msg), - (Convert-XmlEscape $body)) - [void]$sb.AppendLine(' ') - } - [void]$sb.AppendLine(' ') - } - # When there are zero failures at all, emit a single passing testcase so - # dorny renders "all green" instead of an empty/broken testsuites file. - if ($totalFailures -eq 0) { - [void]$sb.AppendLine(' ') - [void]$sb.AppendLine(' ') - [void]$sb.AppendLine(' ') + $params = @{ + Scope = if ($env:INPUT_SCOPE) { $env:INPUT_SCOPE } else { 'all' } + Severity = if ($env:INPUT_SEVERITY) { $env:INPUT_SEVERITY } else { 'All' } + InstalledModuleVersion = $env:INSTALLED_MODULE_VERSION } - [void]$sb.AppendLine('') - $sb.ToString() | Out-File -FilePath $xmlPath -Encoding utf8 - - # Step outputs for the markdown summary step below. - "TOTAL_CLUSTERS=$totalClusters" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "TOTAL_FAILURES=$totalFailures" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "CRITICAL_COUNT=$criticalCount" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "WARNING_COUNT=$warningCount" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "DISTINCT_REASONS=$($summary.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - - # ------------------------------------------------------------------ - # Fleet Health Overview (one row per cluster, ARG-first projection - # of cluster + updateSummaries for fleet-scale performance). Separate cmdlet from - # the failures view above so a fleet with zero failing checks still - # gets an overview-level rollup (last-checked age, current version, - # SBE version, Azure connection, node count). - # ------------------------------------------------------------------ - $overviewArgs = @{} - if ($scope -eq 'by-update-ring' -and $ring) { $overviewArgs.UpdateRingTag = $ring } - $overviewCsv = Join-Path $outputDir 'fleet-health-overview.csv' - $overviewJson = Join-Path $outputDir 'fleet-health-overview.json' - $overview = Get-AzLocalFleetHealthOverview @overviewArgs -ExportPath $overviewCsv -PassThru - if (-not $overview) { $overview = @() } - $overview | ConvertTo-Json -Depth 6 | Out-File -FilePath $overviewJson -Encoding utf8 - "OVERVIEW_ROWS=$($overview.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - - # surface fleet-wide totals so the summary table can report - # Total / Healthy / Unhealthy (rather than only the affected/unhealthy - # subset). 'Affected' in the failures view = clusters with at least one - # failing health check = unhealthy. Force [int] so .Count is reliable - # whether @() is 0/1/N elements (PowerShell scalar-unwrap guard). - $healthyClusters = [int](@($overview | Where-Object { $_.HealthStatus -eq 'Healthy' }).Count) - $totalInSub = [int](@($overview).Count) - "HEALTHY_CLUSTERS=$healthyClusters" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "TOTAL_IN_SUB=$totalInSub" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - - Write-Host "" - Write-Host "Fleet Health Collection complete:" - Write-Host " Total clusters in scope : $totalInSub" - Write-Host " Healthy clusters : $healthyClusters" - Write-Host " Unhealthy clusters : $totalClusters" - Write-Host " Total failing checks : $totalFailures (Critical=$criticalCount, Warning=$warningCount)" - Write-Host " Distinct failure reasons: $($summary.Count)" - Write-Host " Overview rows : $($overview.Count)" + if ($env:INPUT_UPDATE_RING) { $params['UpdateRing'] = $env:INPUT_UPDATE_RING } + Export-AzLocalFleetHealthStatusReport @params - name: Compute Artifact Timestamp if: always() @@ -452,235 +233,6 @@ jobs: # Actions run summary view. Publish Test Results (dorny) runs AFTER and is # configured with list-suites/list-tests=failed so the testsuite expansion # stays compact for large fleets (only failing rows are rendered). - - name: Create Fleet Health Summary - if: always() - shell: pwsh - run: | - $total = "${{ steps.fleet-health.outputs.TOTAL_CLUSTERS }}" - $failures = "${{ steps.fleet-health.outputs.TOTAL_FAILURES }}" - $crit = "${{ steps.fleet-health.outputs.CRITICAL_COUNT }}" - $warn = "${{ steps.fleet-health.outputs.WARNING_COUNT }}" - $reasons = "${{ steps.fleet-health.outputs.DISTINCT_REASONS }}" - # fleet-wide healthy/total counts (Get-AzLocalFleetHealthOverview). - $healthy = "${{ steps.fleet-health.outputs.HEALTHY_CLUSTERS }}" - $totalInSub = "${{ steps.fleet-health.outputs.TOTAL_IN_SUB }}" - - $summaryRows = @() - if (Test-Path './reports/fleet-health-summary.csv') { - $summaryRows = @(Import-Csv -Path './reports/fleet-health-summary.csv') - } - $detailRows = @() - if (Test-Path './reports/fleet-health-detail.csv') { - $detailRows = @(Import-Csv -Path './reports/fleet-health-detail.csv') - } - $overviewRows = @() - if (Test-Path './reports/fleet-health-overview.csv') { - $overviewRows = @(Import-Csv -Path './reports/fleet-health-overview.csv') - } - - $md = @" - ## Fleet Health Status Summary - - | Metric | Count | - |--------|-------| - | **Total Clusters in Subscription** | $totalInSub | - | **Healthy Clusters** | $healthy | - | **Unhealthy Clusters** | $total | - | **Total Failing Checks** | $failures | - | **Critical** | $crit | - | **Warning** | $warn | - | **Distinct Failure Reasons** | $reasons | - - > _**Healthy** / **Unhealthy** count clusters via ``Get-AzLocalFleetHealthOverview``; **Unhealthy** = at least one Critical or Warning health-check failure. **Total Failing Checks** counts individual failing checks (one cluster can contribute multiple)._ - "@ - - # Fleet Health Overview (fleet rollup, one row per cluster) - rendered - # immediately below the KPI summary table so administrators see the - # per-cluster context (Health status, Update Status, current version, - # Azure connection state, last-check timestamp + age) BEFORE diving - # into the failure breakdown. Sourced from Get-AzLocalFleetHealthOverview. - # The 'Health Check Age (days)' column surfaces clusters whose 24-hour - # health check has not run in a while (negative = no LastChecked at all). - $md += "`n### Fleet Health Overview (fleet rollup)`n" - if ($overviewRows.Count -eq 0) { - $md += "`n*No clusters returned from Get-AzLocalFleetHealthOverview.*`n" - } else { - $md += "`n| Cluster | Health | Update Status | Current Version | SBE Version | Azure Connection | Last Checked | Health Check Age (days) | Node Count |`n" - $md += "|---------|--------|---------------|------------------|--------------|------------------|---------------|--------------------------|------------|`n" - foreach ($o in ($overviewRows | Select-Object -First 100)) { - # Use HTML anchor with target="_blank" so clicking a portal - # link opens in a new tab and the operator does not lose the - # pipeline run page they are reviewing. rel="noopener noreferrer" - # is the standard hardening recommendation when opening a new - # tab from rendered output. - $clusterCell = if ($o.ClusterPortalUrl) { - '{0}' -f $o.ClusterName, $o.ClusterPortalUrl - } else { - $o.ClusterName - } - # replace bracketed labels with emoji icons (icon + text) so the - # at-a-glance Health column is visually scannable. Label is retained for - # greppability (CI log searches by 'Healthy' / 'Critical' / etc.). Uses - # literal Unicode (not GH ':name:' shortcodes) so the Azure DevOps twin - # of this pipeline renders identically. - $healthTag = switch ($o.HealthStatus) { - 'Healthy' { "$([char]0x2705) Healthy" } # white heavy check mark - 'Critical' { "$([char]0x274C) Critical" } # cross mark - 'Warning' { "$([char]0x26A0)$([char]0xFE0F) Warning" } # warning sign + VS16 - 'In progress' { "$([char]0x23F3) In progress" } # hourglass with flowing sand - 'Health check failed' { "$([char]0x274C) Failed" } - default { '[' + $o.HealthStatus + ']' } - } - $md += ('| {0} | {1} | {2} | {3} | {4} | {5} | {6} | {7} | {8} |' -f $clusterCell, $healthTag, $o.UpdateStatus, $o.CurrentVersion, $o.SbeVersion, $o.AzureConnection, $o.LastChecked, $o.HealthResultsAgeDays, $o.NodeCount) + "`n" - } - if ($overviewRows.Count -gt 100) { - $md += "`n*Showing first 100 clusters of $($overviewRows.Count); see ``fleet-health-overview.csv`` artifact for the full list.*`n" - } - } - - $md += "`n### Health Check Failures By Reason (most widespread first)`n" - - if ($summaryRows.Count -eq 0) { - $md += "`n`n*No Critical or Warning health-check failures across the fleet.*`n" - } else { - $md += "`n`n| Severity | Failure Reason | Cluster Count | Failure Count | Affected Clusters | Latest |`n" - $md += "|----------|----------------|---------------|---------------|--------------------|--------|`n" - foreach ($r in ($summaryRows | Select-Object -First 25)) { - $sevTag = if ($r.Severity -eq 'Critical') { '[Critical]' } else { '[Warning]' } - # zip AffectedClusters (names) with AffectedClusterPortalUrls (positional) - # to render markdown hyperlinks. Cap at the first 10 names then '... (+N more)' so - # widespread failures stay readable; full list is in the CSV artifact. - # keep @() OUTSIDE the 'if' so single-element arrays do not unwrap to a - # String (PowerShell scalar-unwrap bug). Without this, a row with one cluster - # rendered as the first character of the cluster name only. - $names = @(if ($r.AffectedClusters) { $r.AffectedClusters -split '; ' } else { @() }) - $urls = @(if ($r.AffectedClusterPortalUrls) { $r.AffectedClusterPortalUrls -split '; ' } else { @() }) - $linkedParts = for ($i = 0; $i -lt $names.Count; $i++) { - $n = $names[$i] - $u = if ($i -lt $urls.Count) { $urls[$i] } else { '' } - # HTML anchor with target="_blank" so portal links open in a - # new tab and don't navigate the operator away from the - # pipeline run page. - if ($u) { '{0}' -f $n, $u } else { $n } - } - $clList = if ($linkedParts.Count -le 10) { - $linkedParts -join ', ' - } else { - (($linkedParts | Select-Object -First 10) -join ', ') + (' ... (+{0} more)' -f ($linkedParts.Count - 10)) - } - $md += ('| {0} | {1} | {2} | {3} | {4} | {5} |' -f $sevTag, $r.FailureReason, $r.ClusterCount, $r.FailureCount, $clList, $r.LatestOccurrence) + "`n" - } - if ($summaryRows.Count -gt 25) { - $md += "`n*Showing top 25 failure reasons of $($summaryRows.Count); see ``fleet-health-summary.csv`` artifact for the full list.*`n" - } - } - - $md += "`n### Detailed Results (per-cluster, per-failure)`n" - - if ($detailRows.Count -eq 0) { - $md += "`n*No detail rows.*`n" - } else { - # collapsible per-cluster view: one
block per cluster. The - # carries the cluster name (linkified to portal), a severity tally - # ('[Critical] x N · [Warning] x M', non-zero tiers only, Critical first) - # and the most-recent LastOccurrence across all that cluster's rows. The - # expanded body holds the existing per-failure table. - # - # Cluster ordering: CriticalCount desc, then WarningCount desc, then - # most-recent LastOccurrence desc (worst-cluster-first). Within a cluster, - # rows sort Critical-first then LastOccurrence desc. - # - # Cap is per-CLUSTER (not per-row) so no cluster's failures are silently - # truncated. CSV artifact always has every row. - $md += "`n*Click a cluster to expand its failing health checks. Worst-affected clusters appear first.*`n" - - $detailByCluster = @( - $detailRows | Group-Object -Property ClusterName | ForEach-Object { - $rows = @($_.Group) - $crit = @($rows | Where-Object { $_.Severity -eq 'Critical' }).Count - $warn = @($rows | Where-Object { $_.Severity -eq 'Warning' }).Count - $portalRow = $rows | Where-Object { - $_.PSObject.Properties.Match('ClusterPortalUrl').Count -gt 0 -and $_.ClusterPortalUrl - } | Select-Object -First 1 - $lastOcc = ($rows | Measure-Object -Property LastOccurrence -Maximum).Maximum - [PSCustomObject]@{ - ClusterName = $_.Name - ClusterPortalUrl = if ($portalRow) { [string]$portalRow.ClusterPortalUrl } else { '' } - CriticalCount = $crit - WarningCount = $warn - LastOccurrence = $lastOcc - Rows = @( - $rows | Sort-Object @{Expression={ if($_.Severity -eq 'Critical'){1}else{2} };Descending=$false}, - @{Expression={$_.LastOccurrence};Descending=$true} - ) - } - } | Sort-Object @{Expression={$_.CriticalCount};Descending=$true}, - @{Expression={$_.WarningCount};Descending=$true}, - @{Expression={$_.LastOccurrence};Descending=$true} - ) - - $totalClusters = $detailByCluster.Count - $clustersToShow = @($detailByCluster | Select-Object -First 100) - - foreach ($cl in $clustersToShow) { - $sevParts = @() - if ($cl.CriticalCount -gt 0) { $sevParts += ('[Critical] x {0}' -f $cl.CriticalCount) } - if ($cl.WarningCount -gt 0) { $sevParts += ('[Warning] x {0}' -f $cl.WarningCount) } - $sevTally = $sevParts -join '  ·  ' - - # target="_blank" so clicking the cluster name in the - # opens the portal in a new tab and leaves the pipeline run open. - $clusterCell = if ($cl.ClusterPortalUrl) { - '{1}' -f $cl.ClusterPortalUrl, $cl.ClusterName - } else { - $cl.ClusterName - } - $lastOccStr = if ($cl.LastOccurrence) { ('{0:yyyy-MM-ddTHH:mm:ssZ}' -f $cl.LastOccurrence) } else { '-' } - - $md += "`n
`n" - $md += "$clusterCell  ·  $sevTally  ·  last $lastOccStr`n`n" - $md += "| Severity | Failure Reason | Failure Remediation | Target Resource Name | Target Resource Type | Last Occurrence | Resource Group |`n" - $md += "|----------|----------------|---------------------|----------------------|----------------------|------------------|----------------|`n" - foreach ($r in $cl.Rows) { - $sevTag = if ($r.Severity -eq 'Critical') { '[Critical]' } else { '[Warning]' } - $rem = if ($r.PSObject.Properties.Match('Remediation').Count -gt 0) { [string]$r.Remediation } else { '' } - # Remediation links open in a new tab so the operator can - # read the Microsoft Learn / docs page without losing the - # pipeline run. - $remCell = if ($rem -and $rem.StartsWith('https://')) { 'link' -f $rem } else { $rem } - $tName = if ($r.PSObject.Properties.Match('TargetResourceName').Count -gt 0) { [string]$r.TargetResourceName } else { '' } - $tType = if ($r.PSObject.Properties.Match('TargetResourceType').Count -gt 0) { [string]$r.TargetResourceType } else { '' } - $md += ('| {0} | {1} | {2} | {3} | {4} | {5} | {6} |' -f $sevTag, $r.FailureReason, $remCell, $tName, $tType, $r.LastOccurrence, $r.ResourceGroup) + "`n" - } - $md += "`n
`n" - } - - if ($totalClusters -gt 100) { - $md += "`n*Showing first 100 clusters of $totalClusters; see ``fleet-health-detail.csv`` artifact for the full list.*`n" - } - } - - $md += @" - - ### Reports Available - - ``fleet-health-detail.csv`` - one row per (cluster, failing health check) - - ``fleet-health-summary.csv`` - one row per (FailureReason, Severity); ordered Critical-first, then by ClusterCount desc - - ``fleet-health-overview.csv`` - one row per cluster (ARG-first fleet health summary) - - ``fleet-health-detail.json`` / ``fleet-health-summary.json`` / ``fleet-health-overview.json`` - same data, machine-readable - - ``fleet-health-status.xml`` - JUnit XML for CI/CD visualisation - - _Note: test sections prefixed **[JUnit Debug]** in the Tests view are a diagnostic mirror of the tables above (for CI tooling/ITSM integration). For primary readability, use this summary and the CSV artifacts._ - - *Generated at $(Get-Date -Format "yyyy-MM-dd HH:mm:ss UTC")* - "@ - - $md | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - - # Publish JUnit test results AFTER the markdown summary so the "Fleet Health - # Status Summary" tables are the first thing visible on the run page. dorny - # renders its testsuite expansion below; list-suites and list-tests are set - # to "failed" so the expansion stays compact even for fleets where many - # clusters report the same failure reason. - name: Publish JUnit Diagnostic Results uses: dorny/test-reporter@v3 if: always() @@ -788,4 +340,3 @@ jobs: path: ./reports/itsm-results.xml reporter: java-junit continue-on-error: true - diff --git a/AzLocal.UpdateManagement/AzLocal.UpdateManagement.psd1 b/AzLocal.UpdateManagement/AzLocal.UpdateManagement.psd1 index 4fd6705d..36ef40a9 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.4' + ModuleVersion = '0.8.5' # Supported PSEditions CompatiblePSEditions = @('Desktop', 'Core') @@ -96,6 +96,8 @@ 'Private/Add-AzLocalPipelineStepSummary.ps1', 'Private/Write-AzLocalPipelineNotice.ps1', 'Private/Write-AzLocalPipelineWarning.ps1', + # Generic JUnit XML emitter shared by every Public Step.* cmdlet (v0.8.5) + 'Private/New-AzLocalPipelineJUnitXml.ps1', # Public exported functions 'Public/Connect-AzLocalServicePrincipal.ps1', @@ -103,6 +105,7 @@ 'Public/Copy-AzLocalPipelineExample.ps1', 'Public/Export-AzLocalFleetState.ps1', 'Public/Get-AzLocalApplyUpdatesScheduleConfig.ps1', + 'Public/Get-AzLocalApplyUpdatesScheduleCycleCalendar.ps1', 'Public/Get-AzLocalApplyUpdatesScheduleNextFirings.ps1', 'Public/Get-AzLocalAvailableUpdates.ps1', 'Public/Get-AzLocalClusterInfo.ps1', @@ -135,7 +138,34 @@ 'Public/Update-AzLocalApplyUpdatesScheduleConfig.ps1', 'Public/Update-AzLocalPipelineExample.ps1', 'Public/Get-AzLocalFleetConnectivityStatus.ps1', - 'Public/New-AzLocalFleetConnectivityStatusSummary.ps1' + 'Public/New-AzLocalFleetConnectivityStatusSummary.ps1', + # Thin-YAML pipeline foundation (v0.8.5) + 'Public/Add-AzLocalPipelineVersionBanner.ps1', + # Thin-YAML Step.0 (v0.8.5) - Authentication validation + subscription scope + cluster reachability + 'Public/Export-AzLocalAuthValidationReport.ps1', + # Thin-YAML Step.1 (v0.8.5) - Cluster inventory + canonical CSV + operator README + step summary + 'Public/Invoke-AzLocalClusterInventory.ps1', + # Thin-YAML Step.2 (v0.8.5) - UpdateRing tag management workload (CSV validation + apply + JSON sidecar + step summary) + 'Public/Set-AzLocalClusterUpdateRingTagFromCsv.ps1', + # Thin-YAML Step.7 (v0.8.5) - In-flight update-run monitor (severity scoring + CSV + JUnit + step summary + 6 step outputs) + 'Public/Export-AzLocalUpdateRunMonitorReport.ps1', + # Thin-YAML Step.8 (v0.8.5) - Fleet update status (inventory + readiness + version distribution + 3-suite JUnit XML + step summary + 22 step outputs) + 'Public/Export-AzLocalFleetUpdateStatusReport.ps1', + # Thin-YAML Step.5 (v0.8.5) - Pre-flight Update Readiness Assessment (readiness + blocking-health JUnit + combined JUnit + 8-section markdown summary + 2 step outputs) + 'Public/Export-AzLocalClusterUpdateReadinessReport.ps1', + # Thin-YAML Step.4 (v0.8.5) - Fleet Connectivity Status (Cluster/Arc/NIC/ARB severity classification + JUnit XML via shared helper + markdown summary + 12 step outputs) + 'Public/Export-AzLocalFleetConnectivityStatusReport.ps1', + # Thin-YAML Step.3 (v0.8.5) - Apply-Updates Schedule Coverage Audit (Audit + Matrix + Recommend views + 2-suite JUnit XML + 12-row summary table + allow-list section + always-on cycle calendar + 12 step outputs) + 'Public/Export-AzLocalApplyUpdatesScheduleAudit.ps1', + # Thin-YAML Step.9 (v0.8.5) - Fleet Health Status (Detail + in-process Summary + Overview + 2-suite JUnit XML + 4-section markdown + 8 step outputs; condenses ~600-line inline run: | block in Step.9_fleet-health-status.yml on both platforms) + 'Public/Export-AzLocalFleetHealthStatusReport.ps1', + # Thin-YAML Step.6 (v0.8.5) - Apply-Updates pipeline (resolve UpdateRing from schedule + readiness gate report + readiness-gated apply + per-host apply-updates step summary + no-clusters-ready step summary + ITSM ticketing from JUnit artifact; condenses ~6 inline run: | blocks across both Step.6_apply-updates.yml pipelines into 6 testable Public cmdlets) + 'Public/Resolve-AzLocalPipelineUpdateRing.ps1', + 'Public/Export-AzLocalClusterReadinessGateReport.ps1', + 'Public/Invoke-AzLocalReadinessGatedClusterUpdate.ps1', + 'Public/Add-AzLocalApplyUpdatesStepSummary.ps1', + 'Public/Add-AzLocalNoReadyClustersStepSummary.ps1', + 'Public/Invoke-AzLocalItsmTicketingFromArtifact.ps1' ) FunctionsToExport = @( @@ -185,6 +215,8 @@ 'Get-AzLocalApplyUpdatesScheduleNextFirings', 'New-AzLocalApplyUpdatesScheduleConfig', 'Update-AzLocalApplyUpdatesScheduleConfig', + # Cycle Calendar (v0.8.5) - human-readable per-day projection of the resolver for one full cycle (or any -Days horizon), variable cycle length safe, year-boundary safe, per-ring 'next eligible date' summary + 'Get-AzLocalApplyUpdatesScheduleCycleCalendar', # Fleet Health Overview (v0.7.70) - one row per cluster, ARG-first projection of cluster + updateSummaries (fleet-scale) 'Get-AzLocalFleetHealthOverview', # Latest Released Solution Version (v0.7.70) - public manifest probe (aka.ms/AzureEdgeUpdates) that anchors the rolling YYMM support window @@ -192,7 +224,34 @@ # Fleet Connectivity Status (v0.7.79) - 4-scope connectivity audit: cluster, Arc agent, physical NIC, ARB 'Get-AzLocalFleetConnectivityStatus', # Fleet Connectivity Status Summary Renderer (v0.7.87) - markdown step-summary builder used by Step.4 GH+ADO pipelines - 'New-AzLocalFleetConnectivityStatusSummary' + 'New-AzLocalFleetConnectivityStatusSummary', + # Thin-YAML pipeline foundation (v0.8.5) - install-step version banner + drift annotations + step outputs (condenses ~50-line inline block in every Step.*.yml) + 'Add-AzLocalPipelineVersionBanner', + # Thin-YAML Step.0 (v0.8.5) - Authentication validation + subscription scope + cluster reachability (condenses ~200-line inline run: | block in Step.0_authentication-test.yml on both platforms) + 'Export-AzLocalAuthValidationReport', + # Thin-YAML Step.1 (v0.8.5) - Cluster inventory workload (condenses the inline run: | block in Step.1_inventory-clusters.yml on both platforms; writes timestamped + canonical CSV, JSON, README, and step summary) + 'Invoke-AzLocalClusterInventory', + # Thin-YAML Step.2 (v0.8.5) - UpdateRing tag management workload (validates CSV, applies tags via Set-AzLocalClusterUpdateRingTag, writes JSON sidecar + step summary) + 'Set-AzLocalClusterUpdateRingTagFromCsv', + # Thin-YAML Step.7 (v0.8.5) - In-flight update-run monitor (calls Get-AzLocalUpdateRuns -Latest -PassThru, classifies by per-step + overall elapsed + progress-status, writes CSV + JUnit XML + markdown step summary + 6 step outputs) + 'Export-AzLocalUpdateRunMonitorReport', + # Thin-YAML Step.8 (v0.8.5) - Fleet-wide Azure Local update status snapshot (inventory + readiness + Microsoft-manifest-anchored version distribution + 3-suite JUnit XML + supplementary CSVs + markdown step summary + 22 step outputs; replaces the ~830-line inline 'Collect Fleet Update Status' + 'Create Status Summary' blocks in Step.8_fleet-update-status.yml on both platforms) + 'Export-AzLocalFleetUpdateStatusReport', + # Thin-YAML Step.5 (v0.8.5) - Pre-flight Update Readiness Assessment (calls Get-AzLocalClusterUpdateReadiness + Test-AzLocalClusterHealth -BlockingOnly, writes per-check CSV + JUnit XML, merges into combined assess-readiness.xml, emits 8-section markdown step summary + 2 step outputs; replaces the ~280-line inline 'Run readiness + blocking health checks' block in Step.5_assess-update-readiness.yml on both platforms) + 'Export-AzLocalClusterUpdateReadinessReport', + # Thin-YAML Step.4 (v0.8.5) - Fleet Connectivity Status (calls Get-AzLocalFleetConnectivityStatus, classifies severity across Cluster/Arc/NIC/ARB scopes, emits JUnit XML via shared New-AzLocalPipelineJUnitXml helper, renders markdown via shared New-AzLocalFleetConnectivityStatusSummary, emits 12 lowercase step outputs; replaces the ~255-line inline 'Collect Fleet Connectivity Data' block in Step.4_fleet-connectivity-status.yml on both platforms) + 'Export-AzLocalFleetConnectivityStatusReport', + # Thin-YAML Step.3 (v0.8.5) - Apply-Updates Schedule Coverage Audit (calls Test-AzLocalApplyUpdatesScheduleCoverage Audit + Matrix + Recommend, builds 2-suite JUnit XML via shared New-AzLocalPipelineJUnitXml helper, renders summary table + Schedule/Cron detail tables + allow-list coverage + always-on Cycle calendar via Get-AzLocalApplyUpdatesScheduleCycleCalendar; emits 12 lowercase step outputs; replaces the ~220-line inline 'Run Schedule Coverage Audit' + ~210-line inline 'Create Schedule Coverage Summary' blocks in Step.3_apply-updates-schedule-audit.yml on both platforms; ALWAYS renders the Cycle calendar when -SchedulePath is supplied, fixing the v0.8.4 hasIssues-gate regression that silently dropped the calendar on clean-fleet runs) + 'Export-AzLocalApplyUpdatesScheduleAudit', + # Thin-YAML Step.9 (v0.8.5) - Fleet Health Status (calls Get-AzLocalFleetHealthFailures Detail + Get-AzLocalFleetHealthOverview, computes Summary view in-process, builds 2-suite JUnit XML via shared New-AzLocalPipelineJUnitXml helper, renders KPI / Overview / By-Reason / per-cluster collapsible markdown; emits 8 lowercase step outputs; replaces the ~600-line inline 'Collect Fleet Health Status' + 'Create Fleet Health Summary' blocks in Step.9_fleet-health-status.yml on both platforms) + 'Export-AzLocalFleetHealthStatusReport', + # Thin-YAML Step.6 (v0.8.5) - Apply-Updates pipeline (6 cmdlets that condense ~430 lines of inline run: | blocks across both Step.6_apply-updates.yml pipelines into testable, host-aware Public cmdlets; preserves byte-for-byte parity of all markdown summaries, per-host icon literals, and ADO task.logissue warning/error lines) + 'Resolve-AzLocalPipelineUpdateRing', + 'Export-AzLocalClusterReadinessGateReport', + 'Invoke-AzLocalReadinessGatedClusterUpdate', + 'Add-AzLocalApplyUpdatesStepSummary', + 'Add-AzLocalNoReadyClustersStepSummary', + 'Invoke-AzLocalItsmTicketingFromArtifact' ) # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. @@ -221,20 +280,19 @@ # ReleaseNotes of this module ReleaseNotes = @' -## Version 0.8.4 - Step.3 advisor enhancements (NoWindowTag CSV remediation + Cycle calendar + Exclusion windows summary) + Step.6 per-cluster Step Summary + Node 24 opt-in + version banner on every install step + RBAC custom role renamed to `Azure Stack HCI Update Operator (custom)` - -Step.3 advisor enhancement release. No public API removed. One new optional parameter on `Test-AzLocalApplyUpdatesScheduleCoverage`: `-ClusterCsvPath `. Two new informational sections on `-View Recommend` that render whenever `-SchedulePath` is supplied (Step.3 yml now passes it). All v0.8.3 behaviour preserved unchanged. - -- **Enhancement A - NoWindowTag CSV remediation (new `-ClusterCsvPath` parameter).** When the operator supplies the path to the source-controlled cluster inventory CSV (the file Step.2 consumes - default `config/ClusterUpdateRings.csv`), `-View Recommend` emits a new `## Action required - NoWindowTag remediation` section. For each cluster that has an `UpdateRing` tag but no `UpdateStartWindow` tag, the advisor proposes a **peer-derived `UpdateStartWindow` value** (mode of peers' values in the same ring) plus a source label explaining the choice (unanimous / majority / sole peer / no peers). It then looks up the cluster in the CSV - **case-insensitive on `ResourceId` first, falling back to `ClusterName + ResourceGroup`** for older CSVs that pre-date the `ResourceId` column - and tells the operator either to edit the `UpdateStartWindow` cell on that row, or (if the cluster is absent from the CSV) to re-run Step.1 to regenerate the inventory artifact and replace the source-controlled CSV with it. ARG query also projects `UpdateExclusionsWindow` so the new Enhancement C section has data to render. -- **Enhancement B - Cycle calendar (informational, auto-emits when `-SchedulePath` is supplied).** A new `## Cycle calendar - next N day(s)` table renders one row per UTC day for one full cycle (`CycleWeeks * 7` days starting today). Each row shows Date, Day-of-week, CycleWeek (`X of N`, with `(cycle wraps)` annotation on the wrap row), Eligible rings (UNION of all matching schedule rows), and effective `AllowedUpdateVersions` (or `_(no constraint)_`). Re-uses `Resolve-AzLocalCurrentUpdateRing` per-day so cycle-week, UNION, and allow-list math is identical to runtime - no duplicate ISO-8601 / week-arithmetic logic to drift. Lets operators verify ring coverage matches policy and spot dead days where no ring is eligible. -- **Enhancement C - Configured exclusion windows summary (informational, auto-emits when at least one cluster has a non-empty `UpdateExclusionsWindow` tag).** A new `## Configured exclusion windows (UpdateExclusionsWindow tag)` section groups tagged clusters by `(UpdateRing, UpdateExclusionsWindow)` and renders a rollup table (cluster count + first 10 cluster names + `+N more`). Plus a per-ring breakdown of clusters that have NO `UpdateExclusionsWindow` tag (so operators can spot drift where some clusters in a ring have a blackout configured and others do not). -- **Step.3 GH + ADO yml: new `cluster_csv_path` / `clusterCsvPath` input** (default `config/ClusterUpdateRings.csv`). Validates the file exists at job time - missing file skips Enhancement A quietly (vs throwing) so repos that don't version-control a cluster CSV yet keep getting the legacy advisor output. Both yml templates now also pass `-SchedulePath` to the `-View Recommend` invocation so Enhancements B+C render in the Step Summary. -- **Pester suite updates**: drift-sync test bumped to `'0.8.4'`; new It blocks for CSV-lookup paths, peer-derivation modes, cycle-calendar math + cycle-wrap rendering + UNION rows, and exclusion-windows rollup grouping. -- **Step.6 Enhancement D - per-cluster Step Summary in `apply-updates` (GH + ADO).** `apply-updates` step persists `apply-results.json`; Summary renders new `### Cluster Actions` (icon + Status + UpdateName + Duration + Message per cluster handed to apply) + `### Clusters Skipped at Readiness Gate` (reads `readiness-report.csv`, capped at 100 rows). Eliminates JUnit-artifact downloads to identify per-cluster outcomes. -- **Node.js 24 opt-in across all 10 GH yml.** `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true` at workflow level, ahead of GitHub's platform-wide cutover. -- **Version banner appended to every install step** - one banner per yml across all 20 bundled `Step.{0..9}.yml` templates (Step.6 ADO has 2 install steps but the second deliberately skips upload to avoid duplicating the banner): `_Pipeline YAML v | Module v installed () | PSGallery latest | _` rendered in the Summary view, eliminating "is my YAML / module out of date?" investigation lag. -- **RBAC custom role renamed: `Azure Stack HCI Update Operator` -> `Azure Stack HCI Update Operator (custom)`.** Bundled JSON `Name` updated; `Actions[]` byte-identical to v0.8.3. Existing tenants: `az role definition update --role-definition ./azlocal-update-management-custom-role.json` is an in-place rename - GUID + every existing role assignment preserved; zero pipeline downtime. Reference by `roleDefinitionId` (GUID) in automation, not display name. -- **All 20 bundled `Step.{0..9}.yml` templates** bump `GENERATED_AGAINST_MODULE_VERSION` from `'0.8.3'` to `'0.8.4'`. +## 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.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 6aa73b68..680721bd 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.4' +$script:ModuleVersion = '0.8.5' $script:DefaultApiVersion = '2025-10-01' $script:DefaultLogFolder = Join-Path -Path $env:ProgramData -ChildPath 'AzLocal.UpdateManagement' @@ -307,6 +307,8 @@ Export-ModuleMember -Function @( 'Get-AzLocalApplyUpdatesScheduleNextFirings', 'New-AzLocalApplyUpdatesScheduleConfig', 'Update-AzLocalApplyUpdatesScheduleConfig', + # Cycle Calendar (v0.8.5) - human-readable per-day projection of the resolver for one full cycle (or any -Days horizon), variable cycle length safe, year-boundary safe, per-ring 'next eligible date' summary + 'Get-AzLocalApplyUpdatesScheduleCycleCalendar', # Fleet Health Overview (v0.7.70) - one row per cluster, ARG-first projection of cluster + updateSummaries 'Get-AzLocalFleetHealthOverview', # Latest Released Solution Version (v0.7.70 Phase E) - public manifest probe (aka.ms/AzureEdgeUpdates) that anchors the rolling YYMM support window in Step.6 @@ -314,5 +316,32 @@ Export-ModuleMember -Function @( # Fleet Connectivity Status (v0.7.79) - 4-scope connectivity audit: cluster, Arc agent, physical NIC, ARB 'Get-AzLocalFleetConnectivityStatus', # Fleet Connectivity Status Summary Renderer (v0.7.87) - markdown step-summary builder used by Step.4 GH+ADO pipelines - 'New-AzLocalFleetConnectivityStatusSummary' + 'New-AzLocalFleetConnectivityStatusSummary', + # Thin-YAML pipeline foundation (v0.8.5) - install-step version banner + drift annotations + step outputs (condenses ~50-line inline block in every Step.*.yml) + 'Add-AzLocalPipelineVersionBanner', + # Thin-YAML Step.0 (v0.8.5) - Authentication validation + subscription scope + cluster reachability (condenses ~200-line inline run: | block in Step.0_authentication-test.yml on both platforms) + 'Export-AzLocalAuthValidationReport', + # Thin-YAML Step.1 (v0.8.5) - Cluster inventory workload (condenses the inline run: | block in Step.1_inventory-clusters.yml on both platforms; writes timestamped + canonical CSV, JSON, README, and step summary) + 'Invoke-AzLocalClusterInventory', + # Thin-YAML Step.2 (v0.8.5) - UpdateRing tag management workload (validates CSV, applies tags via Set-AzLocalClusterUpdateRingTag, writes JSON sidecar + step summary) + 'Set-AzLocalClusterUpdateRingTagFromCsv', + # Thin-YAML Step.7 (v0.8.5) - In-flight update-run monitor (CSV + JUnit + markdown + 6 step outputs) + 'Export-AzLocalUpdateRunMonitorReport', + # Thin-YAML Step.8 (v0.8.5) - Fleet update status snapshot (inventory + readiness + version distribution + 3-suite JUnit + markdown + 22 step outputs) + 'Export-AzLocalFleetUpdateStatusReport', + # Thin-YAML Step.5 (v0.8.5) - Pre-flight Update Readiness Assessment (readiness + blocking-health JUnit + combined JUnit + 8-section markdown + 2 step outputs) + 'Export-AzLocalClusterUpdateReadinessReport', + # Thin-YAML Step.4 (v0.8.5) - Fleet Connectivity Status (Cluster/Arc/NIC/ARB severity classification + JUnit + markdown + 12 step outputs) + 'Export-AzLocalFleetConnectivityStatusReport', + # Thin-YAML Step.3 (v0.8.5) - Apply-Updates Schedule Coverage Audit (Audit + Matrix + Recommend views + 2-suite JUnit + allow-list section + always-on cycle calendar + 12 step outputs) + 'Export-AzLocalApplyUpdatesScheduleAudit', + # Thin-YAML Step.9 (v0.8.5) - Fleet Health Status (Detail + Summary + Overview + 2-suite JUnit + KPI / Overview / By-Reason / per-cluster collapsible markdown + 8 step outputs) + 'Export-AzLocalFleetHealthStatusReport', + # Thin-YAML Step.6 (v0.8.5) - Apply-Updates pipeline (6 cmdlets condensing ~430 lines of inline run: | blocks across both Step.6_apply-updates.yml pipelines) + 'Resolve-AzLocalPipelineUpdateRing', + 'Export-AzLocalClusterReadinessGateReport', + 'Invoke-AzLocalReadinessGatedClusterUpdate', + 'Add-AzLocalApplyUpdatesStepSummary', + 'Add-AzLocalNoReadyClustersStepSummary', + 'Invoke-AzLocalItsmTicketingFromArtifact' ) \ No newline at end of file diff --git a/AzLocal.UpdateManagement/CHANGELOG.md b/AzLocal.UpdateManagement/CHANGELOG.md index 83a588d8..d9664363 100644 --- a/AzLocal.UpdateManagement/CHANGELOG.md +++ b/AzLocal.UpdateManagement/CHANGELOG.md @@ -5,6 +5,102 @@ 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.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).** + +### Thin-YAML refactor across all 10 Step pipelines (Step.0 - Step.9) + +- **Goal**: condense the multi-hundred-line `run: |` PowerShell blocks that were duplicated between each Step's GitHub Actions YAML and Azure DevOps YAML into a single Public cmdlet that both pipelines invoke. Zero UI / outcome delta - step output names, artifact contents, JUnit XML, markdown summaries, exit codes, error messages, and stageDependencies bindings all preserved byte-for-byte. +- **14 new Public cmdlets added** (one per Step plus shared helpers): + - `Add-AzLocalPipelineVersionBanner` (shared) - emits the install-time drift banner used by every Step's install step. + - `Export-AzLocalAuthValidationReport` (Step.0) - + shared `New-AzLocalPipelineJUnitXml` helper. + - `Invoke-AzLocalClusterInventory` (Step.1). + - `Set-AzLocalClusterUpdateRingTagFromCsv` (Step.2). + - `Export-AzLocalApplyUpdatesScheduleAudit` (Step.3). + - `Export-AzLocalFleetConnectivityStatusReport` (Step.4). + - `Export-AzLocalClusterUpdateReadinessReport` (Step.5). + - **6 Step.6 cmdlets** (apply-updates is the largest pipeline): `Resolve-AzLocalPipelineUpdateRing`, `Export-AzLocalClusterReadinessGateReport`, `Invoke-AzLocalReadinessGatedClusterUpdate`, `Add-AzLocalApplyUpdatesStepSummary`, `Add-AzLocalNoReadyClustersStepSummary`, `Invoke-AzLocalItsmTicketingFromArtifact`. + - `Export-AzLocalUpdateRunMonitorReport` (Step.7). + - `Export-AzLocalFleetUpdateStatusReport` (Step.8). + - `Export-AzLocalFleetHealthStatusReport` (Step.9). +- **Per-host step-output naming** is owned by the cmdlet, not the YAML. GitHub Actions consumes `UPPER_SNAKE` names (e.g. `READY_COUNT`); Azure DevOps consumes `PascalCase` names (e.g. `ReadyCount`) for stageDependencies `outputs['readiness.ReadyCount']` bindings. Each cmdlet auto-detects the host (`$env:TF_BUILD` / `$env:GITHUB_ACTIONS`) and emits the correct naming convention. +- **Per-host icon style** is owned by the cmdlet: Unicode emoji on GitHub markdown summaries, GitHub-Markdown shortcodes (`:white_check_mark:` etc.) on Azure DevOps summaries. +- **Cumulative YAML reduction** across the 20 templates: approximately **9,000 lines** removed from `Automation-Pipeline-Examples/`. Step.6 alone: GitHub Actions 964 -> 480 lines (-484); Azure DevOps 985 -> 440 lines (-545). +- **Why this matters** for ongoing maintenance: + - **Single source of truth.** Logic previously duplicated between GitHub Actions YAML and Azure DevOps YAML now lives in one place. A bug fix or feature lands in the cmdlet and immediately benefits both platforms. + - **Unit-testable.** The new Public cmdlets are mocked + asserted in Pester (`Tests/AzLocal.UpdateManagement.Tests.ps1`). Previously, validating an inline `run: |` block required spinning up a pipeline run or hand-running a copy-paste of the block in PowerShell. + - **Faster CI signal.** A typo or contract change is caught by the local Pester run (~5 minutes) instead of waiting for a pipeline run + artifact inspection on Azure. + - **Cleaner diffs.** YAML reviewers see "one step calls one cmdlet" instead of 300 lines of inline PowerShell; reviewers can focus on YAML wiring (triggers, env, artifacts, OIDC) while cmdlet behaviour is reviewed under the unit tests. + - **Discoverability.** Operators running the module interactively now have a documented `Get-Help -AzLocal*` for what was previously trapped inside a YAML `run:` block. + - **Re-usable building blocks.** `Add-AzLocalPipelineVersionBanner`, `Set-AzLocalPipelineOutput`, `Add-AzLocalPipelineStepSummary`, `Write-AzLocalPipelineWarning`, `New-AzLocalPipelineJUnitXml` are shared across every Step cmdlet - the next pipeline doesn't re-invent host-detection, step-output emission, JUnit framing, or markdown rendering. +- **One semantically-significant fix shaken out during testing**: `Invoke-AzLocalReadinessGatedClusterUpdate`'s seven-counter emitter was originally defined as `$emitCounters = { ... }.GetNewClosure()`. `.GetNewClosure()` rebinds the scriptblock to the CALLER's SessionState - which makes Private (non-exported) module functions invisible from inside the closure. Removing `.GetNewClosure()` (lexical scoping already gives the inline scriptblock access to the surrounding `$n*` variables when invoked via `& $emitCounters` from the same function) restored access to the private `Set-AzLocalPipelineOutput` function. Pester regression guards are now in place for both GH and ADO short-circuit paths. +- **Pester suite growth**: ~190 new It blocks across the 14 new cmdlets (parameter shape + per-host short-circuit + host-aware naming + error-path guards). Full local suite: **1069 passed, 0 failed, 1 skipped, ~4m38s**. Function-count drift test bumped from 35 -> 55 in two sites. +- **Backwards compatibility**: every Step pipeline keeps its filename, name field, trigger block (cron / workflow_dispatch / schedules / parameters), env block, concurrency block, OIDC login + Option 2 commented block, action-version pins, step IDs, artifact names, ITSM markers, and per-host step-output naming. Consumer pipelines that were running v0.8.4 YAML against v0.8.4 module continue to work against v0.8.5 module (the install-time drift banner surfaces the version skew but does not block). + +### New Step.6 manual schedule-file inputs (`use_schedule_file` / `useScheduleFile` + `resolve_for_date_utc` / `resolveForDateUtc`) + +- **Problem**: in v0.8.4 and earlier, a manual `workflow_dispatch` / "Run pipeline" of Step.6 ALWAYS used the operator's `update_ring` / `updateRing` parameter verbatim and ignored `apply-updates-schedule.yml`. Operators wanting 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 had no clean way to do so - they had to hand-mirror what the resolver would have picked. +- **Fix - GH `workflow_dispatch.inputs`**: + - `use_schedule_file` (choice `'false'`/`'true'`, default `'false'`) - "Resolve UpdateRing & AllowedUpdateVersions from apply-updates-schedule.yml (as a scheduled run would). Overrides update_ring." + - `resolve_for_date_utc` (string, default `''`, format `YYYY-MM-DD`) - "Only used when use_schedule_file=true. Date (UTC) to resolve the schedule for. Empty = today UTC. Useful for previewing 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. +- **Fix - ADO `parameters`**: symmetric `useScheduleFile` (boolean, default `false`) and `resolveForDateUtc` (string, default `''`) parameters, with the same 4-branch resolver decision tree in `resolveRing`. +- **Resolver decision tree (both platforms)**: + 1. Manual trigger + `use_schedule_file=false` -> use manual ring verbatim (back-compat, identical to v0.8.4). + 2. Manual trigger + `use_schedule_file=true` -> read schedule file, run `Resolve-AzLocalCurrentUpdateRing -Schedule $cfg -Now $resolveAt` for today UTC (or `resolve_for_date_utc` when set). Uses ring + AllowedUpdateVersions exactly as a scheduled run would. + 3. Manual trigger + `use_schedule_file=false` + `update_ring` empty -> throw with remediation pointing at both alternatives. + 4. Scheduled trigger -> unchanged (reads schedule for UTC now). +- **`resolve_for_date_utc` parsing**: `[datetime]::ParseExact` against `'yyyy-MM-dd'` with `AssumeUniversal | AdjustToUniversal` so the date is treated as UTC regardless of agent timezone. Invalid format throws a helpful error. +- **No change to scheduled runs**: scheduled `cron:` / `schedules:` firings still resolve via the schedule file for the current UTC moment (path 4) - no behaviour change. +- **Pester guard**: 12 new It blocks under `Context 'v0.8.5 Step.6 manual schedule-file input'` cover both GH and ADO yml: new input declarations, `update_ring` no longer required, resolver script env-var wiring, `-Now $resolveAt` plumbing through to `Resolve-AzLocalCurrentUpdateRing`, the both-empty throw, and the `yyyy-MM-dd` parsing + invalid-format throw. + +### 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 (the same `$scheduleCfg` Test-...Coverage already builds via `Get-AzLocalApplyUpdatesSchedule`). + - `-StartDate ` - default UTC today. + - `-Days ` - default = `CycleWeeks * 7` (one full cycle). ValidateRange 1-3650. + - `-AsMarkdown` - switch. Without it: object pipeline. With it: full markdown block including `## Cycle calendar - next N day(s) (one full N-week cycle)` heading + per-day table + optional `### Per-ring projection` section. + - `-IncludePerRingSummary` - switch. Adds a per-ring projection table after the per-day calendar (next eligible UTC date per ring + all eligible dates inside the horizon). + - `-ClusterRingCounts ` - case-insensitive map (`@{ Std='5'; Canary='3'; Prod='12' }`-shape). When supplied, the per-day table gains a 6th column `Clusters in ring(s)` (e.g. `3+12 (total: 15)` for UNION rows, `5` for single-ring days, blank for dead days), and the per-ring projection table gains a `Cluster count` column. +- **Object output (per day)**: `DateUtc, DayOfWeekName, CycleWeek, CycleWeeksTotal, CycleWeekLabel, IsCycleWrap, Rings, UpdateRingValue, AllowedUpdateVersions, AllowedUpdateVersionsValue, AllowedUpdateVersionsSource, MatchedRowCount, IsDeadDay, Reason`. `Rings` is the UNION of all matching schedule rows for that day (deduplicated, `OrdinalIgnoreCase`). `IsCycleWrap=$true` on the day where `prev CycleWeek = CycleWeeksTotal` AND `current CycleWeek = 1`. +- **Re-uses `Resolve-AzLocalCurrentUpdateRing` per-day** so cycle-week math, UNION semantics, and AllowedUpdateVersions precedence remain identical to runtime - no duplicate ISO-8601 / week-arithmetic logic to drift away from the resolver. +- **Variable cycle length safe** (4 / 8 / 52 weeks all tested via Pester) and **year-boundary safe** (W52 -> W1 wrap on the cycle-anchor week is correctly annotated; W53 years projected correctly). +- **Defensive**: throws when `Schedule.CycleWeeks` is 0 or missing. + +### Regression fix: v0.8.4 cycle calendar silently dropped on healthy fleets + +- **Symptom**: in v0.8.4, Step.3 GH/ADO Summary on a fleet with zero advisor findings did NOT include the cycle-calendar table even though `-SchedulePath` was supplied. +- **Root cause**: the v0.8.4 Enhancement B cycle-calendar block lived inside `Test-AzLocalApplyUpdatesScheduleCoverage -View Recommend`'s snippet builder, AFTER the "any findings?" gate. When there were no findings, the `$fullSb` snippet was empty, and the Step.3 yml `if ($hasIssues -and $reco)` / `if (-not $hasIssues -and $reco)` branches at lines 503/630 both fire only when `$reco` is non-empty - so the calendar quietly disappeared on healthy fleets. +- **Fix**: the new cmdlet is invoked unconditionally whenever `$scheduleCfg` is parsed, so `$reco` is never empty when `-SchedulePath` is supplied - one of the two existing yml branches always fires. NO structural Step.3 yml change required beyond the version bump. +- **Regression guard**: new Pester test `REGRESSION: -AsMarkdown returns the calendar even when the fleet has zero findings (v0.8.4 silent-drop bug)` asserts the markdown is non-empty and contains the `## Cycle calendar` heading even when the fleet input is empty. + +### `Test-AzLocalApplyUpdatesScheduleCoverage -View Recommend` Enhancement B now delegates to the new cmdlet + +- The v0.8.4 inline cycle-calendar block (~37 lines, lines 931-967) in `Test-AzLocalApplyUpdatesScheduleCoverage.ps1` is replaced by a single call: `Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $scheduleCfg -AsMarkdown -IncludePerRingSummary -ClusterRingCounts $ringCountMap`. Wrapped in `try { ... } catch { ... }` with a `_Cycle calendar unavailable: $($_.Exception.Message)_` fallback line so a malformed schedule on a single fleet doesn't break Step.3 for the whole job. +- `$ringCountMap` is built from the live tagged `$clusters` (already in scope from the earlier ARG query) - iterates each `$c.UpdateRing.Trim()` and increments the map. The cmdlet itself stays pure (no CSV / Azure I/O) so it remains unit-testable from a synthetic schedule object. + +### New: per-ring projection section (`-IncludePerRingSummary`) + +- Renders a `### Per-ring projection` section after the per-day table. One row per ring with: ring name, next eligible UTC date inside the horizon (or `(none)`), all eligible UTC dates inside the horizon (comma-separated, first 10 + `+N more` for very long lists), and `Cluster count` (when `-ClusterRingCounts` is supplied). +- Step.3 yml passes `-IncludePerRingSummary` so the section always appears on healthy fleets. + +### Pester suite updates + +- `Module: AzLocal.UpdateManagement / Module Load / Should have version` drift test bumped to `'0.8.5'`. +- New drift assertion asserts `Get-AzLocalApplyUpdatesScheduleCycleCalendar` is present in `FunctionsToExport`. +- Two new Describe blocks (`v0.8.5 ... (object pipeline)` + `v0.8.5 ... (markdown)`) under `#region v0.8.5: Get-AzLocalApplyUpdatesScheduleCycleCalendar` cover ~28 It blocks: + - **Object pipeline** (11): default `Days` math, row shape, `CycleWeeksTotal` invariant, day-0 resolution, `IsDeadDay` on no-match, UNION semantics on multi-row Friday, `IsCycleWrap` on `W8 -> W1` rollover, 52-week cycle 364 rows + year boundary, 8-week cycle with wrap, multi-cycle horizon, defensive `CycleWeeks=0` throw. + - **Markdown** (~17): output type `[string]`, `## Cycle calendar` heading present, 5-column header (no `-ClusterRingCounts`), 6-column header (with `-ClusterRingCounts`), regression guard for v0.8.4 silent-drop, `### Per-ring projection` section structure, UNION row rendering, ClusterRingCounts column variants (single ring, multi-ring `(total: N)`, case-insensitive key match, dead-day blank), cycle-wrap `_(cycle wraps)_` annotation, year-boundary safe with 1-week cycle. +- Expected baseline shift: 882 / 0 / 1 (pre) -> ~910 / 0 / 1 (post). + +### Version pin bumped across all 20 bundled `Step.{0..9}.yml` templates + +- `GENERATED_AGAINST_MODULE_VERSION` moves from `0.8.4` to `0.8.5` across all 20 bundled `Step.{0..9}.yml` files (10 GitHub Actions + 10 Azure DevOps). The install-step drift annotation continues to surface stale YAML to operators. +- `$script:ModuleVersion` (psm1) == `ModuleVersion` (psd1) == `'0.8.5'` (test assertion); the drift guard introduced in v0.7.67 enforces this. + ## [0.8.4] - 2026-06-18 Step.3 advisor enhancement release. No public API removed; one new optional parameter on `Test-AzLocalApplyUpdatesScheduleCoverage` (`-ClusterCsvPath`); three new Recommend-view sections that render when their respective inputs are supplied. All v0.8.3 behaviour preserved unchanged. diff --git a/AzLocal.UpdateManagement/Private/New-AzLocalPipelineJUnitXml.ps1 b/AzLocal.UpdateManagement/Private/New-AzLocalPipelineJUnitXml.ps1 new file mode 100644 index 00000000..7f4b0c3b --- /dev/null +++ b/AzLocal.UpdateManagement/Private/New-AzLocalPipelineJUnitXml.ps1 @@ -0,0 +1,274 @@ +function New-AzLocalPipelineJUnitXml { + <# + .SYNOPSIS + Builds a JUnit XML document from a structured suite/testcase + description and (optionally) writes it to disk. + + .DESCRIPTION + Generic JUnit XML emitter for the v0.8.5 thin-YAML refactor. Every + Public Step.* cmdlet (Export-AzLocalAuthValidationReport, + Invoke-AzLocalClusterInventory, Export-AzLocalUpdateRunMonitorReport, + etc.) emits a JUnit XML report alongside its step-summary markdown so + downstream CI consumers (dorny/test-reporter on GitHub, PublishTestResults + on Azure DevOps) can render the report in the platform's native Tests UI. + + Pre-v0.8.5 the JUnit XML was hand-built inline in every Step.*.yml, + which meant the same StringBuilder + XML-escape + / + / scaffolding was copy-pasted into 20 yml files. + This helper centralises the scaffolding so the per-Step cmdlets only + describe the suites + testcases as PowerShell data structures - no + XML formatting in the per-Step bodies, and a single place to add + new JUnit features (e.g. ) across all Steps. + + The pre-existing Private/Export-ResultsToJUnitXml is per-cluster + update-shape (one testcase per cluster, Status -> failure / error / + skipped) used by Step.6's inline apply-updates path and is not + replaced - it remains in place for that cluster-result-row use case. + + .PARAMETER TestSuitesName + Top-level attribute. Typically the Step + display name, e.g. "Step.0 - Authentication Validation and + Subscription Scope Report". + + .PARAMETER Suites + An array of suite hashtables. Each hashtable describes one + . Recognised keys (all values are XML-escaped on emit): + Name [string] (required) testsuite name attribute + ClassName [string] (optional) default classname for testcases + that omit their own ClassName + Properties [hashtable|ordered] (optional) emits a suite-level + + block. Use [ordered] to preserve insertion order. + TestCases [object[]] (required) array of testcase hashtables + + Each testcase hashtable recognises: + Name [string] (required) testcase name attribute + ClassName [string] (optional) testcase classname attribute; + falls back to the suite ClassName, then to + the suite Name + Time [double] (optional) testcase time attribute (seconds); + default 0 + Properties [hashtable|ordered] (optional) emits a per-testcase + block (e.g. dedupe keys consumed + by the ITSM connector: ClusterName, + ClusterResourceId, UpdateName, Status, ...). + SystemOut [string] (optional) wraps in + SystemErr [string] (optional) wraps in + Failure [hashtable] (optional) emits body; + keys: Message, Type, Body + Error [hashtable] (optional) emits body; + keys: Message, Type, Body + Skipped [string] (optional) emits + (passing an empty string still emits the + element with no message attribute) + + The suite-level attributes are computed from the testcase array so + callers don't have to keep two counters in sync. + + .PARAMETER OutputPath + Optional absolute path to write the rendered XML to. The file is + written UTF-8 without BOM (matches dorny/test-reporter and ADO + PublishTestResults expectations). When omitted, the cmdlet only + returns the XML string and writes nothing. + + .PARAMETER Timestamp + Optional [datetime] used for every attribute. + Defaults to (Get-Date) at invocation time. Parameterised so unit tests + can assert against a fixed value. + + .OUTPUTS + [string] The fully-rendered JUnit XML document. When -OutputPath is + supplied, the same string is also written to disk. + + .EXAMPLE + $xml = New-AzLocalPipelineJUnitXml -TestSuitesName 'Step.0 - Auth' -Suites @( + @{ + Name = 'Authentication' + ClassName = 'Authentication' + TestCases = @( + @{ Name = 'OIDC token exchange succeeded'; SystemOut = "Tenant: $tid" } + @{ Name = "Default subscription = $subName" } + ) + } + @{ + Name = "Subscription Scope (count=$subCount)" + ClassName = 'SubscriptionScope' + TestCases = $subTestCases + } + ) -OutputPath "$reportDir/auth-report.xml" + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$TestSuitesName, + + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [object[]]$Suites, + + [Parameter(Mandatory = $false)] + [string]$OutputPath, + + [Parameter(Mandatory = $false)] + [datetime]$Timestamp = (Get-Date) + ) + + $xmlEscape = { + param([string]$Text) + if ($null -eq $Text) { return '' } + $Text -replace '&', '&' -replace '<', '<' -replace '>', '>' -replace '"', '"' -replace "'", ''' + } + + $emitProperties = { + param($PropTable, [string]$Indent) + if (-not $PropTable) { return '' } + $entries = @() + if ($PropTable -is [System.Collections.IDictionary]) { + foreach ($k in $PropTable.Keys) { + $entries += ,@($k, $PropTable[$k]) + } + } + else { return '' } + if ($entries.Count -eq 0) { return '' } + $pb = [System.Text.StringBuilder]::new() + [void]$pb.AppendLine($Indent + '') + foreach ($e in $entries) { + $kEsc = & $xmlEscape ([string]$e[0]) + $vEsc = & $xmlEscape ([string]$e[1]) + [void]$pb.AppendLine($Indent + " ") + } + [void]$pb.Append($Indent + '') + $pb.AppendLine() | Out-Null + return $pb.ToString() + } + + $now = $Timestamp.ToString('yyyy-MM-ddTHH:mm:ss') + $totalTimeAcrossSuites = 0.0 + + $sb = [System.Text.StringBuilder]::new() + [void]$sb.AppendLine('') + + $suiteBlocks = [System.Text.StringBuilder]::new() + + foreach ($suite in $Suites) { + if (-not $suite -or -not $suite.ContainsKey('Name')) { + throw "New-AzLocalPipelineJUnitXml: each Suites element must be a hashtable with a 'Name' key. Received: $($suite | Out-String)" + } + $suiteName = & $xmlEscape ([string]$suite.Name) + $suiteClass = if ($suite.ContainsKey('ClassName') -and $suite.ClassName) { [string]$suite.ClassName } else { [string]$suite.Name } + $tcs = @() + if ($suite.ContainsKey('TestCases') -and $null -ne $suite.TestCases) { $tcs = @($suite.TestCases) } + + $tcCount = $tcs.Count + $tcFailures = 0 + $tcErrors = 0 + $tcSkipped = 0 + $suiteTime = 0.0 + + $caseBlocks = [System.Text.StringBuilder]::new() + foreach ($tc in $tcs) { + if (-not $tc -or -not $tc.ContainsKey('Name')) { + throw "New-AzLocalPipelineJUnitXml: each TestCases element must be a hashtable with a 'Name' key. Received: $($tc | Out-String)" + } + $tcName = & $xmlEscape ([string]$tc.Name) + $tcClass = if ($tc.ContainsKey('ClassName') -and $tc.ClassName) { [string]$tc.ClassName } else { $suiteClass } + $tcClassEsc = & $xmlEscape $tcClass + $tcTime = 0.0 + if ($tc.ContainsKey('Time') -and $null -ne $tc.Time) { + try { $tcTime = [double]$tc.Time } catch { $tcTime = 0.0 } + } + $suiteTime += $tcTime + + $hasProps = $tc.ContainsKey('Properties') -and $tc.Properties + $hasChild = ($tc.ContainsKey('Failure') -and $tc.Failure) -or ` + ($tc.ContainsKey('Error') -and $tc.Error) -or ` + ($tc.ContainsKey('Skipped')) -or ` + ($tc.ContainsKey('SystemOut') -and $tc.SystemOut) -or ` + ($tc.ContainsKey('SystemErr') -and $tc.SystemErr) -or ` + $hasProps + + if (-not $hasChild) { + [void]$caseBlocks.AppendLine(" ") + continue + } + + [void]$caseBlocks.AppendLine(" ") + + if ($hasProps) { + $propsXml = & $emitProperties $tc.Properties ' ' + if ($propsXml) { [void]$caseBlocks.Append($propsXml) } + } + + if ($tc.ContainsKey('Failure') -and $tc.Failure) { + $tcFailures++ + $fMsg = & $xmlEscape ([string]$tc.Failure.Message) + $fType = if ($tc.Failure.ContainsKey('Type') -and $tc.Failure.Type) { & $xmlEscape ([string]$tc.Failure.Type) } else { 'AssertionError' } + $fBody = if ($tc.Failure.ContainsKey('Body') -and $tc.Failure.Body) { [string]$tc.Failure.Body } else { '' } + if ($fBody) { + [void]$caseBlocks.AppendLine(" ") + } + else { + [void]$caseBlocks.AppendLine(" ") + } + } + if ($tc.ContainsKey('Error') -and $tc.Error) { + $tcErrors++ + $eMsg = & $xmlEscape ([string]$tc.Error.Message) + $eType = if ($tc.Error.ContainsKey('Type') -and $tc.Error.Type) { & $xmlEscape ([string]$tc.Error.Type) } else { 'Error' } + $eBody = if ($tc.Error.ContainsKey('Body') -and $tc.Error.Body) { [string]$tc.Error.Body } else { '' } + if ($eBody) { + [void]$caseBlocks.AppendLine(" ") + } + else { + [void]$caseBlocks.AppendLine(" ") + } + } + if ($tc.ContainsKey('Skipped')) { + $tcSkipped++ + $skMsg = & $xmlEscape ([string]$tc.Skipped) + if ($skMsg) { + [void]$caseBlocks.AppendLine(" ") + } + else { + [void]$caseBlocks.AppendLine(' ') + } + } + if ($tc.ContainsKey('SystemOut') -and $tc.SystemOut) { + [void]$caseBlocks.AppendLine(" ") + } + if ($tc.ContainsKey('SystemErr') -and $tc.SystemErr) { + [void]$caseBlocks.AppendLine(" ") + } + [void]$caseBlocks.AppendLine(' ') + } + + $totalTimeAcrossSuites += $suiteTime + [void]$suiteBlocks.AppendLine(" ") + if ($suite.ContainsKey('Properties') -and $suite.Properties) { + $suitePropsXml = & $emitProperties $suite.Properties ' ' + if ($suitePropsXml) { [void]$suiteBlocks.Append($suitePropsXml) } + } + [void]$suiteBlocks.Append($caseBlocks.ToString()) + [void]$suiteBlocks.AppendLine(' ') + } + + $suitesNameEsc = & $xmlEscape $TestSuitesName + [void]$sb.AppendLine("") + [void]$sb.Append($suiteBlocks.ToString()) + [void]$sb.AppendLine('') + + $xmlString = $sb.ToString() + + if ($OutputPath) { + $dir = Split-Path -Path $OutputPath -Parent + if ($dir -and -not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + [System.IO.File]::WriteAllText($OutputPath, $xmlString, [System.Text.UTF8Encoding]::new($false)) + } + + return $xmlString +} diff --git a/AzLocal.UpdateManagement/Public/Add-AzLocalApplyUpdatesStepSummary.ps1 b/AzLocal.UpdateManagement/Public/Add-AzLocalApplyUpdatesStepSummary.ps1 new file mode 100644 index 00000000..18ff0604 --- /dev/null +++ b/AzLocal.UpdateManagement/Public/Add-AzLocalApplyUpdatesStepSummary.ps1 @@ -0,0 +1,277 @@ +function Add-AzLocalApplyUpdatesStepSummary { + <# + .SYNOPSIS + Renders the Update Application Summary markdown for the apply-updates + job in Step.6. Replaces ~100 lines of inline `run:` summary boilerplate + in both Step.6 pipeline YAMLs. + .DESCRIPTION + v0.8.5 Step.6 thin-YAML helper. Emits the same markdown layout the + prior inline blocks produced: + - H1: 'Update Application Summary' (ADO) / H2 (GitHub - already + inside a single $GITHUB_STEP_SUMMARY file). + - Target UpdateRing line. + - 'This was a dry run' note when -DryRun is set. + - Readiness KPI table (Total / Ready). + - Results KPI table (the seven status buckets). + - Cluster Actions table read from apply-results.json (when present). + - Clusters Skipped at Readiness Gate table read from + readiness-report.csv (when present, capped at MaxSkippedRows). + - Actions Required bullet list (one bullet per non-zero blocking + bucket, with the same operator-facing remediation text). + + Per-host emoji choice (preserved byte-for-byte from the prior inline + blocks): + - GitHub Actions : Unicode emoji glyphs (U+2705 / U+274C / U+26D4 / U+23ED). + - Azure DevOps : GitHub-Markdown shortcode tokens + (:white_check_mark: / :x: / :no_entry: / + :next_track_button: / :warning:). + - Local : same as GitHub (Unicode emoji glyphs). + .PARAMETER UpdateRing + Resolved UpdateRing label (string). + .PARAMETER DryRun + Switch. When set, the dry-run note is rendered and Actions Required + is suppressed (matching prior inline behaviour on ADO). + .PARAMETER TotalCount + Readiness total count (string-or-int accepted; coerced to string for + display, to int for KPI checks). + .PARAMETER ReadyCount + Readiness ready count. + .PARAMETER Succeeded + Apply succeeded count. + .PARAMETER Skipped + Apply skipped count. + .PARAMETER Failed + Apply failed count. + .PARAMETER HealthBlocked + Apply health-blocked count. + .PARAMETER ScheduleBlocked + Apply schedule-blocked count. + .PARAMETER SideloadedBlocked + Apply sideloaded-blocked count. + .PARAMETER ExcludedByTag + Apply excluded-by-tag count. + .PARAMETER ApplyResultsJsonPath + Path to apply-results.json written by Invoke-AzLocalReadinessGatedClusterUpdate. + When the file is missing the Cluster Actions table is omitted. + .PARAMETER ReadinessCsvPath + Path to readiness-report.csv. When missing the Clusters Skipped table + is omitted. + .PARAMETER MaxClusterActionRows + Maximum cluster action rows rendered. Default 200 (the prior inline + block had no explicit cap - the entire list was rendered). + .PARAMETER MaxSkippedRows + Maximum skipped-cluster rows rendered. Default 100 (matches prior). + .PARAMETER SummaryFileName + Per-task markdown filename (ADO/Local only). Default + 'azlocal-step6-apply-summary.md'. + .PARAMETER PassThru + Returns PSCustomObject with: SummaryPath, ActionsRequiredCount, + ClusterActionsRendered, SkippedClustersRendered. + .NOTES + Author : AzLocal.UpdateManagement + Version : 0.8.5 (Step.6 thin-YAML port) + #> + [CmdletBinding()] + [OutputType([void])] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$UpdateRing, + + [switch]$DryRun, + + [Parameter(Mandatory = $false)] [object]$TotalCount = 0, + [Parameter(Mandatory = $false)] [object]$ReadyCount = 0, + [Parameter(Mandatory = $false)] [object]$Succeeded = 0, + [Parameter(Mandatory = $false)] [object]$Skipped = 0, + [Parameter(Mandatory = $false)] [object]$Failed = 0, + [Parameter(Mandatory = $false)] [object]$HealthBlocked = 0, + [Parameter(Mandatory = $false)] [object]$ScheduleBlocked = 0, + [Parameter(Mandatory = $false)] [object]$SideloadedBlocked = 0, + [Parameter(Mandatory = $false)] [object]$ExcludedByTag = 0, + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string]$ApplyResultsJsonPath = '', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string]$ReadinessCsvPath = '', + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 10000)] + [int]$MaxClusterActionRows = 200, + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 10000)] + [int]$MaxSkippedRows = 100, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SummaryFileName = 'azlocal-step6-apply-summary.md', + + [switch]$PassThru + ) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + $pipelineHost = Get-AzLocalPipelineHost + $useShortcodeIcons = ($pipelineHost -eq 'AzureDevOps') + + # Unicode emoji glyphs (GH/Local) vs GitHub-Markdown shortcodes (ADO). + $iconSuccess = if ($useShortcodeIcons) { ':white_check_mark:' } else { [string][char]0x2705 } + $iconFail = if ($useShortcodeIcons) { ':x:' } else { [string][char]0x274C } + $iconBlock = if ($useShortcodeIcons) { ':no_entry:' } else { [string][char]0x26D4 } + $iconSkip = if ($useShortcodeIcons) { ':next_track_button:' } else { ([string][char]0x23ED + [string][char]0xFE0F) } + $iconWarn = if ($useShortcodeIcons) { ':warning:' } else { ([string][char]0x26A0 + [string][char]0xFE0F) } + + $headingLevel = if ($pipelineHost -eq 'AzureDevOps') { '#' } else { '##' } + + # Coerce numeric inputs. + $totalInt = [int]([string]$TotalCount) + $readyInt = [int]([string]$ReadyCount) + $succeededInt = [int]([string]$Succeeded) + $skippedInt = [int]([string]$Skipped) + $failedInt = [int]([string]$Failed) + $healthBlockedInt = [int]([string]$HealthBlocked) + $scheduleBlockedInt = [int]([string]$ScheduleBlocked) + $sideloadedInt = [int]([string]$SideloadedBlocked) + $excludedTagInt = [int]([string]$ExcludedByTag) + + $sb = New-Object System.Text.StringBuilder + + [void]$sb.AppendLine("$headingLevel Update Application Summary") + [void]$sb.AppendLine() + [void]$sb.AppendLine("**Target UpdateRing:** $UpdateRing") + [void]$sb.AppendLine() + + if ($DryRun) { + [void]$sb.AppendLine("**This was a dry run. No updates were applied.**") + [void]$sb.AppendLine() + } + + $h3 = if ($pipelineHost -eq 'AzureDevOps') { '##' } else { '###' } + + [void]$sb.AppendLine("$h3 Readiness") + [void]$sb.AppendLine() + [void]$sb.AppendLine('| Metric | Count |') + [void]$sb.AppendLine('|--------|-------|') + [void]$sb.AppendLine("| Total Clusters | $totalInt |") + [void]$sb.AppendLine("| Ready for Update | $readyInt |") + [void]$sb.AppendLine() + + [void]$sb.AppendLine("$h3 Results") + [void]$sb.AppendLine() + [void]$sb.AppendLine('| Status | Count |') + [void]$sb.AppendLine('|--------|-------|') + [void]$sb.AppendLine("| Updates Started | $succeededInt |") + [void]$sb.AppendLine("| Skipped | $skippedInt |") + [void]$sb.AppendLine("| Failed | $failedInt |") + [void]$sb.AppendLine("| Health Blocked | $healthBlockedInt |") + [void]$sb.AppendLine("| Schedule Blocked | $scheduleBlockedInt |") + [void]$sb.AppendLine("| Sideloaded Blocked | $sideloadedInt |") + [void]$sb.AppendLine("| Excluded By Tag | $excludedTagInt |") + + $actionsRendered = 0 + + # Per-cluster apply results table (from apply-results.json). + if ($ApplyResultsJsonPath -and (Test-Path -LiteralPath $ApplyResultsJsonPath)) { + $applyRows = @(Get-Content -Raw -Path $ApplyResultsJsonPath | ConvertFrom-Json) + if ($applyRows.Count -gt 0) { + [void]$sb.AppendLine() + [void]$sb.AppendLine("$h3 Cluster Actions ($($applyRows.Count) cluster(s))") + [void]$sb.AppendLine() + [void]$sb.AppendLine('| Cluster | Status | Update | Duration | Message |') + [void]$sb.AppendLine('|---|---|---|---|---|') + $startedStates = @('UpdateStarted', 'Started', 'Success') + $blockedStates = @('HealthCheckBlocked', 'ScheduleBlocked', 'SideloadedBlocked', 'ExcludedByTag', 'NotConnected') + $failedStates = @('Failed', 'Error', 'NotFound') + foreach ($r in ($applyRows | Sort-Object Status, ClusterName)) { + if ($actionsRendered -ge $MaxClusterActionRows) { break } + $st = "$($r.Status)" + $icon = if ($startedStates -contains $st) { $iconSuccess } + elseif ($failedStates -contains $st) { $iconFail } + elseif ($blockedStates -contains $st) { $iconBlock } + else { $iconSkip } + $upd = if ($r.UpdateName) { '`' + $r.UpdateName + '`' } else { '-' } + $dur = if ($r.Duration) { "$($r.Duration)" } else { '-' } + $msg = "$($r.Message)" + if ($msg.Length -gt 180) { $msg = $msg.Substring(0, 177) + '...' } + $msg = $msg -replace '\|', '\|' -replace '\r?\n', ' ' + [void]$sb.AppendLine("| ``$($r.ClusterName)`` | $icon $st | $upd | $dur | $msg |") + $actionsRendered++ + } + } + } + + # Clusters Skipped at Readiness Gate table (from readiness-report.csv). + $skippedRendered = 0 + if ($ReadinessCsvPath -and (Test-Path -LiteralPath $ReadinessCsvPath)) { + $blockedRows = @(Import-Csv -Path $ReadinessCsvPath | Where-Object { $_.ReadyForUpdate -ne 'True' }) + if ($blockedRows.Count -gt 0) { + [void]$sb.AppendLine() + [void]$sb.AppendLine("$h3 Clusters Skipped at Readiness Gate ($($blockedRows.Count) cluster(s))") + [void]$sb.AppendLine() + [void]$sb.AppendLine('_These clusters were assessed by Check Update Readiness but not handed to Apply Updates. Resolve the listed blocking reasons (or wait for them to clear) then re-run the pipeline._') + [void]$sb.AppendLine() + [void]$sb.AppendLine('| Cluster | Update State | Health | Current Version | Recommended | Blocking Reasons |') + [void]$sb.AppendLine('|---|---|---|---|---|---|') + foreach ($b in ($blockedRows | Sort-Object UpdateState, ClusterName)) { + if ($skippedRendered -ge $MaxSkippedRows) { break } + $blocking = "$($b.BlockingReasons)" + if ($blocking.Length -gt 200) { $blocking = $blocking.Substring(0, 197) + '...' } + $blocking = $blocking -replace '\|', '\|' -replace '\r?\n', ' ' + $reco = if ($b.RecommendedUpdate) { '`' + $b.RecommendedUpdate + '`' } else { '-' } + $curr = if ($b.CurrentVersion) { '`' + $b.CurrentVersion + '`' } else { '-' } + $hSt = "$($b.HealthState)" + $hCell = switch -Regex ($hSt) { + '^Success$' { "$iconSuccess $hSt"; break } + '^Warning$' { "$iconWarn $hSt"; break } + '^Failure$' { "$iconFail $hSt"; break } + default { $hSt } + } + [void]$sb.AppendLine("| ``$($b.ClusterName)`` | $($b.UpdateState) | $hCell | $curr | $reco | $blocking |") + $skippedRendered++ + } + if ($blockedRows.Count -gt $MaxSkippedRows) { + [void]$sb.AppendLine() + [void]$sb.AppendLine("_Showing first $MaxSkippedRows of $($blockedRows.Count) skipped clusters. Download the readiness-report.csv artifact for the full list._") + } + } + } + + # Actions Required section (suppressed when -DryRun was set, matching ADO + # prior behaviour). GitHub always rendered it - keep consistent: emit + # when there is at least one actionable bucket regardless of dry-run. + # NOTE: prior GH inline block rendered Actions Required even on dry-run, + # so we match GH and ALWAYS rendered when non-dry; for dry-run we skip + # on ADO (matching ADO prior behaviour) but emit on GitHub. + $shouldRenderActions = if ($DryRun -and $pipelineHost -eq 'AzureDevOps') { $false } else { $true } + $actionsNeeded = @() + if ($shouldRenderActions) { + if ($failedInt -gt 0) { $actionsNeeded += "- **$failedInt cluster(s) failed** - Review pipeline logs and cluster health in Azure Portal" } + if ($healthBlockedInt -gt 0) { $actionsNeeded += "- **$healthBlockedInt cluster(s) blocked by health checks** - Resolve critical health failures before retrying" } + if ($scheduleBlockedInt -gt 0) { $actionsNeeded += "- **$scheduleBlockedInt cluster(s) outside maintenance window** - Updates will proceed when the cluster's UpdateStartWindow schedule allows, or re-run during the maintenance window" } + if ($sideloadedInt -gt 0) { $actionsNeeded += "- **$sideloadedInt cluster(s) blocked by UpdateSideloaded=False** - Operator must stage the sideloaded payload and flip the tag to True (or run Reset-AzLocalSideloadedTag) before updates can proceed" } + if ($excludedTagInt -gt 0) { $actionsNeeded += "- **$excludedTagInt cluster(s) excluded by UpdateExcluded=True operator override** - Flip the cluster's UpdateExcluded tag to False (Azure portal or 'az tag update') once the cluster should rejoin automation" } + } + if ($actionsNeeded.Count -gt 0) { + [void]$sb.AppendLine() + [void]$sb.AppendLine("$h3 Actions Required") + [void]$sb.AppendLine() + foreach ($a in $actionsNeeded) { [void]$sb.AppendLine($a) } + } + + $summaryPath = Add-AzLocalPipelineStepSummary -Markdown $sb.ToString() -SummaryFileName $SummaryFileName + + if ($PassThru) { + return [pscustomobject]@{ + SummaryPath = $summaryPath + ActionsRequiredCount = $actionsNeeded.Count + ClusterActionsRendered = $actionsRendered + SkippedClustersRendered = $skippedRendered + } + } +} diff --git a/AzLocal.UpdateManagement/Public/Add-AzLocalNoReadyClustersStepSummary.ps1 b/AzLocal.UpdateManagement/Public/Add-AzLocalNoReadyClustersStepSummary.ps1 new file mode 100644 index 00000000..ae51b8ff --- /dev/null +++ b/AzLocal.UpdateManagement/Public/Add-AzLocalNoReadyClustersStepSummary.ps1 @@ -0,0 +1,97 @@ +function Add-AzLocalNoReadyClustersStepSummary { + <# + .SYNOPSIS + Renders the 'No Clusters Ready for Update' markdown section emitted by + the third Step.6 job (no-clusters-ready / NoClustersReady stage). + .DESCRIPTION + v0.8.5 Step.6 thin-YAML helper. Replaces the ~25-line inline `run:` + block in both Step.6 pipelines (GH job no-clusters-ready, ADO stage + NoClustersReady). + + Behaviour matches the prior inline block: + - Emits the same Markdown headings and bullet lists. + - When -TotalCount is 0: renders the "No clusters found with + UpdateRing tag value 'X'" message. + - When -TotalCount > 0: renders the "Found N cluster(s) ... but none + are ready" message + "Possible reasons" bullet list. + - On GitHub Actions ONLY: also emits Write-Warning with the same + text the prior inline block did (matches the GH no-clusters-ready + run block's `Write-Warning "No clusters ready for update in ring + ''"` final line so logs are identical). + - On Azure DevOps ONLY: emits the same task.logissue warning the + prior inline block did. + .PARAMETER UpdateRing + Resolved UpdateRing label (string; may be empty for schedule-no-row case). + .PARAMETER TotalCount + Total count of clusters discovered by the readiness query (string- + or-int accepted). 0 = no clusters found at all. + .PARAMETER SummaryFileName + Per-task markdown filename (ADO/Local). Default + 'azlocal-step6-no-ready-summary.md'. + .PARAMETER PassThru + Returns PSCustomObject with: SummaryPath. + .NOTES + Author : AzLocal.UpdateManagement + Version : 0.8.5 (Step.6 thin-YAML port) + #> + [CmdletBinding()] + [OutputType([void])] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$UpdateRing, + + [Parameter(Mandatory = $false)] + [object]$TotalCount = 0, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SummaryFileName = 'azlocal-step6-no-ready-summary.md', + + [switch]$PassThru + ) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + $pipelineHost = Get-AzLocalPipelineHost + $headingLevel = if ($pipelineHost -eq 'AzureDevOps') { '#' } else { '##' } + $totalInt = [int]([string]$TotalCount) + + $sb = New-Object System.Text.StringBuilder + [void]$sb.AppendLine("$headingLevel No Clusters Ready for Update") + [void]$sb.AppendLine() + [void]$sb.AppendLine("**Target UpdateRing:** $UpdateRing") + [void]$sb.AppendLine() + + if ($totalInt -eq 0) { + [void]$sb.AppendLine("No clusters found with UpdateRing tag value '$UpdateRing'") + } + else { + [void]$sb.AppendLine("Found $totalInt cluster(s) with UpdateRing='$UpdateRing', but none are ready for updates.") + [void]$sb.AppendLine() + [void]$sb.AppendLine('Possible reasons:') + [void]$sb.AppendLine('- Clusters may already be up to date') + [void]$sb.AppendLine('- Updates may be in progress') + [void]$sb.AppendLine('- Clusters may have health check failures') + [void]$sb.AppendLine() + [void]$sb.AppendLine('Download the readiness report artifact for details.') + } + + $summaryPath = Add-AzLocalPipelineStepSummary -Markdown $sb.ToString() -SummaryFileName $SummaryFileName + + # Preserve per-host log surfacing of the warning (byte-for-byte equivalent + # to the prior inline run-block). + switch ($pipelineHost) { + 'GitHub' { Write-Warning "No clusters ready for update in ring '$UpdateRing'" } + 'AzureDevOps' { Write-Host "##vso[task.logissue type=warning]No clusters are ready for updates in ring '$UpdateRing'" } + default { Write-Warning "No clusters ready for update in ring '$UpdateRing'" } + } + + if ($PassThru) { + return [pscustomobject]@{ + SummaryPath = $summaryPath + } + } +} diff --git a/AzLocal.UpdateManagement/Public/Add-AzLocalPipelineVersionBanner.ps1 b/AzLocal.UpdateManagement/Public/Add-AzLocalPipelineVersionBanner.ps1 new file mode 100644 index 00000000..20170e8a --- /dev/null +++ b/AzLocal.UpdateManagement/Public/Add-AzLocalPipelineVersionBanner.ps1 @@ -0,0 +1,200 @@ +function Add-AzLocalPipelineVersionBanner { + <# + .SYNOPSIS + Emits the module-version drift banner and step outputs that every + AzLocal.UpdateManagement Step.*.yml install step needs. + + .DESCRIPTION + Foundation cmdlet for the v0.8.5 thin-YAML refactor. Condenses the + ~50-line install/drift/banner block that the v0.8.4 Step.*.yml + files inline into ONE cmdlet call. The Step.*.yml install step + becomes a three-liner: + + Install-Module AzLocal.UpdateManagement -Scope CurrentUser -Force -AllowClobber + Import-Module AzLocal.UpdateManagement -Force + Add-AzLocalPipelineVersionBanner -GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION -PinnedVersion $env:REQUIRED_MODULE_VERSION + + The cmdlet: + 1. Resolves the INSTALLED module version (handling the + multi-entry Get-Module quirk when nested modules are loaded) + 2. Looks up the LATEST version on PSGallery via Find-Module + (unless -SkipPSGalleryQuery is set) + 3. Prints a "Module version summary" console block (identical + shape to the v0.8.4 inline block so log greps keep working) + 4. Emits up to three drift annotations via + Write-AzLocalPipelineWarning / Write-AzLocalPipelineNotice: + - WARNING when installed < generated (YAML newer than module) + - NOTICE when installed > generated (YAML older than module) + - NOTICE when latest > installed (newer module on PSGallery) + 5. Appends a one-line banner to the rendered step summary via + Add-AzLocalPipelineStepSummary + 6. Emits three step outputs via Set-AzLocalPipelineOutput: + installed_module_version, generated_against_version, + latest_on_psgallery + 7. Optionally returns a PSCustomObject describing the verdict + when -PassThru is set (for test harnesses + downstream steps). + + Host detection (GitHub / AzureDevOps / Local) flows through the + existing Phase 0 Private helpers - no per-host branching inside + this cmdlet. + + .PARAMETER GeneratedAgainstVersion + The module version this workflow YAML was generated against + (typically '$env:GENERATED_AGAINST_MODULE_VERSION'). Required; + must parse as a [version]. + + .PARAMETER PinnedVersion + The module version the operator pinned via the + REQUIRED_MODULE_VERSION env var / workflow input. Empty / $null + means "latest (fix-forward)". Used only for the human-readable + "pin status" segment of the rendered banner. + + .PARAMETER ModuleName + The PSGallery module to look up. Defaults to + 'AzLocal.UpdateManagement'. Parameterised so the cmdlet can be + unit-tested against a synthetic module without touching the + real Find-Module call. + + .PARAMETER PSGalleryRepositoryName + The Find-Module -Repository value. Defaults to 'PSGallery'. + + .PARAMETER SkipPSGalleryQuery + When set, the cmdlet does NOT call Find-Module. The "latest on + PSGallery" segment of the banner renders as + '(PSGallery lookup skipped)' and the step output + 'latest_on_psgallery' is the empty string. Use for offline + runners or in tests. + + .PARAMETER SummaryFileName + Forwarded to Add-AzLocalPipelineStepSummary. Defaults to + 'azlocal-version-banner.md'. Ignored on GitHub Actions (which + uses a single $env:GITHUB_STEP_SUMMARY file managed by the + runner). + + .PARAMETER PassThru + When set, returns a PSCustomObject with InstalledVersion, + GeneratedAgainstVersion, LatestOnPSGallery, PinStatus, Verdict. + + .OUTPUTS + Nothing by default (side effects only). When -PassThru is set, + returns a single PSCustomObject: + InstalledVersion [version] + GeneratedAgainstVersion [version] + LatestOnPSGallery [version] or $null + PinStatus [string] - 'pinned to vX.Y.Z' or 'latest (fix-forward)' + Verdict [string] - one of: + 'in sync' + 'YAML newer than module - check REQUIRED_MODULE_VERSION' + 'YAML older than module - run Copy-AzLocalPipelineExample -Update' + 'newer module available on PSGallery' + + .EXAMPLE + Add-AzLocalPipelineVersionBanner -GeneratedAgainstVersion '0.8.5' + + Default usage from a Step.*.yml install step. Emits drift + annotations + banner + step outputs. + + .EXAMPLE + $info = Add-AzLocalPipelineVersionBanner -GeneratedAgainstVersion '0.8.5' -PinnedVersion '0.8.5' -PassThru + if ($info.Verdict -ne 'in sync') { ... } + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^\d+\.\d+\.\d+(\.\d+)?$')] + [string]$GeneratedAgainstVersion, + + [Parameter()] + [AllowEmptyString()] + [AllowNull()] + [string]$PinnedVersion, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$ModuleName = 'AzLocal.UpdateManagement', + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$PSGalleryRepositoryName = 'PSGallery', + + [Parameter()] + [switch]$SkipPSGalleryQuery, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$SummaryFileName = 'azlocal-version-banner.md', + + [Parameter()] + [switch]$PassThru + ) + + $generated = [version]$GeneratedAgainstVersion + + $installedModule = Get-Module -Name $ModuleName | Sort-Object Version -Descending | Select-Object -First 1 + if (-not $installedModule) { + throw "Add-AzLocalPipelineVersionBanner: module '$ModuleName' is not loaded. Call Import-Module before invoking this cmdlet." + } + $installed = [version]$installedModule.Version + + $latest = $null + if (-not $SkipPSGalleryQuery) { + try { + $found = Find-Module -Name $ModuleName -Repository $PSGalleryRepositoryName -ErrorAction Stop + if ($found -and $found.Version) { + $latest = [version]$found.Version + } + } catch { + Write-Verbose "Add-AzLocalPipelineVersionBanner: Find-Module lookup failed - $($_.Exception.Message)" + } + } + + $pinStatus = if ([string]::IsNullOrWhiteSpace($PinnedVersion)) { 'latest (fix-forward)' } else { "pinned to v$PinnedVersion" } + $latestStr = if ($SkipPSGalleryQuery) { '(PSGallery lookup skipped)' } elseif ($latest) { "v$latest" } else { '(PSGallery lookup failed)' } + $verdict = if ($installed -lt $generated) { 'YAML newer than module - check REQUIRED_MODULE_VERSION' } + elseif ($installed -gt $generated) { 'YAML older than module - run Copy-AzLocalPipelineExample -Update' } + elseif ($latest -and ($latest -gt $installed)) { 'newer module available on PSGallery' } + else { 'in sync' } + + Write-Host '' + Write-Host 'Module version summary' + Write-Host " Installed on runner : $installed" + Write-Host " YAML generated against : $generated" + Write-Host " Latest on PSGallery : $(if ($SkipPSGalleryQuery) { '(skipped)' } elseif ($latest) { $latest } else { '(lookup failed - check network)' })" + Write-Host " Pin status : $pinStatus" + Write-Host " Verdict : $verdict" + Write-Host '' + + if ($installed -lt $generated) { + Write-AzLocalPipelineWarning ` + -Title "$ModuleName is older than workflow YAML expects" ` + -Message "$ModuleName v$installed is OLDER than the version this workflow YAML was generated against (v$generated). Cmdlets, parameters, or output schemas referenced by this YAML may not exist in v$installed. Set REQUIRED_MODULE_VERSION to v$generated, or refresh the YAML to match the installed module via 'Copy-AzLocalPipelineExample -Update'." + } + if ($installed -gt $generated) { + Write-AzLocalPipelineNotice ` + -Title 'Workflow YAML may be stale' ` + -Message "Workflow YAML was generated against $ModuleName v$generated but the runner installed v$installed. Pipeline steps may have been improved in later releases - to refresh, re-run 'Copy-AzLocalPipelineExample -Update' (you will be prompted per file; add -Confirm:`$false to bypass). Pipeline YAMLs are under git so 'git diff' shows exactly what changed before commit." + } + if ($latest -and ($latest -gt $installed)) { + Write-AzLocalPipelineNotice ` + -Title "Newer $ModuleName version available on PSGallery" ` + -Message "$ModuleName v$latest is available on PSGallery; this run installed v$installed. Review the module CHANGELOG before bumping REQUIRED_MODULE_VERSION (or clear the pin to install the latest automatically)." + } + + $banner = "_Pipeline YAML v$generated | Module v$installed installed ($pinStatus) | PSGallery latest $latestStr | ${verdict}_`n" + [void](Add-AzLocalPipelineStepSummary -Markdown $banner -SummaryFileName $SummaryFileName) + + Set-AzLocalPipelineOutput -Name 'installed_module_version' -Value $installed.ToString() + Set-AzLocalPipelineOutput -Name 'generated_against_version' -Value $generated.ToString() + Set-AzLocalPipelineOutput -Name 'latest_on_psgallery' -Value $(if ($latest) { $latest.ToString() } else { '' }) + + if ($PassThru) { + [PSCustomObject]@{ + InstalledVersion = $installed + GeneratedAgainstVersion = $generated + LatestOnPSGallery = $latest + PinStatus = $pinStatus + Verdict = $verdict + } + } +} diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalApplyUpdatesScheduleAudit.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalApplyUpdatesScheduleAudit.ps1 new file mode 100644 index 00000000..7f87bbbd --- /dev/null +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalApplyUpdatesScheduleAudit.ps1 @@ -0,0 +1,613 @@ +function Export-AzLocalApplyUpdatesScheduleAudit { + <# + .SYNOPSIS + Runs the Step.3 Apply-Updates Schedule Coverage Audit workload: + Test-AzLocalApplyUpdatesScheduleCoverage (Audit + Matrix + + Recommend views) + JUnit XML + markdown step summary + cycle + calendar + allow-list coverage + step outputs for the v0.8.5 + thin-YAML Step.3 pipeline. + + .DESCRIPTION + Phase 1 (v0.8.5) of the thin-YAML refactor. Condenses the + ~220-line inline `run: |` audit block and the ~210-line inline + Create Summary block in v0.8.4 Step.3_apply-updates-schedule- + audit.yml (GitHub Actions + Azure DevOps) into a single cmdlet + call so the per-platform yml shrinks to a few lines and the + workload becomes unit-testable against synthetic schedule and + pipeline YAML inputs. + + The cmdlet: + + 1. Resolves the output directory (defaults to './reports' on + GitHub Actions / Local, or `$env:BUILD_ARTIFACTSTAGINGDIRECTORY` + on Azure DevOps). + 2. Calls `Test-AzLocalApplyUpdatesScheduleCoverage` 3 times: + -View Audit -PassThru (rows + CSV), -View Matrix + (CSV only), -View Recommend (markdown only). Re-uses the + v0.7.65+ advisor instead of re-implementing the + ring/cron diff math inline. + 3. Bucketises rows by Status into Covered / Uncovered / + PartiallyCovered / MalformedTag / UnparseableCron / + NoWindowTag / RingMissingFromSchedule / + RingOrphanedInSchedule / RingMixedWindows. + 4. Builds a JUnit XML report via the shared + `New-AzLocalPipelineJUnitXml` Private helper. Schedule + rows go in the 'Schedule (ring diff)' suite FIRST + (higher blast radius), Cron rows in the 'Cron coverage' + suite. Any non-Covered status becomes a . + 5. Emits 12 step outputs via `Set-AzLocalPipelineOutput` + (lowercase snake_case per v0.8.5 convention): + total_rows, covered, uncovered, partial, malformed, + unparseable, no_window_tag, ring_missing, ring_orphan, + ring_mixed, have_schedule, schedule_path. + 6. Builds the markdown step summary: + - Summary counts table (10 rows) + - Recommend snippet at the top when $hasIssues + - Audit Detail - Schedule (ring-file gap) sub-table + (rendered whenever -SchedulePath was supplied) + - Allow-list coverage section (renders schema v1 + migration nudge or schema v2 per-row effective + allow-list with optional pin-snippet) + - Audit Detail - Cron coverage sub-table + - Recommend snippet at the bottom when clean fleet + - **Cycle calendar** - ALWAYS rendered when + -SchedulePath is supplied (v0.8.5 fix for the v0.8.4 + $hasIssues-gate regression that silently dropped the + calendar from clean-fleet runs). + - Reports list with artifact filenames + 7. Pushes the markdown via `Add-AzLocalPipelineStepSummary`. + + Internal reuse (per the v0.8.5 thin-YAML consistency contract): + * `Test-AzLocalApplyUpdatesScheduleCoverage` for advisor data. + * `Get-AzLocalApplyUpdatesScheduleConfig` for allow-list / cycle. + * `Get-AzLocalApplyUpdatesScheduleCycleCalendar -AsMarkdown + -IncludePerRingSummary` for the calendar section. + * `New-AzLocalPipelineJUnitXml` for JUnit XML. + * `Add-AzLocalPipelineStepSummary` for the rendered markdown. + * `Set-AzLocalPipelineOutput` for the step outputs. + * `Get-AzLocalPipelineHost` is implicit (the above branch on it). + + .PARAMETER OutputDirectory + Directory to write artifacts into. Created if it does not exist. + Defaults to './reports' (GH / Local) or + `$env:BUILD_ARTIFACTSTAGINGDIRECTORY` (Azure DevOps). + + .PARAMETER PipelineYamlPath + Path (file or folder) to Step.6_apply-updates.yml. REQUIRED so + the Recommend view can diff its proposed crons against what is + already in Step.6 and only emit a snippet for the truly missing + entries. + + .PARAMETER SchedulePath + Optional path to apply-updates-schedule.yml. When set, the audit + also reports RingMissingFromSchedule / RingOrphanedInSchedule + rows, the Allow-list coverage section, and the always-rendered + Cycle calendar. + + .PARAMETER LeadTimeMinutes + Minutes before UpdateStartWindow opens that the pipeline should + fire (0-60). Default 5. + + .PARAMETER RecommendFiresPerWindow + Cron entries the Recommend view should emit per UpdateStartWindow + segment. 1 = opening edge only. 2 = belt-and-braces (default). + + .PARAMETER IncludeUntagged + Surface clusters with no UpdateStartWindow tag as their own row. + + .PARAMETER ClusterCsvPath + Optional path to the source-controlled cluster inventory CSV. + When set (and the file exists) the Recommend view emits a + NoWindowTag remediation section. + + .PARAMETER Platform + Pipeline platform for the Recommend snippet: + GitHubActions (default in pipeline contexts), AzureDevOps, Both. + + .PARAMETER AuditCsvFileName + Filename for the per-(Ring, Window) audit CSV. + Default 'schedule-coverage-audit.csv'. + + .PARAMETER MatrixCsvFileName + Filename for the matrix-view CSV. Default 'schedule-coverage-matrix.csv'. + + .PARAMETER RecommendMdFileName + Filename for the Recommend snippet markdown. + Default 'schedule-coverage-recommend.md'. + + .PARAMETER JUnitXmlFileName + Filename for the JUnit XML report. + Default 'schedule-coverage-audit.xml'. + + .PARAMETER SummaryFileName + Per-task markdown summary filename used by + `Add-AzLocalPipelineStepSummary` on Azure DevOps and Local hosts. + Default 'schedule-coverage-summary.md'. + + .PARAMETER InstalledModuleVersion + Optional [string] used in the markdown footer + ('Generated by AzLocal.UpdateManagement v'). + + .PARAMETER PassThru + When set, returns a single PSCustomObject summarising the run. + Without -PassThru the cmdlet emits nothing to the pipeline; the + artifacts and step outputs are still produced. + + .OUTPUTS + Nothing by default. When -PassThru is set, a single PSCustomObject + with: TotalRows, Covered, Uncovered, Partial, Malformed, + Unparseable, NoWindowTag, RingMissing, RingOrphan, RingMixed, + HaveSchedule, SchedulePath, AuditRows, AuditCsvPath, + MatrixCsvPath, RecommendMdPath, JUnitXmlPath, SummaryPath. + + .EXAMPLE + Export-AzLocalApplyUpdatesScheduleAudit ` + -PipelineYamlPath '.github/workflows' ` + -SchedulePath '.github/apply-updates-schedule.yml' ` + -Platform GitHubActions -PassThru + + .NOTES + Author: Neil Bird, MSFT + Added: v0.8.5 + Module: AzLocal.UpdateManagement + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $false)] + [string]$OutputDirectory, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$PipelineYamlPath, + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$SchedulePath, + + [Parameter(Mandatory = $false)] + [ValidateRange(0, 60)] + [int]$LeadTimeMinutes = 5, + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 2)] + [int]$RecommendFiresPerWindow = 2, + + [Parameter(Mandatory = $false)] + [switch]$IncludeUntagged, + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$ClusterCsvPath, + + [Parameter(Mandatory = $false)] + [ValidateSet('GitHubActions', 'AzureDevOps', 'Both')] + [string]$Platform = 'GitHubActions', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$AuditCsvFileName = 'schedule-coverage-audit.csv', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$MatrixCsvFileName = 'schedule-coverage-matrix.csv', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$RecommendMdFileName = 'schedule-coverage-recommend.md', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$JUnitXmlFileName = 'schedule-coverage-audit.xml', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SummaryFileName = 'schedule-coverage-summary.md', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$InstalledModuleVersion, + + [Parameter(Mandatory = $false)] + [switch]$PassThru + ) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + $pipelineHost = Get-AzLocalPipelineHost + + if (-not $OutputDirectory) { + if ($pipelineHost -eq 'AzureDevOps' -and $env:BUILD_ARTIFACTSTAGINGDIRECTORY) { + $OutputDirectory = $env:BUILD_ARTIFACTSTAGINGDIRECTORY + } + else { + $OutputDirectory = './reports' + } + } + if (-not (Test-Path -LiteralPath $OutputDirectory)) { + New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + } + + if (-not (Test-Path -LiteralPath $PipelineYamlPath)) { + throw "PipelineYamlPath '$PipelineYamlPath' does not exist. Set it to the folder containing Step.6_apply-updates.yml (e.g. '.github/workflows' or '.azure-pipelines') so the Recommend view can diff its proposed crons against the existing file." + } + + $haveSchedule = $false + if ($SchedulePath -and (Test-Path -LiteralPath $SchedulePath)) { + $haveSchedule = $true + } + $haveCsv = $false + if ($ClusterCsvPath -and (Test-Path -LiteralPath $ClusterCsvPath)) { + $haveCsv = $true + } + + $auditCsv = Join-Path -Path $OutputDirectory -ChildPath $AuditCsvFileName + $matrixCsv = Join-Path -Path $OutputDirectory -ChildPath $MatrixCsvFileName + $recoMd = Join-Path -Path $OutputDirectory -ChildPath $RecommendMdFileName + $xmlPath = Join-Path -Path $OutputDirectory -ChildPath $JUnitXmlFileName + + Write-Host '========================================' + Write-Host 'Apply-Updates Schedule Coverage Audit' + Write-Host '========================================' + Write-Host "PipelineYamlPath : $PipelineYamlPath" + Write-Host "SchedulePath : $(if ($haveSchedule) { $SchedulePath } else { '(skipped - empty or missing)' })" + Write-Host "LeadTimeMinutes : $LeadTimeMinutes" + Write-Host "FiresPerWindow : $RecommendFiresPerWindow" + Write-Host "IncludeUntagged : $IncludeUntagged" + Write-Host "ClusterCsvPath : $(if ($haveCsv) { $ClusterCsvPath } else { '(skipped - empty or missing)' })" + Write-Host "Platform : $Platform" + Write-Host '' + + # ---- Audit view (rows + CSV) ------------------------------------------ + $auditArgs = @{ + View = 'Audit' + LeadTimeMinutes = $LeadTimeMinutes + RecommendFiresPerWindow = $RecommendFiresPerWindow + ExportPath = $auditCsv + Platform = $Platform + PassThru = $true + PipelineYamlPath = $PipelineYamlPath + } + if ($haveSchedule) { $auditArgs.SchedulePath = $SchedulePath } + if ($IncludeUntagged) { $auditArgs.IncludeUntagged = $true } + if ($haveCsv) { $auditArgs.ClusterCsvPath = $ClusterCsvPath } + $audit = Test-AzLocalApplyUpdatesScheduleCoverage @auditArgs + if (-not $audit) { $audit = @() } + $audit = @($audit) + + # ---- Matrix view (CSV only) ------------------------------------------- + Test-AzLocalApplyUpdatesScheduleCoverage -View Matrix ` + -LeadTimeMinutes $LeadTimeMinutes -ExportPath $matrixCsv | Out-Null + + # ---- Recommend view (markdown only) ----------------------------------- + $recoArgs = @{ + View = 'Recommend' + LeadTimeMinutes = $LeadTimeMinutes + RecommendFiresPerWindow = $RecommendFiresPerWindow + Platform = $Platform + ExportPath = $recoMd + PipelineYamlPath = $PipelineYamlPath + } + if ($haveSchedule) { $recoArgs.SchedulePath = $SchedulePath } + if ($haveCsv) { $recoArgs.ClusterCsvPath = $ClusterCsvPath } + Test-AzLocalApplyUpdatesScheduleCoverage @recoArgs | Out-Null + + # ---- Bucketise -------------------------------------------------------- + $covered = @($audit | Where-Object { $_.Status -eq 'Covered' }) + $uncovered = @($audit | Where-Object { $_.Status -eq 'Uncovered' }) + $partial = @($audit | Where-Object { $_.Status -eq 'PartiallyCovered' }) + $malformed = @($audit | Where-Object { $_.Status -eq 'MalformedTag' }) + $unparseable = @($audit | Where-Object { $_.Status -eq 'UnparseableCron' }) + $noWindowTag = @($audit | Where-Object { $_.Status -eq 'NoWindowTag' }) + $ringMissing = @($audit | Where-Object { $_.Status -eq 'RingMissingFromSchedule' }) + $ringOrphan = @($audit | Where-Object { $_.Status -eq 'RingOrphanedInSchedule' }) + $ringMixed = @($audit | Where-Object { $_.Status -eq 'RingMixedWindows' }) + + $hasIssues = ($uncovered.Count + $partial.Count + $malformed.Count + $unparseable.Count + $noWindowTag.Count + $ringMissing.Count + $ringOrphan.Count) -gt 0 + + Write-Host '' + Write-Host 'Audit complete:' + Write-Host " Covered : $($covered.Count)" + Write-Host " Uncovered : $($uncovered.Count)" + Write-Host " PartiallyCovered : $($partial.Count)" + Write-Host " MalformedTag : $($malformed.Count)" + Write-Host " UnparseableCron : $($unparseable.Count)" + Write-Host " NoWindowTag : $($noWindowTag.Count)" + Write-Host " RingMissingFromSchedule : $($ringMissing.Count)" + Write-Host " RingOrphanedInSchedule : $($ringOrphan.Count)" + Write-Host " RingMixedWindows : $($ringMixed.Count)" + + # ---- JUnit XML -------------------------------------------------------- + $scheduleRows = @($audit | Where-Object { $_.Section -eq 'Schedule' }) + $cronRows = @($audit | Where-Object { $_.Section -ne 'Schedule' }) + + $buildCase = { + param($Row) + $tcName = '{0} / {1}' -f $Row.UpdateRing, ($(if ($Row.UpdateStartWindow) { $Row.UpdateStartWindow } else { '(no window)' })) + $tc = @{ Name = $tcName; ClassName = 'ScheduleCoverage' } + if ($Row.Status -ne 'Covered') { + $issue = if ($Row.PSObject.Properties['Issue'] -and $Row.Issue) { [string]$Row.Issue } else { '' } + $reco = if ($Row.PSObject.Properties['Recommendation'] -and $Row.Recommendation) { [string]$Row.Recommendation } else { '' } + $clCnt = if ($Row.PSObject.Properties['ClusterCount']) { [string]$Row.ClusterCount } else { '0' } + $body = "Recommendation: $reco`nCluster count: $clCnt" + $tc.Failure = @{ Message = $issue; Type = [string]$Row.Status; Body = $body } + } + $tc + } + + $suites = @() + if ($haveSchedule -or $scheduleRows.Count -gt 0) { + $cases = @() + foreach ($r in $scheduleRows) { $cases += ,(& $buildCase $r) } + if ($cases.Count -eq 0) { + $cases = @(@{ Name = 'Schedule and fleet ring sets match - no gaps.'; ClassName = 'ScheduleCoverage' }) + } + $suites += ,@{ + Name = 'Apply-Updates Schedule Coverage - Schedule (ring diff)' + ClassName = 'ScheduleCoverage' + TestCases = $cases + } + } + if ($cronRows.Count -gt 0 -or -not $haveSchedule) { + $cases = @() + foreach ($r in $cronRows) { $cases += ,(& $buildCase $r) } + if ($cases.Count -eq 0) { + $cases = @(@{ Name = 'No tagged clusters found - nothing to audit'; ClassName = 'ScheduleCoverage' }) + } + $suites += ,@{ + Name = 'Apply-Updates Schedule Coverage - Cron coverage' + ClassName = 'ScheduleCoverage' + TestCases = $cases + } + } + New-AzLocalPipelineJUnitXml -TestSuitesName 'Apply-Updates Schedule Coverage' -Suites $suites -OutputPath $xmlPath | Out-Null + + # ---- Step outputs ----------------------------------------------------- + Set-AzLocalPipelineOutput -Name 'total_rows' -Value ([string]$audit.Count) + Set-AzLocalPipelineOutput -Name 'covered' -Value ([string]$covered.Count) + Set-AzLocalPipelineOutput -Name 'uncovered' -Value ([string]$uncovered.Count) + Set-AzLocalPipelineOutput -Name 'partial' -Value ([string]$partial.Count) + Set-AzLocalPipelineOutput -Name 'malformed' -Value ([string]$malformed.Count) + Set-AzLocalPipelineOutput -Name 'unparseable' -Value ([string]$unparseable.Count) + Set-AzLocalPipelineOutput -Name 'no_window_tag' -Value ([string]$noWindowTag.Count) + Set-AzLocalPipelineOutput -Name 'ring_missing' -Value ([string]$ringMissing.Count) + Set-AzLocalPipelineOutput -Name 'ring_orphan' -Value ([string]$ringOrphan.Count) + Set-AzLocalPipelineOutput -Name 'ring_mixed' -Value ([string]$ringMixed.Count) + Set-AzLocalPipelineOutput -Name 'have_schedule' -Value ([string]$haveSchedule) + Set-AzLocalPipelineOutput -Name 'schedule_path' -Value $(if ($haveSchedule) { $SchedulePath } else { '' }) + + # ---- Recommend snippet content (for top-of-summary inlining) ---------- + $recoContent = '' + if (Test-Path -LiteralPath $recoMd) { + $recoContent = Get-Content -LiteralPath $recoMd -Raw + if ($null -eq $recoContent) { $recoContent = '' } + } + + # ---- Markdown step summary -------------------------------------------- + $md = New-Object 'System.Collections.Generic.List[string]' + [void]$md.Add('## Apply-Updates Schedule Coverage Audit') + [void]$md.Add('') + [void]$md.Add('| Metric | Count |') + [void]$md.Add('|--------|-------|') + [void]$md.Add("| **(Ring, Window) pairs audited** | $($audit.Count) |") + [void]$md.Add("| **Covered** | $($covered.Count) |") + [void]$md.Add("| **Uncovered** | $($uncovered.Count) |") + [void]$md.Add("| **PartiallyCovered** | $($partial.Count) |") + [void]$md.Add("| **MalformedTag** | $($malformed.Count) |") + [void]$md.Add("| **UnparseableCron** | $($unparseable.Count) |") + [void]$md.Add("| **NoWindowTag** | $($noWindowTag.Count) |") + [void]$md.Add("| **RingMissingFromSchedule** | $($ringMissing.Count) |") + [void]$md.Add("| **RingOrphanedInSchedule** | $($ringOrphan.Count) |") + [void]$md.Add("| **RingMixedWindows** | $($ringMixed.Count) |") + [void]$md.Add('') + + if ($hasIssues -and $recoContent) { + [void]$md.Add($recoContent) + [void]$md.Add('') + } + + # Helper for the per-section detail table (Schedule + Cron). + $addDetailTable = { + param([string]$Heading, [object[]]$DetailRows, [string]$EmptyText) + if ($Heading) { [void]$md.Add($Heading) } + [void]$md.Add('') + if ($DetailRows.Count -eq 0) { + [void]$md.Add("*$EmptyText*") + [void]$md.Add('') + return + } + [void]$md.Add('| Status | UpdateRing | UpdateStartWindow | Clusters | Required Cron (UTC) | Recommendation |') + [void]$md.Add('|--------|------------|--------------|---------:|----------------------|----------------|') + $shown = 0 + foreach ($r in $DetailRows) { + if ($shown -ge 100) { break } + $status = if ($r.PSObject.Properties['Status']) { [string]$r.Status } else { '' } + $ring = if ($r.PSObject.Properties['UpdateRing']) { [string]$r.UpdateRing } else { '' } + $win = if ($r.PSObject.Properties['UpdateStartWindow']) { [string]$r.UpdateStartWindow } else { '' } + $clCnt = if ($r.PSObject.Properties['ClusterCount']) { [string]$r.ClusterCount } else { '' } + $reqCron = if ($r.PSObject.Properties['RequiredCronUTC']) { [string]$r.RequiredCronUTC } else { '' } + $reco = if ($r.PSObject.Properties['Recommendation']) { [string]$r.Recommendation } else { '' } + [void]$md.Add(('| {0} | {1} | {2} | {3} | {4} | {5} |' -f $status, $ring, $win, $clCnt, $reqCron, $reco)) + $shown++ + } + if ($DetailRows.Count -gt 100) { + [void]$md.Add('') + [void]$md.Add("*Showing first 100 of $($DetailRows.Count); see ``$AuditCsvFileName`` artifact for the full list.*") + } + [void]$md.Add('') + } + + # Schedule detail (always rendered when -SchedulePath supplied OR when + # any Schedule-section rows exist - defensive fallback). + if ($haveSchedule -or $scheduleRows.Count -gt 0) { + [void]$md.Add('### Audit Detail - Schedule (ring-file gap)') + [void]$md.Add('') + [void]$md.Add('> **Higher blast radius.** A `RingMissingFromSchedule` row means apply-updates will NEVER fire on those clusters until you either add the ring to `apply-updates-schedule.yml` or retag them onto an existing scheduled ring.') + & $addDetailTable '' $scheduleRows 'Schedule and fleet ring sets match - no gaps.' + } + + # Allow-list coverage (schema v1 nudge or schema v2 per-row table). + if ($haveSchedule) { + $sched = $null + try { + $sched = Get-AzLocalApplyUpdatesScheduleConfig -Path $SchedulePath -ErrorAction Stop + } + catch { + [void]$md.Add('### Allow-list coverage') + [void]$md.Add('') + [void]$md.Add("*Could not load schedule file ``$SchedulePath`` for allow-list analysis: $($_.Exception.Message)*") + [void]$md.Add('') + } + if ($sched) { + [void]$md.Add("### Allow-list coverage (schema v$($sched.SchemaVersion))") + [void]$md.Add('') + if ($sched.SchemaVersion -lt 2) { + [void]$md.Add('*This schedule is on schema v1. Schema v2 adds `allowedUpdateVersions` for fleet-wide + per-ring update allow-lists (e.g. enforce a ''minimum updates'' policy on Prod). Migrate the file with:*') + [void]$md.Add('') + [void]$md.Add('```powershell') + [void]$md.Add("Update-AzLocalApplyUpdatesScheduleConfig -Path '$SchedulePath' -SchemaMigrate") + [void]$md.Add('```') + [void]$md.Add('') + } + else { + $topAllow = @() + if ($sched.PSObject.Properties['AllowedUpdateVersions'] -and $sched.AllowedUpdateVersions) { + $topAllow = @($sched.AllowedUpdateVersions) + } + $topIsLatest = ($topAllow.Count -eq 1 -and $topAllow[0] -eq 'Latest') + if ($topIsLatest) { + $topLabel = '`Latest` _(no constraint - install the latest Ready update on each cluster)_' + } + else { + $topLabel = "``$($topAllow -join '; ')`` _(explicit allow-list - clusters only install matching updates)_" + } + [void]$md.Add("**Top-level fleet default:** $topLabel") + [void]$md.Add('') + [void]$md.Add('| Row | weeksInCycle | daysOfWeek | rings | Effective allowedUpdateVersions |') + [void]$md.Add('|----:|--------------|------------|-------|---------------------------------|') + $rowsMissingOverride = New-Object System.Collections.Generic.List[object] + $rowIdx = 0 + foreach ($r in @($sched.Schedule)) { + $rowIdx++ + $rowAllow = @() + if ($r.PSObject.Properties['AllowedUpdateVersionsParsed'] -and $r.AllowedUpdateVersionsParsed) { + $rowAllow = @($r.AllowedUpdateVersionsParsed) + } + if ($rowAllow.Count -gt 0) { + if ($rowAllow.Count -eq 1 -and $rowAllow[0] -eq 'Latest') { + $effLabel = '`Latest` _(row override: no constraint)_' + } + else { + $effLabel = "``$($rowAllow -join '; ')`` _(row override)_" + } + } + else { + if ($topIsLatest) { + $effLabel = '`Latest` _(inherits top-level - no constraint)_' + } + else { + $effLabel = "``$($topAllow -join '; ')`` _(inherits top-level)_" + } + $rowsMissingOverride.Add([pscustomobject]@{ + Row = $rowIdx + WeeksInCycle = $r.weeksInCycle + DaysOfWeek = $r.daysOfWeek + Rings = $r.rings + }) + } + [void]$md.Add(('| {0} | {1} | {2} | {3} | {4} |' -f $rowIdx, $r.weeksInCycle, $r.daysOfWeek, $r.rings, $effLabel)) + } + [void]$md.Add('') + if ($rowsMissingOverride.Count -gt 0) { + [void]$md.Add("*$($rowsMissingOverride.Count) row(s) inherit the top-level allow-list. This is fine when you want each ring to install the latest Ready update as soon as it is available.*") + [void]$md.Add('') + [void]$md.Add("### Optional - pin a ring to a specific update in ``$SchedulePath``") + [void]$md.Add('') + [void]$md.Add('*Only needed if you want to PIN a ring to a specific update (e.g. keep Prod on the latest feature drop only). This is NOT a fix for the cron-coverage or ring-diff sections above.* Add `allowedUpdateVersions:` to the row that covers the ring you want to pin. Use `Get-AzLocalAvailableUpdates` (or the Azure portal -> Azure Local -> Updates) to find valid update names / version strings.') + [void]$md.Add('') + [void]$md.Add('```yaml') + [void]$md.Add("- weeksInCycle: '*'") + [void]$md.Add(" daysOfWeek: 'Tue,Wed,Thu'") + [void]$md.Add(" rings: 'Prod'") + [void]$md.Add(" allowedUpdateVersions: 'Solution12.2604.1003.1005;Solution12.2610.1003.XX'") + [void]$md.Add('```') + [void]$md.Add('') + } + else { + [void]$md.Add('*Every schedule row has an explicit `allowedUpdateVersions:` override - no inheritance from the top-level default.*') + [void]$md.Add('') + } + } + } + } + + & $addDetailTable '### Audit Detail - Cron coverage (Uncovered / Partial / Malformed first)' $cronRows 'No tagged clusters found - nothing to audit.' + + if (-not $hasIssues -and $recoContent) { + [void]$md.Add('### Reference - Recommended schedule (copy into Step.6_apply-updates.yml)') + [void]$md.Add('') + [void]$md.Add($recoContent) + [void]$md.Add('') + } + + # ---- 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. + if ($haveSchedule) { + $calendarMd = $null + try { + $schedForCalendar = if ($sched) { $sched } else { Get-AzLocalApplyUpdatesScheduleConfig -Path $SchedulePath -ErrorAction Stop } + $calendarMd = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $schedForCalendar -AsMarkdown -IncludePerRingSummary -ErrorAction Stop + } + catch { + $calendarMd = $null + Write-Warning "Failed to build cycle calendar: $($_.Exception.Message)" + } + if ($calendarMd) { + [void]$md.Add($calendarMd) + [void]$md.Add('') + } + } + + [void]$md.Add('### Reports Available') + [void]$md.Add("- ``$AuditCsvFileName`` - one row per (UpdateRing, UpdateStartWindow) pair") + [void]$md.Add("- ``$MatrixCsvFileName`` - full inventory + required cron per row") + [void]$md.Add("- ``$RecommendMdFileName`` - ready-to-paste cron block") + [void]$md.Add("- ``$JUnitXmlFileName`` - JUnit XML for CI/CD visualisation") + [void]$md.Add('') + [void]$md.Add("*Generated at $((Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss')) UTC*") + + if ($InstalledModuleVersion) { + [void]$md.Add('') + [void]$md.Add(('_Generated by AzLocal.UpdateManagement v{0}._' -f $InstalledModuleVersion)) + } + + Add-AzLocalPipelineStepSummary -Markdown ($md -join [Environment]::NewLine) -SummaryFileName $SummaryFileName | Out-Null + + if ($PassThru) { + return [pscustomobject]@{ + TotalRows = [int]$audit.Count + Covered = [int]$covered.Count + Uncovered = [int]$uncovered.Count + Partial = [int]$partial.Count + Malformed = [int]$malformed.Count + Unparseable = [int]$unparseable.Count + NoWindowTag = [int]$noWindowTag.Count + RingMissing = [int]$ringMissing.Count + RingOrphan = [int]$ringOrphan.Count + RingMixed = [int]$ringMixed.Count + HaveSchedule = [bool]$haveSchedule + SchedulePath = $(if ($haveSchedule) { $SchedulePath } else { '' }) + AuditRows = $audit + AuditCsvPath = $auditCsv + MatrixCsvPath = $matrixCsv + RecommendMdPath = $recoMd + JUnitXmlPath = $xmlPath + SummaryPath = (Join-Path -Path $OutputDirectory -ChildPath $SummaryFileName) + } + } +} diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalAuthValidationReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalAuthValidationReport.ps1 new file mode 100644 index 00000000..8564576d --- /dev/null +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalAuthValidationReport.ps1 @@ -0,0 +1,378 @@ +function Export-AzLocalAuthValidationReport { + <# + .SYNOPSIS + Validates the pipeline identity's Azure authentication, RBAC, and + subscription scope, and emits a JUnit XML report + step-summary + markdown for the v0.8.5 thin-YAML Step.0 pipeline. + + .DESCRIPTION + Phase 1 (v0.8.5) of the thin-YAML refactor. Condenses the + ~200-line inline `run: |` block in the v0.8.4 + `Step.0_authentication-test.yml` (GitHub Actions + Azure DevOps) + into a single cmdlet call so the per-platform yml shrinks to a + few lines and the logic becomes unit-testable against synthetic + `az` CLI responses. + + The cmdlet probes four auth-relevant facts in sequence: + + 1. `az account show` (proves OIDC / Workload Identity Federation + token exchange, secret wiring). + 2. Role assignments for the pipeline identity's appId (proves + RBAC grant). Surfaced to the console only (matches the + v0.8.4 yml behaviour, which used `-o table` for log inspection). + 3. `az account list --refresh` (proves which subscriptions the + identity can actually see; the authoritative count). Subscription + rows are persisted as both `subscriptions.json` and + `subscriptions.csv` in the report directory. + 4. Azure Resource Graph query for `microsoft.azurestackhci/clusters` + (proves cluster reachability for the downstream fleet pipelines). + + All four facts feed FOUR JUnit `` blocks in the emitted + XML (Authentication / Subscription Scope / Resource Graph + Reachability / Module Version Drift). The markdown step summary + renders the same data as a table + subscription roster. Three step + outputs (`subscription_count`, `cluster_count`, `auth_valid`) are + emitted via the Phase 0 `Set-AzLocalPipelineOutput` helper so + downstream jobs / steps can consume them on either platform. + + Internal reuse (per the v0.8.5 thin-YAML consistency contract): + * `Invoke-AzCliJson` for every `az` subcommand (safe stderr/stdout + split; never the inline `2>&1 | ConvertFrom-Json` pattern). + * `Install-AzGraphExtension` for the resource-graph extension + (idempotent install). + * `Invoke-AzResourceGraphQuery` for the cluster reachability KQL. + * `New-AzLocalPipelineJUnitXml` (Private) for the JUnit XML. + * `Add-AzLocalPipelineStepSummary` for the rendered markdown. + * `Set-AzLocalPipelineOutput` for the three step outputs. + * `Get-AzLocalPipelineHost` is implicit (all of the above branch + on it internally). + + .PARAMETER AzureClientId + The pipeline identity's appId. Used to scope the + `az role assignment list --assignee ` console echo. When + empty / null, the cmdlet falls back to `account.user.name` from + `az account show` (the same fallback the v0.8.4 ADO yml uses, + because ADO service connections do not expose the appId via a + secret). + + .PARAMETER ReportDirectory + Directory to write the JUnit XML and the subscription artifacts + (`subscriptions.json`, `subscriptions.csv`) into. Created if it + does not exist. Defaults to `./reports` (which is what the v0.8.4 + GH yml uses) or, on AzureDevOps, to + `$env:BUILD_ARTIFACTSTAGINGDIRECTORY/auth-report` if that env var + is set (matching the v0.8.4 ADO yml). + + .PARAMETER ReportFileName + Filename for the JUnit XML (relative to `-ReportDirectory`). + Default `auth-report.xml`. + + .PARAMETER SubscriptionsJsonFileName + Filename for the JSON subscription artifact. Default + `subscriptions.json`. + + .PARAMETER SubscriptionsCsvFileName + Filename for the CSV subscription artifact. Default + `subscriptions.csv`. + + .PARAMETER MaxClusters + Upper bound on the ARG `--first` page size for the cluster query. + Default 1000. + + .PARAMETER InstalledModuleVersion + Optional [version] / [string] for the Module Version Drift JUnit + suite. When all three of InstalledModuleVersion / + GeneratedAgainstVersion / LatestOnPSGallery are empty, the + Module Version Drift suite is omitted. + + .PARAMETER GeneratedAgainstVersion + See InstalledModuleVersion. + + .PARAMETER LatestOnPSGallery + See InstalledModuleVersion. + + .PARAMETER PassThru + When set, returns a single PSCustomObject summarising the run + (Account, SubscriptionCount, ClusterCount, AuthValid, JUnitXmlPath, + SubscriptionsJsonPath, SubscriptionsCsvPath). Without -PassThru + the cmdlet emits nothing to the pipeline; the artifacts and step + outputs are still produced. + + .OUTPUTS + Nothing by default. When -PassThru is set, a single PSCustomObject: + Account [PSCustomObject] (name, id, tenantId, user) + SubscriptionCount [int] + Subscriptions [PSCustomObject[]] (one per subscription) + ClusterCount [int] + AuthValid [bool] + JUnitXmlPath [string] + SubscriptionsJsonPath [string] + SubscriptionsCsvPath [string] + + .EXAMPLE + Export-AzLocalAuthValidationReport -PassThru + + Runs the four probes against the currently-authenticated `az` + context, writes auth-report.xml + subscriptions.{json,csv} to + ./reports, emits the rendered markdown to the active pipeline + host's step-summary, sets three step outputs, and returns the + summary object. + + .NOTES + Module: AzLocal.UpdateManagement (v0.8.5+) + Roadmap: Step.0 — Authentication Validation and Subscription Scope Report. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$AzureClientId, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$ReportDirectory, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$ReportFileName = 'auth-report.xml', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SubscriptionsJsonFileName = 'subscriptions.json', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SubscriptionsCsvFileName = 'subscriptions.csv', + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 5000)] + [int]$MaxClusters = 1000, + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$InstalledModuleVersion, + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$GeneratedAgainstVersion, + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$LatestOnPSGallery, + + [Parameter(Mandatory = $false)] + [switch]$PassThru + ) + + $pipelineHost = Get-AzLocalPipelineHost + + if (-not $ReportDirectory) { + if ($pipelineHost -eq 'AzureDevOps' -and $env:BUILD_ARTIFACTSTAGINGDIRECTORY) { + $ReportDirectory = Join-Path -Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY -ChildPath 'auth-report' + } + else { + $ReportDirectory = './reports' + } + } + if (-not (Test-Path -LiteralPath $ReportDirectory)) { + New-Item -ItemType Directory -Path $ReportDirectory -Force | Out-Null + } + + Write-Host '--- 1. az account show (proves authentication wiring) ---' + $accountResult = Invoke-AzCliJson -Arguments @('account', 'show') + if (-not $accountResult.Ok) { + throw "Export-AzLocalAuthValidationReport: az account show failed - $($accountResult.Error)" + } + $account = $accountResult.Data + if (-not $account) { + throw "Export-AzLocalAuthValidationReport: az account show returned an empty body. The pipeline identity is not authenticated." + } + $account | Format-List name, id, tenantId, user | Out-Host + + if (-not $AzureClientId) { + if ($account.user -and $account.user.name) { + $AzureClientId = [string]$account.user.name + Write-Host "AzureClientId not supplied - derived '$AzureClientId' from az account show (account.user.name)." + } + } + + Write-Host '' + Write-Host '--- 2. role assignments for the pipeline identity (proves RBAC grant) ---' + if ($AzureClientId) { + $rolesResult = Invoke-AzCliJson -Arguments @('role', 'assignment', 'list', '--assignee', $AzureClientId, '--all') + if ($rolesResult.Ok -and $rolesResult.Data) { + $rolesRows = @($rolesResult.Data | Select-Object roleDefinitionName, principalType, scope) + if ($rolesRows.Count -gt 0) { + $rolesRows | Format-Table -AutoSize | Out-Host + } + else { + Write-Host "(no role assignments returned for appId '$AzureClientId' - confirm the App Registration's service principal has RBAC grants on at least one subscription / management group)" + } + } + else { + Write-Host "(role assignment lookup failed: $($rolesResult.Error))" + } + } + else { + Write-Host "(skipped - no AzureClientId supplied and az account show did not expose account.user.name)" + } + + Write-Host '' + Write-Host '--- 3. Subscription Scope: enumerating all subscriptions visible to the pipeline identity ---' + $subsResult = Invoke-AzCliJson -Arguments @('account', 'list', '--refresh', '--query', '[].{name:name, subscriptionId:id, tenantId:tenantId, state:state}') + if (-not $subsResult.Ok) { + throw "Export-AzLocalAuthValidationReport: az account list --refresh failed - $($subsResult.Error)" + } + $subs = @() + if ($subsResult.Data) { + $subs = @($subsResult.Data | Sort-Object name) + } + $subCount = $subs.Count + Write-Host "Count of subscriptions accessible = $subCount" + if ($subCount -gt 0) { + $subs | Format-Table @{N='#';E={[array]::IndexOf($subs,$_)+1}}, name, subscriptionId, state -AutoSize | Out-Host + } + + $subsJsonPath = Join-Path -Path $ReportDirectory -ChildPath $SubscriptionsJsonFileName + $subsCsvPath = Join-Path -Path $ReportDirectory -ChildPath $SubscriptionsCsvFileName + ($subs | ConvertTo-Json -Depth 4) | Out-File -FilePath $subsJsonPath -Encoding utf8 + $subs | Select-Object name, subscriptionId, tenantId, state | + Export-Csv -Path $subsCsvPath -NoTypeInformation -Encoding utf8 + + Write-Host '' + Write-Host '--- 4. Resource Graph query (proves cluster reachability) ---' + [void](Install-AzGraphExtension) + $clusterKql = "resources | where type =~ 'microsoft.azurestackhci/clusters' | project name, resourceGroup, subscriptionId" + $clusterRows = @() + try { + $clusterRows = @(Invoke-AzResourceGraphQuery -Query $clusterKql -First $MaxClusters -ErrorAction Stop) + } + catch { + Write-Warning "Cluster ARG query failed: $($_.Exception.Message)" + $clusterRows = @() + } + $clusterCount = $clusterRows.Count + Write-Host "Clusters visible to the pipeline identity = $clusterCount" + if ($clusterCount -gt 0) { + $clusterRows | Select-Object -First 10 | Format-Table -AutoSize | Out-Host + } + + # ------------------------------------------------------------------ + # Build JUnit XML via the shared helper + # ------------------------------------------------------------------ + $authSuite = @{ + Name = "🔐 Authentication" + ClassName = 'Authentication' + TestCases = @( + @{ + Name = 'OIDC token exchange (az account show succeeded)' + SystemOut = "Tenant: $($account.tenantId)`nSubscription: $($account.name) ($($account.id))`nIdentity: $(if ($account.user) { $account.user.name } else { '(none)' })" + } + @{ Name = "Default subscription = $($account.name)" } + @{ Name = "Pipeline identity (appId) = $(if ($account.user) { $account.user.name } else { '(none)' })" } + ) + } + + $subTestCases = @( + @{ Name = "Count of subscriptions accessible = $subCount" } + ) + $i = 0 + foreach ($s in $subs) { + $i++ + $subTestCases += @{ Name = "#$i $($s.name) ($($s.subscriptionId)) [$($s.state)]" } + } + $subSuite = @{ + Name = "📋 Subscription Scope (count=$subCount)" + ClassName = 'SubscriptionScope' + TestCases = $subTestCases + } + + $rgSuite = @{ + Name = "🌐 Resource Graph Reachability" + ClassName = 'ResourceGraph' + TestCases = @( + @{ Name = "Clusters visible to pipeline identity = $clusterCount" } + ) + } + + $suites = @($authSuite, $subSuite, $rgSuite) + + $emitDriftSuite = ($InstalledModuleVersion -or $GeneratedAgainstVersion -or $LatestOnPSGallery) + if ($emitDriftSuite) { + $latestRender = if ($LatestOnPSGallery) { $LatestOnPSGallery } else { '(lookup failed)' } + $suites += @{ + Name = "📦 Module Version Drift" + ClassName = 'ModuleVersion' + TestCases = @( + @{ Name = "Installed AzLocal.UpdateManagement = $InstalledModuleVersion" } + @{ Name = "YAML generated against = $GeneratedAgainstVersion" } + @{ Name = "Latest on PSGallery = $latestRender" } + ) + } + } + + $xmlPath = Join-Path -Path $ReportDirectory -ChildPath $ReportFileName + [void](New-AzLocalPipelineJUnitXml -TestSuitesName 'Step.0 - Authentication Validation and Subscription Scope Report' -Suites $suites -OutputPath $xmlPath) + Write-Host "JUnit XML written to: $xmlPath" + + # ------------------------------------------------------------------ + # Build markdown step summary via the shared helper + # ------------------------------------------------------------------ + $md = [System.Text.StringBuilder]::new() + [void]$md.AppendLine('## Step.0 - Authentication Validation and Subscription Scope Report') + [void]$md.AppendLine('') + [void]$md.AppendLine('| Check | Result |') + [void]$md.AppendLine('|---|---|') + [void]$md.AppendLine("| Authentication | :white_check_mark: working |") + [void]$md.AppendLine("| Default Subscription | $($account.name) (``$($account.id)``) |") + [void]$md.AppendLine("| Tenant | ``$($account.tenantId)`` |") + $identityName = if ($account.user) { $account.user.name } else { '(none)' } + [void]$md.AppendLine("| Pipeline identity (appId) | ``$identityName`` |") + [void]$md.AppendLine("| Resource Graph reachability | :white_check_mark: $clusterCount cluster(s) visible |") + if ($emitDriftSuite) { + $latestRender = if ($LatestOnPSGallery) { $LatestOnPSGallery } else { '(lookup failed)' } + [void]$md.AppendLine("| AzLocal.UpdateManagement (installed) | ``$InstalledModuleVersion`` |") + [void]$md.AppendLine("| AzLocal.UpdateManagement (YAML generated against) | ``$GeneratedAgainstVersion`` |") + [void]$md.AppendLine("| AzLocal.UpdateManagement (latest on PSGallery) | ``$latestRender`` |") + } + [void]$md.AppendLine('') + [void]$md.AppendLine("### Count of subscriptions accessible = $subCount") + [void]$md.AppendLine('') + [void]$md.AppendLine('| # | Subscription Name | Subscription ID | Tenant ID | State |') + [void]$md.AppendLine('|---|---|---|---|---|') + $i = 0 + foreach ($s in $subs) { + $i++ + [void]$md.AppendLine("| $i | $($s.name) | ``$($s.subscriptionId)`` | ``$($s.tenantId)`` | $($s.state) |") + } + [void]$md.AppendLine('') + [void]$md.AppendLine("*Generated at $((Get-Date).ToString('yyyy-MM-dd HH:mm:ss UTC'))*") + + [void](Add-AzLocalPipelineStepSummary -Markdown $md.ToString() -SummaryFileName 'auth-report-summary.md') + + # ------------------------------------------------------------------ + # Step outputs (consumed by downstream jobs / steps) + # ------------------------------------------------------------------ + $authValid = ($null -ne $account) -and ($subCount -gt 0) + Set-AzLocalPipelineOutput -Name 'subscription_count' -Value ([string]$subCount) + Set-AzLocalPipelineOutput -Name 'cluster_count' -Value ([string]$clusterCount) + Set-AzLocalPipelineOutput -Name 'auth_valid' -Value ([string]$authValid.ToString().ToLowerInvariant()) + + if ($PassThru) { + return [PSCustomObject]@{ + Account = $account + SubscriptionCount = $subCount + Subscriptions = $subs + ClusterCount = $clusterCount + AuthValid = $authValid + JUnitXmlPath = $xmlPath + SubscriptionsJsonPath = $subsJsonPath + SubscriptionsCsvPath = $subsCsvPath + } + } +} diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalClusterReadinessGateReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalClusterReadinessGateReport.ps1 new file mode 100644 index 00000000..6fd83722 --- /dev/null +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalClusterReadinessGateReport.ps1 @@ -0,0 +1,206 @@ +function Export-AzLocalClusterReadinessGateReport { + <# + .SYNOPSIS + Runs Get-AzLocalClusterUpdateReadiness for the given UpdateRing, + writes readiness-report.csv to the pipeline artifact folder, emits + step outputs READY_COUNT/TOTAL_COUNT/NOT_READY_COUNT, and renders the + per-cluster readiness markdown table to the run summary. + .DESCRIPTION + v0.8.5 Step.6 thin-YAML helper. Replaces the ~80-line inline `run:` + block that lived in both Step.6_apply-updates.yml pipelines. + + Behaviour matches the prior inline block byte-for-byte: + - When -UpdateRing is empty/whitespace (no schedule row matched + today): skips the readiness query, emits zero counts, and exits + cleanly. Downstream apply-updates is gated on ready_count > 0 + and will be skipped. + - Otherwise: discovers clusters by tag, exports CSV, counts ready + vs not-ready, emits the same READY_COUNT/TOTAL_COUNT step outputs + the apply-updates job consumes. + - Markdown table: one row per assessed cluster (sorted Ready-first + then ClusterName ASC), capped at -MaxRows (default 100). Same + columns and emoji as Enhancement D introduced in v0.8.4. + .PARAMETER UpdateRing + UpdateRing tag value to filter clusters by. Empty string OR whitespace + triggers the zero-clusters short-circuit (see DESCRIPTION). + .PARAMETER OutputDirectory + Directory where readiness-report.csv is written. Defaults to: + - Azure DevOps: $env:BUILD_ARTIFACTSTAGINGDIRECTORY + - GitHub / Local: './artifacts' + .PARAMETER ReadinessCsvFileName + Filename for the CSV. Default: 'readiness-report.csv'. + .PARAMETER MaxRows + Maximum rows rendered into the markdown table. Default 100. The CSV + always contains every assessed cluster regardless of this cap. + .PARAMETER SummaryFileName + Filename for the per-task markdown summary (ADO/Local only). Default: + 'azlocal-step6-readiness-summary.md'. + .PARAMETER PassThru + Returns PSCustomObject with: TotalCount, ReadyCount, NotReadyCount, + UpdateRing, ReadinessCsvPath, SummaryPath, Results (raw rows from + Get-AzLocalClusterUpdateReadiness when not in the short-circuit path, + else @()). + .NOTES + Author : AzLocal.UpdateManagement + Version : 0.8.5 (Step.6 thin-YAML port) + #> + [CmdletBinding()] + [OutputType([void])] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string]$UpdateRing = '', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string]$OutputDirectory = '', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$ReadinessCsvFileName = 'readiness-report.csv', + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 10000)] + [int]$MaxRows = 100, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SummaryFileName = 'azlocal-step6-readiness-summary.md', + + [switch]$PassThru + ) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + $pipelineHost = Get-AzLocalPipelineHost + + # OutputDirectory default (host-specific, byte-for-byte the same as the + # prior inline block). + if (-not $OutputDirectory) { + if ($pipelineHost -eq 'AzureDevOps' -and $env:BUILD_ARTIFACTSTAGINGDIRECTORY) { + $OutputDirectory = $env:BUILD_ARTIFACTSTAGINGDIRECTORY + } + else { + $OutputDirectory = './artifacts' + } + } + + if (-not (Test-Path -LiteralPath $OutputDirectory)) { + New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + } + + $csvPath = Join-Path -Path $OutputDirectory -ChildPath $ReadinessCsvFileName + + # Per-host step-output naming - PRESERVE existing pipeline downstream + # bindings byte-for-byte: GH uses UPPER_SNAKE, ADO uses PascalCase + # (e.g. stageDependencies.CheckReadiness.ReadinessCheck.outputs['readiness.ReadyCount']). + if ($pipelineHost -eq 'AzureDevOps') { + $nReadyCount = 'ReadyCount'; $nTotalCount = 'TotalCount'; $nNotReadyCount = 'NotReadyCount' + } + else { + $nReadyCount = 'READY_COUNT'; $nTotalCount = 'TOTAL_COUNT'; $nNotReadyCount = 'NOT_READY_COUNT' + } + + # Short-circuit when the schedule resolver returned no ring. + if ([string]::IsNullOrWhiteSpace($UpdateRing)) { + Write-Host "No UpdateRing scheduled for this firing - skipping readiness check." + Set-AzLocalPipelineOutput -Name $nReadyCount -Value '0' -CrossJob + Set-AzLocalPipelineOutput -Name $nTotalCount -Value '0' -CrossJob + Set-AzLocalPipelineOutput -Name $nNotReadyCount -Value '0' -CrossJob + if ($PassThru) { + return [pscustomobject]@{ + TotalCount = 0 + ReadyCount = 0 + NotReadyCount = 0 + UpdateRing = '' + ReadinessCsvPath = $csvPath + SummaryPath = $null + Results = @() + } + } + return + } + + Write-Host "Checking readiness for clusters with UpdateRing = '$UpdateRing'" + + $results = @(Get-AzLocalClusterUpdateReadiness ` + -ScopeByUpdateRingTag ` + -UpdateRingValue $UpdateRing ` + -ExportPath $csvPath ` + -PassThru) + + $totalCount = $results.Count + $readyCount = @($results | Where-Object { $_.ReadyForUpdate -eq $true }).Count + $notReadyCount = $totalCount - $readyCount + + Write-Host "" + Write-Host "========================================" -ForegroundColor Cyan + Write-Host "Readiness Summary" -ForegroundColor Cyan + Write-Host "========================================" -ForegroundColor Cyan + Write-Host "Total Clusters: $totalCount" + Write-Host "Ready for Update: $readyCount" + Write-Host "Not Ready: $notReadyCount" + + Set-AzLocalPipelineOutput -Name $nReadyCount -Value "$readyCount" -CrossJob + Set-AzLocalPipelineOutput -Name $nTotalCount -Value "$totalCount" -CrossJob + Set-AzLocalPipelineOutput -Name $nNotReadyCount -Value "$notReadyCount" -CrossJob + + if ($readyCount -eq 0 -and $pipelineHost -eq 'AzureDevOps') { + # Preserve byte-for-byte the original Step.6 ADO warning text. + Write-Host "##vso[task.logissue type=warning]No clusters are ready for updates in ring '$UpdateRing'" + } + + # Per-cluster readiness markdown table. Heading is `# Cluster Readiness` + # on ADO (file is its own summary card) and `## Cluster Readiness` on + # GitHub (appended into GITHUB_STEP_SUMMARY which already has H1). + $headingLevel = if ($pipelineHost -eq 'AzureDevOps') { '#' } else { '##' } + $sb = New-Object System.Text.StringBuilder + [void]$sb.AppendLine("$headingLevel Cluster Readiness ($UpdateRing)") + [void]$sb.AppendLine() + [void]$sb.AppendLine("**Total:** $totalCount  |  **Ready:** $readyCount  |  **Not Ready:** $notReadyCount") + [void]$sb.AppendLine() + [void]$sb.AppendLine('| Cluster | Current Version | Update State | Health | Ready? | Recommended Update | Blocking Reasons |') + [void]$sb.AppendLine('|---|---|---|---|---|---|---|') + + $rendered = 0 + foreach ($r in ($results | Sort-Object @{Expression = { [bool]$_.ReadyForUpdate }; Descending = $true }, ClusterName)) { + if ($rendered -ge $MaxRows) { break } + $readyIcon = if ($r.ReadyForUpdate -eq $true) { [char]0x2705 } else { [char]0x26D4 } + $hSt = "$($r.HealthState)" + $hCell = switch -Regex ($hSt) { + '^Success$' { ("{0} {1}" -f [char]0x2705, $hSt); break } + '^Warning$' { ("{0} {1}" -f ([string]([char]0x26A0) + [char]0xFE0F), $hSt); break } + '^Failure$' { ("{0} {1}" -f [char]0x274C, $hSt); break } + default { $hSt } + } + $blocking = "$($r.BlockingReasons)" + if ($blocking.Length -gt 200) { $blocking = $blocking.Substring(0, 197) + '...' } + $blocking = $blocking -replace '\|', '\|' -replace '\r?\n', ' ' + $reco = if ($r.RecommendedUpdate) { '`' + $r.RecommendedUpdate + '`' } else { '-' } + $curr = if ($r.CurrentVersion) { '`' + $r.CurrentVersion + '`' } else { '-' } + [void]$sb.AppendLine("| ``$($r.ClusterName)`` | $curr | $($r.UpdateState) | $hCell | $readyIcon | $reco | $blocking |") + $rendered++ + } + + if ($totalCount -gt $MaxRows) { + [void]$sb.AppendLine() + [void]$sb.AppendLine("_Showing first $MaxRows of $totalCount clusters. Download the readiness-report.csv artifact for the full list._") + } + [void]$sb.AppendLine() + + $summaryPath = Add-AzLocalPipelineStepSummary -Markdown $sb.ToString() -SummaryFileName $SummaryFileName + + if ($PassThru) { + return [pscustomobject]@{ + TotalCount = $totalCount + ReadyCount = $readyCount + NotReadyCount = $notReadyCount + UpdateRing = $UpdateRing + ReadinessCsvPath = $csvPath + SummaryPath = $summaryPath + Results = $results + } + } +} diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalClusterUpdateReadinessReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalClusterUpdateReadinessReport.ps1 new file mode 100644 index 00000000..be916c1c --- /dev/null +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalClusterUpdateReadinessReport.ps1 @@ -0,0 +1,481 @@ +function Export-AzLocalClusterUpdateReadinessReport { + <# + .SYNOPSIS + Runs the Step.5 pre-flight Update Readiness Assessment workload: + Get-AzLocalClusterUpdateReadiness + Test-AzLocalClusterHealth + -BlockingOnly against a target UpdateRing (or whole fleet), + writes per-check CSV/JUnit XML artifacts, merges them into a + combined JUnit report, and emits the markdown step summary + + step outputs for the v0.8.5 thin-YAML Step.5 pipeline. + + .DESCRIPTION + Phase 1 (v0.8.5) of the thin-YAML refactor. Condenses the inline + `run: |` body of the v0.8.4 Step.5_assess-update-readiness.yml + (GitHub Actions + Azure DevOps) into a single cmdlet call so the + per-platform yml shrinks to a few lines and the workload becomes + unit-testable against synthetic Get-AzLocalClusterUpdateReadiness + and Test-AzLocalClusterHealth results. + + The cmdlet: + + 1. Resolves the output directory (defaults to './artifacts' on + GitHub Actions / Local, or `$env:BUILD_ARTIFACTSTAGINGDIRECTORY` + on Azure DevOps - matching the v0.8.4 yml). + 2. Calls `Get-AzLocalClusterInventory -PassThru` to build a + ResourceId -> UpdateRing map for the per-ring pivot section. + 3. When -Scope is 'all' and the inventory is empty, short- + circuits with zero counts, an IDLE markdown summary, and + empty step outputs (matches the v0.8.4 yml early-exit). + 4. Calls `Get-AzLocalClusterUpdateReadiness` TWICE so the + cmdlet's native -ExportPath emitter produces both the + readiness.csv (humans) and readiness.xml (JUnit, one + per cluster). This preserves the v0.8.4 + dorny/test-reporter contract byte-for-byte. + 5. Calls `Test-AzLocalClusterHealth -BlockingOnly` TWICE + (CSV + JUnit) for the same reason. + 6. Computes the 3-bucket model that matches the + Get-AzLocalClusterUpdateReadiness Summary: + ReadyForUpdate / UpToDate / NotReady. + 7. Computes Critical-health bucket counts from the + Test-AzLocalClusterHealth -PassThru row shape + (ClusterName, HealthState, CriticalCount, WarningCount). + 8. Merges readiness.xml + health-blocking.xml into a single + combined assess-readiness.xml (single Checks-tab entry). + 9. Emits the markdown step summary (8 sections: header tile, + action banner, summary counts, Not-Ready table, Critical- + health table, per-ring pivot, all-clusters detail, + cross-link list) via `Add-AzLocalPipelineStepSummary`. + 10. Emits 2 step outputs via `Set-AzLocalPipelineOutput`: + not_ready, critical_failures. + + Internal reuse (per the v0.8.5 thin-YAML consistency contract): + * `Get-AzLocalClusterInventory` for the all-clusters scope and + the UpdateRing pivot map. + * `Get-AzLocalClusterUpdateReadiness` for the readiness CSV + and JUnit XML. + * `Test-AzLocalClusterHealth -BlockingOnly` for the blocking + health CSV and JUnit XML. + * `Add-AzLocalPipelineStepSummary` for the rendered markdown. + * `Set-AzLocalPipelineOutput` for the step outputs. + * `Get-AzLocalPipelineHost` is implicit (the above branch on it). + + .PARAMETER OutputDirectory + Directory to write artifacts into. Created if it does not exist. + Defaults to './artifacts' (GH / Local) or + `$env:BUILD_ARTIFACTSTAGINGDIRECTORY` (Azure DevOps). + + .PARAMETER Scope + 'all' (default) - assess every cluster the identity can see (via + Get-AzLocalClusterInventory). 'by-update-ring' - assess only + clusters whose UpdateRing tag matches -UpdateRing. + + .PARAMETER UpdateRing + UpdateRing tag value to filter by when -Scope is 'by-update-ring'. + Accepts a single ring ('Wave1'), a semicolon-delimited list + ('Prod;Ring2'), or '***' to match every cluster that HAS the + UpdateRing tag set. Ignored when -Scope is 'all'. + + .PARAMETER ReadinessCsvFileName + Filename for the per-cluster readiness CSV. + Default 'readiness.csv'. + + .PARAMETER ReadinessXmlFileName + Filename for the readiness JUnit XML report. + Default 'readiness.xml'. + + .PARAMETER HealthCsvFileName + Filename for the per-cluster blocking-health CSV. + Default 'health-blocking.csv'. + + .PARAMETER HealthXmlFileName + Filename for the blocking-health JUnit XML report. + Default 'health-blocking.xml'. + + .PARAMETER CombinedXmlFileName + Filename for the merged readiness + blocking-health JUnit report. + Default 'assess-readiness.xml'. + + .PARAMETER SummaryFileName + Per-task summary filename used by `Add-AzLocalPipelineStepSummary` + on Azure DevOps and Local hosts. + Default 'assess-readiness-summary.md'. + + .PARAMETER InstalledModuleVersion + Optional [string] used in the markdown footer + ('Generated by AzLocal.UpdateManagement v'). + + .PARAMETER PassThru + When set, returns a single PSCustomObject summarising the run + (TotalCount, ReadyForUpdateCount, UpToDateCount, NotReadyCount, + CriticalFindings, ClustersWithCritical, ReadinessRows, + HealthRows, and the 5 file paths). Without -PassThru the cmdlet + emits nothing to the pipeline; the artifacts and step outputs + are still produced. + + .OUTPUTS + Nothing by default. When -PassThru is set, a single PSCustomObject. + + .EXAMPLE + Export-AzLocalClusterUpdateReadinessReport -Scope all -PassThru + + .EXAMPLE + Export-AzLocalClusterUpdateReadinessReport -Scope by-update-ring -UpdateRing 'Wave1' + + .NOTES + Module: AzLocal.UpdateManagement (v0.8.5+) + Roadmap: Step.5 - Assess Update Readiness (pre-flight gate). + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$OutputDirectory, + + [Parameter(Mandatory = $false)] + [ValidateSet('all', 'by-update-ring')] + [string]$Scope = 'all', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$UpdateRing, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$ReadinessCsvFileName = 'readiness.csv', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$ReadinessXmlFileName = 'readiness.xml', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$HealthCsvFileName = 'health-blocking.csv', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$HealthXmlFileName = 'health-blocking.xml', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$CombinedXmlFileName = 'assess-readiness.xml', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SummaryFileName = 'assess-readiness-summary.md', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$InstalledModuleVersion, + + [Parameter(Mandatory = $false)] + [switch]$PassThru + ) + + $pipelineHost = Get-AzLocalPipelineHost + + if (-not $OutputDirectory) { + if ($pipelineHost -eq 'AzureDevOps' -and $env:BUILD_ARTIFACTSTAGINGDIRECTORY) { + $OutputDirectory = $env:BUILD_ARTIFACTSTAGINGDIRECTORY + } + else { + $OutputDirectory = './artifacts' + } + } + if (-not (Test-Path -LiteralPath $OutputDirectory)) { + New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + } + + $readinessCsv = Join-Path -Path $OutputDirectory -ChildPath $ReadinessCsvFileName + $readinessXml = Join-Path -Path $OutputDirectory -ChildPath $ReadinessXmlFileName + $healthCsv = Join-Path -Path $OutputDirectory -ChildPath $HealthCsvFileName + $healthXml = Join-Path -Path $OutputDirectory -ChildPath $HealthXmlFileName + $combinedXml = Join-Path -Path $OutputDirectory -ChildPath $CombinedXmlFileName + + # Always fetch inventory so we can build a ResourceId -> UpdateRing map + # for the per-ring pivot in the markdown summary (cheap ARG round-trip). + $inventory = Get-AzLocalClusterInventory -PassThru + $ringByResourceId = @{} + if ($inventory) { + foreach ($inv in $inventory) { + $ringValue = if ($inv.UpdateRing) { [string]$inv.UpdateRing } else { '(no ring tag)' } + $ringByResourceId[$inv.ResourceId] = $ringValue + } + } + + # ---- Scope params ----------------------------------------------------- + $scopeParams = @{} + if ($Scope -eq 'by-update-ring' -and $UpdateRing) { + $scopeParams['ScopeByUpdateRingTag'] = $true + $scopeParams['UpdateRingValue'] = $UpdateRing + Write-Host "Scope: UpdateRing = $UpdateRing" + } + else { + Write-Host "Scope: all clusters (via inventory)" + if (-not $inventory -or @($inventory).Count -eq 0) { + Write-Warning 'No clusters found in inventory.' + Set-AzLocalPipelineOutput -Name 'not_ready' -Value '0' + Set-AzLocalPipelineOutput -Name 'critical_failures' -Value '0' + $idleSb = New-Object 'System.Collections.Generic.List[string]' + [void]$idleSb.Add('## Update Readiness Assessment') + [void]$idleSb.Add('') + [void]$idleSb.Add('**[IDLE]** No clusters found in inventory. Nothing to assess.') + Add-AzLocalPipelineStepSummary -Markdown ($idleSb -join [Environment]::NewLine) -SummaryFileName $SummaryFileName | Out-Null + if ($PassThru) { + return [pscustomobject]@{ + TotalCount = 0 + ReadyForUpdateCount = 0 + UpToDateCount = 0 + NotReadyCount = 0 + CriticalFindings = 0 + ClustersWithCritical = 0 + ReadinessRows = @() + HealthRows = @() + ReadinessCsvPath = $readinessCsv + ReadinessXmlPath = $readinessXml + HealthCsvPath = $healthCsv + HealthXmlPath = $healthXml + CombinedXmlPath = $combinedXml + } + } + return + } + $scopeParams['ClusterResourceIds'] = @($inventory | Select-Object -ExpandProperty ResourceId) + } + + Write-Host '' + Write-Host '========================================' + Write-Host 'Step 1: Readiness (Get-AzLocalClusterUpdateReadiness)' + Write-Host '========================================' + + # CSV for humans + $readiness = Get-AzLocalClusterUpdateReadiness @scopeParams ` + -ExportPath $readinessCsv ` + -PassThru + + # JUnit XML for the test reporter (ExportPath .xml auto-detects JUnitXml). + # Two calls intentionally - this preserves the v0.8.4 dorny/test-reporter + # contract byte-for-byte (the cmdlet's native JUnit shape is what operators + # have screenshots / automations for). ARG round-trip is cheap. + $null = Get-AzLocalClusterUpdateReadiness @scopeParams ` + -ExportPath $readinessXml + + # v0.7.99: 3-bucket model matches Get-AzLocalClusterUpdateReadiness Summary. + # UpToDate clusters are NOT rolled into NotReady - they are a distinct bucket. + $readyForUpdate = @($readiness | Where-Object { $_.ReadyForUpdate -eq $true }).Count + $upToDate = @($readiness | Where-Object { + $_.ReadyForUpdate -ne $true -and + $_.UpdateState -in @('UpToDate', 'AppliedSuccessfully') -and + [string]::IsNullOrEmpty([string]$_.AllAvailableUpdates) + }).Count + $total = @($readiness).Count + $notReady = $total - $readyForUpdate - $upToDate + + Write-Host '' + Write-Host "Total clusters in scope: $total" + Write-Host "Ready for update : $readyForUpdate" + Write-Host "Up to date : $upToDate" + Write-Host "Not ready for update : $notReady" + + Write-Host '' + Write-Host '========================================' + Write-Host 'Step 2: Blocking health (Test-AzLocalClusterHealth -BlockingOnly)' + Write-Host '========================================' + + $health = Test-AzLocalClusterHealth @scopeParams ` + -BlockingOnly ` + -ExportPath $healthCsv ` + -PassThru + + $null = Test-AzLocalClusterHealth @scopeParams ` + -BlockingOnly ` + -ExportPath $healthXml + + # ---- Combined JUnit XML ------------------------------------------------ + # Merge readiness.xml + health-blocking.xml into assess-readiness.xml so + # operators get one Checks-tab entry instead of two. The individual XMLs + # are still published below as [JUnit Debug] entries for parity. + try { + $readinessDoc = [xml](Get-Content -LiteralPath $readinessXml -Raw) + $healthDoc = [xml](Get-Content -LiteralPath $healthXml -Raw) + $combinedDoc = New-Object System.Xml.XmlDocument + $declaration = $combinedDoc.CreateXmlDeclaration('1.0', 'utf-8', $null) + $combinedDoc.AppendChild($declaration) | Out-Null + $rootElement = $combinedDoc.CreateElement('testsuites') + $rootElement.SetAttribute('name', 'Update Readiness Assessment') + $combinedDoc.AppendChild($rootElement) | Out-Null + foreach ($srcDoc in @($readinessDoc, $healthDoc)) { + $suites = if ($srcDoc.DocumentElement.LocalName -eq 'testsuites') { + $srcDoc.DocumentElement.SelectNodes('testsuite') + } + else { + ,$srcDoc.DocumentElement + } + foreach ($suite in $suites) { + $imported = $combinedDoc.ImportNode($suite, $true) + $rootElement.AppendChild($imported) | Out-Null + } + } + $combinedDoc.Save($combinedXml) + Write-Host "Combined JUnit report: $combinedXml" + } + catch { + Write-Warning "Failed to build combined JUnit report: $($_.Exception.Message)" + } + + # Test-AzLocalClusterHealth -PassThru row shape (one row per cluster): + # ClusterName, HealthState, Passed, CriticalCount, WarningCount, Failures + # Aggregate from CriticalCount / Failures (NOT a non-existent Severity + # property, which silently returned 0 in earlier yml versions). + $criticalSum = ($health | Measure-Object -Property CriticalCount -Sum).Sum + $criticalFindings = if ($criticalSum) { [int]$criticalSum } else { 0 } + $clustersWithCritical = @($health | Where-Object { [int]$_.CriticalCount -gt 0 }).Count + + Write-Host '' + Write-Host "Critical findings : $criticalFindings" + Write-Host "Clusters with Critical : $clustersWithCritical" + + # ---- Step outputs ----------------------------------------------------- + Set-AzLocalPipelineOutput -Name 'not_ready' -Value ([string]$notReady) + Set-AzLocalPipelineOutput -Name 'critical_failures' -Value ([string]$clustersWithCritical) + + # ---- Markdown step summary (8 sections) ------------------------------- + $md = New-Object 'System.Collections.Generic.List[string]' + [void]$md.Add('## Update Readiness Assessment') + [void]$md.Add('') + + # 1. Header tile (one-line status, ASCII-safe brackets) + $scopeLabel = $Scope + if ($UpdateRing) { $scopeLabel = "$Scope (UpdateRing = $UpdateRing)" } + $statusWord = if ($notReady -gt 0 -or $clustersWithCritical -gt 0) { 'ATTENTION' } else { 'OK' } + [void]$md.Add("**[$statusWord]** $total cluster(s) assessed | $readyForUpdate Ready for Update | $upToDate Up to Date | $notReady Not Ready for Update | $clustersWithCritical with Critical health failures | Scope: $scopeLabel") + [void]$md.Add('') + + # 2. Action banner + if ($notReady -gt 0 -or $clustersWithCritical -gt 0) { + [void]$md.Add("> **Action required**: $notReady cluster(s) not ready and/or $clustersWithCritical cluster(s) with Critical health failures. Review the **Not-Ready** and **Critical-health** sections below first; the CSV artifacts in ``azlocal-step.5-readiness-assessment-report_*`` carry the full per-finding detail. Remediate (hardware vendor SBE / firmware / cluster health) before or alongside the next apply-updates run. **The healthy clusters are safe to proceed** - Step.6_apply-updates.yml is per-cluster scoped.") + } + else { + [void]$md.Add('> **All clear**: every cluster in scope is ready for update. Safe to proceed with Step.6_apply-updates.yml for this ring.') + } + [void]$md.Add('') + + # 3. Summary counts + [void]$md.Add('### Summary counts') + [void]$md.Add('') + [void]$md.Add('| Metric | Count |') + [void]$md.Add('|--------|-------|') + [void]$md.Add("| Total clusters in scope | $total |") + [void]$md.Add("| Ready for update | $readyForUpdate |") + [void]$md.Add("| Up to date | $upToDate |") + [void]$md.Add("| Not ready for update | $notReady |") + [void]$md.Add("| Clusters with Critical health failures | $clustersWithCritical |") + [void]$md.Add("| Total Critical findings | $criticalFindings |") + [void]$md.Add('') + + # 4. Not-Ready cluster table (blocking findings first) + $notReadyRows = @($readiness | Where-Object { $_.ReadyForUpdate -ne $true }) + if ($notReadyRows.Count -gt 0) { + [void]$md.Add('### Not-Ready clusters (review first)') + [void]$md.Add('') + [void]$md.Add('| Cluster | UpdateRing | Current version | Update state | Health | Blocking reasons |') + [void]$md.Add('|---------|------------|-----------------|--------------|--------|------------------|') + foreach ($r in ($notReadyRows | Sort-Object @{Expression={ if ($ringByResourceId.ContainsKey($_.ClusterResourceId)) { $ringByResourceId[$_.ClusterResourceId] } else { 'zzz' } }}, ClusterName)) { + $ring = if ($ringByResourceId.ContainsKey($r.ClusterResourceId)) { $ringByResourceId[$r.ClusterResourceId] } else { '-' } + $cv = if ($r.CurrentVersion) { $r.CurrentVersion } else { '-' } + $br = if ($r.PSObject.Properties['BlockingReasons'] -and $r.BlockingReasons) { $r.BlockingReasons } else { '-' } + [void]$md.Add("| $($r.ClusterName) | $ring | $cv | $($r.UpdateState) | $($r.HealthState) | $br |") + } + [void]$md.Add('') + } + + # 5. Critical-health cluster table + $criticalRows = @($health | Where-Object { [int]$_.CriticalCount -gt 0 }) + if ($criticalRows.Count -gt 0) { + [void]$md.Add('### Critical-health clusters') + [void]$md.Add('') + [void]$md.Add('_Cross-link: see **Step.4_fleet-connectivity-status** for connectivity-class failures and **Step.9_fleet-health-status** for the broader Critical/Warning catalog._') + [void]$md.Add('') + [void]$md.Add('| Cluster | UpdateRing | Health state | Critical | Warning |') + [void]$md.Add('|---------|------------|--------------|----------|---------|') + foreach ($r in ($criticalRows | Sort-Object @{Expression={[int]$_.CriticalCount}; Descending=$true}, ClusterName)) { + $invMatch = $inventory | Where-Object { $_.ClusterName -eq $r.ClusterName } | Select-Object -First 1 + $ring = if ($invMatch -and $invMatch.UpdateRing) { $invMatch.UpdateRing } else { '-' } + [void]$md.Add("| $($r.ClusterName) | $ring | $($r.HealthState) | $($r.CriticalCount) | $($r.WarningCount) |") + } + [void]$md.Add('') + } + + # 6. Per-UpdateRing pivot (only when >1 ring in scope) + $ringGroups = $readiness | Group-Object @{Expression={ if ($ringByResourceId.ContainsKey($_.ClusterResourceId)) { $ringByResourceId[$_.ClusterResourceId] } else { '(no ring tag)' } }} | Sort-Object Name + if (@($ringGroups).Count -gt 1) { + [void]$md.Add('### Per UpdateRing breakdown') + [void]$md.Add('') + [void]$md.Add('| UpdateRing | Total | Ready for Update | Up to Date | Not Ready for Update |') + [void]$md.Add('|------------|-------|------------------|------------|----------------------|') + foreach ($g in $ringGroups) { + $gReady = @($g.Group | Where-Object { $_.ReadyForUpdate -eq $true }).Count + $gUpToDate = @($g.Group | Where-Object { + $_.ReadyForUpdate -ne $true -and + $_.UpdateState -in @('UpToDate', 'AppliedSuccessfully') -and + [string]::IsNullOrEmpty([string]$_.AllAvailableUpdates) + }).Count + $gNotReady = $g.Count - $gReady - $gUpToDate + [void]$md.Add("| $($g.Name) | $($g.Count) | $gReady | $gUpToDate | $gNotReady |") + } + [void]$md.Add('') + } + + # 7. All-clusters detail table + if ($total -gt 0) { + [void]$md.Add('### All clusters detail') + [void]$md.Add('') + [void]$md.Add('| Cluster | UpdateRing | Current version | Current SBE version | Update state | Health | Ready | Recommended update |') + [void]$md.Add('|---------|------------|-----------------|---------------------|--------------|--------|-------|--------------------|') + foreach ($r in ($readiness | Sort-Object @{Expression={ if ($ringByResourceId.ContainsKey($_.ClusterResourceId)) { $ringByResourceId[$_.ClusterResourceId] } else { 'zzz' } }}, ClusterName)) { + $ring = if ($ringByResourceId.ContainsKey($r.ClusterResourceId)) { $ringByResourceId[$r.ClusterResourceId] } else { '-' } + $cv = if ($r.CurrentVersion) { $r.CurrentVersion } else { '-' } + $csv = if ($r.PSObject.Properties['CurrentSbeVersion'] -and $r.CurrentSbeVersion) { $r.CurrentSbeVersion } else { '-' } + $ru = if ($r.RecommendedUpdate) { $r.RecommendedUpdate } else { '-' } + [void]$md.Add("| $($r.ClusterName) | $ring | $cv | $csv | $($r.UpdateState) | $($r.HealthState) | $($r.ReadyForUpdate) | $ru |") + } + [void]$md.Add('') + } + + # 8. Cross-links to other pipelines + [void]$md.Add('### Cross-link to other pipelines') + [void]$md.Add('') + [void]$md.Add('- **Step.4_fleet-connectivity-status** - root-cause Disconnected / Offline / partial-connectivity findings on the Not-Ready and Critical-health rows above.') + [void]$md.Add('- **Step.6_apply-updates** - apply updates to the Ready clusters in this ring (manual workflow_dispatch, or wait for the scheduled cron firing).') + [void]$md.Add('- **Step.7_monitor-updates** - tail in-flight runs once Step.6 has started (auto-trigger on Step.6 completion, or manual).') + [void]$md.Add('- **Step.9_fleet-health-status** - broader Critical / Warning health catalog across the whole fleet (not just blocking-only).') + [void]$md.Add('') + [void]$md.Add('_Note: the **Update Readiness Assessment** entry in the Checks tab is the merged combined view; the [JUnit Debug] entries are diagnostic mirrors for CI/test tooling._') + + if ($InstalledModuleVersion) { + [void]$md.Add('') + [void]$md.Add(('_Generated by AzLocal.UpdateManagement v{0}._' -f $InstalledModuleVersion)) + } + + Add-AzLocalPipelineStepSummary -Markdown ($md -join [Environment]::NewLine) -SummaryFileName $SummaryFileName | Out-Null + + if ($PassThru) { + return [pscustomobject]@{ + TotalCount = [int]$total + ReadyForUpdateCount = [int]$readyForUpdate + UpToDateCount = [int]$upToDate + NotReadyCount = [int]$notReady + CriticalFindings = [int]$criticalFindings + ClustersWithCritical = [int]$clustersWithCritical + ReadinessRows = @($readiness) + HealthRows = @($health) + ReadinessCsvPath = $readinessCsv + ReadinessXmlPath = $readinessXml + HealthCsvPath = $healthCsv + HealthXmlPath = $healthXml + CombinedXmlPath = $combinedXml + } + } +} diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetConnectivityStatusReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetConnectivityStatusReport.ps1 new file mode 100644 index 00000000..a4562a7d --- /dev/null +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetConnectivityStatusReport.ps1 @@ -0,0 +1,443 @@ +function Export-AzLocalFleetConnectivityStatusReport { + <# + .SYNOPSIS + Runs the Step.4 Fleet Connectivity Status workload: + Get-AzLocalFleetConnectivityStatus + per-scope severity + classification + JUnit XML + markdown summary + step outputs + for the v0.8.5 thin-YAML Step.4 pipeline. + + .DESCRIPTION + Phase 1 (v0.8.5) of the thin-YAML refactor. Condenses the inline + `run: |` body of the v0.8.4 Step.4_fleet-connectivity-status.yml + (GitHub Actions + Azure DevOps) into a single cmdlet call so the + per-platform yml shrinks to a few lines and the workload becomes + unit-testable against synthetic Get-AzLocalFleetConnectivityStatus + results. + + The cmdlet: + + 1. Resolves the output directory (defaults to './reports' on + GitHub Actions / Local, or `$env:BUILD_ARTIFACTSTAGINGDIRECTORY` + on Azure DevOps - matching the v0.8.4 yml). + 2. Calls `Get-AzLocalFleetConnectivityStatus` once (with the + optional -SubscriptionId scope) to get all 7 row-sets: + ClusterRows, ArcSummary, NonConnectedMachines, NicIssues, + NicAll, NicStats, ArbRows. The cmdlet's native -ExportPath + emitter writes the 7 CSV + 7 JSON files to the output dir. + 3. Classifies severity per row using the v0.8.4 rules: + Critical = confirmed disconnected/offline/expired, + Warning = expired/partial/unknown, Pass = healthy. + 4. Builds a JUnit XML report (4 suites: Cluster, Arc Agent, + Physical NIC, ARB) via the shared + `New-AzLocalPipelineJUnitXml` Private helper. When the + fleet is fully green, a single synthetic + 'Fleet Connectivity' suite with one passing testcase is + emitted so dorny renders a green banner instead of an + empty file (matches v0.8.4 byte-for-byte). + 5. Calls `New-AzLocalFleetConnectivityStatusSummary` (Public + renderer shared with the v0.8.4 ADO yml) to build the + markdown step summary. + 6. Pushes the markdown via `Add-AzLocalPipelineStepSummary`. + 7. Emits 12 step outputs via `Set-AzLocalPipelineOutput` + (lowercase snake_case per v0.8.5 convention): + cluster_total, cluster_fail, arc_total, arc_fail, + nic_total, nic_fail, nic_all_total, arb_total, arb_fail, + total_failures, critical_count, warning_count. + + Internal reuse (per the v0.8.5 thin-YAML consistency contract): + * `Get-AzLocalFleetConnectivityStatus` for all data fetch. + * `New-AzLocalPipelineJUnitXml` for JUnit XML (replaces the + inline 200-line StringBuilder of the v0.8.4 yml). + * `New-AzLocalFleetConnectivityStatusSummary` for markdown. + * `Add-AzLocalPipelineStepSummary` for the rendered markdown. + * `Set-AzLocalPipelineOutput` for the step outputs. + * `Get-AzLocalPipelineHost` is implicit (the above branch on it). + + .PARAMETER OutputDirectory + Directory to write artifacts into. Created if it does not exist. + Defaults to './reports' (GH / Local) or + `$env:BUILD_ARTIFACTSTAGINGDIRECTORY` (Azure DevOps). + + .PARAMETER SubscriptionFilter + Optional comma-delimited subscription-id list. When set, the + first entry is forwarded as -SubscriptionId to + `Get-AzLocalFleetConnectivityStatus` (matches the v0.8.4 yml's + INPUT_SUBSCRIPTION_FILTER semantics). When empty, the cmdlet + scans every subscription visible to the federated identity. + + .PARAMETER JUnitXmlFileName + Filename for the connectivity-status JUnit XML report. + Default 'fleet-connectivity-status.xml'. + + .PARAMETER SummaryFileName + Per-task markdown summary filename used by + `Add-AzLocalPipelineStepSummary` on Azure DevOps and Local hosts. + Default 'fleet-connectivity-summary.md'. + + .PARAMETER InstalledModuleVersion + Optional [string] used in the markdown footer + ('Generated by AzLocal.UpdateManagement v'). Forwarded to + `New-AzLocalFleetConnectivityStatusSummary`. + + .PARAMETER PassThru + When set, returns a single PSCustomObject summarising the run + (counts, the raw data row-sets, the filtered Fail row-sets, + and the JUnit/summary file paths). Without -PassThru the + cmdlet emits nothing to the pipeline; the artifacts and step + outputs are still produced. + + .OUTPUTS + Nothing by default. When -PassThru is set, a single PSCustomObject + with: ClusterTotal, ClusterFail, ArcTotal, ArcFail, NicTotal, + NicFail, NicAllTotal, ArbTotal, ArbFail, TotalFailures, + CriticalCount, WarningCount, ClusterRows, ArcSummary, + NonConnectedMachines, NicIssues, NicAll, NicStats, ArbRows, + ClusterFailures, ArcFailures, NicFailures, ArbFailures, + JUnitXmlPath, SummaryPath. + + .EXAMPLE + Export-AzLocalFleetConnectivityStatusReport -PassThru + + .EXAMPLE + Export-AzLocalFleetConnectivityStatusReport ` + -SubscriptionFilter '00000000-0000-0000-0000-000000000001' ` + -OutputDirectory './my-reports' + + .NOTES + Module: AzLocal.UpdateManagement (v0.8.5+) + Roadmap: Step.4 - Fleet Connectivity Status (observability gate). + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$OutputDirectory, + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$SubscriptionFilter, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$JUnitXmlFileName = 'fleet-connectivity-status.xml', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SummaryFileName = 'fleet-connectivity-summary.md', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$InstalledModuleVersion, + + [Parameter(Mandatory = $false)] + [switch]$PassThru + ) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + $pipelineHost = Get-AzLocalPipelineHost + + if (-not $OutputDirectory) { + if ($pipelineHost -eq 'AzureDevOps' -and $env:BUILD_ARTIFACTSTAGINGDIRECTORY) { + $OutputDirectory = $env:BUILD_ARTIFACTSTAGINGDIRECTORY + } + else { + $OutputDirectory = './reports' + } + } + if (-not (Test-Path -LiteralPath $OutputDirectory)) { + New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + } + + $xmlPath = Join-Path -Path $OutputDirectory -ChildPath $JUnitXmlFileName + + # ---- Optional subscription scope -------------------------------------- + $invokeArgs = @{ ExportPath = $OutputDirectory; PassThru = $true } + if ($SubscriptionFilter) { + $subList = $SubscriptionFilter -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } + if ($subList -and @($subList).Count -gt 0) { + $invokeArgs['SubscriptionId'] = [string]$subList[0] + Write-Host "Subscription filter active (first sub): $($subList[0])" + } + } + if (-not $invokeArgs.ContainsKey('SubscriptionId')) { + Write-Host 'Subscription filter empty - scanning all subscriptions visible to the federated identity.' + } + + Write-Host '========================================' + Write-Host 'Fleet Connectivity Status Collection' + Write-Host '========================================' + + # Collect all 7 connectivity row-sets via the module cmdlet. + # Runs 5 ARG queries, processes results client-side, and exports + # 7 CSV + 7 JSON files to $OutputDirectory. + $data = Get-AzLocalFleetConnectivityStatus @invokeArgs + + $clusterRows = @($data.ClusterRows) + $arcSummary = @($data.ArcSummary) + $arcRows = @($data.NonConnectedMachines) + $nicRows = @($data.NicIssues) + $nicAllRows = @($data.NicAll) + $nicStats = @($data.NicStats) + $arbRows = @($data.ArbRows) + + Write-Host 'Fleet Connectivity Collection complete:' + Write-Host " Clusters : $($clusterRows.Count) total" + Write-Host " Arc agents: $($arcSummary.Count) distinct status value(s); $($arcRows.Count) NOT 'Connected'" + Write-Host " Physical NIC issues: $($nicRows.Count) (Disconnected with non-APIPA IP)" + Write-Host " NIC inventory (full): $($nicAllRows.Count) total NICs across $($nicStats.Count) NicType+NicStatus groups" + Write-Host " ARB appliances: $($arbRows.Count)" + + # ---- Severity classifiers --------------------------------------------- + # Case-insensitive. Healthy = Pass; confirmed bad = Critical; + # everything else (Unknown / NotSpecified / partial) = Warning. + $getClusterSeverity = { param($s) switch -Regex ([string]$s) { '^(Connected|ConnectedRecently)$' { 'Pass'; break } '^(Disconnected|Expired|Offline|Error)$' { 'Critical'; break } default { 'Warning' } } } + $getArcSeverity = { param($s) switch -Regex ([string]$s) { '^(Connected)$' { 'Pass'; break } '^(Disconnected|Expired|Offline|Error)$' { 'Critical'; break } default { 'Warning' } } } + $getNicSeverity = { param($s) switch -Regex ([string]$s) { '^(Connected|Up)$' { 'Pass'; break } '^(Disconnected|Down|Disabled)$' { 'Critical'; break } default { 'Warning' } } } + $getArbSeverity = { param($s) switch -Regex ([string]$s) { '^(Running)$' { 'Pass'; break } '^(Offline|Failed)$' { 'Critical'; break } default { 'Warning' } } } + + $clusterFail = @($clusterRows | Where-Object { (& $getClusterSeverity $_.ConnectivityStatus) -ne 'Pass' }) + $arcFail = @($arcRows | Where-Object { (& $getArcSeverity $_.AgentStatus) -ne 'Pass' }) + $nicFail = @($nicRows | Where-Object { (& $getNicSeverity $_.NicStatus) -ne 'Pass' }) + $arbFail = @($arbRows | Where-Object { (& $getArbSeverity $_.ArbStatus) -ne 'Pass' }) + + $totalFailures = $clusterFail.Count + $arcFail.Count + $nicFail.Count + $arbFail.Count + $criticalCount = @($clusterFail | Where-Object { (& $getClusterSeverity $_.ConnectivityStatus) -eq 'Critical' }).Count ` + + @($arcFail | Where-Object { (& $getArcSeverity $_.AgentStatus) -eq 'Critical' }).Count ` + + @($nicFail | Where-Object { (& $getNicSeverity $_.NicStatus) -eq 'Critical' }).Count ` + + @($arbFail | Where-Object { (& $getArbSeverity $_.ArbStatus) -eq 'Critical' }).Count + $warningCount = $totalFailures - $criticalCount + + # ---- Build JUnit suites via the shared helper ------------------------- + $suites = @() + + if ($clusterFail.Count -gt 0) { + $cases = foreach ($r in $clusterFail) { + $sev = & $getClusterSeverity $r.ConnectivityStatus + $portal = "https://portal.azure.com/#@/resource$($r.ClusterId)" + @{ + Name = "Cluster '$($r.ClusterName)' :: ConnectivityStatus=$($r.ConnectivityStatus)" + ClassName = [string]$r.ClusterName + Failure = @{ + Type = $sev + Message = "Cluster connectivity is '$($r.ConnectivityStatus)' (cluster.status='$($r.ClusterStatus)')" + Body = "ResourceGroup: $($r.ResourceGroup)`nSubscriptionId: $($r.SubscriptionId)`nClusterPortalUrl: $portal" + } + Properties = [ordered]@{ + ClusterName = [string]$r.ClusterName + ClusterResourceId = [string]$r.ClusterId + UpdateName = "ClusterConnectivity=$($r.ConnectivityStatus)" + Status = $sev + FailureReason = "Cluster connectivity '$($r.ConnectivityStatus)'" + Severity = $sev + ClusterPortalUrl = $portal + TargetResourceName = [string]$r.ClusterName + TargetResourceType = 'microsoft.azurestackhci/clusters' + } + } + } + $suites += @{ Name = 'Cluster Connectivity'; TestCases = @($cases) } + } + + if ($arcFail.Count -gt 0) { + $cases = foreach ($r in $arcFail) { + $sev = & $getArcSeverity $r.AgentStatus + $portal = "https://portal.azure.com/#@/resource$($r.MachineId)" + @{ + Name = "Machine '$($r.NodeName)' :: Status=$($r.AgentStatus)" + ClassName = [string]$r.ClusterName + Failure = @{ + Type = $sev + Message = "Machine '$($r.NodeName)' has Arc agent status '$($r.AgentStatus)' (last status change: $($r.LastStatusChange))" + Body = "ClusterName: $($r.ClusterName)`n" + + "AgentStatus: $($r.AgentStatus)`n" + + "OS SKU: $($r.OsSku)`n" + + "OS Version: $($r.OsVersion)`n" + + "Cluster Version: $($r.ClusterVersion)`n" + + "AgentVersion: $($r.AgentVersion)`n" + + "Disconnected Since (lastStatusChange): $($r.LastStatusChange)`n" + + "ResourceGroup: $($r.ResourceGroup)`n" + + "MachineId: $($r.MachineId)" + } + Properties = [ordered]@{ + ClusterName = [string]$r.ClusterName + ClusterResourceId = [string]$r.ClusterId + UpdateName = "ArcAgent=$($r.AgentStatus) [$($r.NodeName)]" + Status = $sev + FailureReason = "Arc agent '$($r.AgentStatus)' on machine '$($r.NodeName)'" + Severity = $sev + ClusterPortalUrl = $portal + TargetResourceName = [string]$r.NodeName + TargetResourceType = 'microsoft.hybridcompute/machines' + } + } + } + $suites += @{ Name = 'Arc Agent Connectivity'; TestCases = @($cases) } + } + + if ($nicFail.Count -gt 0) { + $cases = foreach ($r in $nicFail) { + $sev = & $getNicSeverity $r.NicStatus + $portal = "https://portal.azure.com/#@/resource$($r.MachineId)" + @{ + Name = "NIC '$($r.NicName)' on '$($r.NodeName)' :: NicStatus=$($r.NicStatus)" + ClassName = [string]$r.ClusterName + Failure = @{ + Type = $sev + Message = "Physical NIC '$($r.NicName)' on node '$($r.NodeName)' reports status '$($r.NicStatus)'" + Body = "ClusterName: $($r.ClusterName)`nInterface: $($r.InterfaceDescription)`nDriverVersion: $($r.DriverVersion)`nIp4Address: $($r.Ip4Address)`nMachineId: $($r.MachineId)" + } + Properties = [ordered]@{ + ClusterName = [string]$r.ClusterName + ClusterResourceId = '' + UpdateName = "PhysicalNic=$($r.NicStatus) [$($r.NodeName)/$($r.NicName)]" + Status = $sev + FailureReason = "Physical NIC '$($r.NicName)' status '$($r.NicStatus)' on node '$($r.NodeName)'" + Severity = $sev + ClusterPortalUrl = $portal + TargetResourceName = "$($r.NodeName)/$($r.NicName)" + TargetResourceType = 'microsoft.azurestackhci/edgedevices/nicDetails' + } + } + } + $suites += @{ Name = 'Physical NIC Status'; TestCases = @($cases) } + } + + if ($arbFail.Count -gt 0) { + $cases = foreach ($r in $arbFail) { + $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 { @() } + $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 { '' } + @{ + Name = "ARB '$($r.ArbName)' :: ArbStatus=$($r.ArbStatus)" + ClassName = if ($r.ClusterName) { [string]$r.ClusterName } else { '(no cluster mapping)' } + Failure = @{ + Type = $sev + Message = "Azure Resource Bridge '$($r.ArbName)' is '$($r.ArbStatus)' (days since lastModified=$($r.DaysSinceLastModified))$multiClusterNote" + Body = "ClusterName: $($r.ClusterName)`nArbId: $($r.ArbId)`nLastModified: $($r.LastModified)`nDaysSinceLastModified: $($r.DaysSinceLastModified)`nClusterPortalUrl: $clusterPortal" + } + Properties = [ordered]@{ + ClusterName = if ($r.ClusterName) { [string]$r.ClusterName } else { '' } + ClusterResourceId = if ($primaryClusterId) { $primaryClusterId } else { '' } + UpdateName = "ARB=$($r.ArbStatus) [$($r.ArbName)]" + Status = $sev + FailureReason = "ARB '$($r.ArbName)' status '$($r.ArbStatus)'$multiClusterNote" + Severity = $sev + ClusterPortalUrl = $clusterPortal + TargetResourceName = [string]$r.ArbName + TargetResourceType = 'microsoft.resourceconnector/appliances' + } + } + } + $suites += @{ Name = 'Azure Resource Bridge'; TestCases = @($cases) } + } + + if ($totalFailures -eq 0) { + # All-green: emit a single passing testcase so dorny renders a + # green banner instead of an empty file (matches v0.8.4 yml). + $suites += @{ + Name = 'Fleet Connectivity' + TestCases = @( + @{ Name = 'No connectivity issues across the fleet' } + ) + } + } + + $null = New-AzLocalPipelineJUnitXml ` + -TestSuitesName 'AzureLocalFleetConnectivity' ` + -Suites $suites ` + -OutputPath $xmlPath + + # ---- Markdown summary via the shared renderer ------------------------- + # Arc totals come from the status-summary histogram so the markdown + # table can show healthy + . + $arcTotalMachines = ($arcSummary | Measure-Object -Property Count -Sum).Sum + if ($null -eq $arcTotalMachines) { $arcTotalMachines = 0 } + + $counts = @{ + ClusterTotal = $clusterRows.Count + ClusterFail = $clusterFail.Count + ArcTotal = $arcTotalMachines + ArcFail = $arcRows.Count + NicTotal = $nicRows.Count + NicFail = $nicFail.Count + NicAllTotal = $nicAllRows.Count + ArbTotal = $arbRows.Count + ArbFail = $arbFail.Count + TotalFailures = $totalFailures + CriticalCount = $criticalCount + WarningCount = $warningCount + } + + $summaryArgs = @{ + ClusterRows = $clusterRows + ArcSummary = $arcSummary + ArcRows = $arcRows + NicRows = $nicRows + NicStats = $nicStats + ArbRows = $arbRows + Counts = $counts + } + $md = New-AzLocalFleetConnectivityStatusSummary @summaryArgs + + Add-AzLocalPipelineStepSummary -Markdown $md -SummaryFileName $SummaryFileName | Out-Null + + # ---- Step outputs (lowercase snake_case, v0.8.5 convention) ----------- + Set-AzLocalPipelineOutput -Name 'cluster_total' -Value ([string]$clusterRows.Count) + Set-AzLocalPipelineOutput -Name 'cluster_fail' -Value ([string]$clusterFail.Count) + Set-AzLocalPipelineOutput -Name 'arc_total' -Value ([string]$arcTotalMachines) + Set-AzLocalPipelineOutput -Name 'arc_fail' -Value ([string]$arcRows.Count) + Set-AzLocalPipelineOutput -Name 'nic_total' -Value ([string]$nicRows.Count) + Set-AzLocalPipelineOutput -Name 'nic_fail' -Value ([string]$nicFail.Count) + Set-AzLocalPipelineOutput -Name 'nic_all_total' -Value ([string]$nicAllRows.Count) + Set-AzLocalPipelineOutput -Name 'arb_total' -Value ([string]$arbRows.Count) + Set-AzLocalPipelineOutput -Name 'arb_fail' -Value ([string]$arbFail.Count) + Set-AzLocalPipelineOutput -Name 'total_failures' -Value ([string]$totalFailures) + Set-AzLocalPipelineOutput -Name 'critical_count' -Value ([string]$criticalCount) + Set-AzLocalPipelineOutput -Name 'warning_count' -Value ([string]$warningCount) + + Write-Host '' + Write-Host "TOTAL FAILURES: $totalFailures (Critical=$criticalCount, Warning=$warningCount)" + + if ($PassThru) { + return [pscustomobject]@{ + ClusterTotal = $clusterRows.Count + ClusterFail = $clusterFail.Count + ArcTotal = $arcTotalMachines + ArcFail = $arcRows.Count + NicTotal = $nicRows.Count + NicFail = $nicFail.Count + NicAllTotal = $nicAllRows.Count + ArbTotal = $arbRows.Count + ArbFail = $arbFail.Count + TotalFailures = $totalFailures + CriticalCount = $criticalCount + WarningCount = $warningCount + ClusterRows = $clusterRows + ArcSummary = $arcSummary + NonConnectedMachines = $arcRows + NicIssues = $nicRows + NicAll = $nicAllRows + NicStats = $nicStats + ArbRows = $arbRows + ClusterFailures = $clusterFail + ArcFailures = $arcFail + NicFailures = $nicFail + ArbFailures = $arbFail + JUnitXmlPath = $xmlPath + SummaryPath = (Join-Path -Path $OutputDirectory -ChildPath $SummaryFileName) + } + } +} diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 new file mode 100644 index 00000000..226df40f --- /dev/null +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetHealthStatusReport.ps1 @@ -0,0 +1,632 @@ +function Export-AzLocalFleetHealthStatusReport { + <# + .SYNOPSIS + Snapshots 24-hour fleet health-check failures and emits the full + Step.9 artefact bundle (CSV + JSON + JUnit XML + markdown summary). + + .DESCRIPTION + Public entry-point for the v0.8.5 thin-YAML refactor of the Step.9 + fleet-health-status pipeline. Before v0.8.5 the GitHub Actions and + Azure DevOps Step.9_fleet-health-status.yml files each carried + ~600 lines of inline PowerShell (failure collection + summary + roll-up + overview collection + JUnit XML construction + + collapsible markdown rendering + step-output emission). This + cmdlet condenses that workload into a single Public PowerShell + entry-point that both pipeline platforms call with a thin + parameter splat. + + The cmdlet: + 1. Queries the fleet for unresolved Critical / Warning + health-check failures via Get-AzLocalFleetHealthFailures + (ARG-first; -View Detail). Detail rows feed the JUnit XML, + the in-process Summary roll-up, and the per-cluster + collapsible markdown view. + 2. Computes a SUMMARY view (Group-Object FailureReason + + Severity) entirely in-process, so the cmdlet issues at + most ONE health-failures ARG query regardless of fleet + size. Severity sorts FIRST (Critical before Warning), + then ClusterCount desc, then FailureCount desc. + 3. Queries Get-AzLocalFleetHealthOverview for the per-cluster + rollup (HealthStatus / UpdateStatus / CurrentVersion / + SbeVersion / AzureConnection / LastChecked / NodeCount). + 4. Writes the standard CSV+JSON artefact bundle: detail, + summary and overview pairs under -OutputDirectory. + 5. Generates a JUnit XML document with two diagnostic + testsuites ('[JUnit Debug] Critical Health Failures' / + '[JUnit Debug] Warning Health Failures'). Each testcase + carries consumed by the New-AzLocalIncident + ITSM connector (ClusterResourceId / FailureReason / Severity / + ClusterPortalUrl / TargetResourceName / TargetResourceType). + FailureReason is fed into the UpdateName slot of the SHA256 + dedupe key so the ITSM connector raises one ticket per + (cluster, failing check) pair. + 6. Renders a four-section markdown step summary: KPI table, + Fleet Health Overview (top 100 clusters, one row each), + Health Check Failures By Reason (top 25 reasons, with + zipped portal-link hyperlinks for affected clusters), and + a per-cluster collapsible
block showing every + failing check (capped at 100 clusters; CSV artefact carries + the full list). + 7. Emits 8 lowercase step outputs describing fleet bucket + counts (total_clusters, total_failures, critical_count, + warning_count, distinct_reasons, overview_rows, + healthy_clusters, total_in_sub). + + The cmdlet replaces the inline 'Collect Fleet Health Failures' + AND the downstream 'Create Fleet Health Summary' steps from the + pre-v0.8.5 YAML. + + .PARAMETER OutputDirectory + Directory to write artefacts into. When omitted, defaults to + $env:BUILD_ARTIFACTSTAGINGDIRECTORY\reports on Azure DevOps + hosts (resolved via Get-AzLocalPipelineHost) and './reports' + everywhere else (GitHub Actions, local interactive use). + + .PARAMETER Scope + Fleet selector: 'all' (every cluster the caller can read) or + 'by-update-ring' (clusters whose UpdateRing tag matches + -UpdateRing). 'by-update-ring' is forwarded to + Get-AzLocalFleetHealthFailures and Get-AzLocalFleetHealthOverview + via the -UpdateRingTag parameter. + + .PARAMETER UpdateRing + UpdateRing tag value to filter on when Scope='by-update-ring'. + Accepts a single value, a ';'-delimited list, or '***' (three + stars) as a wildcard. Ignored when Scope='all'. + + .PARAMETER Severity + Severity filter applied at Resource Graph. 'All' (default) = + Critical + Warning; 'Critical' or 'Warning' restricts to that + tier only. Informational is always excluded. + + .PARAMETER MaxOverviewRows + Maximum rows rendered in the Fleet Health Overview table. + Default 100. Beyond this the markdown shows a + '*Showing first N clusters of M; see csv artifact for the full + list.*' truncation note. Does NOT cap the CSV / JSON outputs. + + .PARAMETER MaxSummaryRows + Maximum rows rendered in the Health Check Failures By Reason + table. Default 25. Same truncation behaviour as + -MaxOverviewRows. + + .PARAMETER MaxDetailClusters + Maximum collapsible
blocks rendered. Default 100. + Cap is per-CLUSTER, not per-row, so no cluster's failures are + silently truncated. + + .PARAMETER DetailCsvFileName + Override the default 'fleet-health-detail.csv' filename. + + .PARAMETER DetailJsonFileName + Override the default 'fleet-health-detail.json' filename. + + .PARAMETER SummaryCsvFileName + Override the default 'fleet-health-summary.csv' filename. + + .PARAMETER SummaryJsonFileName + Override the default 'fleet-health-summary.json' filename. + + .PARAMETER OverviewCsvFileName + Override the default 'fleet-health-overview.csv' filename. + + .PARAMETER OverviewJsonFileName + Override the default 'fleet-health-overview.json' filename. + + .PARAMETER XmlFileName + Override the default 'fleet-health-status.xml' JUnit filename. + + .PARAMETER SummaryFileName + Override the default 'fleet-health-status-summary.md' filename + (the markdown rendered into GITHUB_STEP_SUMMARY / ADO + upload-summary). + + .PARAMETER InstalledModuleVersion + Optional version string rendered in the markdown footer + (e.g. v0.8.5). When omitted, no footer line is appended. + + .PARAMETER Now + Optional [datetime] used as 'snapshot time' in the markdown + summary (Generated at ...). Defaults to (Get-Date). + Parameterised so unit tests can assert against a fixed value. + + .PARAMETER PassThru + Return a PSCustomObject describing the snapshot (counts + file + paths + DetailRows + SummaryRows + OverviewRows). Default is + no return value (the cmdlet only writes files + step outputs + + markdown summary). + + .OUTPUTS + [PSCustomObject] when -PassThru is supplied. Properties: + - TotalClusters, TotalFailures, CriticalCount, WarningCount, + DistinctReasons, OverviewRows, HealthyClusters, TotalInSub + - DetailCsvPath, DetailJsonPath, SummaryCsvPath, + SummaryJsonPath, OverviewCsvPath, OverviewJsonPath, + XmlPath, SummaryPath + - DetailRows, SummaryRows, OverviewRowsData + + .EXAMPLE + Export-AzLocalFleetHealthStatusReport + # All-cluster snapshot; writes the full artefact bundle to ./reports/. + + .EXAMPLE + Export-AzLocalFleetHealthStatusReport -Scope by-update-ring -UpdateRing Prod -PassThru + # Restricts to clusters tagged UpdateRing=Prod and returns the + # snapshot object for downstream PowerShell use. + + .EXAMPLE + # Used by Step.9_fleet-health-status.yml (GitHub Actions + Azure DevOps): + Export-AzLocalFleetHealthStatusReport ` + -Scope $env:INPUT_SCOPE ` + -UpdateRing $env:INPUT_UPDATE_RING ` + -Severity $env:INPUT_SEVERITY ` + -InstalledModuleVersion (Get-Module AzLocal.UpdateManagement).Version + + .NOTES + Author : Neil Bird, Microsoft + Version: v0.8.5 + Added : v0.8.5 (Step.9 thin-YAML port - condenses ~600 lines of inline + PowerShell into a single Public entry-point). + Reuses : Get-AzLocalFleetHealthFailures, Get-AzLocalFleetHealthOverview, + New-AzLocalPipelineJUnitXml, Set-AzLocalPipelineOutput, + Add-AzLocalPipelineStepSummary, Get-AzLocalPipelineHost. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$OutputDirectory, + + [Parameter(Mandatory = $false)] + [ValidateSet('all', 'by-update-ring')] + [string]$Scope = 'all', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$UpdateRing, + + [Parameter(Mandatory = $false)] + [ValidateSet('All', 'Critical', 'Warning')] + [string]$Severity = 'All', + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 1000)] + [int]$MaxOverviewRows = 100, + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 500)] + [int]$MaxSummaryRows = 25, + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 1000)] + [int]$MaxDetailClusters = 100, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$DetailCsvFileName = 'fleet-health-detail.csv', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$DetailJsonFileName = 'fleet-health-detail.json', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SummaryCsvFileName = 'fleet-health-summary.csv', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SummaryJsonFileName = 'fleet-health-summary.json', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$OverviewCsvFileName = 'fleet-health-overview.csv', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$OverviewJsonFileName = 'fleet-health-overview.json', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$XmlFileName = 'fleet-health-status.xml', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SummaryFileName = 'fleet-health-status-summary.md', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$InstalledModuleVersion, + + [Parameter(Mandatory = $false)] + [datetime]$Now = (Get-Date), + + [Parameter(Mandatory = $false)] + [switch]$PassThru + ) + + $pipelineHost = Get-AzLocalPipelineHost + + if (-not $OutputDirectory) { + if ($pipelineHost -eq 'AzureDevOps' -and $env:BUILD_ARTIFACTSTAGINGDIRECTORY) { + $OutputDirectory = Join-Path -Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY -ChildPath 'reports' + } + else { + $OutputDirectory = './reports' + } + } + if (-not (Test-Path -LiteralPath $OutputDirectory)) { + New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + } + + $detailCsv = Join-Path -Path $OutputDirectory -ChildPath $DetailCsvFileName + $detailJson = Join-Path -Path $OutputDirectory -ChildPath $DetailJsonFileName + $summaryCsv = Join-Path -Path $OutputDirectory -ChildPath $SummaryCsvFileName + $summaryJson = Join-Path -Path $OutputDirectory -ChildPath $SummaryJsonFileName + $overviewCsv = Join-Path -Path $OutputDirectory -ChildPath $OverviewCsvFileName + $overviewJson = Join-Path -Path $OutputDirectory -ChildPath $OverviewJsonFileName + $xmlPath = Join-Path -Path $OutputDirectory -ChildPath $XmlFileName + + Write-Host "========================================" -ForegroundColor Cyan + Write-Host "Fleet Health Status Collection" -ForegroundColor Cyan + Write-Host "========================================" -ForegroundColor Cyan + Write-Host "Scope : $Scope" + Write-Host "Severity : $Severity" + if ($Scope -eq 'by-update-ring' -and $UpdateRing) { Write-Host "UpdateRing: $UpdateRing" } + Write-Host "" + + # ---- Step 1: pull DETAIL view once ------------------------------------ + $argSplat = @{ Severity = $Severity } + 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) + 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 + + # ---- Step 2: in-process SUMMARY view ---------------------------------- + # AffectedClusters uses '; ' separator (matches cmdlet); adds positionally + # paired AffectedClusterPortalUrls so the markdown renderer can zip them + # into hyperlinks. Severity sorts FIRST (Critical before Warning). + $summary = @() + if ($detail.Count -gt 0) { + $summary = @( + $detail | Group-Object -Property FailureReason, Severity | ForEach-Object { + $first = $_.Group | Select-Object -First 1 + $clusterList = @($_.Group | Select-Object -ExpandProperty ClusterName -Unique | Sort-Object) + $clusterPortalUrls = @( + foreach ($cn in $clusterList) { + $portalRow = $_.Group | Where-Object { $_.ClusterName -eq $cn } | Select-Object -First 1 + if ($portalRow -and $portalRow.ClusterPortalUrl) { $portalRow.ClusterPortalUrl } else { '' } + } + ) + $latestOcc = ($_.Group | Measure-Object -Property LastOccurrence -Maximum).Maximum + [pscustomobject]@{ + FailureReason = $first.FailureReason + Severity = $first.Severity + ClusterCount = $clusterList.Count + FailureCount = $_.Group.Count + AffectedClusters = ($clusterList -join '; ') + AffectedClusterPortalUrls = ($clusterPortalUrls -join '; ') + LatestOccurrence = $latestOcc + Description = $first.Description + Remediation = $first.Remediation + } + } | Sort-Object @{Expression={ if ($_.Severity -eq 'Critical') { 1 } elseif ($_.Severity -eq 'Warning') { 2 } else { 3 } };Descending=$false}, + @{Expression={$_.ClusterCount};Descending=$true}, + @{Expression={$_.FailureCount};Descending=$true} + ) + } + $summary | Export-Csv -Path $summaryCsv -NoTypeInformation -Force + $summary | ConvertTo-Json -Depth 6 | Out-File -FilePath $summaryJson -Encoding utf8 + + # ---- Step 3: OVERVIEW (one row per cluster) --------------------------- + 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) + if (-not $overview) { $overview = @() } + $overview | ConvertTo-Json -Depth 6 | Out-File -FilePath $overviewJson -Encoding utf8 + Write-Host "Overview rows: $($overview.Count)." -ForegroundColor Green + + # ---- Step 4: bucket counts ------------------------------------------- + $criticalDetail = @($detail | Where-Object { $_.Severity -eq 'Critical' }) + $warningDetail = @($detail | Where-Object { $_.Severity -eq 'Warning' }) + $totalClusters = @($detail | Select-Object -ExpandProperty ClusterName -Unique).Count + $totalFailures = [int]$detail.Count + $criticalCount = [int]$criticalDetail.Count + $warningCount = [int]$warningDetail.Count + $distinctReasons = [int]$summary.Count + $healthyClusters = [int](@($overview | Where-Object { $_.HealthStatus -eq 'Healthy' }).Count) + $totalInSub = [int](@($overview).Count) + + # ---- Step 5: JUnit XML via shared emitter ---------------------------- + Write-Host "Step 3: Generating JUnit XML report..." -ForegroundColor Yellow + $suites = @() + foreach ($severityLabel in @('Critical','Warning')) { + $rows = if ($severityLabel -eq 'Critical') { $criticalDetail } else { $warningDetail } + if (-not $rows -or $rows.Count -eq 0) { continue } + $tcList = New-Object 'System.Collections.Generic.List[hashtable]' + foreach ($r in $rows) { + $clusterResId = if ($r.PSObject.Properties.Match('ClusterResourceId').Count -gt 0) { [string]$r.ClusterResourceId } else { '' } + $clusterPortalUrl = if ($r.PSObject.Properties.Match('ClusterPortalUrl').Count -gt 0) { [string]$r.ClusterPortalUrl } else { '' } + $targetResName = if ($r.PSObject.Properties.Match('TargetResourceName').Count -gt 0){ [string]$r.TargetResourceName }else { '' } + $targetResType = if ($r.PSObject.Properties.Match('TargetResourceType').Count -gt 0){ [string]$r.TargetResourceType }else { '' } + $msg = "{0}: {1} (last occurred {2:yyyy-MM-ddTHH:mm:ssZ})" -f $r.Severity, $r.FailureReason, $r.LastOccurrence + $bodyLines = @( + [string]$r.Description + [string]$r.Remediation + "ResourceGroup: $($r.ResourceGroup)" + "SubscriptionId: $($r.SubscriptionId)" + ) + if ($targetResName) { $bodyLines += "TargetResourceName: $targetResName" } + if ($targetResType) { $bodyLines += "TargetResourceType: $targetResType" } + if ($clusterPortalUrl) { $bodyLines += "ClusterPortalUrl: $clusterPortalUrl" } + $tc = @{ + Name = "{0} :: {1}" -f $r.ClusterName, $r.FailureReason + ClassName = [string]$r.ClusterName + Time = 0.0 + Properties = ([ordered]@{ + ClusterName = [string]$r.ClusterName + ClusterResourceId = $clusterResId + UpdateName = [string]$r.FailureReason # ITSM dedupe key slot + Status = [string]$r.Severity + FailureReason = [string]$r.FailureReason + Severity = [string]$r.Severity + ClusterPortalUrl = $clusterPortalUrl + TargetResourceName = $targetResName + TargetResourceType = $targetResType + }) + Failure = @{ + Type = [string]$r.Severity + Message = $msg + Body = ($bodyLines -join "`n") + } + } + $tcList.Add($tc) | Out-Null + } + $suites += ,@{ + Name = "[JUnit Debug] $severityLabel Health Failures" + ClassName = "FleetHealth.$severityLabel" + TestCases = @($tcList) + } + } + if ($totalFailures -eq 0) { + $suites += ,@{ + Name = 'Fleet Health' + ClassName = 'FleetHealth' + TestCases = @(@{ + Name = 'No Critical or Warning health-check failures across the fleet' + ClassName = 'FleetHealth' + Time = 0.0 + }) + } + } + $null = New-AzLocalPipelineJUnitXml -TestSuitesName 'AzureLocalFleetHealthStatus' -Suites $suites -OutputPath $xmlPath -Timestamp $Now + Write-Host "JUnit XML saved to: $xmlPath" -ForegroundColor Green + + # ---- Step 6: step outputs -------------------------------------------- + Set-AzLocalPipelineOutput -Name 'total_clusters' -Value ([string]$totalClusters) + Set-AzLocalPipelineOutput -Name 'total_failures' -Value ([string]$totalFailures) + Set-AzLocalPipelineOutput -Name 'critical_count' -Value ([string]$criticalCount) + Set-AzLocalPipelineOutput -Name 'warning_count' -Value ([string]$warningCount) + Set-AzLocalPipelineOutput -Name 'distinct_reasons' -Value ([string]$distinctReasons) + Set-AzLocalPipelineOutput -Name 'overview_rows' -Value ([string]$overview.Count) + Set-AzLocalPipelineOutput -Name 'healthy_clusters' -Value ([string]$healthyClusters) + Set-AzLocalPipelineOutput -Name 'total_in_sub' -Value ([string]$totalInSub) + + Write-Host "" + Write-Host "Fleet Health Collection complete:" + Write-Host " Total clusters in scope : $totalInSub" + Write-Host " Healthy clusters : $healthyClusters" + Write-Host " Unhealthy clusters : $totalClusters" + Write-Host " Total failing checks : $totalFailures (Critical=$criticalCount, Warning=$warningCount)" + Write-Host " Distinct failure reasons: $distinctReasons" + Write-Host " Overview rows : $($overview.Count)" + + # ---- Step 7: markdown summary ---------------------------------------- + $generatedUtc = $Now.ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss UTC') + $md = New-Object 'System.Collections.Generic.List[string]' + [void]$md.Add('## Fleet Health Status Summary') + [void]$md.Add('') + [void]$md.Add('| Metric | Count |') + [void]$md.Add('|--------|-------|') + [void]$md.Add("| **Total Clusters in Subscription** | $totalInSub |") + [void]$md.Add("| **Healthy Clusters** | $healthyClusters |") + [void]$md.Add("| **Unhealthy Clusters** | $totalClusters |") + [void]$md.Add("| **Total Failing Checks** | $totalFailures |") + [void]$md.Add("| **Critical** | $criticalCount |") + [void]$md.Add("| **Warning** | $warningCount |") + [void]$md.Add("| **Distinct Failure Reasons** | $distinctReasons |") + [void]$md.Add('') + [void]$md.Add('> _**Healthy** / **Unhealthy** count clusters via `Get-AzLocalFleetHealthOverview`; **Unhealthy** = at least one Critical or Warning health-check failure. **Total Failing Checks** counts individual failing checks (one cluster can contribute multiple)._') + [void]$md.Add('') + + # ---- Fleet Health Overview table ------------------------------------- + [void]$md.Add('### Fleet Health Overview (fleet rollup)') + [void]$md.Add('') + if ($overview.Count -eq 0) { + [void]$md.Add('*No clusters returned from Get-AzLocalFleetHealthOverview.*') + } + else { + [void]$md.Add('| Cluster | Health | Update Status | Current Version | SBE Version | Azure Connection | Last Checked | Health Check Age (days) | Node Count |') + [void]$md.Add('|---------|--------|---------------|------------------|--------------|------------------|---------------|--------------------------|------------|') + foreach ($o in (@($overview) | Select-Object -First $MaxOverviewRows)) { + # target="_blank" so clicking a portal link opens in a new tab + # and the operator does not lose the pipeline run page. + $clusterCell = if ($o.ClusterPortalUrl) { + ('{1}' -f $o.ClusterPortalUrl, $o.ClusterName) + } + else { [string]$o.ClusterName } + # Use literal Unicode glyphs (not GH ':name:' shortcodes) so GH + + # ADO render identically. Label is retained for greppability. + $healthTag = switch ($o.HealthStatus) { + 'Healthy' { "$([char]0x2705) Healthy" } + 'Critical' { "$([char]0x274C) Critical" } + 'Warning' { "$([char]0x26A0)$([char]0xFE0F) Warning" } + 'In progress' { "$([char]0x23F3) In progress" } + 'Health check failed' { "$([char]0x274C) Failed" } + default { '[' + [string]$o.HealthStatus + ']' } + } + [void]$md.Add(('| {0} | {1} | {2} | {3} | {4} | {5} | {6} | {7} | {8} |' -f $clusterCell, $healthTag, $o.UpdateStatus, $o.CurrentVersion, $o.SbeVersion, $o.AzureConnection, $o.LastChecked, $o.HealthResultsAgeDays, $o.NodeCount)) + } + if ($overview.Count -gt $MaxOverviewRows) { + [void]$md.Add('') + [void]$md.Add(('*Showing first {0} clusters of {1}; see `{2}` artifact for the full list.*' -f $MaxOverviewRows, $overview.Count, $OverviewCsvFileName)) + } + } + [void]$md.Add('') + + # ---- Health Check Failures By Reason ---------------------------------- + [void]$md.Add('### Health Check Failures By Reason (most widespread first)') + [void]$md.Add('') + if ($summary.Count -eq 0) { + [void]$md.Add('*No Critical or Warning health-check failures across the fleet.*') + } + else { + [void]$md.Add('| Severity | Failure Reason | Cluster Count | Failure Count | Affected Clusters | Latest |') + [void]$md.Add('|----------|----------------|---------------|---------------|--------------------|--------|') + foreach ($r in (@($summary) | Select-Object -First $MaxSummaryRows)) { + $sevTag = if ($r.Severity -eq 'Critical') { '[Critical]' } else { '[Warning]' } + # Keep @() OUTSIDE the 'if' so single-element splits do not unwrap + # to a bare String (PowerShell scalar-unwrap guard). + $names = @(if ($r.AffectedClusters) { $r.AffectedClusters -split '; ' } else { @() }) + $urls = @(if ($r.AffectedClusterPortalUrls) { $r.AffectedClusterPortalUrls -split '; ' } else { @() }) + $linkedParts = for ($i = 0; $i -lt $names.Count; $i++) { + $n = $names[$i] + $u = if ($i -lt $urls.Count) { $urls[$i] } else { '' } + if ($u) { ('{1}' -f $u, $n) } else { $n } + } + $clList = if ($linkedParts.Count -le 10) { + $linkedParts -join ', ' + } + else { + (($linkedParts | Select-Object -First 10) -join ', ') + (' ... (+{0} more)' -f ($linkedParts.Count - 10)) + } + [void]$md.Add(('| {0} | {1} | {2} | {3} | {4} | {5} |' -f $sevTag, $r.FailureReason, $r.ClusterCount, $r.FailureCount, $clList, $r.LatestOccurrence)) + } + if ($summary.Count -gt $MaxSummaryRows) { + [void]$md.Add('') + [void]$md.Add(('*Showing top {0} failure reasons of {1}; see `{2}` artifact for the full list.*' -f $MaxSummaryRows, $summary.Count, $SummaryCsvFileName)) + } + } + [void]$md.Add('') + + # ---- Detailed Results (collapsible per-cluster) ----------------------- + [void]$md.Add('### Detailed Results (per-cluster, per-failure)') + [void]$md.Add('') + if ($detail.Count -eq 0) { + [void]$md.Add('*No detail rows.*') + } + else { + [void]$md.Add('*Click a cluster to expand its failing health checks. Worst-affected clusters appear first.*') + [void]$md.Add('') + $detailByCluster = @( + $detail | Group-Object -Property ClusterName | ForEach-Object { + $rows = @($_.Group) + $crit = @($rows | Where-Object { $_.Severity -eq 'Critical' }).Count + $warn = @($rows | Where-Object { $_.Severity -eq 'Warning' }).Count + $portalRow = $rows | Where-Object { + $_.PSObject.Properties.Match('ClusterPortalUrl').Count -gt 0 -and $_.ClusterPortalUrl + } | Select-Object -First 1 + $lastOcc = ($rows | Measure-Object -Property LastOccurrence -Maximum).Maximum + [pscustomobject]@{ + ClusterName = $_.Name + ClusterPortalUrl = if ($portalRow) { [string]$portalRow.ClusterPortalUrl } else { '' } + CriticalCount = $crit + WarningCount = $warn + LastOccurrence = $lastOcc + Rows = @( + $rows | Sort-Object @{Expression={ if ($_.Severity -eq 'Critical') { 1 } else { 2 } };Descending=$false}, + @{Expression={$_.LastOccurrence};Descending=$true} + ) + } + } | Sort-Object @{Expression={$_.CriticalCount};Descending=$true}, + @{Expression={$_.WarningCount};Descending=$true}, + @{Expression={$_.LastOccurrence};Descending=$true} + ) + $totalDetailClusters = $detailByCluster.Count + $clustersToShow = @($detailByCluster | Select-Object -First $MaxDetailClusters) + foreach ($cl in $clustersToShow) { + $sevParts = @() + if ($cl.CriticalCount -gt 0) { $sevParts += ('[Critical] x {0}' -f $cl.CriticalCount) } + if ($cl.WarningCount -gt 0) { $sevParts += ('[Warning] x {0}' -f $cl.WarningCount) } + $sevTally = $sevParts -join '  ·  ' + + $clusterCell = if ($cl.ClusterPortalUrl) { + ('{1}' -f $cl.ClusterPortalUrl, $cl.ClusterName) + } + else { [string]$cl.ClusterName } + $lastOccStr = if ($cl.LastOccurrence) { ('{0:yyyy-MM-ddTHH:mm:ssZ}' -f $cl.LastOccurrence) } else { '-' } + + [void]$md.Add('
') + [void]$md.Add(('{0}  ·  {1}  ·  last {2}' -f $clusterCell, $sevTally, $lastOccStr)) + [void]$md.Add('') + [void]$md.Add('| Severity | Failure Reason | Failure Remediation | Target Resource Name | Target Resource Type | Last Occurrence | Resource Group |') + [void]$md.Add('|----------|----------------|---------------------|----------------------|----------------------|------------------|----------------|') + foreach ($r in $cl.Rows) { + $sevTag = if ($r.Severity -eq 'Critical') { '[Critical]' } else { '[Warning]' } + $rem = if ($r.PSObject.Properties.Match('Remediation').Count -gt 0) { [string]$r.Remediation } else { '' } + $remCell = if ($rem -and $rem.StartsWith('https://')) { ('link' -f $rem) } else { $rem } + $tName = if ($r.PSObject.Properties.Match('TargetResourceName').Count -gt 0) { [string]$r.TargetResourceName } else { '' } + $tType = if ($r.PSObject.Properties.Match('TargetResourceType').Count -gt 0) { [string]$r.TargetResourceType } else { '' } + [void]$md.Add(('| {0} | {1} | {2} | {3} | {4} | {5} | {6} |' -f $sevTag, $r.FailureReason, $remCell, $tName, $tType, $r.LastOccurrence, $r.ResourceGroup)) + } + [void]$md.Add('
') + [void]$md.Add('') + } + if ($totalDetailClusters -gt $MaxDetailClusters) { + [void]$md.Add(('*Showing first {0} clusters of {1}; see `{2}` artifact for the full list.*' -f $MaxDetailClusters, $totalDetailClusters, $DetailCsvFileName)) + [void]$md.Add('') + } + } + + [void]$md.Add('### Reports Available') + [void]$md.Add(('- `{0}` - one row per (cluster, failing health check)' -f $DetailCsvFileName)) + [void]$md.Add(('- `{0}` - one row per (FailureReason, Severity); ordered Critical-first, then by ClusterCount desc' -f $SummaryCsvFileName)) + [void]$md.Add(('- `{0}` - one row per cluster (ARG-first fleet health summary)' -f $OverviewCsvFileName)) + [void]$md.Add(('- `{0}` / `{1}` / `{2}` - same data, machine-readable' -f $DetailJsonFileName, $SummaryJsonFileName, $OverviewJsonFileName)) + [void]$md.Add(('- `{0}` - JUnit XML for CI/CD visualisation' -f $XmlFileName)) + [void]$md.Add('') + [void]$md.Add('_Note: test sections prefixed **[JUnit Debug]** in the Tests view are a diagnostic mirror of the tables above (for CI tooling/ITSM integration). For primary readability, use this summary and the CSV artifacts._') + [void]$md.Add('') + [void]$md.Add("*Generated at $generatedUtc*") + if ($InstalledModuleVersion) { + [void]$md.Add('') + [void]$md.Add(('_Generated by AzLocal.UpdateManagement v{0}._' -f $InstalledModuleVersion)) + } + + $summaryPath = Add-AzLocalPipelineStepSummary -Markdown ($md -join [Environment]::NewLine) -SummaryFileName $SummaryFileName + + if ($criticalCount -gt 0) { + Write-Warning "$criticalCount Critical health-check failure(s) across the fleet. Check the detailed reports." + } + + if ($PassThru) { + return [pscustomobject]@{ + TotalClusters = [int]$totalClusters + TotalFailures = [int]$totalFailures + CriticalCount = [int]$criticalCount + WarningCount = [int]$warningCount + DistinctReasons = [int]$distinctReasons + OverviewRows = [int]$overview.Count + HealthyClusters = [int]$healthyClusters + TotalInSub = [int]$totalInSub + DetailCsvPath = $detailCsv + DetailJsonPath = $detailJson + SummaryCsvPath = $summaryCsv + SummaryJsonPath = $summaryJson + OverviewCsvPath = $overviewCsv + OverviewJsonPath = $overviewJson + XmlPath = $xmlPath + SummaryPath = $summaryPath + DetailRows = $detail + SummaryRows = $summary + OverviewRowsData = $overview + } + } +} diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalFleetUpdateStatusReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetUpdateStatusReport.ps1 new file mode 100644 index 00000000..a8e7612a --- /dev/null +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalFleetUpdateStatusReport.ps1 @@ -0,0 +1,1024 @@ +function Export-AzLocalFleetUpdateStatusReport { + <# + .SYNOPSIS + Snapshots fleet-wide Azure Local update status and emits the full + Step.8 artefact bundle (CSV + JSON + JUnit XML + markdown summary). + + .DESCRIPTION + Public entry-point for the v0.8.5 thin-YAML refactor of the Step.8 + fleet-update-status pipeline. Before v0.8.5 the GitHub Actions and + Azure DevOps Step.8 YAML files each carried ~830 lines of inline + PowerShell (cluster inventory + readiness + JUnit XML construction + + markdown summary rendering + step-output emission). This cmdlet + condenses that workload into a single Public PowerShell entry-point + that both pipeline platforms call with a thin parameter splat. + + The cmdlet: + 1. Inventories every Azure Local cluster the caller can read + (or, with -Scope by-update-ring, every cluster whose UpdateRing + tag matches -UpdateRing). + 2. Computes readiness, current version, health state and update + state per cluster. + 3. Pivots clusters by CurrentVersion to produce a Fleet Version + Distribution view, anchored against the latest released Azure + Local solution version from the Microsoft Edge Updates manifest + (with a fleet-observed top-6 YYMM fallback when the manifest is + unreachable). + 4. Generates a 3-testsuite JUnit XML document + (Fleet Version Distribution + AzureLocalFleetUpdateStatus + + Update Run History and Error Details) using the shared + New-AzLocalPipelineJUnitXml emitter. Each testcase carries + that the optional New-AzLocalIncident ITSM + connector consumes to compute the SHA256 dedupe key. + 5. Collects supplementary fleet-wide CSVs + (update-summaries.csv, available-updates.csv, optional + update-runs.csv) and the verbose run-history bundle + (update-run-history.csv + .json) from + Get-AzLocalUpdateRunFailures -State Failed -OnlyUnresolved. + 6. Emits 22 step outputs (lowercase snake_case) describing fleet + bucket counts so downstream pipeline steps can branch. + 7. Renders the full markdown summary to the host's step-summary + surface (GitHub Actions GITHUB_STEP_SUMMARY or Azure DevOps + ##vso[task.uploadsummary]) including the YYMM-grouped version + distribution table, the Critical Health Status pass/fail table, + the Primary Status priority cascade table, the Actions Required + bullet list and the Update Run History collapsible-error table. + + The cmdlet replaces the inline 'Collect Fleet Update Status' AND the + downstream 'Create Status Summary' steps from the pre-v0.8.5 YAML. + + .PARAMETER OutputDirectory + Directory to write artefacts into. When omitted, defaults to + $env:BUILD_ARTIFACTSTAGINGDIRECTORY on Azure DevOps hosts (resolved + via Get-AzLocalPipelineHost) and './reports' everywhere else + (GitHub Actions, local interactive use). + + .PARAMETER Scope + Fleet selector: 'all' (every cluster the caller can read) or + 'by-update-ring' (clusters whose UpdateRing tag matches -UpdateRing). + + .PARAMETER UpdateRing + UpdateRing tag value to filter on when Scope='by-update-ring'. + Accepts a single value, a ';'-delimited list, or '***' (three stars) + as a wildcard. Ignored when Scope='all'. + + .PARAMETER IncludeUpdateRuns + When $true (default), runs Step 4c (Get-AzLocalUpdateRuns -Latest + across the fleet) and writes update-runs.csv. Set $false to skip + the recent-runs collection to shorten run time on very large fleets. + + .PARAMETER RunHistorySinceDays + How far back to look for unresolved Failed update runs when + building the 'Update Run History and Error Details' testsuite. + Default 30 days. Must be 1..365. + + .PARAMETER RunHistoryTopRows + Maximum number of rows persisted to the run-history base64 blob + consumed by the markdown summary. Default 25. Must be 1..500. + Does NOT cap the JUnit XML or CSV emit (those include every row). + + .PARAMETER InventoryCsvFileName + Override the default 'cluster-inventory.csv' filename. + + .PARAMETER ReadinessCsvFileName + Override the default 'readiness-status.csv' filename. + + .PARAMETER ReadinessJsonFileName + Override the default 'readiness-status.json' filename. + + .PARAMETER XmlFileName + Override the default 'readiness-status.xml' JUnit filename. + + .PARAMETER SummariesCsvFileName + Override the default 'update-summaries.csv' filename. + + .PARAMETER AvailableCsvFileName + Override the default 'available-updates.csv' filename. + + .PARAMETER RunsCsvFileName + Override the default 'update-runs.csv' filename. + + .PARAMETER RunHistoryCsvFileName + Override the default 'update-run-history.csv' filename. + + .PARAMETER RunHistoryJsonFileName + Override the default 'update-run-history.json' filename. + + .PARAMETER SummaryFileName + Override the default 'fleet-update-status-summary.md' filename + (the markdown rendered into GITHUB_STEP_SUMMARY / ADO upload-summary). + + .PARAMETER InstalledModuleVersion + Optional version string rendered in the markdown footer (e.g. v0.8.5). + When omitted, no footer line is appended. + + .PARAMETER Now + Optional [datetime] used as 'snapshot time' in the markdown summary + (Generated at ...). Defaults to (Get-Date). Parameterised so unit + tests can assert against a fixed value. + + .PARAMETER PassThru + Return a PSCustomObject describing the fleet snapshot (counts + + file paths + Rows + RunHistoryRows + VersionDistribution). Default + is no return value (the cmdlet only writes files + step outputs + + markdown summary). + + .OUTPUTS + [PSCustomObject] when -PassThru is supplied. Properties: + - TotalClusters, CriticalHealthPassed, CriticalHealthFailed + - UpdateFailedCount, ActionRequiredCount, HealthFailureCount, + SbeBlockedCount, InProgressCount, ReadyForUpdateCount, + UpToDateCount, NeedsInvestigationCount + - HasPrerequisiteCount, RunHistoryCount + - VersionDistCount, SupportedClusters, UnsupportedClusters, + UnknownVersionClusters, SupportSource, SupportedYymmWindow, + LatestReleasedYymm, LatestReleasedVersion + - InventoryCsvPath, ReadinessCsvPath, ReadinessJsonPath, + XmlPath, SummariesCsvPath, AvailableCsvPath, RunsCsvPath, + RunHistoryCsvPath, RunHistoryJsonPath, SummaryPath + - Rows (readiness rows), RunHistoryRows, VersionDistribution + + .EXAMPLE + Export-AzLocalFleetUpdateStatusReport + # All-cluster snapshot; writes the full artefact bundle to ./reports/. + + .EXAMPLE + Export-AzLocalFleetUpdateStatusReport -Scope by-update-ring -UpdateRing Prod -PassThru + # Restricts to clusters tagged UpdateRing=Prod and returns the + # snapshot object for downstream PowerShell use. + + .EXAMPLE + # Used by Step.8_fleet-update-status.yml (GitHub Actions + Azure DevOps): + Export-AzLocalFleetUpdateStatusReport ` + -Scope $env:INPUT_SCOPE ` + -UpdateRing $env:INPUT_UPDATE_RING ` + -IncludeUpdateRuns:($env:INPUT_INCLUDE_UPDATE_RUNS -eq 'true') ` + -InstalledModuleVersion (Get-Module AzLocal.UpdateManagement).Version + + .NOTES + Author : Neil Bird, Microsoft + Version: v0.8.5 + Added : v0.8.5 (Step.8 thin-YAML port - condenses ~830 lines of inline + PowerShell into a single Public entry-point). + Reuses : Get-AzLocalClusterInventory, Get-AzLocalClusterUpdateReadiness, + Get-AzLocalUpdateSummary, Get-AzLocalAvailableUpdates, + Get-AzLocalUpdateRuns, Get-AzLocalUpdateRunFailures, + Get-AzLocalLatestSolutionVersion, New-AzLocalPipelineJUnitXml, + Set-AzLocalPipelineOutput, Add-AzLocalPipelineStepSummary, + Get-AzLocalPipelineHost. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$OutputDirectory, + + [Parameter(Mandatory = $false)] + [ValidateSet('all', 'by-update-ring')] + [string]$Scope = 'all', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$UpdateRing, + + [Parameter(Mandatory = $false)] + [bool]$IncludeUpdateRuns = $true, + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 365)] + [int]$RunHistorySinceDays = 30, + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 500)] + [int]$RunHistoryTopRows = 25, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$InventoryCsvFileName = 'cluster-inventory.csv', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$ReadinessCsvFileName = 'readiness-status.csv', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$ReadinessJsonFileName = 'readiness-status.json', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$XmlFileName = 'readiness-status.xml', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SummariesCsvFileName = 'update-summaries.csv', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$AvailableCsvFileName = 'available-updates.csv', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$RunsCsvFileName = 'update-runs.csv', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$RunHistoryCsvFileName = 'update-run-history.csv', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$RunHistoryJsonFileName = 'update-run-history.json', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SummaryFileName = 'fleet-update-status-summary.md', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$InstalledModuleVersion, + + [Parameter(Mandatory = $false)] + [datetime]$Now = (Get-Date), + + [Parameter(Mandatory = $false)] + [switch]$PassThru + ) + + $pipelineHost = Get-AzLocalPipelineHost + + if (-not $OutputDirectory) { + if ($pipelineHost -eq 'AzureDevOps' -and $env:BUILD_ARTIFACTSTAGINGDIRECTORY) { + $OutputDirectory = Join-Path -Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY -ChildPath 'reports' + } + else { + $OutputDirectory = './reports' + } + } + if (-not (Test-Path -LiteralPath $OutputDirectory)) { + New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + } + + $inventoryCsv = Join-Path -Path $OutputDirectory -ChildPath $InventoryCsvFileName + $readinessCsv = Join-Path -Path $OutputDirectory -ChildPath $ReadinessCsvFileName + $readinessJson = Join-Path -Path $OutputDirectory -ChildPath $ReadinessJsonFileName + $readinessXml = Join-Path -Path $OutputDirectory -ChildPath $XmlFileName + $summariesCsv = Join-Path -Path $OutputDirectory -ChildPath $SummariesCsvFileName + $availableCsv = Join-Path -Path $OutputDirectory -ChildPath $AvailableCsvFileName + $runsCsv = Join-Path -Path $OutputDirectory -ChildPath $RunsCsvFileName + $runHistoryCsv = Join-Path -Path $OutputDirectory -ChildPath $RunHistoryCsvFileName + $runHistoryJson = Join-Path -Path $OutputDirectory -ChildPath $RunHistoryJsonFileName + + Write-Host "========================================" -ForegroundColor Cyan + Write-Host "Fleet Update Status Collection" -ForegroundColor Cyan + Write-Host "========================================" -ForegroundColor Cyan + Write-Host "Scope: $Scope" + if ($UpdateRing) { Write-Host "UpdateRing Filter: $UpdateRing" } + Write-Host "Include Update Runs: $IncludeUpdateRuns" + Write-Host "" + + # ---- Step 1: cluster inventory ---------------------------------------- + Write-Host "Step 1: Getting cluster inventory..." -ForegroundColor Yellow + $inventory = Get-AzLocalClusterInventory -ExportPath $inventoryCsv -PassThru + $inventoryCount = @($inventory).Count + Write-Host "Found $inventoryCount total cluster(s)" -ForegroundColor Green + + if ($inventoryCount -eq 0) { + Write-Warning "No clusters found in inventory; emitting empty artefact bundle and step outputs." + # Emit empty JUnit document + zero-valued step outputs so downstream + # pipeline steps (dorny/test-reporter, ITSM connector) don't crash. + $null = New-AzLocalPipelineJUnitXml -TestSuitesName 'Fleet Update Status' -Suites @( + @{ Name = 'Fleet Version Distribution'; ClassName = 'FleetVersionDistribution'; TestCases = @() } + @{ Name = 'AzureLocalFleetUpdateStatus'; ClassName = 'AzureLocalFleetUpdateStatus'; TestCases = @() } + @{ Name = "Update Run History and Error Details"; ClassName = 'UpdateRunHistory'; TestCases = @() } + ) -OutputPath $readinessXml -Timestamp $Now + foreach ($n in @('total_clusters','critical_health_passed','critical_health_failed','up_to_date','in_progress','ready','sbe_blocked','health_failure','update_failed','action_required','needs_investigation','failures','has_prerequisite','run_history_count','version_dist_count','supported_clusters','unsupported_clusters','unknown_version_clusters')) { + Set-AzLocalPipelineOutput -Name $n -Value '0' + } + foreach ($n in @('supported_yymm_window','support_source','latest_released_yymm','latest_released_version')) { + Set-AzLocalPipelineOutput -Name $n -Value '' + } + $emptyMd = "## Fleet Update Status Summary _(generated $($Now.ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss UTC')))_`n`n_No clusters found in inventory._" + Add-AzLocalPipelineStepSummary -Markdown $emptyMd -SummaryFileName $SummaryFileName | Out-Null + if ($PassThru) { + return [pscustomobject]@{ + TotalClusters = 0 + CriticalHealthPassed = 0 + CriticalHealthFailed = 0 + UpdateFailedCount = 0 + ActionRequiredCount = 0 + HealthFailureCount = 0 + SbeBlockedCount = 0 + InProgressCount = 0 + ReadyForUpdateCount = 0 + UpToDateCount = 0 + NeedsInvestigationCount = 0 + HasPrerequisiteCount = 0 + RunHistoryCount = 0 + VersionDistCount = 0 + SupportedClusters = 0 + UnsupportedClusters = 0 + UnknownVersionClusters = 0 + SupportedYymmWindow = '' + SupportSource = '' + LatestReleasedYymm = '' + LatestReleasedVersion = '' + InventoryCsvPath = $inventoryCsv + ReadinessCsvPath = $readinessCsv + ReadinessJsonPath = $readinessJson + XmlPath = $readinessXml + SummariesCsvPath = $summariesCsv + AvailableCsvPath = $availableCsv + RunsCsvPath = $runsCsv + RunHistoryCsvPath = $runHistoryCsv + RunHistoryJsonPath = $runHistoryJson + Rows = @() + RunHistoryRows = @() + VersionDistribution = @() + } + } + return + } + + # ---- Step 2: readiness ------------------------------------------------ + Write-Host "" + Write-Host "Step 2: Checking update readiness..." -ForegroundColor Yellow + + $readinessParams = @{ ExportPath = $readinessCsv; PassThru = $true } + if ($Scope -eq 'by-update-ring' -and $UpdateRing) { + $readinessParams['ScopeByUpdateRingTag'] = $true + $readinessParams['UpdateRingValue'] = $UpdateRing + } + else { + $readinessParams['ClusterResourceIds'] = @($inventory | Select-Object -ExpandProperty ResourceId) + } + $readiness = @(Get-AzLocalClusterUpdateReadiness @readinessParams) + $totalTests = $readiness.Count + + # ---- Status bucket classification (priority cascade) ------------------ + # Cluster is counted exactly once. Priority (first match wins): + # UpdateFailed > ActionRequired > HealthFailure > SbeBlocked > + # InProgress > ReadyForUpdate > UpToDate > NeedsInvestigation + $failureStates = @('Failed','UpdateFailed','NeedsAttention','PreparationFailed') + $stUpdateFailed = 0 + $stActionRequired = 0 + $stHealthFailure = 0 + $stSbeBlocked = 0 + $stInProgress = 0 + $stReadyForUpdate = 0 + $stUpToDate = 0 + $stOther = 0 + foreach ($c in $readiness) { + if ($c.UpdateState -in @('Failed','UpdateFailed','NeedsAttention')) { $stUpdateFailed++ } + elseif ($c.UpdateState -eq 'PreparationFailed') { $stActionRequired++ } + elseif ($c.HealthState -eq 'Failure') { $stHealthFailure++ } + elseif ($c.HasPrerequisiteUpdates) { $stSbeBlocked++ } + elseif ($c.UpdateState -in @('UpdateInProgress','PreparationInProgress')){ $stInProgress++ } + elseif ($c.ReadyForUpdate -eq $true) { $stReadyForUpdate++ } + elseif ($c.UpdateState -in @('UpToDate','AppliedSuccessfully')) { $stUpToDate++ } + else { $stOther++ } + } + $hasPrerequisite = @($readiness | Where-Object { $_.HasPrerequisiteUpdates -ne '' -and $null -ne $_.HasPrerequisiteUpdates }).Count + + # Critical Health Status = JUnit pass/fail bucket + $criticalHealthFailed = @($readiness | Where-Object { + $_.HealthState -eq 'Failure' -or + ($_.UpdateState -in $failureStates) -or + ($_.HasPrerequisiteUpdates -ne '' -and $null -ne $_.HasPrerequisiteUpdates) + }).Count + $criticalHealthPassed = $totalTests - $criticalHealthFailed + + # ---- readiness-status.json -------------------------------------------- + $readinessExport = [ordered]@{ + Timestamp = $Now.ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss UTC') + TotalClusters = $totalTests + Summary = [ordered]@{ + ReadyForUpdate = @($readiness | Where-Object { $_.ReadyForUpdate -eq $true }).Count + UpToDate = @($readiness | Where-Object { + $_.ReadyForUpdate -ne $true -and + $_.UpdateState -in @('UpToDate','AppliedSuccessfully') -and + [string]::IsNullOrEmpty([string]$_.AllAvailableUpdates) + }).Count + InProgress = @($readiness | Where-Object { $_.UpdateState -in @('UpdateInProgress','PreparationInProgress') }).Count + HealthFailures = @($readiness | Where-Object { $_.HealthState -eq 'Failure' }).Count + UpdateFailures = @($readiness | Where-Object { $_.UpdateState -in @('Failed','UpdateFailed','NeedsAttention') }).Count + ActionRequired = @($readiness | Where-Object { $_.UpdateState -eq 'PreparationFailed' }).Count + HasPrerequisite = $hasPrerequisite + NotReadyForUpdate = @($readiness | Where-Object { + $_.ReadyForUpdate -ne $true -and + ($_.UpdateState -notin @('UpdateInProgress','PreparationInProgress')) -and + -not ($_.UpdateState -in @('UpToDate','AppliedSuccessfully') -and [string]::IsNullOrEmpty([string]$_.AllAvailableUpdates)) + }).Count + } + Clusters = $readiness + } + $readinessExport | ConvertTo-Json -Depth 10 | Out-File -FilePath $readinessJson -Encoding utf8 + + # ---- Fleet Version Distribution + support window ---------------------- + $versionGroups = @($readiness | Group-Object { + if ([string]::IsNullOrWhiteSpace($_.CurrentVersion)) { '(unknown)' } else { [string]$_.CurrentVersion } + } | Sort-Object Count -Descending) + $versionDistribution = @($versionGroups | ForEach-Object { + $clusterNames = @($_.Group | Sort-Object ClusterName | ForEach-Object { [string]$_.ClusterName }) + [pscustomobject]@{ + Version = $_.Name + Count = $_.Count + Percentage = if ($totalTests -gt 0) { [math]::Round(($_.Count / $totalTests) * 100, 1) } else { 0 } + Clusters = ($clusterNames -join ', ') + } + }) + $distinctVersions = $versionDistribution.Count + $mostCommonVersion = if ($distinctVersions -gt 0) { $versionDistribution[0].Version } else { '(none)' } + $mostCommonVersionPct = if ($distinctVersions -gt 0) { $versionDistribution[0].Percentage } else { 0 } + + # Anchor support window on Microsoft manifest (preferred) or + # fleet-observed top-6 YYMM fallback when the manifest is unreachable. + $supportSource = 'fleet-observed' + $latestReleasedYymm = '' + $latestReleasedVersion = '' + $manifestFetchedAt = '' + $manifestUrl = 'https://aka.ms/AzureEdgeUpdates' + $supportedYymms = @() + try { + Write-Host "Querying $manifestUrl for the latest released Azure Local solution version..." + $manifestProbe = Get-AzLocalLatestSolutionVersion -ErrorAction Stop + $supportedYymms = @($manifestProbe.SupportedYYMMs) + $supportSource = 'Microsoft manifest' + $latestReleasedYymm = [string]$manifestProbe.LatestYYMM + $latestReleasedVersion = [string]$manifestProbe.LatestVersion + if ($manifestProbe.ManifestFetchedAt) { + $manifestFetchedAt = $manifestProbe.ManifestFetchedAt.ToString('o') + } + Write-Host ("Latest released solution version: {0} (YYMM={1}); support window anchored on Microsoft manifest." -f $latestReleasedVersion, $latestReleasedYymm) -ForegroundColor Green + } + catch { + Write-Warning "Failed to query $manifestUrl - falling back to fleet-observed top-6 YYMM window. Error: $($_.Exception.Message)" + $observedYymms = @($versionDistribution | ForEach-Object { + $parts = ([string]$_.Version) -split '\.' + if ($parts.Count -ge 2) { $parts[1] } else { '' } + } | Where-Object { $_ -match '^[0-9]{4}$' } | Sort-Object -Unique) + $supportedYymms = @($observedYymms | Sort-Object -Descending | Select-Object -First 6) + } + $supportedCount = 0 + $unsupportedCount = 0 + $unknownVersionCount = 0 + foreach ($v in $versionDistribution) { + $parts = ([string]$v.Version) -split '\.' + $yymm = if ($parts.Count -ge 2) { $parts[1] } else { '' } + $supportStatus = if ($yymm -match '^[0-9]{4}$') { + if ($supportedYymms -contains $yymm) { 'Supported' } else { 'Unsupported' } + } + else { 'Unknown' } + $v | Add-Member -MemberType NoteProperty -Name Yymm -Value $yymm -Force + $v | Add-Member -MemberType NoteProperty -Name SupportStatus -Value $supportStatus -Force + switch ($supportStatus) { + 'Supported' { $supportedCount += [int]$v.Count } + 'Unsupported' { $unsupportedCount += [int]$v.Count } + 'Unknown' { $unknownVersionCount += [int]$v.Count } + } + } + $supportedYymmWindow = if ($supportedYymms.Count -gt 0) { ($supportedYymms -join ',') } else { '(none)' } + + Write-Host "" + Write-Host "Fleet Version Distribution ($distinctVersions distinct version(s) across $totalTests cluster(s)):" -ForegroundColor Cyan + Write-Host ("Support source: {0}" -f $supportSource) + if ($latestReleasedYymm) { + Write-Host ("Latest released YYMM (Microsoft manifest): {0} ({1})" -f $latestReleasedYymm, $latestReleasedVersion) + } + Write-Host ("Supported YYMM window (top {0}): {1}" -f $supportedYymms.Count, $supportedYymmWindow) + foreach ($v in $versionDistribution) { + Write-Host (" {0,-30} {1,5} cluster(s) ({2,5}%) [{3}]" -f $v.Version, $v.Count, $v.Percentage, $v.SupportStatus) + } + + # ---- Step 3b: Update Run History (must run before JUnit XML build) --- + Write-Host "" + Write-Host "Step 3b: Collecting Update Run History + Verbose Error Details (ARG-first, fleet-scale)..." -ForegroundColor Yellow + $runFailures = @() + try { + $runFailures = @(Get-AzLocalUpdateRunFailures -State Failed -OnlyUnresolved -Since $Now.AddDays(-1 * $RunHistorySinceDays)) + } + catch { + Write-Warning "Get-AzLocalUpdateRunFailures threw: $($_.Exception.Message). Continuing with empty run-history section." + $runFailures = @() + } + $runFailures = @($runFailures | Sort-Object @{Expression='StartTime';Descending=$true}, @{Expression='ClusterName';Descending=$false}) + $runHistoryCount = $runFailures.Count + Write-Host "Found $runHistoryCount unresolved Failed update run(s)." -ForegroundColor $(if ($runHistoryCount -gt 0) { 'Yellow' } else { 'Green' }) + + if ($runHistoryCount -gt 0) { + $runFailures | + Select-Object ClusterName, UpdateName, State, Status, CurrentStep, Duration, StartTime, LastUpdated, DeepestStepName, ErrorCategory, DeepestErrMsg, UpdateRunPortalUrl, ClusterResourceId, RunId | + Export-Csv -Path $runHistoryCsv -NoTypeInformation -Force + $runFailures | ConvertTo-Json -Depth 4 | Out-File -FilePath $runHistoryJson -Encoding utf8 + } + else { + '' | Set-Content -LiteralPath $runHistoryCsv -Encoding utf8 + '[]' | Set-Content -LiteralPath $runHistoryJson -Encoding utf8 + } + + # ---- Step 3: JUnit XML via the shared emitter ------------------------- + Write-Host "" + Write-Host "Step 3: Generating JUnit XML report..." -ForegroundColor Yellow + + # Suite 1: Fleet Version Distribution (one testcase per CurrentVersion, all passed) + $vdTestCases = New-Object 'System.Collections.Generic.List[hashtable]' + foreach ($v in $versionDistribution) { + $vdTestCases.Add(@{ + Name = "Version-$($v.Version)" + ClassName = 'FleetVersionDistribution' + Time = 0.0 + Properties = ([ordered]@{ + Version = $v.Version + Yymm = $v.Yymm + SupportStatus = $v.SupportStatus + ClusterCount = $v.Count + Percentage = $v.Percentage + Clusters = $v.Clusters + }) + SystemOut = "Version: $($v.Version)`nYYMM: $($v.Yymm)`nSupportStatus: $($v.SupportStatus)`nClusters: $($v.Count)`nPercentage: $($v.Percentage)%`nClusterNames: $($v.Clusters)" + }) | Out-Null + } + $vdSuite = @{ + Name = 'Fleet Version Distribution' + ClassName = 'FleetVersionDistribution' + TestCases = @($vdTestCases) + Properties = ([ordered]@{ + testCategory = 'FleetVersionDistribution' + description = "Overall Fleet Update Status - cluster count and percentage per distinct CurrentVersion. SupportStatus uses the rolling 6-month YYMM window anchored on the latest released Azure Local solution version (supportSource='Microsoft manifest' or fleet-observed fallback)." + scope = $Scope + updateRing = [string]$UpdateRing + totalClusters = $totalTests + distinctVersions = $distinctVersions + mostCommonVersion = [string]$mostCommonVersion + mostCommonVersionPercentage = $mostCommonVersionPct + supportSource = $supportSource + latestReleasedYymm = $latestReleasedYymm + latestReleasedVersion = $latestReleasedVersion + manifestUrl = $manifestUrl + manifestFetchedAt = $manifestFetchedAt + supportedYymmWindow = $supportedYymmWindow + supportedClusters = $supportedCount + unsupportedClusters = $unsupportedCount + unknownVersionClusters = $unknownVersionCount + }) + } + + # Suite 2: AzureLocalFleetUpdateStatus - one testcase per cluster. + # Failed clusters first (alphabetical), then passed clusters (alphabetical). + $failedClusters = @() + $passedClusters = @() + foreach ($c in $readiness) { + if ($c.HealthState -eq 'Failure' -or ($c.UpdateState -in $failureStates) -or ($c.HasPrerequisiteUpdates -ne '' -and $null -ne $c.HasPrerequisiteUpdates)) { + $failedClusters += $c + } + else { + $passedClusters += $c + } + } + $orderedClusters = @($failedClusters | Sort-Object ClusterName) + @($passedClusters | Sort-Object ClusterName) + $fsTestCases = New-Object 'System.Collections.Generic.List[hashtable]' + foreach ($cluster in $orderedClusters) { + $isFail = $false + $failureType = '' + $failureMessage = '' + if ($cluster.HealthState -eq 'Failure' -or ($cluster.UpdateState -in $failureStates)) { + $isFail = $true + $failureType = if ($cluster.UpdateState -eq 'PreparationFailed') { 'PreparationFailed' } else { 'UpdateFailure' } + $failureMessage = "UpdateState: $($cluster.UpdateState), Health: $($cluster.HealthState)" + if ($cluster.HealthCheckFailures) { $failureMessage += ", Issues: $($cluster.HealthCheckFailures)" } + } + elseif ($cluster.HasPrerequisiteUpdates -ne '' -and $null -ne $cluster.HasPrerequisiteUpdates) { + $isFail = $true + $failureType = 'HasPrerequisite' + $failureMessage = "Updates blocked by SBE prerequisite: $($cluster.HasPrerequisiteUpdates)" + if ($cluster.SBEDependency) { $failureMessage += " - Vendor: $($cluster.SBEDependency)" } + } + + $systemOut = @( + "Cluster: $($cluster.ClusterName)" + "Resource Group: $($cluster.ResourceGroup)" + "Subscription: $($cluster.SubscriptionId)" + "Update State: $($cluster.UpdateState)" + "Health State: $($cluster.HealthState)" + "Ready for Update: $($cluster.ReadyForUpdate)" + "All Available Updates: $($cluster.AllAvailableUpdates)" + "Ready Updates: $($cluster.ReadyUpdates)" + "Has Prerequisite Updates: $($cluster.HasPrerequisiteUpdates)" + "SBE Dependency: $($cluster.SBEDependency)" + "Recommended Update: $($cluster.RecommendedUpdate)" + ) -join "`n" + + $clusterPortalUrl = if ($cluster.ResourceId) { "https://portal.azure.com/#@/resource$($cluster.ResourceId)" } else { '' } + $statusValue = if ($isFail) { $failureType } else { 'Success' } + + $tc = @{ + Name = "UpdateStatus-$($cluster.ClusterName)" + ClassName = "AzureLocalFleetUpdateStatus.$($cluster.ResourceGroup)" + Time = 0.0 + Properties = ([ordered]@{ + ClusterName = [string]$cluster.ClusterName + ClusterResourceId = [string]$cluster.ResourceId + UpdateName = [string]$cluster.RecommendedUpdate + Status = $statusValue + ClusterPortalUrl = $clusterPortalUrl + }) + SystemOut = $systemOut + } + if ($isFail) { + $tc['Failure'] = @{ Message = 'Critical Health Status: Failed'; Type = $failureType; Body = $failureMessage } + } + $fsTestCases.Add($tc) | Out-Null + } + $fsSuite = @{ + Name = 'AzureLocalFleetUpdateStatus' + ClassName = 'AzureLocalFleetUpdateStatus' + TestCases = @($fsTestCases) + Properties = ([ordered]@{ + testCategory = 'CriticalHealthStatus' + description = "Each testcase = one cluster's Critical Health Status. PASSED = healthy + no failures + not SBE-blocked. FAILED = HealthState=Failure OR UpdateState in (Failed,UpdateFailed,NeedsAttention,PreparationFailed) OR SBE prerequisite blocked." + scope = $Scope + updateRing = [string]$UpdateRing + totalClusters = $totalTests + criticalHealthPassed = $criticalHealthPassed + criticalHealthFailed = $criticalHealthFailed + primaryUpdateFailed = $stUpdateFailed + primaryActionRequired = $stActionRequired + primaryHealthFailure = $stHealthFailure + primarySbeBlocked = $stSbeBlocked + primaryInProgress = $stInProgress + primaryReadyForUpdate = $stReadyForUpdate + primaryUpToDate = $stUpToDate + primaryNeedsInvestigation = $stOther + }) + } + + # Suite 3: Update Run History and Error Details + $rhTestCases = New-Object 'System.Collections.Generic.List[hashtable]' + foreach ($f in $runFailures) { + $rowSec = 0 + if ($f.PSObject.Properties['DurationMinutes'] -and $null -ne $f.DurationMinutes) { + try { $rowSec = [int][math]::Round([double]$f.DurationMinutes * 60, 0) } catch { $rowSec = 0 } + if ($rowSec -lt 0) { $rowSec = 0 } + } + $errBody = if ($f.DeepestErrMsg) { + if ([string]$f.DeepestErrMsg.Length -gt 4000) { ([string]$f.DeepestErrMsg).Substring(0,4000) + ' ... (truncated)' } else { [string]$f.DeepestErrMsg } + } else { '(no error message captured)' } + + $sysOutLines = @( + "Cluster: $($f.ClusterName)" + "Update: $($f.UpdateName)" + "Update State: Failed" + "Status: $($f.Status)" + "Current Step: $($f.CurrentStep)" + "Duration: $($f.Duration)" + "Time Started: $($f.StartTime)" + "Last Updated: $($f.LastUpdated)" + "Error Category: $($f.ErrorCategory)" + "Portal Link: $($f.UpdateRunPortalUrl)" + ) + if ($f.StackTracePreview) { $sysOutLines += "Stack Trace Preview: $($f.StackTracePreview)" } + + $fClusterPortal = if ($f.ClusterResourceId) { "https://portal.azure.com/#@/resource$($f.ClusterResourceId)" } else { '' } + + $rhTestCases.Add(@{ + Name = "$($f.ClusterName) - $($f.UpdateName)" + ClassName = 'UpdateRunHistory' + Time = [double]$rowSec + Properties = ([ordered]@{ + ClusterName = [string]$f.ClusterName + ClusterResourceId = [string]$f.ClusterResourceId + UpdateName = [string]$f.UpdateName + Status = 'Failed' + UpdateState = [string]$f.Status + CurrentStep = [string]$f.CurrentStep + Duration = [string]$f.Duration + ErrorCategory = [string]$f.ErrorCategory + UpdateRunPortalUrl = [string]$f.UpdateRunPortalUrl + ClusterPortalUrl = $fClusterPortal + }) + SystemOut = ($sysOutLines -join "`n") + Failure = @{ + Message = "$($f.ClusterName) / $($f.UpdateName) failed at: $($f.CurrentStep)" + Type = [string]$f.ErrorCategory + Body = $errBody + } + }) | Out-Null + } + $rhSuite = @{ + Name = "Update Run History and Error Details" + ClassName = 'UpdateRunHistory' + TestCases = @($rhTestCases) + Properties = ([ordered]@{ + description = "Verbose error details for each unresolved Failed update run (ARG-first, fleet-scale)." + sourceCmdlet = "Get-AzLocalUpdateRunFailures -State Failed -OnlyUnresolved -Since (Get-Date).AddDays(-$RunHistorySinceDays)" + failedRunCount = $runHistoryCount + }) + } + + $null = New-AzLocalPipelineJUnitXml -TestSuitesName 'Fleet Update Status' -Suites @($vdSuite, $fsSuite, $rhSuite) -OutputPath $readinessXml -Timestamp $Now + Write-Host "JUnit XML saved to: $readinessXml" -ForegroundColor Green + + # ---- Step 4: supplementary fleet-wide CSVs ---------------------------- + $fleetResourceIds = if ($Scope -eq 'by-update-ring' -and $UpdateRing) { + @($readiness | Where-Object { $_.SubscriptionId -and $_.ResourceGroup -and $_.ClusterName } | ForEach-Object { + "/subscriptions/$($_.SubscriptionId)/resourceGroups/$($_.ResourceGroup)/providers/Microsoft.AzureStackHCI/clusters/$($_.ClusterName)" + }) + } + else { + @($inventory | Select-Object -ExpandProperty ResourceId) + } + + Write-Host "" + Write-Host "Step 4a: Collecting fleet update summaries..." -ForegroundColor Yellow + $summaries = Get-AzLocalUpdateSummary -ClusterResourceIds $fleetResourceIds -ExportPath $summariesCsv -PassThru + Write-Host "Update summaries collected for $(@($summaries).Count) cluster(s)" -ForegroundColor Green + + Write-Host "" + Write-Host "Step 4b: Collecting available updates..." -ForegroundColor Yellow + $available = Get-AzLocalAvailableUpdates -ClusterResourceIds $fleetResourceIds -ExportPath $availableCsv -PassThru + Write-Host "Found $(@($available).Count) available update(s) across fleet" -ForegroundColor Green + + if ($IncludeUpdateRuns) { + Write-Host "" + Write-Host "Step 4c: Collecting recent update run history..." -ForegroundColor Yellow + $allRuns = Get-AzLocalUpdateRuns -ClusterResourceIds $fleetResourceIds -Latest -ExportPath $runsCsv -PassThru + $allRunsCount = @($allRuns).Count + $succeeded = @($allRuns | Where-Object { $_.State -eq 'Succeeded' }).Count + $inProgressRuns = @($allRuns | Where-Object { $_.State -eq 'InProgress' }).Count + $failedRuns = @($allRuns | Where-Object { $_.State -eq 'Failed' }).Count + Write-Host "Update runs collected for $allRunsCount cluster(s)" -ForegroundColor Green + Write-Host " Succeeded: $succeeded, In Progress: $inProgressRuns, Failed: $failedRuns" + } + + # ---- Step 5: console summary + step outputs --------------------------- + Write-Host "" + Write-Host "========================================" -ForegroundColor Cyan + Write-Host "Fleet Status Summary" -ForegroundColor Cyan + Write-Host "========================================" -ForegroundColor Cyan + Write-Host "Total Clusters: $totalTests" + Write-Host "" + Write-Host "Critical Health Status (matches JUnit XML pass/fail):" -ForegroundColor White + Write-Host " Passed: $criticalHealthPassed" -ForegroundColor Green + Write-Host " Failed: $criticalHealthFailed" -ForegroundColor $(if ($criticalHealthFailed -gt 0) { 'Red' } else { 'Green' }) + Write-Host "" + Write-Host "Primary Status (each cluster counted once; rows sum to Total):" -ForegroundColor White + Write-Host " Up to Date: $stUpToDate" -ForegroundColor Green + Write-Host " Ready for Update: $stReadyForUpdate" -ForegroundColor Cyan + Write-Host " Update In Progress: $stInProgress" -ForegroundColor Yellow + Write-Host " SBE Prerequisite Blocked: $stSbeBlocked" -ForegroundColor $(if ($stSbeBlocked -gt 0) { 'Yellow' } else { 'Green' }) + Write-Host " Health Failure: $stHealthFailure" -ForegroundColor $(if ($stHealthFailure -gt 0) { 'Red' } else { 'Green' }) + Write-Host " Update Failed: $stUpdateFailed" -ForegroundColor $(if ($stUpdateFailed -gt 0) { 'Red' } else { 'Green' }) + Write-Host " Action Required: $stActionRequired" -ForegroundColor $(if ($stActionRequired -gt 0) { 'Red' } else { 'Green' }) + Write-Host " Needs Investigation: $stOther" -ForegroundColor $(if ($stOther -gt 0) { 'Yellow' } else { 'Green' }) + + Set-AzLocalPipelineOutput -Name 'total_clusters' -Value ([string]$totalTests) + Set-AzLocalPipelineOutput -Name 'critical_health_passed' -Value ([string]$criticalHealthPassed) + Set-AzLocalPipelineOutput -Name 'critical_health_failed' -Value ([string]$criticalHealthFailed) + Set-AzLocalPipelineOutput -Name 'up_to_date' -Value ([string]$stUpToDate) + Set-AzLocalPipelineOutput -Name 'in_progress' -Value ([string]$stInProgress) + Set-AzLocalPipelineOutput -Name 'ready' -Value ([string]$stReadyForUpdate) + Set-AzLocalPipelineOutput -Name 'sbe_blocked' -Value ([string]$stSbeBlocked) + Set-AzLocalPipelineOutput -Name 'health_failure' -Value ([string]$stHealthFailure) + Set-AzLocalPipelineOutput -Name 'update_failed' -Value ([string]$stUpdateFailed) + Set-AzLocalPipelineOutput -Name 'action_required' -Value ([string]$stActionRequired) + Set-AzLocalPipelineOutput -Name 'needs_investigation' -Value ([string]$stOther) + Set-AzLocalPipelineOutput -Name 'failures' -Value ([string]$criticalHealthFailed) + Set-AzLocalPipelineOutput -Name 'has_prerequisite' -Value ([string]$hasPrerequisite) + Set-AzLocalPipelineOutput -Name 'run_history_count' -Value ([string]$runHistoryCount) + Set-AzLocalPipelineOutput -Name 'version_dist_count' -Value ([string]$distinctVersions) + Set-AzLocalPipelineOutput -Name 'supported_yymm_window' -Value $supportedYymmWindow + Set-AzLocalPipelineOutput -Name 'supported_clusters' -Value ([string]$supportedCount) + Set-AzLocalPipelineOutput -Name 'unsupported_clusters' -Value ([string]$unsupportedCount) + Set-AzLocalPipelineOutput -Name 'unknown_version_clusters' -Value ([string]$unknownVersionCount) + Set-AzLocalPipelineOutput -Name 'support_source' -Value $supportSource + Set-AzLocalPipelineOutput -Name 'latest_released_yymm' -Value $latestReleasedYymm + Set-AzLocalPipelineOutput -Name 'latest_released_version' -Value $latestReleasedVersion + + # ---- Step 6: render markdown summary ---------------------------------- + $generatedUtc = $Now.ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss UTC') + $md = New-Object 'System.Collections.Generic.List[string]' + [void]$md.Add("## Fleet Update Status Summary _(generated $generatedUtc)_") + [void]$md.Add('') + + # Version distribution table (YYMM-grouped, with support-status emoji) + if ($distinctVersions -gt 0) { + try { + $versionSection = @() + $versionSection += '### Fleet Version Distribution' + $versionSection += '' + $versionSection += "_$distinctVersions distinct version(s) across $totalTests cluster(s). Anchor source: ``$supportSource``" + if ($latestReleasedYymm) { $versionSection[-1] += ". Latest released YYMM (Microsoft manifest): ``$latestReleasedYymm`` (``$latestReleasedVersion``)" } + $versionSection[-1] += "._" + $versionSection += '' + $versionSection += '| YYMM | Update Versions | Support | Clusters | % | Cluster Names (first 15 shown only) |' + $versionSection += '|------|-----------------|---------|----------|---|--------------------------------------|' + $byYymm = $versionDistribution | Group-Object Yymm | Sort-Object @{Expression={ if ($_.Name) { $_.Name } else { '' } }; Descending=$true} + foreach ($g in $byYymm) { + $groupRows = @($g.Group | Sort-Object Count -Descending) + $totalCount = ($groupRows | Measure-Object Count -Sum).Sum + $totalPct = if ($totalTests -gt 0) { [math]::Round(($totalCount / $totalTests) * 100, 1) } else { 0 } + $yymmDisplay = if ($g.Name) { $g.Name } else { '_(unknown)_' } + $supportStatusForYymm = $groupRows[0].SupportStatus + $supportEmoji = switch ($supportStatusForYymm) { + 'Supported' { ':white_check_mark: Supported' } + 'Unsupported' { ':warning: Unsupported' } + default { ':grey_question: Unknown' } + } + $updateVersionsCell = (($groupRows | ForEach-Object { '{0} x {1}' -f $_.Version, $_.Count }) -join '
') + $clusterNames = @() + foreach ($r in $groupRows) { + if ($r.Clusters) { + $clusterNames += @($r.Clusters -split ',\s*' | Where-Object { $_ }) + } + } + $clusterNames = @($clusterNames | Sort-Object -Unique) + $clCell = if ($clusterNames.Count -le 15) { + $clusterNames -join '; ' + } + else { + (($clusterNames | Select-Object -First 15) -join '; ') + (' ... (+{0} more)' -f ($clusterNames.Count - 15)) + } + $clCell = $clCell -replace '\|','\|' + $versionSection += ('| {0} | {1} | {2} | {3} | {4}% | {5} |' -f $yymmDisplay, $updateVersionsCell, $supportEmoji, $totalCount, $totalPct, $clCell) + } + $versionSection += '' + $versionSection += "_Support roll-up: Supported = $supportedCount cluster(s), Unsupported = $unsupportedCount, Unknown = $unknownVersionCount. Anchor source: ``$supportSource``._" + $versionSection += "_Note: the **Clusters (first 15 shown only)** column is intentionally truncated for readability. Download ``$ReadinessCsvFileName`` from artifacts to see the full cluster-name list._" + $versionSection += '' + foreach ($line in $versionSection) { [void]$md.Add($line) } + } + catch { + Write-Warning "Failed to render Fleet Version Distribution markdown table: $($_.Exception.Message)" + } + } + + # Critical Health Status table + [void]$md.Add('### Critical Health Status (matches JUnit XML pass/fail)') + [void]$md.Add('') + [void]$md.Add('| Metric | Count | Status |') + [void]$md.Add('|--------|-------|--------|') + [void]$md.Add("| **Passed** (healthy, no failures, not SBE-blocked) | $criticalHealthPassed | :white_check_mark: |") + $chFailedIcon = if ($criticalHealthFailed -gt 0) { ':x:' } else { ':white_check_mark:' } + [void]$md.Add("| **Failed** (HealthState=Failure OR UpdateState=Failed OR SBE prerequisite blocked) | $criticalHealthFailed | $chFailedIcon |") + [void]$md.Add('') + + # Primary Status table + [void]$md.Add('### Primary Status (each cluster counted once; rows sum to Total Clusters)') + [void]$md.Add('') + [void]$md.Add('| Metric | Count | Status |') + [void]$md.Add('|--------|-------|--------|') + [void]$md.Add("| **Total Clusters** | $totalTests | :information_source: |") + [void]$md.Add("| **Up to Date** | $stUpToDate | :white_check_mark: |") + [void]$md.Add("| **Ready for Update** | $stReadyForUpdate | :green_circle: |") + [void]$md.Add("| **Update In Progress** | $stInProgress | :arrows_counterclockwise: |") + $sbeIcon = if ($stSbeBlocked -gt 0) { ':yellow_circle:' } else { ':white_check_mark:' } + [void]$md.Add("| **SBE Prerequisite Blocked** | $stSbeBlocked | $sbeIcon |") + $hfIcon = if ($stHealthFailure -gt 0) { ':x:' } else { ':white_check_mark:' } + [void]$md.Add("| **Health Failure** (HealthState=Failure) | $stHealthFailure | $hfIcon |") + $ufIcon = if ($stUpdateFailed -gt 0) { ':x:' } else { ':white_check_mark:' } + [void]$md.Add("| **Update Failed** (Failed / UpdateFailed / NeedsAttention) | $stUpdateFailed | $ufIcon |") + $arIcon = if ($stActionRequired -gt 0) { ':x:' } else { ':white_check_mark:' } + [void]$md.Add("| **Action Required** (PreparationFailed) | $stActionRequired | $arIcon |") + $niIcon = if ($stOther -gt 0) { ':warning:' } else { ':white_check_mark:' } + [void]$md.Add("| **Needs Investigation** | $stOther | $niIcon |") + [void]$md.Add('') + [void]$md.Add('Priority cascade used to bucket each cluster exactly once:') + [void]$md.Add('Update Failed > Action Required > Health Failure > SBE Prerequisite Blocked > Update In Progress > Ready for Update > Up to Date > Needs Investigation.') + [void]$md.Add('') + + # Actions Required bullet list + $actions = @() + if ($stUpdateFailed -gt 0) { $actions += "- **$stUpdateFailed cluster(s) have UpdateState in (Failed, UpdateFailed, NeedsAttention)** - Review the most recent update run in Azure Portal and the ``$RunsCsvFileName`` artifact for the failure detail." } + if ($stActionRequired -gt 0) { $actions += "- **$stActionRequired cluster(s) have UpdateState=PreparationFailed** - Update preparation hit a blocker before the run started. Inspect the cluster activity log (most common: prerequisite-check failure, missing SBE content, ARB extension drift). Resolve before re-arming the update." } + if ($stHealthFailure -gt 0) { $actions += "- **$stHealthFailure cluster(s) have HealthState=Failure** - Resolve critical health check issues in Azure Portal before retrying any updates." } + if ($hasPrerequisite -gt 0) { $actions += "- **$hasPrerequisite cluster(s) blocked by SBE prerequisite** - Install the required Solution Builder Extension (SBE) update from the hardware vendor. See ``$AvailableCsvFileName`` for vendor details (Publisher, Family)." } + if ($actions.Count -gt 0) { + [void]$md.Add('### Actions Required') + [void]$md.Add('') + foreach ($a in $actions) { [void]$md.Add($a) } + [void]$md.Add('') + } + + # Update Run History collapsible-error table + if ($runHistoryCount -gt 0) { + try { + # v0.7.90 dedupe: collapse same-error reruns - group by + # (ClusterName, UpdateName, DeepestErrMsg); canonical row = latest StartTime. + $groups = @($runFailures) | Group-Object -Property @{ + Expression = { "$($_.ClusterName)|$($_.UpdateName)|$($_.DeepestErrMsg)" } + } + $renderRows = @() + foreach ($g in $groups) { + $ordered = @($g.Group | Sort-Object @{ Expression = 'StartTime'; Descending = $true }) + $latest = $ordered[0] + $extras = @($ordered | Select-Object -Skip 1) + $latest | Add-Member -NotePropertyName 'EarlierOccurrences' -NotePropertyValue $extras -Force + $renderRows += $latest + } + $renderRows = @($renderRows | Sort-Object @{Expression='StartTime';Descending=$true}, @{Expression='ClusterName';Descending=$false} | Select-Object -First $RunHistoryTopRows) + $totalRowsCnt = $runHistoryCount + $groupCount = $renderRows.Count + $dedupSuffix = if ($groupCount -lt $totalRowsCnt) { + " (collapsed $totalRowsCnt run(s) into $groupCount unique failure group(s); older same-error reruns are listed inside each row's 'Show error' panel)" + } + else { '' } + [void]$md.Add("### :scroll: Update Run History and Error Details") + [void]$md.Add('') + [void]$md.Add("_ARG-first, fleet-scale failure-detail view. Shows up to $RunHistoryTopRows most recent unresolved Failed update runs (last $RunHistorySinceDays days). Source cmdlet: ``Get-AzLocalUpdateRunFailures -State Failed -OnlyUnresolved``.$dedupSuffix._") + [void]$md.Add('') + [void]$md.Add('| Cluster Name | Update Name | Update State | Status | Current Step | Verbose Error Details | Duration | Time Started | Last Updated |') + [void]$md.Add('|---|---|---|---|---|---|---|---|---|') + foreach ($r in $renderRows) { + $errCell = if ($r.DeepestErrMsg) { + $e = [string]$r.DeepestErrMsg + $e = $e -replace '&','&' -replace '<','<' -replace '>','>' + $e = $e -replace "`r`n",'
' -replace "`n",'
' -replace '\|','\|' + $extraBlock = '' + if ($r.EarlierOccurrences -and @($r.EarlierOccurrences).Count -gt 0) { + $bullets = (@($r.EarlierOccurrences) | ForEach-Object { + $dur = if ($_.Duration) { " (Duration: $($_.Duration))" } else { '' } + "• Started $($_.StartTime), Last updated $($_.LastUpdated)$dur" + }) -join '
' + $extraBlock = '

Earlier occurrences with the same error (' + (@($r.EarlierOccurrences).Count) + '):
' + $bullets + } + '
Show error
' + $e + '' + $extraBlock + '
' + } + else { '_(none)_' } + $clusterCell = if ($r.ClusterResourceId) { '{1}' -f $r.ClusterResourceId, $r.ClusterName } else { [string]$r.ClusterName } + $updCell = if ($r.UpdateRunPortalUrl) { '{1}' -f $r.UpdateRunPortalUrl, $r.UpdateName } else { [string]$r.UpdateName } + [void]$md.Add("| $clusterCell | $updCell | $($r.State) | $($r.Status) | $($r.CurrentStep) | $errCell | $($r.Duration) | $($r.StartTime) | $($r.LastUpdated) |") + } + [void]$md.Add('') + } + catch { + Write-Warning "Failed to render Update Run History markdown table: $($_.Exception.Message)" + } + } + + # Reports list + [void]$md.Add('### Reports Available') + [void]$md.Add("- ``$ReadinessCsvFileName`` - Detailed cluster status for spreadsheet analysis") + [void]$md.Add("- ``$ReadinessJsonFileName`` - Machine-readable format for integrations") + [void]$md.Add("- ``$XmlFileName`` - JUnit XML for CI/CD visualization (includes the 'Update Run History and Error Details' suite)") + [void]$md.Add("- ``$InventoryCsvFileName`` - Full cluster inventory") + [void]$md.Add("- ``$SummariesCsvFileName`` - Fleet-wide update state summaries") + [void]$md.Add("- ``$AvailableCsvFileName`` - All available updates across fleet") + if ($IncludeUpdateRuns) { + [void]$md.Add("- ``$RunsCsvFileName`` - Recent update run history") + } + [void]$md.Add("- ``$RunHistoryCsvFileName`` - Verbose error details for unresolved Failed update runs (ARG-first, fleet-scale)") + [void]$md.Add("- ``$RunHistoryJsonFileName`` - Same as above in JSON form for integrations") + [void]$md.Add('') + [void]$md.Add("_Generated at $generatedUtc_") + if ($InstalledModuleVersion) { + [void]$md.Add('') + [void]$md.Add(('_Generated by AzLocal.UpdateManagement v{0}._' -f $InstalledModuleVersion)) + } + + $summaryPath = Add-AzLocalPipelineStepSummary -Markdown ($md -join [Environment]::NewLine) -SummaryFileName $SummaryFileName + + if ($criticalHealthFailed -gt 0) { + Write-Warning "$criticalHealthFailed cluster(s) have update failures, health issues, or SBE prerequisite blocks. Check the detailed reports." + } + + if ($PassThru) { + return [pscustomobject]@{ + TotalClusters = [int]$totalTests + CriticalHealthPassed = [int]$criticalHealthPassed + CriticalHealthFailed = [int]$criticalHealthFailed + UpdateFailedCount = [int]$stUpdateFailed + ActionRequiredCount = [int]$stActionRequired + HealthFailureCount = [int]$stHealthFailure + SbeBlockedCount = [int]$stSbeBlocked + InProgressCount = [int]$stInProgress + ReadyForUpdateCount = [int]$stReadyForUpdate + UpToDateCount = [int]$stUpToDate + NeedsInvestigationCount = [int]$stOther + HasPrerequisiteCount = [int]$hasPrerequisite + RunHistoryCount = [int]$runHistoryCount + VersionDistCount = [int]$distinctVersions + SupportedClusters = [int]$supportedCount + UnsupportedClusters = [int]$unsupportedCount + UnknownVersionClusters = [int]$unknownVersionCount + SupportedYymmWindow = $supportedYymmWindow + SupportSource = $supportSource + LatestReleasedYymm = $latestReleasedYymm + LatestReleasedVersion = $latestReleasedVersion + InventoryCsvPath = $inventoryCsv + ReadinessCsvPath = $readinessCsv + ReadinessJsonPath = $readinessJson + XmlPath = $readinessXml + SummariesCsvPath = $summariesCsv + AvailableCsvPath = $availableCsv + RunsCsvPath = $runsCsv + RunHistoryCsvPath = $runHistoryCsv + RunHistoryJsonPath = $runHistoryJson + SummaryPath = $summaryPath + Rows = $readiness + RunHistoryRows = $runFailures + VersionDistribution = $versionDistribution + } + } +} diff --git a/AzLocal.UpdateManagement/Public/Export-AzLocalUpdateRunMonitorReport.ps1 b/AzLocal.UpdateManagement/Public/Export-AzLocalUpdateRunMonitorReport.ps1 new file mode 100644 index 00000000..b4b2b8a6 --- /dev/null +++ b/AzLocal.UpdateManagement/Public/Export-AzLocalUpdateRunMonitorReport.ps1 @@ -0,0 +1,629 @@ +function Export-AzLocalUpdateRunMonitorReport { + <# + .SYNOPSIS + Runs the Step.7 in-flight update-run monitor pipeline workload: + snapshots the fleet's latest update runs, classifies them by + per-step + overall elapsed and progress-status, writes the CSV + + JUnit XML artifacts, and emits the step-summary markdown + step + outputs for the v0.8.5 thin-YAML Step.7 pipeline. + + .DESCRIPTION + Phase 1 (v0.8.5) of the thin-YAML refactor. Condenses the inline + `run: |` body of the v0.8.4 Step.7_monitor-updates.yml (GitHub + Actions + Azure DevOps) into a single cmdlet call so the + per-platform yml shrinks to a few lines and the workload becomes + unit-testable against a synthetic Get-AzLocalUpdateRuns result. + + The cmdlet: + + 1. Resolves the output directory (defaults to './reports' on + GitHub Actions / Local, or `$env:BUILD_ARTIFACTSTAGINGDIRECTORY` + on Azure DevOps - matching the v0.8.4 yml). + 2. Queries the fleet's latest update runs: + - Scope = 'all' -> `Get-AzLocalClusterInventory -PassThru` + then `Get-AzLocalUpdateRuns + -ClusterResourceIds -Latest + -PassThru -SkipSideloadedReset`. + - Scope = 'by-update-ring'-> `Get-AzLocalUpdateRuns + -ScopeByUpdateRingTag + -UpdateRingValue -Latest + -PassThru -SkipSideloadedReset`. + Both code paths match the v0.8.4 yml byte-for-byte. + 3. Enriches each run row with elapsed durations, threshold + flags (per-step warn/crit, overall warn/crit/skull), step-error + signal, recent-failure / unresolved-failure flags, severity + score, and portal URLs - identical to the v0.8.4 yml. + 4. Writes update-monitor.csv (sorted by severity score) and + update-monitor.xml (JUnit, one per in-flight + one + per unresolved-failed cluster) into the output directory. + 5. Emits the markdown step summary (top status badge + scope/ + threshold line + metric table + 'In-flight runs' table + + 'Failed runs (unresolved)' table + action-required / healthy + footer) via `Add-AzLocalPipelineStepSummary`. + 6. Emits 6 step outputs via `Set-AzLocalPipelineOutput`: + in_flight, long_running, long_running_step, step_errored, + recent_failures, unresolved_failures. + + Internal reuse (per the v0.8.5 thin-YAML consistency contract): + * `Get-AzLocalUpdateRuns` (with `-PassThru -SkipSideloadedReset`) + for the actual run query - the v0.8.4 yml comments call out + that `-PassThru` is REQUIRED (without it Get-AzLocalUpdateRuns + only writes formatted output to the host and returns nothing). + * `Get-AzLocalClusterInventory` for the all-clusters scope. + * `New-AzLocalPipelineJUnitXml` (Private) for JUnit emission. + * `Add-AzLocalPipelineStepSummary` for the rendered markdown. + * `Set-AzLocalPipelineOutput` for the step outputs. + * `Get-AzLocalPipelineHost` is implicit (the above branch on it). + + .PARAMETER OutputDirectory + Directory to write update-monitor.csv + update-monitor.xml into. + Created if it does not exist. Defaults to './reports' (which is + what the v0.8.4 GH yml uses) or, on AzureDevOps, to + `$env:BUILD_ARTIFACTSTAGINGDIRECTORY` if that env var is set. + + .PARAMETER Scope + 'all' (default) - query every cluster the identity can see (via + Get-AzLocalClusterInventory). 'by-update-ring' - query only + clusters whose UpdateRing tag matches -UpdateRing. + + .PARAMETER UpdateRing + UpdateRing tag value to filter by when -Scope is 'by-update-ring'. + Accepts a single ring ('Wave1'), a semicolon-delimited list + ('Prod;Ring2'), or '***' to match every cluster that HAS the + UpdateRing tag set. Ignored when -Scope is 'all'. + + .PARAMETER LongRunningStepHours + PRIMARY stuck-step signal. Flag in-flight runs whose CURRENT STEP + has been running longer than this many hours. Default 2. + + .PARAMETER LongRunningThresholdHours + Belt-and-braces overall-elapsed flag. Default 24h. + + .PARAMETER RecentFailureWindowHours + Surface FAILED runs whose End time falls within the last N hours. + Default 24. Set to 0 to disable the 'recent' chip (unresolved + failures are always surfaced). + + .PARAMETER CriticalElapsedDays + CRITICAL tier for overall-elapsed. In-flight runs older than this + many days get a rotating_light chip; older than 2x get a skull + chip. Default 3. + + .PARAMETER CsvFileName + Filename for the per-cluster CSV. Default 'update-monitor.csv'. + + .PARAMETER XmlFileName + Filename for the JUnit XML report. Default 'update-monitor.xml'. + + .PARAMETER SummaryFileName + Per-task summary filename used by `Add-AzLocalPipelineStepSummary` + on Azure DevOps and Local hosts. Default + 'update-monitor-summary.md'. + + .PARAMETER InstalledModuleVersion + Optional [string] used in the markdown footer + ('Generated by AzLocal.UpdateManagement v'). + + .PARAMETER Now + DateTime used to compute elapsed durations and the failure window. + Defaults to `Get-Date` at invocation time. Tests pass a fixed + value so elapsed comparisons are deterministic. + + .PARAMETER PassThru + When set, returns a single PSCustomObject summarising the run + (InFlightCount, LongRunningCount, LongRunningStepCount, + StepErroredCount, RecentFailureCount, UnresolvedFailureCount, + CsvPath, XmlPath, Rows). Without -PassThru the cmdlet emits + nothing to the pipeline; the artifacts and step outputs are still + produced. + + .OUTPUTS + Nothing by default. When -PassThru is set, a single PSCustomObject. + + .EXAMPLE + Export-AzLocalUpdateRunMonitorReport -Scope all -PassThru + + .NOTES + Module: AzLocal.UpdateManagement (v0.8.5+) + Roadmap: Step.7 - Monitor In-Flight Updates. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$OutputDirectory, + + [Parameter(Mandatory = $false)] + [ValidateSet('all', 'by-update-ring')] + [string]$Scope = 'all', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$UpdateRing, + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 8760)] + [int]$LongRunningStepHours = 2, + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 8760)] + [int]$LongRunningThresholdHours = 24, + + [Parameter(Mandatory = $false)] + [ValidateRange(0, 8760)] + [int]$RecentFailureWindowHours = 24, + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 365)] + [int]$CriticalElapsedDays = 3, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$CsvFileName = 'update-monitor.csv', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$XmlFileName = 'update-monitor.xml', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SummaryFileName = 'update-monitor-summary.md', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$InstalledModuleVersion, + + [Parameter(Mandatory = $false)] + [datetime]$Now = (Get-Date), + + [Parameter(Mandatory = $false)] + [switch]$PassThru + ) + + $pipelineHost = Get-AzLocalPipelineHost + + if (-not $OutputDirectory) { + if ($pipelineHost -eq 'AzureDevOps' -and $env:BUILD_ARTIFACTSTAGINGDIRECTORY) { + $OutputDirectory = $env:BUILD_ARTIFACTSTAGINGDIRECTORY + } + else { + $OutputDirectory = './reports' + } + } + if (-not (Test-Path -LiteralPath $OutputDirectory)) { + New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + } + + $monitorCsv = Join-Path -Path $OutputDirectory -ChildPath $CsvFileName + $monitorXml = Join-Path -Path $OutputDirectory -ChildPath $XmlFileName + + $nowUtc = $Now.ToUniversalTime() + $thresholdSpan = [TimeSpan]::FromHours($LongRunningThresholdHours) + $stepThresholdSpan = [TimeSpan]::FromHours($LongRunningStepHours) + $stepCritSpan = [TimeSpan]::FromHours($LongRunningStepHours * 2) + $critElapsedSpan = [TimeSpan]::FromDays($CriticalElapsedDays) + $skullElapsedSpan = [TimeSpan]::FromDays($CriticalElapsedDays * 2) + $failureWindowStart = if ($RecentFailureWindowHours -gt 0) { $nowUtc.AddHours(-$RecentFailureWindowHours) } else { [datetime]::MaxValue } + + Write-Host ("Thresholds: per-step={0}h (warn) / {1}h (crit), overall={2}h (warn) / {3}d (crit) / {4}d (skull), recent-failure-window={5}h" -f $LongRunningStepHours, ($LongRunningStepHours * 2), $LongRunningThresholdHours, $CriticalElapsedDays, ($CriticalElapsedDays * 2), $RecentFailureWindowHours) + + # ---- Query runs -------------------------------------------------------- + $runs = @() + if ($Scope -eq 'by-update-ring' -and $UpdateRing) { + Write-Host "Scope: UpdateRing = $UpdateRing" + $runs = @(Get-AzLocalUpdateRuns -ScopeByUpdateRingTag -UpdateRingValue $UpdateRing -Latest -PassThru -SkipSideloadedReset) + } + else { + Write-Host "Scope: all clusters (via inventory)" + $inventory = Get-AzLocalClusterInventory -PassThru + if (-not $inventory -or @($inventory).Count -eq 0) { + Write-Warning 'No clusters found in inventory.' + # Write empty CSV so the artifact upload step has something to attach. + '' | Set-Content -LiteralPath $monitorCsv -Encoding utf8 + # Write empty JUnit XML so the publish-test-results step has something to read. + $emptyXml = New-AzLocalPipelineJUnitXml -TestSuitesName 'Update Run Monitor' -Suites @( + @{ Name = 'Update Run Monitor'; ClassName = 'UpdateMonitor'; TestCases = @() } + ) -OutputPath $monitorXml + $null = $emptyXml + Set-AzLocalPipelineOutput -Name 'in_flight' -Value '0' + Set-AzLocalPipelineOutput -Name 'long_running' -Value '0' + Set-AzLocalPipelineOutput -Name 'long_running_step' -Value '0' + Set-AzLocalPipelineOutput -Name 'step_errored' -Value '0' + Set-AzLocalPipelineOutput -Name 'recent_failures' -Value '0' + Set-AzLocalPipelineOutput -Name 'unresolved_failures' -Value '0' + $emptySb = New-Object System.Text.StringBuilder + [void]$emptySb.AppendLine('## In-Flight Update Monitor') + [void]$emptySb.AppendLine('') + [void]$emptySb.AppendLine(':white_circle: **Fleet Status: IDLE** - no clusters found in inventory') + Add-AzLocalPipelineStepSummary -Markdown $emptySb.ToString() -SummaryFileName $SummaryFileName | Out-Null + if ($PassThru) { + return [pscustomobject]@{ + InFlightCount = 0 + LongRunningCount = 0 + LongRunningStepCount = 0 + StepErroredCount = 0 + RecentFailureCount = 0 + UnresolvedFailureCount = 0 + CsvPath = $monitorCsv + XmlPath = $monitorXml + Rows = @() + } + } + return + } + $resourceIds = @($inventory | Select-Object -ExpandProperty ResourceId) + $runs = @(Get-AzLocalUpdateRuns -ClusterResourceIds $resourceIds -Latest -PassThru -SkipSideloadedReset) + } + + # ---- Project + enrich rows -------------------------------------------- + $rows = foreach ($r in $runs) { + $startDt = $null + if ($r.StartTime) { + [datetime]$tmp = [datetime]::MinValue + if ([datetime]::TryParse([string]$r.StartTime, [ref]$tmp)) { + $startDt = [datetime]::SpecifyKind($tmp, [DateTimeKind]::Utc) + } + } + $endDt = $null + if ($r.EndTime) { + [datetime]$tmpE = [datetime]::MinValue + if ([datetime]::TryParse([string]$r.EndTime, [ref]$tmpE)) { + $endDt = [datetime]::SpecifyKind($tmpE, [DateTimeKind]::Utc) + } + } + $stepStartDt = $null + if ($r.PSObject.Properties['StepStartTime'] -and $r.StepStartTime) { + [datetime]$tmpS = [datetime]::MinValue + if ([datetime]::TryParse([string]$r.StepStartTime, [ref]$tmpS)) { + $stepStartDt = [datetime]::SpecifyKind($tmpS, [DateTimeKind]::Utc) + } + } + $elapsed = if ($startDt) { $nowUtc - $startDt } else { $null } + $elapsedDisplay = if ($elapsed) { + if ($elapsed.TotalDays -ge 1) { ('{0}d {1}h {2}m' -f [int]$elapsed.TotalDays, $elapsed.Hours, $elapsed.Minutes) } + elseif ($elapsed.TotalHours -ge 1) { ('{0}h {1}m' -f [int]$elapsed.TotalHours, $elapsed.Minutes) } + else { ('{0}m' -f [int]$elapsed.TotalMinutes) } + } else { '' } + $stepElapsed = if ($stepStartDt -and $r.State -eq 'InProgress') { $nowUtc - $stepStartDt } else { $null } + $stepElapsedHoursVal = if ($stepElapsed) { [math]::Round($stepElapsed.TotalHours, 2) } else { '' } + $stepElapsedDisplay = if ($r.PSObject.Properties['StepElapsed']) { [string]$r.StepElapsed } else { '' } + if ([string]::IsNullOrWhiteSpace($stepElapsedDisplay) -and $stepElapsed) { + $stepElapsedDisplay = if ($stepElapsed.TotalHours -ge 1) { ('{0}h {1}m' -f [int]$stepElapsed.TotalHours, $stepElapsed.Minutes) } else { ('{0}m' -f [int]$stepElapsed.TotalMinutes) } + } + $exceeds = if ($elapsed -and ($r.State -eq 'InProgress')) { $elapsed -gt $thresholdSpan } else { $false } + $exceedsStep = if ($stepElapsed -and ($r.State -eq 'InProgress')) { $stepElapsed -gt $stepThresholdSpan } else { $false } + $isRecentFailure = if ($RecentFailureWindowHours -gt 0 -and $r.State -eq 'Failed' -and $endDt) { $endDt -gt $failureWindowStart } else { $false } + $isUnresolvedFailure = ($r.State -eq 'Failed') + $progressStatus = if ($r.PSObject.Properties['Status']) { [string]$r.Status } else { '' } + $hasStepError = ($progressStatus -eq 'Error' -and $r.State -eq 'InProgress') + + $clusterPortalUrl = if ($r.ClusterResourceId) { 'https://portal.azure.com/#@/resource' + [string]$r.ClusterResourceId + '/updates' } else { '' } + $updateRunPortalUrl = '' + if ($r.PSObject.Properties['RunResourceId'] -and $r.RunResourceId -and $r.ClusterResourceId) { + $rm = [regex]::Match([string]$r.RunResourceId, '/updates/([^/]+)/updateRuns/([^/]+)$') + if ($rm.Success) { + $encClusterId = ((([string]$r.ClusterResourceId) -replace '/', '%2F') -replace ' ', '%20') + $updateRunPortalUrl = 'https://portal.azure.com/#view/Microsoft_AzureStackHCI_PortalExtension/SingleInstanceHistoryDetails.ReactView/resourceId/' + $encClusterId + '/updateName/' + $rm.Groups[1].Value + '/updateRunName/' + $rm.Groups[2].Value + '/refresh~/false' + } + } + + $stepSeverity = 'none' + if ($stepElapsed -and $r.State -eq 'InProgress') { + if ($stepElapsed -gt $stepCritSpan) { $stepSeverity = 'crit' } + elseif ($stepElapsed -gt $stepThresholdSpan) { $stepSeverity = 'warn' } + } + $runSeverity = 'none' + if ($elapsed -and $r.State -eq 'InProgress') { + if ($elapsed -gt $skullElapsedSpan) { $runSeverity = 'skull' } + elseif ($elapsed -gt $critElapsedSpan) { $runSeverity = 'crit' } + elseif ($elapsed -gt $thresholdSpan) { $runSeverity = 'warn' } + } + $stateIcon = switch ($r.State) { + 'InProgress' { ':large_blue_circle:' } + 'Succeeded' { ':large_green_circle:' } + 'Failed' { ':red_circle:' } + 'NotStarted' { ':white_circle:' } + default { ':grey_question:' } + } + $statusIcon = switch ($progressStatus) { + 'Success' { ':white_check_mark:' } + 'Error' { ':x:' } + 'InProgress' { ':hourglass_flowing_sand:' } + 'Skipped' { ':no_entry:' } + 'Cancelled' { ':fast_forward:' } + default { '' } + } + $chipList = New-Object 'System.Collections.Generic.List[string]' + if ($hasStepError) { $chipList.Add(':rotating_light: step errored') | Out-Null } + if ($stepSeverity -eq 'crit') { $chipList.Add((':rotating_light: step >{0}h' -f ($LongRunningStepHours * 2))) | Out-Null } + elseif ($stepSeverity -eq 'warn') { $chipList.Add((':warning: step >{0}h' -f $LongRunningStepHours)) | Out-Null } + if ($runSeverity -eq 'skull') { $chipList.Add((':skull: run >{0}d' -f ($CriticalElapsedDays * 2))) | Out-Null } + elseif ($runSeverity -eq 'crit') { $chipList.Add((':rotating_light: run >{0}d' -f $CriticalElapsedDays)) | Out-Null } + elseif ($runSeverity -eq 'warn') { $chipList.Add((':warning: run >{0}h' -f $LongRunningThresholdHours)) | Out-Null } + if ($chipList.Count -eq 0 -and $r.State -eq 'InProgress') { $chipList.Add('within') | Out-Null } + $flagDisplay = ($chipList -join '
') + + $severityScore = 0.0 + if ($hasStepError) { $severityScore += 1000 } + if ($r.State -eq 'Failed') { $severityScore += 800 } + if ($runSeverity -eq 'skull') { $severityScore += 500 } + elseif ($runSeverity -eq 'crit') { $severityScore += 300 } + elseif ($runSeverity -eq 'warn') { $severityScore += 50 } + if ($stepSeverity -eq 'crit') { $severityScore += 200 } + elseif ($stepSeverity -eq 'warn') { $severityScore += 30 } + $elapsedHoursForScore = if ($elapsed) { $elapsed.TotalHours } else { 0 } + $severityScore += [math]::Round($elapsedHoursForScore / 24, 2) + + $runDurationSeconds = 0 + if ($r.State -ne 'InProgress' -and $startDt -and $endDt) { + $runDurationSeconds = [int][math]::Round(($endDt - $startDt).TotalSeconds, 0) + } + elseif ($elapsed) { + $runDurationSeconds = [int][math]::Round($elapsed.TotalSeconds, 0) + } + if ($runDurationSeconds -lt 0) { $runDurationSeconds = 0 } + + [PSCustomObject]@{ + ClusterName = $r.ClusterName + ClusterPortalUrl = $clusterPortalUrl + UpdateName = $r.UpdateName + UpdateRunPortalUrl = $updateRunPortalUrl + State = $r.State + Status = $progressStatus + CurrentStep = $r.CurrentStep + Progress = $r.Progress + StartTimeUtc = if ($startDt) { $startDt.ToString('yyyy-MM-dd HH:mm') } else { '' } + EndTimeUtc = if ($endDt) { $endDt.ToString('yyyy-MM-dd HH:mm') } else { '' } + ElapsedDisplay = $elapsedDisplay + ElapsedHours = if ($elapsed) { [math]::Round($elapsed.TotalHours, 2) } else { '' } + StepStartTimeUtc = if ($stepStartDt) { $stepStartDt.ToString('yyyy-MM-dd HH:mm') } else { '' } + StepElapsedDisplay = $stepElapsedDisplay + StepElapsedHours = $stepElapsedHoursVal + ExceedsThreshold = $exceeds + ExceedsStepThreshold = $exceedsStep + HasStepError = $hasStepError + IsRecentFailure = $isRecentFailure + IsUnresolvedFailure = $isUnresolvedFailure + ThresholdHours = $LongRunningThresholdHours + StepThresholdHours = $LongRunningStepHours + CriticalElapsedDays = $CriticalElapsedDays + StepSeverity = $stepSeverity + RunSeverity = $runSeverity + StateIcon = $stateIcon + StatusIcon = $statusIcon + Flags = $flagDisplay + SeverityScore = $severityScore + RunDurationSeconds = $runDurationSeconds + RunId = $r.RunId + RunResourceId = if ($r.PSObject.Properties['RunResourceId']) { $r.RunResourceId } else { '' } + ClusterResourceId = $r.ClusterResourceId + Duration = $r.Duration + CurrentStepDetail = $r.CurrentStepDetail + ErrorMessage = if ($r.PSObject.Properties['ErrorMessage']) { [string]$r.ErrorMessage } else { '' } + } + } + $rows = @($rows) + + $inFlight = @($rows | Where-Object { $_.State -eq 'InProgress' }) + $longRunning = @($inFlight | Where-Object { $_.ExceedsThreshold }) + $longRunningStep = @($inFlight | Where-Object { $_.ExceedsStepThreshold }) + $stepErrored = @($inFlight | Where-Object { $_.HasStepError }) + $recentlyFailed = @($rows | Where-Object { $_.IsRecentFailure }) + $unresolvedFailed = @($rows | Where-Object { $_.IsUnresolvedFailure }) + + # ---- CSV (always emit, even if empty) --------------------------------- + if ($rows.Count -gt 0) { + $rows | Sort-Object @{Expression='SeverityScore';Descending=$true}, ClusterName | Export-Csv -Path $monitorCsv -NoTypeInformation -Encoding utf8 + } + else { + '' | Set-Content -LiteralPath $monitorCsv -Encoding utf8 + } + + # ---- JUnit XML via the shared emitter --------------------------------- + $testCases = New-Object 'System.Collections.Generic.List[hashtable]' + foreach ($r in ($inFlight | Sort-Object @{Expression='SeverityScore';Descending=$true}, ClusterName)) { + $safeName = ($r.ClusterName -replace '[^A-Za-z0-9_.-]', '_') + $caseName = "$safeName - $($r.UpdateName) - $($r.CurrentStep)" + if ($r.HasStepError) { + $errSnippet = if ($r.ErrorMessage) { $r.ErrorMessage } else { '(no errorMessage on deepest failed step)' } + $msg = ('Progress status is Error (state still InProgress) - step is stuck. CurrentStep: {0}. StepElapsed: {1}. ErrorMessage: {2}' -f $r.CurrentStep, $r.StepElapsedDisplay, $errSnippet) + $testCases.Add(@{ + Name = $caseName + ClassName = 'UpdateMonitor' + Time = [double]$r.RunDurationSeconds + Failure = @{ Message = $msg; Type = 'StepError'; Body = $msg } + }) | Out-Null + } + elseif ($r.ExceedsStepThreshold) { + $msg = ('Current step elapsed {0} exceeds per-step threshold of {1}h. CurrentStep: {2}. Overall elapsed: {3}. Progress: {4}.' -f $r.StepElapsedDisplay, $r.StepThresholdHours, $r.CurrentStep, $r.ElapsedDisplay, $r.Progress) + $testCases.Add(@{ + Name = $caseName + ClassName = 'UpdateMonitor' + Time = [double]$r.RunDurationSeconds + Failure = @{ Message = $msg; Type = 'LongRunningStep'; Body = $msg } + }) | Out-Null + } + elseif ($r.ExceedsThreshold) { + $msg = ('Overall elapsed {0} exceeds backstop threshold of {1}h (current step within {2}h per-step budget). CurrentStep: {3}. Progress: {4}.' -f $r.ElapsedDisplay, $r.ThresholdHours, $r.StepThresholdHours, $r.CurrentStep, $r.Progress) + $testCases.Add(@{ + Name = $caseName + ClassName = 'UpdateMonitor' + Time = [double]$r.RunDurationSeconds + Failure = @{ Message = $msg; Type = 'LongRunningOverall'; Body = $msg } + }) | Out-Null + } + else { + $testCases.Add(@{ + Name = $caseName + ClassName = 'UpdateMonitor' + Time = [double]$r.RunDurationSeconds + }) | Out-Null + } + } + foreach ($r in ($unresolvedFailed | Sort-Object @{Expression='EndTimeUtc';Descending=$true})) { + $safeName = ($r.ClusterName -replace '[^A-Za-z0-9_.-]', '_') + $caseName = "$safeName - $($r.UpdateName) - FAILED" + $detail = if ($r.ErrorMessage) { $r.ErrorMessage } elseif ($r.CurrentStepDetail) { $r.CurrentStepDetail } else { $r.CurrentStep } + $msg = ('Run failed at {0} UTC. Step: {1}. Detail: {2}.' -f $r.EndTimeUtc, $r.CurrentStep, $detail) + $testCases.Add(@{ + Name = $caseName + ClassName = 'UpdateMonitor' + Time = [double]$r.RunDurationSeconds + Failure = @{ Message = $msg; Type = 'RecentFailure'; Body = $msg } + }) | Out-Null + } + $null = New-AzLocalPipelineJUnitXml -TestSuitesName 'Update Run Monitor' -Suites @( + @{ + Name = 'Update Run Monitor' + ClassName = 'UpdateMonitor' + TestCases = @($testCases) + } + ) -OutputPath $monitorXml + + # ---- Step outputs ----------------------------------------------------- + Set-AzLocalPipelineOutput -Name 'in_flight' -Value ([string]$inFlight.Count) + Set-AzLocalPipelineOutput -Name 'long_running' -Value ([string]$longRunning.Count) + Set-AzLocalPipelineOutput -Name 'long_running_step' -Value ([string]$longRunningStep.Count) + Set-AzLocalPipelineOutput -Name 'step_errored' -Value ([string]$stepErrored.Count) + Set-AzLocalPipelineOutput -Name 'recent_failures' -Value ([string]$recentlyFailed.Count) + Set-AzLocalPipelineOutput -Name 'unresolved_failures' -Value ([string]$unresolvedFailed.Count) + + # ---- Markdown step summary ------------------------------------------- + $md = New-Object 'System.Collections.Generic.List[string]' + [void]$md.Add('## In-Flight Update Monitor') + [void]$md.Add('') + $skullCount = @($inFlight | Where-Object { $_.RunSeverity -eq 'skull' }).Count + $critRunCount = @($inFlight | Where-Object { $_.RunSeverity -eq 'crit' }).Count + $critStepCount = @($inFlight | Where-Object { $_.StepSeverity -eq 'crit' }).Count + $hasCritical = ($stepErrored.Count -gt 0) -or ($unresolvedFailed.Count -gt 0) -or ($skullCount -gt 0) -or ($critRunCount -gt 0) -or ($critStepCount -gt 0) + $warnCount = @($inFlight | Where-Object { $_.StepSeverity -eq 'warn' -or $_.RunSeverity -eq 'warn' }).Count + $hasWarn = ($warnCount -gt 0) + $statusBadge = if ($hasCritical) { + $crits = @() + if ($stepErrored.Count -gt 0) { $crits += "$($stepErrored.Count) stuck step error(s)" } + if ($skullCount -gt 0) { $crits += "$skullCount run(s) > $($CriticalElapsedDays * 2)d" } + if ($critRunCount -gt 0) { $crits += "$critRunCount run(s) > ${CriticalElapsedDays}d" } + if ($critStepCount -gt 0) { $crits += "$critStepCount step(s) > $($LongRunningStepHours * 2)h" } + if ($unresolvedFailed.Count -gt 0) { $crits += "$($unresolvedFailed.Count) unresolved failure(s)" } + ':red_circle: **Fleet Status: CRITICAL** - ' + ($crits -join ', ') + } + elseif ($hasWarn) { + ":yellow_circle: **Fleet Status: WARN** - $warnCount long-running run(s)" + } + elseif ($inFlight.Count -gt 0) { + ":green_circle: **Fleet Status: HEALTHY** - $($inFlight.Count) in-flight run(s) within all thresholds" + } + else { + ':white_circle: **Fleet Status: IDLE** - no update runs currently in flight' + } + [void]$md.Add($statusBadge) + [void]$md.Add('') + $scopeLabel = if ($Scope -eq 'by-update-ring' -and $UpdateRing) { "by-update-ring (UpdateRing = $UpdateRing)" } else { 'all clusters' } + [void]$md.Add("**Scope**: $scopeLabel - **Per-step warn/crit**: ${LongRunningStepHours}h / $($LongRunningStepHours * 2)h - **Overall warn/crit/skull**: ${LongRunningThresholdHours}h / ${CriticalElapsedDays}d / $($CriticalElapsedDays * 2)d - **Recent-failure window**: ${RecentFailureWindowHours}h - **Snapshot (UTC)**: $($nowUtc.ToString('yyyy-MM-dd HH:mm'))") + [void]$md.Add('') + [void]$md.Add('| Metric | Count |') + [void]$md.Add('|--------|-------|') + [void]$md.Add("| Clusters scoped | $($rows.Count) |") + [void]$md.Add("| Update runs in flight | $($inFlight.Count) |") + [void]$md.Add("| Step errored (progress.status == 'Error', state still InProgress) | $($stepErrored.Count) |") + [void]$md.Add("| Step elapsed > ${LongRunningStepHours}h (primary) | $($longRunningStep.Count) |") + [void]$md.Add("| Overall elapsed > ${LongRunningThresholdHours}h (backstop) | $($longRunning.Count) |") + [void]$md.Add("| Unresolved-failed runs (latest run is Failed) | $($unresolvedFailed.Count) |") + if ($RecentFailureWindowHours -gt 0) { + [void]$md.Add("| Recently-failed runs (last ${RecentFailureWindowHours}h) | $($recentlyFailed.Count) |") + } + [void]$md.Add('') + if ($inFlight.Count -gt 0) { + [void]$md.Add('### In-flight runs (sorted by severity score, worst first)') + [void]$md.Add('') + [void]$md.Add('| Cluster | Update | State | Progress Status | Current Step | Progress | Step Started (UTC) | Step Elapsed | Run Started (UTC) | Run Elapsed | Flags |') + [void]$md.Add('|---------|--------|-------|-----------------|--------------|----------|--------------------|--------------|-------------------|-------------|-------|') + foreach ($r in ($inFlight | Sort-Object @{Expression='SeverityScore';Descending=$true}, ClusterName)) { + $cs = if ($r.CurrentStep) { $r.CurrentStep } else { '-' } + $pg = if ($r.Progress) { $r.Progress } else { '-' } + $stepStart = if ($r.StepStartTimeUtc) { $r.StepStartTimeUtc } else { '-' } + $stateCell = if ($r.StateIcon) { "$($r.StateIcon) $($r.State)" } else { [string]$r.State } + $statusCell = if (-not $r.Status) { '-' } + elseif ($r.StatusIcon) { "$($r.StatusIcon) $($r.Status)" } + else { [string]$r.Status } + $stepElPrefix = switch ($r.StepSeverity) { 'crit' { ':rotating_light: ' } 'warn' { ':warning: ' } default { '' } } + $runElPrefix = switch ($r.RunSeverity) { 'skull' { ':skull: ' } 'crit' { ':rotating_light: ' } 'warn' { ':warning: ' } default { '' } } + $stepEl = if ($r.StepElapsedDisplay) { $stepElPrefix + $r.StepElapsedDisplay } else { '-' } + $runEl = if ($r.ElapsedDisplay) { $runElPrefix + $r.ElapsedDisplay } else { '-' } + $flagCell = if ($r.Flags) { $r.Flags } else { '-' } + $clusterCell = if ($r.ClusterPortalUrl) { '' + $r.ClusterName + '' } else { $r.ClusterName } + $updateCell = if ($r.UpdateRunPortalUrl) { '' + $r.UpdateName + '' } else { $r.UpdateName } + [void]$md.Add("| $clusterCell | $updateCell | $stateCell | $statusCell | $cs | $pg | $stepStart | $stepEl | $($r.StartTimeUtc) | $runEl | $flagCell |") + } + [void]$md.Add('') + } + else { + [void]$md.Add('### No update runs currently in flight') + [void]$md.Add('') + [void]$md.Add('No clusters in scope have a latest run in state `InProgress`. To verify scope, see the artifact CSV (`update-monitor.csv`).') + [void]$md.Add('') + } + if ($unresolvedFailed.Count -gt 0) { + [void]$md.Add('### Failed runs (latest run is Failed, unresolved - shown regardless of age)') + [void]$md.Add('') + [void]$md.Add('| Cluster | Update | Ended (UTC) | Failed Step | Verbose Error Details | Recent |') + [void]$md.Add('|---------|--------|-------------|-------------|-----------------------|--------|') + foreach ($r in ($unresolvedFailed | Sort-Object @{Expression='EndTimeUtc';Descending=$true})) { + $cs = if ($r.CurrentStep) { $r.CurrentStep } else { '-' } + $rawDetail = if ($r.ErrorMessage) { [string]$r.ErrorMessage } elseif ($r.CurrentStepDetail) { [string]$r.CurrentStepDetail } else { '' } + $detailCell = if ([string]::IsNullOrWhiteSpace($rawDetail)) { '_(no error detail)_' } else { + $e = $rawDetail -replace '&','&' -replace '<','<' -replace '>','>' + $e = $e -replace "`r`n",'
' -replace "`n",'
' -replace '\|','\|' + '
Show error
' + $e + '
' + } + $recentTag = if ($r.IsRecentFailure) { ":fire: last ${RecentFailureWindowHours}h" } else { '-' } + $clusterCell = if ($r.ClusterPortalUrl) { '' + $r.ClusterName + '' } else { $r.ClusterName } + $updateCell = if ($r.UpdateRunPortalUrl) { '' + $r.UpdateName + '' } else { $r.UpdateName } + [void]$md.Add("| $clusterCell | $updateCell | $($r.EndTimeUtc) | $cs | $detailCell | $recentTag |") + } + [void]$md.Add('') + } + if (($stepErrored.Count + $longRunningStep.Count + $longRunning.Count + $unresolvedFailed.Count) -gt 0) { + [void]$md.Add('> **Action required.** One or more update runs have errored, hit a threshold, or have an unresolved Failed latest run. Common causes (consult the Azure Local Update Manager portal + the cluster activity log for the affected cluster(s)):') + [void]$md.Add('>') + [void]$md.Add('> - Health check failures (storage / network / cluster service) blocking the run from progressing') + [void]$md.Add('> - Node drain stuck (VM live-migration timeout, anti-affinity blocking move)') + [void]$md.Add('> - Sideloaded payload / pre-staged content mismatch on one or more nodes') + [void]$md.Add('> - ARB (Arc Resource Bridge) connectivity loss or extension-version drift') + [void]$md.Add('>') + [void]$md.Add('> Troubleshooting guides:') + [void]$md.Add('> - **Microsoft Learn:** [Troubleshoot update failures (Azure Local 23H2)](https://learn.microsoft.com/azure/azure-local/update/update-troubleshooting-23h2#troubleshoot-update-failures)') + [void]$md.Add('> - **GitHub TSG:** [Azure/AzureLocal-Supportability/TSG/Update](https://github.com/Azure/AzureLocal-Supportability/tree/main/TSG/Update)') + [void]$md.Add('>') + [void]$md.Add('> The Checks tab shows the same rows as JUnit failures (`StepError`, `LongRunningStep`, `LongRunningOverall`, `RecentFailure`).') + [void]$md.Add('') + } + elseif ($inFlight.Count -gt 0) { + [void]$md.Add("> **All in-flight runs are healthy (no step errors, per-step <=${LongRunningStepHours}h, overall <=${LongRunningThresholdHours}h) and no unresolved failures.**") + [void]$md.Add('') + } + [void]$md.Add('_Source data: `Get-AzLocalUpdateRuns -Latest -PassThru`. JUnit emitted as `' + $monitorXml + '`; full per-cluster rows in `' + $monitorCsv + '`._') + if ($InstalledModuleVersion) { + [void]$md.Add('') + [void]$md.Add(('_Generated by AzLocal.UpdateManagement v{0}._' -f $InstalledModuleVersion)) + } + + Add-AzLocalPipelineStepSummary -Markdown ($md -join [Environment]::NewLine) -SummaryFileName $SummaryFileName | Out-Null + + if ($PassThru) { + return [pscustomobject]@{ + InFlightCount = [int]$inFlight.Count + LongRunningCount = [int]$longRunning.Count + LongRunningStepCount = [int]$longRunningStep.Count + StepErroredCount = [int]$stepErrored.Count + RecentFailureCount = [int]$recentlyFailed.Count + UnresolvedFailureCount = [int]$unresolvedFailed.Count + CsvPath = $monitorCsv + XmlPath = $monitorXml + Rows = $rows + } + } +} diff --git a/AzLocal.UpdateManagement/Public/Get-AzLocalApplyUpdatesScheduleCycleCalendar.ps1 b/AzLocal.UpdateManagement/Public/Get-AzLocalApplyUpdatesScheduleCycleCalendar.ps1 new file mode 100644 index 00000000..b640aca2 --- /dev/null +++ b/AzLocal.UpdateManagement/Public/Get-AzLocalApplyUpdatesScheduleCycleCalendar.ps1 @@ -0,0 +1,330 @@ +function Get-AzLocalApplyUpdatesScheduleCycleCalendar { + <# + .SYNOPSIS + Projects an apply-updates-schedule.yml forward one full cycle + (or any -Days window) and renders a human-readable per-day + calendar of which UpdateRing(s) are eligible on each UTC day. + + .DESCRIPTION + Step.3's "what is the upcoming cycle going to do?" advisor. + Built on top of Get-AzLocalApplyUpdatesScheduleNextFirings so all + ISO-8601 week math, cycle-anchor wrap arithmetic, UNION semantics + across overlapping schedule rows, and AllowedUpdateVersions + resolution flow through Resolve-AzLocalCurrentUpdateRing - no + duplicate week-arithmetic code paths to drift. + + Works correctly for cycles of any length (4, 8, 13, 26, 52 + weeks) and across year boundaries (ISO weeks 52 / 53 / W1). + + Default projection horizon is one full cycle (CycleWeeks * 7 + days) starting today, which means an operator currently in week + 8 of an 8-week cycle sees week 8 (today + a few days), then the + full new week-1-through-week-7 of the next cycle, exposing + exactly when each ring will fire next. + + The IsCycleWrap flag and CycleWeekLabel ("X of N (cycle wraps)") + make the moment of rotation back to the start of the cycle + unambiguous in both the object output and the markdown table. + + .PARAMETER Schedule + Parsed config object from Get-AzLocalApplyUpdatesScheduleConfig. + + .PARAMETER StartDate + First UTC day to project. Default: today (UTC). + + .PARAMETER Days + Number of consecutive UTC days to project. Default: one full + cycle ($Schedule.CycleWeeks * 7). Range: 1..3650 so operators + can ask for multi-cycle views. + + .PARAMETER AsMarkdown + Switch. When set, returns a single [string] containing a fully + rendered markdown report (heading + intro + calendar table, plus + the per-ring projection section when -IncludePerRingSummary is + also supplied). When not set, returns the per-day [PSCustomObject] + pipeline for programmatic consumers. + + .PARAMETER IncludePerRingSummary + Switch. When set together with -AsMarkdown, also emits a + "### Per-ring projection" section that lists each ring referenced + by the schedule with its next eligible UTC date and the full set + of eligible dates within the projection horizon. Helpful for + operators answering "when will my Prod ring fire next?". + + .PARAMETER ClusterRingCounts + Optional hashtable mapping ring name (case-insensitive) to the + count of clusters currently tagged with that UpdateRing. When + supplied AND -AsMarkdown is set, the per-day calendar table and + the per-ring projection table both gain a "Clusters in ring(s)" + / "Cluster count" column so operators can see at a glance how + many clusters each upcoming firing will touch. Pure cmdlet - + callers (e.g. Test-AzLocalApplyUpdatesScheduleCoverage / Step.3 + yml) build this dictionary from their cluster CSV or live tag + scan; the cmdlet itself does no Azure or CSV I/O. + + .OUTPUTS + Default: [PSCustomObject[]], one per UTC day, with: + DateUtc [datetime] - midnight UTC + DayOfWeekName [string] - 'Sun'..'Sat' + CycleWeek [int] - 1..CycleWeeks + CycleWeeksTotal [int] - Schedule.CycleWeeks + CycleWeekLabel [string] - 'X of N' (+ ' (cycle wraps)' on the wrap day) + IsCycleWrap [bool] - true on day-of-week-Monday when CycleWeek rolls back to 1 + Rings [string[]] - UNION of all matching rows + UpdateRingValue [string] - ';'-joined for display + AllowedUpdateVersions [string[]] - effective allow-list ([] = no constraint / Latest) + AllowedUpdateVersionsValue [string] - ';'-joined for display + AllowedUpdateVersionsSource [string] - 'row' | 'top-level' | 'none' + MatchedRowCount [int] + IsDeadDay [bool] - true when Rings is empty + Reason [string] - Resolve-AzLocalCurrentUpdateRing reason + + With -AsMarkdown: [string] containing the rendered markdown + report. + + .EXAMPLE + $cfg = Get-AzLocalApplyUpdatesScheduleConfig -Path .\.github\apply-updates-schedule.yml + Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $cfg | Format-Table -AutoSize + + .EXAMPLE + # Render the markdown for a pipeline step summary (the Step.3 use case) + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $cfg -AsMarkdown -IncludePerRingSummary + $md | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append + + .EXAMPLE + # Three full cycles ahead for a 13-week quarterly rotation + Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $cfg -Days ($cfg.CycleWeeks * 7 * 3) | + Where-Object IsDeadDay | Format-Table DateUtc, DayOfWeekName, CycleWeek + #> + [CmdletBinding()] + [OutputType([PSCustomObject[]], [string])] + param( + [Parameter(Mandatory = $true)] + [PSCustomObject]$Schedule, + + [Parameter(Mandatory = $false)] + [datetime]$StartDate = ([datetime]::UtcNow.Date), + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 3650)] + [int]$Days = 0, + + [Parameter(Mandatory = $false)] + [switch]$AsMarkdown, + + [Parameter(Mandatory = $false)] + [switch]$IncludePerRingSummary, + + [Parameter(Mandatory = $false)] + [hashtable]$ClusterRingCounts + ) + + Set-StrictMode -Version Latest + + $cycleWeeks = [int]$Schedule.CycleWeeks + if ($cycleWeeks -lt 1) { + throw "Get-AzLocalApplyUpdatesScheduleCycleCalendar: Schedule.CycleWeeks must be >= 1. Got: $cycleWeeks." + } + if ($Days -eq 0) { $Days = $cycleWeeks * 7 } + + $start = [datetime]::SpecifyKind($StartDate.Date, [DateTimeKind]::Utc) + + # Re-use the existing day-iterator so we inherit all of + # Resolve-AzLocalCurrentUpdateRing's correctness (ISO week math, + # anchor wrap across years, AllowedUpdateVersions resolution). + # Get-AzLocalApplyUpdatesScheduleNextFirings caps -Days at 366; for + # longer horizons we iterate in chunks. + $rawRows = New-Object System.Collections.Generic.List[psobject] + $cursor = $start + $remain = $Days + while ($remain -gt 0) { + $chunk = if ($remain -gt 366) { 366 } else { $remain } + $batch = Get-AzLocalApplyUpdatesScheduleNextFirings -Schedule $Schedule -StartDate $cursor -Days $chunk + foreach ($r in @($batch)) { [void]$rawRows.Add($r) } + $cursor = $cursor.AddDays($chunk) + $remain -= $chunk + } + + # Enrich each day with CycleWeekLabel, IsCycleWrap, IsDeadDay, and + # the allow-list trio that NextFirings does not project today. For + # the allow-list we call Resolve-AzLocalCurrentUpdateRing once per + # day (NextFirings already does this internally; calling again is + # cheap and keeps the enrichment local to this cmdlet without + # changing NextFirings' contract). + $rows = New-Object System.Collections.Generic.List[psobject] + $prevCycleWeek = $null + foreach ($r in $rawRows) { + $moment = ([datetime]::SpecifyKind($r.DateUtc.Date, [DateTimeKind]::Utc)).AddHours(12) + $resolved = Resolve-AzLocalCurrentUpdateRing -Schedule $Schedule -Now $moment + # IsCycleWrap = the first day on which CycleWeek rolls back to + # 1 after being at CycleWeeksTotal. For a 1-week "cycle" this + # is true every Monday; for an N-week cycle it is true once per + # cycle on the day-of-week when ISO-week increments from + # anchor+N-1 to anchor+N (canonically a Monday in ISO-8601). + $isWrap = ($null -ne $prevCycleWeek -and $r.CycleWeek -eq 1 -and $prevCycleWeek -eq $cycleWeeks) + $label = if ($isWrap) { "**1** of $cycleWeeks _(cycle wraps)_" } else { "$($r.CycleWeek) of $cycleWeeks" } + $rings = @($r.Rings) + $rows.Add([pscustomobject]@{ + DateUtc = $r.DateUtc + DayOfWeekName = $r.DayOfWeekName + CycleWeek = $r.CycleWeek + CycleWeeksTotal = $cycleWeeks + CycleWeekLabel = $label + IsCycleWrap = $isWrap + Rings = $rings + UpdateRingValue = $r.UpdateRingValue + AllowedUpdateVersions = @($resolved.AllowedUpdateVersions) + AllowedUpdateVersionsValue = $resolved.AllowedUpdateVersionsValue + AllowedUpdateVersionsSource = $resolved.AllowedUpdateVersionsSource + MatchedRowCount = $r.MatchedRowCount + IsDeadDay = ($rings.Count -eq 0) + Reason = $resolved.Reason + }) | Out-Null + $prevCycleWeek = $r.CycleWeek + } + + if (-not $AsMarkdown) { + return $rows.ToArray() + } + + # Normalise the optional ring -> count map to case-insensitive lookup + # so callers can pass any casing (tag values are not consistent in + # the wild). $hasCounts gates the extra column in the markdown. + $ringCounts = $null + $hasCounts = $false + if ($PSBoundParameters.ContainsKey('ClusterRingCounts') -and $ClusterRingCounts) { + $ringCounts = New-Object 'System.Collections.Generic.Dictionary[string,int]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach ($k in $ClusterRingCounts.Keys) { + if ($null -eq $k) { continue } + $kn = [string]$k + if ([string]::IsNullOrWhiteSpace($kn)) { continue } + $vRaw = $ClusterRingCounts[$k] + $vInt = 0 + if ($null -ne $vRaw -and [int]::TryParse([string]$vRaw, [ref]$vInt)) { } else { $vInt = 0 } + if (-not $ringCounts.ContainsKey($kn.Trim())) { $ringCounts[$kn.Trim()] = $vInt } + } + $hasCounts = ($ringCounts.Count -gt 0) + } + + # ---- Markdown rendering ---------------------------------------- + $sb = New-Object System.Text.StringBuilder + $endDate = $start.AddDays($Days - 1) + [void]$sb.AppendLine("## Cycle calendar - next $Days day(s) (cycle length: $cycleWeeks week(s))") + [void]$sb.AppendLine() + [void]$sb.AppendLine("**Projection horizon.** ``$($start.ToString('yyyy-MM-dd'))`` -> ``$($endDate.ToString('yyyy-MM-dd'))`` (UTC). For each UTC day the table below lists which ``UpdateRing`` value(s) ``Resolve-AzLocalCurrentUpdateRing`` would return given your schedule file. Days where multiple schedule rows match are UNIONed (all eligible rings shown, deduplicated). The ``AllowedUpdateVersions`` column shows the effective allow-list for that day (empty = no constraint - install the latest Ready update).") + [void]$sb.AppendLine() + if ($Days -ge $cycleWeeks * 7) { + [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() + } + 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('|---|---|---|---|---|') + } + foreach ($r in $rows) { + $ringsCell = if ($r.Rings -and @($r.Rings).Count -gt 0) { + (@($r.Rings) | ForEach-Object { '`' + $_ + '`' }) -join ', ' + } else { + '_(none - dead day)_' + } + $allowCell = if (@($r.AllowedUpdateVersions).Count -gt 0) { + (@($r.AllowedUpdateVersions) | ForEach-Object { '`' + $_ + '`' }) -join ', ' + } else { + '_(no constraint)_' + } + if ($hasCounts) { + $countCell = if ($r.Rings -and @($r.Rings).Count -gt 0) { + $parts = @() + $total = 0 + foreach ($rg in @($r.Rings)) { + $cnt = 0 + if ($ringCounts.ContainsKey($rg)) { $cnt = $ringCounts[$rg] } + $parts += "``$rg``: $cnt" + $total += $cnt + } + if (@($r.Rings).Count -gt 1) { + (($parts -join ', ') + " (total: $total)") + } else { + ($parts -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 |") + } + } + [void]$sb.AppendLine() + + if ($IncludePerRingSummary) { + # Build the universe of rings: UNION of all rings the schedule + # ever references (so we surface "this ring is configured but + # never fires in the horizon" as a dead-ring signal too). + $allRings = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach ($srow in @($Schedule.Schedule)) { + foreach ($rg in ($srow.rings -split ';')) { + $tg = $rg.Trim() + if (-not [string]::IsNullOrWhiteSpace($tg)) { [void]$allRings.Add($tg) } + } + } + + # Index ring -> sorted list of eligible dates within the horizon. + $ringToDates = @{} + foreach ($ring in $allRings) { $ringToDates[$ring] = New-Object System.Collections.Generic.List[datetime] } + foreach ($r in $rows) { + foreach ($rg in @($r.Rings)) { + if ($ringToDates.ContainsKey($rg)) { + [void]$ringToDates[$rg].Add($r.DateUtc) + } else { + # Ring appeared in resolver output but not in schedule + # ring set (case-insensitive miss?). Add defensively. + $ringToDates[$rg] = New-Object System.Collections.Generic.List[datetime] + [void]$ringToDates[$rg].Add($r.DateUtc) + } + } + } + + [void]$sb.AppendLine('### Per-ring projection (next eligible UTC dates within the horizon)') + [void]$sb.AppendLine() + [void]$sb.AppendLine("**What this shows.** One row per ``UpdateRing`` referenced anywhere in your schedule. ``Next eligible`` is the soonest UTC date in the horizon when ``Resolve-AzLocalCurrentUpdateRing`` returns that ring. ``All eligible dates`` is the full list (deduplicated, ordered). A ring with ``(none in horizon)`` is configured in the schedule but does not fire within the next $Days day(s) - usually because its ``weeksInCycle`` does not intersect the horizon (e.g. a Prod ring scheduled for weeks 5-8 of an 8-week cycle, when today is still in week 1).") + [void]$sb.AppendLine() + if ($hasCounts) { + [void]$sb.AppendLine('| UpdateRing | Cluster count | Next eligible | Eligible days (count) | All eligible dates |') + [void]$sb.AppendLine('|---|---|---|---|---|') + } else { + [void]$sb.AppendLine('| UpdateRing | Next eligible | Eligible days (count) | All eligible dates |') + [void]$sb.AppendLine('|---|---|---|---|') + } + foreach ($ring in ($ringToDates.Keys | Sort-Object)) { + $dates = @($ringToDates[$ring]) + $clusterCountCell = if ($hasCounts) { + $cv = 0 + if ($ringCounts.ContainsKey($ring)) { $cv = $ringCounts[$ring] } + $cv.ToString() + } else { $null } + if ($dates.Count -eq 0) { + if ($hasCounts) { + [void]$sb.AppendLine("| ``$ring`` | $clusterCountCell | _(none in horizon)_ | 0 | - |") + } else { + [void]$sb.AppendLine("| ``$ring`` | _(none in horizon)_ | 0 | - |") + } + continue + } + $next = $dates[0].ToString('yyyy-MM-dd') + $count = $dates.Count + $list = ($dates | ForEach-Object { $_.ToString('yyyy-MM-dd') }) -join ', ' + if ($hasCounts) { + [void]$sb.AppendLine("| ``$ring`` | $clusterCountCell | $next | $count | $list |") + } else { + [void]$sb.AppendLine("| ``$ring`` | $next | $count | $list |") + } + } + [void]$sb.AppendLine() + } + + return $sb.ToString() +} diff --git a/AzLocal.UpdateManagement/Public/Invoke-AzLocalClusterInventory.ps1 b/AzLocal.UpdateManagement/Public/Invoke-AzLocalClusterInventory.ps1 new file mode 100644 index 00000000..ecac9431 --- /dev/null +++ b/AzLocal.UpdateManagement/Public/Invoke-AzLocalClusterInventory.ps1 @@ -0,0 +1,362 @@ +function Invoke-AzLocalClusterInventory { + <# + .SYNOPSIS + Runs the Step.1 cluster-inventory pipeline workload: queries the + fleet, writes timestamped + canonical CSV / JSON artifacts plus the + operator README, and emits the JUnit-style step summary + step + outputs for the v0.8.5 thin-YAML Step.1 pipeline. + + .DESCRIPTION + Phase 1 (v0.8.5) of the thin-YAML refactor. Condenses the inline + `run: |` body of the v0.8.4 + `Step.1_inventory-clusters.yml` (GitHub Actions + Azure DevOps) + into a single cmdlet call so the per-platform yml shrinks to a + few lines and the workload becomes unit-testable against a + synthetic Get-AzLocalClusterInventory result. + + The cmdlet: + + 1. Resolves the output directory (defaults to `./artifacts` on + GitHub Actions / local, or `$env:BUILD_ARTIFACTSTAGINGDIRECTORY` + on Azure DevOps - matching the v0.8.4 yml). + 2. Calls `Get-AzLocalClusterInventory -PassThru -ExportPath ` + ONCE (the v0.8.4 yml called it twice; the cmdlet path + collapses that into one query + a local JSON serialisation). + 3. Serialises the same in-memory inventory to JSON. + 4. Copies the timestamped CSV to a canonical + `ClusterUpdateRings.csv` (the filename the Step.2 + 'Manage UpdateRing Tags' pipeline consumes by default). + 5. Writes a `README_Instructions.txt` next to the CSV that + explains the three-step operator flow (edit CSV in Excel, + commit, run Step.2). + 6. Emits the markdown step summary (Total / WithTag / WithoutTag + metric table + UpdateRing distribution table) via + `Add-AzLocalPipelineStepSummary`. + 7. Emits four step outputs via `Set-AzLocalPipelineOutput`: + `cluster_count`, `with_tag_count`, `without_tag_count`, + `csv_path`. + + Internal reuse (per the v0.8.5 thin-YAML consistency contract): + * `Get-AzLocalClusterInventory` for the actual inventory query. + * `Add-AzLocalPipelineStepSummary` for the rendered markdown. + * `Set-AzLocalPipelineOutput` for the step outputs. + * `Get-AzLocalPipelineHost` is implicit (the above branch on it). + + .PARAMETER OutputDirectory + Directory to write the CSV / JSON / canonical CSV / README into. + Created if it does not exist. Defaults to `./artifacts` (which is + what the v0.8.4 GH yml uses) or, on AzureDevOps, to + `$env:BUILD_ARTIFACTSTAGINGDIRECTORY` if that env var is set + (matching the v0.8.4 ADO yml). + + .PARAMETER SubscriptionFilter + Optional. When set and non-empty, passed through as `-SubscriptionId` + to `Get-AzLocalClusterInventory`. When empty / null, the inventory + runs across every subscription the pipeline identity can see. + + .PARAMETER Timestamp + DateTime used to build the timestamped CSV / JSON filenames. + Defaults to `Get-Date`. Tests pass a fixed value so filenames are + deterministic. + + .PARAMETER CanonicalCsvFileName + Filename for the canonical (no-timestamp) CSV copy. Default + `ClusterUpdateRings.csv` - matches the Step.2 default + `csv_file_path` input of `config/ClusterUpdateRings.csv`. + + .PARAMETER ReadmeFileName + Filename for the operator README. Default `README_Instructions.txt`. + + .PARAMETER InstalledModuleVersion + Optional [string] used in the README footer + ('Generated by AzLocal.UpdateManagement v'). + + .PARAMETER SummaryFileName + Per-task summary filename used by `Add-AzLocalPipelineStepSummary` + on Azure DevOps and Local hosts. Default + `cluster-inventory-summary.md`. + + .PARAMETER PassThru + When set, returns a single PSCustomObject summarising the run + (ClusterCount, WithTagCount, WithoutTagCount, CsvPath, JsonPath, + CanonicalCsvPath, ReadmePath, Clusters, UpdateRingDistribution). + Without -PassThru the cmdlet emits nothing to the pipeline; the + artifacts and step outputs are still produced. + + .OUTPUTS + Nothing by default. When -PassThru is set, a single PSCustomObject: + ClusterCount [int] + WithTagCount [int] + WithoutTagCount [int] + CsvPath [string] + JsonPath [string] + CanonicalCsvPath [string] + ReadmePath [string] + Clusters [PSCustomObject[]] + UpdateRingDistribution [PSCustomObject[]] (Name + Count) + + .EXAMPLE + Invoke-AzLocalClusterInventory -PassThru + + Runs the inventory against every subscription the identity can + see, writes the four artifacts to `./artifacts`, emits the + markdown summary + step outputs to the active pipeline host, + and returns the summary object. + + .NOTES + Module: AzLocal.UpdateManagement (v0.8.5+) + Roadmap: Step.1 - Inventory Azure Local Clusters. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$OutputDirectory, + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$SubscriptionFilter, + + [Parameter(Mandatory = $false)] + [datetime]$Timestamp = (Get-Date), + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$CanonicalCsvFileName = 'ClusterUpdateRings.csv', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$ReadmeFileName = 'README_Instructions.txt', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$InstalledModuleVersion, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SummaryFileName = 'cluster-inventory-summary.md', + + [Parameter(Mandatory = $false)] + [switch]$PassThru + ) + + $pipelineHost = Get-AzLocalPipelineHost + + if (-not $OutputDirectory) { + if ($pipelineHost -eq 'AzureDevOps' -and $env:BUILD_ARTIFACTSTAGINGDIRECTORY) { + $OutputDirectory = $env:BUILD_ARTIFACTSTAGINGDIRECTORY + } + else { + $OutputDirectory = './artifacts' + } + } + if (-not (Test-Path -LiteralPath $OutputDirectory)) { + New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + } + + $timestampStr = $Timestamp.ToString('yyyyMMdd_HHmmss') + $csvPath = Join-Path -Path $OutputDirectory -ChildPath ("ClusterInventory_{0}.csv" -f $timestampStr) + $jsonPath = Join-Path -Path $OutputDirectory -ChildPath ("ClusterInventory_{0}.json" -f $timestampStr) + $canonicalCsvPath = Join-Path -Path $OutputDirectory -ChildPath $CanonicalCsvFileName + $readmePath = Join-Path -Path $OutputDirectory -ChildPath $ReadmeFileName + + $invParams = @{} + if ($SubscriptionFilter) { + $invParams['SubscriptionId'] = $SubscriptionFilter + Write-Host "Filtering to subscription: $SubscriptionFilter" + } + else { + Write-Host 'Querying all accessible subscriptions' + } + + Write-Host '--- Running cluster inventory ---' + $inventory = Get-AzLocalClusterInventory @invParams -ExportPath $csvPath -PassThru + if ($null -eq $inventory) { $inventory = @() } + # Force array shape so .Count is reliable even when a single row comes back as a bare PSCustomObject. + $inventory = @($inventory) + + # Serialise the same in-memory inventory to JSON next to the CSV. + $inventory | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $jsonPath -Encoding UTF8 + + if (Test-Path -LiteralPath $csvPath) { + Copy-Item -LiteralPath $csvPath -Destination $canonicalCsvPath -Force + Write-Host "Canonical copy created: $canonicalCsvPath (edit this one)" + } + else { + # Empty fleet: Get-AzLocalClusterInventory does not write a CSV when there + # are zero rows. Synthesise an empty canonical CSV so Step.2 has a file + # to read (a header-only CSV is treated as 'no work to do'). + $emptyHeader = 'ClusterName,ResourceGroup,SubscriptionId,SubscriptionName,UpdateRing,HasUpdateRingTag,UpdateStartWindow,UpdateExclusions,UpdateSideloaded,UpdateVersionInProgress,ResourceId' + Set-Content -LiteralPath $canonicalCsvPath -Value $emptyHeader -Encoding UTF8 + Set-Content -LiteralPath $csvPath -Value $emptyHeader -Encoding UTF8 + Write-Host "No cluster rows returned - wrote header-only CSV to $canonicalCsvPath." + } + + # README operator instructions. + $generatedUtc = $Timestamp.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'") + $moduleVersionForReadme = if ($InstalledModuleVersion) { $InstalledModuleVersion } else { '(unknown)' } + $readme = @' +======================================================================== + Cluster Inventory - Next Steps + AzLocal.UpdateManagement (UpdateRing tagging workflow) +======================================================================== + +This artifact contains the cluster inventory generated by the +'Inventory Azure Local Clusters' pipeline. Use it to assign each +cluster to an update wave (UpdateRing tag), then commit the result +back into your repository so the 'Manage UpdateRing Tags' pipeline +can apply the tags to Azure. + +------------------------------------------------------------------------ +FILES IN THIS ARTIFACT +------------------------------------------------------------------------ + + ClusterUpdateRings.csv + <-- EDIT THIS FILE. Canonical name expected by the 'Manage + UpdateRing Tags' pipeline's default csv_file_path / + csvFilePath input (config/ClusterUpdateRings.csv). + + ClusterInventory_.csv + Audit / historical copy of this run's inventory. Do NOT edit - + keep it so you can diff future inventory runs against this + snapshot. + + ClusterInventory_.json + Machine-readable copy for dashboards / custom integrations. + + README_Instructions.txt + This file. + +------------------------------------------------------------------------ +STEP 1 - Open ClusterUpdateRings.csv in Excel +------------------------------------------------------------------------ + + - For each cluster row, fill in the 'UpdateRing' column with the + wave name you want that cluster to belong to. Examples: + Canary, Pilot, Production + (or your own scheme, e.g. Wave1, Wave2, Wave3 / Dev, Test, Prod) + - Leave the 'UpdateRing' column EMPTY for any cluster you do not + want this run to tag - those rows will be skipped. + - Save as CSV (UTF-8). Do not change other columns or column order. + - Do NOT rename the file. Keep it as 'ClusterUpdateRings.csv' so + the 'Manage UpdateRing Tags' pipeline's default path picks it up. + +------------------------------------------------------------------------ +STEP 2 - Commit ClusterUpdateRings.csv into your ops repository +------------------------------------------------------------------------ + + - Recommended path: config/ClusterUpdateRings.csv + - Commit and push (PR review optional but recommended). + +------------------------------------------------------------------------ +STEP 3 - Run the 'Manage UpdateRing Tags' pipeline +------------------------------------------------------------------------ + + - Trigger 'Manage UpdateRing Tags' (Step.2) and point its + csv_file_path / csvFilePath input at the path you committed in + STEP 2 (default is 'config/ClusterUpdateRings.csv'). + - RECOMMENDED FIRST RUN: leave 'dry_run' / 'dryRun' = true. The + pipeline will preview which tags would be set/changed without + modifying any Azure resources. + - Once the preview looks correct, re-run with dry-run = false to + apply the tags. Use force = true only if you need to overwrite + existing UpdateRing values already set on a cluster. + +------------------------------------------------------------------------ +DOCUMENTATION +------------------------------------------------------------------------ + + Pipeline examples README (online): + https://github.com/NeilBird/Azure-Local/blob/main/AzLocal.UpdateManagement/Automation-Pipeline-Examples/README.md + + Module README (online): + https://github.com/NeilBird/Azure-Local/blob/main/AzLocal.UpdateManagement/README.md + + Module on the PowerShell Gallery: + https://www.powershellgallery.com/packages/AzLocal.UpdateManagement + +Generated by AzLocal.UpdateManagement v__MODULE_VERSION__ +Run timestamp (UTC): __TIMESTAMP__ +======================================================================== +'@ + $readme = $readme.Replace('__MODULE_VERSION__', $moduleVersionForReadme).Replace('__TIMESTAMP__', $generatedUtc) + Set-Content -LiteralPath $readmePath -Value $readme -Encoding ASCII + Write-Host "Instructions written to: $readmePath" + + # Count tag coverage. + $clusterCount = [int]$inventory.Count + $withTag = [int]@($inventory | Where-Object { $_.HasUpdateRingTag -eq 'Yes' }).Count + $withoutTag = $clusterCount - $withTag + + # UpdateRing distribution (rows with a non-empty UpdateRing value). + $ringGroups = @($inventory | + Where-Object { $_.UpdateRing } | + Group-Object -Property UpdateRing | + Sort-Object -Property Name) + $ringDistribution = @($ringGroups | ForEach-Object { [pscustomobject]@{ Name = $_.Name; Count = $_.Count } }) + + Write-Host '' + Write-Host '========================================' + Write-Host 'Inventory Complete' + Write-Host '========================================' + Write-Host ("Total Clusters : {0}" -f $clusterCount) + Write-Host ("With UpdateRing Tag : {0}" -f $withTag) + Write-Host ("Without UpdateRing Tag : {0}" -f $withoutTag) + Write-Host ("CSV : {0}" -f $csvPath) + Write-Host ("JSON : {0}" -f $jsonPath) + + # Step outputs. + Set-AzLocalPipelineOutput -Name 'cluster_count' -Value ([string]$clusterCount) + Set-AzLocalPipelineOutput -Name 'with_tag_count' -Value ([string]$withTag) + Set-AzLocalPipelineOutput -Name 'without_tag_count' -Value ([string]$withoutTag) + Set-AzLocalPipelineOutput -Name 'csv_path' -Value ([string]$csvPath) + + # Markdown step summary. + $sb = New-Object System.Text.StringBuilder + [void]$sb.AppendLine('## Step.1 - Cluster Inventory') + [void]$sb.AppendLine('') + [void]$sb.AppendLine('| Metric | Value |') + [void]$sb.AppendLine('|--------|-------|') + [void]$sb.AppendLine(("| Total Clusters | {0} |" -f $clusterCount)) + [void]$sb.AppendLine(("| With UpdateRing Tag | {0} |" -f $withTag)) + [void]$sb.AppendLine(("| Without UpdateRing Tag | {0} |" -f $withoutTag)) + [void]$sb.AppendLine('') + [void]$sb.AppendLine('### UpdateRing Distribution') + [void]$sb.AppendLine('') + if ($ringDistribution.Count -gt 0) { + [void]$sb.AppendLine('| UpdateRing | Count |') + [void]$sb.AppendLine('|------------|-------|') + foreach ($row in $ringDistribution) { + [void]$sb.AppendLine(("| {0} | {1} |" -f $row.Name, $row.Count)) + } + } + else { + [void]$sb.AppendLine('No UpdateRing tags found.') + } + [void]$sb.AppendLine('') + [void]$sb.AppendLine('### Next Steps') + [void]$sb.AppendLine('') + [void]$sb.AppendLine('1. Download the artifact attached to this run.') + [void]$sb.AppendLine('2. Open `ClusterUpdateRings.csv` in Excel and fill in the `UpdateRing` column.') + [void]$sb.AppendLine('3. Commit the edited CSV (recommended path `config/ClusterUpdateRings.csv`).') + [void]$sb.AppendLine('4. Run the "Manage UpdateRing Tags" pipeline (Step.2).') + + Add-AzLocalPipelineStepSummary -Markdown $sb.ToString() -SummaryFileName $SummaryFileName | Out-Null + + if ($PassThru) { + return [pscustomobject]@{ + ClusterCount = $clusterCount + WithTagCount = $withTag + WithoutTagCount = $withoutTag + CsvPath = $csvPath + JsonPath = $jsonPath + CanonicalCsvPath = $canonicalCsvPath + ReadmePath = $readmePath + Clusters = $inventory + UpdateRingDistribution = $ringDistribution + } + } +} diff --git a/AzLocal.UpdateManagement/Public/Invoke-AzLocalItsmTicketingFromArtifact.ps1 b/AzLocal.UpdateManagement/Public/Invoke-AzLocalItsmTicketingFromArtifact.ps1 new file mode 100644 index 00000000..d5b05500 --- /dev/null +++ b/AzLocal.UpdateManagement/Public/Invoke-AzLocalItsmTicketingFromArtifact.ps1 @@ -0,0 +1,195 @@ +function Invoke-AzLocalItsmTicketingFromArtifact { + <# + .SYNOPSIS + Reads an ITSM config + the apply-updates JUnit artifact, invokes + New-AzLocalIncident, and writes itsm-results.csv / itsm-results.xml. + Replaces ~45 lines of inline `run:` boilerplate in both Step.6 pipelines. + .DESCRIPTION + v0.8.5 Step.6 thin-YAML helper. Behaviour matches the prior inline + block byte-for-byte: + - Short-circuits with a host-appropriate warning when the config + file is missing. + - Short-circuits with a host-appropriate warning when the JUnit + input is missing. + - Otherwise calls New-AzLocalIncident with the same parameter + shape, AUTO-POPULATING -RunMetadata from the detected pipeline + host: + - GitHub : Platform='github', RunId=GITHUB_RUN_ID, + RunUrl=GITHUB_SERVER_URL/GITHUB_REPOSITORY/ + actions/runs/GITHUB_RUN_ID, Branch=GITHUB_REF. + - AzureDevOps : Platform='azure-devops', RunId=BUILD_BUILDID, + RunUrl=SYSTEM_COLLECTIONURI + SYSTEM_TEAMPROJECT + + /_build/results?buildId=BUILD_BUILDID, + Branch=BUILD_SOURCEBRANCH. + - Local : Platform='local', RunId=hostname:pid, + RunUrl=empty, Branch=current branch (best effort). + - Renders the same Format-Table summary the prior block did + (ClusterName, Action, TicketId, Severity). + .PARAMETER ConfigPath + Path to the ITSM YAML config (consumed by Get-AzLocalItsmConfig). + .PARAMETER InputArtifactPath + Path to the JUnit XML produced by apply-updates (default per host). + .PARAMETER ExportDirectory + Directory where itsm-results.csv/.xml are written. Defaults to the + InputArtifactPath's parent directory. + .PARAMETER ExportCsvFileName + Default 'itsm-results.csv'. + .PARAMETER ExportJUnitFileName + Default 'itsm-results.xml'. + .PARAMETER DryRun + Switch. Forwarded to New-AzLocalIncident -DryRun. + .PARAMETER ForceCreate + Switch. Forwarded to New-AzLocalIncident -ForceCreate. + .PARAMETER PassThru + Returns PSCustomObject with: Results, ExportCsvPath, ExportJUnitPath, + ShortCircuited (one of 'ConfigMissing' / 'InputMissing' / $null). + .NOTES + Author : AzLocal.UpdateManagement + Version : 0.8.5 (Step.6 thin-YAML port) + #> + [CmdletBinding()] + [OutputType([void])] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$ConfigPath, + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string]$InputArtifactPath = '', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string]$ExportDirectory = '', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$ExportCsvFileName = 'itsm-results.csv', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$ExportJUnitFileName = 'itsm-results.xml', + + [switch]$DryRun, + + [switch]$ForceCreate, + + [switch]$PassThru + ) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + $pipelineHost = Get-AzLocalPipelineHost + + # Defaults that match the prior inline block per host. + if (-not $InputArtifactPath) { + if ($pipelineHost -eq 'AzureDevOps' -and $env:BUILD_ARTIFACTSTAGINGDIRECTORY) { + $InputArtifactPath = Join-Path -Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY -ChildPath 'update-results.xml' + } + else { + $InputArtifactPath = './artifacts/update-results.xml' + } + } + + if (-not $ExportDirectory) { + $ExportDirectory = Split-Path -Path $InputArtifactPath -Parent + if (-not $ExportDirectory) { $ExportDirectory = '.' } + } + if (-not (Test-Path -LiteralPath $ExportDirectory)) { + New-Item -ItemType Directory -Path $ExportDirectory -Force | Out-Null + } + $exportCsvPath = Join-Path -Path $ExportDirectory -ChildPath $ExportCsvFileName + $exportJUnitPath = Join-Path -Path $ExportDirectory -ChildPath $ExportJUnitFileName + + # Config short-circuit. + if (-not $ConfigPath -or -not (Test-Path -LiteralPath $ConfigPath)) { + switch ($pipelineHost) { + 'GitHub' { Write-Host "::warning::ITSM config not found at '$ConfigPath' - skipping ticket creation." } + 'AzureDevOps' { Write-Host "##vso[task.logissue type=warning]ITSM config not found at '$ConfigPath' - skipping ticket creation." } + default { Write-Warning "ITSM config not found at '$ConfigPath' - skipping ticket creation." } + } + if ($PassThru) { + return [pscustomobject]@{ + Results = @() + ExportCsvPath = $exportCsvPath + ExportJUnitPath = $exportJUnitPath + ShortCircuited = 'ConfigMissing' + } + } + return + } + + $cfg = Get-AzLocalItsmConfig -Path $ConfigPath + + # JUnit short-circuit. + if (-not (Test-Path -LiteralPath $InputArtifactPath)) { + switch ($pipelineHost) { + 'GitHub' { Write-Host "::warning::No update-results.xml found at '$InputArtifactPath' - skipping ticket creation." } + 'AzureDevOps' { Write-Host "##vso[task.logissue type=warning]No update-results.xml found at '$InputArtifactPath' - skipping ticket creation." } + default { Write-Warning "No update-results.xml found at '$InputArtifactPath' - skipping ticket creation." } + } + if ($PassThru) { + return [pscustomobject]@{ + Results = @() + ExportCsvPath = $exportCsvPath + ExportJUnitPath = $exportJUnitPath + ShortCircuited = 'InputMissing' + } + } + return + } + + # Build host-aware RunMetadata. + $runMeta = switch ($pipelineHost) { + 'GitHub' { + @{ + Platform = 'github' + RunId = $env:GITHUB_RUN_ID + RunUrl = "$env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY/actions/runs/$env:GITHUB_RUN_ID" + Branch = $env:GITHUB_REF + } + } + 'AzureDevOps' { + @{ + Platform = 'azure-devops' + RunId = $env:BUILD_BUILDID + RunUrl = "$($env:SYSTEM_COLLECTIONURI)$($env:SYSTEM_TEAMPROJECT)/_build/results?buildId=$($env:BUILD_BUILDID)" + Branch = $env:BUILD_SOURCEBRANCH + } + } + default { + $branch = '' + try { $branch = (git rev-parse --abbrev-ref HEAD 2>$null) } catch { $branch = '' } + @{ + Platform = 'local' + RunId = ("{0}:{1}" -f $env:COMPUTERNAME, $PID) + RunUrl = '' + Branch = $branch + } + } + } + + $params = @{ + InputArtifactPath = $InputArtifactPath + Config = $cfg + RunMetadata = $runMeta + DryRun = [bool]$DryRun + ForceCreate = [bool]$ForceCreate + ExportPath = $exportCsvPath + ExportJUnitPath = $exportJUnitPath + } + + $results = @(New-AzLocalIncident @params) + $results | Format-Table ClusterName, Action, TicketId, Severity -AutoSize + + if ($PassThru) { + return [pscustomobject]@{ + Results = $results + ExportCsvPath = $exportCsvPath + ExportJUnitPath = $exportJUnitPath + ShortCircuited = $null + } + } +} diff --git a/AzLocal.UpdateManagement/Public/Invoke-AzLocalReadinessGatedClusterUpdate.ps1 b/AzLocal.UpdateManagement/Public/Invoke-AzLocalReadinessGatedClusterUpdate.ps1 new file mode 100644 index 00000000..54c4d205 --- /dev/null +++ b/AzLocal.UpdateManagement/Public/Invoke-AzLocalReadinessGatedClusterUpdate.ps1 @@ -0,0 +1,294 @@ +function Invoke-AzLocalReadinessGatedClusterUpdate { + <# + .SYNOPSIS + Loads the readiness CSV produced by Export-AzLocalClusterReadinessGateReport, + applies updates to the gated cluster set via Start-AzLocalClusterUpdate, + emits per-status step outputs (SUCCEEDED/SKIPPED/FAILED/HEALTH_BLOCKED/ + SCHEDULE_BLOCKED/SIDELOADED_BLOCKED/EXCLUDED_BY_TAG), and persists + per-cluster apply results to apply-results.json. + .DESCRIPTION + v0.8.5 Step.6 thin-YAML helper. Replaces the ~110-line inline `run:` + block that lived in both Step.6_apply-updates.yml pipelines. + + Behaviour matches the prior inline block byte-for-byte: + - Loads readiness-report.csv, validates the ClusterResourceId + column exists (introduced in v0.7.62), extracts rows with + ReadyForUpdate='True' AND non-empty ClusterResourceId. + - When the ready set is empty: emits zero counts and exits cleanly + (matches the prior 'nothing to apply' short-circuit). + - Otherwise: invokes Start-AzLocalClusterUpdate with the same + parameter shape the inline block built (-ClusterResourceIds, + -Force, -LogFolderPath, -ExportResultsPath, optional -UpdateName, + optional -AllowedUpdateVersions, optional -WhatIf for dry runs). + - Counts results into the seven status buckets and writes them as + cross-job step outputs. + - Persists per-cluster results to apply-results.json (selected + columns: ClusterName, Status, UpdateName, Duration, Message). + - On Azure DevOps: emits the same per-bucket warning/error + task.logissue lines the inline block did (preserving CI behaviour). + .PARAMETER ReadinessCsvPath + Path to readiness-report.csv produced by the check-readiness job. + .PARAMETER UpdateRing + UpdateRing label used in console output (e.g. 'Wave1'). Cosmetic only; + the actual apply scope comes from ReadinessCsvPath. + .PARAMETER UpdateName + Optional specific update name to apply (forwarded to + Start-AzLocalClusterUpdate -UpdateName). Empty/whitespace = the + cmdlet picks the latest Ready update on each cluster. + .PARAMETER DryRun + Switch. When set, Start-AzLocalClusterUpdate -WhatIf is invoked so no + updates actually start. + .PARAMETER AllowedUpdateVersions + String[] or single ';'-joined string. Allow-list resolved from + apply-updates-schedule.yml schema-v2 'allowedUpdateVersions'. Empty = + no allow-list applied. + .PARAMETER OutputDirectory + Directory where logs / update-results.xml / apply-results.json are + written. Defaults to the readiness CSV's parent directory. + .PARAMETER JUnitFileName + JUnit XML filename. Default 'update-results.xml'. + .PARAMETER ApplyResultsJsonFileName + Per-cluster JSON filename consumed by Add-AzLocalApplyUpdatesStepSummary. + Default 'apply-results.json'. + .PARAMETER PassThru + Returns PSCustomObject with all seven counters + Results + + JUnitPath + ApplyResultsJsonPath + ReadyResourceIds (the ARM IDs + actually handed to Start-AzLocalClusterUpdate). + .NOTES + Author : AzLocal.UpdateManagement + Version : 0.8.5 (Step.6 thin-YAML port) + #> + [CmdletBinding(SupportsShouldProcess = $true)] + [OutputType([void])] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$ReadinessCsvPath, + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string]$UpdateRing = '', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string]$UpdateName = '', + + [switch]$DryRun, + + [Parameter(Mandatory = $false)] + [AllowEmptyCollection()] + [AllowNull()] + [object]$AllowedUpdateVersions, + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string]$OutputDirectory = '', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$JUnitFileName = 'update-results.xml', + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$ApplyResultsJsonFileName = 'apply-results.json', + + [switch]$PassThru + ) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + $pipelineHost = Get-AzLocalPipelineHost + + # Normalise AllowedUpdateVersions into a [string[]] (accept string, + # string[], or null/empty). + [string[]]$allowList = @() + if ($AllowedUpdateVersions) { + if ($AllowedUpdateVersions -is [string]) { + if (-not [string]::IsNullOrWhiteSpace($AllowedUpdateVersions)) { + $allowList = @(($AllowedUpdateVersions -split ';') | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } + } + else { + $allowList = @($AllowedUpdateVersions | ForEach-Object { "$_".Trim() } | Where-Object { $_ }) + } + } + + # OutputDirectory default: parent of the readiness CSV. + if (-not $OutputDirectory) { + $OutputDirectory = Split-Path -Path $ReadinessCsvPath -Parent + if (-not $OutputDirectory) { $OutputDirectory = '.' } + } + if (-not (Test-Path -LiteralPath $OutputDirectory)) { + New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + } + + $junitPath = Join-Path -Path $OutputDirectory -ChildPath $JUnitFileName + $applyJsonPath = Join-Path -Path $OutputDirectory -ChildPath $ApplyResultsJsonFileName + + # Per-host step-output naming - PRESERVE existing pipeline downstream + # bindings byte-for-byte: GH uses UPPER_SNAKE, ADO uses PascalCase + # (e.g. stageDependencies...outputs['applyUpdates.Succeeded']). + if ($pipelineHost -eq 'AzureDevOps') { + $nSucceeded = 'Succeeded'; $nSkipped = 'Skipped'; $nFailed = 'Failed' + $nHealth = 'HealthBlocked'; $nSchedule = 'ScheduleBlocked' + $nSideload = 'SideloadedBlocked'; $nExcluded = 'ExcludedByTag' + } + else { + $nSucceeded = 'SUCCEEDED'; $nSkipped = 'SKIPPED'; $nFailed = 'FAILED' + $nHealth = 'HEALTH_BLOCKED'; $nSchedule = 'SCHEDULE_BLOCKED' + $nSideload = 'SIDELOADED_BLOCKED'; $nExcluded = 'EXCLUDED_BY_TAG' + } + + # Helper to emit the seven counters as step outputs. Do NOT call + # .GetNewClosure() here - it severs the scriptblock from the module's + # SessionState, making the private Set-AzLocalPipelineOutput function + # invisible. Lexical scoping already gives this scriptblock access to + # the $n* variables since '& $emitCounters' is invoked from within the + # same function. + $emitCounters = { + param($s, $sk, $f, $hb, $scb, $sb, $ebt) + Set-AzLocalPipelineOutput -Name $nSucceeded -Value "$s" -CrossJob + Set-AzLocalPipelineOutput -Name $nSkipped -Value "$sk" -CrossJob + Set-AzLocalPipelineOutput -Name $nFailed -Value "$f" -CrossJob + Set-AzLocalPipelineOutput -Name $nHealth -Value "$hb" -CrossJob + Set-AzLocalPipelineOutput -Name $nSchedule -Value "$scb" -CrossJob + Set-AzLocalPipelineOutput -Name $nSideload -Value "$sb" -CrossJob + Set-AzLocalPipelineOutput -Name $nExcluded -Value "$ebt" -CrossJob + } + + if (-not (Test-Path -LiteralPath $ReadinessCsvPath)) { + throw "Invoke-AzLocalReadinessGatedClusterUpdate: Readiness CSV not found at '$ReadinessCsvPath'. The check-readiness job did not upload a readiness-report artifact - cannot determine which clusters to apply." + } + + $readinessRows = @(Import-Csv -Path $ReadinessCsvPath) + if ($readinessRows.Count -gt 0 -and -not ($readinessRows[0].PSObject.Properties.Name -contains 'ClusterResourceId')) { + throw "Invoke-AzLocalReadinessGatedClusterUpdate: Readiness CSV at '$ReadinessCsvPath' is missing the 'ClusterResourceId' column. This column was added in AzLocal.UpdateManagement v0.7.62. Re-run check-readiness with v0.7.62+ or refresh the pipeline YAML via Copy-AzLocalPipelineExample -Update." + } + + [string[]]$readyResourceIds = @($readinessRows | + Where-Object { $_.ReadyForUpdate -eq 'True' -and $_.ClusterResourceId } | + ForEach-Object { $_.ClusterResourceId }) + + Write-Host "Readiness CSV: $($readinessRows.Count) row(s), $($readyResourceIds.Count) marked ReadyForUpdate=True." + + if ($readyResourceIds.Count -eq 0) { + # Preserve original per-host wording for the 'nothing to apply' warning. + switch ($pipelineHost) { + 'GitHub' { Write-Host "::warning::Readiness CSV reports zero clusters with ReadyForUpdate=True - nothing to apply." } + 'AzureDevOps' { Write-Host "##vso[task.logissue type=warning]Readiness CSV reports zero clusters with ReadyForUpdate=True - nothing to apply." } + default { Write-Warning "Readiness CSV reports zero clusters with ReadyForUpdate=True - nothing to apply." } + } + & $emitCounters 0 0 0 0 0 0 0 + if ($PassThru) { + return [pscustomobject]@{ + Succeeded = 0 + Skipped = 0 + Failed = 0 + HealthBlocked = 0 + ScheduleBlocked = 0 + SideloadedBlocked = 0 + ExcludedByTag = 0 + Results = @() + JUnitPath = $junitPath + ApplyResultsJsonPath = $applyJsonPath + ReadyResourceIds = @() + } + } + return + } + + $applyParams = @{ + ClusterResourceIds = $readyResourceIds + Force = $true + LogFolderPath = $OutputDirectory + ExportResultsPath = $junitPath + } + + if ($UpdateName -and $UpdateName -ne '') { + $applyParams['UpdateName'] = $UpdateName + Write-Host "Applying specific update: $UpdateName" + } + + if ($allowList.Count -gt 0) { + $applyParams['AllowedUpdateVersions'] = $allowList + Write-Host "AllowedUpdateVersions allow-list (schema v2): [$($allowList -join ', ')]. Clusters with no Ready update matching the list will be skipped with status 'NotInAllowList'." + } + + if ($DryRun) { + $applyParams['WhatIf'] = $true + # Preserve original per-host wording. + switch ($pipelineHost) { + 'AzureDevOps' { Write-Host "##vso[task.logissue type=warning]DRY RUN MODE - No updates will be applied" } + default { Write-Host "DRY RUN MODE - No updates will be applied" } + } + } + + Write-Host "" + Write-Host "========================================" -ForegroundColor Cyan + Write-Host "Applying Updates to UpdateRing: $UpdateRing" -ForegroundColor Cyan + Write-Host " Clusters (from readiness CSV): $($readyResourceIds.Count)" -ForegroundColor Cyan + Write-Host "========================================" -ForegroundColor Cyan + + $results = @(Start-AzLocalClusterUpdate @applyParams -PassThru) + + Write-Host "" + Write-Host "Update operation complete" + + $succeeded = @($results | Where-Object { $_.Status -eq 'Started' -or $_.Status -eq 'Success' -or $_.Status -eq 'UpdateStarted' }).Count + $skipped = @($results | Where-Object { $_.Status -in @('Skipped', 'NotReady', 'NoUpdatesAvailable', 'NoReadyUpdates', 'NotFound', 'UpdateNotFound', 'NotInAllowList') }).Count + $failed = @($results | Where-Object { $_.Status -in @('Failed', 'Error') }).Count + $healthBlocked = @($results | Where-Object { $_.Status -eq 'HealthCheckBlocked' }).Count + $scheduleBlocked = @($results | Where-Object { $_.Status -eq 'ScheduleBlocked' }).Count + $sideloadedBlocked = @($results | Where-Object { $_.Status -eq 'SideloadedBlocked' }).Count + $excludedByTag = @($results | Where-Object { $_.Status -eq 'ExcludedByTag' }).Count + + & $emitCounters $succeeded $skipped $failed $healthBlocked $scheduleBlocked $sideloadedBlocked $excludedByTag + + # Persist per-cluster apply results to JSON for the downstream Summary step. + @($results) | Select-Object ClusterName, Status, UpdateName, Duration, Message | + ConvertTo-Json -Depth 4 | + Out-File -FilePath $applyJsonPath -Encoding utf8 -Force + Write-Host "Wrote per-cluster apply results to $applyJsonPath" + + # ADO-only: per-bucket warning/error log lines (preserves CI surface). + if ($pipelineHost -eq 'AzureDevOps') { + if ($failed -gt 0) { + if ($succeeded -eq 0 -and $skipped -eq 0 -and $healthBlocked -eq 0 -and $scheduleBlocked -eq 0 -and $sideloadedBlocked -eq 0 -and $excludedByTag -eq 0) { + Write-Host "##vso[task.logissue type=error]All $failed cluster(s) in scope failed to start updates. Review the Azure Local portal, cluster health, and the published update-results.xml for per-cluster detail." + } + else { + Write-Host "##vso[task.logissue type=warning]$failed cluster(s) failed to start updates. $succeeded succeeded, $skipped skipped, $healthBlocked health-blocked, $scheduleBlocked schedule-blocked, $sideloadedBlocked sideloaded-blocked, $excludedByTag excluded-by-tag. See update-results.xml for per-cluster detail." + } + } + if ($healthBlocked -gt 0) { + Write-Host "##vso[task.logissue type=warning]$healthBlocked cluster(s) blocked by critical health check failures" + } + if ($scheduleBlocked -gt 0) { + Write-Host "##vso[task.logissue type=warning]$scheduleBlocked cluster(s) blocked by maintenance schedule (outside UpdateStartWindow or in UpdateExclusionsWindow period)" + } + if ($sideloadedBlocked -gt 0) { + Write-Host "##vso[task.logissue type=warning]$sideloadedBlocked cluster(s) blocked by UpdateSideloaded=False - operator must stage the sideloaded payload and flip the tag (or run Reset-AzLocalSideloadedTag) before updates can proceed" + } + if ($excludedByTag -gt 0) { + Write-Host "##vso[task.logissue type=warning]$excludedByTag cluster(s) excluded by UpdateExcluded=True operator override - flip the UpdateExcluded tag to False (Azure portal or 'az tag update') once the cluster should rejoin automation" + } + } + + if ($PassThru) { + return [pscustomobject]@{ + Succeeded = $succeeded + Skipped = $skipped + Failed = $failed + HealthBlocked = $healthBlocked + ScheduleBlocked = $scheduleBlocked + SideloadedBlocked = $sideloadedBlocked + ExcludedByTag = $excludedByTag + Results = $results + JUnitPath = $junitPath + ApplyResultsJsonPath = $applyJsonPath + ReadyResourceIds = $readyResourceIds + } + } +} diff --git a/AzLocal.UpdateManagement/Public/New-AzLocalFleetConnectivityStatusSummary.ps1 b/AzLocal.UpdateManagement/Public/New-AzLocalFleetConnectivityStatusSummary.ps1 index fb82fb2a..8d61a059 100644 --- a/AzLocal.UpdateManagement/Public/New-AzLocalFleetConnectivityStatusSummary.ps1 +++ b/AzLocal.UpdateManagement/Public/New-AzLocalFleetConnectivityStatusSummary.ps1 @@ -227,12 +227,12 @@ function New-AzLocalFleetConnectivityStatusSummary { $nicStatCsv = Join-Path -Path $ReportsPath -ChildPath 'fleet-physical-nic-stats.csv' $arbCsv = Join-Path -Path $ReportsPath -ChildPath 'fleet-arb-status.csv' - $ClusterRows = if (Test-Path -LiteralPath $clusterCsv) { @(Import-Csv -Path $clusterCsv) } else { @() } - $ArcSummary = if (Test-Path -LiteralPath $arcSumCsv) { @(Import-Csv -Path $arcSumCsv) } else { @() } - $ArcRows = if (Test-Path -LiteralPath $arcCsv) { @(Import-Csv -Path $arcCsv) } else { @() } - $NicRows = if (Test-Path -LiteralPath $nicCsv) { @(Import-Csv -Path $nicCsv) } else { @() } - $NicStats = if (Test-Path -LiteralPath $nicStatCsv) { @(Import-Csv -Path $nicStatCsv) } else { @() } - $ArbRows = if (Test-Path -LiteralPath $arbCsv) { @(Import-Csv -Path $arbCsv) } else { @() } + if (Test-Path -LiteralPath $clusterCsv) { $ClusterRows = @(Import-Csv -Path $clusterCsv) } else { $ClusterRows = @() } + if (Test-Path -LiteralPath $arcSumCsv) { $ArcSummary = @(Import-Csv -Path $arcSumCsv) } else { $ArcSummary = @() } + if (Test-Path -LiteralPath $arcCsv) { $ArcRows = @(Import-Csv -Path $arcCsv) } else { $ArcRows = @() } + if (Test-Path -LiteralPath $nicCsv) { $NicRows = @(Import-Csv -Path $nicCsv) } else { $NicRows = @() } + if (Test-Path -LiteralPath $nicStatCsv) { $NicStats = @(Import-Csv -Path $nicStatCsv) } else { $NicStats = @() } + if (Test-Path -LiteralPath $arbCsv) { $ArbRows = @(Import-Csv -Path $arbCsv) } else { $ArbRows = @() } } # ------------------------------------------------------------------ @@ -287,8 +287,14 @@ function New-AzLocalFleetConnectivityStatusSummary { }) # Reconciliation counts. - $clusterNodeSum = ($ClusterRows | Measure-Object -Property NodeCount -Sum).Sum - if ($null -eq $clusterNodeSum) { $clusterNodeSum = 0 } + $clusterNodeSum = 0 + foreach ($row in $ClusterRows) { + if ($row -and $row.PSObject.Properties['NodeCount']) { + $n = $row.NodeCount + if ($n -is [int] -or $n -is [long] -or $n -is [double]) { $clusterNodeSum += [int]$n } + elseif ($n -is [string] -and $n.Length -gt 0) { $tmp = 0; if ([int]::TryParse($n, [ref]$tmp)) { $clusterNodeSum += $tmp } } + } + } $clustersWithArb = @($ClusterRows | Where-Object { $_.ClusterId -and $arbByClusterId.ContainsKey($_.ClusterId.ToLowerInvariant()) }).Count $clustersWithoutArb = [math]::Max(0, $ClusterRows.Count - $clustersWithArb) $nodeCoverageDelta = [int]$clusterNodeSum - [int]$arcTotal diff --git a/AzLocal.UpdateManagement/Public/Resolve-AzLocalPipelineUpdateRing.ps1 b/AzLocal.UpdateManagement/Public/Resolve-AzLocalPipelineUpdateRing.ps1 new file mode 100644 index 00000000..45531f3b --- /dev/null +++ b/AzLocal.UpdateManagement/Public/Resolve-AzLocalPipelineUpdateRing.ps1 @@ -0,0 +1,238 @@ +function Resolve-AzLocalPipelineUpdateRing { + <# + .SYNOPSIS + Resolves the UpdateRing (and optional AllowedUpdateVersions allow-list) + for the current pipeline firing, from either an operator-supplied + manual input or apply-updates-schedule.yml. + .DESCRIPTION + v0.8.5 Step.6 thin-YAML helper. Replaces the ~80-line inline `run:` + block that lived in both Step.6_apply-updates.yml pipelines (GitHub + Actions + Azure DevOps). + + Three resolution paths: + 1. Manual trigger + UseScheduleFile=$false (back-compat): + - Returns ManualUpdateRing verbatim. No schedule file read. + - AllowedUpdateVersions is empty (the cmdlet's default 'latest + Ready update', or operator-supplied -UpdateName, applies). + 2. Manual trigger + UseScheduleFile=$true: + - Reads apply-updates-schedule.yml and runs the ring/AllowedUpdateVersions + resolver against UtcNow OR a user-supplied ResolveForDateUtc. + 3. Schedule trigger: + - Reads apply-updates-schedule.yml and runs the resolver against + UtcNow (ResolveForDateUtc is ignored for schedule firings). + + Side effects: + - Emits two cross-job step outputs via Set-AzLocalPipelineOutput: + RESOLVED_UPDATE_RING (e.g. 'Wave1' or 'Prod;Ring2' or '') + RESOLVED_ALLOWED_UPDATE_VERSIONS (';'-joined allow-list or '') + - On GitHub Actions, ALSO appends both to $env:GITHUB_ENV so later + steps in the same job can consume via $env:RESOLVED_UPDATE_RING. + - Throws on hard failures (missing schedule file when one is + required; malformed ResolveForDateUtc). + + Byte-for-byte output preservation: the emitted step output names and + formats match the prior inline `run:` block exactly so downstream + cross-job/cross-stage variable bindings keep working unchanged. + .PARAMETER ManualUpdateRing + Operator-supplied UpdateRing tag value (single 'Wave1', list + 'Prod;Ring2', or '***' for ALL). Used when -Trigger is 'Manual' AND + -UseScheduleFile is not set. Required in that combination. + .PARAMETER UseScheduleFile + Switch. When set, force-runs the schedule-file resolver even when the + trigger is 'Manual' (preview / re-run-missed-day use case). + .PARAMETER SchedulePath + Path to apply-updates-schedule.yml. Defaults to env var + APPLY_UPDATES_SCHEDULE_PATH, or './.github/apply-updates-schedule.yml' + (GitHub) / './apply-updates-schedule.yml' (Azure DevOps / Local). + .PARAMETER ResolveForDateUtc + Only honoured when -Trigger='Manual' AND -UseScheduleFile is set. + UTC date string in 'yyyy-MM-dd' to resolve the schedule for (preview + a future cycleWeek/dayOfWeek). Empty/whitespace = UtcNow. + .PARAMETER Trigger + 'Manual' or 'Schedule'. Auto-detected from the pipeline host when + omitted (GitHub: GITHUB_EVENT_NAME != 'workflow_dispatch' is Schedule; + ADO: BUILD_REASON == 'Schedule'). + .PARAMETER PassThru + Switch. Returns a PSCustomObject with: ResolvedUpdateRing, + ResolvedAllowedUpdateVersions, IsManual, UseScheduleFile, ResolveAt, + SchedulePath, Decision (Resolve-AzLocalCurrentUpdateRing output or + $null for back-compat manual path). + .EXAMPLE + # Schedule trigger (cron firing) - resolves from default path. + Resolve-AzLocalPipelineUpdateRing + .EXAMPLE + # Manual trigger, pass operator input through verbatim. + Resolve-AzLocalPipelineUpdateRing -Trigger Manual -ManualUpdateRing 'Wave1' + .EXAMPLE + # Manual trigger, override into schedule-resolver preview mode. + Resolve-AzLocalPipelineUpdateRing -Trigger Manual ` + -ManualUpdateRing 'Wave1' ` + -UseScheduleFile ` + -ResolveForDateUtc '2026-07-15' ` + -PassThru + .NOTES + Author : AzLocal.UpdateManagement + Version : 0.8.5 (Step.6 thin-YAML port) + #> + [CmdletBinding()] + [OutputType([void])] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string]$ManualUpdateRing = '', + + [switch]$UseScheduleFile, + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string]$SchedulePath = '', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string]$ResolveForDateUtc = '', + + [Parameter(Mandatory = $false)] + [ValidateSet('Manual', 'Schedule', '')] + [string]$Trigger = '', + + [switch]$PassThru + ) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + $pipelineHost = Get-AzLocalPipelineHost + + # Trigger auto-detection (host-specific). + if (-not $Trigger) { + switch ($pipelineHost) { + 'GitHub' { + $eventName = $env:GITHUB_EVENT_NAME + if (-not $eventName) { $eventName = 'workflow_dispatch' } + $Trigger = if ($eventName -eq 'workflow_dispatch') { 'Manual' } else { 'Schedule' } + } + 'AzureDevOps' { + $buildReason = $env:BUILD_REASON + $Trigger = if ($buildReason -eq 'Schedule') { 'Schedule' } else { 'Manual' } + } + default { + $Trigger = 'Manual' + } + } + } + + $isManual = ($Trigger -eq 'Manual') + + # SchedulePath default (host-specific). + if (-not $SchedulePath) { + $envPath = $env:APPLY_UPDATES_SCHEDULE_PATH + if ($envPath) { + $SchedulePath = $envPath + } + else { + $SchedulePath = if ($pipelineHost -eq 'GitHub') { './.github/apply-updates-schedule.yml' } else { './apply-updates-schedule.yml' } + } + } + + $resolved = '' + $resolvedAllow = '' + $resolveAt = [datetime]::UtcNow + $decision = $null + + if ($isManual -and -not $UseScheduleFile) { + # Back-compat path: manual ring verbatim, schedule file ignored. + if ([string]::IsNullOrWhiteSpace($ManualUpdateRing)) { + throw "Resolve-AzLocalPipelineUpdateRing: Trigger='Manual' with UseScheduleFile=`$false requires -ManualUpdateRing to be non-empty. Supply -ManualUpdateRing (e.g. 'Wave1', 'Prod;Ring2', or '***'), OR set -UseScheduleFile to resolve from apply-updates-schedule.yml." + } + $resolved = $ManualUpdateRing + Write-Host "Trigger='Manual', UseScheduleFile=`$false - using manual input UpdateRing='$resolved' (schedule file ignored). AllowedUpdateVersions is not applied for manual runs - the cmdlet's default 'latest Ready update' (or -UpdateName) is used." + } + else { + # Schedule trigger OR manual-with-schedule-file: same resolver pipeline. + $modeLabel = "Trigger='$Trigger'" + if ($isManual -and $UseScheduleFile) { + $modeLabel = "Trigger='Manual', UseScheduleFile=`$true" + if (-not [string]::IsNullOrWhiteSpace($ResolveForDateUtc)) { + try { + $resolveAt = [datetime]::ParseExact( + $ResolveForDateUtc.Trim(), + 'yyyy-MM-dd', + [System.Globalization.CultureInfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal + ) + } + catch { + throw "Resolve-AzLocalPipelineUpdateRing: -ResolveForDateUtc='$ResolveForDateUtc' is not a valid date. Use YYYY-MM-DD (e.g. 2026-07-15), or leave empty to resolve for today (UTC)." + } + Write-Host "$modeLabel - resolving UpdateRing from schedule file '$SchedulePath' for date $($resolveAt.ToString('yyyy-MM-dd')) UTC (PREVIEW - operator-supplied date)." + } + else { + Write-Host "$modeLabel - resolving UpdateRing from schedule file '$SchedulePath' for today UTC." + } + } + else { + Write-Host "$modeLabel - resolving UpdateRing from schedule file '$SchedulePath' (UTC now)." + } + + if (-not (Test-Path -LiteralPath $SchedulePath)) { + $hint = if ($isManual) { + "Manual trigger with -UseScheduleFile requires a schedule file at this path." + } + else { + "The apply-updates pipeline was triggered by a schedule event but no schedule file is present." + } + throw "Resolve-AzLocalPipelineUpdateRing: apply-updates-schedule.yml not found at '$SchedulePath'. $hint Generate a STRAWMAN from your fleet via: New-AzLocalApplyUpdatesScheduleConfig -OutputPath '$SchedulePath' (then review and UNCOMMENT at least one row before re-running). Set APPLY_UPDATES_SCHEDULE_PATH if you keep the schedule elsewhere." + } + + $cfg = Get-AzLocalApplyUpdatesScheduleConfig -Path $SchedulePath + $decision = Resolve-AzLocalCurrentUpdateRing -Schedule $cfg -Now $resolveAt + Write-Host "Resolver decision: $($decision.Reason)" + + if ($decision.Rings -and $decision.Rings.Count -gt 0) { + $resolved = $decision.UpdateRingValue + Write-Host "Resolved UpdateRing='$resolved' (cycleWeek=$($decision.CycleWeek), dayOfWeek=$($decision.DayOfWeekName))." + if ($decision.AllowedUpdateVersions -and $decision.AllowedUpdateVersions.Count -gt 0) { + $resolvedAllow = $decision.AllowedUpdateVersionsValue + Write-Host "Resolved AllowedUpdateVersions ($($decision.AllowedUpdateVersionsSource)): $resolvedAllow" + } + else { + Write-Host "Resolved AllowedUpdateVersions: - apply-updates will install the latest Ready update on each cluster." + } + } + else { + # Preserve original Step.6 severity per host (GH=notice, ADO=warning). + switch ($pipelineHost) { + 'GitHub' { Write-Host "::notice title=No UpdateRing scheduled for this firing::$($decision.Reason)" } + 'AzureDevOps' { Write-Host "##vso[task.logissue type=warning]No UpdateRing scheduled for this firing: $($decision.Reason)" } + default { Write-Host "[notice] No UpdateRing scheduled for this firing: $($decision.Reason)" } + } + Write-Host "Setting resolved UpdateRing to empty - check-readiness will report ready_count=0 and apply-updates will be skipped cleanly." + $resolved = '' + } + } + + # Bridge: cross-job step outputs (GitHub: GITHUB_OUTPUT; ADO: isOutput=true). + Set-AzLocalPipelineOutput -Name 'RESOLVED_UPDATE_RING' -Value $resolved -CrossJob + Set-AzLocalPipelineOutput -Name 'RESOLVED_ALLOWED_UPDATE_VERSIONS' -Value $resolvedAllow -CrossJob + + # GitHub-only: ALSO append to GITHUB_ENV so same-job downstream steps can + # read via $env:RESOLVED_*. Azure DevOps already achieves this via the + # isOutput=true variable being readable in the same job as $(name.var). + if ($pipelineHost -eq 'GitHub' -and $env:GITHUB_ENV) { + "RESOLVED_UPDATE_RING=$resolved" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "RESOLVED_ALLOWED_UPDATE_VERSIONS=$resolvedAllow" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + } + + if ($PassThru) { + return [pscustomobject]@{ + ResolvedUpdateRing = $resolved + ResolvedAllowedUpdateVersions = $resolvedAllow + IsManual = $isManual + UseScheduleFile = [bool]$UseScheduleFile + ResolveAt = $resolveAt + SchedulePath = $SchedulePath + Decision = $decision + } + } +} diff --git a/AzLocal.UpdateManagement/Public/Set-AzLocalClusterUpdateRingTagFromCsv.ps1 b/AzLocal.UpdateManagement/Public/Set-AzLocalClusterUpdateRingTagFromCsv.ps1 new file mode 100644 index 00000000..0248fd00 --- /dev/null +++ b/AzLocal.UpdateManagement/Public/Set-AzLocalClusterUpdateRingTagFromCsv.ps1 @@ -0,0 +1,331 @@ +function Set-AzLocalClusterUpdateRingTagFromCsv { + <# + .SYNOPSIS + Runs the Step.2 "Manage UpdateRing Tags" pipeline workload: validates + the operator-edited CSV, applies UpdateRing tags via + Set-AzLocalClusterUpdateRingTag, writes a JSON results sidecar plus + markdown step summary, and emits step outputs for the v0.8.5 + thin-YAML Step.2 pipeline. + + .DESCRIPTION + Phase 1 (v0.8.5) of the thin-YAML refactor. Condenses the inline + `run: |` body of the v0.8.4 `Step.2_manage-updatering-tags.yml` + (GitHub Actions + Azure DevOps) into a single cmdlet call so the + per-platform yml shrinks to a few lines and the workload becomes + unit-testable against a mocked Set-AzLocalClusterUpdateRingTag. + + The cmdlet: + + 1. Validates that the CSV file exists. When it does not, writes + the operator-recovery instructions to host (the same TWO-STAGE + workflow guidance the v0.8.4 yml used) and throws so the + pipeline step fails with a clear message. + 2. Validates that the CSV contains the required columns + (`ResourceId`, `UpdateRing`) and at least one row with a + non-empty `UpdateRing` value. + 3. Resolves the output directory (defaults to `./artifacts` on + GitHub Actions / local, or `$env:BUILD_ARTIFACTSTAGINGDIRECTORY` + on Azure DevOps - matching the v0.8.4 yml). + 4. Calls `Set-AzLocalClusterUpdateRingTag -InputCsvPath + -LogFolderPath -PassThru` (with `-Force` / `-WhatIf` + propagated from this cmdlet) ONCE. + 5. Serialises the per-cluster results to + `UpdateRingTag_Results.json` next to the logs (the canonical + sidecar the v0.8.4 Summary step consumed). + 6. Emits the markdown step summary (Settings table + Result + breakdown table + per-cluster details) via + `Add-AzLocalPipelineStepSummary`. + 7. Emits step outputs via `Set-AzLocalPipelineOutput`: + `total_count`, `created_count`, `updated_count`, + `already_in_sync_count`, `skipped_count`, `failed_count`, + `whatif_count`, `results_json_path`. + + Internal reuse (per the v0.8.5 thin-YAML consistency contract): + * `Set-AzLocalClusterUpdateRingTag` for the actual tag write. + * `Add-AzLocalPipelineStepSummary` for the rendered markdown. + * `Set-AzLocalPipelineOutput` for the step outputs. + * `Get-AzLocalPipelineHost` is implicit (the above branch on it). + + .PARAMETER InputCsvPath + Path to the operator-edited CSV produced by Step.1 + (Invoke-AzLocalClusterInventory). Must contain at minimum the + columns `ResourceId` and `UpdateRing`. Rows with an empty + `UpdateRing` are skipped by the inner cmdlet. + + .PARAMETER OutputDirectory + Directory to write the JSON results sidecar and per-cluster log + files into. Created if it does not exist. Defaults to + `./artifacts` (which is what the v0.8.4 GH yml uses) or, on + AzureDevOps, to `$env:BUILD_ARTIFACTSTAGINGDIRECTORY` if that env + var is set (matching the v0.8.4 ADO yml). + + .PARAMETER Force + Propagated to `Set-AzLocalClusterUpdateRingTag -Force`. When set, + existing UpdateRing tag values are overwritten. Without it, + clusters that already carry an UpdateRing value are skipped with + a Status of 'Skipped'. + + .PARAMETER ResultsJsonFileName + Filename for the per-cluster JSON results sidecar. Default + `UpdateRingTag_Results.json` - matches the v0.8.4 yml. + + .PARAMETER InstalledModuleVersion + Optional [string] surfaced in the step summary footer + ('Generated by AzLocal.UpdateManagement v'). + + .PARAMETER SummaryFileName + Per-task summary filename used by `Add-AzLocalPipelineStepSummary` + on Azure DevOps and Local hosts. Default + `updatering-tag-summary.md`. + + .PARAMETER PassThru + When set, returns a single PSCustomObject summarising the run + (TotalCount, CreatedCount, UpdatedCount, AlreadyInSyncCount, + SkippedCount, FailedCount, WhatIfCount, ResultsJsonPath, Results). + Without -PassThru the cmdlet emits nothing to the pipeline; the + artifacts and step outputs are still produced. + + .OUTPUTS + Nothing by default. When -PassThru is set, a single PSCustomObject: + TotalCount [int] + CreatedCount [int] + UpdatedCount [int] + AlreadyInSyncCount [int] + SkippedCount [int] + FailedCount [int] + WhatIfCount [int] + ResultsJsonPath [string] + Results [PSCustomObject[]] + + .EXAMPLE + Set-AzLocalClusterUpdateRingTagFromCsv -InputCsvPath ./config/ClusterUpdateRings.csv -WhatIf + + Previews the tag changes that would be applied without writing + anything to Azure. Emits the same markdown summary + JSON sidecar + as a real run (every row is Status='WhatIf'). + + .EXAMPLE + Set-AzLocalClusterUpdateRingTagFromCsv -InputCsvPath ./config/ClusterUpdateRings.csv -Force -PassThru + + Applies the tags, overwriting any existing UpdateRing values, and + returns the per-cluster summary object. + + .NOTES + Module: AzLocal.UpdateManagement (v0.8.5+) + Roadmap: Step.2 - Manage UpdateRing Tags. + #> + [CmdletBinding(SupportsShouldProcess = $true)] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$InputCsvPath, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$OutputDirectory, + + [Parameter(Mandatory = $false)] + [switch]$Force, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$ResultsJsonFileName = 'UpdateRingTag_Results.json', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$InstalledModuleVersion, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SummaryFileName = 'updatering-tag-summary.md', + + [Parameter(Mandatory = $false)] + [switch]$PassThru + ) + + $pipelineHost = Get-AzLocalPipelineHost + + # ----- 1. CSV existence guard -------------------------------------- + if (-not (Test-Path -LiteralPath $InputCsvPath -PathType Leaf)) { + Write-Host '' + Write-Host '===========================================================================' + Write-Host ' UpdateRing tagging is a TWO-STAGE workflow. You must run the inventory' + Write-Host ' pipeline first, edit the CSV, commit it, then re-run THIS pipeline.' + Write-Host '===========================================================================' + Write-Host '' + Write-Host 'STEP 1 - Generate the cluster inventory (Step.1 pipeline).' + Write-Host 'STEP 2 - Edit ClusterUpdateRings.csv in Excel and fill in the UpdateRing column.' + Write-Host 'STEP 3 - Commit the edited CSV into your ops repo (recommended path config/ClusterUpdateRings.csv).' + Write-Host 'STEP 4 - Re-run THIS pipeline pointing at the committed CSV.' + Write-Host '' + throw "CSV file not found at path: $InputCsvPath. See operator instructions above." + } + + # ----- 2. CSV column / value guard --------------------------------- + $rows = @(Import-Csv -LiteralPath $InputCsvPath) + $requiredColumns = @('ResourceId', 'UpdateRing') + if ($rows.Count -gt 0) { + $columns = $rows[0].PSObject.Properties.Name + foreach ($col in $requiredColumns) { + if ($col -notin $columns) { + throw "Required column '$col' not found in CSV: $InputCsvPath" + } + } + } + else { + throw "CSV file is empty (no rows): $InputCsvPath. Re-run the Step.1 inventory pipeline to regenerate the file." + } + + $rowsWithValues = @($rows | Where-Object { $_.UpdateRing -and $_.UpdateRing.Trim() -ne '' }).Count + Write-Host ("CSV File: {0}" -f $InputCsvPath) + Write-Host ("Total Rows: {0}" -f $rows.Count) + Write-Host ("Rows with UpdateRing values: {0}" -f $rowsWithValues) + if ($rowsWithValues -eq 0) { + throw "No rows have UpdateRing values set. Edit the CSV and set UpdateRing values before running this pipeline." + } + + # ----- 3. Output directory ----------------------------------------- + if (-not $OutputDirectory) { + if ($pipelineHost -eq 'AzureDevOps' -and $env:BUILD_ARTIFACTSTAGINGDIRECTORY) { + $OutputDirectory = $env:BUILD_ARTIFACTSTAGINGDIRECTORY + } + else { + $OutputDirectory = './artifacts' + } + } + if (-not (Test-Path -LiteralPath $OutputDirectory)) { + New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + } + + # ----- 4. Apply tags via Set-AzLocalClusterUpdateRingTag ----------- + $applyParams = @{ + InputCsvPath = $InputCsvPath + LogFolderPath = $OutputDirectory + PassThru = $true + } + if ($Force) { + $applyParams['Force'] = $true + Write-Host 'Force mode enabled - will overwrite existing tags' + } + if ($WhatIfPreference) { + $applyParams['WhatIf'] = $true + Write-Host 'DRY RUN MODE - No changes will be applied' + } + + Write-Host '' + Write-Host '========================================' + Write-Host 'Applying UpdateRing Tags' + Write-Host '========================================' + + $results = @(Set-AzLocalClusterUpdateRingTag @applyParams) + + # ----- 5. JSON sidecar --------------------------------------------- + $resultsJsonPath = Join-Path -Path $OutputDirectory -ChildPath $ResultsJsonFileName + if ($results.Count -gt 0) { + $results | ConvertTo-Json -Depth 5 | Out-File -FilePath $resultsJsonPath -Encoding utf8 + Write-Host ("Wrote {0} per-cluster result row(s) to: {1}" -f $results.Count, $resultsJsonPath) + } + else { + '[]' | Out-File -FilePath $resultsJsonPath -Encoding utf8 + Write-Host ("No per-cluster results returned (empty CSV?). Wrote empty array to: {0}" -f $resultsJsonPath) + } + + # ----- 6. Tally outcomes ------------------------------------------- + $total = $results.Count + $created = @($results | Where-Object { $_.Action -eq 'Created' -and $_.Status -eq 'Success' }).Count + $updated = @($results | Where-Object { $_.Action -eq 'Updated' -and $_.Status -eq 'Success' }).Count + $alreadyInSync = @($results | Where-Object { $_.Status -eq 'AlreadyInSync' }).Count + $skipped = @($results | Where-Object { $_.Status -eq 'Skipped' }).Count + $failed = @($results | Where-Object { $_.Status -eq 'Failed' }).Count + $whatIfCount = @($results | Where-Object { $_.Status -eq 'WhatIf' }).Count + + Write-Host '' + Write-Host '========================================' + Write-Host 'Tag management complete' + Write-Host '========================================' + Write-Host ("Total clusters processed: {0}" -f $total) + Write-Host ("Tags created : {0}" -f $created) + Write-Host ("Tags updated : {0}" -f $updated) + Write-Host ("Already in sync : {0}" -f $alreadyInSync) + Write-Host ("Skipped (no -Force) : {0}" -f $skipped) + Write-Host ("WhatIf (dry-run) : {0}" -f $whatIfCount) + Write-Host ("Failed : {0}" -f $failed) + + # ----- 7. Step outputs --------------------------------------------- + Set-AzLocalPipelineOutput -Name 'total_count' -Value ([string]$total) + Set-AzLocalPipelineOutput -Name 'created_count' -Value ([string]$created) + Set-AzLocalPipelineOutput -Name 'updated_count' -Value ([string]$updated) + Set-AzLocalPipelineOutput -Name 'already_in_sync_count' -Value ([string]$alreadyInSync) + Set-AzLocalPipelineOutput -Name 'skipped_count' -Value ([string]$skipped) + Set-AzLocalPipelineOutput -Name 'failed_count' -Value ([string]$failed) + Set-AzLocalPipelineOutput -Name 'whatif_count' -Value ([string]$whatIfCount) + Set-AzLocalPipelineOutput -Name 'results_json_path' -Value ([string]$resultsJsonPath) + + # ----- 8. Markdown step summary ------------------------------------ + $sb = New-Object System.Text.StringBuilder + [void]$sb.AppendLine('## Step.2 - UpdateRing Tag Management Summary') + [void]$sb.AppendLine('') + [void]$sb.AppendLine('| Setting | Value |') + [void]$sb.AppendLine('|---------|-------|') + [void]$sb.AppendLine(("| Dry Run | {0} |" -f $WhatIfPreference.ToString())) + [void]$sb.AppendLine(("| Force Overwrite | {0} |" -f $Force.IsPresent)) + [void]$sb.AppendLine(("| Input CSV | {0} |" -f $InputCsvPath)) + [void]$sb.AppendLine('') + [void]$sb.AppendLine('### Result breakdown') + [void]$sb.AppendLine('') + [void]$sb.AppendLine('| Outcome | Count |') + [void]$sb.AppendLine('|---------|------:|') + [void]$sb.AppendLine(("| Total clusters processed | {0} |" -f $total)) + [void]$sb.AppendLine(("| Tags created | {0} |" -f $created)) + [void]$sb.AppendLine(("| Tags updated | {0} |" -f $updated)) + [void]$sb.AppendLine(("| Already in sync (no-op) | {0} |" -f $alreadyInSync)) + [void]$sb.AppendLine(("| Skipped (UpdateRing differs, no -Force) | {0} |" -f $skipped)) + if ($whatIfCount -gt 0) { + [void]$sb.AppendLine(("| WhatIf (dry-run preview) | {0} |" -f $whatIfCount)) + } + [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)) + } + [void]$sb.AppendLine('') + [void]$sb.AppendLine('
') + } + if ($WhatIfPreference) { + [void]$sb.AppendLine('') + [void]$sb.AppendLine('**This was a dry run. No changes were applied.**') + } + if ($InstalledModuleVersion) { + [void]$sb.AppendLine('') + [void]$sb.AppendLine(("_Generated by AzLocal.UpdateManagement v{0}._" -f $InstalledModuleVersion)) + } + + Add-AzLocalPipelineStepSummary -Markdown $sb.ToString() -SummaryFileName $SummaryFileName | Out-Null + + if ($PassThru) { + return [pscustomobject]@{ + TotalCount = $total + CreatedCount = $created + UpdatedCount = $updated + AlreadyInSyncCount = $alreadyInSync + SkippedCount = $skipped + FailedCount = $failed + WhatIfCount = $whatIfCount + ResultsJsonPath = $resultsJsonPath + Results = $results + } + } +} diff --git a/AzLocal.UpdateManagement/Public/Test-AzLocalApplyUpdatesScheduleCoverage.ps1 b/AzLocal.UpdateManagement/Public/Test-AzLocalApplyUpdatesScheduleCoverage.ps1 index ee41d415..6309a941 100644 --- a/AzLocal.UpdateManagement/Public/Test-AzLocalApplyUpdatesScheduleCoverage.ps1 +++ b/AzLocal.UpdateManagement/Public/Test-AzLocalApplyUpdatesScheduleCoverage.ps1 @@ -928,41 +928,38 @@ resources [void]$fullSb.AppendLine() } - # v0.8.4 - Enhancement B: Cycle calendar (informational). - # One row per UTC day for one full cycle (CycleWeeks * 7) starting - # today. Re-uses Resolve-AzLocalCurrentUpdateRing so cycle-week, - # UNION, and AllowedUpdateVersions math is identical to runtime. + # v0.8.5 - Cycle calendar (informational). + # Delegated to Get-AzLocalApplyUpdatesScheduleCycleCalendar so the + # per-day projection, ISO-week math, UNION semantics, and + # per-ring summary live in one cmdlet that Step.3 yml also + # calls directly (unconditionally) to avoid the v0.8.4 silent- + # drop bug where this section was lost on clean fleets. + # + # Build a ring -> tagged-cluster-count map from the live tag + # scan ($clusters already has UpdateRing populated) and pass + # it to the cmdlet so the calendar surfaces 'Clusters in + # ring(s)' per day and 'Cluster count' per ring. The cmdlet + # itself does no CSV / Azure I/O. if ($scheduleCfg) { - $calCycleWeeks = [int]$scheduleCfg.CycleWeeks - $lookaheadDays = $calCycleWeeks * 7 - $startDay = [datetime]::UtcNow.Date - $scheduleFileLabelShort = [System.IO.Path]::GetFileName($SchedulePath) - [void]$fullSb.AppendLine() - [void]$fullSb.AppendLine("## Cycle calendar - next $lookaheadDays day(s) (one full $calCycleWeeks-week cycle)") - [void]$fullSb.AppendLine() - [void]$fullSb.AppendLine("**What this shows.** For each UTC day starting today, the table lists which ``UpdateRing`` value(s) ``Resolve-AzLocalCurrentUpdateRing`` would return given your ``$scheduleFileLabelShort``. Use it to verify ring coverage matches your policy and to spot dead days where no ring is eligible. Days where multiple schedule rows match are UNIONed (all eligible rings shown, deduplicated). The ``AllowedUpdateVersions`` column shows the effective allow-list for that day (empty = no constraint).") - [void]$fullSb.AppendLine() - [void]$fullSb.AppendLine('| Date (UTC) | Day | CycleWeek | Eligible rings | AllowedUpdateVersions |') - [void]$fullSb.AppendLine('|---|---|---|---|---|') - $prevCycleWeek = $null - for ($i = 0; $i -lt $lookaheadDays; $i++) { - $d = $startDay.AddDays($i) - try { - $r = Resolve-AzLocalCurrentUpdateRing -Schedule $scheduleCfg -Now $d - } catch { - [void]$fullSb.AppendLine("| $($d.ToString('yyyy-MM-dd')) | ? | ? | _(resolver error: $($_.Exception.Message -replace '\|','\|'))_ | - |") - continue - } - $ringsCell = if ($r.Rings -and @($r.Rings).Count -gt 0) { (@($r.Rings) | ForEach-Object { '``' + $_ + '``' }) -join ', ' } else { '_(none - dead day)_' } - $allowCell = if ($r.AllowedUpdateVersions -and @($r.AllowedUpdateVersions).Count -gt 0) { (@($r.AllowedUpdateVersions) | ForEach-Object { '``' + $_ + '``' }) -join ', ' } else { '_(no constraint)_' } - $cycleCell = "$($r.CycleWeek) of $calCycleWeeks" - if ($null -ne $prevCycleWeek -and $r.CycleWeek -eq 1 -and $prevCycleWeek -eq $calCycleWeeks) { - $cycleCell = "**1** of $calCycleWeeks _(cycle wraps)_" + $ringCountMap = @{} + foreach ($c in @($clusters)) { + if ($c.UpdateRing -and -not [string]::IsNullOrWhiteSpace($c.UpdateRing)) { + $rname = $c.UpdateRing.Trim() + if ($ringCountMap.ContainsKey($rname)) { + $ringCountMap[$rname] = [int]$ringCountMap[$rname] + 1 + } else { + $ringCountMap[$rname] = 1 + } } - $prevCycleWeek = $r.CycleWeek - [void]$fullSb.AppendLine("| $($d.ToString('yyyy-MM-dd')) | $($r.DayOfWeekName) | $cycleCell | $ringsCell | $allowCell |") } [void]$fullSb.AppendLine() + try { + $calendarMd = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $scheduleCfg -AsMarkdown -IncludePerRingSummary -ClusterRingCounts $ringCountMap + if (-not [string]::IsNullOrWhiteSpace($calendarMd)) { [void]$fullSb.AppendLine($calendarMd) } + } catch { + [void]$fullSb.AppendLine("_Cycle calendar unavailable: $($_.Exception.Message)_") + [void]$fullSb.AppendLine() + } } # v0.8.4 - Enhancement C: Configured exclusion windows summary. diff --git a/AzLocal.UpdateManagement/README.md b/AzLocal.UpdateManagement/README.md index 190e6290..f12b4433 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.4 - [Published in PowerShell Gallery](https://www.powershellgallery.com/packages/AzLocal.UpdateManagement/0.8.4) +**Latest Version:** v0.8.5 - [Published in PowerShell Gallery](https://www.powershellgallery.com/packages/AzLocal.UpdateManagement/0.8.5) > 📢 **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,8 +23,8 @@ 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.4](#whats-new-in-v084) -- [What's New in v0.8.2](#whats-new-in-v082) - [Files](#files) - [Prerequisites](#prerequisites) - [RBAC Requirements](#rbac-requirements) (summary; full reference in [docs/rbac.md](docs/rbac.md)) @@ -87,27 +87,22 @@ If you are new to this module, work through these in order from a regular PowerS > Most CI/CD pipelines in [Automation-Pipeline-Examples/](Automation-Pipeline-Examples/) are direct implementations of one of these workflows. Start there if you want a copy-pasteable end-to-end pipeline. -## What's New in v0.8.4 +## What's New in v0.8.5 -**Step.3 advisor enhancements.** Three new sections in `Test-AzLocalApplyUpdatesScheduleCoverage -View Recommend`. No public API removed; one new optional parameter on the cmdlet (`-ClusterCsvPath`). +**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).** -1. **NoWindowTag CSV-driven remediation (new `-ClusterCsvPath` parameter).** When the operator supplies the path to the source-controlled cluster inventory CSV (the file Step.2 consumes - default `config/ClusterUpdateRings.csv`), the Recommend view emits a new `## Action required - NoWindowTag remediation` section. For each cluster that has an `UpdateRing` tag but no `UpdateStartWindow` tag, the advisor proposes a peer-derived value (mode of peers' `UpdateStartWindow` in the same ring), explains the choice (unanimous / majority / sole peer / no peers), then looks up the cluster in the CSV - case-insensitive on `ResourceId` first, falling back to `ClusterName + ResourceGroup`. If found, tells the operator which row's `UpdateStartWindow` cell to edit; if not found, tells the operator to re-run Step.1 to regenerate the cluster inventory artifact and replace the source-controlled CSV with it. -2. **Cycle calendar (informational, auto-renders when `-SchedulePath` supplied).** A new `## Cycle calendar - next N day(s)` table renders one row per UTC day for one full cycle (`CycleWeeks * 7` days starting today). Each row shows Date, Day-of-week, CycleWeek (`X of N`, with `(cycle wraps)` annotation on the wrap row), Eligible rings (UNION of all matching schedule rows), and effective `AllowedUpdateVersions`. Re-uses `Resolve-AzLocalCurrentUpdateRing` per-day so cycle-week, UNION, and allow-list math is identical to runtime. Lets operators verify ring coverage matches their maintenance policy and spot dead days where no ring is eligible. -3. **Configured exclusion windows summary (informational, auto-renders when at least one cluster has a non-empty `UpdateExclusionsWindow` tag).** A new `## Configured exclusion windows (UpdateExclusionsWindow tag)` section groups tagged clusters by `(UpdateRing, UpdateExclusionsWindow)` and renders a rollup table. Plus a per-ring breakdown of clusters that have NO `UpdateExclusionsWindow` tag, so operators can spot drift where some clusters in a ring have a blackout configured and others do not. +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". -Step.3 GH + ADO yml templates gain a new `cluster_csv_path` / `clusterCsvPath` input (default `config/ClusterUpdateRings.csv`) and now also pass `-SchedulePath` to the Recommend invocation so Enhancements 2+3 render in the Step Summary. ARG query also fetches `UpdateExclusionsWindow` so Enhancement 3 has data to render. +`GENERATED_AGAINST_MODULE_VERSION` bumped from `0.8.4` to `0.8.5` across all 20 bundled `Step.{0..9}.yml` templates. -**Step.6 Enhancement D - per-cluster Step Summary in `apply-updates` (GitHub Actions + Azure DevOps).** The `apply-updates` step now persists `apply-results.json` to the artifact-staging dir and the downstream Summary step renders two new per-cluster tables: `### Cluster Actions` (one row per cluster handed to apply - icon + Status + UpdateName + Duration + Message, sorted by Status then ClusterName) and `### Clusters Skipped at Readiness Gate` (one row per cluster filtered out by Step.6 check-readiness, reading `readiness-report.csv` from the CheckReadiness stage - shows Cluster, UpdateState, Health, CurrentVersion, RecommendedUpdate, BlockingReasons, capped at first 100 rows for large fleets). Eliminates the need to download and parse the JUnit artifact to identify which specific cluster failed or which clusters were schedule-blocked. - -**Node.js 24 opt-in across all 10 GitHub Actions yml.** Workflow-level `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true` env var added to every bundled GitHub Actions template, letting early-adopter operators surface any per-action Node 24 incompatibility on their own timeline ahead of GitHub's platform-wide cutover. - -**Version banner appended to every install step (GitHub Actions + Azure DevOps).** `_Pipeline YAML v | Module v installed () | PSGallery latest | _` is now appended to the Step Summary (GH) / Build Summary (ADO) by every install step on both platforms - one banner per yml across all 20 bundled `Step.{0..9}.yml` templates (Step.6 ADO has 2 install steps but the second deliberately skips upload to avoid duplicating the banner). Surfaces "what version actually ran" in the Summary view fleet operators open first, eliminating "the YAML in my repo is too old" investigation lag. - -**RBAC custom role renamed: `Azure Stack HCI Update Operator` -> `Azure Stack HCI Update Operator (custom)`.** The bundled `azlocal-update-management-custom-role.json` role definition is renamed to make the role's customer-managed status obvious and pre-empt any future collision with a Microsoft built-in of the same brand name. `Actions[]` / `NotActions[]` / `DataActions[]` / `AssignableScopes[]` are byte-identical to v0.8.3. **Existing tenants upgrade in place** with `az role definition update --role-definition ./azlocal-update-management-custom-role.json` - the role's GUID and every existing role assignment are preserved; only the display name changes. **Pipelines see zero downtime.** Documentation updated in lockstep across the bundled JSON, `Automation-Pipeline-Examples/README.md` (section header, TOC, 23 references, 11 anchor links), `Automation-Pipeline-Examples/docs/appendix-pipelines.md` (8 references, 1 anchor link), and `docs/rbac.md` (10 references in 3 inline JSON blocks plus assignment commands and DINE policy display name). When referencing the role from automation, prefer `roleDefinitionId` (the GUID) over display name - the GUID is stable across renames and avoids built-in vs custom display-name collision risk. - -`GENERATED_AGAINST_MODULE_VERSION` bumped from `0.8.3` to `0.8.4` across all 20 bundled `Step.{0..9}.yml` templates. - -See [CHANGELOG.md](CHANGELOG.md#084---2026-06-18) for the full v0.8.4 entry. +See [CHANGELOG.md](CHANGELOG.md#085---2026-06-09) for the full v0.8.5 entry. ## Files @@ -586,7 +581,13 @@ This code is provided as-is for educational and reference purposes. The full What's-New history (v0.7.81 and earlier) has moved to [docs/release-history.md](docs/release-history.md). -The most recent release notes for **v0.8.4** stay above under [`What's New in v0.8.4`](#whats-new-in-v084). +The most recent release notes for **v0.8.5** stay above under [`What's New in v0.8.5`](#whats-new-in-v085). + +### What's New in v0.8.4 + +**Step.3 advisor enhancements + Step.6 per-cluster Step Summary + version banner on every install step + RBAC custom-role rename.** Three new informational sections in `Test-AzLocalApplyUpdatesScheduleCoverage -View Recommend` (NoWindowTag CSV remediation with new `-ClusterCsvPath` parameter; cycle calendar; configured exclusion windows). Step.6 `apply-updates` step persists `apply-results.json` and the downstream Summary renders `### Cluster Actions` + `### Clusters Skipped at Readiness Gate` per-cluster tables on both GH + ADO. Node.js 24 opt-in (`FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true`) added to all 10 bundled GH yml. Every install step on both platforms appends a `_Pipeline YAML v... | Module v... | PSGallery latest ... | _` banner to the Run/Build Summary. Bundled custom role renamed from `Azure Stack HCI Update Operator` to `Azure Stack HCI Update Operator (custom)` (in-place `az role definition update` is a zero-downtime rename - GUID + all role assignments preserved). + +See [CHANGELOG.md](CHANGELOG.md#084---2026-06-18) for the full v0.8.4 entry. ### What's New in v0.8.3 diff --git a/AzLocal.UpdateManagement/Tests/AzLocal.UpdateManagement.Tests.ps1 b/AzLocal.UpdateManagement/Tests/AzLocal.UpdateManagement.Tests.ps1 index 53c20511..2c1dc14f 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.4' { - $script:ModuleInfo.Version | Should -Be '0.8.4' + It 'Should have version 0.8.5' { + $script:ModuleInfo.Version | Should -Be '0.8.5' } It 'Module version constants are in sync between .psm1 and .psd1' { @@ -180,7 +180,7 @@ Describe 'Module: AzLocal.UpdateManagement' { } } - It 'Step.4 pipeline templates call Get-AzLocalFleetConnectivityStatus (v0.7.79 migration)' -ForEach @( + It 'Step.4 pipeline templates call Export-AzLocalFleetConnectivityStatusReport (v0.8.5 thin-YAML)' -ForEach @( @{ Platform = 'github-actions'; Path = '..\Automation-Pipeline-Examples\github-actions\Step.4_fleet-connectivity-status.yml' } @{ Platform = 'azure-devops'; Path = '..\Automation-Pipeline-Examples\azure-devops\Step.4_fleet-connectivity-status.yml' } ) { @@ -188,19 +188,32 @@ Describe 'Module: AzLocal.UpdateManagement' { Test-Path $yamlPath | Should -BeTrue -Because "Step.4 template for $Platform must exist at $yamlPath" $content = Get-Content -Path $yamlPath -Raw - # v0.7.79 migration guard: inline KQL + Invoke-ArgQuery replaced by module cmdlet. - $content | Should -Match 'Get-AzLocalFleetConnectivityStatus' -Because "Step.4 $Platform must call the module cmdlet (v0.7.79 migration)" + # v0.8.5 thin-YAML guard: the ~255-line inline collection block + # was replaced by the single Export-AzLocalFleetConnectivityStatusReport call. + $content | Should -Match 'Export-AzLocalFleetConnectivityStatusReport' -Because "Step.4 $Platform must call the v0.8.5 thin-YAML cmdlet" $content | Should -Not -Match 'function Invoke-ArgQuery' -Because "Step.4 $Platform must not contain inline Invoke-ArgQuery (removed in v0.7.79)" + $content | Should -Not -Match '\$data\.ClusterRows' -Because "Step.4 $Platform must not still wire raw cmdlet rowsets into the yml - the cmdlet emits step outputs instead (v0.8.5)" + } + + It 'Step.3 pipeline templates call Export-AzLocalApplyUpdatesScheduleAudit (v0.8.5 thin-YAML)' -ForEach @( + @{ Platform = 'github-actions'; Path = '..\Automation-Pipeline-Examples\github-actions\Step.3_apply-updates-schedule-audit.yml' } + @{ Platform = 'azure-devops'; Path = '..\Automation-Pipeline-Examples\azure-devops\Step.3_apply-updates-schedule-audit.yml' } + ) { + $yamlPath = Join-Path -Path $PSScriptRoot -ChildPath $Path + Test-Path $yamlPath | Should -BeTrue -Because "Step.3 template for $Platform must exist at $yamlPath" + $content = Get-Content -Path $yamlPath -Raw - # Cmdlet output properties must be assigned to pipeline variables. - $content | Should -Match '\$data\.ClusterRows' -Because "Step.4 $Platform must assign `$data.ClusterRows from cmdlet output" - $content | Should -Match '\$data\.ArcSummary' -Because "Step.4 $Platform must assign `$data.ArcSummary from cmdlet output" - $content | Should -Match '\$data\.NicIssues' -Because "Step.4 $Platform must assign `$data.NicIssues from cmdlet output" - $content | Should -Match '\$data\.ArbRows' -Because "Step.4 $Platform must assign `$data.ArbRows from cmdlet output" + # v0.8.5 thin-YAML guard: the ~220-line inline 'Run Schedule + # Coverage Audit' block + ~210-line inline 'Create Schedule + # Coverage Summary' block were replaced by a single call to + # Export-AzLocalApplyUpdatesScheduleAudit. + $content | Should -Match 'Export-AzLocalApplyUpdatesScheduleAudit' -Because "Step.3 $Platform must call the v0.8.5 thin-YAML cmdlet" + $content | Should -Not -Match 'Test-AzLocalApplyUpdatesScheduleCoverage[^\.]+-View\s+Audit' -Because "Step.3 $Platform must not call the advisor inline - the cmdlet does all 3 views internally (v0.8.5)" + $content | Should -Not -Match 'function\s+Convert-ScheduleRow' -Because "Step.3 $Platform must not contain inline schedule-row helpers (removed in v0.8.5)" } - It 'Should export exactly 38 functions' { - $script:ModuleInfo.ExportedFunctions.Count | Should -Be 38 + It 'Should export exactly 55 functions' { + $script:ModuleInfo.ExportedFunctions.Count | Should -Be 55 } It 'Should export the expected functions' { @@ -251,6 +264,8 @@ Describe 'Module: AzLocal.UpdateManagement' { 'Update-AzLocalApplyUpdatesScheduleConfig', 'Resolve-AzLocalCurrentUpdateRing', 'Get-AzLocalApplyUpdatesScheduleNextFirings', + # Cycle Calendar (v0.8.5) - human-readable per-day projection + per-ring summary + 'Get-AzLocalApplyUpdatesScheduleCycleCalendar', # Fleet Health Overview (v0.7.70) - ARG-first projection of cluster + updateSummaries 'Get-AzLocalFleetHealthOverview', # Latest Released Solution Version (v0.7.70 Phase E) - public manifest probe (aka.ms/AzureEdgeUpdates) anchoring the rolling YYMM support window in Step.6 @@ -258,7 +273,34 @@ Describe 'Module: AzLocal.UpdateManagement' { # Fleet Connectivity Status (v0.7.79) - 4-scope connectivity audit: cluster, Arc agent, physical NIC, ARB 'Get-AzLocalFleetConnectivityStatus', # Fleet Connectivity Status Summary Renderer (v0.7.87) - markdown step-summary builder used by Step.4 GH+ADO pipelines - 'New-AzLocalFleetConnectivityStatusSummary' + 'New-AzLocalFleetConnectivityStatusSummary', + # Thin-YAML pipeline foundation (v0.8.5) - install-step version banner + drift annotations + step outputs + 'Add-AzLocalPipelineVersionBanner', + # Thin-YAML Step.0 (v0.8.5) - Authentication validation + subscription scope + cluster reachability + 'Export-AzLocalAuthValidationReport', + # Thin-YAML Step.1 (v0.8.5) - Cluster inventory workload (timestamped + canonical CSV, JSON, README, step summary) + 'Invoke-AzLocalClusterInventory', + # Thin-YAML Step.2 (v0.8.5) - UpdateRing tag management workload (CSV validation + apply via Set-AzLocalClusterUpdateRingTag + JSON sidecar + step summary) + 'Set-AzLocalClusterUpdateRingTagFromCsv', + # Thin-YAML Step.7 (v0.8.5) - In-flight update-run monitor (CSV + JUnit + step summary + 6 step outputs) + 'Export-AzLocalUpdateRunMonitorReport', + # Thin-YAML Step.8 (v0.8.5) - Fleet update status snapshot (inventory + readiness + version distribution + 3-suite JUnit + step summary + 22 step outputs) + 'Export-AzLocalFleetUpdateStatusReport', + # Thin-YAML Step.5 (v0.8.5) - Pre-flight Update Readiness Assessment (readiness + blocking-health JUnit + combined JUnit + 8-section markdown + 2 step outputs) + 'Export-AzLocalClusterUpdateReadinessReport', + # Thin-YAML Step.4 (v0.8.5) - Fleet Connectivity Status (Cluster/Arc/NIC/ARB severity classification + JUnit + markdown + 12 step outputs) + 'Export-AzLocalFleetConnectivityStatusReport', + # Thin-YAML Step.3 (v0.8.5) - Apply-Updates Schedule Coverage Audit (Audit + Matrix + Recommend views + 2-suite JUnit + allow-list section + always-on cycle calendar + 12 step outputs) + 'Export-AzLocalApplyUpdatesScheduleAudit', + # Thin-YAML Step.9 (v0.8.5) - Fleet Health Status (Detail + Summary + Overview + 2-suite JUnit + KPI / Overview / By-Reason / per-cluster collapsible markdown + 8 step outputs) + 'Export-AzLocalFleetHealthStatusReport', + # Thin-YAML Step.6 (v0.8.5) - Apply-Updates pipeline (schedule resolver + readiness gate report + readiness-gated apply + apply-summary + no-ready-clusters summary + ITSM ticketing from JUnit artifact) + 'Resolve-AzLocalPipelineUpdateRing', + 'Export-AzLocalClusterReadinessGateReport', + 'Invoke-AzLocalReadinessGatedClusterUpdate', + 'Add-AzLocalApplyUpdatesStepSummary', + 'Add-AzLocalNoReadyClustersStepSummary', + 'Invoke-AzLocalItsmTicketingFromArtifact' ) foreach ($func in $expectedFunctions) { @@ -371,16 +413,24 @@ Describe 'Module: AzLocal.UpdateManagement' { $installsModule = $content -match 'Install-Module\s+@installArgs' if (-not $installsModule) { continue } - # The drift guard has two equally valid emitter shapes: - # GH Actions: '::warning title=AzLocal.UpdateManagement is older than workflow YAML expects' - # Azure DevOps: '##vso[task.logissue type=warning]AzLocal.UpdateManagement v$installed is OLDER' - # Both are anchored on the '$installed -lt $generated' comparison. + # The drift guard has three equally valid emitter shapes: + # GH Actions inline: '::warning title=AzLocal.UpdateManagement is older than workflow YAML expects' + # Azure DevOps inline: '##vso[task.logissue type=warning]AzLocal.UpdateManagement v$installed is OLDER' + # v0.8.5 thin-YAML: Add-AzLocalPipelineVersionBanner (Public cmdlet that performs + # the '$installed -lt $generated' comparison internally and emits + # the warning via Write-AzLocalPipelineWarning). $hasComparison = $content -match '\$installed\s+-lt\s+\$generated' + # Ignore the cmdlet name when it appears only inside a YAML comment + # (lines beginning with optional whitespace then '#'). + $cmdletInvocationLines = ($content -split "`r?`n") | Where-Object { + ($_ -notmatch '^\s*#') -and ($_ -match 'Add-AzLocalPipelineVersionBanner') + } + $hasCmdlet = ($cmdletInvocationLines.Count -gt 0) $relPath = $yml.FullName.Substring($examplesRoot.Length).TrimStart('\','/') - if (-not $hasComparison) { - $issues.Add("${relPath}: installs the module but is MISSING the 'installed -lt generated' drift guard") + if ((-not $hasComparison) -and (-not $hasCmdlet)) { + $issues.Add("${relPath}: installs the module but is MISSING the 'installed -lt generated' drift guard (neither inline comparison nor Add-AzLocalPipelineVersionBanner call found)") } } @@ -512,23 +562,27 @@ Describe 'Module: AzLocal.UpdateManagement' { # (GITHUB_STEP_SUMMARY on GH, ##vso[task.uploadsummary] on ADO). # This makes the version triple obvious in the Summary view fleet # operators open first, rather than buried in annotations. - # Expected sites: 10 GH yml + 11 ADO sites (Step.6 ADO has 2 install - # steps: check-readiness + apply-updates). + # v0.8.5 thin-YAML: ports replace the ~50-line inline banner block + # with a single Add-AzLocalPipelineVersionBanner call (Public cmdlet + # that produces the same banner string internally). Either form is + # acceptable for these guards. It 'Every GitHub Actions install step contains the version banner string' { $ghRoot = Join-Path -Path $PSScriptRoot -ChildPath '..\Automation-Pipeline-Examples\github-actions' $ghRoot = (Resolve-Path -Path $ghRoot).Path $ymlFiles = Get-ChildItem -Path $ghRoot -Filter 'Step.*.yml' -File + $bannerLiteral = [regex]::Escape('Pipeline YAML v$generated | Module v$installed installed') + $cmdletCall = [regex]::Escape('Add-AzLocalPipelineVersionBanner') $offenders = New-Object System.Collections.Generic.List[string] foreach ($yml in $ymlFiles) { $content = Get-Content -LiteralPath $yml.FullName -Raw - if ($content -notmatch [regex]::Escape('Pipeline YAML v$generated | Module v$installed installed')) { + if (($content -notmatch $bannerLiteral) -and ($content -notmatch $cmdletCall)) { $offenders.Add($yml.Name) } } $detail = if ($offenders.Count -gt 0) { $offenders -join ', ' } else { '(none)' } - $offenders.Count | Should -Be 0 -Because "every GitHub Actions yml install step must append the v0.8.4 version banner to GITHUB_STEP_SUMMARY. Missing from: $detail" + $offenders.Count | Should -Be 0 -Because "every GitHub Actions yml install step must either inline the v0.8.4 banner string or call Add-AzLocalPipelineVersionBanner (v0.8.5 thin-YAML). Missing from: $detail" } It 'Every Azure DevOps yml install site contains the version banner string (Step.6 second install deliberately skips upload to avoid duplicate banner)' { @@ -537,11 +591,15 @@ Describe 'Module: AzLocal.UpdateManagement' { $ymlFiles = Get-ChildItem -Path $adoRoot -Filter 'Step.*.yml' -File $bannerPattern = [regex]::Escape('Pipeline YAML v$generated | Module v$installed installed') + $cmdletPattern = [regex]::Escape('Add-AzLocalPipelineVersionBanner') $totalSites = 0 # Every Step.X.yml emits exactly ONE banner to the build Summary. # Step.6 has TWO install steps (CheckReadiness + ApplyUpdates) but # the second one deliberately skips ##vso[task.uploadsummary] so the # banner does not appear twice in the rendered Summary. + # v0.8.5 thin-YAML: ported YAMLs replace the inline banner block + # with one Add-AzLocalPipelineVersionBanner call - count either + # form as a banner emit. $perFileExpect = @{ 'Step.0_authentication-test.yml' = 1 'Step.1_inventory-clusters.yml' = 1 @@ -557,44 +615,152 @@ Describe 'Module: AzLocal.UpdateManagement' { $offenders = New-Object System.Collections.Generic.List[string] foreach ($yml in $ymlFiles) { $content = Get-Content -LiteralPath $yml.FullName -Raw - $count = ([regex]::Matches($content, $bannerPattern)).Count + # Count cmdlet hits only on non-comment lines so a comment that + # mentions Add-AzLocalPipelineVersionBanner does not double-count. + $nonCommentLines = ($content -split "`r?`n") | Where-Object { $_ -notmatch '^\s*#' } + $nonCommentText = $nonCommentLines -join "`n" + $bannerHits = ([regex]::Matches($nonCommentText, $bannerPattern)).Count + $cmdletHits = ([regex]::Matches($nonCommentText, $cmdletPattern)).Count + # The cmdlet call produces 1 banner per invocation; the inline + # block produces 1 banner per Out-File of the literal. Count + # both signals as banner emits. + $count = $bannerHits + $cmdletHits $totalSites += $count if ($perFileExpect.ContainsKey($yml.Name)) { $want = $perFileExpect[$yml.Name] if ($count -ne $want) { - $offenders.Add(("{0}: expected {1} banner emit(s), found {2}" -f $yml.Name, $want, $count)) + $offenders.Add(("{0}: expected {1} banner emit(s), found {2} (inline={3}, cmdlet={4})" -f $yml.Name, $want, $count, $bannerHits, $cmdletHits)) } } } $detail = if ($offenders.Count -gt 0) { $offenders -join [Environment]::NewLine } else { '(no mismatches)' } - $offenders.Count | Should -Be 0 -Because "every ADO yml uploads exactly 1 version banner to the build Summary via ##vso[task.uploadsummary]. Step.6 has 2 install steps but the second (ApplyUpdates stage) deliberately skips the upload so the banner is not duplicated. Findings:$([Environment]::NewLine)$detail" + $offenders.Count | Should -Be 0 -Because "every ADO yml uploads exactly 1 version banner to the build Summary - via ##vso[task.uploadsummary] for the v0.8.4 inline form, or via Add-AzLocalPipelineVersionBanner for the v0.8.5 thin-YAML form. Step.6 has 2 install steps but the second (ApplyUpdates stage) deliberately skips the upload so the banner is not duplicated. Findings:$([Environment]::NewLine)$detail" $totalSites | Should -Be 10 -Because "total ADO Summary banner emits should be 10 (1 per yml; Step.6's second install step skips upload)" } } - Context 'v0.8.4 Step.6 Enhancement D per-cluster Step Summary headers' { - # v0.8.4: Step.6 apply-updates Summary on both platforms includes - # two new per-cluster tables: '### Cluster Actions' (read from - # apply-results.json written by the apply-updates step) and - # '### Clusters Skipped at Readiness Gate' (read from - # readiness-report.csv written by the check-readiness stage). - It 'GitHub Actions Step.6 yml contains both per-cluster table headers and apply-results.json persist' { + Context 'v0.8.5 Step.6 thin-YAML: apply-updates pipeline calls shared cmdlets' { + # v0.8.5 thin-YAML port: the inline run: / inline scripts that + # previously rendered the per-cluster 'Cluster Actions' and + # 'Clusters Skipped at Readiness Gate' tables (and persisted + # apply-results.json next to update-results.xml) were moved into the + # public cmdlets Add-AzLocalApplyUpdatesStepSummary and + # Invoke-AzLocalReadinessGatedClusterUpdate. The behaviour that + # produces apply-results.json + both per-cluster tables is preserved + # byte-for-byte by the cmdlets - this guard asserts the YAMLs invoke + # the cmdlets that produce that output (the per-cluster table + # content is asserted in the cmdlet-level Pester suite). + It 'GitHub Actions Step.6 yml invokes Invoke-AzLocalReadinessGatedClusterUpdate (writes apply-results.json) and Add-AzLocalApplyUpdatesStepSummary (renders both per-cluster tables)' { + $yml = Join-Path -Path $PSScriptRoot -ChildPath '..\Automation-Pipeline-Examples\github-actions\Step.6_apply-updates.yml' + $yml = (Resolve-Path -Path $yml).Path + $content = Get-Content -LiteralPath $yml -Raw + $content | Should -Match 'Invoke-AzLocalReadinessGatedClusterUpdate' -Because 'Step.6 GH apply-updates step must call the readiness-gated apply cmdlet (which writes apply-results.json)' + $content | Should -Match 'Add-AzLocalApplyUpdatesStepSummary' -Because 'Step.6 GH Summary step must call the apply-updates step-summary cmdlet (which renders the Cluster Actions + Clusters Skipped at Readiness Gate tables)' + $content | Should -Match 'ApplyResultsJsonPath\s*=\s*''\./artifacts/apply-results\.json''' -Because 'Step.6 GH Summary must pass the apply-results.json path so the per-cluster Cluster Actions table can be rendered from it' + $content | Should -Match 'ReadinessCsvPath\s*=\s*''\./artifacts/readiness-report\.csv''' -Because 'Step.6 GH Summary must pass the readiness-report.csv path so the Clusters Skipped at Readiness Gate table can be rendered from it' + } + + It 'Azure DevOps Step.6 yml invokes Invoke-AzLocalReadinessGatedClusterUpdate (writes apply-results.json) and Add-AzLocalApplyUpdatesStepSummary (renders both per-cluster tables)' { + $yml = Join-Path -Path $PSScriptRoot -ChildPath '..\Automation-Pipeline-Examples\azure-devops\Step.6_apply-updates.yml' + $yml = (Resolve-Path -Path $yml).Path + $content = Get-Content -LiteralPath $yml -Raw + $content | Should -Match 'Invoke-AzLocalReadinessGatedClusterUpdate' -Because 'ADO Step.6 apply-updates task must call the readiness-gated apply cmdlet' + $content | Should -Match 'Add-AzLocalApplyUpdatesStepSummary' -Because 'ADO Step.6 Generate Summary task must call the apply-updates step-summary cmdlet' + $content | Should -Match 'ApplyResultsJsonPath\s*=\s*"\$\(Build\.ArtifactStagingDirectory\)/apply-results\.json"' -Because 'ADO Step.6 Summary must pass apply-results.json so the Cluster Actions table can be rendered' + $content | Should -Match 'ReadinessCsvPath\s*=\s*"\$\(Build\.ArtifactStagingDirectory\)/readiness-report\.csv"' -Because 'ADO Step.6 Summary must pass readiness-report.csv so the Clusters Skipped at Readiness Gate table can be rendered' + } + + It 'GitHub Actions Step.6 yml invokes the schedule-resolver, readiness-gate, no-ready, and ITSM cmdlets in their respective steps' { $yml = Join-Path -Path $PSScriptRoot -ChildPath '..\Automation-Pipeline-Examples\github-actions\Step.6_apply-updates.yml' $yml = (Resolve-Path -Path $yml).Path $content = Get-Content -LiteralPath $yml -Raw - $content | Should -Match 'apply-results\.json' -Because 'Step.6 apply-updates step must persist apply-results.json so Summary can render Cluster Actions table' - $content | Should -Match '### Cluster Actions' -Because 'Step.6 Summary must render the per-cluster Cluster Actions table' - $content | Should -Match '### Clusters Skipped at Readiness Gate' -Because 'Step.6 Summary must render the per-cluster Clusters Skipped at Readiness Gate table' + $content | Should -Match 'Resolve-AzLocalPipelineUpdateRing' -Because 'Step.6 GH resolve-ring step must call Resolve-AzLocalPipelineUpdateRing (replaces the ~80-line inline resolver script)' + $content | Should -Match 'Export-AzLocalClusterReadinessGateReport' -Because 'Step.6 GH readiness step must call Export-AzLocalClusterReadinessGateReport (replaces the ~80-line inline readiness gate script)' + $content | Should -Match 'Add-AzLocalNoReadyClustersStepSummary' -Because 'Step.6 GH no-clusters-ready job must call Add-AzLocalNoReadyClustersStepSummary (replaces the ~25-line inline summary script)' + $content | Should -Match 'Invoke-AzLocalItsmTicketingFromArtifact' -Because 'Step.6 GH Raise ITSM tickets step must call Invoke-AzLocalItsmTicketingFromArtifact (replaces the ~45-line inline ITSM script)' } - It 'Azure DevOps Step.6 yml contains both per-cluster table headers and apply-results.json persist' { + It 'Azure DevOps Step.6 yml invokes the schedule-resolver, readiness-gate, no-ready, and ITSM cmdlets in their respective tasks' { $yml = Join-Path -Path $PSScriptRoot -ChildPath '..\Automation-Pipeline-Examples\azure-devops\Step.6_apply-updates.yml' $yml = (Resolve-Path -Path $yml).Path $content = Get-Content -LiteralPath $yml -Raw - $content | Should -Match 'apply-results\.json' -Because 'ADO Step.6 apply-updates step must persist apply-results.json' - $content | Should -Match '### Cluster Actions' -Because 'ADO Step.6 Generate Summary must render the Cluster Actions table' - $content | Should -Match '### Clusters Skipped at Readiness Gate' -Because 'ADO Step.6 Generate Summary must render the Clusters Skipped at Readiness Gate table' + $content | Should -Match 'Resolve-AzLocalPipelineUpdateRing' + $content | Should -Match 'Export-AzLocalClusterReadinessGateReport' + $content | Should -Match 'Add-AzLocalNoReadyClustersStepSummary' + $content | Should -Match 'Invoke-AzLocalItsmTicketingFromArtifact' + } + } + + Context 'v0.8.5 Step.6 manual schedule-file input' { + # v0.8.5: Step.6 (GH + ADO) gains two manual-run inputs that let an + # operator trigger the apply-updates pipeline by hand BUT have it + # resolve UpdateRing + AllowedUpdateVersions from apply-updates-schedule.yml + # exactly as a scheduled run would (use_schedule_file / useScheduleFile), + # optionally for a future UTC date (resolve_for_date_utc / resolveForDateUtc) + # to preview a future cycleWeek/dayOfWeek. The pre-existing manual ring-name + # path must still work verbatim when the new input is false (back-compat), + # and the resolver must throw a helpful error when BOTH paths are empty. + BeforeAll { + $script:S6Gh = (Resolve-Path (Join-Path -Path $PSScriptRoot -ChildPath '..\Automation-Pipeline-Examples\github-actions\Step.6_apply-updates.yml')).Path + $script:S6Ado = (Resolve-Path (Join-Path -Path $PSScriptRoot -ChildPath '..\Automation-Pipeline-Examples\azure-devops\Step.6_apply-updates.yml')).Path + $script:S6GhContent = Get-Content -LiteralPath $script:S6Gh -Raw + $script:S6AdoContent = Get-Content -LiteralPath $script:S6Ado -Raw + } + + It 'GH Step.6 declares the use_schedule_file workflow_dispatch input (boolean choice)' { + $script:S6GhContent | Should -Match '(?m)^\s*use_schedule_file:\s*$' -Because 'v0.8.5 GH Step.6 must declare the new use_schedule_file input under workflow_dispatch.inputs' + $script:S6GhContent | Should -Match "use_schedule_file:[\s\S]{0,400}?type:\s*choice[\s\S]{0,200}?-\s*'false'[\s\S]{0,100}?-\s*'true'" -Because 'use_schedule_file must be a choice between false and true' + } + + It 'GH Step.6 declares the resolve_for_date_utc workflow_dispatch input' { + $script:S6GhContent | Should -Match '(?m)^\s*resolve_for_date_utc:\s*$' -Because 'v0.8.5 GH Step.6 must declare the new resolve_for_date_utc input' + $script:S6GhContent | Should -Match "resolve_for_date_utc:[\s\S]{0,400}?default:\s*''" -Because 'resolve_for_date_utc must default to empty (today UTC)' + } + + It 'GH Step.6 update_ring is NO LONGER required (v0.8.5 drops required:true so use_schedule_file=true can stand alone)' { + # match the update_ring block specifically, capturing through the next blank line / next input + if ($script:S6GhContent -match "update_ring:\s*[\s\S]{0,800}?(?:\n\s*\n|\n \w)") { + $block = $Matches[0] + $block | Should -Not -Match 'required:\s*true' -Because 'update_ring must NOT have required:true in v0.8.5 - use_schedule_file=true is an alternative path' + } else { + throw 'Could not locate update_ring block in GH Step.6' + } + } + + It 'GH Step.6 resolver step exposes USE_SCHEDULE_FILE and RESOLVE_FOR_DATE_UTC env vars' { + $script:S6GhContent | Should -Match 'USE_SCHEDULE_FILE:\s*\$\{\{\s*github\.event\.inputs\.use_schedule_file\s*\}\}' + $script:S6GhContent | Should -Match 'RESOLVE_FOR_DATE_UTC:\s*\$\{\{\s*github\.event\.inputs\.resolve_for_date_utc\s*\}\}' + } + + It 'GH Step.6 resolver step invokes Resolve-AzLocalPipelineUpdateRing (v0.8.5 thin-YAML)' { + # The ~80-line inline resolver block was replaced by a single + # Resolve-AzLocalPipelineUpdateRing call. The cmdlet preserves + # all prior behaviours byte-for-byte (back-compat manual ring, + # schedule-file resolve, future-date preview via -ResolveForDateUtc, + # hard-throw on missing schedule, per-host notice/warning when + # no row matches today). Those behaviours are asserted in the + # cmdlet-level Pester suite, not here. + $script:S6GhContent | Should -Match 'Resolve-AzLocalPipelineUpdateRing' -Because 'Step.6 GH resolver step must call the v0.8.5 thin-YAML cmdlet' + } + + It 'ADO Step.6 declares the useScheduleFile boolean parameter' { + $script:S6AdoContent | Should -Match '(?m)^\s*-\s*name:\s*useScheduleFile' -Because 'v0.8.5 ADO Step.6 must declare the new useScheduleFile parameter' + $script:S6AdoContent | Should -Match "useScheduleFile[\s\S]{0,400}?type:\s*boolean[\s\S]{0,200}?default:\s*false" -Because 'useScheduleFile must be a boolean defaulting to false' + } + + It 'ADO Step.6 declares the resolveForDateUtc string parameter' { + $script:S6AdoContent | Should -Match '(?m)^\s*-\s*name:\s*resolveForDateUtc' + $script:S6AdoContent | Should -Match "resolveForDateUtc[\s\S]{0,400}?default:\s*''" + } + + It 'ADO Step.6 resolveRing task exposes USE_SCHEDULE_FILE and RESOLVE_FOR_DATE_UTC env vars' { + $script:S6AdoContent | Should -Match 'USE_SCHEDULE_FILE:\s*\$\{\{\s*parameters\.useScheduleFile\s*\}\}' + $script:S6AdoContent | Should -Match 'RESOLVE_FOR_DATE_UTC:\s*\$\{\{\s*parameters\.resolveForDateUtc\s*\}\}' + } + + It 'ADO Step.6 resolveRing task invokes Resolve-AzLocalPipelineUpdateRing (v0.8.5 thin-YAML)' { + $script:S6AdoContent | Should -Match 'Resolve-AzLocalPipelineUpdateRing' -Because 'ADO Step.6 resolveRing task must call the v0.8.5 thin-YAML cmdlet' } } @@ -6044,7 +6210,13 @@ Describe 'Function: Copy-AzLocalPipelineExample' { # The function reads from (Get-Module AzLocal.UpdateManagement).ModuleBase # which during test runs resolves to the repo module root, so the real # Automation-Pipeline-Examples/ folder under the repo is the test source. - $script:cpDestRoot = Join-Path $env:TEMP "azlocal-cpe-$([guid]::NewGuid().Guid.Substring(0,8))" + # NOTE: resolve $env:TEMP to its canonical long-form path so the test + # comparisons below survive runners where $env:TEMP returns an 8.3 + # short-name (e.g. GitHub-hosted: C:\Users\RUNNER~1\AppData\Local\Temp). + # Copy-AzLocalPipelineExample returns DirectoryInfo.FullName in long form + # so without this normalisation the string-equality assertions fail in CI. + $tempLong = (Get-Item -LiteralPath $env:TEMP).FullName + $script:cpDestRoot = Join-Path $tempLong "azlocal-cpe-$([guid]::NewGuid().Guid.Substring(0,8))" New-Item -Path $script:cpDestRoot -ItemType Directory -Force | Out-Null } @@ -6345,7 +6517,11 @@ Describe 'Function: Copy-AzLocalPipelineExample' { Describe 'Function: Copy-AzLocalItsmSample' { BeforeAll { - $script:itsmDestRoot = Join-Path $env:TEMP "azlocal-itsm-$([guid]::NewGuid().Guid.Substring(0,8))" + # See Copy-AzLocalPipelineExample BeforeAll: normalise $env:TEMP to its + # canonical long-form path so the FullName string-equality assertions + # survive runners where $env:TEMP returns an 8.3 short name (CI). + $tempLong = (Get-Item -LiteralPath $env:TEMP).FullName + $script:itsmDestRoot = Join-Path $tempLong "azlocal-itsm-$([guid]::NewGuid().Guid.Substring(0,8))" New-Item -Path $script:itsmDestRoot -ItemType Directory -Force | Out-Null } @@ -7474,8 +7650,8 @@ schedule: $out = Join-Path $calDir 'reco-cal.md' Test-AzLocalApplyUpdatesScheduleCoverage -View Recommend -PipelineYamlPath (Join-Path $calDir 'github-actions') -SchedulePath $schedule -ExportPath $out 6>$null | Out-Null $md = Get-Content -Path $out -Raw - $md | Should -Match 'Cycle calendar - next 14 day\(s\) \(one full 2-week cycle\)' - $md | Should -Match '\| Date \(UTC\) \| Day \| CycleWeek \| Eligible rings \| AllowedUpdateVersions \|' + $md | Should -Match 'Cycle calendar - next 14 day\(s\) \(cycle length: 2 week\(s\)\)' + $md | Should -Match '\| Date \(UTC\) \| Day \| CycleWeek \| Eligible rings \|( Clusters in ring\(s\) \|)? AllowedUpdateVersions \|' # Should have 14 data rows $rowCount = ([regex]::Matches($md, '(?m)^\|\s*\d{4}-\d{2}-\d{2}\s*\|')).Count $rowCount | Should -Be 14 @@ -7835,55 +8011,6 @@ Describe 'v0.7.66 Artifact download names carry a UTC timestamp suffix' { } } -Describe 'v0.7.66 Fleet Update Status summary uses status emojis and groups failures first' { - # Guards the v0.7.66 UX refresh of Step.8_fleet-update-status.yml summary blocks (file was Step.7 prior to v0.7.90 renumber) - # on both GH and ADO. The summary now uses 'red cross / green tick' emojis - # instead of '[ok]/[fail]/...' bracket markers, and the per-cluster JUnit - # block orders failed clusters first. - - BeforeAll { - $script:examplesRoot = (Resolve-Path -Path (Join-Path $PSScriptRoot '..\Automation-Pipeline-Examples')).Path - $script:fleetStatusFiles = @( - Join-Path $script:examplesRoot 'github-actions\Step.8_fleet-update-status.yml' - Join-Path $script:examplesRoot 'azure-devops\Step.8_fleet-update-status.yml' - ) - } - - It "Both Step.8_fleet-update-status.yml files contain success and failure status emojis" { - foreach ($yml in $script:fleetStatusFiles) { - # Must read explicitly as UTF-8; the YAML has no BOM, and PS 5.1 - # Get-Content -Raw without -Encoding defaults to Default (cp1252), - # which mangles multi-byte glyphs like U+2705 into 3 separate chars. - $content = [System.IO.File]::ReadAllText($yml, [System.Text.UTF8Encoding]::new($false)) - # PowerShell here strings to dodge Unicode source-file confusion in this test: - $tick = [string]::new([char[]]@(0x2705)) # white-heavy-check-mark - $cross = [string]::new([char[]]@(0x274C)) # cross-mark - ($content -match [regex]::Escape($tick)) | Should -BeTrue -Because "$(Split-Path -Leaf $yml) must use the U+2705 success emoji in its summary" - ($content -match [regex]::Escape($cross)) | Should -BeTrue -Because "$(Split-Path -Leaf $yml) must use the U+274C failure emoji in its summary" - # Legacy bracket markers must be gone: - ($content -match '\[ok\]') | Should -BeFalse -Because "$(Split-Path -Leaf $yml) must no longer use the '[ok]' bracket marker" - ($content -match '\[fail\]') | Should -BeFalse -Because "$(Split-Path -Leaf $yml) must no longer use the '[fail]' bracket marker" - } - } - - It "Both Step.8_fleet-update-status.yml files render a UTC timestamp in the summary heading" { - foreach ($yml in $script:fleetStatusFiles) { - $content = Get-Content -LiteralPath $yml -Raw - ($content -match 'Fleet Update Status Summary\s*_\(generated \$generatedUtc\)_') | Should -BeTrue -Because "$(Split-Path -Leaf $yml) must include the generated UTC timestamp in the H2 heading" - ($content -match 'generatedUtc\s*=\s*\(Get-Date\)\.ToUniversalTime\(\)') | Should -BeTrue -Because "$(Split-Path -Leaf $yml) must compute generatedUtc with ToUniversalTime()" - } - } - - It "Both Step.8_fleet-update-status.yml files bucket failed clusters ahead of passed clusters before emitting the JUnit table" { - foreach ($yml in $script:fleetStatusFiles) { - $content = Get-Content -LiteralPath $yml -Raw - ($content -match '\$failedClusters') | Should -BeTrue -Because "$(Split-Path -Leaf $yml) must build a `$failedClusters bucket" - ($content -match '\$passedClusters') | Should -BeTrue -Because "$(Split-Path -Leaf $yml) must build a `$passedClusters bucket" - ($content -match '\$orderedClusters\s*=\s*@\(\$failedClusters') | Should -BeTrue -Because "$(Split-Path -Leaf $yml) must concatenate failed-first, then passed, into `$orderedClusters" - } - } -} - Describe 'v0.7.66 Pipeline update_ring inputs document multi-value and wildcard support' { BeforeAll { $script:examplesRoot = (Resolve-Path -Path (Join-Path $PSScriptRoot '..\Automation-Pipeline-Examples')).Path @@ -7915,38 +8042,24 @@ Describe 'v0.7.66 Pipeline update_ring inputs document multi-value and wildcard #region v0.7.67 CI/CD parity + doc-drift regression suite Describe 'v0.7.67 schedule-audit zero-row JUnit parity' { - # v0.7.67 regression guard: Step.3_apply-updates-schedule-audit.yml previously - # behaved differently across CI platforms when the fleet had no tagged - # clusters. Azure DevOps emitted a passing testcase ("No tagged clusters - # found - nothing to audit") so the run rendered as passed (1/1) in the - # Tests tab. GitHub Actions wrote an EMPTY , which - # dorny/test-reporter surfaced as "no tests found" - indistinguishable - # from a broken reporter step. v0.7.67 brings the GH workflow into parity - # by writing the same passing testcase for the zero-row case. This test - # guards both files so neither side regresses. + # v0.7.67 regression guard: when the fleet has no tagged clusters, the + # schedule-audit cmdlet must emit a passing zero-row testcase named + # 'No tagged clusters found - nothing to audit' so dorny/test-reporter + # (GH) and Azure DevOps Test Results render a clean pass instead of + # 'no tests found'. v0.8.5 thin-YAML refactor moved the wiring from + # inline YAML into the Public cmdlet Export-AzLocalApplyUpdatesScheduleAudit; + # the cmdlet calls New-AzLocalPipelineJUnitXml with the suites array that + # carries the empty-placeholder testcase. Both CI platforms now share the + # same cmdlet so a single assertion against the cmdlet guards both sides. BeforeAll { - $script:examplesRoot = (Resolve-Path -Path (Join-Path $PSScriptRoot '..\Automation-Pipeline-Examples')).Path + $script:cmdletPath = (Resolve-Path -Path (Join-Path $PSScriptRoot '..\Public\Export-AzLocalApplyUpdatesScheduleAudit.ps1')).Path } - It 'GitHub Actions schedule-audit YAML emits the zero-row testcase' { - $yml = Join-Path $script:examplesRoot 'github-actions\Step.3_apply-updates-schedule-audit.yml' - Test-Path -LiteralPath $yml | Should -BeTrue -Because "the GH schedule-audit YAML should exist at $yml" - $content = Get-Content -LiteralPath $yml -Raw - # v0.7.70: zero-row JUnit emission was refactored into a Write-Suite helper - # with -EmptyPlaceholderName. The semantic guarantee (zero-row testcase is - # emitted with this exact name) is preserved; the literal string - # is now built at runtime from the helper template. - $content | Should -Match "-EmptyPlaceholderName 'No tagged clusters found - nothing to audit'" -Because 'v0.7.67/v0.7.70 wire the zero-row JUnit testcase via Write-Suite -EmptyPlaceholderName in the GH schedule-audit YAML' - $content | Should -Match 'classname=`"ScheduleCoverage`" name=`"\{0\}`"' -Because 'the Write-Suite helper must build the testcase line with classname="ScheduleCoverage" and a {0} placeholder for the name' - } - - It 'Azure DevOps schedule-audit YAML still emits the zero-row testcase' { - $yml = Join-Path $script:examplesRoot 'azure-devops\Step.3_apply-updates-schedule-audit.yml' - Test-Path -LiteralPath $yml | Should -BeTrue -Because "the ADO schedule-audit YAML should exist at $yml" - $content = Get-Content -LiteralPath $yml -Raw - # See GH note above - same refactor applies. - $content | Should -Match "-EmptyPlaceholderName 'No tagged clusters found - nothing to audit'" -Because 'the ADO schedule-audit YAML must wire the zero-row JUnit testcase via Write-Suite -EmptyPlaceholderName since v0.7.0; v0.7.70 must not regress it' - $content | Should -Match 'classname=`"ScheduleCoverage`" name=`"\{0\}`"' -Because 'the Write-Suite helper must build the testcase line with classname="ScheduleCoverage" and a {0} placeholder for the name' + It 'Schedule-audit cmdlet wires the zero-row testcase with the canonical name' { + Test-Path -LiteralPath $script:cmdletPath | Should -BeTrue -Because "the Step.3 cmdlet should exist at $script:cmdletPath" + $content = Get-Content -LiteralPath $script:cmdletPath -Raw + $content | Should -Match "Name\s*=\s*'No tagged clusters found - nothing to audit'" -Because 'v0.8.5 must keep the zero-row placeholder testcase Name so both GH and ADO render the same passing case when no clusters carry UpdateStartWindow tags' + $content | Should -Match "ClassName\s*=\s*'ScheduleCoverage'" -Because 'the placeholder testcase must declare classname=ScheduleCoverage so test-reporter groups it under the Schedule (ring diff) suite' } } @@ -7982,37 +8095,25 @@ Describe 'v0.7.67 doc drift - old UpdateRing regex' { } Describe 'v0.7.67 schedule-audit summary - cron fixes first when issues exist' { - # Phase 4.3: when Uncovered / PartiallyCovered / MalformedTag / UnparseableCron > 0, - # the schedule-audit pipelines must surface the recommended cron block ABOVE the - # detail table so operators can act without scrolling. Guardrail asserts that both - # YAMLs carry the conditional structure (hasIssues + 'Action required' header). - $moduleRoot = Split-Path $PSScriptRoot -Parent - $yamlCases = @( - @{ - Platform = 'github-actions' - YamlPath = (Join-Path $moduleRoot 'Automation-Pipeline-Examples\github-actions\Step.3_apply-updates-schedule-audit.yml') - } - @{ - Platform = 'azure-devops' - YamlPath = (Join-Path $moduleRoot 'Automation-Pipeline-Examples\azure-devops\Step.3_apply-updates-schedule-audit.yml') - } - ) - - It '[] schedule-audit YAML emits recommendation before audit detail when issues exist' -ForEach $yamlCases { - Test-Path $YamlPath | Should -BeTrue -Because "expected schedule-audit YAML at $YamlPath" - $content = Get-Content -Path $YamlPath -Raw - $content | Should -Match '\$hasIssues\s*=\s*\(\(\[int\]\$uncovered\)' -Because 'Phase 4.3 / v0.7.70 conditional must compute $hasIssues from the issue counts.' - # v0.7.70: the 'Action required - ...' header literals moved into the - # -View Recommend cmdlet output ($reco), so the YAML no longer contains - # them verbatim. The structural guarantee (recommendation BEFORE detail - # when $hasIssues) is preserved by the 'if ($hasIssues -and $reco) { $md = $reco + $md }' - # block which prepends $reco to the summary markdown. - $content | Should -Match 'if\s*\(\s*\$hasIssues\s+-and\s+\$reco\s*\)' -Because 'v0.7.70 must keep the hasIssues+reco conditional so the Recommend block is prepended above the audit detail table.' - $idxRecoBlock = $content.IndexOf('if ($hasIssues -and $reco)') - $idxAuditDetail = $content.IndexOf('### Audit Detail') - $idxRecoBlock | Should -BeGreaterThan -1 - $idxAuditDetail | Should -BeGreaterThan -1 - $idxRecoBlock | Should -BeLessThan $idxAuditDetail -Because 'When issues exist, the conditional that prepends the Recommend output must appear earlier in the summary script than the detail table emission.' + # Phase 4.3 / v0.7.70 guarantee: when Uncovered / PartiallyCovered / + # MalformedTag / UnparseableCron > 0, the schedule-audit pipelines must + # surface the recommended cron block ABOVE the detail table so operators + # can act without scrolling. v0.8.5 thin-YAML refactor moved the + # hasIssues + recommendation-prepend logic into the Public cmdlet + # Export-AzLocalApplyUpdatesScheduleAudit. This guardrail asserts the + # cmdlet computes $hasIssues, conditionally prepends the Recommend output, + # and that the prepend block appears BEFORE the audit detail emission. + It 'Schedule-audit cmdlet prepends the recommendation above audit detail when issues exist' { + $cmdletPath = (Resolve-Path -Path (Join-Path $PSScriptRoot '..\Public\Export-AzLocalApplyUpdatesScheduleAudit.ps1')).Path + Test-Path -LiteralPath $cmdletPath | Should -BeTrue -Because "the Step.3 cmdlet should exist at $cmdletPath" + $content = Get-Content -LiteralPath $cmdletPath -Raw + $content | Should -Match '\$hasIssues\s*=\s*\(\$uncovered\.Count' -Because 'v0.8.5 must compute $hasIssues from the issue counts so the recommendation gate stays driven by uncovered/partial/malformed/unparseable totals.' + $content | Should -Match 'if\s*\(\s*\$hasIssues\s+-and\s+\$recoContent\s*\)' -Because 'v0.8.5 must keep the hasIssues+recoContent conditional so the Recommend block is prepended above the audit detail table.' + $idxRecoBlock = $content.IndexOf('if ($hasIssues -and $recoContent)') + $idxAuditDetail = $content.IndexOf('### Audit Detail') + $idxRecoBlock | Should -BeGreaterThan -1 + $idxAuditDetail | Should -BeGreaterThan -1 + $idxRecoBlock | Should -BeLessThan $idxAuditDetail -Because 'The conditional that prepends the Recommend output must appear earlier in the cmdlet body than the detail table emission so the rendered markdown puts the cron fix at the top.' } } @@ -9421,22 +9522,19 @@ schedule: Describe 'Step.3 pipeline scaffolds - v0.7.70 dual JUnit testsuite emission' { - Context 'AS7 - YAML scaffolds reference both Schedule and Cron suite names' { + Context 'AS7 - Step.3 cmdlet references both Schedule and Cron suite names' { - It 'Step.3 GitHub Actions YAML contains both "Schedule (ring diff)" and "Cron coverage" suite names' { - $ghYaml = Join-Path $PSScriptRoot '..\Automation-Pipeline-Examples\github-actions\Step.3_apply-updates-schedule-audit.yml' - Test-Path $ghYaml | Should -BeTrue - $raw = Get-Content -Raw -LiteralPath $ghYaml - $raw | Should -Match 'Schedule \(ring diff\)' - $raw | Should -Match 'Cron coverage' - } - - It 'Step.3 Azure DevOps YAML contains both "Schedule (ring diff)" and "Cron coverage" suite names' { - $adoYaml = Join-Path $PSScriptRoot '..\Automation-Pipeline-Examples\azure-devops\Step.3_apply-updates-schedule-audit.yml' - Test-Path $adoYaml | Should -BeTrue - $raw = Get-Content -Raw -LiteralPath $adoYaml - $raw | Should -Match 'Schedule \(ring diff\)' - $raw | Should -Match 'Cron coverage' + # v0.8.5 thin-YAML refactor moved the dual emission from + # the inline YAML into the Public cmdlet + # Export-AzLocalApplyUpdatesScheduleAudit, which builds a $Suites array + # and hands it to New-AzLocalPipelineJUnitXml. The cmdlet is the single + # source for both GH and ADO platforms so one assertion guards both. + It 'Step.3 cmdlet declares both "Schedule (ring diff)" and "Cron coverage" suite names' { + $cmdletPath = (Resolve-Path -Path (Join-Path $PSScriptRoot '..\Public\Export-AzLocalApplyUpdatesScheduleAudit.ps1')).Path + Test-Path -LiteralPath $cmdletPath | Should -BeTrue -Because "the Step.3 cmdlet should exist at $cmdletPath" + $raw = Get-Content -Raw -LiteralPath $cmdletPath + $raw | Should -Match 'Schedule \(ring diff\)' -Because 'the cmdlet must emit a Schedule (ring diff) JUnit suite for the ring-file two-way diff results' + $raw | Should -Match 'Cron coverage' -Because 'the cmdlet must emit a Cron coverage JUnit suite for the (UpdateRing, UpdateStartWindow) coverage results' } } } @@ -9451,18 +9549,20 @@ Describe 'Step.3 pipeline scaffolds - v0.7.70 dual JUnit testsuite emission' { Describe 'Step.3 pipeline scaffolds - v0.8.2 Allow-list section shape' { - Context 'YAML emits the trimmed Allow-list subsection (no verbose Tip / 3-row example block)' { + Context 'Step.3 cmdlet emits the trimmed Allow-list subsection (no verbose Tip / 3-row example block)' { - It '[] emits the reframed Optional pin-a-ring subsection and drops the verbose Tip block' -ForEach @( - @{ Platform='GitHub'; YamlPath=(Join-Path $PSScriptRoot '..\Automation-Pipeline-Examples\github-actions\Step.3_apply-updates-schedule-audit.yml') } - @{ Platform='ADO'; YamlPath=(Join-Path $PSScriptRoot '..\Automation-Pipeline-Examples\azure-devops\Step.3_apply-updates-schedule-audit.yml') } - ) { - Test-Path $YamlPath | Should -BeTrue - $raw = Get-Content -Raw -LiteralPath $YamlPath + # v0.8.5 thin-YAML refactor moved the allow-list markdown emission + # from inline YAML into the Public cmdlet. Both GH and ADO ymls now + # call the same cmdlet, so the section-shape guarantee is asserted + # once against the cmdlet source. + It 'Step.3 cmdlet emits the reframed Optional pin-a-ring subsection and drops the verbose Tip block' { + $cmdletPath = (Resolve-Path -Path (Join-Path $PSScriptRoot '..\Public\Export-AzLocalApplyUpdatesScheduleAudit.ps1')).Path + Test-Path -LiteralPath $cmdletPath | Should -BeTrue -Because "the Step.3 cmdlet should exist at $cmdletPath" + $raw = Get-Content -Raw -LiteralPath $cmdletPath # v0.8.3 shape: heading is explicitly scoped to the OPTIONAL pin # use case (the v0.8.2 heading sounded like a general 'fix this # file' instruction even though it only covers allowedUpdateVersions). - $raw | Should -Match '### Optional - pin a ring to a specific update in ``\$schedulePath``' + $raw | Should -Match '### Optional - pin a ring to a specific update in' $raw | Should -Match 'inherit the top-level allow-list' $raw | Should -Match 'install the latest Ready update as soon as it is available' # v0.8.3: explicitly disclaim that this is NOT the cron-coverage or ring-diff fix. @@ -10133,8 +10233,8 @@ Describe 'Function: Get-AzLocalFleetHealthOverview - v0.7.70 (ARG-first fleet he $cmd.CommandType | Should -Be 'Function' } - It 'BS7: Module exports exactly 38 functions (was 37 in v0.7.86 before New-AzLocalFleetConnectivityStatusSummary was added in v0.7.87)' { - (Get-Module AzLocal.UpdateManagement).ExportedFunctions.Count | Should -Be 38 + It 'BS7: Module exports exactly 55 functions (was 49 after Step.9 thin-YAML port; Step.6 thin-YAML port adds 6 apply-updates pipeline cmdlets)' { + (Get-Module AzLocal.UpdateManagement).ExportedFunctions.Count | Should -Be 55 } } @@ -10295,57 +10395,6 @@ Describe 'Function: Get-AzLocalUpdateRunFailures - v0.7.70 fleet-scale failure-d } } -Describe 'v0.7.70 Step.6 "📜 Update Run History and Error Details" JUnit + markdown wiring' { - - BeforeAll { - $script:examplesRoot = (Resolve-Path -Path (Join-Path $PSScriptRoot '..\Automation-Pipeline-Examples')).Path - $script:step6Files = @( - Join-Path $script:examplesRoot 'github-actions\Step.8_fleet-update-status.yml' - Join-Path $script:examplesRoot 'azure-devops\Step.8_fleet-update-status.yml' - ) - } - - It 'BS13: Both Step.6 YAML files emit the new "📜 Update Run History and Error Details" testsuite name' { - foreach ($yml in $script:step6Files) { - $content = [System.IO.File]::ReadAllText($yml, [System.Text.UTF8Encoding]::new($false)) - # U+1F4DC SCROLL emoji - $scroll = [string]::new([System.Text.Encoding]::UTF32.GetChars([System.BitConverter]::GetBytes(0x1F4DC))) - ($content.Contains($scroll + ' Update Run History and Error Details')) | Should -BeTrue -Because "$(Split-Path -Leaf $yml) must declare the new testsuite name with the scroll emoji" - } - } - - It 'BS14: Both Step.6 YAML files call Get-AzLocalUpdateRunFailures -State Failed -OnlyUnresolved' { - foreach ($yml in $script:step6Files) { - $content = Get-Content -LiteralPath $yml -Raw - ($content -match 'Get-AzLocalUpdateRunFailures\s+-State\s+Failed\s+-OnlyUnresolved') | Should -BeTrue -Because "$(Split-Path -Leaf $yml) must drive the new section from the cmdlet with -State Failed -OnlyUnresolved" - } - } - - It 'BS15: Both Step.6 YAML files export update-run-history.csv and update-run-history.json' { - foreach ($yml in $script:step6Files) { - $content = Get-Content -LiteralPath $yml -Raw - $content | Should -Match 'update-run-history\.csv' - $content | Should -Match 'update-run-history\.json' - } - } - - It 'BS16: Both Step.6 YAML files render the markdown table heading and use the 9-column failure-detail layout' { - foreach ($yml in $script:step6Files) { - $content = [System.IO.File]::ReadAllText($yml, [System.Text.UTF8Encoding]::new($false)) - $content | Should -Match 'Cluster Name \| Update Name \| Update State \| Status \| Current Step \| Verbose Error Details \| Duration \| Time Started \| Last Updated' - } - } - - It 'BS17: Both Step.6 YAML files surface a runHistoryCount output for downstream gating' { - foreach ($yml in $script:step6Files) { - $content = Get-Content -LiteralPath $yml -Raw - # GH-actions uses RUN_HISTORY_COUNT; ADO uses runHistoryCount. - $hasOutput = ($content -match 'RUN_HISTORY_COUNT=') -or ($content -match 'variable=runHistoryCount;') - $hasOutput | Should -BeTrue -Because "$(Split-Path -Leaf $yml) must export runHistoryCount so downstream steps can render the markdown table" - } - } -} - #endregion v0.7.70 (Workstream A: Step.3 Audit UX refresh + Workstream B: Step.7 Fleet Health Overview) #endregion v0.7.69 Apply-Updates Schedule (ring-aware) @@ -10565,38 +10614,6 @@ Describe 'Function: Get-AzLocalLatestSolutionVersion (v0.7.70 Phase E)' { } } -Describe 'Pipeline contract: Step.6 SupportStatus anchor (v0.7.70 Phase E)' { - - BeforeAll { - $script:repoRoot = Split-Path -Path $PSScriptRoot -Parent - $script:step6Files = @( - Join-Path -Path $script:repoRoot -ChildPath 'Automation-Pipeline-Examples/github-actions/Step.8_fleet-update-status.yml' - Join-Path -Path $script:repoRoot -ChildPath 'Automation-Pipeline-Examples/azure-devops/Step.8_fleet-update-status.yml' - ) - } - - It 'Both Step.6 YAML files invoke Get-AzLocalLatestSolutionVersion' { - foreach ($yml in $script:step6Files) { - $content = Get-Content -LiteralPath $yml -Raw - $content | Should -Match 'Get-AzLocalLatestSolutionVersion' -Because "$(Split-Path -Leaf $yml) must call the Microsoft-manifest probe to anchor the SupportStatus window" - } - } - - It 'Both Step.6 YAML files surface a supportSource property in JUnit' { - foreach ($yml in $script:step6Files) { - $content = Get-Content -LiteralPath $yml -Raw - $content | Should -Match 'supportSource' -Because "$(Split-Path -Leaf $yml) must emit the SupportSource flag so consumers can tell manifest-anchored from fleet-observed windows apart" - } - } - - It 'Both Step.6 YAML files wrap the manifest probe in try/catch with a fleet-observed fallback' { - foreach ($yml in $script:step6Files) { - $content = Get-Content -LiteralPath $yml -Raw - $content | Should -Match 'fleet-observed' -Because "$(Split-Path -Leaf $yml) must fall back to fleet-observed top-6 when the manifest is unreachable" - } - } -} - Describe 'Smoke test: Step.4 pipeline cmdlet migration (v0.7.79)' { # v0.7.79: Step.4 inline KQL + Invoke-ArgQuery/Extract-ArgProjectedColumns replaced by # Get-AzLocalFleetConnectivityStatus module cmdlet. This smoke test verifies that: @@ -12169,3 +12186,4036 @@ Describe 'Pipeline output helpers: Write-AzLocalPipelineNotice / Write-AzLocalPi #endregion v0.8.2: Pipeline host abstraction +#region v0.8.5: Add-AzLocalPipelineVersionBanner (thin-YAML foundation) + +Describe 'Thin-YAML foundation: Add-AzLocalPipelineVersionBanner' { + + BeforeEach { + $script:_savedGhActions = $env:GITHUB_ACTIONS + $script:_savedTfBuild = $env:TF_BUILD + $script:_savedGhOutput = $env:GITHUB_OUTPUT + $script:_savedGhSummary = $env:GITHUB_STEP_SUMMARY + $script:_savedAdoStage = $env:BUILD_ARTIFACTSTAGINGDIRECTORY + Remove-Item Env:\GITHUB_ACTIONS -ErrorAction SilentlyContinue + Remove-Item Env:\TF_BUILD -ErrorAction SilentlyContinue + Remove-Item Env:\GITHUB_OUTPUT -ErrorAction SilentlyContinue + Remove-Item Env:\GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue + Remove-Item Env:\BUILD_ARTIFACTSTAGINGDIRECTORY -ErrorAction SilentlyContinue + + $script:_ghOutputFile = Join-Path -Path $env:TEMP -ChildPath ("vb-gh-output-{0}" -f ([Guid]::NewGuid())) + $script:_ghSummaryFile = Join-Path -Path $env:TEMP -ChildPath ("vb-gh-summary-{0}.md" -f ([Guid]::NewGuid())) + $script:_adoStageDir = Join-Path -Path $env:TEMP -ChildPath ("vb-ado-stage-{0}" -f ([Guid]::NewGuid())) + New-Item -ItemType File -Path $script:_ghOutputFile -Force | Out-Null + New-Item -ItemType File -Path $script:_ghSummaryFile -Force | Out-Null + New-Item -ItemType Directory -Path $script:_adoStageDir -Force | Out-Null + } + AfterEach { + if ($null -ne $script:_savedGhActions) { $env:GITHUB_ACTIONS = $script:_savedGhActions } else { Remove-Item Env:\GITHUB_ACTIONS -ErrorAction SilentlyContinue } + if ($null -ne $script:_savedTfBuild) { $env:TF_BUILD = $script:_savedTfBuild } else { Remove-Item Env:\TF_BUILD -ErrorAction SilentlyContinue } + if ($null -ne $script:_savedGhOutput) { $env:GITHUB_OUTPUT = $script:_savedGhOutput } else { Remove-Item Env:\GITHUB_OUTPUT -ErrorAction SilentlyContinue } + if ($null -ne $script:_savedGhSummary) { $env:GITHUB_STEP_SUMMARY = $script:_savedGhSummary } else { Remove-Item Env:\GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue } + if ($null -ne $script:_savedAdoStage) { $env:BUILD_ARTIFACTSTAGINGDIRECTORY = $script:_savedAdoStage } else { Remove-Item Env:\BUILD_ARTIFACTSTAGINGDIRECTORY -ErrorAction SilentlyContinue } + Remove-Item -LiteralPath $script:_ghOutputFile,$script:_ghSummaryFile -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $script:_adoStageDir -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'throws ValidatePattern when -GeneratedAgainstVersion is not a SemVer string' { + { InModuleScope AzLocal.UpdateManagement { Add-AzLocalPipelineVersionBanner -GeneratedAgainstVersion 'not-a-version' -SkipPSGalleryQuery } } | + Should -Throw + } + + It 'in-sync verdict when installed == generated and PSGallery skipped (PassThru)' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_ghSummaryFile + $info = InModuleScope AzLocal.UpdateManagement { + $real = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version.ToString() + Add-AzLocalPipelineVersionBanner -GeneratedAgainstVersion $real -SkipPSGalleryQuery -PassThru + } + $info.Verdict | Should -Be 'in sync' + $info.PinStatus | Should -Be 'latest (fix-forward)' + $info.LatestOnPSGallery | Should -BeNullOrEmpty + $info.InstalledVersion | Should -BeOfType ([version]) + $info.GeneratedAgainstVersion | Should -BeOfType ([version]) + } + + It "PinnedVersion populated -> PinStatus says 'pinned to vX.Y.Z'" { + $info = InModuleScope AzLocal.UpdateManagement { + $real = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version.ToString() + Add-AzLocalPipelineVersionBanner -GeneratedAgainstVersion $real -PinnedVersion '0.8.5' -SkipPSGalleryQuery -PassThru + } + $info.PinStatus | Should -Be 'pinned to v0.8.5' + } + + It "PinnedVersion empty string -> PinStatus says 'latest (fix-forward)'" { + $info = InModuleScope AzLocal.UpdateManagement { + $real = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version.ToString() + Add-AzLocalPipelineVersionBanner -GeneratedAgainstVersion $real -PinnedVersion '' -SkipPSGalleryQuery -PassThru + } + $info.PinStatus | Should -Be 'latest (fix-forward)' + } + + It 'GitHub: emits installed_module_version + generated_against_version + latest_on_psgallery step outputs' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_ghSummaryFile + InModuleScope AzLocal.UpdateManagement { + $real = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version.ToString() + Add-AzLocalPipelineVersionBanner -GeneratedAgainstVersion $real -SkipPSGalleryQuery + } + $contents = Get-Content -LiteralPath $script:_ghOutputFile -Raw + $contents | Should -Match '(?m)^installed_module_version=\d+\.\d+\.\d+' + $contents | Should -Match '(?m)^generated_against_version=\d+\.\d+\.\d+' + $contents | Should -Match '(?m)^latest_on_psgallery=\s*$' + } + + It 'GitHub: appends a one-line banner row to $env:GITHUB_STEP_SUMMARY (markdown italics + pipes)' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_ghSummaryFile + InModuleScope AzLocal.UpdateManagement { + $real = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version.ToString() + Add-AzLocalPipelineVersionBanner -GeneratedAgainstVersion $real -SkipPSGalleryQuery + } + $md = Get-Content -LiteralPath $script:_ghSummaryFile -Raw + $md | Should -Match '_Pipeline YAML v\d+\.\d+\.\d+ \| Module v\d+\.\d+\.\d+ installed \(.+?\) \| PSGallery latest .+? \| .+?_' + } + + It 'YAML newer than module -> WARNING annotation on GH' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_ghSummaryFile + $captured = & { + InModuleScope AzLocal.UpdateManagement { + Add-AzLocalPipelineVersionBanner -GeneratedAgainstVersion '99.99.99' -SkipPSGalleryQuery + } + } *>&1 + ($captured -join "`n") | Should -Match '::warning title=AzLocal\.UpdateManagement is older than workflow YAML expects::' + } + + It 'YAML older than module -> NOTICE annotation on GH' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_ghSummaryFile + $captured = & { + InModuleScope AzLocal.UpdateManagement { + Add-AzLocalPipelineVersionBanner -GeneratedAgainstVersion '0.0.1' -SkipPSGalleryQuery + } + } *>&1 + ($captured -join "`n") | Should -Match '::notice title=Workflow YAML may be stale::' + } + + It 'PassThru verdict -> "YAML newer than module" when installed < generated' { + $info = InModuleScope AzLocal.UpdateManagement { + Add-AzLocalPipelineVersionBanner -GeneratedAgainstVersion '99.99.99' -SkipPSGalleryQuery -PassThru + } + $info.Verdict | Should -Match 'YAML newer than module' + } + + It 'PassThru verdict -> "YAML older than module" when installed > generated' { + $info = InModuleScope AzLocal.UpdateManagement { + Add-AzLocalPipelineVersionBanner -GeneratedAgainstVersion '0.0.1' -SkipPSGalleryQuery -PassThru + } + $info.Verdict | Should -Match 'YAML older than module' + } + + It 'Find-Module failure is swallowed (returns $null latest, no throw)' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_ghSummaryFile + $info = InModuleScope AzLocal.UpdateManagement { + Mock -ModuleName AzLocal.UpdateManagement -CommandName Find-Module -MockWith { throw 'simulated PSGallery outage' } + $real = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version.ToString() + Add-AzLocalPipelineVersionBanner -GeneratedAgainstVersion $real -PassThru + } + $info.LatestOnPSGallery | Should -BeNullOrEmpty + $info.Verdict | Should -Be 'in sync' + } + + It "newer module on PSGallery -> NOTICE annotation + verdict 'newer module available on PSGallery'" { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_ghSummaryFile + $script:_capturedInfo = $null + $captured = & { + $script:_capturedInfo = InModuleScope AzLocal.UpdateManagement { + Mock -ModuleName AzLocal.UpdateManagement -CommandName Find-Module -MockWith { + [PSCustomObject]@{ Name = 'AzLocal.UpdateManagement'; Version = [version]'99.99.99' } + } + $real = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version.ToString() + Add-AzLocalPipelineVersionBanner -GeneratedAgainstVersion $real -PassThru + } + } *>&1 + $script:_capturedInfo.Verdict | Should -Be 'newer module available on PSGallery' + $script:_capturedInfo.LatestOnPSGallery | Should -Be ([version]'99.99.99') + ($captured -join "`n") | Should -Match '::notice title=Newer AzLocal\.UpdateManagement version available on PSGallery::' + } + + It 'AzureDevOps: emits ##vso[task.setvariable...] style step outputs' { + $env:TF_BUILD = 'True' + $env:BUILD_ARTIFACTSTAGINGDIRECTORY = $script:_adoStageDir + $captured = & { + InModuleScope AzLocal.UpdateManagement { + $real = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version.ToString() + Add-AzLocalPipelineVersionBanner -GeneratedAgainstVersion $real -SkipPSGalleryQuery + } + } *>&1 + $joined = $captured -join "`n" + $joined | Should -Match '##vso\[task\.setvariable variable=installed_module_version;isOutput=false\]\d+\.\d+\.\d+' + $joined | Should -Match '##vso\[task\.setvariable variable=generated_against_version;isOutput=false\]\d+\.\d+\.\d+' + } + + It 'Local: writes [pipeline-output] lines for the three outputs' { + $captured = & { + InModuleScope AzLocal.UpdateManagement { + $real = (Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1).Version.ToString() + Add-AzLocalPipelineVersionBanner -GeneratedAgainstVersion $real -SkipPSGalleryQuery + } + } *>&1 + $joined = $captured -join "`n" + $joined | Should -Match '\[pipeline-output\] installed_module_version=\d+\.\d+\.\d+' + $joined | Should -Match '\[pipeline-output\] generated_against_version=\d+\.\d+\.\d+' + $joined | Should -Match '\[pipeline-output\] latest_on_psgallery=' + } +} + +#endregion v0.8.5: Add-AzLocalPipelineVersionBanner (thin-YAML foundation) + +#region v0.8.5: Get-AzLocalApplyUpdatesScheduleCycleCalendar + +Describe 'v0.8.5 Apply-Updates Schedule: Get-AzLocalApplyUpdatesScheduleCycleCalendar (object pipeline)' { + BeforeAll { + $script:cc_tmp = Join-Path $TestDrive 'cycle-cal-obj' + New-Item -ItemType Directory -Path $script:cc_tmp -Force | Out-Null + + # 4-week cycle anchored at ISO W1 2026; mirrors the v0.7.69 resolver + # fixture so cycle math is identical and we can assert by row. + $p = Join-Path $script:cc_tmp 'cycle4.yml' + $body = @' +schemaVersion: 1 +cycleWeeks: 4 +cycleAnchorISOWeek: 1 +cycleAnchorYear: 2026 +schedule: + - weeksInCycle: '1' + daysOfWeek: 'Mon' + rings: 'Canary' + - weeksInCycle: '2' + daysOfWeek: 'Mon' + rings: 'Ring1' + - weeksInCycle: '3,4' + daysOfWeek: 'Mon' + rings: 'Prod' + - weeksInCycle: '*' + daysOfWeek: 'Fri' + rings: 'Canary;Ring1' +'@ + Set-Content -LiteralPath $p -Value $body -Encoding utf8 + $script:cc_cfg4 = Get-AzLocalApplyUpdatesScheduleConfig -Path $p + + # Monday of ISO W1 2026 (anchor day in UTC) - same computation as the + # v0.7.69 resolver tests so the fixtures stay consistent. + $jan4 = [datetime]::new(2026, 1, 4, 0, 0, 0, [DateTimeKind]::Utc) + $script:cc_wk1Mon = $jan4.AddDays(-1 * ((($jan4.DayOfWeek.value__ + 6) % 7))) + } + + It 'Default -Days = CycleWeeks * 7 for a 4-week cycle (28 rows)' { + $r = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cc_cfg4 -StartDate $script:cc_wk1Mon + @($r).Count | Should -Be 28 + } + + It 'Each row carries the new v0.8.5 shape (CycleWeekLabel, IsCycleWrap, IsDeadDay, AllowedUpdateVersions*)' { + $r = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cc_cfg4 -StartDate $script:cc_wk1Mon -Days 1 + $row = $r[0] + $row.PSObject.Properties.Name | Should -Contain 'DateUtc' + $row.PSObject.Properties.Name | Should -Contain 'DayOfWeekName' + $row.PSObject.Properties.Name | Should -Contain 'CycleWeek' + $row.PSObject.Properties.Name | Should -Contain 'CycleWeeksTotal' + $row.PSObject.Properties.Name | Should -Contain 'CycleWeekLabel' + $row.PSObject.Properties.Name | Should -Contain 'IsCycleWrap' + $row.PSObject.Properties.Name | Should -Contain 'Rings' + $row.PSObject.Properties.Name | Should -Contain 'UpdateRingValue' + $row.PSObject.Properties.Name | Should -Contain 'AllowedUpdateVersions' + $row.PSObject.Properties.Name | Should -Contain 'AllowedUpdateVersionsValue' + $row.PSObject.Properties.Name | Should -Contain 'AllowedUpdateVersionsSource' + $row.PSObject.Properties.Name | Should -Contain 'MatchedRowCount' + $row.PSObject.Properties.Name | Should -Contain 'IsDeadDay' + $row.PSObject.Properties.Name | Should -Contain 'Reason' + } + + It 'CycleWeeksTotal equals Schedule.CycleWeeks on every row' { + $r = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cc_cfg4 -StartDate $script:cc_wk1Mon + ($r | Where-Object { $_.CycleWeeksTotal -ne 4 }).Count | Should -Be 0 + } + + It 'Day-0 row resolves to week-1 Monday Canary in the 4-week fixture' { + $r = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cc_cfg4 -StartDate $script:cc_wk1Mon -Days 1 + $r[0].CycleWeek | Should -Be 1 + $r[0].UpdateRingValue | Should -Be 'Canary' + $r[0].IsDeadDay | Should -Be $false + @($r[0].Rings).Count | Should -Be 1 + } + + It 'IsDeadDay is $true on no-match days (Tuesday in the 4-week fixture)' { + $r = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cc_cfg4 -StartDate $script:cc_wk1Mon -Days 2 + $r[1].IsDeadDay | Should -Be $true + @($r[1].Rings).Count | Should -Be 0 + $r[1].UpdateRingValue | Should -BeNullOrEmpty + } + + It 'UNION semantics: Friday in week 1 returns both Canary and Ring1 from overlapping rows' { + # Friday is day +4 from Monday of W1 + $r = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cc_cfg4 -StartDate $script:cc_wk1Mon.AddDays(4) -Days 1 + @($r[0].Rings).Count | Should -Be 2 + $r[0].Rings | Should -Contain 'Canary' + $r[0].Rings | Should -Contain 'Ring1' + $r[0].UpdateRingValue | Should -Be 'Canary;Ring1' + } + + It 'IsCycleWrap is $true on the first Monday of cycle-week 1 after week 4' { + # Full cycle from W1 Monday: 28 rows. Day 28 is the next Monday and CycleWeek rolls back to 1. + $r = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cc_cfg4 -StartDate $script:cc_wk1Mon -Days 29 + # Day 27 (index 27 => start + 27 days = next-cycle Monday) was the wrap day. + $wrapRow = $r[28] # 28 days after start = next cycle Monday + $wrapRow.CycleWeek | Should -Be 1 + $wrapRow.IsCycleWrap | Should -Be $true + $wrapRow.CycleWeekLabel | Should -Match 'cycle wraps' + # Non-wrap rows should have IsCycleWrap = $false + ($r[0].IsCycleWrap) | Should -Be $false + } + + It 'Variable cycle length: 52-week cycle returns 364 rows by default and crosses the calendar-year boundary cleanly' { + $p52 = Join-Path $script:cc_tmp 'cycle52.yml' + $body52 = @' +schemaVersion: 1 +cycleWeeks: 52 +cycleAnchorISOWeek: 1 +cycleAnchorYear: 2024 +schedule: + - weeksInCycle: '*' + daysOfWeek: 'Mon' + rings: 'Canary' +'@ + Set-Content -LiteralPath $p52 -Value $body52 -Encoding utf8 + $cfg52 = Get-AzLocalApplyUpdatesScheduleConfig -Path $p52 + # Project from a Monday inside ISO W50 2024 so the horizon crosses + # into the next calendar year - exercises the year-boundary path. + $start = [datetime]::new(2024, 12, 9, 0, 0, 0, [DateTimeKind]::Utc) # Mon W50 2024 + $r = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $cfg52 -StartDate $start + @($r).Count | Should -Be 364 + # No row should have an empty Reason or unresolved year-boundary state - resolver must keep returning rows. + ($r | Where-Object { -not $_.PSObject.Properties['CycleWeek'] }).Count | Should -Be 0 + # The horizon must straddle two calendar years. + ($r | Where-Object { $_.DateUtc.Year -eq 2024 }).Count | Should -BeGreaterThan 0 + ($r | Where-Object { $_.DateUtc.Year -eq 2025 }).Count | Should -BeGreaterThan 0 + } + + It '8-week cycle starting partway through week 8 wraps to week 1 within the default horizon' { + $p8 = Join-Path $script:cc_tmp 'cycle8.yml' + $body8 = @' +schemaVersion: 1 +cycleWeeks: 8 +cycleAnchorISOWeek: 1 +cycleAnchorYear: 2026 +schedule: + - weeksInCycle: '1' + daysOfWeek: 'Mon' + rings: 'Canary' + - weeksInCycle: '8' + daysOfWeek: 'Mon' + rings: 'Prod' +'@ + Set-Content -LiteralPath $p8 -Value $body8 -Encoding utf8 + $cfg8 = Get-AzLocalApplyUpdatesScheduleConfig -Path $p8 + # Pick a Monday in cycle-week 8 (week 8 of an 8-week anchor-at-W1 cycle starts at week 8 = Mon 2026-02-23). + $w8Mon = $script:cc_wk1Mon.AddDays(7 * 7) + $r = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $cfg8 -StartDate $w8Mon + @($r).Count | Should -Be 56 # 8 * 7 + $r[0].CycleWeek | Should -Be 8 + $r[0].UpdateRingValue | Should -Be 'Prod' + # Day +7 = the wrap Monday + $r[7].CycleWeek | Should -Be 1 + $r[7].IsCycleWrap | Should -Be $true + $r[7].UpdateRingValue | Should -Be 'Canary' + # The remaining rows include weeks 2-7 with no schedule rows = dead days, plus another week-8 Monday at end + # @() wrap forces .Count to exist even when filter yields 0 or 1 elements + @($r | Where-Object { $_.CycleWeek -eq 1 -and $_.UpdateRingValue -eq 'Canary' }).Count | Should -BeGreaterOrEqual 1 + } + + It 'Multi-cycle horizon: -Days greater than one cycle iterates the cycle multiple times' { + $days = 4 * 7 * 2 # two full 4-week cycles = 56 days + $r = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:cc_cfg4 -StartDate $script:cc_wk1Mon -Days $days + @($r).Count | Should -Be $days + # Two wrap rows expected when iterating two full cycles (one at start of cycle 2, one at start of cycle 3 which is outside the horizon). + @($r | Where-Object { $_.IsCycleWrap }).Count | Should -BeGreaterOrEqual 1 + } + + It 'Throws on Schedule with invalid CycleWeeks of 0 (defensive guard)' { + $bad = [pscustomobject]@{ CycleWeeks = 0; Schedule = @() } + { Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $bad } | + Should -Throw -ExpectedMessage '*CycleWeeks must be >= 1*' + } +} + +Describe 'v0.8.5 Apply-Updates Schedule: Get-AzLocalApplyUpdatesScheduleCycleCalendar (markdown)' { + BeforeAll { + $script:ccmd_tmp = Join-Path $TestDrive 'cycle-cal-md' + New-Item -ItemType Directory -Path $script:ccmd_tmp -Force | Out-Null + + # Minimal CLEAN-fleet schedule: a single ring on a single day per + # cycle. Used by the regression test that pins 'markdown emitted even + # when there are zero advisor findings'. + $p = Join-Path $script:ccmd_tmp 'clean.yml' + $body = @' +schemaVersion: 1 +cycleWeeks: 2 +cycleAnchorISOWeek: 1 +cycleAnchorYear: 2026 +schedule: + - weeksInCycle: '*' + daysOfWeek: 'Mon' + rings: 'Canary' +'@ + Set-Content -LiteralPath $p -Value $body -Encoding utf8 + $script:ccmd_clean = Get-AzLocalApplyUpdatesScheduleConfig -Path $p + + # Multi-row schedule used to exercise UNION rendering + per-ring summary. + $p2 = Join-Path $script:ccmd_tmp 'multi.yml' + $body2 = @' +schemaVersion: 1 +cycleWeeks: 4 +cycleAnchorISOWeek: 1 +cycleAnchorYear: 2026 +schedule: + - weeksInCycle: '1' + daysOfWeek: 'Mon' + rings: 'Canary' + - weeksInCycle: '2' + daysOfWeek: 'Mon' + rings: 'Ring1' + - weeksInCycle: '3,4' + daysOfWeek: 'Mon' + rings: 'Prod' + - weeksInCycle: '*' + daysOfWeek: 'Fri' + rings: 'Canary;Ring1' +'@ + Set-Content -LiteralPath $p2 -Value $body2 -Encoding utf8 + $script:ccmd_multi = Get-AzLocalApplyUpdatesScheduleConfig -Path $p2 + + $jan4 = [datetime]::new(2026, 1, 4, 0, 0, 0, [DateTimeKind]::Utc) + $script:ccmd_wk1Mon = $jan4.AddDays(-1 * ((($jan4.DayOfWeek.value__ + 6) % 7))) + } + + It '-AsMarkdown returns a single string (not an array)' { + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:ccmd_clean -StartDate $script:ccmd_wk1Mon -AsMarkdown + $md | Should -BeOfType ([string]) + } + + It '-AsMarkdown emits the ## Cycle calendar heading' { + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:ccmd_clean -StartDate $script:ccmd_wk1Mon -AsMarkdown + $md | Should -Match '## Cycle calendar' + } + + It '-AsMarkdown emits the day-by-day table header (no ClusterRingCounts variant)' { + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:ccmd_clean -StartDate $script:ccmd_wk1Mon -AsMarkdown + $md | Should -Match '\| Date \(UTC\) \| Day \| CycleWeek \| Eligible rings \| AllowedUpdateVersions \|' + } + + It 'REGRESSION: -AsMarkdown returns the calendar even when the fleet has zero findings (v0.8.4 silent-drop bug)' { + # The v0.8.4 bug: cycle calendar was hidden when Test-AzLocalApplyUpdatesScheduleCoverage + # had no advisor findings AND no ClusterCsvPath. The new cmdlet is + # fully decoupled from advisor findings - the calendar MUST render + # purely based on the schedule object passed in. + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:ccmd_clean -StartDate $script:ccmd_wk1Mon -AsMarkdown + $md | Should -Not -BeNullOrEmpty + $md | Should -Match '## Cycle calendar' + # 14 day rows for a 2-week default cycle. The horizon may straddle 2025-12 and 2026-01 depending on the anchor Monday, so accept any yyyy-MM-dd prefix. + ([regex]::Matches($md, '\| \d{4}-\d{2}-\d{2} \|')).Count | Should -BeGreaterOrEqual 14 + } + + It '-IncludePerRingSummary adds the ### Per-ring projection section' { + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:ccmd_multi -StartDate $script:ccmd_wk1Mon -AsMarkdown -IncludePerRingSummary + $md | Should -Match '### Per-ring projection' + $md | Should -Match '\| UpdateRing \| Next eligible \| Eligible days \(count\) \| All eligible dates \|' + # All four scheduled rings should appear in the per-ring table. + $md | Should -Match '\| `Canary` \|' + $md | Should -Match '\| `Ring1` \|' + $md | Should -Match '\| `Prod` \|' + } + + It 'UNION rendering: overlap day shows multiple rings comma-separated' { + # Friday of W1 in the multi fixture: Canary + Ring1 + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:ccmd_multi -StartDate $script:ccmd_wk1Mon -Days 5 -AsMarkdown + $fridayDate = $script:ccmd_wk1Mon.AddDays(4).ToString('yyyy-MM-dd') + # Both ring names should appear on the same row. + $md | Should -Match "$fridayDate.*Canary.*Ring1|$fridayDate.*Ring1.*Canary" + } + + It '-ClusterRingCounts adds a Clusters in ring(s) column on the calendar table' { + $counts = @{ Canary = 3; Ring1 = 2; Prod = 9 } + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:ccmd_multi -StartDate $script:ccmd_wk1Mon -AsMarkdown -ClusterRingCounts $counts + $md | Should -Match '\| Date \(UTC\) \| Day \| CycleWeek \| Eligible rings \| Clusters in ring\(s\) \| AllowedUpdateVersions \|' + # Day-0 (W1 Monday) has Canary -> count of 3 should appear in the row cell + $w1MonStr = $script:ccmd_wk1Mon.ToString('yyyy-MM-dd') + $md | Should -Match "$w1MonStr.*``Canary``: 3" + } + + It '-ClusterRingCounts on an overlap day shows per-ring counts plus a (total: N) aggregate' { + $counts = @{ Canary = 3; Ring1 = 2; Prod = 9 } + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:ccmd_multi -StartDate $script:ccmd_wk1Mon -Days 5 -AsMarkdown -ClusterRingCounts $counts + # Friday of W1: Canary + Ring1, total = 5 + $md | Should -Match 'total: 5' + } + + It '-ClusterRingCounts with -IncludePerRingSummary adds Cluster count column on the per-ring projection table' { + $counts = @{ Canary = 3; Ring1 = 2; Prod = 9 } + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:ccmd_multi -StartDate $script:ccmd_wk1Mon -AsMarkdown -IncludePerRingSummary -ClusterRingCounts $counts + $md | Should -Match '\| UpdateRing \| Cluster count \| Next eligible \| Eligible days \(count\) \| All eligible dates \|' + # Each ring row should carry its count. + $md | Should -Match '\| `Canary` \| 3 \|' + $md | Should -Match '\| `Ring1` \| 2 \|' + $md | Should -Match '\| `Prod` \| 9 \|' + } + + It '-ClusterRingCounts omitted: the legacy 5-column calendar header is used' { + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:ccmd_multi -StartDate $script:ccmd_wk1Mon -AsMarkdown + # Must NOT contain the 6-column header. + ($md -match '\| Clusters in ring\(s\) \|') | Should -Be $false + } + + It '-ClusterRingCounts is case-insensitive (canary == Canary)' { + $counts = @{ canary = 7 } + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:ccmd_clean -StartDate $script:ccmd_wk1Mon -AsMarkdown -ClusterRingCounts $counts + $w1MonStr = $script:ccmd_wk1Mon.ToString('yyyy-MM-dd') + $md | Should -Match "$w1MonStr.*``Canary``: 7" + } + + It '-ClusterRingCounts on dead days shows the dead-day cell' { + $counts = @{ Canary = 3 } + # Day-1 (Tuesday) in the clean fixture is a dead day. + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:ccmd_clean -StartDate $script:ccmd_wk1Mon -Days 2 -AsMarkdown -ClusterRingCounts $counts + $tueStr = $script:ccmd_wk1Mon.AddDays(1).ToString('yyyy-MM-dd') + $md | Should -Match "$tueStr.*_\(0 - dead day\)_" + } + + It 'Cycle-wrap row renders the (cycle wraps) annotation in CycleWeek cell' { + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $script:ccmd_multi -StartDate $script:ccmd_wk1Mon -Days 29 -AsMarkdown + $md | Should -Match 'cycle wraps' + } + + It 'Year-boundary safe: a 1-week cycle projected across new year does not break markdown' { + $p1 = Join-Path $script:ccmd_tmp 'cycle1.yml' + $body1 = @' +schemaVersion: 1 +cycleWeeks: 1 +cycleAnchorISOWeek: 1 +cycleAnchorYear: 2024 +schedule: + - weeksInCycle: '*' + daysOfWeek: 'Mon-Fri' + rings: 'Canary' +'@ + Set-Content -LiteralPath $p1 -Value $body1 -Encoding utf8 + $cfg1 = Get-AzLocalApplyUpdatesScheduleConfig -Path $p1 + # Project from a date that straddles end-of-2024 -> 2025 + $start = [datetime]::new(2024, 12, 29, 0, 0, 0, [DateTimeKind]::Utc) + $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $cfg1 -StartDate $start -Days 14 -AsMarkdown + $md | Should -Not -BeNullOrEmpty + $md | Should -Match '## Cycle calendar' + # Both 2024 and 2025 dates must appear in the rendered table. + $md | Should -Match '\| 2024-12-' + $md | Should -Match '\| 2025-01-' + } +} + +#endregion v0.8.5: Get-AzLocalApplyUpdatesScheduleCycleCalendar + +#region v0.8.5: New-AzLocalPipelineJUnitXml (shared JUnit emitter) + +Describe 'Thin-YAML shared helper: New-AzLocalPipelineJUnitXml' { + + It 'Emits a well-formed XML document with the xml declaration and testsuites wrapper' { + $xml = InModuleScope AzLocal.UpdateManagement { + New-AzLocalPipelineJUnitXml -TestSuitesName 'demo' -Suites @( + @{ Name = 'S1'; TestCases = @(@{ Name = 'tc' }) } + ) + } + $xml | Should -Match '^<\?xml version="1\.0" encoding="UTF-8"\?>' + $xml | Should -Match '' + } + + It 'Computes per-suite tests/failures/errors/skipped attributes from the testcase array' { + $xml = InModuleScope AzLocal.UpdateManagement { + New-AzLocalPipelineJUnitXml -TestSuitesName 'demo' -Suites @( + @{ + Name = 'S' + TestCases = @( + @{ Name = 'pass1' } + @{ Name = 'fail1'; Failure = @{ Message = 'boom' } } + @{ Name = 'err1'; Error = @{ Message = 'oops' } } + @{ Name = 'skip1'; Skipped = 'because' } + ) + } + ) + } + $xml | Should -Match '' + } + + It 'Wraps SystemOut content in a CDATA section' { + $xml = InModuleScope AzLocal.UpdateManagement { + New-AzLocalPipelineJUnitXml -TestSuitesName 'demo' -Suites @( + @{ Name = 'S'; TestCases = @(@{ Name = 'tc'; SystemOut = 'line1`nline2' }) } + ) + } + $xml | Should -Match '' + } + + It 'Emits failure element with CDATA body when Failure has a Body property' { + $xml = InModuleScope AzLocal.UpdateManagement { + New-AzLocalPipelineJUnitXml -TestSuitesName 'demo' -Suites @( + @{ Name = 'S'; TestCases = @(@{ Name = 'tc'; Failure = @{ Message = 'm'; Type = 'T'; Body = 'trace' } }) } + ) + } + $xml | Should -Match '' + } + + It 'Self-closes failure / error elements when no Body is provided' { + $xml = InModuleScope AzLocal.UpdateManagement { + New-AzLocalPipelineJUnitXml -TestSuitesName 'demo' -Suites @( + @{ Name = 'S'; TestCases = @( + @{ Name = 'f'; Failure = @{ Message = 'm' } } + @{ Name = 'e'; Error = @{ Message = 'e' } } + ) } + ) + } + $xml | Should -Match '' + $xml | Should -Match '' + } + + It 'XML-escapes special characters in suite/testcase names and Failure messages' { + $xml = InModuleScope AzLocal.UpdateManagement { + New-AzLocalPipelineJUnitXml -TestSuitesName 'demo & ' -Suites @( + @{ + Name = 'A & B' + TestCases = @( + @{ Name = 'name "quoted"' ; Failure = @{ Message = 'ac' } } + ) + } + ) + } + $xml | Should -Match 'demo & <data>' + $xml | Should -Match 'A & B' + $xml | Should -Match 'name "quoted"' + $xml | Should -Match 'a<b>c' + } + + It 'Writes the XML to disk (UTF-8 no BOM) when -OutputPath is supplied' { + $tmpPath = Join-Path -Path $TestDrive -ChildPath 'sample-junit.xml' + $xml = InModuleScope AzLocal.UpdateManagement -Parameters @{ TmpPath = $tmpPath } { + param($TmpPath) + New-AzLocalPipelineJUnitXml -TestSuitesName 'demo' -OutputPath $TmpPath -Suites @( + @{ Name = 'S'; TestCases = @(@{ Name = 'tc' }) } + ) + } + Test-Path -LiteralPath $tmpPath | Should -BeTrue + $bytes = [System.IO.File]::ReadAllBytes($tmpPath) + # No UTF-8 BOM (EF BB BF) at the start. + ($bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) | Should -BeFalse + $diskContent = [System.IO.File]::ReadAllText($tmpPath) + $diskContent | Should -Be $xml + } + + It 'Honours the -Timestamp parameter on every attribute' { + $fixed = [datetime]::new(2026, 6, 10, 12, 34, 56) + $xml = InModuleScope AzLocal.UpdateManagement -Parameters @{ Fixed = $fixed } { + param($Fixed) + New-AzLocalPipelineJUnitXml -TestSuitesName 'demo' -Timestamp $Fixed -Suites @( + @{ Name = 'S1'; TestCases = @(@{ Name = 'tc1' }) } + @{ Name = 'S2'; TestCases = @(@{ Name = 'tc2' }) } + ) + } + ([regex]::Matches($xml, 'timestamp="2026-06-10T12:34:56"')).Count | Should -Be 2 + } + + # v0.8.5 Step.8 - Properties hashtable support (suite + testcase) + It 'Emits suite-level block when Suites contains a Properties hashtable' { + $xml = InModuleScope AzLocal.UpdateManagement { + New-AzLocalPipelineJUnitXml -TestSuitesName 'demo' -Suites @( + @{ + Name = 'S' + Properties = ([ordered]@{ testCategory = 'FleetVersionDistribution'; totalClusters = 42 }) + TestCases = @(@{ Name = 'tc' }) + } + ) + } + $xml | Should -Match '' + $xml | Should -Match '' + $xml | Should -Match '' + $xml | Should -Match '' + } + + It 'Emits testcase-level block when a TestCase contains a Properties hashtable' { + $xml = InModuleScope AzLocal.UpdateManagement { + New-AzLocalPipelineJUnitXml -TestSuitesName 'demo' -Suites @( + @{ + Name = 'S' + TestCases = @(@{ + Name = 'cluster-alpha' + Properties = ([ordered]@{ + ClusterName = 'alpha' + ClusterResourceId = '/subscriptions/s1/resourceGroups/rg1/providers/Microsoft.AzureStackHCI/clusters/alpha' + Status = 'UpdateFailure' + }) + }) + } + ) + } + $xml | Should -Match '' + $xml | Should -Match '' + $xml | Should -Match '' + } + + It 'XML-escapes special characters in property keys and values' { + $xml = InModuleScope AzLocal.UpdateManagement { + New-AzLocalPipelineJUnitXml -TestSuitesName 'demo' -Suites @( + @{ + Name = 'S' + TestCases = @(@{ + Name = 'tc' + Properties = ([ordered]@{ 'Key&"<>' = 'val&"' }) + }) + } + ) + } + $xml | Should -Match '' + } +} + +#endregion v0.8.5: New-AzLocalPipelineJUnitXml + +#region v0.8.5: Export-AzLocalAuthValidationReport (Step.0 thin-YAML port) + +Describe 'Thin-YAML Step.0: Export-AzLocalAuthValidationReport' { + + BeforeAll { + # Test helper: drives the cmdlet against a fixed account / subs / + # clusters / roles payload by mocking Invoke-AzCliJson + + # Invoke-AzResourceGraphQuery + Install-AzGraphExtension inside the + # module scope. Defined in script: scope so it survives Pester v5 + # discovery -> run phase split. + function script:Invoke-Step0Cmdlet { + param( + [hashtable]$Params = @{}, + [object]$Account, + [object[]]$Subs, + [object[]]$Clusters, + [object[]]$RoleRows + ) + $global:_avr_payload = @{ + Account = $Account + Subs = $Subs + Clusters = $Clusters + Roles = $RoleRows + Params = $Params + } + InModuleScope AzLocal.UpdateManagement { + Mock Install-AzGraphExtension { $true } + Mock Invoke-AzResourceGraphQuery { @($global:_avr_payload.Clusters) } + Mock Invoke-AzCliJson { + param([string[]]$Arguments) + $pl = $global:_avr_payload + if ($Arguments[0] -eq 'account' -and $Arguments[1] -eq 'show') { + return [pscustomobject]@{ Ok = $true; Data = $pl.Account; Error = $null } + } + if ($Arguments[0] -eq 'role' -and $Arguments[1] -eq 'assignment') { + return [pscustomobject]@{ Ok = $true; Data = $pl.Roles; Error = $null } + } + if ($Arguments[0] -eq 'account' -and $Arguments[1] -eq 'list') { + return [pscustomobject]@{ Ok = $true; Data = $pl.Subs; Error = $null } + } + return [pscustomobject]@{ Ok = $false; Data = $null; Error = "unmocked az: $($Arguments -join ' ')" } + } + $callParams = $global:_avr_payload.Params + Export-AzLocalAuthValidationReport @callParams + } + } + } + + BeforeEach { + $script:_avr_savedGhActions = $env:GITHUB_ACTIONS + $script:_avr_savedTfBuild = $env:TF_BUILD + $script:_avr_savedGhOutput = $env:GITHUB_OUTPUT + $script:_avr_savedGhSummary = $env:GITHUB_STEP_SUMMARY + $script:_avr_savedAdoStage = $env:BUILD_ARTIFACTSTAGINGDIRECTORY + Remove-Item Env:\GITHUB_ACTIONS -ErrorAction SilentlyContinue + Remove-Item Env:\TF_BUILD -ErrorAction SilentlyContinue + Remove-Item Env:\GITHUB_OUTPUT -ErrorAction SilentlyContinue + Remove-Item Env:\GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue + Remove-Item Env:\BUILD_ARTIFACTSTAGINGDIRECTORY -ErrorAction SilentlyContinue + + $script:_avr_ghOutputFile = Join-Path -Path $env:TEMP -ChildPath ("avr-gh-output-{0}" -f ([Guid]::NewGuid())) + $script:_avr_ghSummaryFile = Join-Path -Path $env:TEMP -ChildPath ("avr-gh-summary-{0}.md" -f ([Guid]::NewGuid())) + $script:_avr_adoStageDir = Join-Path -Path $env:TEMP -ChildPath ("avr-ado-stage-{0}" -f ([Guid]::NewGuid())) + $script:_avr_reportDir = Join-Path -Path $env:TEMP -ChildPath ("avr-report-{0}" -f ([Guid]::NewGuid())) + New-Item -ItemType File -Path $script:_avr_ghOutputFile -Force | Out-Null + New-Item -ItemType File -Path $script:_avr_ghSummaryFile -Force | Out-Null + New-Item -ItemType Directory -Path $script:_avr_adoStageDir -Force | Out-Null + New-Item -ItemType Directory -Path $script:_avr_reportDir -Force | Out-Null + + # Synthetic Azure responses shared across the It blocks (mutable; tests + # that need a non-empty fleet / multi-sub setup override these). + $script:_avr_account = [pscustomobject]@{ + name = 'My Subscription' + id = '00000000-0000-0000-0000-000000000000' + tenantId = '00000000-0000-0000-0000-000000000000' + user = [pscustomobject]@{ name = 'app-id-from-cli' } + } + $script:_avr_subs = @( + [pscustomobject]@{ name = 'Alpha'; subscriptionId = 'sub-aaa'; tenantId = 'ten-1'; state = 'Enabled' } + [pscustomobject]@{ name = 'Beta'; subscriptionId = 'sub-bbb'; tenantId = 'ten-1'; state = 'Enabled' } + ) + $script:_avr_clusters = @( + [pscustomobject]@{ name = 'cluster-a'; resourceGroup = 'rg1'; subscriptionId = 'sub-aaa' } + ) + $script:_avr_roleRows = @( + [pscustomobject]@{ roleDefinitionName = 'Reader'; principalType = 'ServicePrincipal'; scope = '/subscriptions/sub-aaa' } + ) + } + AfterEach { + if ($null -ne $script:_avr_savedGhActions) { $env:GITHUB_ACTIONS = $script:_avr_savedGhActions } else { Remove-Item Env:\GITHUB_ACTIONS -ErrorAction SilentlyContinue } + if ($null -ne $script:_avr_savedTfBuild) { $env:TF_BUILD = $script:_avr_savedTfBuild } else { Remove-Item Env:\TF_BUILD -ErrorAction SilentlyContinue } + if ($null -ne $script:_avr_savedGhOutput) { $env:GITHUB_OUTPUT = $script:_avr_savedGhOutput } else { Remove-Item Env:\GITHUB_OUTPUT -ErrorAction SilentlyContinue } + if ($null -ne $script:_avr_savedGhSummary) { $env:GITHUB_STEP_SUMMARY = $script:_avr_savedGhSummary } else { Remove-Item Env:\GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue } + if ($null -ne $script:_avr_savedAdoStage) { $env:BUILD_ARTIFACTSTAGINGDIRECTORY = $script:_avr_savedAdoStage } else { Remove-Item Env:\BUILD_ARTIFACTSTAGINGDIRECTORY -ErrorAction SilentlyContinue } + + # File cleanup is best-effort; TestDrive cleans automatically anyway. + Remove-Item -Path $script:_avr_ghOutputFile, $script:_avr_ghSummaryFile -Force -ErrorAction SilentlyContinue + Remove-Item -Path $script:_avr_adoStageDir, $script:_avr_reportDir -Force -Recurse -ErrorAction SilentlyContinue + } + + It 'Local host: returns PassThru object with the expected shape' { + $params = @{ ReportDirectory = $script:_avr_reportDir; PassThru = $true } + $result = Invoke-Step0Cmdlet -Params $params -Account $script:_avr_account -Subs $script:_avr_subs -Clusters $script:_avr_clusters -RoleRows $script:_avr_roleRows + $result | Should -Not -BeNullOrEmpty + $result.PSObject.Properties.Name | Should -Contain 'Account' + $result.PSObject.Properties.Name | Should -Contain 'SubscriptionCount' + $result.PSObject.Properties.Name | Should -Contain 'Subscriptions' + $result.PSObject.Properties.Name | Should -Contain 'ClusterCount' + $result.PSObject.Properties.Name | Should -Contain 'AuthValid' + $result.PSObject.Properties.Name | Should -Contain 'JUnitXmlPath' + $result.PSObject.Properties.Name | Should -Contain 'SubscriptionsJsonPath' + $result.PSObject.Properties.Name | Should -Contain 'SubscriptionsCsvPath' + $result.SubscriptionCount | Should -Be 2 + $result.ClusterCount | Should -Be 1 + $result.AuthValid | Should -BeTrue + } + + It 'Local host: writes JUnit XML, subscriptions.json, subscriptions.csv to the report dir' { + $params = @{ ReportDirectory = $script:_avr_reportDir; PassThru = $true } + $result = Invoke-Step0Cmdlet -Params $params -Account $script:_avr_account -Subs $script:_avr_subs -Clusters $script:_avr_clusters -RoleRows $script:_avr_roleRows + Test-Path -LiteralPath $result.JUnitXmlPath | Should -BeTrue + Test-Path -LiteralPath $result.SubscriptionsJsonPath | Should -BeTrue + Test-Path -LiteralPath $result.SubscriptionsCsvPath | Should -BeTrue + # JUnit XML must include all 3 always-on suites. + $xml = Get-Content -LiteralPath $result.JUnitXmlPath -Raw + $xml | Should -Match 'Authentication' + $xml | Should -Match 'Subscription Scope \(count=2\)' + $xml | Should -Match 'Resource Graph Reachability' + # Subscription artifact contents. + $csv = Get-Content -LiteralPath $result.SubscriptionsCsvPath -Raw + $csv | Should -Match 'Alpha' + $csv | Should -Match 'Beta' + } + + It 'JUnit XML includes the Module Version Drift suite ONLY when version metadata is supplied' { + $paramsWith = @{ ReportDirectory = $script:_avr_reportDir; PassThru = $true; InstalledModuleVersion = '0.8.5'; GeneratedAgainstVersion = '0.8.5'; LatestOnPSGallery = '0.8.5' } + $resultWith = Invoke-Step0Cmdlet -Params $paramsWith -Account $script:_avr_account -Subs $script:_avr_subs -Clusters $script:_avr_clusters -RoleRows $script:_avr_roleRows + (Get-Content -LiteralPath $resultWith.JUnitXmlPath -Raw) | Should -Match 'Module Version Drift' + + # Wipe and re-run WITHOUT the drift inputs - same dir, file is overwritten. + Remove-Item -LiteralPath $resultWith.JUnitXmlPath -Force + $paramsNo = @{ ReportDirectory = $script:_avr_reportDir; PassThru = $true } + $resultNo = Invoke-Step0Cmdlet -Params $paramsNo -Account $script:_avr_account -Subs $script:_avr_subs -Clusters $script:_avr_clusters -RoleRows $script:_avr_roleRows + (Get-Content -LiteralPath $resultNo.JUnitXmlPath -Raw) | Should -Not -Match 'Module Version Drift' + } + + It 'GitHub host: writes step-summary markdown to GITHUB_STEP_SUMMARY and step outputs to GITHUB_OUTPUT' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_avr_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_avr_ghSummaryFile + $params = @{ ReportDirectory = $script:_avr_reportDir; PassThru = $true } + [void](Invoke-Step0Cmdlet -Params $params -Account $script:_avr_account -Subs $script:_avr_subs -Clusters $script:_avr_clusters -RoleRows $script:_avr_roleRows) + # Summary file should now contain the rendered markdown. + $summary = Get-Content -LiteralPath $script:_avr_ghSummaryFile -Raw + $summary | Should -Match '## Step\.0 - Authentication Validation and Subscription Scope Report' + $summary | Should -Match '### Count of subscriptions accessible = 2' + $summary | Should -Match 'Alpha' + $summary | Should -Match 'Beta' + # Step outputs: all three keys present. + $outputs = Get-Content -LiteralPath $script:_avr_ghOutputFile -Raw + $outputs | Should -Match 'subscription_count=2' + $outputs | Should -Match 'cluster_count=1' + $outputs | Should -Match 'auth_valid=true' + } + + It 'AzureDevOps host: uses BUILD_ARTIFACTSTAGINGDIRECTORY for the default report dir and emits ##vso[task.uploadsummary]' { + $env:TF_BUILD = 'True' + $env:BUILD_ARTIFACTSTAGINGDIRECTORY = $script:_avr_adoStageDir + # Capture the console stream so we can scan for the ##vso directive. + $captured = & { + $params = @{ PassThru = $true } + Invoke-Step0Cmdlet -Params $params -Account $script:_avr_account -Subs $script:_avr_subs -Clusters $script:_avr_clusters -RoleRows $script:_avr_roleRows + } *>&1 + $joined = $captured -join "`n" + $joined | Should -Match '##vso\[task\.uploadsummary\]' + $joined | Should -Match '##vso\[task\.setvariable variable=subscription_count' + $joined | Should -Match '##vso\[task\.setvariable variable=cluster_count' + $joined | Should -Match '##vso\[task\.setvariable variable=auth_valid' + # Default report dir on ADO is $BUILD_ARTIFACTSTAGINGDIRECTORY\auth-report + $expectedReportDir = Join-Path $script:_avr_adoStageDir 'auth-report' + Test-Path -LiteralPath (Join-Path $expectedReportDir 'auth-report.xml') | Should -BeTrue + Test-Path -LiteralPath (Join-Path $expectedReportDir 'subscriptions.json') | Should -BeTrue + } + + It 'Empty fleet: ClusterCount = 0 and JUnit XML still renders the Resource Graph suite' { + $params = @{ ReportDirectory = $script:_avr_reportDir; PassThru = $true } + $result = Invoke-Step0Cmdlet -Params $params -Account $script:_avr_account -Subs $script:_avr_subs -Clusters @() -RoleRows $script:_avr_roleRows + $result.ClusterCount | Should -Be 0 + $xml = Get-Content -LiteralPath $result.JUnitXmlPath -Raw + $xml | Should -Match 'Clusters visible to pipeline identity = 0' + } + + It 'Empty subscription list: SubscriptionCount = 0, AuthValid = $false, subscription suite has 1 testcase' { + $params = @{ ReportDirectory = $script:_avr_reportDir; PassThru = $true } + $result = Invoke-Step0Cmdlet -Params $params -Account $script:_avr_account -Subs @() -Clusters $script:_avr_clusters -RoleRows @() + $result.SubscriptionCount | Should -Be 0 + $result.AuthValid | Should -BeFalse + $xml = Get-Content -LiteralPath $result.JUnitXmlPath -Raw + # Subscription Scope suite has exactly one testcase (the count row). + $xml | Should -Match 'Subscription Scope \(count=0\).*tests="1"' + } + + It 'Multi-subscription: each subscription row appears in the JUnit XML and the markdown roster' { + $multi = @( + [pscustomobject]@{ name = 'A'; subscriptionId = 's1'; tenantId = 't1'; state = 'Enabled' } + [pscustomobject]@{ name = 'B'; subscriptionId = 's2'; tenantId = 't1'; state = 'Enabled' } + [pscustomobject]@{ name = 'C'; subscriptionId = 's3'; tenantId = 't1'; state = 'Disabled' } + ) + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_avr_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_avr_ghSummaryFile + $params = @{ ReportDirectory = $script:_avr_reportDir; PassThru = $true } + $result = Invoke-Step0Cmdlet -Params $params -Account $script:_avr_account -Subs $multi -Clusters $script:_avr_clusters -RoleRows $script:_avr_roleRows + $result.SubscriptionCount | Should -Be 3 + $summary = Get-Content -LiteralPath $script:_avr_ghSummaryFile -Raw + $summary | Should -Match '\| 1 \| A \| `s1`' + $summary | Should -Match '\| 2 \| B \| `s2`' + $summary | Should -Match '\| 3 \| C \| `s3`' + } + + It 'Throws a helpful error when az account show fails' { + $global:_avr_payload = @{ Subs = @(); Clusters = @(); Roles = @() } + InModuleScope AzLocal.UpdateManagement -Parameters @{ ReportDirectory = $script:_avr_reportDir } { + param($ReportDirectory) + Mock Install-AzGraphExtension { $true } + Mock Invoke-AzResourceGraphQuery { @() } + Mock Invoke-AzCliJson { + param([string[]]$Arguments) + if ($Arguments[0] -eq 'account' -and $Arguments[1] -eq 'show') { + return [pscustomobject]@{ Ok = $false; Data = $null; Error = 'AADSTS70021: No matching federated identity record found.' } + } + return [pscustomobject]@{ Ok = $true; Data = @(); Error = $null } + } + { Export-AzLocalAuthValidationReport -ReportDirectory $ReportDirectory } | + Should -Throw -ExpectedMessage '*az account show failed*AADSTS70021*' + } + } +} + +#endregion v0.8.5: Export-AzLocalAuthValidationReport + +#region v0.8.5: Invoke-AzLocalClusterInventory (Step.1 thin-YAML port) + +Describe 'Thin-YAML Step.1: Invoke-AzLocalClusterInventory' { + + BeforeAll { + # Test helper: drives the cmdlet against a fixed inventory payload + # by mocking Get-AzLocalClusterInventory inside the module scope. + # The mock writes the CSV file (when -ExportPath is supplied) so + # the cmdlet's canonical-CSV copy path is exercised the same way + # it is in production. Defined in script: scope so it survives + # the Pester v5 discovery -> run phase split. + function script:Invoke-Step1Cmdlet { + param( + [hashtable]$Params = @{}, + [object[]]$Inventory + ) + $global:_inv_payload = @{ + Inventory = $Inventory + LastCallParams = $null + } + InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { + # Record which parameters Invoke-AzLocalClusterInventory passed through. + $global:_inv_payload.LastCallParams = $PSBoundParameters + if ($PSBoundParameters.ContainsKey('ExportPath') -and $PSBoundParameters['ExportPath']) { + $exportPath = $PSBoundParameters['ExportPath'] + $rows = $global:_inv_payload.Inventory + if ($null -ne $rows -and @($rows).Count -gt 0) { + @($rows) | Export-Csv -LiteralPath $exportPath -NoTypeInformation -Encoding UTF8 + } + } + return @($global:_inv_payload.Inventory) + } + $callParams = $global:_inv_payload.Params + if ($null -eq $callParams) { $callParams = @{} } + Invoke-AzLocalClusterInventory @callParams + } + } + } + + BeforeEach { + $script:_inv_savedGhActions = $env:GITHUB_ACTIONS + $script:_inv_savedTfBuild = $env:TF_BUILD + $script:_inv_savedGhOutput = $env:GITHUB_OUTPUT + $script:_inv_savedGhSummary = $env:GITHUB_STEP_SUMMARY + $script:_inv_savedAdoStage = $env:BUILD_ARTIFACTSTAGINGDIRECTORY + Remove-Item Env:\GITHUB_ACTIONS -ErrorAction SilentlyContinue + Remove-Item Env:\TF_BUILD -ErrorAction SilentlyContinue + Remove-Item Env:\GITHUB_OUTPUT -ErrorAction SilentlyContinue + Remove-Item Env:\GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue + Remove-Item Env:\BUILD_ARTIFACTSTAGINGDIRECTORY -ErrorAction SilentlyContinue + + $script:_inv_ghOutputFile = Join-Path -Path $env:TEMP -ChildPath ("inv-gh-output-{0}" -f ([Guid]::NewGuid())) + $script:_inv_ghSummaryFile = Join-Path -Path $env:TEMP -ChildPath ("inv-gh-summary-{0}.md" -f ([Guid]::NewGuid())) + $script:_inv_adoStageDir = Join-Path -Path $env:TEMP -ChildPath ("inv-ado-stage-{0}" -f ([Guid]::NewGuid())) + $script:_inv_outDir = Join-Path -Path $env:TEMP -ChildPath ("inv-out-{0}" -f ([Guid]::NewGuid())) + New-Item -ItemType File -Path $script:_inv_ghOutputFile -Force | Out-Null + New-Item -ItemType File -Path $script:_inv_ghSummaryFile -Force | Out-Null + New-Item -ItemType Directory -Path $script:_inv_adoStageDir -Force | Out-Null + New-Item -ItemType Directory -Path $script:_inv_outDir -Force | Out-Null + + # Synthetic fleet shared across the It blocks. + $script:_inv_fleet = @( + [pscustomobject]@{ ClusterName = 'alpha'; ResourceGroup = 'rg1'; SubscriptionId = 's1'; SubscriptionName = 'Sub A'; UpdateRing = 'Canary'; HasUpdateRingTag = 'Yes'; UpdateStartWindow = ''; UpdateExclusions = ''; UpdateSideloaded = ''; UpdateVersionInProgress = ''; ResourceId = '/subscriptions/s1/resourceGroups/rg1/providers/Microsoft.AzureStackHCI/clusters/alpha' } + [pscustomobject]@{ ClusterName = 'bravo'; ResourceGroup = 'rg1'; SubscriptionId = 's1'; SubscriptionName = 'Sub A'; UpdateRing = 'Pilot'; HasUpdateRingTag = 'Yes'; UpdateStartWindow = ''; UpdateExclusions = ''; UpdateSideloaded = ''; UpdateVersionInProgress = ''; ResourceId = '/subscriptions/s1/resourceGroups/rg1/providers/Microsoft.AzureStackHCI/clusters/bravo' } + [pscustomobject]@{ ClusterName = 'charlie'; ResourceGroup = 'rg2'; SubscriptionId = 's2'; SubscriptionName = 'Sub B'; UpdateRing = 'Production'; HasUpdateRingTag = 'Yes'; UpdateStartWindow = ''; UpdateExclusions = ''; UpdateSideloaded = ''; UpdateVersionInProgress = ''; ResourceId = '/subscriptions/s2/resourceGroups/rg2/providers/Microsoft.AzureStackHCI/clusters/charlie' } + [pscustomobject]@{ ClusterName = 'delta'; ResourceGroup = 'rg2'; SubscriptionId = 's2'; SubscriptionName = 'Sub B'; UpdateRing = ''; HasUpdateRingTag = 'No'; UpdateStartWindow = ''; UpdateExclusions = ''; UpdateSideloaded = ''; UpdateVersionInProgress = ''; ResourceId = '/subscriptions/s2/resourceGroups/rg2/providers/Microsoft.AzureStackHCI/clusters/delta' } + ) + } + AfterEach { + if ($null -ne $script:_inv_savedGhActions) { $env:GITHUB_ACTIONS = $script:_inv_savedGhActions } else { Remove-Item Env:\GITHUB_ACTIONS -ErrorAction SilentlyContinue } + if ($null -ne $script:_inv_savedTfBuild) { $env:TF_BUILD = $script:_inv_savedTfBuild } else { Remove-Item Env:\TF_BUILD -ErrorAction SilentlyContinue } + if ($null -ne $script:_inv_savedGhOutput) { $env:GITHUB_OUTPUT = $script:_inv_savedGhOutput } else { Remove-Item Env:\GITHUB_OUTPUT -ErrorAction SilentlyContinue } + if ($null -ne $script:_inv_savedGhSummary) { $env:GITHUB_STEP_SUMMARY = $script:_inv_savedGhSummary } else { Remove-Item Env:\GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue } + if ($null -ne $script:_inv_savedAdoStage) { $env:BUILD_ARTIFACTSTAGINGDIRECTORY = $script:_inv_savedAdoStage } else { Remove-Item Env:\BUILD_ARTIFACTSTAGINGDIRECTORY -ErrorAction SilentlyContinue } + + Remove-Item -Path $script:_inv_ghOutputFile, $script:_inv_ghSummaryFile -Force -ErrorAction SilentlyContinue + Remove-Item -Path $script:_inv_adoStageDir, $script:_inv_outDir -Force -Recurse -ErrorAction SilentlyContinue + } + + It 'Local host: returns PassThru object with the expected shape' { + $global:_inv_payload = @{ Inventory = $script:_inv_fleet; LastCallParams = $null; Params = @{ OutputDirectory = $script:_inv_outDir; PassThru = $true } } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { + if ($PSBoundParameters.ContainsKey('ExportPath') -and $PSBoundParameters['ExportPath']) { + @($global:_inv_payload.Inventory) | Export-Csv -LiteralPath $PSBoundParameters['ExportPath'] -NoTypeInformation -Encoding UTF8 + } + @($global:_inv_payload.Inventory) + } + $p = $global:_inv_payload.Params + Invoke-AzLocalClusterInventory @p + } + $result | Should -Not -BeNullOrEmpty + $result.PSObject.Properties.Name | Should -Contain 'ClusterCount' + $result.PSObject.Properties.Name | Should -Contain 'WithTagCount' + $result.PSObject.Properties.Name | Should -Contain 'WithoutTagCount' + $result.PSObject.Properties.Name | Should -Contain 'CsvPath' + $result.PSObject.Properties.Name | Should -Contain 'JsonPath' + $result.PSObject.Properties.Name | Should -Contain 'CanonicalCsvPath' + $result.PSObject.Properties.Name | Should -Contain 'ReadmePath' + $result.PSObject.Properties.Name | Should -Contain 'Clusters' + $result.PSObject.Properties.Name | Should -Contain 'UpdateRingDistribution' + $result.ClusterCount | Should -Be 4 + $result.WithTagCount | Should -Be 3 + $result.WithoutTagCount | Should -Be 1 + } + + It 'Writes timestamped CSV, JSON, canonical CSV, and README to the output dir' { + $global:_inv_payload = @{ Inventory = $script:_inv_fleet; Params = @{ OutputDirectory = $script:_inv_outDir; PassThru = $true; Timestamp = [datetime]'2099-01-02T03:04:05' } } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { + if ($PSBoundParameters.ContainsKey('ExportPath') -and $PSBoundParameters['ExportPath']) { + @($global:_inv_payload.Inventory) | Export-Csv -LiteralPath $PSBoundParameters['ExportPath'] -NoTypeInformation -Encoding UTF8 + } + @($global:_inv_payload.Inventory) + } + $p = $global:_inv_payload.Params + Invoke-AzLocalClusterInventory @p + } + Test-Path -LiteralPath $result.CsvPath | Should -BeTrue + Test-Path -LiteralPath $result.JsonPath | Should -BeTrue + Test-Path -LiteralPath $result.CanonicalCsvPath | Should -BeTrue + Test-Path -LiteralPath $result.ReadmePath | Should -BeTrue + # Filenames carry the explicit timestamp. + $result.CsvPath | Should -Match 'ClusterInventory_20990102_030405\.csv$' + $result.JsonPath | Should -Match 'ClusterInventory_20990102_030405\.json$' + # Canonical CSV uses the well-known name expected by Step.2. + Split-Path -Path $result.CanonicalCsvPath -Leaf | Should -Be 'ClusterUpdateRings.csv' + # JSON round-trips back into the same shape. + $json = Get-Content -LiteralPath $result.JsonPath -Raw | ConvertFrom-Json + @($json).Count | Should -Be 4 + } + + It 'Empty fleet: writes header-only canonical CSV so Step.2 has a file to read' { + $global:_inv_payload = @{ Inventory = @(); Params = @{ OutputDirectory = $script:_inv_outDir; PassThru = $true } } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @() } + $p = $global:_inv_payload.Params + Invoke-AzLocalClusterInventory @p + } + $result.ClusterCount | Should -Be 0 + Test-Path -LiteralPath $result.CanonicalCsvPath | Should -BeTrue + $canonical = Get-Content -LiteralPath $result.CanonicalCsvPath -Raw + $canonical | Should -Match 'ClusterName,ResourceGroup,SubscriptionId' + # No data rows beyond the header. + ($canonical -split "`n" | Where-Object { $_.Trim() }).Count | Should -Be 1 + } + + It 'GitHub host: writes step-summary markdown to GITHUB_STEP_SUMMARY and step outputs to GITHUB_OUTPUT' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_inv_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_inv_ghSummaryFile + $global:_inv_payload = @{ Inventory = $script:_inv_fleet; Params = @{ OutputDirectory = $script:_inv_outDir; PassThru = $true } } + [void](InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { + if ($PSBoundParameters.ContainsKey('ExportPath') -and $PSBoundParameters['ExportPath']) { + @($global:_inv_payload.Inventory) | Export-Csv -LiteralPath $PSBoundParameters['ExportPath'] -NoTypeInformation -Encoding UTF8 + } + @($global:_inv_payload.Inventory) + } + $p = $global:_inv_payload.Params + Invoke-AzLocalClusterInventory @p + }) + $summary = Get-Content -LiteralPath $script:_inv_ghSummaryFile -Raw + $summary | Should -Match '## Step\.1 - Cluster Inventory' + $summary | Should -Match '\| Total Clusters \| 4 \|' + $summary | Should -Match '\| With UpdateRing Tag \| 3 \|' + $summary | Should -Match '\| Without UpdateRing Tag \| 1 \|' + $summary | Should -Match '### UpdateRing Distribution' + $summary | Should -Match '\| Canary \| 1 \|' + $summary | Should -Match '\| Pilot \| 1 \|' + $summary | Should -Match '\| Production \| 1 \|' + + $outputs = Get-Content -LiteralPath $script:_inv_ghOutputFile -Raw + $outputs | Should -Match 'cluster_count=4' + $outputs | Should -Match 'with_tag_count=3' + $outputs | Should -Match 'without_tag_count=1' + $outputs | Should -Match 'csv_path=' + } + + It 'AzureDevOps host: uses BUILD_ARTIFACTSTAGINGDIRECTORY for the default output dir and emits ##vso[task.setvariable]' { + $env:TF_BUILD = 'True' + $env:BUILD_ARTIFACTSTAGINGDIRECTORY = $script:_inv_adoStageDir + $global:_inv_payload = @{ Inventory = $script:_inv_fleet; Params = @{ PassThru = $true } } + $captured = & { + InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { + if ($PSBoundParameters.ContainsKey('ExportPath') -and $PSBoundParameters['ExportPath']) { + @($global:_inv_payload.Inventory) | Export-Csv -LiteralPath $PSBoundParameters['ExportPath'] -NoTypeInformation -Encoding UTF8 + } + @($global:_inv_payload.Inventory) + } + $p = $global:_inv_payload.Params + Invoke-AzLocalClusterInventory @p + } + } *>&1 + $joined = $captured -join "`n" + $joined | Should -Match '##vso\[task\.setvariable variable=cluster_count' + $joined | Should -Match '##vso\[task\.setvariable variable=with_tag_count' + $joined | Should -Match '##vso\[task\.setvariable variable=without_tag_count' + $joined | Should -Match '##vso\[task\.setvariable variable=csv_path' + # Default output dir on ADO is BUILD_ARTIFACTSTAGINGDIRECTORY itself. + Test-Path -LiteralPath (Join-Path $script:_inv_adoStageDir 'ClusterUpdateRings.csv') | Should -BeTrue + Test-Path -LiteralPath (Join-Path $script:_inv_adoStageDir 'README_Instructions.txt') | Should -BeTrue + } + + It 'SubscriptionFilter is passed through as -SubscriptionId to Get-AzLocalClusterInventory' { + $global:_inv_payload = @{ Inventory = $script:_inv_fleet; Params = @{ OutputDirectory = $script:_inv_outDir; SubscriptionFilter = 'sub-xyz'; PassThru = $true } } + InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { + param($SubscriptionId, $ExportPath, [switch]$PassThru) + if ($ExportPath) { + @($global:_inv_payload.Inventory) | Export-Csv -LiteralPath $ExportPath -NoTypeInformation -Encoding UTF8 + } + @($global:_inv_payload.Inventory) + } + $p = $global:_inv_payload.Params + [void](Invoke-AzLocalClusterInventory @p) + Should -Invoke Get-AzLocalClusterInventory -Times 1 -Exactly -ParameterFilter { $SubscriptionId -eq 'sub-xyz' -and $PassThru } + } + } + + It 'UpdateRing distribution is sorted by name and excludes empty rings' { + $global:_inv_payload = @{ Inventory = $script:_inv_fleet; Params = @{ OutputDirectory = $script:_inv_outDir; PassThru = $true } } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { + if ($PSBoundParameters.ContainsKey('ExportPath') -and $PSBoundParameters['ExportPath']) { + @($global:_inv_payload.Inventory) | Export-Csv -LiteralPath $PSBoundParameters['ExportPath'] -NoTypeInformation -Encoding UTF8 + } + @($global:_inv_payload.Inventory) + } + $p = $global:_inv_payload.Params + Invoke-AzLocalClusterInventory @p + } + $names = @($result.UpdateRingDistribution | ForEach-Object { $_.Name }) + $names.Count | Should -Be 3 + $names | Should -Be @('Canary', 'Pilot', 'Production') + # 'delta' had no UpdateRing - it must NOT appear in the distribution. + $names | Should -Not -Contain '' + } + + It 'Single-cluster fleet: counts are 1 / 0 or 1 / 1, no array-shape collapse' { + $singleTagged = @( + [pscustomobject]@{ ClusterName = 'only-one'; ResourceGroup = 'rg'; SubscriptionId = 's'; SubscriptionName = 'S'; UpdateRing = 'Wave1'; HasUpdateRingTag = 'Yes'; UpdateStartWindow = ''; UpdateExclusions = ''; UpdateSideloaded = ''; UpdateVersionInProgress = ''; ResourceId = '/r' } + ) + $global:_inv_payload = @{ Inventory = $singleTagged; Params = @{ OutputDirectory = $script:_inv_outDir; PassThru = $true } } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { + if ($PSBoundParameters.ContainsKey('ExportPath') -and $PSBoundParameters['ExportPath']) { + @($global:_inv_payload.Inventory) | Export-Csv -LiteralPath $PSBoundParameters['ExportPath'] -NoTypeInformation -Encoding UTF8 + } + @($global:_inv_payload.Inventory) + } + $p = $global:_inv_payload.Params + Invoke-AzLocalClusterInventory @p + } + $result.ClusterCount | Should -Be 1 + $result.WithTagCount | Should -Be 1 + $result.WithoutTagCount | Should -Be 0 + @($result.UpdateRingDistribution).Count | Should -Be 1 + } +} + +#endregion v0.8.5: Invoke-AzLocalClusterInventory + +#region v0.8.5: Set-AzLocalClusterUpdateRingTagFromCsv (Step.2 thin-YAML port) + +Describe 'Thin-YAML Step.2: Set-AzLocalClusterUpdateRingTagFromCsv' { + + BeforeEach { + $script:_s2_savedGhActions = $env:GITHUB_ACTIONS + $script:_s2_savedTfBuild = $env:TF_BUILD + $script:_s2_savedGhOutput = $env:GITHUB_OUTPUT + $script:_s2_savedGhSummary = $env:GITHUB_STEP_SUMMARY + $script:_s2_savedAdoStage = $env:BUILD_ARTIFACTSTAGINGDIRECTORY + Remove-Item Env:\GITHUB_ACTIONS -ErrorAction SilentlyContinue + Remove-Item Env:\TF_BUILD -ErrorAction SilentlyContinue + Remove-Item Env:\GITHUB_OUTPUT -ErrorAction SilentlyContinue + Remove-Item Env:\GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue + Remove-Item Env:\BUILD_ARTIFACTSTAGINGDIRECTORY -ErrorAction SilentlyContinue + + $script:_s2_ghOutputFile = Join-Path -Path $env:TEMP -ChildPath ("s2-gh-output-{0}" -f ([Guid]::NewGuid())) + $script:_s2_ghSummaryFile = Join-Path -Path $env:TEMP -ChildPath ("s2-gh-summary-{0}.md" -f ([Guid]::NewGuid())) + $script:_s2_adoStageDir = Join-Path -Path $env:TEMP -ChildPath ("s2-ado-stage-{0}" -f ([Guid]::NewGuid())) + $script:_s2_outDir = Join-Path -Path $env:TEMP -ChildPath ("s2-out-{0}" -f ([Guid]::NewGuid())) + $script:_s2_csvDir = Join-Path -Path $env:TEMP -ChildPath ("s2-csv-{0}" -f ([Guid]::NewGuid())) + New-Item -ItemType File -Path $script:_s2_ghOutputFile -Force | Out-Null + New-Item -ItemType File -Path $script:_s2_ghSummaryFile -Force | Out-Null + New-Item -ItemType Directory -Path $script:_s2_adoStageDir -Force | Out-Null + New-Item -ItemType Directory -Path $script:_s2_outDir -Force | Out-Null + New-Item -ItemType Directory -Path $script:_s2_csvDir -Force | Out-Null + + # Synthetic CSV: three rows, two with UpdateRing values, one blank (skipped). + $script:_s2_csvPath = Join-Path -Path $script:_s2_csvDir -ChildPath 'ClusterUpdateRings.csv' + @" +ClusterName,ResourceGroup,SubscriptionId,UpdateRing,ResourceId +alpha,rg1,s1,Canary,/subscriptions/s1/resourceGroups/rg1/providers/Microsoft.AzureStackHCI/clusters/alpha +beta,rg2,s1,Production,/subscriptions/s1/resourceGroups/rg2/providers/Microsoft.AzureStackHCI/clusters/beta +gamma,rg3,s1,,/subscriptions/s1/resourceGroups/rg3/providers/Microsoft.AzureStackHCI/clusters/gamma +"@ | Set-Content -LiteralPath $script:_s2_csvPath -Encoding UTF8 + + # Default mock results: 1 created, 1 already-in-sync. + $script:_s2_results = @( + [pscustomobject]@{ ClusterName = 'alpha'; Action = 'Created'; PreviousTagValue = ''; NewTagValue = 'Canary'; Status = 'Success'; Message = 'Tag created' } + [pscustomobject]@{ ClusterName = 'beta'; Action = 'NoChange'; PreviousTagValue = 'Production'; NewTagValue = 'Production'; Status = 'AlreadyInSync'; Message = 'Already in sync' } + ) + } + + AfterEach { + if ($null -ne $script:_s2_savedGhActions) { $env:GITHUB_ACTIONS = $script:_s2_savedGhActions } else { Remove-Item Env:\GITHUB_ACTIONS -ErrorAction SilentlyContinue } + if ($null -ne $script:_s2_savedTfBuild) { $env:TF_BUILD = $script:_s2_savedTfBuild } else { Remove-Item Env:\TF_BUILD -ErrorAction SilentlyContinue } + if ($null -ne $script:_s2_savedGhOutput) { $env:GITHUB_OUTPUT = $script:_s2_savedGhOutput } else { Remove-Item Env:\GITHUB_OUTPUT -ErrorAction SilentlyContinue } + if ($null -ne $script:_s2_savedGhSummary) { $env:GITHUB_STEP_SUMMARY = $script:_s2_savedGhSummary } else { Remove-Item Env:\GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue } + if ($null -ne $script:_s2_savedAdoStage) { $env:BUILD_ARTIFACTSTAGINGDIRECTORY = $script:_s2_savedAdoStage } else { Remove-Item Env:\BUILD_ARTIFACTSTAGINGDIRECTORY -ErrorAction SilentlyContinue } + foreach ($p in @($script:_s2_ghOutputFile, $script:_s2_ghSummaryFile)) { + if ($p -and (Test-Path -LiteralPath $p)) { Remove-Item -LiteralPath $p -Force -ErrorAction SilentlyContinue } + } + foreach ($d in @($script:_s2_adoStageDir, $script:_s2_outDir, $script:_s2_csvDir)) { + if ($d -and (Test-Path -LiteralPath $d)) { Remove-Item -LiteralPath $d -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + It 'PassThru returns counts + ResultsJsonPath + Results' { + $global:_s2_payload = @{ Results = $script:_s2_results; Params = @{ InputCsvPath = $script:_s2_csvPath; OutputDirectory = $script:_s2_outDir; PassThru = $true } } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Set-AzLocalClusterUpdateRingTag { @($global:_s2_payload.Results) } + $p = $global:_s2_payload.Params + Set-AzLocalClusterUpdateRingTagFromCsv @p + } + $result | Should -Not -BeNullOrEmpty + $result.TotalCount | Should -Be 2 + $result.CreatedCount | Should -Be 1 + $result.AlreadyInSyncCount | Should -Be 1 + $result.UpdatedCount | Should -Be 0 + $result.SkippedCount | Should -Be 0 + $result.FailedCount | Should -Be 0 + $result.WhatIfCount | Should -Be 0 + $result.ResultsJsonPath | Should -Match 'UpdateRingTag_Results\.json$' + @($result.Results).Count | Should -Be 2 + } + + It 'Writes UpdateRingTag_Results.json sidecar with per-cluster rows' { + $global:_s2_payload = @{ Results = $script:_s2_results; Params = @{ InputCsvPath = $script:_s2_csvPath; OutputDirectory = $script:_s2_outDir; PassThru = $true } } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Set-AzLocalClusterUpdateRingTag { @($global:_s2_payload.Results) } + $p = $global:_s2_payload.Params + Set-AzLocalClusterUpdateRingTagFromCsv @p + } + Test-Path -LiteralPath $result.ResultsJsonPath | Should -BeTrue + $sidecar = Get-Content -Raw -LiteralPath $result.ResultsJsonPath | ConvertFrom-Json + @($sidecar).Count | Should -Be 2 + @($sidecar)[0].ClusterName | Should -Be 'alpha' + @($sidecar)[0].Action | Should -Be 'Created' + @($sidecar)[1].Status | Should -Be 'AlreadyInSync' + } + + It 'Empty results array still writes a valid empty JSON sidecar' { + $global:_s2_payload = @{ Results = @(); Params = @{ InputCsvPath = $script:_s2_csvPath; OutputDirectory = $script:_s2_outDir; PassThru = $true } } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Set-AzLocalClusterUpdateRingTag { @() } + $p = $global:_s2_payload.Params + Set-AzLocalClusterUpdateRingTagFromCsv @p + } + $result.TotalCount | Should -Be 0 + Test-Path -LiteralPath $result.ResultsJsonPath | Should -BeTrue + $parsed = Get-Content -Raw -LiteralPath $result.ResultsJsonPath | ConvertFrom-Json + @($parsed).Count | Should -Be 0 + } + + It 'Throws with operator-recovery guidance when CSV path does not exist' { + $missingPath = Join-Path -Path $script:_s2_csvDir -ChildPath 'does-not-exist.csv' + InModuleScope AzLocal.UpdateManagement -Parameters @{ MissingPath = $missingPath; OutDir = $script:_s2_outDir } { + param($MissingPath, $OutDir) + { Set-AzLocalClusterUpdateRingTagFromCsv -InputCsvPath $MissingPath -OutputDirectory $OutDir } | + Should -Throw -ExpectedMessage "*CSV file not found at path: $MissingPath*" + } + } + + It 'Throws when CSV is missing required UpdateRing column' { + $badCsv = Join-Path -Path $script:_s2_csvDir -ChildPath 'missing-column.csv' + "ClusterName,ResourceId`r`nalpha,/subscriptions/s1/resourceGroups/rg1/providers/Microsoft.AzureStackHCI/clusters/alpha" | + Set-Content -LiteralPath $badCsv -Encoding UTF8 + InModuleScope AzLocal.UpdateManagement -Parameters @{ BadCsv = $badCsv; OutDir = $script:_s2_outDir } { + param($BadCsv, $OutDir) + { Set-AzLocalClusterUpdateRingTagFromCsv -InputCsvPath $BadCsv -OutputDirectory $OutDir } | + Should -Throw -ExpectedMessage "*Required column 'UpdateRing' not found*" + } + } + + It 'Throws when CSV has zero rows with UpdateRing values set' { + $blankCsv = Join-Path -Path $script:_s2_csvDir -ChildPath 'all-blank.csv' + @" +ClusterName,ResourceId,UpdateRing +alpha,/subscriptions/s1/resourceGroups/rg1/providers/Microsoft.AzureStackHCI/clusters/alpha, +beta,/subscriptions/s1/resourceGroups/rg2/providers/Microsoft.AzureStackHCI/clusters/beta, +"@ | Set-Content -LiteralPath $blankCsv -Encoding UTF8 + InModuleScope AzLocal.UpdateManagement -Parameters @{ BlankCsv = $blankCsv; OutDir = $script:_s2_outDir } { + param($BlankCsv, $OutDir) + { Set-AzLocalClusterUpdateRingTagFromCsv -InputCsvPath $BlankCsv -OutputDirectory $OutDir } | + Should -Throw -ExpectedMessage '*No rows have UpdateRing values set*' + } + } + + It 'Emits GitHub Actions step outputs and step summary when GITHUB_ACTIONS=true' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s2_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s2_ghSummaryFile + $global:_s2_payload = @{ Results = $script:_s2_results; Params = @{ InputCsvPath = $script:_s2_csvPath; OutputDirectory = $script:_s2_outDir; PassThru = $true } } + [void](InModuleScope AzLocal.UpdateManagement { + Mock Set-AzLocalClusterUpdateRingTag { @($global:_s2_payload.Results) } + $p = $global:_s2_payload.Params + Set-AzLocalClusterUpdateRingTagFromCsv @p + }) + $outputs = Get-Content -LiteralPath $script:_s2_ghOutputFile -Raw + $outputs | Should -Match 'total_count=2' + $outputs | Should -Match 'created_count=1' + $outputs | Should -Match 'already_in_sync_count=1' + $outputs | Should -Match 'results_json_path=' + $summary = Get-Content -LiteralPath $script:_s2_ghSummaryFile -Raw + $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' + } + + It 'Defaults OutputDirectory to BUILD_ARTIFACTSTAGINGDIRECTORY when TF_BUILD=true' { + $env:TF_BUILD = 'true' + $env:BUILD_ARTIFACTSTAGINGDIRECTORY = $script:_s2_adoStageDir + $global:_s2_payload = @{ Results = $script:_s2_results; Params = @{ InputCsvPath = $script:_s2_csvPath; PassThru = $true } } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Set-AzLocalClusterUpdateRingTag { @($global:_s2_payload.Results) } + $p = $global:_s2_payload.Params + Set-AzLocalClusterUpdateRingTagFromCsv @p + } + $result.ResultsJsonPath | Should -Be (Join-Path -Path $script:_s2_adoStageDir -ChildPath 'UpdateRingTag_Results.json') + Test-Path -LiteralPath $result.ResultsJsonPath | Should -BeTrue + } + + It 'Propagates -Force to Set-AzLocalClusterUpdateRingTag' { + $global:_s2_payload = @{ Results = $script:_s2_results; Params = @{ InputCsvPath = $script:_s2_csvPath; OutputDirectory = $script:_s2_outDir; Force = $true; PassThru = $true } } + InModuleScope AzLocal.UpdateManagement { + Mock Set-AzLocalClusterUpdateRingTag { @($global:_s2_payload.Results) } + $p = $global:_s2_payload.Params + [void](Set-AzLocalClusterUpdateRingTagFromCsv @p) + Should -Invoke Set-AzLocalClusterUpdateRingTag -Times 1 -Exactly -ParameterFilter { $Force -eq $true -and $PassThru } + } + } + + It 'Propagates -WhatIf to Set-AzLocalClusterUpdateRingTag (dry-run preview)' { + $whatIfResults = @( + [pscustomobject]@{ ClusterName = 'alpha'; Action = 'Created'; PreviousTagValue = ''; NewTagValue = 'Canary'; Status = 'WhatIf'; Message = 'Would create' } + [pscustomobject]@{ ClusterName = 'beta'; Action = 'Updated'; PreviousTagValue = 'Pilot'; NewTagValue = 'Production'; Status = 'WhatIf'; Message = 'Would update' } + ) + $global:_s2_payload = @{ Results = $whatIfResults; Params = @{ InputCsvPath = $script:_s2_csvPath; OutputDirectory = $script:_s2_outDir; WhatIf = $true; PassThru = $true } } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Set-AzLocalClusterUpdateRingTag { @($global:_s2_payload.Results) } + $p = $global:_s2_payload.Params + Set-AzLocalClusterUpdateRingTagFromCsv @p + } + $result.WhatIfCount | Should -Be 2 + $result.CreatedCount | Should -Be 0 + $result.UpdatedCount | Should -Be 0 + } + + It 'Tallies failed + skipped + updated counts independently' { + $mixed = @( + [pscustomobject]@{ ClusterName = 'a'; Action = 'Created'; PreviousTagValue = ''; NewTagValue = 'R1'; Status = 'Success'; Message = 'ok' } + [pscustomobject]@{ ClusterName = 'b'; Action = 'Updated'; PreviousTagValue = 'R0'; NewTagValue = 'R1'; Status = 'Success'; Message = 'ok' } + [pscustomobject]@{ ClusterName = 'c'; Action = 'NoChange'; PreviousTagValue = 'R1'; NewTagValue = 'R1'; Status = 'AlreadyInSync'; Message = 'ok' } + [pscustomobject]@{ ClusterName = 'd'; Action = 'Skipped'; PreviousTagValue = 'R2'; NewTagValue = 'R1'; Status = 'Skipped'; Message = 'no force' } + [pscustomobject]@{ ClusterName = 'e'; Action = 'Created'; PreviousTagValue = ''; NewTagValue = 'R1'; Status = 'Failed'; Message = 'rbac denied' } + ) + $global:_s2_payload = @{ Results = $mixed; Params = @{ InputCsvPath = $script:_s2_csvPath; OutputDirectory = $script:_s2_outDir; PassThru = $true } } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Set-AzLocalClusterUpdateRingTag { @($global:_s2_payload.Results) } + $p = $global:_s2_payload.Params + Set-AzLocalClusterUpdateRingTagFromCsv @p + } + $result.TotalCount | Should -Be 5 + $result.CreatedCount | Should -Be 1 + $result.UpdatedCount | Should -Be 1 + $result.AlreadyInSyncCount | Should -Be 1 + $result.SkippedCount | Should -Be 1 + $result.FailedCount | Should -Be 1 + } +} + +#endregion v0.8.5: Set-AzLocalClusterUpdateRingTagFromCsv + + + +#region v0.8.5: Export-AzLocalUpdateRunMonitorReport (Step.7 thin-YAML port) + +Describe 'Thin-YAML Step.7: Export-AzLocalUpdateRunMonitorReport' { + + BeforeEach { + $script:_s7_savedGhActions = $env:GITHUB_ACTIONS + $script:_s7_savedTfBuild = $env:TF_BUILD + $script:_s7_savedGhOutput = $env:GITHUB_OUTPUT + $script:_s7_savedGhSummary = $env:GITHUB_STEP_SUMMARY + $script:_s7_savedAdoStage = $env:BUILD_ARTIFACTSTAGINGDIRECTORY + Remove-Item Env:\GITHUB_ACTIONS -ErrorAction SilentlyContinue + Remove-Item Env:\TF_BUILD -ErrorAction SilentlyContinue + Remove-Item Env:\GITHUB_OUTPUT -ErrorAction SilentlyContinue + Remove-Item Env:\GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue + Remove-Item Env:\BUILD_ARTIFACTSTAGINGDIRECTORY -ErrorAction SilentlyContinue + + $script:_s7_outDir = Join-Path -Path $env:TEMP -ChildPath ("s7-out-{0}" -f ([Guid]::NewGuid())) + $script:_s7_ghOutputFile = Join-Path -Path $env:TEMP -ChildPath ("s7-gh-output-{0}" -f ([Guid]::NewGuid())) + $script:_s7_ghSummaryFile = Join-Path -Path $env:TEMP -ChildPath ("s7-gh-summary-{0}.md" -f ([Guid]::NewGuid())) + New-Item -ItemType Directory -Path $script:_s7_outDir -Force | Out-Null + New-Item -ItemType File -Path $script:_s7_ghOutputFile -Force | Out-Null + New-Item -ItemType File -Path $script:_s7_ghSummaryFile -Force | Out-Null + + # Deterministic snapshot time for elapsed comparisons. + $script:_s7_now = [datetime]::SpecifyKind([datetime]'2026-06-10T12:00:00', [DateTimeKind]::Utc) + + # Default inventory: three clusters. + $script:_s7_inventory = @( + [pscustomobject]@{ ClusterName = 'alpha'; ResourceId = '/subscriptions/s1/resourceGroups/rg1/providers/Microsoft.AzureStackHCI/clusters/alpha' } + [pscustomobject]@{ ClusterName = 'beta'; ResourceId = '/subscriptions/s1/resourceGroups/rg2/providers/Microsoft.AzureStackHCI/clusters/beta' } + [pscustomobject]@{ ClusterName = 'gamma'; ResourceId = '/subscriptions/s1/resourceGroups/rg3/providers/Microsoft.AzureStackHCI/clusters/gamma' } + ) + } + + AfterEach { + if ($null -ne $script:_s7_savedGhActions) { $env:GITHUB_ACTIONS = $script:_s7_savedGhActions } else { Remove-Item Env:\GITHUB_ACTIONS -ErrorAction SilentlyContinue } + if ($null -ne $script:_s7_savedTfBuild) { $env:TF_BUILD = $script:_s7_savedTfBuild } else { Remove-Item Env:\TF_BUILD -ErrorAction SilentlyContinue } + if ($null -ne $script:_s7_savedGhOutput) { $env:GITHUB_OUTPUT = $script:_s7_savedGhOutput } else { Remove-Item Env:\GITHUB_OUTPUT -ErrorAction SilentlyContinue } + if ($null -ne $script:_s7_savedGhSummary) { $env:GITHUB_STEP_SUMMARY = $script:_s7_savedGhSummary } else { Remove-Item Env:\GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue } + if ($null -ne $script:_s7_savedAdoStage) { $env:BUILD_ARTIFACTSTAGINGDIRECTORY = $script:_s7_savedAdoStage } else { Remove-Item Env:\BUILD_ARTIFACTSTAGINGDIRECTORY -ErrorAction SilentlyContinue } + foreach ($p in @($script:_s7_ghOutputFile, $script:_s7_ghSummaryFile)) { + if ($p -and (Test-Path -LiteralPath $p)) { Remove-Item -LiteralPath $p -Force -ErrorAction SilentlyContinue } + } + if ($script:_s7_outDir -and (Test-Path -LiteralPath $script:_s7_outDir)) { + Remove-Item -LiteralPath $script:_s7_outDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Empty fleet emits all-zero step outputs, empty CSV + JUnit, and idle status badge' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s7_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s7_ghSummaryFile + $global:_s7_payload = @{ Inventory = @(); Runs = @(); Now = $script:_s7_now; OutDir = $script:_s7_outDir } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s7_payload.Inventory) } + Mock Get-AzLocalUpdateRuns { @($global:_s7_payload.Runs) } + Export-AzLocalUpdateRunMonitorReport -OutputDirectory $global:_s7_payload.OutDir -Now $global:_s7_payload.Now -PassThru + } + $result.InFlightCount | Should -Be 0 + $result.LongRunningCount | Should -Be 0 + $result.LongRunningStepCount | Should -Be 0 + $result.StepErroredCount | Should -Be 0 + $result.UnresolvedFailureCount | Should -Be 0 + $result.RecentFailureCount | Should -Be 0 + @($result.Rows).Count | Should -Be 0 + Test-Path -LiteralPath $result.CsvPath | Should -BeTrue + Test-Path -LiteralPath $result.XmlPath | Should -BeTrue + $outputs = Get-Content -Raw -LiteralPath $script:_s7_ghOutputFile + $outputs | Should -Match 'in_flight=0' + $outputs | Should -Match 'long_running=0' + $outputs | Should -Match 'long_running_step=0' + $outputs | Should -Match 'step_errored=0' + $outputs | Should -Match 'recent_failures=0' + $outputs | Should -Match 'unresolved_failures=0' + $summary = Get-Content -Raw -LiteralPath $script:_s7_ghSummaryFile + $summary | Should -Match 'Fleet Status: IDLE' + } + + It 'Single in-flight run within thresholds: InFlightCount=1, no warn/crit chips, status HEALTHY' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s7_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s7_ghSummaryFile + $runs = @( + [pscustomobject]@{ + ClusterName = 'alpha' + ClusterResourceId = $script:_s7_inventory[0].ResourceId + UpdateName = '12.2509.1.21' + State = 'InProgress' + Status = 'InProgress' + CurrentStep = 'Run Update Health Checks' + Progress = '40%' + StartTime = $script:_s7_now.AddMinutes(-30).ToString('yyyy-MM-ddTHH:mm:ss') # 30m elapsed + StepStartTime = $script:_s7_now.AddMinutes(-30).ToString('yyyy-MM-ddTHH:mm:ss') # well within 2h + EndTime = $null + RunId = 'r1' + RunResourceId = ($script:_s7_inventory[0].ResourceId + '/updates/12.2509.1.21/updateRuns/r1') + } + ) + $global:_s7_payload = @{ Inventory = $script:_s7_inventory; Runs = $runs; Now = $script:_s7_now; OutDir = $script:_s7_outDir } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s7_payload.Inventory) } + Mock Get-AzLocalUpdateRuns { @($global:_s7_payload.Runs) } + Export-AzLocalUpdateRunMonitorReport -OutputDirectory $global:_s7_payload.OutDir -Now $global:_s7_payload.Now -PassThru + } + $result.InFlightCount | Should -Be 1 + $result.LongRunningCount | Should -Be 0 + $result.LongRunningStepCount | Should -Be 0 + $result.StepErroredCount | Should -Be 0 + $row = @($result.Rows)[0] + $row.State | Should -Be 'InProgress' + $row.StepSeverity | Should -Be 'none' + $row.RunSeverity | Should -Be 'none' + $row.Flags | Should -Be 'within' + $row.ExceedsThreshold | Should -BeFalse + $row.ExceedsStepThreshold | Should -BeFalse + $summary = Get-Content -Raw -LiteralPath $script:_s7_ghSummaryFile + $summary | Should -Match 'Fleet Status: HEALTHY' + $outputs = Get-Content -Raw -LiteralPath $script:_s7_ghOutputFile + $outputs | Should -Match 'in_flight=1' + } + + It 'Per-step warn: step elapsed > LongRunningStepHours flags warn chip and LongRunningStepCount=1' { + $runs = @( + [pscustomobject]@{ + ClusterName = 'beta' + ClusterResourceId = $script:_s7_inventory[1].ResourceId + UpdateName = '12.2509.1.21' + State = 'InProgress' + Status = 'InProgress' + CurrentStep = 'Update Node 2' + Progress = '60%' + StartTime = $script:_s7_now.AddHours(-4).ToString('yyyy-MM-ddTHH:mm:ss') # 4h overall (within 24h backstop) + StepStartTime = $script:_s7_now.AddHours(-3).ToString('yyyy-MM-ddTHH:mm:ss') # 3h step elapsed (> 2h warn, < 4h crit) + EndTime = $null + RunId = 'r2' + RunResourceId = ($script:_s7_inventory[1].ResourceId + '/updates/12.2509.1.21/updateRuns/r2') + } + ) + $global:_s7_payload = @{ Inventory = $script:_s7_inventory; Runs = $runs; Now = $script:_s7_now; OutDir = $script:_s7_outDir } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s7_payload.Inventory) } + Mock Get-AzLocalUpdateRuns { @($global:_s7_payload.Runs) } + Export-AzLocalUpdateRunMonitorReport -OutputDirectory $global:_s7_payload.OutDir -Now $global:_s7_payload.Now -LongRunningStepHours 2 -LongRunningThresholdHours 24 -PassThru + } + $result.InFlightCount | Should -Be 1 + $result.LongRunningStepCount | Should -Be 1 + $result.LongRunningCount | Should -Be 0 + $row = @($result.Rows)[0] + $row.ExceedsStepThreshold | Should -BeTrue + $row.ExceedsThreshold | Should -BeFalse + $row.StepSeverity | Should -Be 'warn' + $row.RunSeverity | Should -Be 'none' + $row.Flags | Should -Match 'step >2h' + } + + It 'Step error (progressStatus=Error while State=InProgress): StepErroredCount=1, severity score elevated' { + $runs = @( + [pscustomobject]@{ + ClusterName = 'gamma' + ClusterResourceId = $script:_s7_inventory[2].ResourceId + UpdateName = '12.2509.1.21' + State = 'InProgress' + Status = 'Error' # stuck step + CurrentStep = 'Run Pre Update Validation' + Progress = '15%' + StartTime = $script:_s7_now.AddHours(-1).ToString('yyyy-MM-ddTHH:mm:ss') + StepStartTime = $script:_s7_now.AddMinutes(-30).ToString('yyyy-MM-ddTHH:mm:ss') + EndTime = $null + RunId = 'r3' + RunResourceId = ($script:_s7_inventory[2].ResourceId + '/updates/12.2509.1.21/updateRuns/r3') + ErrorMessage = 'Health check NetworkATC.SwitchEmbeddedTeaming failed.' + } + ) + $global:_s7_payload = @{ Inventory = $script:_s7_inventory; Runs = $runs; Now = $script:_s7_now; OutDir = $script:_s7_outDir } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s7_payload.Inventory) } + Mock Get-AzLocalUpdateRuns { @($global:_s7_payload.Runs) } + Export-AzLocalUpdateRunMonitorReport -OutputDirectory $global:_s7_payload.OutDir -Now $global:_s7_payload.Now -PassThru + } + $result.InFlightCount | Should -Be 1 + $result.StepErroredCount | Should -Be 1 + $row = @($result.Rows)[0] + $row.HasStepError | Should -BeTrue + $row.Flags | Should -Match 'step errored' + $row.SeverityScore | Should -BeGreaterThan 999 + $xml = [xml](Get-Content -Raw -LiteralPath $result.XmlPath) + $failedCases = @($xml.SelectNodes('//testcase[failure]')) + $failedCases.Count | Should -BeGreaterOrEqual 1 + $failedCases[0].failure.type | Should -Be 'StepError' + } + + It 'Unresolved failure (latest run is Failed) surfaces in UnresolvedFailureCount and JUnit' { + $runs = @( + [pscustomobject]@{ + ClusterName = 'alpha' + ClusterResourceId = $script:_s7_inventory[0].ResourceId + UpdateName = '12.2509.1.21' + State = 'Failed' + Status = 'Error' + CurrentStep = 'Restart Cluster Node' + Progress = '' + StartTime = $script:_s7_now.AddHours(-10).ToString('yyyy-MM-ddTHH:mm:ss') + StepStartTime = $script:_s7_now.AddHours(-9).ToString('yyyy-MM-ddTHH:mm:ss') + EndTime = $script:_s7_now.AddHours(-8).ToString('yyyy-MM-ddTHH:mm:ss') # 8h ago - within 24h recent window + RunId = 'r4' + RunResourceId = ($script:_s7_inventory[0].ResourceId + '/updates/12.2509.1.21/updateRuns/r4') + ErrorMessage = 'Node failed to restart within timeout.' + CurrentStepDetail = 'Step failed.' + } + ) + $global:_s7_payload = @{ Inventory = $script:_s7_inventory; Runs = $runs; Now = $script:_s7_now; OutDir = $script:_s7_outDir } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s7_payload.Inventory) } + Mock Get-AzLocalUpdateRuns { @($global:_s7_payload.Runs) } + Export-AzLocalUpdateRunMonitorReport -OutputDirectory $global:_s7_payload.OutDir -Now $global:_s7_payload.Now -PassThru + } + $result.InFlightCount | Should -Be 0 + $result.UnresolvedFailureCount | Should -Be 1 + $result.RecentFailureCount | Should -Be 1 + $row = @($result.Rows)[0] + $row.IsUnresolvedFailure | Should -BeTrue + $row.IsRecentFailure | Should -BeTrue + $xml = [xml](Get-Content -Raw -LiteralPath $result.XmlPath) + $failedCases = @($xml.SelectNodes('//testcase[failure]')) + $failedCases.Count | Should -BeGreaterOrEqual 1 + ($failedCases | ForEach-Object { $_.failure.type }) | Should -Contain 'RecentFailure' + } + + It 'RecentFailureWindowHours=0 disables recent flag but unresolved still surfaces' { + $runs = @( + [pscustomobject]@{ + ClusterName = 'beta' + ClusterResourceId = $script:_s7_inventory[1].ResourceId + UpdateName = '12.2509.1.21' + State = 'Failed' + Status = 'Error' + CurrentStep = 'Apply Update' + Progress = '' + StartTime = $script:_s7_now.AddHours(-5).ToString('yyyy-MM-ddTHH:mm:ss') + StepStartTime = $script:_s7_now.AddHours(-4).ToString('yyyy-MM-ddTHH:mm:ss') + EndTime = $script:_s7_now.AddHours(-3).ToString('yyyy-MM-ddTHH:mm:ss') + RunId = 'r5' + RunResourceId = ($script:_s7_inventory[1].ResourceId + '/updates/12.2509.1.21/updateRuns/r5') + ErrorMessage = 'CAU error.' + CurrentStepDetail = '' + } + ) + $global:_s7_payload = @{ Inventory = $script:_s7_inventory; Runs = $runs; Now = $script:_s7_now; OutDir = $script:_s7_outDir } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s7_payload.Inventory) } + Mock Get-AzLocalUpdateRuns { @($global:_s7_payload.Runs) } + Export-AzLocalUpdateRunMonitorReport -OutputDirectory $global:_s7_payload.OutDir -Now $global:_s7_payload.Now -RecentFailureWindowHours 0 -PassThru + } + $result.UnresolvedFailureCount | Should -Be 1 + $result.RecentFailureCount | Should -Be 0 + $row = @($result.Rows)[0] + $row.IsUnresolvedFailure | Should -BeTrue + $row.IsRecentFailure | Should -BeFalse + } + + It 'Scope=by-update-ring skips Get-AzLocalClusterInventory and queries by tag' { + $runs = @( + [pscustomobject]@{ + ClusterName = 'alpha' + ClusterResourceId = $script:_s7_inventory[0].ResourceId + UpdateName = '12.2509.1.21' + State = 'InProgress' + Status = 'InProgress' + CurrentStep = 'Apply Update' + Progress = '50%' + StartTime = $script:_s7_now.AddMinutes(-15).ToString('yyyy-MM-ddTHH:mm:ss') + StepStartTime = $script:_s7_now.AddMinutes(-15).ToString('yyyy-MM-ddTHH:mm:ss') + EndTime = $null + RunId = 'r6' + RunResourceId = ($script:_s7_inventory[0].ResourceId + '/updates/12.2509.1.21/updateRuns/r6') + } + ) + $global:_s7_payload = @{ Inventory = $script:_s7_inventory; Runs = $runs; Now = $script:_s7_now; OutDir = $script:_s7_outDir } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { throw 'Should NOT be called when Scope=by-update-ring' } + Mock Get-AzLocalUpdateRuns { @($global:_s7_payload.Runs) } -ParameterFilter { $ScopeByUpdateRingTag -eq $true -and $UpdateRingValue -eq 'Canary' } + $r = Export-AzLocalUpdateRunMonitorReport -OutputDirectory $global:_s7_payload.OutDir -Now $global:_s7_payload.Now -Scope 'by-update-ring' -UpdateRing 'Canary' -PassThru + Should -Invoke Get-AzLocalClusterInventory -Times 0 -Exactly + Should -Invoke Get-AzLocalUpdateRuns -Times 1 -Exactly -ParameterFilter { $ScopeByUpdateRingTag -eq $true -and $UpdateRingValue -eq 'Canary' } + $r + } + $result.InFlightCount | Should -Be 1 + } + + It 'CSV is sorted by SeverityScore descending (worst first), and contains all rows' { + $runs = @( + [pscustomobject]@{ + ClusterName = 'lowsev' + ClusterResourceId = $script:_s7_inventory[0].ResourceId + UpdateName = 'U1' + State = 'InProgress' + Status = 'InProgress' + CurrentStep = 'Init' + Progress = '5%' + StartTime = $script:_s7_now.AddMinutes(-10).ToString('yyyy-MM-ddTHH:mm:ss') + StepStartTime = $script:_s7_now.AddMinutes(-10).ToString('yyyy-MM-ddTHH:mm:ss') + EndTime = $null + RunId = 'rA' + RunResourceId = ($script:_s7_inventory[0].ResourceId + '/updates/U1/updateRuns/rA') + } + [pscustomobject]@{ + ClusterName = 'highsev' + ClusterResourceId = $script:_s7_inventory[1].ResourceId + UpdateName = 'U2' + State = 'InProgress' + Status = 'Error' # stuck step => 1000 + extras + CurrentStep = 'Run Pre Update Validation' + Progress = '10%' + StartTime = $script:_s7_now.AddHours(-2).ToString('yyyy-MM-ddTHH:mm:ss') + StepStartTime = $script:_s7_now.AddHours(-1).ToString('yyyy-MM-ddTHH:mm:ss') + EndTime = $null + RunId = 'rB' + RunResourceId = ($script:_s7_inventory[1].ResourceId + '/updates/U2/updateRuns/rB') + ErrorMessage = 'health check fail' + } + ) + $global:_s7_payload = @{ Inventory = $script:_s7_inventory; Runs = $runs; Now = $script:_s7_now; OutDir = $script:_s7_outDir } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s7_payload.Inventory) } + Mock Get-AzLocalUpdateRuns { @($global:_s7_payload.Runs) } + Export-AzLocalUpdateRunMonitorReport -OutputDirectory $global:_s7_payload.OutDir -Now $global:_s7_payload.Now -PassThru + } + $csv = @(Import-Csv -LiteralPath $result.CsvPath) + $csv.Count | Should -Be 2 + $csv[0].ClusterName | Should -Be 'highsev' # higher SeverityScore first + $csv[1].ClusterName | Should -Be 'lowsev' + } + + It 'JUnit XML is well-formed and contains a testcase per in-flight + per unresolved failure' { + $runs = @( + [pscustomobject]@{ + ClusterName = 'alpha' + ClusterResourceId = $script:_s7_inventory[0].ResourceId + UpdateName = 'U1' + State = 'InProgress' + Status = 'InProgress' + CurrentStep = 'Apply Update' + Progress = '40%' + StartTime = $script:_s7_now.AddMinutes(-20).ToString('yyyy-MM-ddTHH:mm:ss') + StepStartTime = $script:_s7_now.AddMinutes(-20).ToString('yyyy-MM-ddTHH:mm:ss') + EndTime = $null + RunId = 'rA' + RunResourceId = ($script:_s7_inventory[0].ResourceId + '/updates/U1/updateRuns/rA') + } + [pscustomobject]@{ + ClusterName = 'beta' + ClusterResourceId = $script:_s7_inventory[1].ResourceId + UpdateName = 'U1' + State = 'Failed' + Status = 'Error' + CurrentStep = 'Apply Update' + Progress = '' + StartTime = $script:_s7_now.AddHours(-6).ToString('yyyy-MM-ddTHH:mm:ss') + StepStartTime = $script:_s7_now.AddHours(-5).ToString('yyyy-MM-ddTHH:mm:ss') + EndTime = $script:_s7_now.AddHours(-4).ToString('yyyy-MM-ddTHH:mm:ss') + RunId = 'rB' + RunResourceId = ($script:_s7_inventory[1].ResourceId + '/updates/U1/updateRuns/rB') + ErrorMessage = 'CAU failure' + } + ) + $global:_s7_payload = @{ Inventory = $script:_s7_inventory; Runs = $runs; Now = $script:_s7_now; OutDir = $script:_s7_outDir } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s7_payload.Inventory) } + Mock Get-AzLocalUpdateRuns { @($global:_s7_payload.Runs) } + Export-AzLocalUpdateRunMonitorReport -OutputDirectory $global:_s7_payload.OutDir -Now $global:_s7_payload.Now -PassThru + } + Test-Path -LiteralPath $result.XmlPath | Should -BeTrue + $xml = [xml](Get-Content -Raw -LiteralPath $result.XmlPath) + $cases = @($xml.SelectNodes('//testcase')) + $cases.Count | Should -Be 2 + # The in-flight case must NOT have (within thresholds), the unresolved must HAVE one. + ($cases | Where-Object { $_.classname -eq 'UpdateMonitor' }).Count | Should -Be 2 + $failed = @($cases | Where-Object { $_.failure }) + $failed.Count | Should -Be 1 + $failed[0].failure.type | Should -Be 'RecentFailure' + } + + It 'Markdown step summary contains required headings, metric table, and footer module version' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s7_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s7_ghSummaryFile + $runs = @( + [pscustomobject]@{ + ClusterName = 'alpha' + ClusterResourceId = $script:_s7_inventory[0].ResourceId + UpdateName = 'U1' + State = 'InProgress' + Status = 'InProgress' + CurrentStep = 'Apply Update' + Progress = '40%' + StartTime = $script:_s7_now.AddMinutes(-20).ToString('yyyy-MM-ddTHH:mm:ss') + StepStartTime = $script:_s7_now.AddMinutes(-20).ToString('yyyy-MM-ddTHH:mm:ss') + EndTime = $null + RunId = 'rA' + RunResourceId = ($script:_s7_inventory[0].ResourceId + '/updates/U1/updateRuns/rA') + } + ) + $global:_s7_payload = @{ Inventory = $script:_s7_inventory; Runs = $runs; Now = $script:_s7_now; OutDir = $script:_s7_outDir } + [void](InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s7_payload.Inventory) } + Mock Get-AzLocalUpdateRuns { @($global:_s7_payload.Runs) } + Export-AzLocalUpdateRunMonitorReport -OutputDirectory $global:_s7_payload.OutDir -Now $global:_s7_payload.Now -InstalledModuleVersion '0.8.5' -PassThru + }) + $summary = Get-Content -Raw -LiteralPath $script:_s7_ghSummaryFile + $summary | Should -Match '## In-Flight Update Monitor' + $summary | Should -Match 'Fleet Status: HEALTHY' + $summary | Should -Match '\| Metric \| Count \|' + $summary | Should -Match '\| Update runs in flight \| 1 \|' + $summary | Should -Match '### In-flight runs' + $summary | Should -Match '_Generated by AzLocal.UpdateManagement v0\.8\.5\._' + } + + It 'Defaults OutputDirectory to BUILD_ARTIFACTSTAGINGDIRECTORY when on Azure DevOps host' { + $adoStage = Join-Path -Path $env:TEMP -ChildPath ("s7-ado-stage-{0}" -f ([Guid]::NewGuid())) + New-Item -ItemType Directory -Path $adoStage -Force | Out-Null + try { + $env:TF_BUILD = 'true' + $env:BUILD_ARTIFACTSTAGINGDIRECTORY = $adoStage + $global:_s7_payload = @{ Inventory = @(); Runs = @(); Now = $script:_s7_now } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s7_payload.Inventory) } + Mock Get-AzLocalUpdateRuns { @($global:_s7_payload.Runs) } + Export-AzLocalUpdateRunMonitorReport -Now $global:_s7_payload.Now -PassThru + } + $result.CsvPath | Should -Be (Join-Path -Path $adoStage -ChildPath 'update-monitor.csv') + $result.XmlPath | Should -Be (Join-Path -Path $adoStage -ChildPath 'update-monitor.xml') + } + finally { + if (Test-Path -LiteralPath $adoStage) { Remove-Item -LiteralPath $adoStage -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + It 'In-flight runs table renders Cluster and Update cells as target=_blank links so they do NOT navigate away from the pipeline output' { + # Regression guard: the In-flight runs (and Failed runs) tables MUST + # render Cluster + Update cells as ... so operators clicking through to the Azure + # portal from the pipeline run summary keep the pipeline tab open. + # The cmdlet honours both ClusterPortalUrl and UpdateRunPortalUrl - so + # both must be present on the source row AND surface in the rendered + # markdown summary. + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s7_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s7_ghSummaryFile + $runs = @( + [pscustomobject]@{ + ClusterName = 'alpha' + ClusterResourceId = $script:_s7_inventory[0].ResourceId + UpdateName = '12.2509.1.21' + State = 'InProgress' + Status = 'InProgress' + CurrentStep = 'Run Stage Cluster Update' + Progress = '45%' + StartTime = $script:_s7_now.AddHours(-3).ToString('yyyy-MM-ddTHH:mm:ss') + StepStartTime = $script:_s7_now.AddMinutes(-45).ToString('yyyy-MM-ddTHH:mm:ss') + EndTime = $null + RunId = 'r-link' + RunResourceId = ($script:_s7_inventory[0].ResourceId + '/updates/12.2509.1.21/updateRuns/r-link') + } + ) + $global:_s7_payload = @{ Inventory = $script:_s7_inventory; Runs = $runs; Now = $script:_s7_now; OutDir = $script:_s7_outDir } + $null = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s7_payload.Inventory) } + Mock Get-AzLocalUpdateRuns { @($global:_s7_payload.Runs) } + Export-AzLocalUpdateRunMonitorReport -OutputDirectory $global:_s7_payload.OutDir -Now $global:_s7_payload.Now -PassThru + } + $summary = Get-Content -Raw -LiteralPath $script:_s7_ghSummaryFile + $summary | Should -Match '### In-flight runs' + # Cluster cell renders as target=_blank link with rel=noopener + $summary | Should -Match 'alpha' + # Update cell renders as target=_blank link with rel=noopener + $summary | Should -Match '12\.2509\.1\.21' + } +} + +#endregion v0.8.5: Export-AzLocalUpdateRunMonitorReport + +#region v0.8.5: Export-AzLocalFleetUpdateStatusReport (Step.8 thin-YAML port) + +Describe 'Thin-YAML Step.8: Export-AzLocalFleetUpdateStatusReport' { + + BeforeEach { + $script:_s8_savedGhActions = $env:GITHUB_ACTIONS + $script:_s8_savedTfBuild = $env:TF_BUILD + $script:_s8_savedGhOutput = $env:GITHUB_OUTPUT + $script:_s8_savedGhSummary = $env:GITHUB_STEP_SUMMARY + $script:_s8_savedAdoStage = $env:BUILD_ARTIFACTSTAGINGDIRECTORY + Remove-Item Env:\GITHUB_ACTIONS -ErrorAction SilentlyContinue + Remove-Item Env:\TF_BUILD -ErrorAction SilentlyContinue + Remove-Item Env:\GITHUB_OUTPUT -ErrorAction SilentlyContinue + Remove-Item Env:\GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue + Remove-Item Env:\BUILD_ARTIFACTSTAGINGDIRECTORY -ErrorAction SilentlyContinue + + $script:_s8_outDir = Join-Path -Path $env:TEMP -ChildPath ("s8-out-{0}" -f ([Guid]::NewGuid())) + $script:_s8_ghOutputFile = Join-Path -Path $env:TEMP -ChildPath ("s8-gh-output-{0}" -f ([Guid]::NewGuid())) + $script:_s8_ghSummaryFile = Join-Path -Path $env:TEMP -ChildPath ("s8-gh-summary-{0}.md" -f ([Guid]::NewGuid())) + New-Item -ItemType Directory -Path $script:_s8_outDir -Force | Out-Null + New-Item -ItemType File -Path $script:_s8_ghOutputFile -Force | Out-Null + New-Item -ItemType File -Path $script:_s8_ghSummaryFile -Force | Out-Null + + $script:_s8_now = [datetime]::SpecifyKind([datetime]'2026-06-10T12:00:00', [DateTimeKind]::Utc) + } + + AfterEach { + if ($null -ne $script:_s8_savedGhActions) { $env:GITHUB_ACTIONS = $script:_s8_savedGhActions } else { Remove-Item Env:\GITHUB_ACTIONS -ErrorAction SilentlyContinue } + if ($null -ne $script:_s8_savedTfBuild) { $env:TF_BUILD = $script:_s8_savedTfBuild } else { Remove-Item Env:\TF_BUILD -ErrorAction SilentlyContinue } + if ($null -ne $script:_s8_savedGhOutput) { $env:GITHUB_OUTPUT = $script:_s8_savedGhOutput } else { Remove-Item Env:\GITHUB_OUTPUT -ErrorAction SilentlyContinue } + if ($null -ne $script:_s8_savedGhSummary) { $env:GITHUB_STEP_SUMMARY = $script:_s8_savedGhSummary } else { Remove-Item Env:\GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue } + if ($null -ne $script:_s8_savedAdoStage) { $env:BUILD_ARTIFACTSTAGINGDIRECTORY = $script:_s8_savedAdoStage } else { Remove-Item Env:\BUILD_ARTIFACTSTAGINGDIRECTORY -ErrorAction SilentlyContinue } + foreach ($p in @($script:_s8_ghOutputFile, $script:_s8_ghSummaryFile)) { + if ($p -and (Test-Path -LiteralPath $p)) { Remove-Item -LiteralPath $p -Force -ErrorAction SilentlyContinue } + } + if ($script:_s8_outDir -and (Test-Path -LiteralPath $script:_s8_outDir)) { + Remove-Item -LiteralPath $script:_s8_outDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Empty fleet emits empty JUnit XML, zero-valued step outputs, and PassThru with zero counts' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s8_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s8_ghSummaryFile + $global:_s8_payload = @{ Inventory = @(); OutDir = $script:_s8_outDir; Now = $script:_s8_now } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s8_payload.Inventory) } + Mock Get-AzLocalClusterUpdateReadiness { @() } + Mock Get-AzLocalLatestSolutionVersion { throw 'not used in empty path' } + Mock Get-AzLocalUpdateSummary { @() } + Mock Get-AzLocalAvailableUpdates { @() } + Mock Get-AzLocalUpdateRuns { @() } + Mock Get-AzLocalUpdateRunFailures { @() } + Export-AzLocalFleetUpdateStatusReport -OutputDirectory $global:_s8_payload.OutDir -Now $global:_s8_payload.Now -PassThru + } + $result.TotalClusters | Should -Be 0 + $result.CriticalHealthFailed | Should -Be 0 + $result.RunHistoryCount | Should -Be 0 + Test-Path -LiteralPath $result.XmlPath | Should -BeTrue + $xml = [System.IO.File]::ReadAllText($result.XmlPath) + $xml | Should -Match '' + $out = Get-Content -LiteralPath $script:_s8_ghOutputFile -Raw + $out | Should -Match 'update_failed=1' + } + + It 'PreparationFailed cluster is classified as ActionRequired (priority cascade)' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s8_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s8_ghSummaryFile + $global:_s8_payload = @{ + Inventory = @([pscustomobject]@{ ClusterName='gamma'; ResourceId='/subscriptions/s1/resourceGroups/rg3/providers/Microsoft.AzureStackHCI/clusters/gamma' }) + Readiness = @( + [pscustomobject]@{ + ClusterName='gamma'; ResourceGroup='rg3'; SubscriptionId='s1' + ResourceId='/subscriptions/s1/resourceGroups/rg3/providers/Microsoft.AzureStackHCI/clusters/gamma' + UpdateState='PreparationFailed'; HealthState='Success'; ReadyForUpdate=$false + HasPrerequisiteUpdates=''; AllAvailableUpdates=''; ReadyUpdates=''; SBEDependency='' + RecommendedUpdate=''; CurrentVersion='12.2510.0.123' + } + ) + Manifest = [pscustomobject]@{ SupportedYYMMs=@('2510'); LatestYYMM='2510'; LatestVersion='12.2510.0.999'; ManifestFetchedAt=(Get-Date).ToUniversalTime() } + OutDir = $script:_s8_outDir; Now = $script:_s8_now + } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s8_payload.Inventory) } + Mock Get-AzLocalClusterUpdateReadiness { @($global:_s8_payload.Readiness) } + Mock Get-AzLocalLatestSolutionVersion { $global:_s8_payload.Manifest } + Mock Get-AzLocalUpdateSummary { @() } + Mock Get-AzLocalAvailableUpdates { @() } + Mock Get-AzLocalUpdateRuns { @() } + Mock Get-AzLocalUpdateRunFailures { @() } + Export-AzLocalFleetUpdateStatusReport -OutputDirectory $global:_s8_payload.OutDir -Now $global:_s8_payload.Now -PassThru + } + $result.ActionRequiredCount | Should -Be 1 + $result.UpdateFailedCount | Should -Be 0 + $result.CriticalHealthFailed | Should -Be 1 + } + + It 'HealthFailure cluster increments health_failure but not update_failed' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s8_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s8_ghSummaryFile + $global:_s8_payload = @{ + Inventory = @([pscustomobject]@{ ClusterName='delta'; ResourceId='/subscriptions/s1/resourceGroups/rg4/providers/Microsoft.AzureStackHCI/clusters/delta' }) + Readiness = @( + [pscustomobject]@{ + ClusterName='delta'; ResourceGroup='rg4'; SubscriptionId='s1' + ResourceId='/subscriptions/s1/resourceGroups/rg4/providers/Microsoft.AzureStackHCI/clusters/delta' + UpdateState='UpToDate'; HealthState='Failure'; ReadyForUpdate=$false + HasPrerequisiteUpdates=''; AllAvailableUpdates=''; ReadyUpdates=''; SBEDependency='' + RecommendedUpdate=''; CurrentVersion='12.2510.0.0'; HealthCheckFailures='NodeOffline' + } + ) + Manifest = [pscustomobject]@{ SupportedYYMMs=@('2510'); LatestYYMM='2510'; LatestVersion='12.2510.0.999'; ManifestFetchedAt=(Get-Date).ToUniversalTime() } + OutDir = $script:_s8_outDir; Now = $script:_s8_now + } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s8_payload.Inventory) } + Mock Get-AzLocalClusterUpdateReadiness { @($global:_s8_payload.Readiness) } + Mock Get-AzLocalLatestSolutionVersion { $global:_s8_payload.Manifest } + Mock Get-AzLocalUpdateSummary { @() } + Mock Get-AzLocalAvailableUpdates { @() } + Mock Get-AzLocalUpdateRuns { @() } + Mock Get-AzLocalUpdateRunFailures { @() } + Export-AzLocalFleetUpdateStatusReport -OutputDirectory $global:_s8_payload.OutDir -Now $global:_s8_payload.Now -PassThru + } + $result.HealthFailureCount | Should -Be 1 + $result.UpdateFailedCount | Should -Be 0 + $result.CriticalHealthFailed | Should -Be 1 + } + + It 'SBE prerequisite blocked cluster is classified as SbeBlocked' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s8_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s8_ghSummaryFile + $global:_s8_payload = @{ + Inventory = @([pscustomobject]@{ ClusterName='epsilon'; ResourceId='/subscriptions/s1/resourceGroups/rg5/providers/Microsoft.AzureStackHCI/clusters/epsilon' }) + Readiness = @( + [pscustomobject]@{ + ClusterName='epsilon'; ResourceGroup='rg5'; SubscriptionId='s1' + ResourceId='/subscriptions/s1/resourceGroups/rg5/providers/Microsoft.AzureStackHCI/clusters/epsilon' + UpdateState='UpToDate'; HealthState='Success'; ReadyForUpdate=$false + HasPrerequisiteUpdates='SolutionBuilderExtension-Dell-1.0'; AllAvailableUpdates=''; ReadyUpdates=''; SBEDependency='Dell' + RecommendedUpdate=''; CurrentVersion='12.2510.0.0' + } + ) + Manifest = [pscustomobject]@{ SupportedYYMMs=@('2510'); LatestYYMM='2510'; LatestVersion='12.2510.0.999'; ManifestFetchedAt=(Get-Date).ToUniversalTime() } + OutDir = $script:_s8_outDir; Now = $script:_s8_now + } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s8_payload.Inventory) } + Mock Get-AzLocalClusterUpdateReadiness { @($global:_s8_payload.Readiness) } + Mock Get-AzLocalLatestSolutionVersion { $global:_s8_payload.Manifest } + Mock Get-AzLocalUpdateSummary { @() } + Mock Get-AzLocalAvailableUpdates { @() } + Mock Get-AzLocalUpdateRuns { @() } + Mock Get-AzLocalUpdateRunFailures { @() } + Export-AzLocalFleetUpdateStatusReport -OutputDirectory $global:_s8_payload.OutDir -Now $global:_s8_payload.Now -PassThru + } + $result.SbeBlockedCount | Should -Be 1 + $result.HasPrerequisiteCount | Should -Be 1 + $result.CriticalHealthFailed | Should -Be 1 + } + + It 'Version distribution falls back to fleet-observed top-6 YYMM when manifest probe throws' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s8_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s8_ghSummaryFile + $global:_s8_payload = @{ + Inventory = @([pscustomobject]@{ ClusterName='zeta'; ResourceId='/subscriptions/s1/resourceGroups/rg6/providers/Microsoft.AzureStackHCI/clusters/zeta' }) + Readiness = @( + [pscustomobject]@{ + ClusterName='zeta'; ResourceGroup='rg6'; SubscriptionId='s1' + ResourceId='/subscriptions/s1/resourceGroups/rg6/providers/Microsoft.AzureStackHCI/clusters/zeta' + UpdateState='UpToDate'; HealthState='Success'; ReadyForUpdate=$false + HasPrerequisiteUpdates=''; AllAvailableUpdates=''; ReadyUpdates=''; SBEDependency='' + RecommendedUpdate=''; CurrentVersion='12.2509.0.0' + } + ) + OutDir = $script:_s8_outDir; Now = $script:_s8_now + } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s8_payload.Inventory) } + Mock Get-AzLocalClusterUpdateReadiness { @($global:_s8_payload.Readiness) } + Mock Get-AzLocalLatestSolutionVersion { throw 'simulated manifest fetch failure' } + Mock Get-AzLocalUpdateSummary { @() } + Mock Get-AzLocalAvailableUpdates { @() } + Mock Get-AzLocalUpdateRuns { @() } + Mock Get-AzLocalUpdateRunFailures { @() } + Export-AzLocalFleetUpdateStatusReport -OutputDirectory $global:_s8_payload.OutDir -Now $global:_s8_payload.Now -PassThru + } + $result.SupportSource | Should -Be 'fleet-observed' + $result.SupportedClusters | Should -Be 1 # 2509 is in the fleet-observed window + $result.LatestReleasedYymm | Should -BeNullOrEmpty + } + + It 'Unresolved Failed update runs flow into RunHistoryCount + dedicated JUnit suite' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s8_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s8_ghSummaryFile + $start = $script:_s8_now.AddHours(-3) + $end = $script:_s8_now.AddHours(-1) + $global:_s8_payload = @{ + Inventory = @([pscustomobject]@{ ClusterName='eta'; ResourceId='/subscriptions/s1/resourceGroups/rg7/providers/Microsoft.AzureStackHCI/clusters/eta' }) + Readiness = @( + [pscustomobject]@{ + ClusterName='eta'; ResourceGroup='rg7'; SubscriptionId='s1' + ResourceId='/subscriptions/s1/resourceGroups/rg7/providers/Microsoft.AzureStackHCI/clusters/eta' + UpdateState='Failed'; HealthState='Success'; ReadyForUpdate=$false + HasPrerequisiteUpdates=''; AllAvailableUpdates=''; ReadyUpdates=''; SBEDependency='' + RecommendedUpdate=''; CurrentVersion='12.2510.0.0'; HealthCheckFailures='' + } + ) + Manifest = [pscustomobject]@{ SupportedYYMMs=@('2510'); LatestYYMM='2510'; LatestVersion='12.2510.0.999'; ManifestFetchedAt=(Get-Date).ToUniversalTime() } + Failures = @( + [pscustomobject]@{ + ClusterName='eta'; ClusterResourceId='/subscriptions/s1/resourceGroups/rg7/providers/Microsoft.AzureStackHCI/clusters/eta' + UpdateName='12.2510.0.999'; State='Failed'; Status='Failed'; CurrentStep='ApplyNodeUpdate' + Duration='02:00:00'; DurationMinutes=120.0; StartTime=$start; LastUpdated=$end + DeepestStepName='ApplyNodeUpdate'; ErrorCategory='NodeReboot' + DeepestErrMsg='Node reboot timed out after 30 minutes' + UpdateRunPortalUrl='https://portal.azure.com/run/1'; RunId='run-1'; StackTracePreview='' + } + ) + OutDir = $script:_s8_outDir; Now = $script:_s8_now + } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s8_payload.Inventory) } + Mock Get-AzLocalClusterUpdateReadiness { @($global:_s8_payload.Readiness) } + Mock Get-AzLocalLatestSolutionVersion { $global:_s8_payload.Manifest } + Mock Get-AzLocalUpdateSummary { @() } + Mock Get-AzLocalAvailableUpdates { @() } + Mock Get-AzLocalUpdateRuns { @() } + Mock Get-AzLocalUpdateRunFailures { @($global:_s8_payload.Failures) } + Export-AzLocalFleetUpdateStatusReport -OutputDirectory $global:_s8_payload.OutDir -Now $global:_s8_payload.Now -PassThru + } + $result.RunHistoryCount | Should -Be 1 + Test-Path -LiteralPath $result.RunHistoryCsvPath | Should -BeTrue + Test-Path -LiteralPath $result.RunHistoryJsonPath | Should -BeTrue + $xml = [System.IO.File]::ReadAllText($result.XmlPath) + $xml | Should -Match 'Update Run History and Error Details' + $xml | Should -Match 'NodeReboot' + $xml | Should -Match 'Node reboot timed out after 30 minutes' + $out = Get-Content -LiteralPath $script:_s8_ghOutputFile -Raw + $out | Should -Match 'run_history_count=1' + } + + It '-IncludeUpdateRuns:$false skips the Get-AzLocalUpdateRuns step' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s8_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s8_ghSummaryFile + $global:_s8_payload = @{ + Inventory = @([pscustomobject]@{ ClusterName='theta'; ResourceId='/subscriptions/s1/resourceGroups/rg8/providers/Microsoft.AzureStackHCI/clusters/theta' }) + Readiness = @( + [pscustomobject]@{ + ClusterName='theta'; ResourceGroup='rg8'; SubscriptionId='s1' + ResourceId='/subscriptions/s1/resourceGroups/rg8/providers/Microsoft.AzureStackHCI/clusters/theta' + UpdateState='UpToDate'; HealthState='Success'; ReadyForUpdate=$false + HasPrerequisiteUpdates=''; AllAvailableUpdates=''; ReadyUpdates=''; SBEDependency='' + RecommendedUpdate=''; CurrentVersion='12.2510.0.0' + } + ) + Manifest = [pscustomobject]@{ SupportedYYMMs=@('2510'); LatestYYMM='2510'; LatestVersion='12.2510.0.999'; ManifestFetchedAt=(Get-Date).ToUniversalTime() } + OutDir = $script:_s8_outDir; Now = $script:_s8_now + } + InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s8_payload.Inventory) } + Mock Get-AzLocalClusterUpdateReadiness { @($global:_s8_payload.Readiness) } + Mock Get-AzLocalLatestSolutionVersion { $global:_s8_payload.Manifest } + Mock Get-AzLocalUpdateSummary { @() } + Mock Get-AzLocalAvailableUpdates { @() } + Mock Get-AzLocalUpdateRuns { throw 'should not be called when IncludeUpdateRuns is $false' } + Mock Get-AzLocalUpdateRunFailures { @() } + { Export-AzLocalFleetUpdateStatusReport -OutputDirectory $global:_s8_payload.OutDir -Now $global:_s8_payload.Now -IncludeUpdateRuns:$false } | Should -Not -Throw + Assert-MockCalled Get-AzLocalUpdateRuns -Times 0 -Exactly -Scope It + } + } + + It 'Scope=by-update-ring passes ScopeByUpdateRingTag + UpdateRingValue to Get-AzLocalClusterUpdateReadiness' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s8_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s8_ghSummaryFile + $global:_s8_payload = @{ + Inventory = @([pscustomobject]@{ ClusterName='iota'; ResourceId='/subscriptions/s1/resourceGroups/rg9/providers/Microsoft.AzureStackHCI/clusters/iota' }) + Readiness = @( + [pscustomobject]@{ + ClusterName='iota'; ResourceGroup='rg9'; SubscriptionId='s1' + ResourceId='/subscriptions/s1/resourceGroups/rg9/providers/Microsoft.AzureStackHCI/clusters/iota' + UpdateState='UpToDate'; HealthState='Success'; ReadyForUpdate=$false + HasPrerequisiteUpdates=''; AllAvailableUpdates=''; ReadyUpdates=''; SBEDependency='' + RecommendedUpdate=''; CurrentVersion='12.2510.0.0' + } + ) + Manifest = [pscustomobject]@{ SupportedYYMMs=@('2510'); LatestYYMM='2510'; LatestVersion='12.2510.0.999'; ManifestFetchedAt=(Get-Date).ToUniversalTime() } + OutDir = $script:_s8_outDir; Now = $script:_s8_now + } + InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s8_payload.Inventory) } + Mock Get-AzLocalClusterUpdateReadiness { @($global:_s8_payload.Readiness) } + Mock Get-AzLocalLatestSolutionVersion { $global:_s8_payload.Manifest } + Mock Get-AzLocalUpdateSummary { @() } + Mock Get-AzLocalAvailableUpdates { @() } + Mock Get-AzLocalUpdateRuns { @() } + Mock Get-AzLocalUpdateRunFailures { @() } + Export-AzLocalFleetUpdateStatusReport -OutputDirectory $global:_s8_payload.OutDir -Now $global:_s8_payload.Now -Scope 'by-update-ring' -UpdateRing 'Prod' | Out-Null + Assert-MockCalled Get-AzLocalClusterUpdateReadiness -Times 1 -Exactly -Scope It -ParameterFilter { + $ScopeByUpdateRingTag -eq $true -and $UpdateRingValue -eq 'Prod' + } + } + } + + It 'ADO host (TF_BUILD=true) resolves default OutputDirectory to $BUILD_ARTIFACTSTAGINGDIRECTORY/reports' { + $adoStage = Join-Path -Path $env:TEMP -ChildPath ("s8-ado-stage-{0}" -f ([Guid]::NewGuid())) + New-Item -ItemType Directory -Path $adoStage -Force | Out-Null + try { + $env:TF_BUILD = 'True' + $env:BUILD_ARTIFACTSTAGINGDIRECTORY = $adoStage + $global:_s8_payload = @{ Inventory = @(); Now = $script:_s8_now } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s8_payload.Inventory) } + Mock Get-AzLocalClusterUpdateReadiness { @() } + Mock Get-AzLocalLatestSolutionVersion { throw 'unused in empty path' } + Mock Get-AzLocalUpdateSummary { @() } + Mock Get-AzLocalAvailableUpdates { @() } + Mock Get-AzLocalUpdateRuns { @() } + Mock Get-AzLocalUpdateRunFailures { @() } + Export-AzLocalFleetUpdateStatusReport -Now $global:_s8_payload.Now -PassThru + } + $expected = Join-Path -Path $adoStage -ChildPath 'reports' + $result.XmlPath | Should -Be (Join-Path -Path $expected -ChildPath 'readiness-status.xml') + $result.ReadinessJsonPath | Should -Be (Join-Path -Path $expected -ChildPath 'readiness-status.json') + } + finally { + if (Test-Path -LiteralPath $adoStage) { Remove-Item -LiteralPath $adoStage -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + It 'PassThru object includes all 22 step-output values plus generated file paths' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s8_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s8_ghSummaryFile + $global:_s8_payload = @{ + Inventory = @([pscustomobject]@{ ClusterName='kappa'; ResourceId='/subscriptions/s1/resourceGroups/rg10/providers/Microsoft.AzureStackHCI/clusters/kappa' }) + Readiness = @( + [pscustomobject]@{ + ClusterName='kappa'; ResourceGroup='rg10'; SubscriptionId='s1' + ResourceId='/subscriptions/s1/resourceGroups/rg10/providers/Microsoft.AzureStackHCI/clusters/kappa' + UpdateState='UpToDate'; HealthState='Success'; ReadyForUpdate=$false + HasPrerequisiteUpdates=''; AllAvailableUpdates=''; ReadyUpdates=''; SBEDependency='' + RecommendedUpdate=''; CurrentVersion='12.2510.0.0' + } + ) + Manifest = [pscustomobject]@{ SupportedYYMMs=@('2510'); LatestYYMM='2510'; LatestVersion='12.2510.0.999'; ManifestFetchedAt=(Get-Date).ToUniversalTime() } + OutDir = $script:_s8_outDir; Now = $script:_s8_now + } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s8_payload.Inventory) } + Mock Get-AzLocalClusterUpdateReadiness { @($global:_s8_payload.Readiness) } + Mock Get-AzLocalLatestSolutionVersion { $global:_s8_payload.Manifest } + Mock Get-AzLocalUpdateSummary { @() } + Mock Get-AzLocalAvailableUpdates { @() } + Mock Get-AzLocalUpdateRuns { @() } + Mock Get-AzLocalUpdateRunFailures { @() } + Export-AzLocalFleetUpdateStatusReport -OutputDirectory $global:_s8_payload.OutDir -Now $global:_s8_payload.Now -PassThru + } + $expectedProps = @( + 'TotalClusters','CriticalHealthPassed','CriticalHealthFailed', + 'UpdateFailedCount','ActionRequiredCount','HealthFailureCount','SbeBlockedCount', + 'InProgressCount','ReadyForUpdateCount','UpToDateCount','NeedsInvestigationCount', + 'HasPrerequisiteCount','RunHistoryCount','VersionDistCount', + 'SupportedClusters','UnsupportedClusters','UnknownVersionClusters', + 'SupportedYymmWindow','SupportSource','LatestReleasedYymm','LatestReleasedVersion', + 'InventoryCsvPath','ReadinessCsvPath','ReadinessJsonPath','XmlPath', + 'SummariesCsvPath','AvailableCsvPath','RunsCsvPath','RunHistoryCsvPath','RunHistoryJsonPath', + 'SummaryPath','Rows','RunHistoryRows','VersionDistribution' + ) + foreach ($p in $expectedProps) { + $result.PSObject.Properties.Name | Should -Contain $p + } + } + + It 'JUnit XML carries suite-level for the AzureLocalFleetUpdateStatus suite (per-cluster ITSM dedupe keys)' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s8_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s8_ghSummaryFile + $global:_s8_payload = @{ + Inventory = @([pscustomobject]@{ ClusterName='lambda'; ResourceId='/subscriptions/s1/resourceGroups/rg11/providers/Microsoft.AzureStackHCI/clusters/lambda' }) + Readiness = @( + [pscustomobject]@{ + ClusterName='lambda'; ResourceGroup='rg11'; SubscriptionId='s1' + ResourceId='/subscriptions/s1/resourceGroups/rg11/providers/Microsoft.AzureStackHCI/clusters/lambda' + UpdateState='Failed'; HealthState='Success'; ReadyForUpdate=$false + HasPrerequisiteUpdates=''; AllAvailableUpdates=''; ReadyUpdates=''; SBEDependency='' + RecommendedUpdate='12.2510.0.999'; CurrentVersion='12.2509.0.0'; HealthCheckFailures='' + } + ) + Manifest = [pscustomobject]@{ SupportedYYMMs=@('2510'); LatestYYMM='2510'; LatestVersion='12.2510.0.999'; ManifestFetchedAt=(Get-Date).ToUniversalTime() } + OutDir = $script:_s8_outDir; Now = $script:_s8_now + } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s8_payload.Inventory) } + Mock Get-AzLocalClusterUpdateReadiness { @($global:_s8_payload.Readiness) } + Mock Get-AzLocalLatestSolutionVersion { $global:_s8_payload.Manifest } + Mock Get-AzLocalUpdateSummary { @() } + Mock Get-AzLocalAvailableUpdates { @() } + Mock Get-AzLocalUpdateRuns { @() } + Mock Get-AzLocalUpdateRunFailures { @() } + Export-AzLocalFleetUpdateStatusReport -OutputDirectory $global:_s8_payload.OutDir -Now $global:_s8_payload.Now -PassThru + } + $xml = [System.IO.File]::ReadAllText($result.XmlPath) + # Per-testcase properties (required by ITSM dedupe key in New-AzLocalIncident) + $xml | Should -Match '' + $xml | Should -Match '' + $xml | Should -Match '' + $xml | Should -Match '' + # Suite-level properties on AzureLocalFleetUpdateStatus + $xml | Should -Match '' + } +} + +#endregion v0.8.5: Export-AzLocalFleetUpdateStatusReport + + +#region v0.8.5: Export-AzLocalFleetHealthStatusReport (Step.9 thin-YAML port) + +Describe 'Thin-YAML Step.9: Export-AzLocalFleetHealthStatusReport' { + + BeforeEach { + $script:_s9_savedGhActions = $env:GITHUB_ACTIONS + $script:_s9_savedTfBuild = $env:TF_BUILD + $script:_s9_savedGhOutput = $env:GITHUB_OUTPUT + $script:_s9_savedGhSummary = $env:GITHUB_STEP_SUMMARY + $script:_s9_savedAdoStage = $env:BUILD_ARTIFACTSTAGINGDIRECTORY + Remove-Item Env:\GITHUB_ACTIONS -ErrorAction SilentlyContinue + Remove-Item Env:\TF_BUILD -ErrorAction SilentlyContinue + Remove-Item Env:\GITHUB_OUTPUT -ErrorAction SilentlyContinue + Remove-Item Env:\GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue + Remove-Item Env:\BUILD_ARTIFACTSTAGINGDIRECTORY -ErrorAction SilentlyContinue + + $script:_s9_outDir = Join-Path -Path $env:TEMP -ChildPath ("s9-out-{0}" -f ([Guid]::NewGuid())) + $script:_s9_ghOutputFile = Join-Path -Path $env:TEMP -ChildPath ("s9-gh-output-{0}" -f ([Guid]::NewGuid())) + $script:_s9_ghSummaryFile = Join-Path -Path $env:TEMP -ChildPath ("s9-gh-summary-{0}.md" -f ([Guid]::NewGuid())) + New-Item -ItemType Directory -Path $script:_s9_outDir -Force | Out-Null + New-Item -ItemType File -Path $script:_s9_ghOutputFile -Force | Out-Null + New-Item -ItemType File -Path $script:_s9_ghSummaryFile -Force | Out-Null + + $script:_s9_now = [datetime]::SpecifyKind([datetime]'2026-06-10T12:00:00', [DateTimeKind]::Utc) + } + + AfterEach { + if ($null -ne $script:_s9_savedGhActions) { $env:GITHUB_ACTIONS = $script:_s9_savedGhActions } else { Remove-Item Env:\GITHUB_ACTIONS -ErrorAction SilentlyContinue } + if ($null -ne $script:_s9_savedTfBuild) { $env:TF_BUILD = $script:_s9_savedTfBuild } else { Remove-Item Env:\TF_BUILD -ErrorAction SilentlyContinue } + if ($null -ne $script:_s9_savedGhOutput) { $env:GITHUB_OUTPUT = $script:_s9_savedGhOutput } else { Remove-Item Env:\GITHUB_OUTPUT -ErrorAction SilentlyContinue } + if ($null -ne $script:_s9_savedGhSummary) { $env:GITHUB_STEP_SUMMARY = $script:_s9_savedGhSummary } else { Remove-Item Env:\GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue } + if ($null -ne $script:_s9_savedAdoStage) { $env:BUILD_ARTIFACTSTAGINGDIRECTORY = $script:_s9_savedAdoStage } else { Remove-Item Env:\BUILD_ARTIFACTSTAGINGDIRECTORY -ErrorAction SilentlyContinue } + foreach ($p in @($script:_s9_ghOutputFile, $script:_s9_ghSummaryFile)) { + if ($p -and (Test-Path -LiteralPath $p)) { Remove-Item -LiteralPath $p -Force -ErrorAction SilentlyContinue } + } + if ($script:_s9_outDir -and (Test-Path -LiteralPath $script:_s9_outDir)) { + Remove-Item -LiteralPath $script:_s9_outDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Empty fleet emits zero-valued step outputs, placeholder JUnit testcase, and PassThru with zero counts' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s9_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s9_ghSummaryFile + $global:_s9_payload = @{ Detail = @(); Overview = @(); OutDir = $script:_s9_outDir; Now = $script:_s9_now } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalFleetHealthFailures { @($global:_s9_payload.Detail) } + Mock Get-AzLocalFleetHealthOverview { @($global:_s9_payload.Overview) } + Export-AzLocalFleetHealthStatusReport -OutputDirectory $global:_s9_payload.OutDir -Now $global:_s9_payload.Now -PassThru + } + $result.TotalFailures | Should -Be 0 + $result.CriticalCount | Should -Be 0 + $result.WarningCount | Should -Be 0 + $result.DistinctReasons | Should -Be 0 + $result.OverviewRows | Should -Be 0 + Test-Path -LiteralPath $result.XmlPath | Should -BeTrue + $xml = [System.IO.File]::ReadAllText($result.XmlPath) + $xml | Should -Match ' on each testcase + $xml | Should -Match '' + $xml | Should -Match '' + $xml | Should -Match '' + # Markdown summary: target=_blank on cluster portal link + $summary = Get-Content -Raw -LiteralPath $script:_s9_ghSummaryFile + $summary | Should -Match '### Fleet Health Overview' + $summary | Should -Match 'alpha' + $summary | Should -Match '### Health Check Failures By Reason' + $summary | Should -Match '### Detailed Results' + # KPI table values + $summary | Should -Match '\| \*\*Total Failing Checks\*\* \| 3 \|' + $summary | Should -Match '\| \*\*Critical\*\* \| 2 \|' + $summary | Should -Match '\| \*\*Warning\*\* \| 1 \|' + $summary | Should -Match '\| \*\*Healthy Clusters\*\* \| 1 \|' + # step outputs + $out = Get-Content -LiteralPath $script:_s9_ghOutputFile -Raw + $out | Should -Match 'total_failures=3' + $out | Should -Match 'critical_count=2' + $out | Should -Match 'distinct_reasons=2' + $out | Should -Match 'healthy_clusters=1' + $out | Should -Match 'total_in_sub=3' + } + + It 'Scope by-update-ring forwards UpdateRing as -UpdateRingTag to both source cmdlets' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s9_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s9_ghSummaryFile + $global:_s9_payload = @{ OutDir = $script:_s9_outDir; Now = $script:_s9_now } + InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalFleetHealthFailures { @() } + Mock Get-AzLocalFleetHealthOverview { @() } + Export-AzLocalFleetHealthStatusReport -OutputDirectory $global:_s9_payload.OutDir -Now $global:_s9_payload.Now -Scope 'by-update-ring' -UpdateRing 'Prod' | Out-Null + Should -Invoke -CommandName Get-AzLocalFleetHealthFailures -Times 1 -Exactly -ParameterFilter { $UpdateRingTag -eq 'Prod' } + Should -Invoke -CommandName Get-AzLocalFleetHealthOverview -Times 1 -Exactly -ParameterFilter { $UpdateRingTag -eq 'Prod' } + } + } + + It 'Severity filter forwards to Get-AzLocalFleetHealthFailures' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s9_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s9_ghSummaryFile + $global:_s9_payload = @{ OutDir = $script:_s9_outDir; Now = $script:_s9_now } + InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalFleetHealthFailures { @() } + Mock Get-AzLocalFleetHealthOverview { @() } + Export-AzLocalFleetHealthStatusReport -OutputDirectory $global:_s9_payload.OutDir -Now $global:_s9_payload.Now -Severity 'Critical' | Out-Null + Should -Invoke -CommandName Get-AzLocalFleetHealthFailures -Times 1 -Exactly -ParameterFilter { $Severity -eq 'Critical' } + } + } + + It 'Azure DevOps host defaults OutputDirectory to BUILD_ARTIFACTSTAGINGDIRECTORY\reports' { + $tempStage = Join-Path -Path $env:TEMP -ChildPath ("s9-ado-stage-{0}" -f ([Guid]::NewGuid())) + New-Item -ItemType Directory -Path $tempStage -Force | Out-Null + try { + $env:TF_BUILD = 'True' + $env:BUILD_ARTIFACTSTAGINGDIRECTORY = $tempStage + $global:_s9_payload = @{ Detail = @(); Overview = @(); Now = $script:_s9_now } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalFleetHealthFailures { @() } + Mock Get-AzLocalFleetHealthOverview { @() } + Export-AzLocalFleetHealthStatusReport -Now $global:_s9_payload.Now -PassThru + } + $expected = Join-Path -Path $tempStage -ChildPath 'reports' + $result.XmlPath | Should -BeLike "$expected*" + } + finally { + if (Test-Path -LiteralPath $tempStage) { Remove-Item -LiteralPath $tempStage -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + It 'Summary view orders Critical-first then by ClusterCount desc' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s9_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s9_ghSummaryFile + $global:_s9_payload = @{ + # 3 distinct (reason, severity) groups: + # Reason=A Sev=Warning ClusterCount=3 -> rank by severity puts this 3rd + # Reason=B Sev=Critical ClusterCount=1 -> rank by severity puts this 1st (Critical+lower count, but severity wins) + # Reason=C Sev=Critical ClusterCount=2 -> rank by severity 1st-tier; count desc puts this above B + Detail = @( + [pscustomobject]@{ ClusterName='c1'; Severity='Warning'; FailureReason='A'; LastOccurrence=$script:_s9_now; ClusterPortalUrl='https://portal/c1' } + [pscustomobject]@{ ClusterName='c2'; Severity='Warning'; FailureReason='A'; LastOccurrence=$script:_s9_now; ClusterPortalUrl='https://portal/c2' } + [pscustomobject]@{ ClusterName='c3'; Severity='Warning'; FailureReason='A'; LastOccurrence=$script:_s9_now; ClusterPortalUrl='https://portal/c3' } + [pscustomobject]@{ ClusterName='c4'; Severity='Critical'; FailureReason='B'; LastOccurrence=$script:_s9_now; ClusterPortalUrl='https://portal/c4' } + [pscustomobject]@{ ClusterName='c5'; Severity='Critical'; FailureReason='C'; LastOccurrence=$script:_s9_now; ClusterPortalUrl='https://portal/c5' } + [pscustomobject]@{ ClusterName='c6'; Severity='Critical'; FailureReason='C'; LastOccurrence=$script:_s9_now; ClusterPortalUrl='https://portal/c6' } + ) + Overview = @() + OutDir = $script:_s9_outDir; Now = $script:_s9_now + } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalFleetHealthFailures { @($global:_s9_payload.Detail) } + Mock Get-AzLocalFleetHealthOverview { @($global:_s9_payload.Overview) } + Export-AzLocalFleetHealthStatusReport -OutputDirectory $global:_s9_payload.OutDir -Now $global:_s9_payload.Now -PassThru + } + $result.SummaryRows.Count | Should -Be 3 + $result.SummaryRows[0].FailureReason | Should -Be 'C' # Critical, ClusterCount=2 + $result.SummaryRows[0].Severity | Should -Be 'Critical' + $result.SummaryRows[1].FailureReason | Should -Be 'B' # Critical, ClusterCount=1 + $result.SummaryRows[2].FailureReason | Should -Be 'A' # Warning + } +} + +#endregion v0.8.5: Export-AzLocalFleetHealthStatusReport + + +#region v0.8.5: Export-AzLocalClusterUpdateReadinessReport +Describe 'Thin-YAML Step.5: Export-AzLocalClusterUpdateReadinessReport' { + + BeforeEach { + $script:_s5_savedGhActions = $env:GITHUB_ACTIONS + $script:_s5_savedTfBuild = $env:TF_BUILD + $script:_s5_savedGhOutput = $env:GITHUB_OUTPUT + $script:_s5_savedGhSummary = $env:GITHUB_STEP_SUMMARY + $script:_s5_savedAdoStage = $env:BUILD_ARTIFACTSTAGINGDIRECTORY + Remove-Item Env:\GITHUB_ACTIONS -ErrorAction SilentlyContinue + Remove-Item Env:\TF_BUILD -ErrorAction SilentlyContinue + Remove-Item Env:\GITHUB_OUTPUT -ErrorAction SilentlyContinue + Remove-Item Env:\GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue + Remove-Item Env:\BUILD_ARTIFACTSTAGINGDIRECTORY -ErrorAction SilentlyContinue + + $script:_s5_outDir = Join-Path -Path $env:TEMP -ChildPath ("s5-out-{0}" -f ([Guid]::NewGuid())) + $script:_s5_ghOutputFile = Join-Path -Path $env:TEMP -ChildPath ("s5-gh-output-{0}" -f ([Guid]::NewGuid())) + $script:_s5_ghSummaryFile = Join-Path -Path $env:TEMP -ChildPath ("s5-gh-summary-{0}.md" -f ([Guid]::NewGuid())) + New-Item -ItemType Directory -Path $script:_s5_outDir -Force | Out-Null + New-Item -ItemType File -Path $script:_s5_ghOutputFile -Force | Out-Null + New-Item -ItemType File -Path $script:_s5_ghSummaryFile -Force | Out-Null + } + + AfterEach { + if ($null -ne $script:_s5_savedGhActions) { $env:GITHUB_ACTIONS = $script:_s5_savedGhActions } else { Remove-Item Env:\GITHUB_ACTIONS -ErrorAction SilentlyContinue } + if ($null -ne $script:_s5_savedTfBuild) { $env:TF_BUILD = $script:_s5_savedTfBuild } else { Remove-Item Env:\TF_BUILD -ErrorAction SilentlyContinue } + if ($null -ne $script:_s5_savedGhOutput) { $env:GITHUB_OUTPUT = $script:_s5_savedGhOutput } else { Remove-Item Env:\GITHUB_OUTPUT -ErrorAction SilentlyContinue } + if ($null -ne $script:_s5_savedGhSummary) { $env:GITHUB_STEP_SUMMARY = $script:_s5_savedGhSummary } else { Remove-Item Env:\GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue } + if ($null -ne $script:_s5_savedAdoStage) { $env:BUILD_ARTIFACTSTAGINGDIRECTORY = $script:_s5_savedAdoStage } else { Remove-Item Env:\BUILD_ARTIFACTSTAGINGDIRECTORY -ErrorAction SilentlyContinue } + foreach ($p in @($script:_s5_ghOutputFile, $script:_s5_ghSummaryFile)) { + if ($p -and (Test-Path -LiteralPath $p)) { Remove-Item -LiteralPath $p -Force -ErrorAction SilentlyContinue } + } + if ($script:_s5_outDir -and (Test-Path -LiteralPath $script:_s5_outDir)) { + Remove-Item -LiteralPath $script:_s5_outDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Empty inventory short-circuits with IDLE markdown, zero outputs, and PassThru with zero counts' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s5_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s5_ghSummaryFile + $global:_s5_payload = @{ Inventory = @(); OutDir = $script:_s5_outDir } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s5_payload.Inventory) } + Mock Get-AzLocalClusterUpdateReadiness { throw 'should not be called on empty path' } + Mock Test-AzLocalClusterHealth { throw 'should not be called on empty path' } + Export-AzLocalClusterUpdateReadinessReport -OutputDirectory $global:_s5_payload.OutDir -PassThru + } + $result.TotalCount | Should -Be 0 + $result.ReadyForUpdateCount | Should -Be 0 + $result.UpToDateCount | Should -Be 0 + $result.NotReadyCount | Should -Be 0 + $result.CriticalFindings | Should -Be 0 + $result.ClustersWithCritical | Should -Be 0 + $out = Get-Content -LiteralPath $script:_s5_ghOutputFile -Raw + $out | Should -Match 'not_ready=0' + $out | Should -Match 'critical_failures=0' + $summary = Get-Content -LiteralPath $script:_s5_ghSummaryFile -Raw + $summary | Should -Match '\[IDLE\]' + } + + It 'Scope=all forwards ClusterResourceIds from inventory to Get-AzLocalClusterUpdateReadiness' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s5_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s5_ghSummaryFile + $global:_s5_payload = @{ + Inventory = @( + [pscustomobject]@{ ClusterName='alpha'; ResourceId='/subscriptions/s1/resourceGroups/rg1/providers/Microsoft.AzureStackHCI/clusters/alpha'; UpdateRing='Wave1' } + ) + Readiness = @( + [pscustomobject]@{ + ClusterName='alpha'; ClusterResourceId='/subscriptions/s1/resourceGroups/rg1/providers/Microsoft.AzureStackHCI/clusters/alpha' + UpdateState='UpToDate'; HealthState='Success'; ReadyForUpdate=$false + AllAvailableUpdates=''; CurrentVersion='12.2510.0.0'; RecommendedUpdate=''; BlockingReasons='' + } + ) + Health = @([pscustomobject]@{ ClusterName='alpha'; HealthState='Success'; Passed=$true; CriticalCount=0; WarningCount=0; Failures='' }) + OutDir = $script:_s5_outDir + } + InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s5_payload.Inventory) } + Mock Get-AzLocalClusterUpdateReadiness { @($global:_s5_payload.Readiness) } + Mock Test-AzLocalClusterHealth { @($global:_s5_payload.Health) } + Export-AzLocalClusterUpdateReadinessReport -OutputDirectory $global:_s5_payload.OutDir -Scope 'all' | Out-Null + Assert-MockCalled Get-AzLocalClusterUpdateReadiness -Times 2 -Exactly -Scope It -ParameterFilter { + $ClusterResourceIds -and $ClusterResourceIds[0] -like '*/clusters/alpha' + } + } + } + + It 'Scope=by-update-ring forwards ScopeByUpdateRingTag + UpdateRingValue' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s5_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s5_ghSummaryFile + $global:_s5_payload = @{ + Inventory = @([pscustomobject]@{ ClusterName='beta'; ResourceId='/subscriptions/s1/resourceGroups/rg2/providers/Microsoft.AzureStackHCI/clusters/beta'; UpdateRing='Prod' }) + Readiness = @( + [pscustomobject]@{ + ClusterName='beta'; ClusterResourceId='/subscriptions/s1/resourceGroups/rg2/providers/Microsoft.AzureStackHCI/clusters/beta' + UpdateState='UpToDate'; HealthState='Success'; ReadyForUpdate=$false + AllAvailableUpdates=''; CurrentVersion='12.2510.0.0'; RecommendedUpdate=''; BlockingReasons='' + } + ) + Health = @([pscustomobject]@{ ClusterName='beta'; HealthState='Success'; Passed=$true; CriticalCount=0; WarningCount=0; Failures='' }) + OutDir = $script:_s5_outDir + } + InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s5_payload.Inventory) } + Mock Get-AzLocalClusterUpdateReadiness { @($global:_s5_payload.Readiness) } + Mock Test-AzLocalClusterHealth { @($global:_s5_payload.Health) } + Export-AzLocalClusterUpdateReadinessReport -OutputDirectory $global:_s5_payload.OutDir -Scope 'by-update-ring' -UpdateRing 'Prod' | Out-Null + Assert-MockCalled Get-AzLocalClusterUpdateReadiness -Times 2 -Exactly -Scope It -ParameterFilter { + $ScopeByUpdateRingTag -eq $true -and $UpdateRingValue -eq 'Prod' + } + } + } + + It '3-bucket counts: 1 ready, 1 uptodate, 1 notready' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s5_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s5_ghSummaryFile + $global:_s5_payload = @{ + Inventory = @( + [pscustomobject]@{ ClusterName='ready'; ResourceId='/subscriptions/s1/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/ready'; UpdateRing='Wave1' } + [pscustomobject]@{ ClusterName='uptd'; ResourceId='/subscriptions/s1/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/uptd'; UpdateRing='Wave1' } + [pscustomobject]@{ ClusterName='notrdy'; ResourceId='/subscriptions/s1/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/notrdy'; UpdateRing='Wave1' } + ) + Readiness = @( + [pscustomobject]@{ ClusterName='ready'; ClusterResourceId='/subscriptions/s1/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/ready'; + UpdateState='UpdateAvailable'; HealthState='Success'; ReadyForUpdate=$true; AllAvailableUpdates='12.2510.0.999'; CurrentVersion='12.2509.0.0'; RecommendedUpdate='12.2510.0.999'; BlockingReasons='' } + [pscustomobject]@{ ClusterName='uptd'; ClusterResourceId='/subscriptions/s1/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/uptd'; + UpdateState='UpToDate'; HealthState='Success'; ReadyForUpdate=$false; AllAvailableUpdates=''; CurrentVersion='12.2510.0.0'; RecommendedUpdate=''; BlockingReasons='' } + [pscustomobject]@{ ClusterName='notrdy'; ClusterResourceId='/subscriptions/s1/resourceGroups/rg/providers/Microsoft.AzureStackHCI/clusters/notrdy'; + UpdateState='Failed'; HealthState='Failure'; ReadyForUpdate=$false; AllAvailableUpdates='12.2510.0.999'; CurrentVersion='12.2509.0.0'; RecommendedUpdate='12.2510.0.999'; BlockingReasons='Critical Health Status: Failed' } + ) + Health = @( + [pscustomobject]@{ ClusterName='ready'; HealthState='Success'; Passed=$true; CriticalCount=0; WarningCount=0; Failures='' } + [pscustomobject]@{ ClusterName='uptd'; HealthState='Success'; Passed=$true; CriticalCount=0; WarningCount=0; Failures='' } + [pscustomobject]@{ ClusterName='notrdy'; HealthState='Failure'; Passed=$false; CriticalCount=2; WarningCount=1; Failures='NodeOffline; StorageDegraded' } + ) + OutDir = $script:_s5_outDir + } + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s5_payload.Inventory) } + Mock Get-AzLocalClusterUpdateReadiness { @($global:_s5_payload.Readiness) } + Mock Test-AzLocalClusterHealth { @($global:_s5_payload.Health) } + Export-AzLocalClusterUpdateReadinessReport -OutputDirectory $global:_s5_payload.OutDir -PassThru + } + $result.TotalCount | Should -Be 3 + $result.ReadyForUpdateCount | Should -Be 1 + $result.UpToDateCount | Should -Be 1 + $result.NotReadyCount | Should -Be 1 + $result.CriticalFindings | Should -Be 2 + $result.ClustersWithCritical | Should -Be 1 + $out = Get-Content -LiteralPath $script:_s5_ghOutputFile -Raw + $out | Should -Match 'not_ready=1' + $out | Should -Match 'critical_failures=1' + } + + It 'Combined assess-readiness.xml merges both testsuites under the Update Readiness Assessment root' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = $script:_s5_ghOutputFile + $env:GITHUB_STEP_SUMMARY = $script:_s5_ghSummaryFile + $global:_s5_payload = @{ + Inventory = @([pscustomobject]@{ ClusterName='gamma'; ResourceId='/subscriptions/s1/resourceGroups/rg3/providers/Microsoft.AzureStackHCI/clusters/gamma'; UpdateRing='Wave1' }) + Readiness = @( + [pscustomobject]@{ ClusterName='gamma'; ClusterResourceId='/subscriptions/s1/resourceGroups/rg3/providers/Microsoft.AzureStackHCI/clusters/gamma' + UpdateState='UpToDate'; HealthState='Success'; ReadyForUpdate=$false + AllAvailableUpdates=''; CurrentVersion='12.2510.0.0'; RecommendedUpdate=''; BlockingReasons='' } + ) + Health = @([pscustomobject]@{ ClusterName='gamma'; HealthState='Success'; Passed=$true; CriticalCount=0; WarningCount=0; Failures='' }) + OutDir = $script:_s5_outDir + } + # Pre-create the per-cmdlet JUnit XML files so the cmdlet's + # combined-XML merge has something to read. The mocks below cannot + # write to $ExportPath inside a Pester Mock body reliably (Pester + # rebinds parameter scopes), so we materialize the inputs here. + $readinessXmlContent = @' + + +'@ + $healthXmlContent = @' + + +'@ + [System.IO.File]::WriteAllText((Join-Path $script:_s5_outDir 'readiness.xml'), $readinessXmlContent, [System.Text.UTF8Encoding]::new($false)) + [System.IO.File]::WriteAllText((Join-Path $script:_s5_outDir 'health-blocking.xml'), $healthXmlContent, [System.Text.UTF8Encoding]::new($false)) + $result = InModuleScope AzLocal.UpdateManagement { + Mock Get-AzLocalClusterInventory { @($global:_s5_payload.Inventory) } + Mock Get-AzLocalClusterUpdateReadiness { @($global:_s5_payload.Readiness) } + Mock Test-AzLocalClusterHealth { @($global:_s5_payload.Health) } + Export-AzLocalClusterUpdateReadinessReport -OutputDirectory $global:_s5_payload.OutDir -PassThru + } + Test-Path -LiteralPath $result.CombinedXmlPath | Should -BeTrue + $xml = [System.IO.File]::ReadAllText($result.CombinedXmlPath) + $xml | Should -Match ']*type="Critical"' + $xml | Should -Match ' in the Cron coverage suite + non-zero uncovered output' { + $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_PIPELINEDIR = $script:_s3_pipelineDir + + $global:_s3_auditRows = @( (New-S3AuditRow -Status 'Uncovered' -Issue 'No cron entry matches Sat 01:00 +60' -Recommendation 'Add 55 0 * * 6 to Step.6_apply-updates.yml') ) + + $result = InModuleScope AzLocal.UpdateManagement { + Mock Test-AzLocalApplyUpdatesScheduleCoverage { $global:_s3_auditRows } -ParameterFilter { $View -eq 'Audit' } + Mock Test-AzLocalApplyUpdatesScheduleCoverage { } -ParameterFilter { $View -ne 'Audit' } + Export-AzLocalApplyUpdatesScheduleAudit ` + -PipelineYamlPath $env:_S3_PIPELINEDIR ` + -OutputDirectory $env:_S3_OUTDIR ` + -PassThru + } + $result.Uncovered | Should -Be 1 + $xml = Get-Content -LiteralPath $result.JUnitXmlPath -Raw + $xml | Should -Match ']*type="Uncovered"' + $out = Get-Content -LiteralPath $script:_s3_ghOutputFile -Raw + $out | Should -Match 'uncovered=1' + } + + It 'PartiallyCovered status increments the partial output and emits a ' { + $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_PIPELINEDIR = $script:_s3_pipelineDir + + $global:_s3_auditRows = @( (New-S3AuditRow -Status 'PartiallyCovered') ) + + $result = InModuleScope AzLocal.UpdateManagement { + Mock Test-AzLocalApplyUpdatesScheduleCoverage { $global:_s3_auditRows } -ParameterFilter { $View -eq 'Audit' } + Mock Test-AzLocalApplyUpdatesScheduleCoverage { } -ParameterFilter { $View -ne 'Audit' } + Export-AzLocalApplyUpdatesScheduleAudit -PipelineYamlPath $env:_S3_PIPELINEDIR -OutputDirectory $env:_S3_OUTDIR -PassThru + } + $result.Partial | Should -Be 1 + $out = Get-Content -LiteralPath $script:_s3_ghOutputFile -Raw + $out | Should -Match 'partial=1' + $xml = Get-Content -LiteralPath $result.JUnitXmlPath -Raw + $xml | Should -Match ']*type="PartiallyCovered"' + } + + It 'MalformedTag + UnparseableCron each surface as their own status outputs' { + $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_PIPELINEDIR = $script:_s3_pipelineDir + + $global:_s3_auditRows = @( + (New-S3AuditRow -Status 'MalformedTag' -UpdateStartWindow 'BadTag') + (New-S3AuditRow -Status 'UnparseableCron' -UpdateStartWindow 'Sat 25:99') + ) + + $result = InModuleScope AzLocal.UpdateManagement { + Mock Test-AzLocalApplyUpdatesScheduleCoverage { $global:_s3_auditRows } -ParameterFilter { $View -eq 'Audit' } + Mock Test-AzLocalApplyUpdatesScheduleCoverage { } -ParameterFilter { $View -ne 'Audit' } + Export-AzLocalApplyUpdatesScheduleAudit -PipelineYamlPath $env:_S3_PIPELINEDIR -OutputDirectory $env:_S3_OUTDIR -PassThru + } + $result.Malformed | Should -Be 1 + $result.Unparseable | Should -Be 1 + $out = Get-Content -LiteralPath $script:_s3_ghOutputFile -Raw + $out | Should -Match 'malformed=1' + $out | Should -Match 'unparseable=1' + } + + It 'NoWindowTag with ClusterCsvPath populates the no_window_tag output' { + $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_PIPELINEDIR = $script:_s3_pipelineDir + $clusterCsv = Join-Path -Path $env:TEMP -ChildPath ("s3-clusters-{0}.csv" -f ([Guid]::NewGuid())) + Set-Content -LiteralPath $clusterCsv -Value 'ClusterName,Ring' -Encoding ASCII + $env:_S3_CSV = $clusterCsv + + $global:_s3_auditRows = @( (New-S3AuditRow -Status 'NoWindowTag' -UpdateRing 'Wave1' -UpdateStartWindow '') ) + + try { + $result = InModuleScope AzLocal.UpdateManagement { + Mock Test-AzLocalApplyUpdatesScheduleCoverage { $global:_s3_auditRows } -ParameterFilter { $View -eq 'Audit' } + Mock Test-AzLocalApplyUpdatesScheduleCoverage { } -ParameterFilter { $View -ne 'Audit' } + Export-AzLocalApplyUpdatesScheduleAudit ` + -PipelineYamlPath $env:_S3_PIPELINEDIR ` + -ClusterCsvPath $env:_S3_CSV ` + -IncludeUntagged ` + -OutputDirectory $env:_S3_OUTDIR ` + -PassThru + } + $result.NoWindowTag | Should -Be 1 + $out = Get-Content -LiteralPath $script:_s3_ghOutputFile -Raw + $out | Should -Match 'no_window_tag=1' + } + finally { + if (Test-Path -LiteralPath $clusterCsv) { Remove-Item -LiteralPath $clusterCsv -Force -ErrorAction SilentlyContinue } + } + } + + It 'RingMissingFromSchedule routes the row to the Schedule (ring diff) suite' { + $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_PIPELINEDIR = $script:_s3_pipelineDir + $env:_S3_SCHEDULE = $script:_s3_scheduleFile + + $global:_s3_auditRows = @( (New-S3AuditRow -Status 'RingMissingFromSchedule' -Section 'Schedule' -UpdateRing 'Wave9' -UpdateStartWindow '') ) + $global:_s3_schedule = New-S3ScheduleV2 + + $result = 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 { '' } + Export-AzLocalApplyUpdatesScheduleAudit ` + -PipelineYamlPath $env:_S3_PIPELINEDIR ` + -SchedulePath $env:_S3_SCHEDULE ` + -OutputDirectory $env:_S3_OUTDIR ` + -PassThru + } + $result.RingMissing | Should -Be 1 + $xml = Get-Content -LiteralPath $result.JUnitXmlPath -Raw + $xml | Should -Match ']*type="RingMissingFromSchedule"' + $out = Get-Content -LiteralPath $script:_s3_ghOutputFile -Raw + $out | Should -Match 'ring_missing=1' + } + + It 'RingOrphanedInSchedule + RingMixedWindows each populate their own outputs' { + $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_PIPELINEDIR = $script:_s3_pipelineDir + $env:_S3_SCHEDULE = $script:_s3_scheduleFile + + $global:_s3_auditRows = @( + (New-S3AuditRow -Status 'RingOrphanedInSchedule' -Section 'Schedule' -UpdateRing 'WaveOrphan') + (New-S3AuditRow -Status 'RingMixedWindows' -Section 'Cron' -UpdateRing 'Wave1') + ) + $global:_s3_schedule = New-S3ScheduleV2 + + $result = 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 { '' } + Export-AzLocalApplyUpdatesScheduleAudit ` + -PipelineYamlPath $env:_S3_PIPELINEDIR ` + -SchedulePath $env:_S3_SCHEDULE ` + -OutputDirectory $env:_S3_OUTDIR ` + -PassThru + } + $result.RingOrphan | Should -Be 1 + $result.RingMixed | Should -Be 1 + $out = Get-Content -LiteralPath $script:_s3_ghOutputFile -Raw + $out | Should -Match 'ring_orphan=1' + $out | Should -Match 'ring_mixed=1' + } + + It 'Schema v1 schedule emits a migration nudge (not the per-row table)' { + $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_PIPELINEDIR = $script:_s3_pipelineDir + $env:_S3_SCHEDULE = $script:_s3_scheduleFile + + $global:_s3_auditRows = @( (New-S3AuditRow -Status 'Covered') ) + $global:_s3_schedule = New-S3ScheduleV1 + + 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 { '' } + Export-AzLocalApplyUpdatesScheduleAudit ` + -PipelineYamlPath $env:_S3_PIPELINEDIR ` + -SchedulePath $env:_S3_SCHEDULE ` + -OutputDirectory $env:_S3_OUTDIR | Out-Null + } + $summary = Get-Content -LiteralPath $script:_s3_ghSummaryFile -Raw + $summary | Should -Match 'Allow-list coverage \(schema v1\)' + $summary | Should -Match 'Update-AzLocalApplyUpdatesScheduleConfig.*-SchemaMigrate' + $summary | Should -Not -Match 'Top-level fleet default' + } + + It 'Schema v2 schedule emits the per-row Effective allowedUpdateVersions table' { + $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_PIPELINEDIR = $script:_s3_pipelineDir + $env:_S3_SCHEDULE = $script:_s3_scheduleFile + + $global:_s3_auditRows = @( (New-S3AuditRow -Status 'Covered') ) + $global:_s3_schedule = New-S3ScheduleV2 + + 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 { '' } + Export-AzLocalApplyUpdatesScheduleAudit ` + -PipelineYamlPath $env:_S3_PIPELINEDIR ` + -SchedulePath $env:_S3_SCHEDULE ` + -OutputDirectory $env:_S3_OUTDIR | Out-Null + } + $summary = Get-Content -LiteralPath $script:_s3_ghSummaryFile -Raw + $summary | Should -Match 'Allow-list coverage \(schema v2\)' + $summary | Should -Match 'Top-level fleet default' + $summary | Should -Match '\| Row \| weeksInCycle \| daysOfWeek \| rings \| Effective allowedUpdateVersions \|' + } + + It 'PipelineYamlPath that does not exist throws a descriptive error' { + $missing = Join-Path -Path $env:TEMP -ChildPath ("s3-missing-{0}" -f ([Guid]::NewGuid())) + $env:_S3_OUTDIR = $script:_s3_outDir + $env:_S3_MISSING = $missing + InModuleScope AzLocal.UpdateManagement { + { Export-AzLocalApplyUpdatesScheduleAudit -PipelineYamlPath $env:_S3_MISSING -OutputDirectory $env:_S3_OUTDIR } | + Should -Throw -ExpectedMessage "*PipelineYamlPath*does not exist*" + } + } + + It 'ADO host resolves OutputDirectory to BUILD_ARTIFACTSTAGINGDIRECTORY when not explicitly passed' { + $env:TF_BUILD = 'True' + $adoStage = Join-Path -Path $env:TEMP -ChildPath ("s3-ado-{0}" -f ([Guid]::NewGuid())) + New-Item -ItemType Directory -Path $adoStage -Force | Out-Null + $env:BUILD_ARTIFACTSTAGINGDIRECTORY = $adoStage + $env:_S3_PIPELINEDIR = $script:_s3_pipelineDir + + $global:_s3_auditRows = @( (New-S3AuditRow -Status 'Covered') ) + try { + $result = InModuleScope AzLocal.UpdateManagement { + Mock Test-AzLocalApplyUpdatesScheduleCoverage { $global:_s3_auditRows } -ParameterFilter { $View -eq 'Audit' } + Mock Test-AzLocalApplyUpdatesScheduleCoverage { } -ParameterFilter { $View -ne 'Audit' } + Export-AzLocalApplyUpdatesScheduleAudit -PipelineYamlPath $env:_S3_PIPELINEDIR -PassThru + } + $result.JUnitXmlPath | Should -BeLike "$adoStage*" + $result.SummaryPath | Should -BeLike "$adoStage*" + } + finally { + if (Test-Path -LiteralPath $adoStage) { Remove-Item -LiteralPath $adoStage -Recurse -Force -ErrorAction SilentlyContinue } + } + } +} +#endregion v0.8.5: Export-AzLocalApplyUpdatesScheduleAudit + +#region v0.8.5 Step.6 thin-YAML: Apply-Updates pipeline cmdlets +# ----------------------------------------------------------------------------- +# v0.8.5 - Step.6 thin-YAML port: six new public cmdlets that own the +# behaviour previously hand-rolled inline in the Step.6 GH + ADO YAMLs. +# These tests cover the cross-cutting invariants the YAMLs depend on: +# - parameter shape (exists + types + mandatory flags) +# - per-host step-output naming (UPPER_SNAKE on GH, PascalCase on ADO) +# - empty-input short-circuits (no clusters / no schedule row) +# - host-aware icon style in the apply summary +# Engine behaviours (Get-AzLocalClusterUpdateReadiness, Start-AzLocalClusterUpdate, +# New-AzLocalIncident, Resolve-AzLocalCurrentUpdateRing) are mocked so the +# tests stay pure and offline. +# ----------------------------------------------------------------------------- + +Describe 'Thin-YAML Step.6: Resolve-AzLocalPipelineUpdateRing' { + + BeforeAll { + $script:S6PriorTfBuild = $env:TF_BUILD + $script:S6PriorGhActions = $env:GITHUB_ACTIONS + $script:S6PriorGhOutput = $env:GITHUB_OUTPUT + $script:S6PriorGhEnv = $env:GITHUB_ENV + $script:S6PriorBuildArtStage = $env:BUILD_ARTIFACTSTAGINGDIRECTORY + $script:S6PriorGhStepSummary = $env:GITHUB_STEP_SUMMARY + $script:S6TmpRoot = Join-Path -Path $env:TEMP -ChildPath ("s6-cmdlet-{0}" -f ([Guid]::NewGuid())) + New-Item -ItemType Directory -Path $script:S6TmpRoot -Force | Out-Null + } + + AfterAll { + $env:TF_BUILD = $script:S6PriorTfBuild + $env:GITHUB_ACTIONS = $script:S6PriorGhActions + $env:GITHUB_OUTPUT = $script:S6PriorGhOutput + $env:GITHUB_ENV = $script:S6PriorGhEnv + $env:BUILD_ARTIFACTSTAGINGDIRECTORY = $script:S6PriorBuildArtStage + $env:GITHUB_STEP_SUMMARY = $script:S6PriorGhStepSummary + if ($script:S6TmpRoot -and (Test-Path -LiteralPath $script:S6TmpRoot)) { + Remove-Item -LiteralPath $script:S6TmpRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + BeforeEach { + # Default to Local host (no GH / ADO env vars); each test opts in. + Remove-Item Env:TF_BUILD -ErrorAction SilentlyContinue + Remove-Item Env:GITHUB_ACTIONS -ErrorAction SilentlyContinue + Remove-Item Env:GITHUB_OUTPUT -ErrorAction SilentlyContinue + Remove-Item Env:GITHUB_ENV -ErrorAction SilentlyContinue + Remove-Item Env:BUILD_ARTIFACTSTAGINGDIRECTORY -ErrorAction SilentlyContinue + Remove-Item Env:GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue + } + + Context 'Parameter shape' { + BeforeAll { $script:S6CmdR = Get-Command Resolve-AzLocalPipelineUpdateRing } + + It 'Has parameter ManualUpdateRing' { $script:S6CmdR.Parameters.Keys | Should -Contain 'ManualUpdateRing' } + It 'Has parameter UseScheduleFile' { $script:S6CmdR.Parameters.Keys | Should -Contain 'UseScheduleFile' } + It 'Has parameter SchedulePath' { $script:S6CmdR.Parameters.Keys | Should -Contain 'SchedulePath' } + It 'Has parameter ResolveForDateUtc' { $script:S6CmdR.Parameters.Keys | Should -Contain 'ResolveForDateUtc' } + It 'Has parameter Trigger' { $script:S6CmdR.Parameters.Keys | Should -Contain 'Trigger' } + It 'Has parameter PassThru (switch)' { + $script:S6CmdR.Parameters.Keys | Should -Contain 'PassThru' + $script:S6CmdR.Parameters['PassThru'].ParameterType.Name | Should -Be 'SwitchParameter' + } + } + + Context 'Manual back-compat path (no schedule file)' { + It 'Returns ManualUpdateRing verbatim when Trigger=Manual and UseScheduleFile not set' { + $info = Resolve-AzLocalPipelineUpdateRing -Trigger Manual -ManualUpdateRing 'Wave1' -PassThru + $info.ResolvedUpdateRing | Should -Be 'Wave1' + $info.ResolvedAllowedUpdateVersions | Should -Be '' + $info.IsManual | Should -BeTrue + $info.UseScheduleFile | Should -BeFalse + } + + It 'Throws when Trigger=Manual + UseScheduleFile not set + empty ManualUpdateRing' { + { Resolve-AzLocalPipelineUpdateRing -Trigger Manual -ManualUpdateRing '' } | + Should -Throw -ExpectedMessage "*requires -ManualUpdateRing to be non-empty*" + } + } + + Context 'Invalid ResolveForDateUtc' { + It 'Throws on malformed YYYY-MM-DD' { + { Resolve-AzLocalPipelineUpdateRing -Trigger Manual -ManualUpdateRing 'Wave1' -UseScheduleFile -ResolveForDateUtc '07-15-2026' } | + Should -Throw -ExpectedMessage "*not a valid date*" + } + } +} + +Describe 'Thin-YAML Step.6: Export-AzLocalClusterReadinessGateReport' { + + BeforeAll { + $script:S6_2PriorTfBuild = $env:TF_BUILD + $script:S6_2PriorGhActions = $env:GITHUB_ACTIONS + $script:S6_2PriorGhOutput = $env:GITHUB_OUTPUT + $script:S6_2PriorGhStepSummary = $env:GITHUB_STEP_SUMMARY + $script:S6_2TmpRoot = Join-Path -Path $env:TEMP -ChildPath ("s6-cmdlet-readiness-{0}" -f ([Guid]::NewGuid())) + New-Item -ItemType Directory -Path $script:S6_2TmpRoot -Force | Out-Null + } + + AfterAll { + $env:TF_BUILD = $script:S6_2PriorTfBuild + $env:GITHUB_ACTIONS = $script:S6_2PriorGhActions + $env:GITHUB_OUTPUT = $script:S6_2PriorGhOutput + $env:GITHUB_STEP_SUMMARY = $script:S6_2PriorGhStepSummary + if ($script:S6_2TmpRoot -and (Test-Path -LiteralPath $script:S6_2TmpRoot)) { + Remove-Item -LiteralPath $script:S6_2TmpRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + BeforeEach { + Remove-Item Env:TF_BUILD -ErrorAction SilentlyContinue + Remove-Item Env:GITHUB_ACTIONS -ErrorAction SilentlyContinue + Remove-Item Env:GITHUB_OUTPUT -ErrorAction SilentlyContinue + Remove-Item Env:GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue + } + + Context 'Parameter shape' { + BeforeAll { $script:S6CmdE = Get-Command Export-AzLocalClusterReadinessGateReport } + + It 'Has parameter UpdateRing' { $script:S6CmdE.Parameters.Keys | Should -Contain 'UpdateRing' } + It 'Has parameter OutputDirectory' { $script:S6CmdE.Parameters.Keys | Should -Contain 'OutputDirectory' } + It 'Has parameter ReadinessCsvFileName'{ $script:S6CmdE.Parameters.Keys | Should -Contain 'ReadinessCsvFileName' } + It 'Has parameter MaxRows' { $script:S6CmdE.Parameters.Keys | Should -Contain 'MaxRows' } + It 'Has parameter SummaryFileName' { $script:S6CmdE.Parameters.Keys | Should -Contain 'SummaryFileName' } + It 'Has parameter PassThru (switch)' { + $script:S6CmdE.Parameters.Keys | Should -Contain 'PassThru' + $script:S6CmdE.Parameters['PassThru'].ParameterType.Name | Should -Be 'SwitchParameter' + } + } + + Context 'Empty-ring short-circuit (no clusters scanned, zero counters emitted)' { + It 'GitHub host emits READY_COUNT=0 / TOTAL_COUNT=0 / NOT_READY_COUNT=0 to GITHUB_OUTPUT' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = Join-Path $script:S6_2TmpRoot 'gh-empty-output.txt' + $env:GITHUB_STEP_SUMMARY = Join-Path $script:S6_2TmpRoot 'gh-empty-summary.md' + $outDir = Join-Path $script:S6_2TmpRoot 'gh-empty-artifacts' + New-Item -ItemType Directory -Path $outDir -Force | Out-Null + + $info = Export-AzLocalClusterReadinessGateReport -UpdateRing '' -OutputDirectory $outDir -PassThru + $info.ReadyCount | Should -Be 0 + $info.TotalCount | Should -Be 0 + $info.NotReadyCount | Should -Be 0 + $outFile = Get-Content -LiteralPath $env:GITHUB_OUTPUT -Raw + $outFile | Should -Match 'READY_COUNT=0' + $outFile | Should -Match 'TOTAL_COUNT=0' + $outFile | Should -Match 'NOT_READY_COUNT=0' + $outFile | Should -Not -Match 'ReadyCount=0' -Because 'GH host must emit UPPER_SNAKE names, not PascalCase' + } + + It 'Azure DevOps host emits PascalCase ReadyCount/TotalCount/NotReadyCount via ##vso[task.setvariable]' { + $env:TF_BUILD = 'True' + $outDir = Join-Path $script:S6_2TmpRoot 'ado-empty-artifacts' + New-Item -ItemType Directory -Path $outDir -Force | Out-Null + + $writeHostOutput = & { + Export-AzLocalClusterReadinessGateReport -UpdateRing '' -OutputDirectory $outDir + } *>&1 | Out-String + $writeHostOutput | Should -Match '##vso\[task\.setvariable variable=ReadyCount;isOutput=true\]0' + $writeHostOutput | Should -Match '##vso\[task\.setvariable variable=TotalCount;isOutput=true\]0' + $writeHostOutput | Should -Match '##vso\[task\.setvariable variable=NotReadyCount;isOutput=true\]0' + $writeHostOutput | Should -Not -Match 'READY_COUNT=' -Because 'ADO host must not emit UPPER_SNAKE for these counters - the ADO YAML stageDependencies binding uses PascalCase' + } + } +} + +Describe 'Thin-YAML Step.6: Invoke-AzLocalReadinessGatedClusterUpdate' { + + BeforeAll { + $script:S6_3PriorTfBuild = $env:TF_BUILD + $script:S6_3PriorGhActions = $env:GITHUB_ACTIONS + $script:S6_3PriorGhOutput = $env:GITHUB_OUTPUT + $script:S6_3PriorGhStepSummary = $env:GITHUB_STEP_SUMMARY + $script:S6_3TmpRoot = Join-Path -Path $env:TEMP -ChildPath ("s6-cmdlet-apply-{0}" -f ([Guid]::NewGuid())) + New-Item -ItemType Directory -Path $script:S6_3TmpRoot -Force | Out-Null + } + + AfterAll { + $env:TF_BUILD = $script:S6_3PriorTfBuild + $env:GITHUB_ACTIONS = $script:S6_3PriorGhActions + $env:GITHUB_OUTPUT = $script:S6_3PriorGhOutput + $env:GITHUB_STEP_SUMMARY = $script:S6_3PriorGhStepSummary + if ($script:S6_3TmpRoot -and (Test-Path -LiteralPath $script:S6_3TmpRoot)) { + Remove-Item -LiteralPath $script:S6_3TmpRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + BeforeEach { + Remove-Item Env:TF_BUILD -ErrorAction SilentlyContinue + Remove-Item Env:GITHUB_ACTIONS -ErrorAction SilentlyContinue + Remove-Item Env:GITHUB_OUTPUT -ErrorAction SilentlyContinue + Remove-Item Env:GITHUB_STEP_SUMMARY -ErrorAction SilentlyContinue + } + + Context 'Parameter shape' { + BeforeAll { $script:S6CmdI = Get-Command Invoke-AzLocalReadinessGatedClusterUpdate } + + It 'Has mandatory parameter ReadinessCsvPath' { + $p = $script:S6CmdI.Parameters['ReadinessCsvPath'] + $p | Should -Not -BeNullOrEmpty + ($p.Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } | Select-Object -First 1).Mandatory | Should -BeTrue + } + It 'Has parameter UpdateRing' { $script:S6CmdI.Parameters.Keys | Should -Contain 'UpdateRing' } + It 'Has parameter UpdateName' { $script:S6CmdI.Parameters.Keys | Should -Contain 'UpdateName' } + It 'Has parameter DryRun (switch)' { + $script:S6CmdI.Parameters.Keys | Should -Contain 'DryRun' + $script:S6CmdI.Parameters['DryRun'].ParameterType.Name | Should -Be 'SwitchParameter' + } + It 'Has parameter AllowedUpdateVersions' { $script:S6CmdI.Parameters.Keys | Should -Contain 'AllowedUpdateVersions' } + It 'Has parameter OutputDirectory' { $script:S6CmdI.Parameters.Keys | Should -Contain 'OutputDirectory' } + It 'Has parameter JUnitFileName' { $script:S6CmdI.Parameters.Keys | Should -Contain 'JUnitFileName' } + It 'Has parameter ApplyResultsJsonFileName' { $script:S6CmdI.Parameters.Keys | Should -Contain 'ApplyResultsJsonFileName' } + It 'Has parameter PassThru (switch)' { + $script:S6CmdI.Parameters.Keys | Should -Contain 'PassThru' + $script:S6CmdI.Parameters['PassThru'].ParameterType.Name | Should -Be 'SwitchParameter' + } + } + + Context 'Empty-ready short-circuit (zero counters emitted, no apply attempted)' { + It 'Azure DevOps host emits PascalCase counter names when CSV has zero ready rows' { + $env:TF_BUILD = 'True' + $outDir = Join-Path $script:S6_3TmpRoot 'ado-empty-ready' + New-Item -ItemType Directory -Path $outDir -Force | Out-Null + $csv = Join-Path $outDir 'readiness-report.csv' + # Headers (incl. ClusterResourceId) but no Ready=True rows + @' +ClusterName,ClusterResourceId,ReadyForUpdate,UpdateState,HealthState,BlockingReasons,CurrentVersion,RecommendedUpdate +foo,/subscriptions/x/resourceGroups/y/providers/Microsoft.AzureStackHCI/clusters/foo,False,NotReady,Failure,Unhealthy,12.2511.0.301, +'@ | Out-File -FilePath $csv -Encoding utf8 -Force + + $writeHostOutput = & { + Invoke-AzLocalReadinessGatedClusterUpdate -ReadinessCsvPath $csv -UpdateRing 'Wave1' -OutputDirectory $outDir + } *>&1 | Out-String + $writeHostOutput | Should -Match '##vso\[task\.setvariable variable=Succeeded;isOutput=true\]0' + $writeHostOutput | Should -Match '##vso\[task\.setvariable variable=Skipped;isOutput=true\]0' + $writeHostOutput | Should -Match '##vso\[task\.setvariable variable=Failed;isOutput=true\]0' + $writeHostOutput | Should -Match '##vso\[task\.setvariable variable=HealthBlocked;isOutput=true\]0' + $writeHostOutput | Should -Match '##vso\[task\.setvariable variable=ScheduleBlocked;isOutput=true\]0' + $writeHostOutput | Should -Match '##vso\[task\.setvariable variable=SideloadedBlocked;isOutput=true\]0' + $writeHostOutput | Should -Match '##vso\[task\.setvariable variable=ExcludedByTag;isOutput=true\]0' + } + + It 'GitHub host emits UPPER_SNAKE counter names to GITHUB_OUTPUT when CSV has zero ready rows' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = Join-Path $script:S6_3TmpRoot 'gh-empty-ready-output.txt' + $outDir = Join-Path $script:S6_3TmpRoot 'gh-empty-ready' + New-Item -ItemType Directory -Path $outDir -Force | Out-Null + $csv = Join-Path $outDir 'readiness-report.csv' + @' +ClusterName,ClusterResourceId,ReadyForUpdate,UpdateState,HealthState,BlockingReasons,CurrentVersion,RecommendedUpdate +foo,/subscriptions/x/resourceGroups/y/providers/Microsoft.AzureStackHCI/clusters/foo,False,NotReady,Failure,Unhealthy,12.2511.0.301, +'@ | Out-File -FilePath $csv -Encoding utf8 -Force + + Invoke-AzLocalReadinessGatedClusterUpdate -ReadinessCsvPath $csv -UpdateRing 'Wave1' -OutputDirectory $outDir | Out-Null + $outFile = Get-Content -LiteralPath $env:GITHUB_OUTPUT -Raw + foreach ($n in 'SUCCEEDED','SKIPPED','FAILED','HEALTH_BLOCKED','SCHEDULE_BLOCKED','SIDELOADED_BLOCKED','EXCLUDED_BY_TAG') { + $outFile | Should -Match "$n=0" + } + } + } + + Context 'Missing ClusterResourceId column (v0.7.62 contract)' { + It 'Throws when readiness CSV is missing the ClusterResourceId column' { + $env:TF_BUILD = 'True' + $outDir = Join-Path $script:S6_3TmpRoot 'missing-col' + New-Item -ItemType Directory -Path $outDir -Force | Out-Null + $csv = Join-Path $outDir 'readiness-report.csv' + @' +ClusterName,ReadyForUpdate,UpdateState,HealthState,BlockingReasons +foo,True,Ready,Success, +'@ | Out-File -FilePath $csv -Encoding utf8 -Force + + { Invoke-AzLocalReadinessGatedClusterUpdate -ReadinessCsvPath $csv -UpdateRing 'Wave1' -OutputDirectory $outDir } | + Should -Throw -ExpectedMessage "*ClusterResourceId*" + } + } +} + +Describe 'Thin-YAML Step.6: Add-AzLocalApplyUpdatesStepSummary' { + + Context 'Parameter shape' { + BeforeAll { $script:S6CmdS = Get-Command Add-AzLocalApplyUpdatesStepSummary } + + It 'Has mandatory parameter UpdateRing' { + $p = $script:S6CmdS.Parameters['UpdateRing'] + $p | Should -Not -BeNullOrEmpty + ($p.Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } | Select-Object -First 1).Mandatory | Should -BeTrue + } + It 'Has parameter DryRun (switch)' { + $script:S6CmdS.Parameters.Keys | Should -Contain 'DryRun' + $script:S6CmdS.Parameters['DryRun'].ParameterType.Name | Should -Be 'SwitchParameter' + } + It 'Has counter parameters TotalCount/ReadyCount/Succeeded/Skipped/Failed/HealthBlocked/ScheduleBlocked/SideloadedBlocked/ExcludedByTag' { + foreach ($n in 'TotalCount','ReadyCount','Succeeded','Skipped','Failed','HealthBlocked','ScheduleBlocked','SideloadedBlocked','ExcludedByTag') { + $script:S6CmdS.Parameters.Keys | Should -Contain $n -Because "Add-AzLocalApplyUpdatesStepSummary must expose $n" + } + } + It 'Has parameter ApplyResultsJsonPath' { $script:S6CmdS.Parameters.Keys | Should -Contain 'ApplyResultsJsonPath' } + It 'Has parameter ReadinessCsvPath' { $script:S6CmdS.Parameters.Keys | Should -Contain 'ReadinessCsvPath' } + It 'Has parameter SummaryFileName' { $script:S6CmdS.Parameters.Keys | Should -Contain 'SummaryFileName' } + It 'Has parameter PassThru (switch)' { + $script:S6CmdS.Parameters.Keys | Should -Contain 'PassThru' + $script:S6CmdS.Parameters['PassThru'].ParameterType.Name | Should -Be 'SwitchParameter' + } + } +} + +Describe 'Thin-YAML Step.6: Add-AzLocalNoReadyClustersStepSummary' { + + Context 'Parameter shape' { + BeforeAll { $script:S6CmdN = Get-Command Add-AzLocalNoReadyClustersStepSummary } + + It 'Has mandatory parameter UpdateRing' { + $p = $script:S6CmdN.Parameters['UpdateRing'] + $p | Should -Not -BeNullOrEmpty + ($p.Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } | Select-Object -First 1).Mandatory | Should -BeTrue + } + It 'Has parameter TotalCount' { $script:S6CmdN.Parameters.Keys | Should -Contain 'TotalCount' } + It 'Has parameter SummaryFileName' { $script:S6CmdN.Parameters.Keys | Should -Contain 'SummaryFileName' } + It 'Has parameter PassThru (switch)' { + $script:S6CmdN.Parameters.Keys | Should -Contain 'PassThru' + $script:S6CmdN.Parameters['PassThru'].ParameterType.Name | Should -Be 'SwitchParameter' + } + } +} + +Describe 'Thin-YAML Step.6: Invoke-AzLocalItsmTicketingFromArtifact' { + + BeforeAll { + $script:S6_6PriorTfBuild = $env:TF_BUILD + $script:S6_6PriorGhActions = $env:GITHUB_ACTIONS + $script:S6_6TmpRoot = Join-Path -Path $env:TEMP -ChildPath ("s6-cmdlet-itsm-{0}" -f ([Guid]::NewGuid())) + New-Item -ItemType Directory -Path $script:S6_6TmpRoot -Force | Out-Null + } + + AfterAll { + $env:TF_BUILD = $script:S6_6PriorTfBuild + $env:GITHUB_ACTIONS = $script:S6_6PriorGhActions + if ($script:S6_6TmpRoot -and (Test-Path -LiteralPath $script:S6_6TmpRoot)) { + Remove-Item -LiteralPath $script:S6_6TmpRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + BeforeEach { + Remove-Item Env:TF_BUILD -ErrorAction SilentlyContinue + Remove-Item Env:GITHUB_ACTIONS -ErrorAction SilentlyContinue + } + + Context 'Parameter shape' { + BeforeAll { $script:S6CmdT = Get-Command Invoke-AzLocalItsmTicketingFromArtifact } + + It 'Has mandatory parameter ConfigPath' { + $p = $script:S6CmdT.Parameters['ConfigPath'] + $p | Should -Not -BeNullOrEmpty + ($p.Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } | Select-Object -First 1).Mandatory | Should -BeTrue + } + It 'Has parameter InputArtifactPath' { $script:S6CmdT.Parameters.Keys | Should -Contain 'InputArtifactPath' } + It 'Has parameter ExportDirectory' { $script:S6CmdT.Parameters.Keys | Should -Contain 'ExportDirectory' } + It 'Has parameter ExportCsvFileName' { $script:S6CmdT.Parameters.Keys | Should -Contain 'ExportCsvFileName' } + It 'Has parameter ExportJUnitFileName' { $script:S6CmdT.Parameters.Keys | Should -Contain 'ExportJUnitFileName' } + It 'Has parameter DryRun (switch)' { + $script:S6CmdT.Parameters.Keys | Should -Contain 'DryRun' + $script:S6CmdT.Parameters['DryRun'].ParameterType.Name | Should -Be 'SwitchParameter' + } + It 'Has parameter ForceCreate (switch)' { + $script:S6CmdT.Parameters.Keys | Should -Contain 'ForceCreate' + $script:S6CmdT.Parameters['ForceCreate'].ParameterType.Name | Should -Be 'SwitchParameter' + } + It 'Has parameter PassThru (switch)' { + $script:S6CmdT.Parameters.Keys | Should -Contain 'PassThru' + $script:S6CmdT.Parameters['PassThru'].ParameterType.Name | Should -Be 'SwitchParameter' + } + } + + Context 'Short-circuits on missing prerequisites' { + It 'Returns without error when ConfigPath does not exist (does NOT throw)' { + $missing = Join-Path $script:S6_6TmpRoot 'no-such-config.yml' + { Invoke-AzLocalItsmTicketingFromArtifact -ConfigPath $missing -InputArtifactPath (Join-Path $script:S6_6TmpRoot 'whatever.xml') } | Should -Not -Throw + } + } +} + +#endregion v0.8.5 Step.6 thin-YAML: Apply-Updates pipeline cmdlets diff --git a/AzLocal.UpdateManagement/Tests/Live-Integration.Tests.ps1 b/AzLocal.UpdateManagement/Tests/Live-Integration.Tests.ps1 index 46c7a2ee..f6d195b3 100644 --- a/AzLocal.UpdateManagement/Tests/Live-Integration.Tests.ps1 +++ b/AzLocal.UpdateManagement/Tests/Live-Integration.Tests.ps1 @@ -368,3 +368,148 @@ Describe 'Live-Integration: Get-AzLocalFleetConnectivityStatus (v0.7.79)' -Tag ' } } +Describe 'Live-Integration: Export-*Report cmdlets emit non-empty artifacts (v0.8.5 thin-YAML wrappers)' -Tag 'Live' -Skip:$SkipLive { + # v0.8.5: One read-only round-trip per non-destructive Export-* cmdlet + # added by the thin-YAML refactor. Asserts the cmdlet produces the + # documented artifact files (CSV / JSON / JUnit XML / markdown) and the + # -PassThru payload exposes the documented top-level properties. The + # destructive cmdlets Invoke-AzLocalReadinessGatedClusterUpdate and + # Set-AzLocalClusterUpdateRingTagFromCsv are deliberately NOT covered + # here - they need a scratch-cluster fixture pattern (tracked as a + # follow-up: "Live-Integration coverage for destructive v0.8.5 cmdlets"). + # + # Each It block uses a per-test temp OutputDirectory and cleans it up in + # a finally{} so the runner workspace stays tidy even on failure. + + BeforeAll { + $script:liveExportRoot = Join-Path (Get-Item -LiteralPath $env:TEMP).FullName "azlocal-live-exp-$([guid]::NewGuid().Guid.Substring(0,8))" + New-Item -ItemType Directory -Path $script:liveExportRoot -Force | Out-Null + } + + AfterAll { + if ($script:liveExportRoot -and (Test-Path -LiteralPath $script:liveExportRoot)) { + Remove-Item -LiteralPath $script:liveExportRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It '[Step.0] Export-AzLocalAuthValidationReport emits JSON + CSV + JUnit XML and PassThru exposes documented properties' { + $outDir = Join-Path $script:liveExportRoot "s0-$([guid]::NewGuid().Guid.Substring(0,8))" + $r = Export-AzLocalAuthValidationReport -ReportDirectory $outDir -PassThru -ErrorAction Stop + $r | Should -Not -BeNullOrEmpty + $r.AuthValid | Should -BeTrue + $r.SubscriptionCount | Should -BeGreaterThan 0 + $r.ClusterCount | Should -BeGreaterThan 0 + Test-Path -LiteralPath $r.JUnitXmlPath | Should -BeTrue -Because 'Step.0 must emit the JUnit XML' + Test-Path -LiteralPath $r.SubscriptionsJsonPath | Should -BeTrue -Because 'Step.0 must emit the subscriptions JSON' + Test-Path -LiteralPath $r.SubscriptionsCsvPath | Should -BeTrue -Because 'Step.0 must emit the subscriptions CSV' + (Get-Item -LiteralPath $r.JUnitXmlPath).Length | Should -BeGreaterThan 0 + (Get-Item -LiteralPath $r.SubscriptionsJsonPath).Length | Should -BeGreaterThan 0 + } + + It '[Step.3] Export-AzLocalApplyUpdatesScheduleAudit emits audit + matrix CSV, recommend MD, JUnit XML and PassThru exposes counts' { + $modRoot = Split-Path -Path (Get-Module AzLocal.UpdateManagement | Select-Object -First 1).Path -Parent + $schedule = Join-Path $modRoot 'Automation-Pipeline-Examples\apply-updates-schedule.example.yml' + $pipelineYml = Join-Path $modRoot 'Automation-Pipeline-Examples\github-actions\Step.3_apply-updates-schedule-audit.yml' + Test-Path -LiteralPath $schedule | Should -BeTrue -Because 'bundled schedule example must exist' + Test-Path -LiteralPath $pipelineYml | Should -BeTrue -Because 'bundled Step.3 pipeline yml must exist' + + $outDir = Join-Path $script:liveExportRoot "s3-$([guid]::NewGuid().Guid.Substring(0,8))" + $r = Export-AzLocalApplyUpdatesScheduleAudit ` + -OutputDirectory $outDir ` + -PipelineYamlPath $pipelineYml ` + -SchedulePath $schedule ` + -Platform GitHubActions ` + -PassThru -ErrorAction Stop + $r | Should -Not -BeNullOrEmpty + $r.TotalRows | Should -BeGreaterThan 0 + Test-Path -LiteralPath $r.AuditCsvPath | Should -BeTrue + Test-Path -LiteralPath $r.MatrixCsvPath | Should -BeTrue + Test-Path -LiteralPath $r.RecommendMdPath | Should -BeTrue + Test-Path -LiteralPath $r.JUnitXmlPath | Should -BeTrue + (Get-Item -LiteralPath $r.AuditCsvPath).Length | Should -BeGreaterThan 0 + } + + It '[Step.4] Export-AzLocalFleetConnectivityStatusReport emits artifacts and PassThru exposes counts + rollups' { + $outDir = Join-Path $script:liveExportRoot "s4-$([guid]::NewGuid().Guid.Substring(0,8))" + $r = Export-AzLocalFleetConnectivityStatusReport -OutputDirectory $outDir -PassThru -ErrorAction Stop + $r | Should -Not -BeNullOrEmpty + $r.ClusterTotal | Should -BeGreaterThan 0 + $r.PSObject.Properties.Name | Should -Contain 'ArcSummary' + $r.PSObject.Properties.Name | Should -Contain 'NicStats' + $r.PSObject.Properties.Name | Should -Contain 'JUnitXmlPath' + Test-Path -LiteralPath $r.JUnitXmlPath | Should -BeTrue + Test-Path -LiteralPath $r.SummaryPath | Should -BeTrue + } + + It '[Step.5] Export-AzLocalClusterUpdateReadinessReport emits readiness + health artifacts and PassThru exposes counts' { + $outDir = Join-Path $script:liveExportRoot "s5-$([guid]::NewGuid().Guid.Substring(0,8))" + $r = Export-AzLocalClusterUpdateReadinessReport -OutputDirectory $outDir -Scope all -PassThru -ErrorAction Stop + $r | Should -Not -BeNullOrEmpty + $r.TotalCount | Should -BeGreaterThan 0 + Test-Path -LiteralPath $r.ReadinessCsvPath | Should -BeTrue + Test-Path -LiteralPath $r.ReadinessXmlPath | Should -BeTrue + Test-Path -LiteralPath $r.CombinedXmlPath | Should -BeTrue + (Get-Item -LiteralPath $r.ReadinessCsvPath).Length | Should -BeGreaterThan 0 + } + + It '[Step.6] Export-AzLocalClusterReadinessGateReport short-circuits cleanly when -UpdateRing is empty (no-op, read-only)' { + # Empty -UpdateRing is the documented short-circuit. The cmdlet does + # NOT call Get-AzLocalClusterUpdateReadiness and does NOT trigger any + # update; it just emits zero counts. This is the only safe Live test + # for Step.6 - the populated-ring path overlaps Step.5 coverage and + # the apply-updates side (Invoke-AzLocalReadinessGatedClusterUpdate) + # is deliberately deferred (destructive). + $outDir = Join-Path $script:liveExportRoot "s6-$([guid]::NewGuid().Guid.Substring(0,8))" + $r = Export-AzLocalClusterReadinessGateReport -OutputDirectory $outDir -UpdateRing '' -PassThru -ErrorAction Stop + $r | Should -Not -BeNullOrEmpty + $r.TotalCount | Should -Be 0 + $r.ReadyCount | Should -Be 0 + $r.NotReadyCount | Should -Be 0 + $r.UpdateRing | Should -BeNullOrEmpty + $r.Results | Should -BeNullOrEmpty + } + + It '[Step.7] Export-AzLocalUpdateRunMonitorReport emits CSV + JUnit XML and PassThru exposes the in-flight + failure counts' { + $outDir = Join-Path $script:liveExportRoot "s7-$([guid]::NewGuid().Guid.Substring(0,8))" + $r = Export-AzLocalUpdateRunMonitorReport -OutputDirectory $outDir -Scope all -PassThru -ErrorAction Stop + $r | Should -Not -BeNullOrEmpty + $r.PSObject.Properties.Name | Should -Contain 'InFlightCount' + $r.PSObject.Properties.Name | Should -Contain 'UnresolvedFailureCount' + $r.PSObject.Properties.Name | Should -Contain 'CsvPath' + $r.PSObject.Properties.Name | Should -Contain 'XmlPath' + Test-Path -LiteralPath $r.CsvPath | Should -BeTrue + Test-Path -LiteralPath $r.XmlPath | Should -BeTrue + } + + It '[Step.8] Export-AzLocalFleetUpdateStatusReport emits inventory + readiness + run-history artifacts and PassThru exposes version-distribution counts' { + $outDir = Join-Path $script:liveExportRoot "s8-$([guid]::NewGuid().Guid.Substring(0,8))" + $r = Export-AzLocalFleetUpdateStatusReport -OutputDirectory $outDir -Scope all -PassThru -ErrorAction Stop + $r | Should -Not -BeNullOrEmpty + $r.TotalClusters | Should -BeGreaterThan 0 + $r.PSObject.Properties.Name | Should -Contain 'VersionDistCount' + $r.PSObject.Properties.Name | Should -Contain 'InventoryCsvPath' + $r.PSObject.Properties.Name | Should -Contain 'ReadinessCsvPath' + $r.PSObject.Properties.Name | Should -Contain 'XmlPath' + Test-Path -LiteralPath $r.InventoryCsvPath | Should -BeTrue + Test-Path -LiteralPath $r.ReadinessCsvPath | Should -BeTrue + Test-Path -LiteralPath $r.XmlPath | Should -BeTrue + (Get-Item -LiteralPath $r.InventoryCsvPath).Length | Should -BeGreaterThan 0 + } + + It '[Step.9] Export-AzLocalFleetHealthStatusReport emits overview + detail + summary artifacts and PassThru exposes failure counts' { + $outDir = Join-Path $script:liveExportRoot "s9-$([guid]::NewGuid().Guid.Substring(0,8))" + $r = Export-AzLocalFleetHealthStatusReport -OutputDirectory $outDir -Scope all -Severity All -PassThru -ErrorAction Stop + $r | Should -Not -BeNullOrEmpty + $r.TotalClusters | Should -BeGreaterThan 0 + $r.PSObject.Properties.Name | Should -Contain 'CriticalCount' + $r.PSObject.Properties.Name | Should -Contain 'WarningCount' + $r.PSObject.Properties.Name | Should -Contain 'DetailCsvPath' + $r.PSObject.Properties.Name | Should -Contain 'OverviewCsvPath' + $r.PSObject.Properties.Name | Should -Contain 'XmlPath' + Test-Path -LiteralPath $r.OverviewCsvPath | Should -BeTrue + Test-Path -LiteralPath $r.OverviewJsonPath | Should -BeTrue + Test-Path -LiteralPath $r.XmlPath | Should -BeTrue + (Get-Item -LiteralPath $r.OverviewCsvPath).Length | Should -BeGreaterThan 0 + } +} +