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