From 33ac3d93c4341c71ccc9c6d8e6e5bdf072d0ec5d Mon Sep 17 00:00:00 2001 From: Fabio Kaiser Rauber Date: Mon, 9 Nov 2026 09:34:41 -0300 Subject: [PATCH] Add crontab command backed by systemd timers Flatcar Container Linux has no cron. Provide a bash crontab(1) that turns each crontab line into a systemd --user timer and service, so scripts calling crontab run unchanged. Co-Authored-By: Claude Opus 5.5 --- README.md | 60 ++++++++++++++++ crontab | 209 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 README.md create mode 100755 crontab diff --git a/README.md b/README.md new file mode 100644 index 0000000..c11ef9a --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# flatcar-crontab + +A `crontab` command for [Flatcar Container Linux](https://www.flatcar.org/), written in bash, that needs nothing beyond what Flatcar ships. + +Flatcar has no cron, and no official or sysext-bakery extension provides one. This wrapper lets scripts that call `crontab` run unchanged: each crontab line becomes a systemd `--user` timer and service for the calling user. + +## Install + +```bash +sudo install -D -m 0755 crontab /opt/bin/crontab +sudo loginctl enable-linger core # the user that owns the crontab +``` + +`/opt/bin` must be on the `PATH` of whatever calls `crontab`. Linger keeps the user's timers running without a login session. + +## Usage + +```bash +crontab FILE # replace the crontab from a file +crontab - # replace the crontab from stdin +crontab -l # print it +crontab -r # remove it +``` + +There is no `crontab -e`: edit a file and install it. + +## Checking jobs + +```bash +systemctl --user list-timers --all +journalctl --user -u crontab-job-3 +``` + +Jobs are numbered in crontab order. + +## Files + +| Path | Content | +|---|---| +| `~/.config/crontab-timers/crontab` | The installed crontab | +| `~/.config/crontab-timers/jobs/N.sh` | The command of job N | +| `~/.config/systemd/user/crontab-job-N.{timer,service}` | Generated units | + +## Supported schedules + +Five fields, each `*`, `*/n`, a value, `a-b`, `a-b/n` or a comma list. Month and weekday names work. Weekdays do not accept steps, and a weekday range must be ascending (`0-2`, not `5-1`). + +These are refused, and the installed crontab is left as it was: + +- `@daily` and other `@` schedules +- `VAR=value` lines +- Unescaped `%` (`\%` is accepted and becomes `%`) +- Restricting both day-of-month and weekday: cron ORs them, systemd ANDs them + +## Differences from cron + +- A run that comes due while the previous one is still active is skipped, not started alongside it. +- There is no mail; capture job output yourself, for example with `logger`. +- Runs missed while the machine was off are not caught up, as in cron. +- Times follow the host's time zone, which is normally UTC on Flatcar. diff --git a/crontab b/crontab new file mode 100755 index 0000000..2639b9c --- /dev/null +++ b/crontab @@ -0,0 +1,209 @@ +#!/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