#!/bin/sh # Planner for periodic backups. # # busybox crond is deliberately not used here: when the container runs under a # non-root uid (a very common k8s securityContext) crond loads no crontab at # all and never says why - it just keeps printing "wakeup dt=60" forever while # no backup is ever made. This loop does the same job, works under any uid and # fails loudly on a bad schedule. # -f matters: the schedule is full of '*' and word splitting would otherwise # expand them against the files in the working directory. set -euf SCHEDULE_RAW="${CRONTAB:-0 * * * *}" POLL_INTERVAL="${POLL_INTERVAL:-20}" log() { echo "[scheduler] $*"; } die() { echo "[scheduler] FATAL: $*" >&2; exit 1; } # A value coming from .env, a configmap or a helm chart may carry quotes or a # trailing CR; both silently break the schedule, so strip them up front. SCHEDULE=$(printf '%s' "$SCHEDULE_RAW" | tr -d '\r' \ | sed -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/") MONTH_NAMES="jan feb mar apr may jun jul aug sep oct nov dec" DOW_NAMES="sun mon tue wed thu fri sat" # Resolve a three-letter month/weekday name to its number, echo the input back # when it is not a name. resolve_name() { # $1=token $2=names $3=base _tok=$(echo "$1" | tr '[:upper:]' '[:lower:]') _n=0 for _w in $2; do if [ "$_tok" = "$_w" ]; then echo $((_n + $3)) return 0 fi _n=$((_n + 1)) done echo "$1" } # Drop a leading zero so that "07" is not read as an invalid octal number. strip_zero() { _v=${1#0} [ -n "$_v" ] || _v=0 echo "$_v" } # Expand one crontab field into the explicit list of values it matches. # Supports *, N, a-b, a,b, */n, a-b/n and N/n. expand_field() { # $1=spec $2=lo $3=hi $4=field name $5=names $6=names base _spec=$1; _lo=$2; _hi=$3; _name=$4; _names=$5; _base=$6 _out='' _oifs=$IFS IFS=, for _part in $_spec; do IFS=$_oifs _step=1 case $_part in */*) _step=${_part##*/} _part=${_part%%/*} ;; esac case $_step in ''|*[!0-9]*) die "$_name: bad step in '$_spec'" ;; esac _step=$(strip_zero "$_step") [ "$_step" -ge 1 ] || die "$_name: step must be >= 1 in '$_spec'" case $_part in '*') _s=$_lo; _e=$_hi ;; *-*) _s=$(resolve_name "${_part%%-*}" "$_names" "$_base") _e=$(resolve_name "${_part##*-}" "$_names" "$_base") ;; *) _s=$(resolve_name "$_part" "$_names" "$_base") # "N/step" means "from N to the end of the range", like vixie cron. if [ "$_step" -gt 1 ]; then _e=$_hi; else _e=$_s; fi ;; esac for _v in "$_s" "$_e"; do case $_v in ''|*[!0-9]*) die "$_name: '$_part' is not a number or a known name" ;; esac done _s=$(strip_zero "$_s") _e=$(strip_zero "$_e") [ "$_s" -ge "$_lo" ] && [ "$_e" -le "$_hi" ] \ || die "$_name: '$_part' is outside ${_lo}-${_hi}" [ "$_s" -le "$_e" ] || die "$_name: reversed range '$_part'" _i=$_s while [ "$_i" -le "$_e" ]; do _out="$_out $_i" _i=$((_i + _step)) done IFS=, done IFS=$_oifs [ -n "$_out" ] || die "$_name: empty field" echo "$_out" } in_list() { # $1=list $2=value case " $1 " in *" $2 "*) return 0 ;; esac return 1 } # Word splitting is the point here, and globbing is off (set -f). # shellcheck disable=SC2086 set -- $SCHEDULE [ $# -eq 5 ] || die "CRONTAB must have 5 fields, got $#: '$SCHEDULE_RAW'" SPEC_MIN=$1 SPEC_HOUR=$2 SPEC_DOM=$3 SPEC_MON=$4 SPEC_DOW=$5 LIST_MIN=$(expand_field "$SPEC_MIN" 0 59 minute '' 0) LIST_HOUR=$(expand_field "$SPEC_HOUR" 0 23 hour '' 0) LIST_DOM=$(expand_field "$SPEC_DOM" 1 31 day '' 0) LIST_MON=$(expand_field "$SPEC_MON" 1 12 month "$MONTH_NAMES" 1) LIST_DOW=$(expand_field "$SPEC_DOW" 0 7 weekday "$DOW_NAMES" 0) # Sunday is both 0 and 7. if in_list "$LIST_DOW" 7 && ! in_list "$LIST_DOW" 0; then LIST_DOW="$LIST_DOW 0" fi # crontab(5): when both day-of-month and day-of-week are restricted, a run # happens if either of them matches, not both. if [ "$SPEC_DOM" != '*' ] && [ "$SPEC_DOW" != '*' ]; then DAY_MATCH=or else DAY_MATCH=and fi log "schedule: $SCHEDULE (TZ=${TZ:-UTC}, now $(date '+%F %T %z'))" log "expanded: min=[${LIST_MIN# }] hour=[${LIST_HOUR# }] dom=[${LIST_DOM# }] mon=[${LIST_MON# }] dow=[${LIST_DOW# }] day-match=$DAY_MATCH" matches_now() { # $1=minute $2=hour $3=dom $4=month $5=dow in_list "$LIST_MIN" "$1" || return 1 in_list "$LIST_HOUR" "$2" || return 1 in_list "$LIST_MON" "$4" || return 1 if [ "$DAY_MATCH" = or ]; then in_list "$LIST_DOM" "$3" || in_list "$LIST_DOW" "$5" || return 1 else in_list "$LIST_DOM" "$3" || return 1 in_list "$LIST_DOW" "$5" || return 1 fi return 0 } TERMINATE=0 trap 'TERMINATE=1' TERM INT # Initial backup: do not abort the container if it fails, the schedule matters # more than this one run. /scripts/backup.sh || log "initial backup failed, continuing to the schedule" # The startup backup counts as this minute's run, otherwise a schedule that # matches the minute the container came up would immediately back up twice. LAST_RUN=$(date '+%Y-%m-%dT%H:%M') while [ "$TERMINATE" -eq 0 ]; do # Polling instead of sleeping to the minute boundary: a backup can take # minutes and the clock can jump, LAST_RUN is what keeps a minute from # firing twice. # shellcheck disable=SC2046 set -- $(date '+%Y-%m-%dT%H:%M %M %H %d %m %w') KEY=$1 MIN=$(strip_zero "$2"); HOUR=$(strip_zero "$3") DOM=$(strip_zero "$4"); MON=$(strip_zero "$5"); DOW=$(strip_zero "$6") if [ "$KEY" != "$LAST_RUN" ] && matches_now "$MIN" "$HOUR" "$DOM" "$MON" "$DOW"; then LAST_RUN=$KEY log "triggered at $KEY" /scripts/backup.sh || log "backup failed, waiting for the next run" fi # Backgrounded so that SIGTERM is handled right away instead of after the # whole interval. sleep "$POLL_INTERVAL" & wait $! || true done log "terminating"