Browse Source
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
commit
33ac3d93c4
2 changed files with 269 additions and 0 deletions
@ -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. |
||||
@ -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…
Reference in new issue