#!/bin/sh
######################################################################
#
# $Id: cronjob-manager,v 1.7 2012/01/07 07:56:14 mavrik Exp $
#
######################################################################
#
# Copyright 2003-2012 The WebJob Project, All Rights Reserved.
#
######################################################################
#
# Purpose: Deploy, remove, or update cron jobs.
#
######################################################################

IFS=' 	
'

PATH="/sbin:/usr/sbin:/usr/local/sbin:/bin:/usr/bin:/usr/local/bin"

PROGRAM=`basename ${0}`

######################################################################
#
# TestPid
#
######################################################################

TestPid()
{
  my_pid="${1}"
  my_pid_regexp="^[0-9]+$"
  echo "${my_pid}" | egrep "${my_pid_regexp}" > /dev/null 2>&1
  if [ $? -eq 0 ] ; then # The PID is valid.
    return 0;
  fi
  return 1; # The PID is not valid.
}

######################################################################
#
# CreateLockFile
#
######################################################################

CreateLockFile()
{
  my_lock_file="${1}"
  # Customize ln(1) options based on the OS.
  case `uname -s` in
  NIKOS) # This OS is so old it doesn't support '-n'.
    ln_options=""
    ;;
  *)
    ln_options="-n"
    ;;
  esac
  if [ -z "${my_lock_file}" ] ; then
    return 1 # Rats, we didn't even get to the gate.
  fi
  my_old_umask=`umask`
  umask 022
  my_lock_dir=`dirname "${my_lock_file}"`
  if [ ! -d "${my_lock_dir}" ] ; then
    mkdir -p "${my_lock_dir}"
    if [ $? -ne 0 ] ; then
      return 1 # Rats, we got bushwhacked.
    fi
  fi
  umask 077
  my_temp_file="${my_lock_file}.$$"
  echo $$ | cat - > "${my_temp_file}"
  if [ $? -ne 0 ] ; then
    return 1 # Rats, we didn't even get out of the gate.
  fi
  umask ${my_old_umask}
  ln ${ln_options} "${my_temp_file}" "${my_lock_file}" > /dev/null 2>&1
  if [ $? -eq 0 ] ; then
    rm -f "${my_temp_file}"
    return 0 # Ding ding ding, we have a winner.
  fi
  my_old_pid=`head -1 "${my_lock_file}"`
  TestPid "${my_old_pid}"
  if [ $? -eq 0 ] ; then
    kill -0 ${my_old_pid} > /dev/null 2>&1
    if [ $? -eq 0 ] ; then
      rm -f "${my_temp_file}"
      return 1 # Rats, the lock is in use.
    fi
  fi
  # At this point, the lock is corrupt, stale, or owned by a different
  # user. Attempt to delete it, and go for the gold.
  rm -f "${my_lock_file}"
  ln ${ln_options} "${my_temp_file}" "${my_lock_file}" > /dev/null 2>&1
  if [ $? -eq 0 ] ; then
    rm -f "${my_temp_file}"
    return 0 # Ding ding ding, we have a winner.
  fi
  rm -f "${my_temp_file}"
  return 1 # Rats, someone else got there first.
}

######################################################################
#
# DeleteLockFile
#
######################################################################

DeleteLockFile()
{
  my_lock_file="${1}"
  if [ -n "${my_lock_file}" -a -f "${my_lock_file}" ] ; then
    my_old_pid=`head -1 "${my_lock_file}"`
    TestPid "${my_old_pid}"
    if [ $? -eq 0 ] ; then
      if [ ${my_old_pid} -eq $$ ] ; then
        rm -f "${my_lock_file}"
      fi
    fi
  fi
  return 0
}

######################################################################
#
# CjmCleanup
#
######################################################################

CjmCleanup()
{
  MY_BAK_FILE="crontab.bak"
  MY_NEW_FILE="crontab.new"
  rm -f "${MY_BAK_FILE}" "${MY_NEW_FILE}"
  DeleteLockFile "${1}"
}

######################################################################
#
# CjmUpdateCronTab
#
######################################################################

CjmUpdateCronTab()
{
  MY_ACTION="${1}"
  MY_CRONTAB_FILE="${2}"
  MY_TIME_SPECIFICATION="${3}"
  MY_COMMAND_LINE="${4}"
  MY_CRONTAB_ENTRY="${5}"
  MY_DRY_RUN="${6-0}"
  MY_FORCE="${7-0}"
  MY_AUTO_CREATE="${8-0}"
  MY_BE_QUIET="${9-0}"

  ####################################################################
  #
  # NOTE: The return values for this routine have the following
  # meanings:
  #
  #   0 - A new crontab was not installed. This means that there were
  #       no net changes.
  #   1 - A new crontab was installed depending on whether this was a
  #       dry run or not.
  #   2 - A fatal error occurred, and no further action was taken.
  #
  ####################################################################

  ####################################################################
  #
  # Determine whether the crontab comes from a file or a command.
  #
  ####################################################################

  if [ -n "${MY_CRONTAB_FILE}" ] ; then
    if [ ! -r "${MY_CRONTAB_FILE}" ] ; then
      echo "${PROGRAM}: Error='File (${MY_CRONTAB_FILE}) does not exist or is not readable. Job aborted.'" 1>&2
      return 2
    fi
    MY_USE_CRON="0"
  else
    MY_USE_CRON="1"
  fi

  ####################################################################
  #
  # Convert the specified command line or crontab entry into a regular
  # expression. To ensure a tight match, simply squash any characters
  # that could be problematic in a regular expression (e.g., '[', '\',
  # ']', '^', quotes, non-printables, etc.) into '.'s and/or enclose
  # special characters in square brackets ('$', '%', '&', '(', ')',
  # '*', '+', '.', '?', '@', '{', '|', and '}'). The result should be
  # as close to a literal match as possible. Note that some of the
  # special characters listed above are special to Perl and some are
  # special to regular expressions.
  #
  ####################################################################

  case "${MY_ACTION}" in
  remove)
    if [ -z "${MY_CRONTAB_ENTRY}" ] ; then
      echo "${PROGRAM}: Error='Null crontab entry. Job aborted.'" 1>&2
      return 2
    fi
    MY_MATCH_STRING="${MY_CRONTAB_ENTRY}"
    ;;
  *)
    if [ -z "${MY_COMMAND_LINE}" ] ; then
      echo "${PROGRAM}: Error='Null command line. Job aborted.'" 1>&2
      return 2
    fi
    MY_MATCH_STRING="${MY_COMMAND_LINE}"
    ;;
  esac

  MY_CRONTAB_EXPR=`echo "${MY_MATCH_STRING}" | perl -p -e 's/[\r\n]*$//; s/[^\w\x20-\x2f\x3a-\x40\x7b-\x7e]/\x00/g; s/([\x24-\x26\x28-\x2b\x2e\x3f\x40\x7b-\x7d])/[$1]/g; s/\x00/./g;'`
  if [ -z "${MY_CRONTAB_EXPR}" ] ; then
    echo "${PROGRAM}: Error='Unable to convert the specified match string into a regular expression. Job aborted.'" 1>&2
    return 2
  fi

  ####################################################################
  #
  # Record some key information.
  #
  ####################################################################

  if [ ${MY_BE_QUIET} -eq 0 ] ; then
    echo "ACTION=${MY_ACTION}"
    echo "AUTO_CREATE=${MY_AUTO_CREATE}"
    echo "COMMAND_LINE=${MY_COMMAND_LINE}"
    echo "CRONTAB_EXPR=${MY_CRONTAB_EXPR}"
    echo "DRY_RUN=${MY_DRY_RUN}"
    echo "FORCE=${MY_FORCE}"
    echo "TIME_SPECIFICATION=${MY_TIME_SPECIFICATION}"
    echo "USER=${USER}"
  fi

  ####################################################################
  #
  # Record the old crontab.
  #
  ####################################################################

  MY_BAK_FILE="crontab.bak"

  if [ ${MY_BE_QUIET} -eq 0 ] ; then
    echo "--- ${MY_BAK_FILE} ---"
    if [ ${MY_USE_CRON} -eq 1 ] ; then
      crontab -l | tee "${MY_BAK_FILE}"
    else
      cat "${MY_CRONTAB_FILE}" | tee "${MY_BAK_FILE}"
    fi
    echo "--- ${MY_BAK_FILE} ---"
  else
    if [ ${MY_USE_CRON} -eq 1 ] ; then
      crontab -l > "${MY_BAK_FILE}"
    else
      cat "${MY_CRONTAB_FILE}" > "${MY_BAK_FILE}"
    fi
  fi

  ####################################################################
  #
  # Scan the old crontab to see if there is a match.
  #
  ####################################################################

  MY_LOGIC='BEGIN { $c=0; } if (m{'${MY_CRONTAB_EXPR}'}) { $c++; } END { print $c; }' # NOTE: The placement of single quotes here is critical.
  MY_COUNT=`perl -n -e "${MY_LOGIC}" "${MY_BAK_FILE}"`
  if [ -z "${MY_COUNT}" ] ; then
    echo "${PROGRAM}: Error='Null match count. Job aborted.'" 1>&2
    return 2
  fi
  if [ ${MY_COUNT} -lt 0 ] ; then
    echo "${PROGRAM}: Error='Invalid match count (${MY_COUNT}). Job aborted.'" 1>&2
    return 2
  fi

  ####################################################################
  #
  # Determine what needs to be done based on the user's request.
  #
  ####################################################################

  MY_ADD_CRONJOB="0"
  MY_DEL_CRONJOB="0"

  case "${MY_ACTION}" in
  deploy)
    if [ ${MY_COUNT} -gt 0 ] ; then
      : # An entry matching the command expression exists. No action is required.
    else
      MY_ADD_CRONJOB="1"
    fi
    ;;
  remove)
    if [ ${MY_COUNT} -gt 1 ] ; then
      if [ ${MY_FORCE} -ne 1 ] ; then
        echo "${PROGRAM}: Error='Expected 1 match, but found ${MY_COUNT}. Job aborted. Use the \"-F\" option to force this action.'" 1>&2
        return 2
      fi
      MY_DEL_CRONJOB="1"
    elif [ ${MY_COUNT} -eq 1 ] ; then
      MY_DEL_CRONJOB="1"
    else
      : # No entry matching the command expression exists. No action is required.
    fi
    ;;
  update)
    if [ ${MY_COUNT} -gt 1 ] ; then
      if [ ${MY_FORCE} -ne 1 ] ; then
        echo "${PROGRAM}: Error='Expected 1 match, but found ${MY_COUNT}. Job aborted. Use the \"-F\" option to force this action.'" 1>&2
        return 2
      fi
      MY_DEL_CRONJOB="1"
      MY_ADD_CRONJOB="1"
    elif [ ${MY_COUNT} -eq 1 ] ; then
      MY_DEL_CRONJOB="1"
      MY_ADD_CRONJOB="1"
    else
      if [ ${MY_AUTO_CREATE} -ne 1 ] ; then
        echo "${PROGRAM}: Error='Expected 1 match, but found ${MY_COUNT}. Job aborted. Use the \"-a\" option to auto-create missing entries.'" 1>&2
        return 2
      fi
      MY_ADD_CRONJOB="1"
    fi
    ;;
  *)
    echo "${PROGRAM}: Error='Invalid action (${MY_ACTION}). Job aborted.'" 1>&2
    return 2
    ;;
  esac

  ####################################################################
  #
  # Create a new crontab file. For updates, we must delete existing
  # entries first.
  #
  ####################################################################

  MY_NEW_FILE="crontab.new"

  if [ ${MY_DEL_CRONJOB} -eq 1 ] ; then
    MY_LOGIC='if (m{'${MY_CRONTAB_EXPR}'}) { next; } print;' # NOTE: The placement of single quotes here is critical.
    perl -n -e "${MY_LOGIC}" "${MY_BAK_FILE}" > "${MY_NEW_FILE}"
  else
    cat "${MY_BAK_FILE}" > "${MY_NEW_FILE}"
  fi

  if [ ${MY_ADD_CRONJOB} -eq 1 ] ; then
    MY_LOGIC='BEGIN { $n="[0-9]{1,2}"; $u="($n(-$n)?)(/$n)?"; $l="([*](/$n)?|$u(,$u)*)"; $r="$l([ ]+$l){4}"; } exit(($ARGV[0] =~ m{^$r$}) ? 0 : 1);'
    perl -e "${MY_LOGIC}" "${MY_TIME_SPECIFICATION}"
    if [ $? -ne 0 ] ; then
      echo "${PROGRAM}: Error='Invalid time specification. Job aborted.'" 1>&2
      return 2
    fi
    cat - >> "${MY_NEW_FILE}" << EOF
${MY_TIME_SPECIFICATION} ${MY_COMMAND_LINE}
EOF
  fi

  ####################################################################
  #
  # Conditionally install and record the new crontab.
  #
  ####################################################################

  if [ ${MY_ADD_CRONJOB} -eq 1 -o ${MY_DEL_CRONJOB} -eq 1 ] ; then
    if [ ! -r "${MY_NEW_FILE}" ] ; then
      echo "${PROGRAM}: Error='File (${MY_NEW_FILE}) does not exist or is not readable. Job aborted.'" 1>&2
      return 2
    fi
    if [ ${MY_DRY_RUN} -eq 0 ] ; then
      if [ ${MY_USE_CRON} -eq 1 ] ; then
        crontab "${MY_NEW_FILE}"
      else
        cat "${MY_NEW_FILE}" > "${MY_CRONTAB_FILE}"
      fi
      if [ $? -ne 0 ] ; then
        echo "${PROGRAM}: Error='Failed to install new crontab. A manual fix may be required.'" 1>&2
        return 2
      fi
      if [ ${MY_BE_QUIET} -eq 0 ] ; then
        echo "--- ${MY_NEW_FILE} ---"
        if [ ${MY_USE_CRON} -eq 1 ] ; then
          crontab -l
        else
          cat "${MY_CRONTAB_FILE}"
        fi
        echo "--- ${MY_NEW_FILE} ---"
      fi
    else
      if [ ${MY_BE_QUIET} -eq 0 ] ; then
        echo "--- ${MY_NEW_FILE} ---"
        cat "${MY_NEW_FILE}"
        echo "--- ${MY_NEW_FILE} ---"
      fi
    fi
    return 1
  fi

  return 0
}

######################################################################
#
# CjmUsage
#
######################################################################

CjmUsage()
{
  echo 1>&2
  echo "Usage: ${PROGRAM} {-d|--deploy} [-nq] [-f crontab] -c 'command-line' -t 'time-specification'" 1>&2
  echo "       ${PROGRAM} {-l|--lsjobs}" 1>&2
  echo "       ${PROGRAM} {-r|--remove} [-Fnq] [-f crontab] -e 'crontab-entry'" 1>&2
  echo "       ${PROGRAM} {-u|--update} [-aFnq] [-f crontab] -c 'command-line' -t 'time-specification'" 1>&2
  echo 1>&2
  exit 1
}

######################################################################
#
# CjmMain
#
######################################################################

CjmMain()
{
  ####################################################################
  #
  # Punch in and go to work.
  #
  ####################################################################

  AUTO_CREATE="0"

  BE_QUIET="0"

  COMMAND_LINE=""

  CRONTAB_ENTRY=""

  CRONTAB_FILE=""

  DRY_RUN="0"

  FORCE="0"

  TIME_SPECIFICATION=""

  umask 077

  ####################################################################
  #
  # Process command line arguments.
  #
  ####################################################################

  if [ $# -lt 1 ] ; then
    CjmUsage
  fi

  case "$1" in
  -d|--deploy)
    options="c:f:nqt:"
    ACTION="deploy"
    ;;
  -l|--lsjobs)
    ACTION="lsjobs"
    ;;
  -r|--remove)
    options="e:Ff:nq"
    ACTION="remove"
    ;;
  -u|--update)
    options="ac:Ff:nqt:"
    ACTION="update"
    ;;
  *)
    CjmUsage
    ;;
  esac

  shift # Remove the mode argument.

  while getopts "${options}" OPTION ; do
    case "${OPTION}" in
    a)
      AUTO_CREATE="1"
      ;;
    c)
      COMMAND_LINE="${OPTARG}"
      ;;
    e)
      CRONTAB_ENTRY="${OPTARG}"
      ;;
    F)
      FORCE="1"
      ;;
    f)
      CRONTAB_FILE="${OPTARG}"
      ;;
    n)
      DRY_RUN="1"
      ;;
    q)
      BE_QUIET="1"
      ;;
    t)
      TIME_SPECIFICATION="${OPTARG}"
      ;;
    *)
      CjmUsage
      ;;
    esac
  done

  if [ ${OPTIND} -le $# ] ; then
    CjmUsage
  fi

  case "${ACTION}" in
  deploy|update)
    if [ -z "${COMMAND_LINE}" -o -z "${TIME_SPECIFICATION}" ] ; then
      CjmUsage
    fi
    ;;
  lsjobs) # Do the user's bidding and exit.
    crontab -l
    exit 0
    ;;
  remove)
    if [ -z "${CRONTAB_ENTRY}" ] ; then
      CjmUsage
    fi
    ;;
  *)
    CjmUsage
    ;;
  esac

  ####################################################################
  #
  # Make sure the required version of Perl is installed.
  #
  ####################################################################

  PERL_OK=`perl -e 'use 5.006;' > /dev/null 2>&1`
  if [ $? -ne 0 ] ; then
    echo "${PROGRAM}: Error='Perl 5.6.0 or higher is required. Job aborted.'" 1>&2
    exit 2
  fi

  ####################################################################
  #
  # Create a lock file.
  #
  ####################################################################

  CURRENT_UID=`id | sed 's/^uid=//; s/[(].*$//;' | egrep '^[0-9]+$'` # NOTE: 'id -u' is not supported on Solaris.
  if [ -z "${CURRENT_UID}" ] ; then
    CURRENT_UID="nouid"
  fi

  LOCK_FILE="/var/tmp/cronjob-manager.${CURRENT_UID}.pid"

  CreateLockFile "${LOCK_FILE}"
  if [ $? -ne 0 ] ; then
    echo "${PROGRAM}: Error='Unable to secure a lock file. Job aborted.'" 1>&2
    exit 2
  fi

  ####################################################################
  #
  # Setup a signal handler.
  #
  ####################################################################

  trap "CjmCleanup \"${LOCK_FILE}\" ; exit 3" 1 2 15

  ####################################################################
  #
  # Do the user's bidding and report the results.
  #
  ####################################################################

  CjmUpdateCronTab "${ACTION}" "${CRONTAB_FILE}" "${TIME_SPECIFICATION}" "${COMMAND_LINE}" "${CRONTAB_ENTRY}" "${DRY_RUN}" "${FORCE}" "${AUTO_CREATE}" "${BE_QUIET}"
  UPDATE_STATUS="${?}"

  if [ ${BE_QUIET} -eq 0 ] ; then
    echo "UPDATE_STATUS=${UPDATE_STATUS}"
  fi

  ####################################################################
  #
  # Shutdown and go home.
  #
  ####################################################################

  CjmCleanup "${LOCK_FILE}"

  if [ ${UPDATE_STATUS} -eq 2 ] ; then
    exit 4
  fi
}

CjmMain "${@}"
