#!/bin/bash # # crontab(1) replacement for hosts without cron (Flatcar Container Linux). # Each crontab line becomes a systemd --user timer and service for the calling # user, so jobs keep that user's HOME, groups and working directory. # # Install as /opt/bin/crontab (mode 0755). # # crontab FILE | crontab - replace the crontab # crontab -l print it # crontab -r remove it # # Anything that cannot be converted faithfully is refused and the installed # crontab is left as it was. set -euo pipefail STATE_DIR="$HOME/.config/crontab-timers" UNIT_DIR="$HOME/.config/systemd/user" PREFIX=crontab-job export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" die() { echo "crontab: $*" >&2; exit 1; } # Convert one numeric cron field: "*", "*/n", "a", "a-b", "a-b/n", or a list. convert_field() { # value first-value local out="" item local -a items IFS=, read -ra items <<< "$1" for item in "${items[@]}"; do case "$item" in '*') ;; '*/'*) item="$2/${item#\*/}" ;; *-*) item="${item/-/..}" ;; esac [[ "$item" =~ ^(\*|[0-9]+((\.\.[0-9]+)?(/[0-9]+)?))$ ]] || return 1 out+="${out:+,}$item" done printf '%s' "$out" } month_numbers() { sed -E 's/jan/1/I; s/feb/2/I; s/mar/3/I; s/apr/4/I; s/may/5/I; s/jun/6/I; s/jul/7/I; s/aug/8/I; s/sep/9/I; s/oct/10/I; s/nov/11/I; s/dec/12/I' <<< "$1" } # Weekdays are expanded to names, so that 0-2 (Sun..Tue) survives systemd's # Monday-first week. Prints nothing for "*". convert_weekdays() { local -a names=(Sun Mon Tue Wed Thu Fri Sat) items local value item first last day out="" [ "$1" = '*' ] && return 0 value="$(sed -E 's/sun/0/Ig; s/mon/1/Ig; s/tue/2/Ig; s/wed/3/Ig; s/thu/4/Ig; s/fri/5/Ig; s/sat/6/Ig' <<< "$1")" IFS=, read -ra items <<< "$value" for item in "${items[@]}"; do [[ "$item" =~ ^([0-7])(-([0-7]))?$ ]] || return 1 first="${BASH_REMATCH[1]}" last="${BASH_REMATCH[3]:-$first}" [ "$first" -le "$last" ] || return 1 for ((day = first; day <= last; day++)); do out+="${out:+,}${names[day % 7]}" done done printf '%s ' "$out" } # Prints the OnCalendar= value for the five cron fields. to_calendar() { # minute hour day-of-month month day-of-week local minute hour dom month weekdays # cron ORs day-of-month with day-of-week, systemd ANDs them. if [ "$3" != '*' ] && [ "$5" != '*' ]; then return 1 fi minute="$(convert_field "$1" 0)" || return 1 hour="$(convert_field "$2" 0)" || return 1 dom="$(convert_field "$3" 1)" || return 1 month="$(convert_field "$(month_numbers "$4")" 1)" || return 1 weekdays="$(convert_weekdays "$5")" || return 1 printf '%s*-%s-%s %s:%s:00' "$weekdays" "$month" "$dom" "$hour" "$minute" } # Turns the crontab in $1 into job scripts and calendars under $2. parse_crontab() { local input="$1" out="$2" line minute hour dom month dow command calendar local number=0 line_number=0 while IFS= read -r line || [ -n "$line" ]; do line_number=$((line_number + 1)) [[ "$line" =~ ^[[:space:]]*(#.*)?$ ]] && continue [[ "$line" =~ ^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*[[:space:]]*= ]] \ && die "line $line_number: environment settings are not supported" [[ "$line" =~ ^[[:space:]]*@ ]] \ && die "line $line_number: @-schedules are not supported, use the five fields" read -r minute hour dom month dow command <<< "$line" [ -n "$command" ] || die "line $line_number: expected five time fields and a command" [[ "${command//\\%/}" == *%* ]] \ && die "line $line_number: unescaped % (stdin/newline in cron) is not supported" command="${command//\\%/%}" calendar="$(to_calendar "$minute" "$hour" "$dom" "$month" "$dow")" \ || die "line $line_number: cannot convert schedule: $minute $hour $dom $month $dow" systemd-analyze calendar "$calendar" > /dev/null 2>&1 \ || die "line $line_number: invalid schedule: $minute $hour $dom $month $dow" number=$((number + 1)) printf '#!/bin/sh\n%s\n' "$command" > "$out/$number.sh" printf '%s\n' "$calendar" > "$out/$number.calendar" done < "$input" } ensure_user_manager() { local attempt if [ "$(loginctl show-user "$(id -un)" --property=Linger --value 2> /dev/null)" != yes ]; then loginctl enable-linger "$(id -un)" 2> /dev/null \ || sudo -n loginctl enable-linger "$(id -un)" \ || die "cannot enable linger; run: sudo loginctl enable-linger $(id -un)" fi for attempt in 1 2 3 4 5 6 7 8 9 10; do [ -S "$XDG_RUNTIME_DIR/bus" ] && return 0 sleep 1 done die "the systemd user manager is not running ($XDG_RUNTIME_DIR/bus is missing)" } remove_units() { local timer for timer in "$UNIT_DIR/$PREFIX"-*.timer; do [ -e "$timer" ] || continue # Stops the timer only: a job already running is left to finish. systemctl --user disable --now "$(basename "$timer")" > /dev/null 2>&1 || true rm -f "$timer" "${timer%.timer}.service" done } write_units() { # number calendar cat > "$UNIT_DIR/$PREFIX-$1.service" < "$UNIT_DIR/$PREFIX-$1.timer" < "$work/crontab" mkdir "$work/jobs" parse_crontab "$work/crontab" "$work/jobs" ensure_user_manager mkdir -p "$UNIT_DIR" "$STATE_DIR" remove_units rm -rf "$STATE_DIR/jobs" mkdir "$STATE_DIR/jobs" for calendar in "$work"/jobs/*.calendar; do [ -e "$calendar" ] || continue number="$(basename "$calendar" .calendar)" cp "$work/jobs/$number.sh" "$STATE_DIR/jobs/$number.sh" write_units "$number" "$(cat "$calendar")" done cp "$work/crontab" "$STATE_DIR/crontab" systemctl --user daemon-reload for calendar in "$STATE_DIR"/jobs/*.sh; do [ -e "$calendar" ] || continue systemctl --user enable --now "$PREFIX-$(basename "$calendar" .sh).timer" > /dev/null 2>&1 done } case "${1:-}" in -l) [ -f "$STATE_DIR/crontab" ] || die "no crontab for $(id -un)" cat "$STATE_DIR/crontab" ;; -r) remove_units systemctl --user daemon-reload rm -rf "$STATE_DIR" ;; -) install_crontab /dev/stdin ;; '' | -*) die "usage: crontab FILE | crontab - | crontab -l | crontab -r" ;; *) [ -r "$1" ] || die "cannot read $1" install_crontab "$1" ;; esac