You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
209 lines
6.9 KiB
209 lines
6.9 KiB
#!/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" <<EOF
|
|
[Unit]
|
|
Description=crontab job $1
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
WorkingDirectory=%h
|
|
ExecStart=/bin/sh %h/.config/crontab-timers/jobs/$1.sh
|
|
EOF
|
|
cat > "$UNIT_DIR/$PREFIX-$1.timer" <<EOF
|
|
[Unit]
|
|
Description=crontab job $1 schedule
|
|
|
|
[Timer]
|
|
OnCalendar=$2
|
|
AccuracySec=1s
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
EOF
|
|
}
|
|
|
|
install_crontab() {
|
|
local input="$1" calendar number
|
|
# Not local: the EXIT trap runs after this function has returned.
|
|
work="$(mktemp -d)"
|
|
trap 'rm -rf "$work"' EXIT
|
|
cat -- "$input" > "$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
|
|
|