Skip to content

Instantly share code, notes, and snippets.

@Badgerati
Last active April 13, 2023 21:53
Show Gist options
  • Save Badgerati/19f2721bc5bf9222417d36362b04d9e2 to your computer and use it in GitHub Desktop.
Save Badgerati/19f2721bc5bf9222417d36362b04d9e2 to your computer and use it in GitHub Desktop.
PowerShell cron expression parser, to check if a date/time matches a cron expression
<#
.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
@mmunchandersen
Copy link

@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?

@Badgerati
Copy link
Author

@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. :)

@mmunchandersen
Copy link

@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
Copy link

@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.

@reijoh
Copy link

reijoh commented Jun 14, 2022

@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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment