Private/Convert-AzLocalUpdateWindowToCron.ps1
|
function Convert-AzLocalUpdateWindowToCron { <# .SYNOPSIS Derives the recommended cron expression(s) needed to fire an apply-updates pipeline at the opening edge of every maintenance window encoded in an UpdateStartWindow tag value. .DESCRIPTION Used by Test-AzLocalApplyUpdatesScheduleCoverage. Reuses the existing ConvertFrom-AzLocalUpdateWindow parser, then for each parsed segment: - computes the fire time = StartTime - LeadTimeMinutes (with day wrap when the fire time goes negative) - converts the DayOfWeek[] set to cron DoW notation (Sun=0, Mon=1, ..., Sat=6 - contiguous sets emit ranges, others emit comma lists) - emits one cron string '<M> <H> * * <DoW>' per window opening edge Same-day window: fire at (start - lead) on each day in the set. Overnight window: the window opens on the listed day(s); fire only on the opening edge - the runtime gate (Test-AzLocalUpdateScheduleAllowed) handles the wrap into the next day. Multi-segment windows like 'Mon-Fri_22:00-04:00;Sat-Sun_02:00-10:00' produce one cron string per segment. Belt-and-braces (FiresPerWindow=2, default 1 for back-compat): When -FiresPerWindow 2 is supplied, each segment ALSO emits a second "retry" cron INSIDE the window. The retry fire time is computed as the lesser of: - the window midpoint (StartTime + (WindowDurationMinutes / 2)), or - 60 minutes after the window opens. The cap keeps retries quick on long windows (a 24h window retries at +60min, not at +12h). Day-shift handling mirrors the opening-edge logic but in the FORWARD direction (e.g. a 23:30-00:30 window with a 30min retry offset retries at 00:00 the NEXT day). Each row's IsRetry property tags whether it is the opening-edge cron (IsRetry=$false, first row per segment) or a retry cron (IsRetry=$true, subsequent rows). .PARAMETER UpdateStartWindow The raw UpdateStartWindow tag value. .PARAMETER LeadTimeMinutes Minutes before the window opens that the pipeline should fire. Default 7 to avoid crowded five-minute scheduler boundaries. .PARAMETER FiresPerWindow How many cron entries to emit per window segment. Default 1 (opening edge only - back-compat for v0.7.91 and earlier callers). Set to 2 for the belt-and-braces pattern: opening edge + one mid-window retry. Range 1-2; values above 2 are reserved for future use. .OUTPUTS PSCustomObject[] - one per window segment when FiresPerWindow=1, two per segment when FiresPerWindow=2. Each row carries: Segment - the raw window segment string Days - DayOfWeek[] CronDoWSet - sorted int[] (cron 0-6) of firing days CronExpression - '<M> <H> * * <DoW>' string suitable for GitHub Actions / ADO FireMinute - int 0-59 FireHour - int 0-23 DayShift - $true if the fire time landed on a different day from the window opening (lead-time pushed backward, or retry offset pushed forward) IsRetry - $false for the opening-edge cron, $true for the belt-and-braces mid-window retry .EXAMPLE Convert-AzLocalUpdateWindowToCron -UpdateStartWindow 'Sat-Sun_02:00-06:00' # Returns one row, CronExpression = '53 1 * * 6,0' .EXAMPLE Convert-AzLocalUpdateWindowToCron -UpdateStartWindow 'Mon-Fri_20:00-23:00' -FiresPerWindow 2 # Returns two rows: # '53 19 * * 1-5' (IsRetry=$false, opening edge minus 7min lead) # '53 20 * * 1-5' (IsRetry=$true, de-contented retry before the +60min target) #> [CmdletBinding()] [OutputType([PSCustomObject[]])] param( [Parameter(Mandatory = $true)] [string]$UpdateStartWindow, [Parameter(Mandatory = $false)] [ValidateRange(0, 60)] [int]$LeadTimeMinutes = 7, [Parameter(Mandatory = $false)] [ValidateRange(1, 2)] [int]$FiresPerWindow = 1 ) # DayOfWeek enum value -> cron DoW int. Cron: Sun=0, Mon=1, ..., Sat=6 - # this also matches the .NET DayOfWeek enum numeric values. $dowToCron = @{ [System.DayOfWeek]::Sunday = 0 [System.DayOfWeek]::Monday = 1 [System.DayOfWeek]::Tuesday = 2 [System.DayOfWeek]::Wednesday = 3 [System.DayOfWeek]::Thursday = 4 [System.DayOfWeek]::Friday = 5 [System.DayOfWeek]::Saturday = 6 } $parsed = ConvertFrom-AzLocalUpdateWindow -WindowString $UpdateStartWindow $output = New-Object System.Collections.Generic.List[PSCustomObject] foreach ($w in $parsed) { # Compute fire time = StartTime - LeadTimeMinutes. If this crosses # midnight backwards, push each firing day back by one (e.g. Mon 00:05 # window with 10min lead fires at Sun 23:55). $startMinutes = ($w.StartTime.Hours * 60) + $w.StartTime.Minutes $fireMinutes = $startMinutes - $LeadTimeMinutes $dayShift = $false if ($fireMinutes -lt 0) { $fireMinutes += (24 * 60) $dayShift = $true } $fireHour = [int]([math]::Floor($fireMinutes / 60)) $fireMinute = $fireMinutes - ($fireHour * 60) # Translate firing days (with optional shift) to cron DoW ints. $cronDows = New-Object System.Collections.Generic.List[int] foreach ($d in $w.Days) { $cronDow = $dowToCron[$d] if ($dayShift) { $cronDow = ($cronDow + 6) % 7 # shift back one day, wrap Sun->Sat } $cronDows.Add($cronDow) } $cronDowSet = @($cronDows | Sort-Object -Unique) # Render DoW set: a contiguous range becomes '<a>-<b>', otherwise a comma list. # Special-case Sun (0) merged with Sat (6) - the parser may emit {0,6} # which is logically Sat-Sun but cron can't express a wrap range, so # emit as '6,0' (Sat first, then Sun) to read like the human tag value # 'Sat-Sun'. Cron treats day lists as unordered so '6,0' and '0,6' are # equivalent at runtime - this is purely cosmetic. $dowStr = if ($cronDowSet.Count -eq 1) { "$($cronDowSet[0])" } elseif ($cronDowSet.Count -eq 2 -and $cronDowSet[0] -eq 0 -and $cronDowSet[1] -eq 6) { '6,0' } elseif ($cronDowSet.Count -gt 1 -and ($cronDowSet[-1] - $cronDowSet[0]) -eq ($cronDowSet.Count - 1)) { "$($cronDowSet[0])-$($cronDowSet[-1])" } else { ($cronDowSet -join ',') } $output.Add([PSCustomObject]@{ Segment = $w.Raw Days = $w.Days CronDoWSet = $cronDowSet CronExpression = "$fireMinute $fireHour * * $dowStr" FireMinute = $fireMinute FireHour = $fireHour DayShift = $dayShift IsRetry = $false }) # Belt-and-braces retry cron (FiresPerWindow=2). Fires INSIDE the # window at min(midpoint, +60min after open) so: # - GitHub Actions scheduled-workflow jitter (~15 min) cannot # cause the opening-edge cron to miss the window entirely # without a second chance, # - a transient first-fire failure (auth, runner exhaustion, # module install hiccup) is retried while the gate is still # open, # - long windows do not wait half the window for the retry # (a 24h window retries at +60min, not at +12h). # Test-AzLocalUpdateScheduleAllowed + the in-flight guard ensure # the retry never double-triggers a cluster whose first run is # already in progress. if ($FiresPerWindow -ge 2) { # Window duration in minutes, supporting overnight wrap. $endMinutes = ($w.EndTime.Hours * 60) + $w.EndTime.Minutes $windowMinutes = $endMinutes - $startMinutes if ($windowMinutes -le 0) { $windowMinutes += (24 * 60) } $retryOffset = [int][math]::Min(($windowMinutes / 2), 60) # Retry must be strictly after the opening edge; if a tiny # window (<= 2min) collapses the offset to 0, skip the retry # rather than emit a duplicate cron. if ($retryOffset -le 0) { continue } # Prefer seven minutes before the natural retry target so generated # schedules do not land on crowded :00/:05 boundaries. For short # windows, clamp to one minute after opening so the retry remains # strictly inside the window. $retryFireMinutes = [math]::Max(($startMinutes + 1), ($startMinutes + $retryOffset - 7)) $retryDayShift = $false if ($retryFireMinutes -ge (24 * 60)) { $retryFireMinutes -= (24 * 60) $retryDayShift = $true } $retryHour = [int]([math]::Floor($retryFireMinutes / 60)) $retryMinute = $retryFireMinutes - ($retryHour * 60) # Retry day shift is FORWARD (next day) - opposite to the # lead-time shift which is BACKWARD (previous day). $retryDows = New-Object System.Collections.Generic.List[int] foreach ($d in $w.Days) { $cronDow = $dowToCron[$d] if ($retryDayShift) { $cronDow = ($cronDow + 1) % 7 } $retryDows.Add($cronDow) } $retryDowSet = @($retryDows | Sort-Object -Unique) $retryDowStr = if ($retryDowSet.Count -eq 1) { "$($retryDowSet[0])" } elseif ($retryDowSet.Count -eq 2 -and $retryDowSet[0] -eq 0 -and $retryDowSet[1] -eq 6) { '6,0' } elseif ($retryDowSet.Count -gt 1 -and ($retryDowSet[-1] - $retryDowSet[0]) -eq ($retryDowSet.Count - 1)) { "$($retryDowSet[0])-$($retryDowSet[-1])" } else { ($retryDowSet -join ',') } $output.Add([PSCustomObject]@{ Segment = $w.Raw Days = $w.Days CronDoWSet = $retryDowSet CronExpression = "$retryMinute $retryHour * * $retryDowStr" FireMinute = $retryMinute FireHour = $retryHour DayShift = $retryDayShift IsRetry = $true }) } } # WARNING: Callers MUST use direct assignment ($x = func ...) and NEVER # wrap with @(func ...). The unary-comma return below preserves Object[N] # shape for any N including 0 and 1, but @() at the call site collapses # to Object[1] containing the inner array, silently producing one-row # output instead of N rows. See `docs/MODULE-REVIEW-AND-RECOMMENDATIONS.md` # Finding 1 for the v0.7.75 incident. return , $output.ToArray() } |