Cron-safe wrapper: lock, log and report failures for any scheduled command
Wrap any cron or timer job so it never runs twice at once, writes a timestamped log, keeps the exit code and sends a short failure report by mail or webhook. Pure bash and util-linux flock.
Drafted with AI from official documentation. Scripts were run in a test environment and repository links verified. Awaiting a human check. Did it work for you? Last edited Outdated?
What this changes
Creates a lock file under /run/lock (or $TMPDIR), appends to a log file under the directory you pass with -l (default /var/log/cronwrap) and runs the wrapped command. On failure it mails or posts a short report if -m or -w is set.
How to undo it
Delete the lock and log files it created, for example rm /run/lock/cronwrap-NAME.lock /var/log/cronwrap/NAME.log. The wrapper does not change the wrapped command or any system setting.
A scheduled job has three classic failure modes: it runs again before the previous run finished and both runs fight over the same files, it fails silently for weeks because nobody reads cron mail, and when it finally does break there is no log of what happened. This wrapper fixes all three. Put it in front of any command in a crontab or a systemd service.
The script
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_NAME=$(basename "$0")
LOG_DIR=${CRONWRAP_LOG_DIR:-/var/log/cronwrap}
LOCK_DIR=${CRONWRAP_LOCK_DIR:-${XDG_RUNTIME_DIR:-/run/lock}}
MAIL_TO=""
WEBHOOK=""
TIMEOUT=""
NAME=""
usage() {
cat <<USAGE >&2
Usage: $SCRIPT_NAME -n NAME [-l LOG_DIR] [-m EMAIL] [-w URL] [-t SECONDS] -- COMMAND [ARGS...]
-n NAME short job name, used for the lock and log file names
-l LOG_DIR where to write NAME.log (default: $LOG_DIR)
-m EMAIL send a failure report with the local mail command
-w URL POST a failure report as JSON to this webhook
-t SECONDS kill the command if it runs longer than this
USAGE
exit 2
}
while getopts ":n:l:m:w:t:h" opt; do
case "$opt" in
n) NAME=$OPTARG ;;
l) LOG_DIR=$OPTARG ;;
m) MAIL_TO=$OPTARG ;;
w) WEBHOOK=$OPTARG ;;
t) TIMEOUT=$OPTARG ;;
h) usage ;;
:) echo "missing value for -$OPTARG" >&2; usage ;;
\?) echo "unknown option -$OPTARG" >&2; usage ;;
esac
done
shift $((OPTIND - 1))
[[ -n "$NAME" && $# -ge 1 ]] || usage
[[ "$NAME" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "NAME may only contain letters, digits, . _ -" >&2; exit 2; }
command -v flock >/dev/null 2>&1 || { echo "flock not found: install util-linux (Linux) or flock from Homebrew (macOS)" >&2; exit 1; }
if [[ -n "$TIMEOUT" ]] && ! command -v timeout >/dev/null 2>&1; then
echo "timeout not found: install GNU coreutils or drop -t" >&2
exit 1
fi
mkdir -p -- "$LOG_DIR"
LOG_FILE="$LOG_DIR/$NAME.log"
LOCK_FILE="$LOCK_DIR/cronwrap-$NAME.lock"
log() { printf '%s [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$NAME" "$*" >> "$LOG_FILE"; }
exec 9> "$LOCK_FILE"
if ! flock -n 9; then
log "SKIP: previous run still holds $LOCK_FILE"
exit 0
fi
report_failure() {
local status=$1
local tail_text
tail_text=$(tail -n 20 -- "$LOG_FILE")
local subject="[cronwrap] $NAME failed on $(hostname -s) with exit code $status"
if [[ -n "$MAIL_TO" ]] && command -v mail >/dev/null 2>&1; then
printf '%s\n\nLast log lines:\n%s\n' "$subject" "$tail_text" | mail -s "$subject" "$MAIL_TO" || true
fi
if [[ -n "$WEBHOOK" ]] && command -v curl >/dev/null 2>&1; then
local payload
payload=$(printf '%s\n%s' "$subject" "$tail_text" | python3 -c 'import json,sys; print(json.dumps({"text": sys.stdin.read()}))' 2>/dev/null || printf '{"text": "%s"}' "$subject")
curl -sS -m 15 -H 'Content-Type: application/json' -d "$payload" -- "$WEBHOOK" >/dev/null || true
fi
}
start=$(date +%s)
log "START: $*"
set +e
if [[ -n "$TIMEOUT" ]]; then
timeout --kill-after=30 "$TIMEOUT" "$@" >> "$LOG_FILE" 2>&1
else
"$@" >> "$LOG_FILE" 2>&1
fi
status=$?
set -e
elapsed=$(( $(date +%s) - start ))
if (( status == 0 )); then
log "OK: finished in ${elapsed}s"
else
if (( status == 124 )) && [[ -n "$TIMEOUT" ]]; then
log "FAIL: timed out after ${TIMEOUT}s (exit $status)"
else
log "FAIL: exit code $status after ${elapsed}s"
fi
report_failure "$status"
fi
exit "$status"
How to use it
Check before you run this
- Runs with administrator rights. Make sure you understand every line.
sudo install -m 755 cronwrap.sh /usr/local/bin/cronwrap
# Try it by hand first
cronwrap -n hello -l /tmp/cronwrap -- echo "it works"
cronwrap -n hello -l /tmp/cronwrap -- false; echo "exit was $?"
cat /tmp/cronwrap/hello.log
# In a crontab
30 2 * * * /usr/local/bin/cronwrap -n nightly-report -m [email protected] -t 3600 -- /usr/local/bin/nightly-report.sh
# In a systemd service
ExecStart=/usr/local/bin/cronwrap -n nightly-report -w https://hooks.example.com/abc -- /usr/local/bin/nightly-report.sh
What each part does
exec 9> lockfileopens file descriptor 9 andflock -n 9tries to lock it without waiting. If another run holds the lock the wrapper logs SKIP and exits 0, so cron does not mail you about an expected overlap. The lock is released automatically when the process exits, even if it is killed.- All output of the command, stdout and stderr, is appended to
NAME.logbetween START and OK or FAIL lines with timestamps, sogrep FAILtells you the history of the job. set +earound the command lets the wrapper capture the exit code instead of dying with it; the original code is then passed back withexit "$status", so systemd and cron still see the real result.timeout(from GNU coreutils) stops a hung job; exit code 124 means it timed out and--kill-aftersends SIGKILL if the job ignores SIGTERM.- The failure report contains the last 20 log lines. Mail uses the local
mailcommand (installmailutilsors-nailand a working MTA); the webhook posts a{"text": ...}JSON body that Slack-style incoming webhooks and most chat tools accept. --separates the wrapper’s options from the command, so the wrapped command can take its own-xstyle flags.
Notes and limits
- The lock is per machine. If the same job runs on several hosts, add a shared lock (a database row, Redis
SET NX, or a scheduler) or let only one host run it. - Log files grow forever. Add a
/etc/logrotate.d/cronwrapentry, or point-lat a directory you already rotate. flockis part of util-linux and is on every mainstream Linux. macOS does not ship aflockcommand, so the wrapper refuses to start there until you install it withbrew install flock.- Careful: the wrapper does not add any safety to the command itself. A job that deletes files will still delete files; the wrapper only makes sure it runs once at a time and that you hear about failures.
Official documentation
Did this work for you?
No votes yet. Be the first.
If it did not work, the report form below tells the editors what went wrong.
See also
- 0058 Safe bash script template with set ‑euo pipefail and trap A starting point for bash scripts that stops on errors, rejects unset variables, cleans up temp files on exit and parses options.… Intermediate · Cheat sheet · Linux, macOS Script Shell scripting 15 Oct 2026
- 0059 Incremental snapshot backups with rsync A bash script that copies a folder into dated snapshots with rsync, hard-links unchanged files to the previous snapshot to save space,… Advanced · Step by step · Linux, macOS Script Backups & syncing · Shell scripting 15 Oct 2026
- 0061 Fix: Address already in use (free a busy port) Your server cannot start because another process already listens on its port. This script shows which process holds the port and stops… Intermediate · Fixes an error · Linux, macOS Script Processes & ports · Shell scripting 15 Oct 2026
- 0108 List all Proxmox VE guests with status and locks (pvesh + jq) A Bash script that lists every VM and container in the cluster with node, status, lock, memory and uptime, using the Proxmox… Intermediate · Cheat sheet · Linux Script Shell scripting · VMs & containers 15 Oct 2026
- 0109 A vzdump wrapper script with checks, retention and a log file A Bash wrapper around vzdump that checks the target storage first, backs up chosen guests or all guests with zstd and keep-last… Intermediate · Step by step · Linux Script Backup & restore · Shell scripting 15 Oct 2026
- 0265 Turn a one-liner into a robust bash script: strict mode, options, logging, traps, locking and a schedule Grow a find-and-gzip one-liner step by step into a dependable bash script with strict mode, getopts, usage text, logging, error traps, dry-run,… Intermediate · Guided project · Linux, macOS Article Services & systemd · Shell scripting 15 Oct 2026
History
Every saved edit is kept. The plot on the sleeve draws one line per edit. Open any edit to see exactly what changed.
- Dominik +6,632 chars