Making life easier with cron

Profile image of Karl Vogel

By Karl Vogel ✓ Guest post

June 04, 2020

This post shares some ideas about working with cronjobs, to help make common tasks more easy for both junior and senior sysadmins.

What’s in your environment?

Almost every question I see about cron includes something like “it works fine when I run it from the command line, but…"

This script will tell you exactly what your program will see when run via cron:

#!/bin/ksh
#<showcron: show me what cron uses

tag=${0##*/}
exec > /tmp/$tag.$$

echo "Generated by $tag on $(date)

Environment:"

/bin/env | /bin/sort
echo
echo '----------------------------------------------'
echo Shell settings:
set
exit 0

I put it under $HOME/cron/showcron, and I (briefly) uncomment this line to run it:

* * * * * $HOME/cron/showcron

Results in /tmp/showcron.7294:

Generated by showcron on Mon May 11 03:09:01 EDT 2020

Environment:
CRON=yes
HOME=/home/me
LANG=en_US.UTF-8
LOGNAME=me
PATH=/usr/bin:/bin:/usr/sbin:/usr/local/sbin
PWD=/home/me
SHELL=/bin/sh
SHLVL=2
USER=me

----------------------------------------------
Shell settings:
_=settings:
CRON=yes
ENV=/home/me/.kshrc
FCEDIT=/bin/ed
HISTCMD=0
HOME=/home/me
IFS=$' \t\n'
JOBMAX=0
KSH_VERSION=.sh.version
LANG=en_US.UTF-8
LINENO=15
LOGNAME=me
MAILCHECK=600
OPTIND=1
PATH=/usr/bin:/bin:/usr/sbin:/usr/local/sbin
PPID=7290
PS2='> '
PS3='#? '
PS4='+ '
PWD=/home/me
RANDOM=21060
SECONDS=0.005
SHELL=/bin/sh
SHLVL=2
tag=showcron
TMOUT=0
USER=me

If you’re using Vixie cron or something close to it, you can specify environment variables at the top of your crontab file:

CRON=yes
LOGNAME=me
PATH=/usr/bin:/bin:/usr/sbin:/usr/local/sbin
SHELL=/bin/sh
USER=me

I like to set a variable called CRON so scripts can do things like sending output to syslog instead of using echo, etc:

case "$CRON" in
    yes) logmsg () { logger -t $tag "$@"; }              ;;
    *)   logmsg () { echo "$(date '+%F %T') $tag: $@"; } ;;
esac

warn () { logmsg "WARN: $@" ; }
die ()  { logmsg "FATAL: $@"; exit 1 ; }
...

Put a header in your crontab file

It’s much easier to fix or create an entry when you don’t have to remember syntax. I put this at the top of all my crontab files:

# Everything on a line is separated by blanks or tabs.
# Ie:
#+--------------------------- Minute (0-59)
#|    +---------------------- Hour   (0-23)
#|    |     +---------------- Day    (1-31)
#|    |     |   +------------ Month  (1-12)
#|    |     |   |   +-------- Day of week (0-6, 0=Sunday)
#|    |     |   |   |    +--- Command to be run
#|    |     |   |   |    |
#v    v     v   v   v    v
#====================================================================
# Uncomment for testing
#*     *     *   *   *    $HOME/cron/showcron

Use run-parts to run scripts in sequence

Most versions of Linux come with a program called run-parts, which will run all the executable scripts or programs in a directory in lexical-sort order.

I have all sorts of things set up this way; it makes the main crontab file way simpler:

#+--------------------------- Minute (0-59)
#|    +---------------------- Hour   (0-23)
#|    |     +---------------- Day    (1-31)
#|    |     |   +------------ Month  (1-12)
#|    |     |   |   +-------- Day of week (0-6, 0=Sunday)
#|    |     |   |   |    +--- Command to be run
#|    |     |   |   |    |
#v    v     v   v   v    v
#====================================================================
00    *     *   *   *    run-parts /etc/periodic/hourly
03,33 *     *   *   *    run-parts /etc/periodic/backups
02    4     *   *   *    run-parts /etc/periodic/daily
22    4     *   *   0    run-parts /etc/periodic/weekly
42    4     1   *   *    run-parts /etc/periodic/monthly
01    0     *   *   *    run-parts /etc/periodic/daystart
53   23     *   *   *    run-parts /etc/periodic/dayend
42    5    18  12   *    run-parts /etc/periodic/yearly

Red Hat and CentOS have a compiled version with a few extra tweaks, like a --new-session option which runs each program in a separate process session; that’s come in handy for me several times.

If your system doesn’t include run-parts, this script is a good start. I’ve used it on FreeBSD and Solaris with minor tweaks, and it’ll run under bash or KSH:

#!/bin/bash
# run-parts - concept taken from Debian

# keep going when something fails
set +e

if [ $# -lt 1 ]; then
    echo "Usage: run-parts <dir>"
    exit 1
fi

if [ ! -d $1 ]; then
    echo "Not a directory: $1"
    exit 1
fi

# Ignore *~ and *, scripts
for i in $(LC_ALL=C; echo $1/*[^~,]) ; do
    [ -d $i ] && continue
    # Don't run *.{rpmsave,rpmorig,rpmnew,swp,cfsaved} scripts
    [ "${i%.cfsaved}" != "${i}" ] && continue
    [ "${i%.rpmsave}" != "${i}" ] && continue
    [ "${i%.rpmorig}" != "${i}" ] && continue
    [ "${i%.rpmnew}" != "${i}" ] && continue
    [ "${i%.swp}" != "${i}" ] && continue
    [ "${i%,v}" != "${i}" ] && continue

    # jobs.deny prevents specific files from being executed
    # jobs.allow prohibits all non-named jobs from being run.
    # can be used in conjunction but there's no reason to do so.
    if [ -r $1/jobs.deny ]; then
    grep -q "^$(basename $i)$" $1/jobs.deny && continue
    fi
    if [ -r $1/jobs.allow ]; then
    grep -q "^$(basename $i)$" $1/jobs.allow || continue
    fi

    if [ -x $i ]; then
    if [ -r $1/whitelist ]; then
        grep -q "^$(basename $i)$" $1/whitelist && continue
    fi
    logger -p cron.notice -t "run-parts($1)[$$]" "starting $(basename $i)"
    $i 2>&1 | awk -v "progname=$i" \
            'progname {
                print progname ":\n"
                progname="";
                }
                { print; }'
    logger -i -p cron.notice -t "run-parts($1)" "finished $(basename $i)"
    fi
done

exit 0

Use a SETUP file for shared code

When your scripts have code in common, put it in a separate file. I do incremental backups every 30 minutes, and /etc/periodic/backups looks like this:

/etc
|   +--periodic
|   |   +--backups
|   |   |   +--100.incbkup  << stores a local incremental backup
|   |   |   +--200.remote   << copies that backup to a remote system
|   |   |   +--SETUP

I use a common set of logging and error functions:

#<SETUP: functions used by all backup routines

# Identifier.
case "$tag" in
    "") tag=${0##*/} ;;
    *)  ;;
esac

# Logging.
top="/backup"
logfile="$top/$(date +%Y)"

# Timestamp.
tstamp () {
    echo $(date '+%F %T.%N') $tag: $@ | cut -c1-23,30-
}

if test -t 2            # write to stdout...
then
    logmsg () {
        case "$#" in
            0) ;;
            *) tstamp $@ ;;
        esac
    }

elif test -w "$top"     # ... or a writable logfile...
then
    logmsg () {
        case "$#" in
            0) ;;
            *) tstamp $@ >> $logfile ;;
        esac
    }

else                    # ... or the system log.
    logmsg () {
        case "$#" in
            0) ;;
            *) logger -t "$tag" "$@" ;;
        esac
    }
fi

# Error/exit.
warn () { logmsg "WARN: $@"; }
die ()  { logmsg "FATAL: $@"; exit 1; }

I put the following at the top of any scripts in that directory:

# Common settings, logging.
setup="$(dirname $0)/SETUP"
test -f $setup || { logger -t backups "NO SETUP"; exit 1; }
. $setup

Special days - last day of the month

Occasionally, I want to schedule something to run on the last day of the month, and I don’t feel like dorking around with date or coming up with some other weird incantation.

This script (lastdom) abuses the hell out of the TZ variable to figure out if tomorrow is the first of the month, exits true if so, and exits false if not. I wrote it this way because I didn’t want to depend on the GNU version of date being installed.

If you pass any arguments to the script, they’ll be treated as something to be run if tomorrow is the first:

#!/bin/ksh
#<lastdom: run something on the last day of the month
# If no arguments, exit true if it's the last day of the month.

export PATH=/usr/local/bin:/bin:/usr/bin
export TZ=$(date +%Z)

# Print the current version.

version () {
    lsedscr='s/RCSfile: //
    s/.Date: //
    s/,v . .Revision: /  v/
    s/\$//g'

    lrevno='$RCSfile: lastdom,v $ $Revision: 1.4 $'
    lrevdate='$Date: 2010-10-29 13:38:17-04 $'
    echo "$lrevno $lrevdate" | sed -e "$lsedscr"
}

# Handle command line arguments.

while getopts vx c
do
    case $c in
        v)  version; exit 0 ;;
        x)  set -x ;;
        \?) exit 0 ;;
    esac
done
shift $(($OPTIND - 1))

# Real work starts here.  We could put "$(TZ...)" in the case
# statement, but this way you see the value when running with "-x".

tomorrow=$(TZ=$TZ-24 date +%e)

case "$tomorrow" in
    1) lastday=true ;;
    *) lastday= ;;
esac

case "$#" in
    0) test $lastday && exit 0 ;;
    *) test $lastday && exec ${1+"$@"} ;;
esac

exit 1

Special days - last workday of the month

This script is similar to lastdom but it checks if

  1. it’s Monday-Thursday and the following day is the first of the month, or
  2. it’s a Friday and the following Sunday is either the first or the second.

and will exit false unless one of those conditions is true. The same rules about arguments apply here as well:

#!/bin/ksh
#<lastwkmon: run something on the last workday of the month
# If no arguments, exit true if it's the last workday of the month.

export PATH=/usr/local/bin:/bin:/usr/sbin:/usr/bin
export TZ=$(date +%Z)

# Print the current version.

version () {
    lsedscr='s/RCSfile: //
    s/.Date: //
    s/,v . .Revision: /  v/
    s/\$//g'

    lrevno='$RCSfile: lastwkdom,v $ $Revision: 1.6 $'
    lrevdate='$Date: 2010-10-29 13:39:42-04 $'
    echo "$lrevno $lrevdate" | sed -e "$lsedscr"
}

# Handle command line arguments.

while getopts vx c
do
    case $c in
        v)  version; exit 0 ;;
        x)  set -x ;;
        \?) exit 0 ;;
    esac
done
shift $(($OPTIND - 1))

# Real work starts here.

DoW=$(date +%w)                  # day of week
tomorrow=$(TZ=$TZ-24 date +%e)   # tomorrow's date
aft3days=$(TZ=$TZ-72 date +%e)   # three days after today

case $DoW in
    [1-4]) test $tomorrow == 1  && lastworkday=true ;;
    5)     test $aft3days -le 3 && lastworkday=true ;;
esac

# If we have a program to run on the last workday, run it.

case "$#" in
    0) test $lastworkday && exit 0 ;;
    *) test $lastworkday && exec ${1+"$@"} ;;
esac

exit 1

It’s handy to be able to do

1  6  28-31  *   *   lastdom run-parts $HOME/etc/periodic/lastdom

and run a bunch of scripts on the last day of the month without having to think about getting the day wrong or waiting for one script to finish before another one starts.

Feedback

Feel free to send comments.

Profile image of Karl Vogel

About Karl Vogel ✓ Guest post

Karl is a system administrator at Wright-Patterson Air Force Base, Ohio. He joined the Air Force in 1981, and after spending a few years on DEC and IBM mainframes, he became a contractor in 1986 and started using Unix. He likes FreeBSD, trashy supermarket tabloids, Perl, cats, teen-angst TV shows, and movies.



Want to subscribe to the cron.weekly newsletter?

I write a weekly-ish newsletter on Linux, open source & webdevelopment called cron.weekly.

It features the latest news, guides & tutorials and new open source projects. You can sign up via email below.

No spam. Just some good, practical Linux & open source content.