Public/Resolve-AzLocalPipelineUpdateRing.ps1
|
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: <none> - 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 } } } |