Sunday, September 05, 2021

psCron | psTaskScheduler

Not into board games? Need a challenge? Re-write a utility. 

So I was considering the inner workings of a task scheduler and figured I'd re-write, though with less polish, the classic cron command from Linux. Of course my needs are to run Powershell and other tasks and while Windows Task Scheduler works fine in most cases this was simply a personal challenge. This time in Powershell, but perhaps next time it'll happen in Python or Rust so I can turn it into a Windows Service.

Though, you can turn a Powershell Script into a Windows Service: link

So, to start I used the following accepted rules of Cron:

Again, I'm not perfect but I enjoy the challenges of coding, figuring out how to make a computer do what I'd like it to. You may write better code than me and any comments I hope are what we like to call constructive criticism rather than hurling insults but here we go explaining my process...


First-off, the format is well established, as noted above, and I've aimed to follow this as accurately as possible, though at this time the */x tasks are only on the Hour and Minute attributes. I created a sample psTaskScheduler.jobs file and worked from there. Because I wanted this to run indefinitely the exit is triggered by adding a file called psTaskScheduler.quit and killing the "in-use" indicator of a psTaskScheduler.running file. Cheesy but effective methods. If you crash or abort the script you need to use the quit option to reset it. I might change this if I moved this up to being a Windows Service.

I define the parameters (command-line options) for the functions I want in the script including quit, debug, audio, and log (logging is not done yet). This is as much for planning as it is the practical side, but I follow this up with a help page of sorts. I may add a help parameter later (-h | -help).

Lastly I define a few variables. Global variables are best to use sparingly but for this it seemed logical and a couple of these may vanish as I head into cleaning up the code.

I try to use function main{} and position it at the top of the file and end the script with:

All of the supporting functions are between the closing } of the main function and that # for main. In this case the main function calls testForEcho() and loadSchedules() within the primary loop that persists through the life of the script.

The main() [function] is comprised of the header/title, handling a stop request, setting the running state, the primary loop, and the post-op that wraps things up. 

The Header:

The header is mostly to establish what the script is going to be for the user. They (you?) would probably like to know what you're running and who wrote it I had some fun and improvised an asterisk-based Canadian flag into this. 

The Stop Request (brakes):

The parameter for -q/quit is in the variable $stopTS which is boolean (true/false) these are simple to use with an if as they are absolute. This code will only run when a -q or -quit (or technically -stopTS) are used in the command line. We move on to Test-Path to check of the file psTaskScheduler.quit already exists because why do again what you have already done? Much like when you're riding a modern bus you can't ring the bell more than once.

We move on to creating the .quit file and clearing the .running file if present.

Finally we let the user know the stop has been requested and exit.

Set The Running State:

So, this is the real startup of the script where we establish the baselines. We create a psTaskScheduler.running file if it doesn't already exist and clear the .quit file if we're set to go. This is to prevent the script being run twice* and causing issues. We let the user know why we can't run if the script is found to be running.

If all is well we continue to syncronise the script's execution with the top of the minute (00 seconds, line 92). We repeat this after each task loop to ensure we stay in synch' with the minute's "00 seconds."

The Primary Loop:

I'm using a Do Loop here because I'd prefer to check if we need to continue at the end of the sequence. The test, line 112, is whether the .quit file now exists.

The first task is to load the Schedules from the psTaskScheduler.jobs file, we call the loadSchedules() function for this. We will get to that soon.

If there are no tasks, we let the user know and keep going. It's okay to let this happen. Once we have a list of scheduled jobs we cycle through the jobs (line 103~105) calling testForEcho, (If you're a fan of RUSH you'll get this) which does the heavy-lifting of carrying out the tasks.

Because we have the ability to run tasks on start (or reboot) we follow this loop up by turning off the $Global:onReboot flag, a boolean value that we're done with because we've completed our first pass after startup.

The script is not commanded to sleep until the top of the next second (00, line 111).

The Post Op:   

If the stop request bell has been ring the Post Op process cleans up the .quit and .running files and the script exits politely.

On to the functions we glazed over earlier...

loadSchedules():

We start by setting the newFile flag to false but to prevent errors, we use Test-Path to ensure the expected file is present before trying to load it. The type of file, or -PathType parameter indicates whether this is a file (leaf) or folder (container), in this case we're looking for the leaf (file) named psTaskScheduler.jobs in the current folder (".\...") 

We now check the last write time on the .jobs file to determine if it's been changed recently (since the first loop of the script). We set the file's Last Write Time to $fileDate then follow this up with a check to see if the currently stored date is less than this newly acquired date but we will also accept move forward if there was no date set yet (line 134). 

    If the file or the stored date unset ($null) we set the stored value, $Global:jobsFileDate, and read the file into the $arrNewSchedule array (line 135). We've elected to exclude and commented lines, those beginning with the pound or hash symbol ("#") and empty lines. Please ignore the excessive tabs.

We extract any generic notes in the .jobs file into scheduleNotes and move forward.

If this is the first time through the script's loop the $Global:jobsFileDate will be null (not set), on line 137 we are looking to display information on file updates and skip this on first execution. On subsequent iterations if there's an updated .jobs file loaded this will be indicated with a green note about a New Job File and the date of the file.

Now that we're through that we update $Global:jobsFileDate with the latest file's LastWriteTime, print the corresponding notes, and update the $Global:arrSchedule (array) with the task information to performing the checks every minute.

Note: Line 157 makes line 144 irrelevant, remove it or remark it out line 144 with a "#" character. Oops.

testForEcho():

This is where the real work is done. In this function we break down the task entry and separate it out to match the time we need to do the work of running the required task.

(updated: corrected switch options for @reboot, @daily, and yearly)*

We start with a required parameter sourced from line 104 and a line of data from $arrSchedule as $task. The data arrives here as $thisTask and we'll be working elements of that data through this function.

While there should be no null or zero-character strings, thisTask is checked anyway. If it is not empty, it is trimmed (spaces and tabs removed from the beginning and end of the string) followed by a scrub of tabs, double-spaces, and double-asterisks so we have a good starting point that's easy to break out into the components.

In preparation for a @reboot task we set the local flag for $onRebootTask to $false. We also set $thisCmd to a default $null string before stepping into the next phase.

We will now check for the special time tags noted in the cron specification including: @yearly or @annually, @monthly, @weekly, @daily or @midnight, @hourly, or @reboot 

We use a switch function in Powershell to achieve this after checking to see if the first character in the string is an @ symbol and breaking the command at the first space where the first portion is expected to be the special tag and the command to be the remainder of thisTask (the value for $thisCmd).

We use the switch function to set the value of $thisTask to a preset mask based on the standard definitions. For the @reboot the flag for $onRebootTask is set to $true (on or 1) in boolean.

We now begin to break down $thisTask into the required sections and values. Determining the values set in the schedule for Minute, Hour, Day of the Month, the Month, and the Day of the Week for comparison. We again use the Powershell Switch function based on the element count (segment) and the $scheduleData which is an array based on the split of $thisTask.

And extra functionality, notes! This mostly useless function came out of debugging and stayed. If $thisCmd begins with an exclamation point (!) a note is displayed and the placeholders %T, %D, and %d are fulfilled. 
 
We're nearly there, we can now break out the current time functions to match against the scheduled job. We'll need a mirror of the settings with the current minute, hour, day of the month, the month, and day of the week are assigned to variables (lines 242~246).

Determining the perfect match is based on asterisks and matching numbers is completed in the lines 248~277, with an override for @reboot on 279~281. The premise is that where there is and asterisk in $thisTask (the scheduled job) we get a free point and where the numbers match we get a point. The total based on the elements is 5. If the matchCount equals 5 then the task is executed (or the note displayed). The override for @reboot ensures the matchCount is 5... once. Lines 282~290 ensure the task is carried out.

The Result:

Though I have cut the noise a little with both the -a and -d parameters the result is pretty-much as shown. I've also been tweaking this all day as I see little refinements. This is called scope-creep. It's not ideal in big projects but in hobbyist activities like this do what you'd like. It's your project, have fun!

I hope this inspires you a little to explore coding. I have a week off and may get a few more of these done. I'm not sure I'll blog about each one though.




There is no individual ownership when you are part of a team, it's the sum of the parts that makes you the RESILIENT team you need to be.