-
-
Save Badgerati/19f2721bc5bf9222417d36362b04d9e2 to your computer and use it in GitHub Desktop.
<# | |
.DESCRIPTION | |
PowerShell cron expression parser, to check if a date/time matches a cron expression | |
Format: | |
<min> <hour> <day-of-month> <month> <day-of-week> | |
.PARAMETER Expression | |
A cron expression to validate | |
.PARAMETER DateTime | |
[Optional] A specific date/time to check cron expression against. (Default: DateTime.Now) | |
.EXAMPLE Test expression against the current date/time | |
Test-CronExpression -Expression '5/7 * 29 FEB,MAR *' | |
.EXAMPLE Test expression against a specific date/time | |
Test-CronExpression -Expression '5/7 * 29 FEB,MAR *' -DateTime ([DateTime]::Now) | |
#> | |
param ( | |
[Parameter(Mandatory=$true)] | |
[ValidateNotNullOrEmpty()] | |
[string] | |
$Expression, | |
[Parameter()] | |
$DateTime = $null | |
) | |
function Get-CronFields | |
{ | |
return @( | |
'Minute', | |
'Hour', | |
'DayOfMonth', | |
'Month', | |
'DayOfWeek' | |
) | |
} | |
function Get-CronFieldConstraints | |
{ | |
return @{ | |
'MinMax' = @( | |
@(0, 59), | |
@(0, 23), | |
@(1, 31), | |
@(1, 12), | |
@(0, 6) | |
); | |
'DaysInMonths' = @( | |
31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 | |
); | |
'Months' = @( | |
'January', 'February', 'March', 'April', 'May', 'June', 'July', | |
'August', 'September', 'October', 'November', 'December' | |
) | |
} | |
} | |
function Get-CronPredefined | |
{ | |
return @{ | |
# normal | |
'@minutely' = '* * * * *'; | |
'@hourly' = '0 * * * *'; | |
'@daily' = '0 0 * * *'; | |
'@weekly' = '0 0 * * 0'; | |
'@monthly' = '0 0 1 * *'; | |
'@quarterly' = '0 0 1 1,4,7,10'; | |
'@yearly' = '0 0 1 1 *'; | |
'@annually' = '0 0 1 1 *'; | |
# twice | |
'@semihourly' = '0,30 * * * *'; | |
'@semidaily' = '0 0,12 * * *'; | |
'@semiweekly' = '0 0 * * 0,4'; | |
'@semimonthly' = '0 0 1,15 * *'; | |
'@semiyearly' = '0 0 1 1,6 *'; | |
'@semiannually' = '0 0 1 1,6 *'; | |
} | |
} | |
function Get-CronFieldAliases | |
{ | |
return @{ | |
'Month' = @{ | |
'Jan' = 1; | |
'Feb' = 2; | |
'Mar' = 3; | |
'Apr' = 4; | |
'May' = 5; | |
'Jun' = 6; | |
'Jul' = 7; | |
'Aug' = 8; | |
'Sep' = 9; | |
'Oct' = 10; | |
'Nov' = 11; | |
'Dec' = 12; | |
}; | |
'DayOfWeek' = @{ | |
'Sun' = 0; | |
'Mon' = 1; | |
'Tue' = 2; | |
'Wed' = 3; | |
'Thu' = 4; | |
'Fri' = 5; | |
'Sat' = 6; | |
}; | |
} | |
} | |
function ConvertFrom-CronExpression | |
{ | |
param ( | |
[Parameter(Mandatory=$true)] | |
[ValidateNotNullOrEmpty()] | |
[string] | |
$Expression | |
) | |
$Expression = $Expression.Trim() | |
# check predefineds | |
$predef = Get-CronPredefined | |
if ($null -ne $predef[$Expression]) { | |
$Expression = $predef[$Expression] | |
} | |
# split and check atoms length | |
$atoms = @($Expression -isplit '\s+') | |
if ($atoms.Length -ne 5) { | |
throw "Cron expression should only consist of 5 parts: $($Expression)" | |
} | |
# basic variables | |
$aliasRgx = '(?<tag>[a-z]{3})' | |
# get cron obj and validate atoms | |
$fields = Get-CronFields | |
$constraints = Get-CronFieldConstraints | |
$aliases = Get-CronFieldAliases | |
$cron = @{} | |
for ($i = 0; $i -lt $atoms.Length; $i++) | |
{ | |
$_cronExp = @{ | |
'Range' = $null; | |
'Values' = $null; | |
} | |
$_atom = $atoms[$i] | |
$_field = $fields[$i] | |
$_constraint = $constraints.MinMax[$i] | |
$_aliases = $aliases[$_field] | |
# replace day of week and months with numbers | |
switch ($_field) | |
{ | |
{ $_field -ieq 'month' -or $_field -ieq 'dayofweek' } | |
{ | |
while ($_atom -imatch $aliasRgx) { | |
$_alias = $_aliases[$Matches['tag']] | |
if ($null -eq $_alias) { | |
throw "Invalid $($_field) alias found: $($Matches['tag'])" | |
} | |
$_atom = $_atom -ireplace $Matches['tag'], $_alias | |
$_atom -imatch $aliasRgx | Out-Null | |
} | |
} | |
} | |
# ensure atom is a valid value | |
if (!($_atom -imatch '^[\d|/|*|\-|,]+$')) { | |
throw "Invalid atom character: $($_atom)" | |
} | |
# replace * with min/max constraint | |
$_atom = $_atom -ireplace '\*', ($_constraint -join '-') | |
# parse the atom for either a literal, range, array, or interval | |
# literal | |
if ($_atom -imatch '^\d+$') { | |
$_cronExp.Values = @([int]$_atom) | |
} | |
# range | |
elseif ($_atom -imatch '^(?<min>\d+)\-(?<max>\d+)$') { | |
$_cronExp.Range = @{ 'Min' = [int]($Matches['min'].Trim()); 'Max' = [int]($Matches['max'].Trim()); } | |
} | |
# array | |
elseif ($_atom -imatch '^[\d,]+$') { | |
$_cronExp.Values = [int[]](@($_atom -split ',').Trim()) | |
} | |
# interval | |
elseif ($_atom -imatch '(?<start>(\d+|\*))\/(?<interval>\d+)$') { | |
$start = $Matches['start'] | |
$interval = [int]$Matches['interval'] | |
if ($interval -ieq 0) { | |
$interval = 1 | |
} | |
if ([string]::IsNullOrWhiteSpace($start) -or $start -ieq '*') { | |
$start = 0 | |
} | |
$start = [int]$start | |
$_cronExp.Values = @($start) | |
$next = $start + $interval | |
while ($next -le $_constraint[1]) { | |
$_cronExp.Values += $next | |
$next += $interval | |
} | |
} | |
# error | |
else { | |
throw "Invalid cron atom format found: $($_atom)" | |
} | |
# ensure cron expression values are valid | |
if ($null -ne $_cronExp.Range) { | |
if ($_cronExp.Range.Min -gt $_cronExp.Range.Max) { | |
throw "Min value for $($_field) should not be greater than the max value" | |
} | |
if ($_cronExp.Range.Min -lt $_constraint[0]) { | |
throw "Min value '$($_cronExp.Range.Min)' for $($_field) is invalid, should be greater than/equal to $($_constraint[0])" | |
} | |
if ($_cronExp.Range.Max -gt $_constraint[1]) { | |
throw "Max value '$($_cronExp.Range.Max)' for $($_field) is invalid, should be less than/equal to $($_constraint[1])" | |
} | |
} | |
if ($null -ne $_cronExp.Values) { | |
$_cronExp.Values | ForEach-Object { | |
if ($_ -lt $_constraint[0] -or $_ -gt $_constraint[1]) { | |
throw "Value '$($_)' for $($_field) is invalid, should be between $($_constraint[0]) and $($_constraint[1])" | |
} | |
} | |
} | |
# assign value | |
$cron[$_field] = $_cronExp | |
} | |
# post validation for month/days in month | |
if ($null -ne $cron['Month'].Values -and $null -ne $cron['DayOfMonth'].Values) | |
{ | |
foreach ($mon in $cron['Month'].Values) { | |
foreach ($day in $cron['DayOfMonth'].Values) { | |
if ($day -gt $constraints.DaysInMonths[$mon - 1]) { | |
throw "$($constraints.Months[$mon - 1]) only has $($constraints.DaysInMonths[$mon - 1]) days, but $($day) was supplied" | |
} | |
} | |
} | |
} | |
# return the parsed cron expression | |
return $cron | |
} | |
function Test-RangeAndValue($AtomContraint, $NowValue) { | |
if ($null -ne $AtomContraint.Range) { | |
if ($NowValue -lt $AtomContraint.Range.Min -or $NowValue -gt $AtomContraint.Range.Max) { | |
return $false | |
} | |
} | |
elseif ($AtomContraint.Values -inotcontains $NowValue) { | |
return $false | |
} | |
return $true | |
} | |
# current time | |
if ($null -eq $DateTime) { | |
$DateTime = [datetime]::Now | |
} | |
# convert the expression | |
$Atoms = ConvertFrom-CronExpression -Expression $Expression | |
# check day of month | |
if (!(Test-RangeAndValue -AtomContraint $Atoms.DayOfMonth -NowValue $DateTime.Day)) { | |
return $false | |
} | |
# check day of week | |
if (!(Test-RangeAndValue -AtomContraint $Atoms.DayOfWeek -NowValue ([int]$DateTime.DayOfWeek))) { | |
return $false | |
} | |
# check month | |
if (!(Test-RangeAndValue -AtomContraint $Atoms.Month -NowValue $DateTime.Month)) { | |
return $false | |
} | |
# check hour | |
if (!(Test-RangeAndValue -AtomContraint $Atoms.Hour -NowValue $DateTime.Hour)) { | |
return $false | |
} | |
# check minute | |
if (!(Test-RangeAndValue -AtomContraint $Atoms.Minute -NowValue $DateTime.Minute)) { | |
return $false | |
} | |
# date is valid | |
return $true |
@Vinyjones You're correct, I've fixed it
Thank you for this great Script :)
But wouldn't it make more sense to split the expression for DayOfWeek and DayOfMonth so you can check for Day only? I had to split it to run it on specific days of the month without caring about the day in the week....
@Paxilein - Hi, and thanks!
You're correct, it would make far more sense. I've split the two apart, and also updated with missing @minutely
and @quarterly
predefined types.
I think quarterly should not include the 8 and the semidaily is more like a semihourly?
'@quarterly' = '0 0 1 1,4,8,7,10'; -> '@quarterly' = '0 0 1 1,4,7,10';
'@semidaily' = '0,12 0 * * *'; -> '@semidaily' = '0 0,12 * * *';
Yep, fixed. Cheers
This is neat, what's the license on it?
@Badgerati I am also curious about the license. Could you help to clarify?
@codykonior Apologies, I didn't get a ping for your question! π
@codykonior @saymastermind it's MIT, you can do what ever you want with this function π
The cron expression */15 * * * * doesn't work properly for me unless I change the interval regex to:
'(?<start>(\d+|\*))-?(?<end>(\d+|\*))\/(?<interval>\d+)$'
Otherwise is populated by the last digit of the $_constraint array @(0, 59), so start is in correctly set at 59. I haven't tested rigorously but it appears to work right for minute interval tests.
@Badgerati forgive for asking. Which is the best way to use the function? Can the ps1 file simply be imported from another script or must it be rewritten into a psm1 file?
@sean-mcardle - Have you got the lines you had to alter? I just tried myself but it seems to work ok; and the regex you edited doesn't match */15
π€
@mmunchandersen You can either copy-paste this into your existing scripts/modules. Or, save the file and dot-source it into your scripts. What ever suits you best really. :)
@Badgerati thanks. I ended up keeping your script intact in a a separate file and using the dot-source method like this: "$x = .\Test-CronExpression.ps1 '* 7,12,16 * * 0,1,2,3,4,5'" (great function)
@sean-mcardle - Have you got the lines you had to alter? I just tried myself but it seems to work ok; and the regex you edited doesn't match
*/15
π€
The regex on line 198 is what I updated. I've ended up moving my scheduling needs into Jenkins after trying to get too fancy with things.
@Badgerati I tested this nice script just now and could not quite get it to work with some of my cron schedule expressions. I took a closer look at the code and there are some logical 'bugs' related to ranges of numbers and lists in the expressions. For example, consider odd numbers, e.g. "At every 2nd minute from 1 through 59" using expression "1-59/2 * * * *" or a more complex "At minute 23 past every 2nd hour from 0 through 20 and 4" using expression "23 0-20/2,4 * * *". Well, it just did not work. But I forked and did some changes that seems to work for me. You can find my updates in the gist below. Please feel free to test and include my updates in your script:
https://gist.github.com/reijoh/fffa5642272fdc84d4b7a01691e91795
ConvertFrom-CronExpression function is never user
$Expression.Month how it is a thing ?
Something missing, isn't it ?