Browse Source

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 <noreply@anthropic.com>
main
Fábio Kaiser Rauber 5 hours ago
commit
33ac3d93c4
  1. 60
      README.md
  2. 209
      crontab

60
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.

209
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" <<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
Loading…
Cancel
Save