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 5. .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' -LeadTimeMinutes 5 # Returns one row, CronExpression = '55 1 * * 6,0' .EXAMPLE Convert-AzLocalUpdateWindowToCron -UpdateStartWindow 'Mon-Fri_20:00-23:00' -FiresPerWindow 2 # Returns two rows: # '55 19 * * 1-5' (IsRetry=$false, opening edge minus 5min lead) # '0 21 * * 1-5' (IsRetry=$true, window midpoint capped at +60min) #> [CmdletBinding()] [OutputType([PSCustomObject[]])] param( [Parameter(Mandatory = $true)] [string]$UpdateStartWindow, [Parameter(Mandatory = $false)] [ValidateRange(0, 60)] [int]$LeadTimeMinutes = 5, [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 } $retryFireMinutes = $startMinutes + $retryOffset $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() } |