#!/bin/bash
# -*- coding: utf-8 mode: sh -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8

function display_help() {
    uecho "$scriptname: lancer une suite de commande en respectant une planification de type cron

USAGE
    $scriptname [options] /path/to/crontab
    $scriptname -l [/path/to/crontab]

La première forme du script doit normalement être lancé toutes les minutes par
une tâche cron. Utiliser l'option --install pour ajouter automatique la ligne
dans la crontab de l'utilisateur.

A chaque lancement du script, il examine quels scripts doivent être exécutés
dans le fichier crontab spécifié. Ce fichier est composé de lignes dans un
format particulier, qui sont analysées et traitées dans l'ordre.

Quelles que soient les lignes qui sont sélectionnées pour le lancement, elles
sont garanties de s'exécuter dans l'ordre du fichier, l'une après l'autre.

Les lignes commençant par # sont des commentaires et sont ignorées

== Définitions de variables et exécution de commandes ==

    Les lignes de la forme suivante sont des définitions de variable:

        [export] var=\"valeur de la variable\"

    Ces lignes sont des définitions de variable bash qui sont exécutées telles
    quelles. Il n'est donc pas autorisé de mettre des espaces autour de =. Par
    exemple, les lignes suivantes sont des erreurs de syntaxe:

        var = bad
        var=pas de quotes autour de la valeur

    alors que celles-ci sont correctes:

        var=ok
        var=\"valeur avec des espaces\"
        var='on peut utiliser des quotes aussi'

    Il est possible de manipuler les variables de type PATH avec une syntaxe
    particulière de l'opérateur d'assignation. Les opérateurs += et %= utilisent
    uaddpath(), #= utilise uinspath() et -= utilise udelpath(). Par exemple, les
    lignes suivantes ajoutent respectivement /usr/local/nutools puis enlèvent
    /opt/rogue au PATH:

        PATH+=/usr/local/nutools
        PATH-=/opt/rogue

    Bien sûr, il ne faut pas oublier de quoter les espaces:

        PATH+=\"/path/to/dir with spaces\"

    Les lignes de la forme suivante permettent d'exécuter une commande
    quelconque:

        $one-line-command

    Une variante permet de spécifier des commandes sur plusieurs lignes.
    ATTENTION! ${ et $} doivent être tous seuls sur la ligne.

        ${
        several
        commands
        ...
        $}

    Ces commandes sont exécutées systématiquement et ignorent la planification.
    On peut s'en servir notamment pour lire un fichier de configuration qui
    définit des variables ou des fonctions:

        $source path/to/file

== Planification de commandes ==

    Les autres lignes doivent être au format d'une ligne de crontab:

        minutes hours days months dows command-line

    command-line peut être n'importe quelle ligne de commande bash, pourvu
    qu'elle soit sur une seule ligne.

    Certaines extensions par rapport à la syntaxe de crontab sont autorisées. Il
    est en particulier possible de spécifier plusieurs planifications pour une
    seule commande. Par exemple, les lignes suivantes permettent d'exécuter
    'command' toutes les heures ET à 1h05:

        0 * * * *
        5 1 * * * command

    Il est aussi possible d'utiliser la même planification pour plusieurs
    commandes sans devoir répéter la définition de la planification. Les lignes
    suivantes planifient command1 et command2 toutes les heures:

        0 * * * * command1
                  command2

    Pour être prise en compte, la ligne command2 doit commencer par au moins un
    espace ou une tabulation. Pour la lisibilité, la syntaxe suivante est
    supportée aussi:

        0 * * * *
          command1
          command2

== Fonctions disponibles ==

    La fonction check_pidfile() est disponible, et permet de vérifier qu'une
    opération n'est pas déjà en cours. Si cette fonction est utilisée, il ne
    faut pas modifier la valeur de -k. Par exemple:

        0 1 * * *
          check_pidfile /path/to/pid [args]
          long-running-script

    check_pidfile() doit être utilisée toute seule sur la ligne et s'utilise
    avec les argument suivants:

        check_pidfile PIDFILE [DESC] [BARRIER]

    - PIDFILE est le fichier de PID qui est vérifié
    - DESC est la description du traitement qui est effectué. La valeur par
      défaut est \"Une synchronisation\". Si le fichier de PID est présent, le
      message suivant est affiché:
        DESC est en cours.
        Si vous pensez que c'est une erreur, veuillez vérifier le process de pid PID
        puis supprimez le cas échéant le fichier PIDFILE
    - BARRIER est un fichier qui est créé avec le contenu 'PID' s'il n'existe
      pas encore, et si la vérification du fichier de PID est faite avec succès.
      La présence de ce fichier peut-être vérifiée par un processus externe pour
      empêcher par exemple de mettre à jour les scripts pendant qu'il sont en
      train de tourner.
      Son contenu peut être examiné pour connaître le PID du processus qui l'a
      créé initialement. Le fichier est automatiquement supprimé à la fin de ce
      script.
      Attention: ce fichier n'est pas un verrou, il peut être supprimé à tout
      moment. Notamment, si deux scripts sont configurés pour créer le même
      fichier barrière, le premier script supprimera le fichier barrière avant
      la fin de l'exécution du second script.

    La fonction remove_pidfile() permet de supprimer un fichier de pid pour
    spécifier qu'une opération est terminée. Considérons l'exemple suivant:

        0 1 * * *
          check_pidfile /path/to/pid
          script1
          script2
          remove_pidfile /path/to/pid
          script3

    Dans cet exemple, il ne faut pas qu'une autre occurence de script1 tourne
    pendant que script2 tourne. Par contre, plusieurs occurences de script3
    peuvent tourner en parallèle.

OPTIONS
    -A, --install
        Installer une planification toutes les minutes de ce script dans la
        crontab de l'utilisateur. L'argument /path/to/crontab est requis.
    -R, --uninstall
        Désinstaller la planification toutes les minutes de ce script du crontab
        de l'utilisateur. L'argument /path/to/crontab est requis, et seule cette
        instance est désinstallée le cas échéant.
    -l, --list
        Lister les contenus des fichiers crontab dont l'exécution a été
        planifiée avec --install
        Si /path/to/crontab est spécifié, ne lister le contenu de ce fichier que
        si et seulement si son exécution a été planifiée.
    -n, --fake
        Afficher au lieu de les exécuter les commandes qui doivent être lancées

OPTIONS AVANCEES
    --lock LOCKFILE
        Inscrire dans le fichier spécifié des informations permettant d'éviter
        les invocations simultanées de ce script. Si selon ce fichier, le script
        tourne depuis plus de $USCRONTAB_LOCKDELAY heures, un message d'erreur est loggé
        et un message d'avertissement est affiché au plus une fois.
        Utiliser --lock '' pour désactiver cette fonctionnalité
        Par défaut, si ce script est lancé en root, le fichier utilisé pour le
        verrouillage est de la forme /var/run/$scriptname/abspath/to/crontab
        Si le script est lancé avec un compte utilisateur, aucun verrouillage
        n'est effectué.
    --lockdelay LOCKDELAY[=$USCRONTAB_LOCKDELAY]
        Changer le nombre d'heures pendant lesquelles on autorise le script a
        verrouiller l'exécution avant d'afficher un avertissement.
    -c, --continuous
        Par défaut, ce script s'arrête à la première commande qui retourne avec
        une code d'erreur. Avec cette option, ce script ne s'arrête jamais, bien
        qu'il retourne toujours un code d'erreur si une erreur s'est produite.
    -k, --stopec EXITCODE[=101]
        Spécifier un code d'erreur spécial qui arrête ce script sans erreur, ou
        '' pour désactiver cette fonctionnalité. Ceci permet en début de script
        de faire des tests par exemple sur l'environnement avant de lancer les
        scripts planifiés. Si l'environnement ne convient pas, il suffit au
        script de contrôle de retourner le code d'erreur spécifique pour arrêter
        le traitement."
}

source "$(dirname "$0")/ulib/ulib" &&
urequire DEFAULTS crontab ||
exit 1

USCRONTAB_CTLINE="* * * * * $script"
USCRONTAB_LOCKDELAY=8
USCRONTAB_STOPEC=101

action=run
lockfile=auto
lockdelay=
fake=
continuous=
parse_opts "${PRETTYOPTS[@]}" \
    --help '$exit_with display_help' \
    -A,--add,--install action=install \
    -R,--remove,--uninstall action=uninstall \
    --lock: lockfile= \
    --lockdelay: lockdelay= \
    -n,--fake fake=1 \
    -c,--continuous continuous=1 \
    -k:,--stop: USCRONTAB_STOPEC= \
    -l,--list action=list \
    @ args -- "$@" && set -- "${args[@]}" || die "$args"

if [ "$action" == "list" ]; then
    crontab="$1"; shift
    [ -n "$crontab" ] && crontab="$(abspath "$crontab")"

    array_from_lines ctfiles "$(crontab -l 2>/dev/null | awkrun script="$script" '$6 == script { print $7 }')"
    found=
    for ctfile in "${ctfiles[@]}"; do
        if [ -z "$crontab" -o "$ctfile" == "$crontab" ]; then
            found=1
            etitle "$(ppath "$ctfile")"
            cat "$ctfile"
            eend
        fi
    done
    if [ -n "$crontab" -a -z "$found" ]; then
        ewarn "$(ppath "$crontab"): non planifié"
    fi
    exit 0
fi

crontab="$1"; shift
[ -n "$crontab" ] || die_with "Vous devez spécifier le fichier crontab" display_help
[ -f "$crontab" ] || die "$crontab: fichier introuvable"
crontab="$(abspath "$crontab")"

if [ "$action" == "install" ]; then
    enable_in_crontab "$USCRONTAB_CTLINE $(quoted_arg "$crontab")" && estep "add_to_crontab $USCRONTAB_CTLINE $(quoted_arg "$crontab")"

elif [ "$action" == "uninstall" ]; then
    remove_from_crontab "$USCRONTAB_CTLINE $(quoted_arg "$crontab")" && estep "remove_from_crontab $USCRONTAB_CTLINE $(quoted_arg "$crontab")"

elif [ "$action" == "run" ]; then
    if [ "$lockfile" == auto ]; then
        if is_root; then
            lockfile="/var/run/$scriptname$crontab.lock"
            mkdirof "$lockfile" || die
        else
            lockfile=
        fi
    fi
    [ -n "$lockdelay" ] || lockdelay="$USCRONTAB_LOCKDELAY"

    if [ -n "$lockfile" ]; then
        lockwarn="${lockfile%.lock}.lockwarn"
        retry=1
        while [ -n "$retry" ]; do
            case "$(lf_trylock -h "$lockdelay" "$lockfile")" in
            locked)
                edebug "Arrêt du script parce que le verrou $lockfile est posé"
                exit 0
                ;;
            stale)
                msg="Un verrou sur '$scriptname $crontab' est posé depuis plus de $lockdelay heures. Veuillez faire vos vérification et supprimer le cas échéant le fichier $lockfile"
                logger -p cron.warn -t "$scriptname" -- "$msg"
                if [ -f "$lockwarn" ]; then
                    edebug "$msg"
                else
                    touch "$lockwarn"
                    ewarn "$msg"
                fi
                exit 1
                ;;
            retry) :;;
            *) retry=;;
            esac
        done
        [ -f "$lockwarn" ] && rm "$lockwarn"
        autoclean "$lockfile"
    fi

    function __ctexec() {
        local ec=0
        if [ -n "$fake" ]; then
            echo "$*"
        else
            edebug "$*"
            if [ "${1#check_pidfile }" != "$1" ]; then
                # cas particulier, c'est une fonction à exécuter dans le
                # contexte courant, et non pas dans un sous-shell
                eval "$*"; ec=$?
            else
                (eval "$*"); ec=$?
            fi
        fi
        [ -n "$USCRONTAB_STOPEC" -a "$ec" == "$USCRONTAB_STOPEC" ] && exit 0
        [ -z "$continuous" -a "$ec" != 0 ] && exit "$ec"
        return 0
    }
    function __cterror() {
        die "$*"
    }
    ctscript="$(ctresolve <"$crontab")"
    ec=0
    edebug "$ctscript"
    (
        function check_pidfile() {
            if [ -n "$1" ]; then
                local status
                pidfile_set -r "$1"; status=$?
                case "$status" in
                1)
                    eerror "${2:-Une synchronisation} est en cours.
Si vous pensez que c'est une erreur, veuillez vérifier le process de pid $(<"$1")
puis supprimez le cas échéant le fichier $1"
                    return "$USCRONTAB_STOPEC"
                    ;;
                10)
                    die "Une erreur s'est produite pendant l'écriture du fichier de pid. Impossible de continuer"
                    ;;
                esac
            fi
            if [ -n "$3" -a -w "$(dirname "$3")" ]; then
                (set -o noclobber
                    echo_ $$ >"$3" &&
                    chmod 644 "$3"
                ) 2>/dev/null &&
                autoclean "$3"
            fi
            return 0
        }
        function remove_pidfile() {
            [ -n "$1" ] && ac_clean "$1"
        }
        __ac_forgetall
        eval "$ctscript"
        ac_cleanall
    ); ec=$?
    [ -f "$lockfile" ] && rm "$lockfile"
    exit "$ec"
fi