diff --git a/src/MaintenancePlan/Public/Schedule-MaintenancePlan.ps1 b/src/MaintenancePlan/Public/Schedule-MaintenancePlan.ps1 index 878618c8..4a087fa8 100644 --- a/src/MaintenancePlan/Public/Schedule-MaintenancePlan.ps1 +++ b/src/MaintenancePlan/Public/Schedule-MaintenancePlan.ps1 @@ -24,18 +24,92 @@ function Schedule-MaintenancePlan { Assert-ParameterNotNull $PlanPath 'PlanPath' Assert-ParameterNotNull $Cron 'Cron' + function Parse-CronField { + param( + [string]$Value, + [int]$Min, + [int]$Max, + [string]$Name, + [switch]$AllowStar, + [switch]$AllowStep + ) + if ($AllowStar -and $Value -eq '*') { return @{} } + if ($AllowStep -and $Value -match '^\*/(\d+)$') { + $step = [int]$Matches[1] + if ($step -lt 1 -or $step -gt $Max) { throw "Cron $Name step must be between 1 and $Max" } + return @{ Interval = $step } + } + if ($Value -notmatch '^\d+$') { throw "Cron $Name field must be an integer" } + $num = [int]$Value + if ($num -lt $Min -or $num -gt $Max) { throw "Cron $Name field must be between $Min and $Max" } + return @{ Value = $num } + } + + function Convert-Cron { + param([string]$CronExpression) + $parts = $CronExpression -split '\s+' + if ($parts.Length -ne 5) { + throw 'Cron expression must have 5 fields: minute hour day-of-month month day-of-week' + } + $minute = Parse-CronField $parts[0] 0 59 'minute' + $hour = Parse-CronField $parts[1] 0 23 'hour' + $dom = Parse-CronField $parts[2] 1 31 'day-of-month' -AllowStar -AllowStep + $month = Parse-CronField $parts[3] 1 12 'month' -AllowStar + $dow = Parse-CronField $parts[4] 0 7 'day-of-week' -AllowStar -AllowStep + + return [pscustomobject]@{ + Minute = $minute.Value + Hour = $hour.Value + DayOfMonth = $dom.Value + DayInterval = $dom.Interval + Month = $month.Value + DayOfWeek = if ($dow.Value) { if ($dow.Value -eq 7) { 0 } else { $dow.Value } } else { $null } + WeekInterval = $dow.Interval + } + } + $modulePath = Join-Path (Split-Path $PSScriptRoot -Parent) 'MaintenancePlan.psd1' $command = "Import-Module '$modulePath'; `$plan = Import-MaintenancePlan -Path '$PlanPath'; Invoke-MaintenancePlan -Plan `$plan" + $cronParts = Convert-Cron $Cron + if ($IsWindows) { Write-STStatus "Registering scheduled task $Name" -Level INFO -Log - $parts = $Cron -split '\s+' - if ($parts.Length -lt 2) { throw 'Cron expression must include minute and hour' } - $time = '{0:D2}:{1:D2}' -f [int]$parts[1], [int]$parts[0] - $action = New-ScheduledTaskAction -Execute 'pwsh' -Argument "-NoProfile -Command \"$command\"" - $trigger = New-ScheduledTaskTrigger -Daily -At $time + $time = '{0:D2}:{1:D2}' -f $cronParts.Hour, $cronParts.Minute + $action = New-ScheduledTaskAction -Execute 'pwsh' -Argument "-NoProfile -Command \"$command\"" + + $triggerParams = @{ At = $time } + if ($cronParts.DayOfWeek -ne $null -or $cronParts.WeekInterval) { + $triggerParams.Weekly = $true + if ($cronParts.DayOfWeek -ne $null) { + $triggerParams.DaysOfWeek = [System.DayOfWeek]$cronParts.DayOfWeek + } + if ($cronParts.WeekInterval) { + $triggerParams.WeeksInterval = $cronParts.WeekInterval + } + } elseif ($cronParts.DayOfMonth -ne $null -or $cronParts.Month -ne $null) { + $triggerParams.Monthly = $true + if ($cronParts.DayOfMonth -ne $null) { + $triggerParams.DaysOfMonth = $cronParts.DayOfMonth + } + if ($cronParts.Month -ne $null) { + $triggerParams.MonthsOfYear = $cronParts.Month + } + if ($cronParts.DayInterval) { + $triggerParams.DaysInterval = $cronParts.DayInterval + } + } else { + $triggerParams.Daily = $true + if ($cronParts.DayInterval) { + $triggerParams.DaysInterval = $cronParts.DayInterval + } + } + + $trigger = New-ScheduledTaskTrigger @triggerParams Register-ScheduledTask -TaskName $Name -Action $action -Trigger $trigger -Force | Out-Null } else { + # validate cron on non-windows too + $null = $cronParts $entry = "$Cron pwsh -NoProfile -Command \"$command\" # $Name" Write-STStatus "Cron entry generated" -Level INFO -Log return $entry diff --git a/tests/MaintenancePlan.Tests.ps1 b/tests/MaintenancePlan.Tests.ps1 index d000a1f6..503f048e 100644 --- a/tests/MaintenancePlan.Tests.ps1 +++ b/tests/MaintenancePlan.Tests.ps1 @@ -46,11 +46,33 @@ Describe 'MaintenancePlan Module' { Set-Variable -Name IsWindows -Value $true -Scope Script -Force function Register-ScheduledTask {} function New-ScheduledTaskAction { param($Execute,$Argument) $script:arg = $Argument; [pscustomobject]@{Execute=$Execute;Argument=$Argument} } - function New-ScheduledTaskTrigger { param([string]$Daily,[string]$At) [pscustomobject]@{At=$At} } + function New-ScheduledTaskTrigger { + param($At,$Daily,$Weekly,$DaysOfWeek) + $script:trigger = @{ At=$At;Daily=$Daily;Weekly=$Weekly;DaysOfWeek=$DaysOfWeek } + [pscustomobject]@{ At=$At } + } Schedule-MaintenancePlan -PlanPath '/tmp/p.json' -Cron '5 1 * * *' -Name 'MP' $expectedModule = Join-Path (Get-Module MaintenancePlan).ModuleBase 'MaintenancePlan.psd1' $script:arg | Should -Match [Regex]::Escape($expectedModule) $script:arg | Should -Match '/tmp/p.json' + $script:trigger.At | Should -Be '01:05' + $script:trigger.Daily | Should -Be $true + } + } + + Safe-It 'uses day-of-week from cron' { + InModuleScope MaintenancePlan { + Set-Variable -Name IsWindows -Value $true -Scope Script -Force + function Register-ScheduledTask {} + function New-ScheduledTaskAction { param($Execute,$Argument) } + function New-ScheduledTaskTrigger { + param($At,$Weekly,$DaysOfWeek) + $script:trigger = @{ At=$At;Weekly=$Weekly;DaysOfWeek=$DaysOfWeek } + [pscustomobject]@{ At=$At } + } + Schedule-MaintenancePlan -PlanPath '/tmp/p.json' -Cron '5 1 * * 0' -Name 'MP' + $script:trigger.Weekly | Should -Be $true + $script:trigger.DaysOfWeek | Should -Be [System.DayOfWeek]::Sunday } }