#!/bin/bash
#
# bashclub-zsync-timer: systemd timers for bashclub-zsync, one per configuration /etc/bashclub/<name>.conf
# The configuration decides (trigger=systemd, schedule, run_as, log_file), the systemd generator
# bashclub-zsync-generator creates the timers from it; "sync" activates the changes.
# Configurations without trigger=systemd are not touched (cron and autorun keep running as before).

PATH="/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin"
prog=$(basename "$0")
conf_dir=${ZSYNC_CONF_DIR:-/etc/bashclub}
unit_dir=${ZSYNC_UNIT_DIR:-/etc/systemd/system}
default_dir=${ZSYNC_DEFAULT_DIR:-/etc/default}
cron_dirs=${ZSYNC_CRON_DIRS:-/etc/crontab /etc/cron.d /var/spool/cron/crontabs /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly}
marker="# bashclub-zsync-timer: migrated to bashclub-zsync@"
. ${ZSYNC_LIB:-/usr/lib/bashclub-zsync/schedule.sh} || exit 1

usage() {
    cat >&2 << EOF
usage: $prog list
       $prog enable <name> [<schedule>] [--user <user>] [--log <file>]
       $prog disable <name>
       $prog sync [--dry-run]
       $prog migrate [--dry-run] [<name>]

    <name>          configuration /etc/bashclub/<name>.conf, timer bashclub-zsync@<name>.timer
    <schedule>      cron format, e.g. "*/15 6-22 * * 1-5", "0 23 * * *" or @daily, several separated by ";"
                    default: keep the schedule of the configuration, otherwise @daily
    --user          run as this user instead of root (run_as, operation without root, see README)
    --log           append the output to this file instead of the journal (log_file)
    enable          writes trigger=systemd (and the given settings) into the configuration and runs sync
    disable         writes trigger=cron into the configuration and runs sync (the timer stops)
    sync            reads all configurations and starts, updates or stops their timers
    migrate         take over cron jobs that run bashclub-zsync -c /etc/bashclub/<name>.conf (all configurations
                    or only <name>): schedule, user and log file into the configuration, comment out the cron line, sync
EOF
    exit 1
}

die() { echo "$prog: $*" >&2; exit 1; }

# configuration used by zsync-autorun (/etc/default/zsync-autorun@<pool>, often a link to the configuration)
autorun_of() {
    local conf link
    conf=$(readlink -f "$1")
    for link in "$default_dir"/zsync-autorun@*; do
        [[ -e $link ]] || continue
        if [[ $(readlink -f "$link") == "$conf" ]]; then echo "${link##*/}"; fi
    done | paste -sd','
}

timer_state() {
    systemctl is-enabled "bashclub-zsync@$1.timer" 2> /dev/null || echo "-"
}

cmd_list() {
    local conf name trigger schedule user state autorun
    printf '%-24s %-8s %-30s %-14s %-10s %-6s %s\n' CONFIGURATION TRIGGER SCHEDULE RUN_AS TIMER CRON AUTORUN
    for conf in "$conf_dir"/*.conf; do
        [[ -f $conf ]] || continue
        name=$(basename "$conf" .conf)
        trigger=$(conf_trigger "$conf")
        schedule=$(conf_value "$conf" schedule)
        user=$(conf_value "$conf" run_as)
        state=$(timer_state "$name")
        if [[ $trigger == "systemd" ]] && systemctl is-active --quiet "bashclub-zsync@$name.timer" 2> /dev/null; then
            state="$state,active"
        fi
        autorun=$(autorun_of "$conf")
        printf '%-24s %-8s %-30s %-14s %-10s %-6s %s\n' "$name" "$trigger" "${schedule:--}" "${user:-root}" "$state" \
            "$(cron_jobs | awk -F'\t' -v c="$conf" '$4 == c' | grep -c .)" "${autorun:--}"
        if [[ $trigger != "autorun" && -n $autorun ]]; then
            echo "    $name is used by $autorun: set trigger=autorun (no timer, no age check in the monitoring)"
        fi
    done
}

# timers written by the first version of this tool (drop-ins in /etc/systemd/system) are replaced by the generator
remove_old_dropins() {
    local name=$1 f
    for f in "$unit_dir/bashclub-zsync@$name.timer.d/schedule.conf" "$unit_dir/bashclub-zsync@$name.service.d/user.conf" \
        "$unit_dir/bashclub-zsync@$name.service.d/log.conf"; do
        if grep -q "^# written by bashclub-zsync-timer" "$f" 2> /dev/null; then rm -f "$f"; fi
    done
    rmdir "$unit_dir/bashclub-zsync@$name.timer.d" "$unit_dir/bashclub-zsync@$name.service.d" 2> /dev/null
    # enabled with systemctl enable by the first version: the generator enables it now
    if [[ -L $unit_dir/timers.target.wants/bashclub-zsync@$name.timer ]]; then
        rm -f "$unit_dir/timers.target.wants/bashclub-zsync@$name.timer"
    fi
}

cmd_enable() {
    local name=$1 schedule= user= log= conf
    shift || usage
    while [ $# -gt 0 ]; do
        case $1 in
            --user) user=$2; shift ;;
            --log) log=$2; shift ;;
            *) schedule=$1 ;;
        esac
        shift
    done
    conf=$conf_dir/$name.conf
    [[ -f $conf ]] || die "configuration $conf not found"
    valid_name "$name" || die "name '$name' not usable for a unit (letters, digits, _ . -)"
    [[ -n $schedule ]] || schedule=$(conf_value "$conf" schedule)
    [[ -n $schedule ]] || schedule=@daily
    schedule_calendars "$schedule" > /dev/null || die "invalid schedule: $schedule (cron format, e.g. \"0 23 * * *\" or @daily)"
    if [[ -n $user ]]; then
        valid_user "$user" || die "invalid user name '$user'"
        id "$user" > /dev/null 2>&1 || die "user $user does not exist"
    fi
    if [[ -n $log ]]; then valid_path "$log" || die "invalid log file '$log' (absolute path without spaces, quotes, % and ..)"; fi
    { set_conf_value "$conf" trigger systemd && set_conf_value "$conf" schedule "$schedule"; } || die "writing $conf failed"
    if [[ $user == "root" ]]; then set_conf_value "$conf" run_as ""
    elif [[ -n $user ]]; then set_conf_value "$conf" run_as "$user"; fi
    if [[ -n $log ]]; then set_conf_value "$conf" log_file "$log"; fi
    remove_old_dropins "$name"
    cmd_sync
}

cmd_disable() {
    local conf=$conf_dir/$1.conf
    [[ -n $1 ]] || usage
    [[ -f $conf ]] || die "configuration $conf not found"
    set_conf_value "$conf" trigger cron
    remove_old_dropins "$1"
    cmd_sync
    echo "$1: trigger=cron, the timer is stopped; without a cron job the configuration does not run anymore"
}

#### sync: generator (daemon-reload), then start/restart the timers of trigger=systemd, stop the others
cmd_sync() {
    local dry=0 conf name schedule user problems=0 starts=() stops=() unit
    [[ $1 == --dry-run ]] && dry=1
    for conf in "$conf_dir"/*.conf; do
        [[ -f $conf ]] || continue
        [[ $(conf_trigger "$conf") == "systemd" ]] || continue
        name=$(basename "$conf" .conf)
        schedule=$(conf_value "$conf" schedule)
        user=$(conf_value "$conf" run_as)
        if ! valid_name "$name"; then echo "error $name: name not usable for a unit (letters, digits, _ . -)"; ((problems++)); continue; fi
        if ! schedule_calendars "${schedule:-@daily}" > /dev/null; then
            echo "error $name: schedule '$schedule' is not a cron schedule"; ((problems++)); continue
        fi
        if [[ -n $user ]] && { ! valid_user "$user" || ! id "$user" > /dev/null 2>&1; }; then
            echo "error $name: run_as user $user does not exist"; ((problems++)); continue
        fi
        if [[ -n $(conf_value "$conf" log_file) ]] && ! valid_path "$(conf_value "$conf" log_file)"; then
            echo "error $name: invalid log_file $(conf_value "$conf" log_file)"; ((problems++)); continue
        fi
        if systemctl is-active --quiet "bashclub-zsync@$name.timer" 2> /dev/null; then
            echo "$([ $dry -eq 1 ] && echo plan || echo do)  update bashclub-zsync@$name.timer: ${schedule:-@daily}, user ${user:-root}"
        else
            echo "$([ $dry -eq 1 ] && echo plan || echo do)  start  bashclub-zsync@$name.timer: ${schedule:-@daily}, user ${user:-root}"
        fi
        starts+=("$name")
    done
    # active timers without trigger=systemd: stop, unless enabled by hand (systemctl enable)
    for unit in $(systemctl list-units --type=timer --state=active --plain --no-legend 'bashclub-zsync@*.timer' 2> /dev/null | awk '{ print $1 }'); do
        name=${unit#bashclub-zsync@}; name=${name%.timer}
        [[ " ${starts[*]} " == *" $name "* ]] && continue
        [[ $(timer_state "$name") == "enabled" ]] && continue
        echo "$([ $dry -eq 1 ] && echo plan || echo do)  stop   $unit: $conf_dir/$name.conf has no trigger=systemd"
        stops+=("$unit")
    done
    if [ ${#starts[@]} -eq 0 ] && [ ${#stops[@]} -eq 0 ]; then echo "no configuration with trigger=systemd"; fi
    if [ $dry -eq 1 ]; then echo "dry run, nothing changed"; return $((problems > 0)); fi
    systemctl daemon-reload
    for name in "${starts[@]}"; do
        # restart: a changed schedule applies at once
        systemctl restart "bashclub-zsync@$name.timer" || { echo "error starting bashclub-zsync@$name.timer"; ((problems++)); continue; }
        echo "      bashclub-zsync@$name.timer next run: $(systemctl show -p NextElapseUSecRealtime --value "bashclub-zsync@$name.timer")"
    done
    for unit in "${stops[@]}"; do systemctl stop "$unit"; done
    return $((problems > 0))
}

#### cron jobs that run bashclub-zsync: file<TAB>line number<TAB>schedule<TAB>config<TAB>user<TAB>log file
cron_jobs() {
    local file user n line rest schedule conf log
    for file in $(find $cron_dirs -maxdepth 1 -type f 2>/dev/null); do
        n=0
        while IFS= read -r line || [[ -n $line ]]; do
            ((n++))
            [[ $line =~ ^[[:space:]]*# ]] && continue
            [[ $line == *bashclub-zsync* ]] || continue
            [[ $line =~ -c[[:space:]]+([^[:space:]]+) ]] || continue
            conf=${BASH_REMATCH[1]}
            # scripts in /etc/cron.{hourly,daily,weekly,monthly}: times of run-parts in the Debian /etc/crontab
            if [[ $file =~ /cron\.hourly/ ]]; then schedule="17 * * * *"; rest=$line
            elif [[ $file =~ /cron\.daily/ ]]; then schedule="25 6 * * *"; rest=$line
            elif [[ $file =~ /cron\.weekly/ ]]; then schedule="47 6 * * 7"; rest=$line
            elif [[ $file =~ /cron\.monthly/ ]]; then schedule="52 6 1 * *"; rest=$line
            elif [[ $line =~ ^[[:space:]]*(@[a-z]+)[[:space:]]+(.*)$ ]]; then
                schedule=${BASH_REMATCH[1]}; rest=${BASH_REMATCH[2]}
            elif [[ $line =~ ^[[:space:]]*([^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+)[[:space:]]+(.*)$ ]]; then
                schedule=${BASH_REMATCH[1]}; rest=${BASH_REMATCH[2]}
            else
                continue
            fi
            # system crontabs have a user field, user crontabs are named after the user
            if [[ $file =~ /cron\.(hourly|daily|weekly|monthly)/ ]]; then user=root
            elif [[ $file == /etc/crontab || $file == */cron.d/* ]]; then user=${rest%%[[:space:]]*}
            else user=$(basename "$file"); fi
            log=
            if [[ $line =~ \>\>?[[:space:]]*(/[^[:space:]]+) ]]; then log=${BASH_REMATCH[1]}; fi
            printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$file" "$n" "$schedule" "$conf" "$user" "$log"
        done < "$file"
    done
}

cmd_migrate() {
    local dry=0 only= file n schedule conf user log name todo=() names=() entry schedules
    while [ $# -gt 0 ]; do
        case $1 in
            --dry-run) dry=1 ;;
            *) only=$1 ;;
        esac
        shift
    done
    while IFS=$'\t' read -r file n schedule conf user log; do
        name=$(basename "$conf" .conf)
        [[ -z $only || $name == "$only" ]] || continue
        if [[ $(dirname "$conf") != "$conf_dir" ]]; then
            echo "skip  $file:$n: $conf is not in $conf_dir (the timer needs $conf_dir/<name>.conf)"; continue
        fi
        if [[ $(conf_trigger "$conf") == "autorun" ]]; then
            echo "skip  $file:$n: $conf has trigger=autorun"; continue
        fi
        if ! schedule_calendars "$schedule" > /dev/null; then
            echo "skip  $file:$n: schedule '$schedule' cannot be converted, set it in $conf and run: $prog enable $name"; continue
        fi
        if ! valid_user "$user"; then echo "skip  $file:$n: user '$user' is not a valid user name"; continue; fi
        if [[ -n $log ]] && ! valid_path "$log"; then
            echo "skip  $file:$n: log file '$log' cannot be used by the timer (path with spaces, quotes, % or ..)"; continue
        fi
        echo "$([ $dry -eq 1 ] && echo plan || echo do)  $file:$n -> $conf: trigger=systemd schedule=\"$schedule\" run_as=$user${log:+ log_file=$log}"
        todo+=("$file"$'\t'"$n"$'\t'"$name"$'\t'"$schedule"$'\t'"$user"$'\t'"$log")
    done < <(cron_jobs)
    if [ ${#todo[@]} -eq 0 ]; then echo "no cron jobs of bashclub-zsync to migrate"; return 0; fi
    [ $dry -eq 1 ] && { echo "dry run, nothing changed"; return 0; }

    # one timer per configuration, several cron lines become several schedules
    for entry in "${todo[@]}"; do
        IFS=$'\t' read -r file n name schedule user log <<< "$entry"
        [[ " ${names[*]} " == *" $name "* ]] || names+=("$name")
    done
    for name in "${names[@]}"; do
        schedules=$(printf '%s\n' "${todo[@]}" | awk -F'\t' -v n="$name" '$3 == n { print $4 }' | paste -sd';')
        entry=$(printf '%s\n' "${todo[@]}" | awk -F'\t' -v n="$name" '$3 == n' | head -1)
        IFS=$'\t' read -r file n name schedule user log <<< "$entry"
        conf=$conf_dir/$name.conf
        set_conf_value "$conf" trigger systemd
        set_conf_value "$conf" schedule "$schedules"
        if [[ $user != "root" ]]; then set_conf_value "$conf" run_as "$user"; fi
        if [[ -n $log ]]; then set_conf_value "$conf" log_file "$log"; fi
        remove_old_dropins "$name"
    done
    # comment out the cron lines (from the end, line numbers stay valid)
    while IFS=$'\t' read -r file n name schedule user log; do
        sed -i "${n}s|^|$marker$name.timer: |" "$file"
    done < <(printf '%s\n' "${todo[@]}" | sort -t$'\t' -k1,1 -k2,2nr)
    cmd_sync
}

case $1 in
    list) cmd_list ;;
    enable) shift; cmd_enable "$@" ;;
    disable) shift; cmd_disable "$@" ;;
    sync) shift; cmd_sync "$@" ;;
    migrate) shift; cmd_migrate "$@" ;;
    *) usage ;;
esac
