Public/Resolve-AzLocalCurrentUpdateRing.ps1
|
function Resolve-AzLocalCurrentUpdateRing { <# .SYNOPSIS Resolves which UpdateRing tag value(s) are eligible to update right now, given an apply-updates-schedule.yml config object and a UTC moment. .DESCRIPTION Implements the v0.7.69 ring-aware scheduling design: 1. Compute (CycleWeek, DayOfWeek) for the supplied UTC moment, anchored at `cycleAnchorISOWeek / cycleAnchorYear`. 2. For each row in `Schedule`, evaluate whether its `weeksInCycle` AND `daysOfWeek` selectors both match (CycleWeek, DayOfWeek). 3. Union the `rings` columns of every matching row, dedupe (case-insensitively), and return the joined ';'-list. If zero rows match, returns a decision with Rings=@() and a human-readable Reason. Step.5 logs it and exits 0 (no error). Wildcards / ranges accepted in both selectors: weeksInCycle '*' | '1' | '1-4' | '1,3,5' | '1-3,5,7' daysOfWeek '*' | 'Mon-Fri' | 'Mon,Wed,Fri' | numeric 0-6 form (0=Sun .. 6=Sat). 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' (case-insensitive) all work. Cycle math uses ISO-8601 week numbers (Monday-start, week 1 is the week containing the first Thursday of the year). The algorithm is portable - no dependency on System.Globalization.ISOWeek (which is .NET Core / PS 7+ only). .PARAMETER Schedule The parsed config object from Get-AzLocalApplyUpdatesScheduleConfig (or the lower-level ConvertFrom-AzLocalScheduleYaml). .PARAMETER Now The UTC moment to resolve. Default: [DateTime]::UtcNow. .OUTPUTS [PSCustomObject] with: Rings [string[]] - deduped, ordered as-encountered UpdateRingValue [string] - ';'-joined for direct hand-off CycleWeek [int] - 1..CycleWeeks DayOfWeek [int] - 0=Sun .. 6=Sat DayOfWeekName [string] - 'Sun' .. 'Sat' Reason [string] - human-readable summary MatchedRows [object[]] - the schedule rows that matched NowUtc [datetime] - the moment used AllowedUpdateVersions [string[]] - resolved allow-list of Azure Local solution-update names / version strings (schema v2 'allowedUpdateVersions' field); empty array when the field is absent everywhere (meaning "no constraint - use cmdlet default of latest Ready update"). AllowedUpdateVersionsValue [string] - ';'-joined for env-var bridge in CI / CD pipelines; '' when AllowedUpdateVersions is empty. AllowedUpdateVersionsSource [string] - 'row' | 'top-level' | 'none' - explains where the allow-list came from for audit / logs. .EXAMPLE $cfg = Get-AzLocalApplyUpdatesScheduleConfig -Path .\.github\apply-updates-schedule.yml $decision = Resolve-AzLocalCurrentUpdateRing -Schedule $cfg if (-not $decision.Rings) { Write-Log "No UpdateRing configured for $($decision.NowUtc.ToString('o')). $($decision.Reason)" -Level Info exit 0 } Start-AzLocalClusterUpdate -ScopeByUpdateRingTag -UpdateRingValue $decision.UpdateRingValue #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory = $true)] [PSCustomObject]$Schedule, [Parameter(Mandatory = $false)] [datetime]$Now = [datetime]::UtcNow ) # ---- ISO-8601 week (year, week) for any DateTime ------------------ # Portable across PS 5.1 / PS 7. Uses the canonical # "Thursday-of-the-week" trick: a date's ISO week number is the # week containing the Thursday that falls in the same week. function Get-AzLocalISOWeek([datetime]$d) { $dayOfWeekIso = ((($d.DayOfWeek.value__ + 6) % 7)) # Mon=0..Sun=6 $thursday = $d.Date.AddDays(3 - $dayOfWeekIso) $isoYear = $thursday.Year $jan4 = [datetime]::new($isoYear, 1, 4, 0, 0, 0, [DateTimeKind]::Utc) $jan4DowIso = ((($jan4.DayOfWeek.value__ + 6) % 7)) $week1Mon = $jan4.AddDays(-1 * $jan4DowIso) $weekNum = [int]([math]::Floor(($thursday - $week1Mon).TotalDays / 7)) + 1 [pscustomobject]@{ Year = $isoYear; Week = $weekNum } } # Build an absolute ordinal: years are at most ~53 ISO-weeks long, so # ordinal = year * 53 + week is monotonic enough for differencing # *across* a few cycles. For correctness across multi-year cycles we # actually sum weeks between (anchorYear, anchorWeek) and (nowYear, # nowWeek) directly via week-count - simpler and exact. function Get-AzLocalWeeksBetween([int]$y1, [int]$w1, [int]$y2, [int]$w2) { # Convert each (Year, Week) to the Monday of its ISO week and # subtract calendar dates. Works for any year range. function MondayOfIsoWeek([int]$y, [int]$w) { $jan4 = [datetime]::new($y, 1, 4, 0, 0, 0, [DateTimeKind]::Utc) $jan4DowIso = ((($jan4.DayOfWeek.value__ + 6) % 7)) $week1Mon = $jan4.AddDays(-1 * $jan4DowIso) return $week1Mon.AddDays(7 * ($w - 1)) } $m1 = MondayOfIsoWeek $y1 $w1 $m2 = MondayOfIsoWeek $y2 $w2 return [int]([math]::Round(($m2 - $m1).TotalDays / 7)) } # ---- weeksInCycle / daysOfWeek expression matchers -------------- function Expand-AzLocalCyclesExpression([string]$expr, [int]$max) { # '*' = entire 1..$max range; otherwise comma-separated ranges/numbers if ($expr -eq '*') { return 1..$max } $out = New-Object System.Collections.Generic.HashSet[int] foreach ($tok in ($expr -split ',')) { $t = $tok.Trim() if ($t -match '^(\d+)-(\d+)$') { $lo = [int]$Matches[1]; $hi = [int]$Matches[2] if ($lo -lt 1 -or $hi -gt $max -or $lo -gt $hi) { throw "Resolve-AzLocalCurrentUpdateRing: weeksInCycle range '$t' is out of 1..$max." } for ($n = $lo; $n -le $hi; $n++) { [void]$out.Add($n) } } elseif ($t -match '^\d+$') { $n = [int]$t if ($n -lt 1 -or $n -gt $max) { throw "Resolve-AzLocalCurrentUpdateRing: weeksInCycle value '$t' is out of 1..$max." } [void]$out.Add($n) } else { throw "Resolve-AzLocalCurrentUpdateRing: weeksInCycle token '$t' must be '*', N, N-M, or a comma list of those." } } return @($out) } $dayNameToNum = @{ 'sun'=0; 'sunday'=0 'mon'=1; 'monday'=1 'tue'=2; 'tuesday'=2 'wed'=3; 'wednesday'=3 'thu'=4; 'thursday'=4 'fri'=5; 'friday'=5 'sat'=6; 'saturday'=6 } function Resolve-AzLocalDayToken([string]$tok) { $t = $tok.Trim().ToLowerInvariant() if ($t -match '^\d+$') { $n = [int]$t if ($n -lt 0 -or $n -gt 6) { throw "Resolve-AzLocalCurrentUpdateRing: daysOfWeek numeric '$tok' must be 0-6 (Sun=0..Sat=6)." } return $n } if ($dayNameToNum.ContainsKey($t)) { return $dayNameToNum[$t] } throw "Resolve-AzLocalCurrentUpdateRing: daysOfWeek token '$tok' is not recognised (expected 0-6 or Sun/Mon/.../Sat)." } function Expand-AzLocalDaysExpression([string]$expr) { if ($expr -eq '*') { return 0..6 } $out = New-Object System.Collections.Generic.HashSet[int] foreach ($tok in ($expr -split ',')) { $t = $tok.Trim() if ($t -match '^(.+?)-(.+)$') { $lo = Resolve-AzLocalDayToken $Matches[1] $hi = Resolve-AzLocalDayToken $Matches[2] if ($lo -le $hi) { for ($n = $lo; $n -le $hi; $n++) { [void]$out.Add($n) } } else { # Wrap-around (e.g. 'Fri-Mon' = 5,6,0,1) for ($n = $lo; $n -le 6; $n++) { [void]$out.Add($n) } for ($n = 0; $n -le $hi; $n++) { [void]$out.Add($n) } } } else { [void]$out.Add((Resolve-AzLocalDayToken $t)) } } return @($out) } # ---- Compute (CycleWeek, DayOfWeek) for $Now in UTC ------------- $nowUtc = $Now.ToUniversalTime() $iso = Get-AzLocalISOWeek $nowUtc $cycleWeeks = [int]$Schedule.CycleWeeks $anchorYr = [int]$Schedule.CycleAnchorYear $anchorWk = [int]$Schedule.CycleAnchorISOWeek $delta = Get-AzLocalWeeksBetween $anchorYr $anchorWk $iso.Year $iso.Week # In PowerShell, ((-1) % 7) = -1, so guard with (((x % m) + m) % m). $cycleWeek = ((($delta % $cycleWeeks) + $cycleWeeks) % $cycleWeeks) + 1 $dowNum = [int]$nowUtc.DayOfWeek.value__ # 0=Sun..6=Sat $dowName = ([System.Globalization.CultureInfo]::InvariantCulture.DateTimeFormat.GetAbbreviatedDayName($nowUtc.DayOfWeek)) # ---- Walk schedule, union matching rows ------------------------- $rings = New-Object System.Collections.Generic.List[string] $seen = New-Object System.Collections.Generic.HashSet[string] ([System.StringComparer]::OrdinalIgnoreCase) $matched = New-Object System.Collections.Generic.List[object] foreach ($row in @($Schedule.Schedule)) { $weeks = Expand-AzLocalCyclesExpression $row.weeksInCycle $cycleWeeks if (-not ($weeks -contains $cycleWeek)) { continue } $days = Expand-AzLocalDaysExpression $row.daysOfWeek if (-not ($days -contains $dowNum)) { continue } $matched.Add($row) | Out-Null foreach ($r in ($row.rings -split ';')) { $tr = $r.Trim() if ($tr -and $seen.Add($tr)) { $rings.Add($tr) | Out-Null } } } # ---- Resolve AllowedUpdateVersions (schema v2) ------------------ # Precedence: # 1. If ANY matched row has its own allowedUpdateVersions, take # the UNION across those rows (rows WITHOUT the field are # treated as "no opinion" - they do not collapse the allow-list # to empty). Source = 'row'. # 2. Else if top-level allowedUpdateVersions exists, use it. # Source = 'top-level'. # 3. Else empty array + Source = 'none' = "no constraint". # The validator pre-parsed both into typed arrays so this resolver # does not re-split. # # 'Latest' sentinel (v0.7.89): If the effective allow-list contains # the reserved sentinel 'Latest' (case-insensitive), the result is # treated as "no constraint" - AllowedUpdateVersions is emitted as # an empty array and the picker / pipeline falls back to the # historic "install latest Ready update" behaviour. The Source field # still reflects the origin (row / top-level) so audit logs explain # WHERE the 'Latest' decision came from. Cross-row UNION semantics: # if any matching row contributes 'Latest', the UNION is 'Latest' # (the explicit allow-lists on sibling rows are ignored for that # day). This is the only way to combine 'Latest' with explicit # versions across rows; mixing them WITHIN a single field is # rejected at validation. $allowList = New-Object System.Collections.Generic.List[string] $allowSeen = New-Object System.Collections.Generic.HashSet[string] ([System.StringComparer]::OrdinalIgnoreCase) $allowSource = 'none' $rowsWithAllow = @($matched | Where-Object { $_.PSObject.Properties.Match('AllowedUpdateVersionsParsed').Count -gt 0 -and $null -ne $_.AllowedUpdateVersionsParsed -and @($_.AllowedUpdateVersionsParsed).Count -gt 0 }) if ($rowsWithAllow.Count -gt 0) { $allowSource = 'row' foreach ($r in $rowsWithAllow) { foreach ($v in @($r.AllowedUpdateVersionsParsed)) { $sv = [string]$v if ($sv -and $allowSeen.Add($sv)) { $allowList.Add($sv) | Out-Null } } } } elseif ($Schedule.PSObject.Properties.Match('AllowedUpdateVersions').Count -gt 0 -and $null -ne $Schedule.AllowedUpdateVersions -and @($Schedule.AllowedUpdateVersions).Count -gt 0) { $allowSource = 'top-level' foreach ($v in @($Schedule.AllowedUpdateVersions)) { $sv = [string]$v if ($sv -and $allowSeen.Add($sv)) { $allowList.Add($sv) | Out-Null } } } # Detect the 'Latest' sentinel in the resolved UNION. When present, # collapse the effective allow-list to empty (= no constraint). Use # ordinal-case-insensitive compare (the validator already # canonicalised tokens to 'Latest', but be defensive in case a # caller constructs the schedule object directly). $hasLatestSentinel = $false foreach ($v in $allowList) { if ([string]::Equals($v, 'Latest', [System.StringComparison]::OrdinalIgnoreCase)) { $hasLatestSentinel = $true break } } if ($hasLatestSentinel) { $allowArray = @() $allowValue = '' } else { $allowArray = $allowList.ToArray() $allowValue = ($allowArray -join ';') } $reason = if ($matched.Count -eq 0) { "No schedule row matches (cycleWeek=$cycleWeek of $cycleWeeks, dayOfWeek=$dowName)." } else { $base = "Matched $($matched.Count) schedule row(s) for cycleWeek=$cycleWeek of $cycleWeeks, dayOfWeek=$dowName. Resolved UpdateRing(s): $($rings -join ', ')." if ($allowSource -ne 'none') { if ($hasLatestSentinel) { $base + " AllowedUpdateVersions ($allowSource): Latest (no constraint - install latest Ready update)." } else { $base + " AllowedUpdateVersions ($allowSource): $($allowArray -join ', ')." } } else { $base } } return [pscustomobject]@{ Rings = $rings.ToArray() UpdateRingValue = ($rings -join ';') CycleWeek = $cycleWeek DayOfWeek = $dowNum DayOfWeekName = $dowName Reason = $reason MatchedRows = $matched.ToArray() NowUtc = $nowUtc AllowedUpdateVersions = $allowArray AllowedUpdateVersionsValue = $allowValue AllowedUpdateVersionsSource = $allowSource } } |