Private/ConvertFrom-AzLocalCronExpression.ps1
|
function ConvertFrom-AzLocalCronExpression { <# .SYNOPSIS Parses a 5-field cron expression and enumerates every fire time within a reference week (Sunday 00:00 -> Saturday 23:59 UTC). .DESCRIPTION Used by Test-AzLocalApplyUpdatesScheduleCoverage to decide whether a cron entry from Step.6_apply-updates.yml covers any of the maintenance windows derived from cluster UpdateWindow tags. Supports the subset of cron syntax that GitHub Actions and Azure DevOps both honour for `schedule:` / `schedules:` blocks: <Minute> <Hour> <DayOfMonth> <Month> <DayOfWeek> Per field: * <n> (e.g. 5) <n>,<n>,<n>... (e.g. 6,0) <a>-<b> (e.g. 1-5) comma-separated mix (e.g. 0,15,30,45 or 1-3,5) */N (v0.7.67: every N from Min to Max, e.g. */15 in minute field -> 0,15,30,45) <a>-<b>/N (v0.7.67: every N within [a,b], e.g. 9-17/2 in hour field -> 9,11,13,15,17) <a>/N (v0.7.67: every N from a to Max, e.g. 5/15 in minute field -> 5,20,35,50) DayOfMonth and Month MUST be * - any other value returns IsComplex=$true and FireTimes=@(), so the caller can surface "this cron is too complex for the advisor to reason about" rather than emit a wrong answer. DayOfWeek: 0 and 7 both mean Sunday (cron convention). Wrap-around ranges (e.g. 5-1) are NOT supported - use comma syntax (5,6,0,1). .PARAMETER Expression The cron string, e.g. '55 1 * * 6,0'. .OUTPUTS PSCustomObject with: Raw - the input string IsValid - $true if parsed without error IsComplex - $true if DOM or Month are non-* (advisor cannot evaluate) ErrorMessage - parse error if IsValid is $false FireTimes - DateTime[] in a reference week starting Sunday 2024-01-07 00:00 UTC, one entry per cron firing in that week .EXAMPLE ConvertFrom-AzLocalCronExpression -Expression '55 1 * * 6,0' #> [CmdletBinding()] [OutputType([PSCustomObject])] param( # AllowEmptyString lets the IsNullOrWhiteSpace handler below actually run. # Without it, [Parameter(Mandatory)][string] rejects '' at the binder # with 'Cannot bind argument to parameter Expression because it is an # empty string', which crashes any caller piping cron records from a # YAML pre-scan instead of getting a structured invalid result back. [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$Expression ) # Reference week: Sunday 2024-01-07 00:00 UTC -> Saturday 2024-01-13 23:59 UTC. # All FireTimes are within this 7-day window; the caller does day-of-week # matching against the calendar dates rather than DayOfWeek enum directly. $weekStart = [datetime]::new(2024, 1, 7, 0, 0, 0, [DateTimeKind]::Utc) $result = [PSCustomObject]@{ Raw = $Expression IsValid = $false IsComplex = $false ErrorMessage = $null FireTimes = @() } if ([string]::IsNullOrWhiteSpace($Expression)) { $result.ErrorMessage = 'Cron expression is empty.' return $result } $trimmed = $Expression.Trim() $fields = $trimmed -split '\s+' if ($fields.Count -ne 5) { $result.ErrorMessage = "Expected 5 cron fields (M H DoM Month DoW), got $($fields.Count): '$Expression'" return $result } $minField = $fields[0] $hourField = $fields[1] $domField = $fields[2] $monField = $fields[3] $dowField = $fields[4] if ($domField -ne '*' -or $monField -ne '*') { $result.IsValid = $true $result.IsComplex = $true $result.ErrorMessage = "DayOfMonth='$domField' or Month='$monField' is not '*' - advisor cannot reason about month/day-of-month restrictions." return $result } # Inner helper: expand a single field into a sorted, deduped int[] within [min,max]. function Expand-AzLocalCronField { param( [string]$Field, [int]$Min, [int]$Max ) if ($Field -eq '*') { return ($Min..$Max) } $values = @() foreach ($part in $Field -split ',') { $part = $part.Trim() # Step syntax (v0.7.67): '*/N' fires every N starting at $Min; # '<a>-<b>/N' fires every N within the explicit range [a,b]; # '<a>/N' fires every N from a to $Max (cron treats a single # value with a step as 'from a to max in steps of N'). # N must be a positive integer; N==1 is allowed but degenerate. if ($part -match '^(\*|\d+|\d+-\d+)/(\d+)$') { $base = $matches[1] $step = [int]$matches[2] if ($step -le 0) { throw "Invalid step '$part' (step value must be a positive integer)." } $rangeStart = $Min $rangeEnd = $Max if ($base -eq '*') { # full range } elseif ($base -match '^(\d+)-(\d+)$') { $rangeStart = [int]$matches[1]; $rangeEnd = [int]$matches[2] if ($rangeStart -gt $rangeEnd) { throw "Invalid range '$part' (start > end). Wrap-around ranges are not supported - use comma syntax." } } elseif ($base -match '^\d+$') { $rangeStart = [int]$base # rangeEnd stays at $Max so '5/15' under [0,59] means 5,20,35,50. } if ($rangeStart -lt $Min -or $rangeEnd -gt $Max) { throw "Step base '$base' out of bounds [$Min-$Max]." } for ($i = $rangeStart; $i -le $rangeEnd; $i += $step) { $values += $i } } elseif ($part -match '^(\d+)-(\d+)$') { $a = [int]$matches[1]; $b = [int]$matches[2] if ($a -gt $b) { throw "Invalid range '$part' (start > end). Wrap-around ranges are not supported - use comma syntax." } if ($a -lt $Min -or $b -gt $Max) { throw "Range '$part' out of bounds [$Min-$Max]." } for ($i = $a; $i -le $b; $i++) { $values += $i } } elseif ($part -match '^\d+$') { $n = [int]$part if ($n -lt $Min -or $n -gt $Max) { throw "Value '$part' out of bounds [$Min-$Max]." } $values += $n } else { throw "Unsupported cron token '$part' (named months/days and other shorthand are not supported by the advisor)." } } return @($values | Sort-Object -Unique) } try { $minutes = Expand-AzLocalCronField -Field $minField -Min 0 -Max 59 $hours = Expand-AzLocalCronField -Field $hourField -Min 0 -Max 23 $dowRaw = Expand-AzLocalCronField -Field $dowField -Min 0 -Max 7 # Normalise: 7 -> 0 (Sunday). $dows = @($dowRaw | ForEach-Object { if ($_ -eq 7) { 0 } else { $_ } } | Sort-Object -Unique) } catch { $result.ErrorMessage = "Cron field parse error: $($_.Exception.Message)" return $result } # Enumerate fire times across the reference week. Cron day-of-week index: # 0 = Sunday, 1 = Monday, ..., 6 = Saturday. The reference week starts on # Sunday so dayOffset == cron DOW. $fireTimes = New-Object System.Collections.Generic.List[datetime] foreach ($dow in $dows) { $dayDate = $weekStart.AddDays($dow) foreach ($h in $hours) { foreach ($m in $minutes) { $fireTimes.Add($dayDate.Date.AddHours($h).AddMinutes($m)) } } } $result.IsValid = $true $result.IsComplex = $false $result.FireTimes = $fireTimes.ToArray() | Sort-Object return $result } |