Fetches the last N lines of journal output (default 10) via journalctl and includes them in desktop/email notification bodies. Supports CLI (-o 5) and edit mode (o or o=5 flag syntax). Also fixes systemd %s specifier expansion in email printf format strings (must use %%s so systemd passes %s through to the shell). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1038 lines
35 KiB
Bash
Executable file
1038 lines
35 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# systab - A cron/at/batch-like interface for systemd
|
|
# Managed jobs are marked with: # SYSTAB_MANAGED
|
|
|
|
readonly SCRIPT_NAME="systab"
|
|
readonly SYSTEMD_USER_DIR="${HOME}/.config/systemd/user"
|
|
readonly MARKER="# SYSTAB_MANAGED"
|
|
|
|
# Global variables for options
|
|
opt_time=""
|
|
opt_command=""
|
|
opt_file=""
|
|
opt_notify=false
|
|
opt_email=""
|
|
opt_edit=false
|
|
opt_list=false
|
|
opt_clean=false
|
|
opt_status=false
|
|
opt_pause=""
|
|
opt_resume=""
|
|
opt_filter=""
|
|
opt_output=""
|
|
opt_jobid=""
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: $SCRIPT_NAME [OPTIONS]
|
|
|
|
Create and manage systemd timer jobs with cron/at-like simplicity.
|
|
|
|
Job Creation Options:
|
|
-t <time> Time specification (see TIME FORMATS below)
|
|
-c <command> Command string to execute
|
|
-f <script> Script file to execute
|
|
-i Send desktop notification on completion (success/failure)
|
|
-m <email> Send email notification to address (via sendmail)
|
|
-o <lines> Include last N lines of job output in notifications (default: 10)
|
|
|
|
Management Options:
|
|
-P <id> Pause (disable) a job
|
|
-R <id> Resume (enable) a paused job
|
|
-E Edit jobs in crontab-like format
|
|
-L [id] [filter] List job logs (optionally for a specific job and/or filtered)
|
|
-S [id] Show status of all managed jobs (or a specific job)
|
|
-C Clean up completed one-time jobs
|
|
|
|
TIME FORMATS:
|
|
Natural: "every 5 minutes", "every day at 2am", "every monday at 9am"
|
|
Relative: "in 5 minutes", "in 2 hours", "tomorrow"
|
|
Absolute: "2025-01-21 14:30", "next tuesday at noon"
|
|
Systemd: "daily", "weekly", "hourly", "*:0/15" (every 15 min)
|
|
|
|
EXAMPLES:
|
|
# Run command every 5 minutes
|
|
$SCRIPT_NAME -t "every 5 minutes" -c "echo Hello"
|
|
|
|
# Run script every day at 2am with desktop notification
|
|
$SCRIPT_NAME -t "every day at 2am" -f ~/backup.sh -i
|
|
|
|
# Run command with email notification
|
|
$SCRIPT_NAME -t "in 5 minutes" -c "echo Hello" -m user@example.com
|
|
|
|
# Run command in 5 minutes (one-time)
|
|
$SCRIPT_NAME -t "in 5 minutes" -c "echo Hello"
|
|
|
|
# Read command from stdin
|
|
echo "ls -la" | $SCRIPT_NAME -t "next monday at 9am"
|
|
|
|
# Edit existing jobs (supports adding notifications via ID:flags syntax)
|
|
$SCRIPT_NAME -E
|
|
|
|
# Pause and resume a job
|
|
$SCRIPT_NAME -P <id>
|
|
$SCRIPT_NAME -R <id>
|
|
|
|
# View logs for backup jobs
|
|
$SCRIPT_NAME -L backup
|
|
|
|
# Show status of all jobs
|
|
$SCRIPT_NAME -S
|
|
|
|
# Clean up completed jobs
|
|
$SCRIPT_NAME -C
|
|
|
|
EOF
|
|
exit "${1:-0}"
|
|
}
|
|
|
|
error() {
|
|
echo "Error: $*" >&2
|
|
exit 1
|
|
}
|
|
|
|
warn() {
|
|
echo "Warning: $*" >&2
|
|
}
|
|
|
|
# Extract the command string from a service file's ExecStart line
|
|
get_job_command() {
|
|
sed -n "s/^ExecStart=.*-c '\\(.*\\)'$/\\1/p" "$1"
|
|
}
|
|
|
|
# Trim leading and trailing whitespace, result in _trimmed
|
|
trim() {
|
|
_trimmed="$1"
|
|
_trimmed="${_trimmed#"${_trimmed%%[![:space:]]*}"}"
|
|
_trimmed="${_trimmed%"${_trimmed##*[![:space:]]}"}"
|
|
}
|
|
|
|
# Parse time specification into systemd OnCalendar format
|
|
parse_time() {
|
|
local time_spec="$1"
|
|
|
|
# Common systemd calendar formats (pass through)
|
|
case "${time_spec,,}" in
|
|
hourly|daily|weekly|monthly|yearly) echo "$time_spec"; return ;;
|
|
*:*|*-*-*) echo "$time_spec"; return ;; # Assume systemd time format
|
|
esac
|
|
|
|
# Natural language recurring patterns
|
|
local lower="${time_spec,,}"
|
|
if [[ "$lower" =~ ^every[[:space:]]+(.+) ]]; then
|
|
local rest="${BASH_REMATCH[1]}"
|
|
case "$rest" in
|
|
# "every N minutes/hours/seconds"
|
|
[0-9]*' minute'*) echo "*:0/${rest%% *}"; return ;;
|
|
[0-9]*' hour'*) echo "0/${rest%% *}:00"; return ;;
|
|
[0-9]*' second'*) echo "*:*:0/${rest%% *}"; return ;;
|
|
# "every minute/hour"
|
|
'minute'*) echo "*:*"; return ;;
|
|
'hour'*) echo "hourly"; return ;;
|
|
# "every day at <time>"
|
|
'day at '*)
|
|
local at_time="${rest#day at }"
|
|
local parsed
|
|
if parsed=$(date -d "$at_time" '+%H:%M:%S' 2>/dev/null); then
|
|
echo "*-*-* $parsed"; return
|
|
fi ;;
|
|
# "every <weekday> [at <time>]"
|
|
mon|monday|tue|tuesday|wed|wednesday|thu|thursday|fri|friday|sat|saturday|sun|sunday)
|
|
local day="${rest:0:3}"; day="${day^}"
|
|
echo "$day *-*-*"; return ;;
|
|
mon' at '*|monday' at '*|tue' at '*|tuesday' at '*|wed' at '*|wednesday' at '*|thu' at '*|thursday' at '*|fri' at '*|friday' at '*|sat' at '*|saturday' at '*|sun' at '*|sunday' at '*)
|
|
local day_part="${rest%% at *}" at_time="${rest#* at }"
|
|
local day="${day_part:0:3}"; day="${day^}"
|
|
local parsed
|
|
if parsed=$(date -d "$at_time" '+%H:%M:%S' 2>/dev/null); then
|
|
echo "$day *-*-* $parsed"; return
|
|
fi ;;
|
|
# "every day/week/month/year"
|
|
'day'*) echo "daily"; return ;;
|
|
'week'*) echo "weekly"; return ;;
|
|
'month'*) echo "monthly"; return ;;
|
|
'year'*) echo "yearly"; return ;;
|
|
esac
|
|
fi
|
|
|
|
# Try to parse with date command (one-time specs)
|
|
local parsed_date
|
|
if parsed_date=$(date -d "$time_spec" '+%Y-%m-%d %H:%M:%S' 2>/dev/null); then
|
|
echo "$parsed_date"
|
|
return
|
|
fi
|
|
|
|
error "Unable to parse time specification: $time_spec"
|
|
}
|
|
|
|
# Check if time spec is recurring
|
|
# One-time specs are absolute timestamps (from date -d): "2026-02-14 09:33:10"
|
|
# Everything else (calendar specs with wildcards, keywords, intervals) is recurring
|
|
is_recurring() {
|
|
local time_spec="$1"
|
|
case "${time_spec,,}" in
|
|
hourly|daily|weekly|monthly|yearly) return 0 ;;
|
|
*'*'*|*/*) return 0 ;; # Contains wildcard or interval
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
# Generate a random 6-char hex ID
|
|
generate_id() {
|
|
od -An -tx1 -N3 /dev/urandom | tr -d ' \n'
|
|
}
|
|
|
|
# Extract short ID from job name (systab_<id> → <id>)
|
|
job_id() {
|
|
echo "${1#"${SCRIPT_NAME}"_}"
|
|
}
|
|
|
|
# Check if a job's timer is enabled
|
|
is_job_enabled() {
|
|
systemctl --user is-enabled "${1}.timer" &>/dev/null
|
|
}
|
|
|
|
# Disable a job (stop + disable timer)
|
|
disable_job() {
|
|
systemctl --user stop "${1}.timer" 2>/dev/null || true
|
|
systemctl --user disable "${1}.timer" 2>/dev/null || true
|
|
}
|
|
|
|
# Enable a job (enable + start timer)
|
|
enable_job() {
|
|
systemctl --user enable "${1}.timer" 2>/dev/null || true
|
|
systemctl --user start "${1}.timer" 2>/dev/null || true
|
|
}
|
|
|
|
# Validate a short ID refers to a managed job, sets _job_name
|
|
validate_job_id() {
|
|
local id="$1"
|
|
_job_name="${SCRIPT_NAME}_${id}"
|
|
local timer_file="$SYSTEMD_USER_DIR/${_job_name}.timer"
|
|
[[ -f "$timer_file" ]] || error "No job found with ID: $id"
|
|
grep -q "^$MARKER" "$timer_file" 2>/dev/null || error "Not a managed job: $id"
|
|
}
|
|
|
|
# Pause a job by short ID
|
|
pause_job() {
|
|
validate_job_id "$1"
|
|
if ! is_job_enabled "$_job_name"; then
|
|
echo "Already paused: $1"
|
|
return
|
|
fi
|
|
disable_job "$_job_name"
|
|
echo "Paused: $1"
|
|
}
|
|
|
|
# Resume a job by short ID
|
|
resume_job() {
|
|
validate_job_id "$1"
|
|
if is_job_enabled "$_job_name"; then
|
|
echo "Already running: $1"
|
|
return
|
|
fi
|
|
enable_job "$_job_name"
|
|
echo "Resumed: $1"
|
|
}
|
|
|
|
# Get all managed unit files of a given type (service or timer)
|
|
get_managed_units() {
|
|
local ext="$1"
|
|
[[ -d "$SYSTEMD_USER_DIR" ]] || return
|
|
local file
|
|
for file in "$SYSTEMD_USER_DIR"/*."$ext"; do
|
|
[[ -f "$file" ]] || continue
|
|
grep -q "^$MARKER" "$file" 2>/dev/null && basename "$file" ".$ext"
|
|
done
|
|
}
|
|
|
|
# Build flags string from opt_notify and opt_email
|
|
build_flags_string() {
|
|
local flags=""
|
|
if $opt_notify; then
|
|
flags="i"
|
|
fi
|
|
if [[ -n "$opt_output" ]]; then
|
|
[[ -n "$flags" ]] && flags+=","
|
|
if [[ "$opt_output" == "10" ]]; then flags+="o"; else flags+="o=$opt_output"; fi
|
|
fi
|
|
if [[ -n "$opt_email" ]]; then
|
|
[[ -n "$flags" ]] && flags+=","
|
|
flags+="e=$opt_email"
|
|
fi
|
|
echo "$flags"
|
|
}
|
|
|
|
# Parse flags string into _notify_flag (bool) and _email_addr (string)
|
|
parse_flags() {
|
|
local flags="$1"
|
|
_notify_flag=false
|
|
_email_addr=""
|
|
_output_lines=""
|
|
IFS=',' read -ra parts <<< "$flags"
|
|
for part in "${parts[@]}"; do
|
|
case "$part" in
|
|
i) _notify_flag=true ;;
|
|
o) _output_lines=10 ;;
|
|
o=*) _output_lines="${part#o=}" ;;
|
|
e=*) _email_addr="${part#e=}" ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# Write ExecStopPost notification lines to a service file
|
|
# Usage: write_notify_lines <short_id> <notify_flag> <email_addr> <file> [output_lines] [job_name]
|
|
write_notify_lines() {
|
|
local short_id="$1" notify="$2" email="$3" file="$4"
|
|
local output_lines="${5-}" job_name="${6-}"
|
|
local out_cmd=""
|
|
if [[ -n "$output_lines" && -n "$job_name" ]]; then
|
|
out_cmd="out=\$(journalctl --user SYSLOG_IDENTIFIER=$job_name -n $output_lines --no-pager -o cat); "
|
|
fi
|
|
if [[ "$notify" == true ]]; then
|
|
if [[ -n "$out_cmd" ]]; then
|
|
cat >> "$file" <<EOF
|
|
ExecStopPost=/bin/sh -c 'if [ "\$SERVICE_RESULT" = success ]; then icon=dialog-information; s=completed; else icon=dialog-error; s="failed (\$EXIT_STATUS)"; fi; ${out_cmd}body=\$(printf "Job $short_id: %%s\n%%s" "\$s" "\$out"); notify-send -i "\$icon" "systab" "\$body" || true'
|
|
EOF
|
|
else
|
|
cat >> "$file" <<EOF
|
|
ExecStopPost=/bin/sh -c 'if [ "\$SERVICE_RESULT" = success ]; then icon=dialog-information; s=completed; else icon=dialog-error; s="failed (\$EXIT_STATUS)"; fi; notify-send -i "\$icon" "systab" "Job $short_id: \$s" || true'
|
|
EOF
|
|
fi
|
|
fi
|
|
if [[ -n "$email" ]]; then
|
|
local mailer
|
|
mailer=$(command -v sendmail || command -v msmtp || true)
|
|
[[ -n "$mailer" ]] || { warn "No sendmail or msmtp found, skipping email notification"; return; }
|
|
if [[ -n "$out_cmd" ]]; then
|
|
cat >> "$file" <<EOF
|
|
ExecStopPost=/bin/sh -c 'if [ "\$SERVICE_RESULT" = success ]; then s=completed; else s="failed (\$EXIT_STATUS)"; fi; ${out_cmd}printf "Subject: systab: $short_id %%s\\n\\n%%s at %%s\\n\\n%%s\\n" "\$s" "\$s" "\$(date)" "\$out" | $mailer $email'
|
|
EOF
|
|
else
|
|
cat >> "$file" <<EOF
|
|
ExecStopPost=/bin/sh -c 'if [ "\$SERVICE_RESULT" = success ]; then s=completed; else s="failed (\$EXIT_STATUS)"; fi; printf "Subject: systab: $short_id %%s\\n\\n%%s at %%s\\n" "\$s" "\$s" "\$(date)" | $mailer $email'
|
|
EOF
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# Write service + timer unit files, enable and start the timer
|
|
# Usage: _write_unit_files <command> <schedule> [flags]
|
|
# Sets _created_id to the short ID of the new job
|
|
_write_unit_files() {
|
|
local command_to_run="$1" schedule="$2" flags="${3-}"
|
|
local job_name short_id
|
|
|
|
job_name="${SCRIPT_NAME}_$(generate_id)"
|
|
short_id=$(job_id "$job_name")
|
|
|
|
mkdir -p "$SYSTEMD_USER_DIR"
|
|
|
|
# Service file
|
|
local service_file="$SYSTEMD_USER_DIR/${job_name}.service"
|
|
cat > "$service_file" <<EOF
|
|
$MARKER
|
|
[Unit]
|
|
Description=$SCRIPT_NAME job: $job_name
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
ExecStart=${SHELL:-/bin/bash} -c '$command_to_run'
|
|
StandardOutput=journal
|
|
StandardError=journal
|
|
SyslogIdentifier=$job_name
|
|
EOF
|
|
|
|
if [[ -n "$flags" ]]; then
|
|
echo "# SYSTAB_FLAGS=$flags" >> "$service_file"
|
|
parse_flags "$flags"
|
|
write_notify_lines "$short_id" "$_notify_flag" "$_email_addr" "$service_file" "$_output_lines" "$job_name"
|
|
fi
|
|
|
|
# Timer file
|
|
local timer_file="$SYSTEMD_USER_DIR/${job_name}.timer"
|
|
cat > "$timer_file" <<EOF
|
|
$MARKER
|
|
[Unit]
|
|
Description=Timer for $SCRIPT_NAME job: $job_name
|
|
|
|
[Timer]
|
|
OnCalendar=$schedule
|
|
EOF
|
|
|
|
if ! is_recurring "$schedule"; then
|
|
cat >> "$timer_file" <<'ONETIME'
|
|
Persistent=false
|
|
RemainAfterElapse=no
|
|
ONETIME
|
|
fi
|
|
|
|
cat >> "$timer_file" <<EOF
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
EOF
|
|
|
|
systemctl --user enable "$job_name.timer"
|
|
systemctl --user start "$job_name.timer"
|
|
|
|
_created_id="$short_id"
|
|
}
|
|
|
|
# Create a job from CLI options
|
|
create_job() {
|
|
local command_to_run
|
|
|
|
if [[ -n "$opt_command" ]]; then
|
|
command_to_run="$opt_command"
|
|
elif [[ -n "$opt_file" ]]; then
|
|
[[ -f "$opt_file" ]] || error "File not found: $opt_file"
|
|
[[ -x "$opt_file" ]] || error "File not executable: $opt_file"
|
|
command_to_run="$opt_file"
|
|
else
|
|
if [[ -t 0 ]]; then
|
|
echo "Please enter a command, then press Ctrl+D when done:" >&2
|
|
fi
|
|
command_to_run=$(cat)
|
|
[[ -n "$command_to_run" ]] || error "No command provided"
|
|
fi
|
|
|
|
local time_spec
|
|
time_spec=$(parse_time "$opt_time")
|
|
|
|
_write_unit_files "$command_to_run" "$time_spec" "$(build_flags_string)"
|
|
systemctl --user daemon-reload
|
|
|
|
echo "Job created: $_created_id"
|
|
local job_name="${SCRIPT_NAME}_${_created_id}"
|
|
echo "Next run: $(systemctl --user list-timers "$job_name.timer" --no-pager | tail -n 2 | head -n 1 | awk '{print $1, $2, $3}')"
|
|
}
|
|
|
|
# Remove a managed job (stop, disable, delete unit files)
|
|
remove_job() {
|
|
local job_name="$1"
|
|
systemctl --user stop "${job_name}.timer" 2>/dev/null || true
|
|
systemctl --user disable "${job_name}.timer" 2>/dev/null || true
|
|
rm -f "$SYSTEMD_USER_DIR/${job_name}.service" "$SYSTEMD_USER_DIR/${job_name}.timer"
|
|
}
|
|
|
|
# Create a job from edit mode (schedule + command + flags, prints short ID)
|
|
create_job_from_edit() {
|
|
local schedule
|
|
schedule=$(parse_time "$1")
|
|
_write_unit_files "$2" "$schedule" "${3-}"
|
|
echo "$_created_id"
|
|
}
|
|
|
|
# Edit jobs in crontab-like format
|
|
edit_jobs() {
|
|
local temp_file orig_file
|
|
temp_file="${TMPDIR:-/dev/shm}/systab_edit_$$"
|
|
orig_file="${TMPDIR:-/dev/shm}/systab_orig_$$"
|
|
# shellcheck disable=SC2064
|
|
trap "rm -f '$temp_file' '$orig_file'" EXIT
|
|
|
|
# Build crontab content
|
|
{
|
|
cat <<'HEADER'
|
|
# systab jobs — edit schedule/command, add/remove lines
|
|
# Format: ID[:FLAGS] | SCHEDULE | COMMAND (pipe-separated)
|
|
# Remove a line to delete a job.
|
|
# Add a line with "new" as ID to create a job: new | daily | /path/to/cmd
|
|
# Comment out a line to disable, uncomment to re-enable.
|
|
#
|
|
# Notification flags (append to ID with ':'): i = desktop, e=addr = email,
|
|
# o = include output (default 10 lines), o=N = include N lines of output
|
|
# a1b2c3:i | daily | cmd desktop notification
|
|
# a1b2c3:i,o | daily | cmd desktop with last 10 lines of output
|
|
# a1b2c3:i,o=5 | daily | cmd desktop with last 5 lines of output
|
|
# a1b2c3:e=user@host | daily | cmd email notification
|
|
# a1b2c3:i,e=user@host | daily | cmd both
|
|
#
|
|
# Schedule formats (systemd OnCalendar):
|
|
# hourly, daily, weekly, monthly, yearly
|
|
# *:0/15 every 15 minutes
|
|
# *-*-* 02:00:00 daily at 2am
|
|
# Mon *-*-* 09:00 every Monday at 9am
|
|
# *-*-01 00:00:00 first of every month
|
|
HEADER
|
|
|
|
local job
|
|
while IFS= read -r job; do
|
|
[[ -z "$job" ]] && continue
|
|
|
|
local timer_file="$SYSTEMD_USER_DIR/${job}.timer"
|
|
local service_file="$SYSTEMD_USER_DIR/${job}.service"
|
|
|
|
if [[ -f "$timer_file" && -f "$service_file" ]]; then
|
|
local id schedule command flags id_field
|
|
id=$(job_id "$job")
|
|
schedule=$(grep "^OnCalendar=" "$timer_file" | cut -d= -f2-)
|
|
command=$(get_job_command "$service_file")
|
|
flags=$(grep "^# SYSTAB_FLAGS=" "$service_file" 2>/dev/null | sed 's/^# SYSTAB_FLAGS=//' || true)
|
|
if [[ -n "$flags" ]]; then
|
|
id_field="$id:$flags"
|
|
else
|
|
id_field="$id"
|
|
fi
|
|
if is_job_enabled "$job"; then
|
|
printf '%s | %s | %s\n' "$id_field" "$schedule" "$command"
|
|
else
|
|
printf '# %s | %s | %s\n' "$id_field" "$schedule" "$command"
|
|
fi
|
|
fi
|
|
done < <(get_managed_units timer)
|
|
} > "$temp_file"
|
|
|
|
# Save original for diffing
|
|
cp "$temp_file" "$orig_file"
|
|
|
|
# Validate all time specs in a crontab file, returns 0 if valid
|
|
# Prints error messages for each bad schedule
|
|
validate_crontab_schedules() {
|
|
local file="$1" errors=0 line
|
|
while IFS= read -r line; do
|
|
[[ -z "$line" ]] && continue
|
|
local is_comment=false
|
|
# Strip comment prefix for disabled jobs
|
|
if [[ "$line" =~ ^#[[:space:]]+(.*) ]]; then
|
|
line="${BASH_REMATCH[1]}"
|
|
is_comment=true
|
|
fi
|
|
[[ "$line" == *"|"* ]] || continue
|
|
# Extract and trim ID field
|
|
local id_field="${line%%|*}"
|
|
trim "$id_field"; id_field="$_trimmed"
|
|
# Strip :flags suffix
|
|
local id_bare="${id_field%%:*}"
|
|
# Only validate lines with a valid job ID (6-char hex or "new")
|
|
if $is_comment; then
|
|
[[ "$id_bare" =~ ^[0-9a-f]{6}$ ]] || continue
|
|
else
|
|
[[ "$id_bare" =~ ^[0-9a-f]{6}$ || "$id_bare" == "new" ]] || continue
|
|
fi
|
|
local sched="${line#*|}"
|
|
sched="${sched%%|*}"
|
|
trim "$sched"; sched="$_trimmed"
|
|
[[ -z "$sched" ]] && continue
|
|
if ! (parse_time "$sched") &>/dev/null; then
|
|
echo "Bad schedule: \"$sched\"" >&2
|
|
errors=$((errors + 1))
|
|
fi
|
|
done < "$file"
|
|
return "$errors"
|
|
}
|
|
|
|
# Edit loop: open editor, validate, re-edit on errors (like crontab -e)
|
|
while true; do
|
|
if ! "${EDITOR:-vi}" "$temp_file"; then
|
|
echo "Editor exited with error, aborting."
|
|
return 1
|
|
fi
|
|
|
|
# Check for no changes
|
|
if diff -q "$orig_file" "$temp_file" &>/dev/null; then
|
|
echo "No changes."
|
|
return
|
|
fi
|
|
|
|
# Validate schedules before applying
|
|
if validate_crontab_schedules "$temp_file"; then
|
|
break
|
|
fi
|
|
|
|
echo "Errors in systab file, can't install." >&2
|
|
read -p "Do you want to retry the same edit? (y/n) " -n 1 -r </dev/tty
|
|
echo
|
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
|
echo "Aborted."
|
|
return 1
|
|
fi
|
|
done
|
|
|
|
# Parse files into associative arrays: id -> "schedule\tcmd"
|
|
# "new" lines are collected separately since there can be multiple
|
|
# commented_ids tracks disabled (commented-out) job lines
|
|
declare -A orig_jobs edited_jobs orig_commented edited_commented
|
|
declare -a new_jobs=()
|
|
|
|
# Parse a crontab line into id, flags, sched, cmd (split on first two | delimiters)
|
|
# _parsed_id gets the bare ID, _parsed_flags gets any flags after ':'
|
|
parse_crontab_line() {
|
|
local line="$1"
|
|
_parsed_id="" _parsed_flags="" _parsed_sched="" _parsed_cmd=""
|
|
if [[ "$line" == *"|"* ]]; then
|
|
local id_field="${line%%|*}"
|
|
local rest="${line#*|}"
|
|
_parsed_sched="${rest%%|*}"
|
|
_parsed_cmd="${rest#*|}"
|
|
# Trim leading/trailing whitespace
|
|
trim "$id_field"; id_field="$_trimmed"
|
|
trim "$_parsed_sched"; _parsed_sched="$_trimmed"
|
|
trim "$_parsed_cmd"; _parsed_cmd="$_trimmed"
|
|
# Split id_field on ':' into ID and flags
|
|
if [[ "$id_field" == *:* ]]; then
|
|
_parsed_id="${id_field%%:*}"
|
|
_parsed_flags="${id_field#*:}"
|
|
else
|
|
_parsed_id="$id_field"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# Parse a file, populating jobs and commented arrays
|
|
# Values stored as "flags<TAB>schedule|cmd"
|
|
# Usage: parse_crontab_file <file> <jobs_var> <commented_var> [new_jobs_var]
|
|
parse_crontab_file() {
|
|
local file="$1" jobs_ref="$2" commented_ref="$3" new_ref="${4-}"
|
|
local line tab=$'\t'
|
|
while IFS= read -r line; do
|
|
[[ -z "$line" ]] && continue
|
|
# Check for commented job line: "# <id> <sched> <cmd>"
|
|
if [[ "$line" =~ ^#[[:space:]]+(.*) ]]; then
|
|
local uncommented="${BASH_REMATCH[1]}"
|
|
parse_crontab_line "$uncommented"
|
|
# Only treat as disabled job if ID looks like a hex short ID (with optional :flags)
|
|
if [[ -n "$_parsed_id" && -n "$_parsed_sched" && "$_parsed_id" =~ ^[0-9a-f]{6}$ ]]; then
|
|
local _val="${_parsed_flags}${tab}${_parsed_sched}|${_parsed_cmd}"
|
|
eval "${commented_ref}[\"\$_parsed_id\"]=\$_val"
|
|
fi
|
|
continue
|
|
fi
|
|
parse_crontab_line "$line"
|
|
[[ -n "$_parsed_id" && -n "$_parsed_sched" ]] || continue
|
|
local _val="${_parsed_flags}${tab}${_parsed_sched}|${_parsed_cmd}"
|
|
if [[ "$_parsed_id" == "new" && -n "$new_ref" ]]; then
|
|
eval "${new_ref}+=(\"\$_val\")"
|
|
else
|
|
eval "${jobs_ref}[\"\$_parsed_id\"]=\$_val"
|
|
fi
|
|
done < "$file"
|
|
}
|
|
|
|
parse_crontab_file "$orig_file" orig_jobs orig_commented
|
|
parse_crontab_file "$temp_file" edited_jobs edited_commented new_jobs
|
|
|
|
# Collect all known IDs (from both active and commented in original)
|
|
declare -A all_orig_ids
|
|
for id in "${!orig_jobs[@]}"; do all_orig_ids["$id"]=1; done
|
|
for id in "${!orig_commented[@]}"; do all_orig_ids["$id"]=1; done
|
|
|
|
local created=0 deleted=0 updated=0 enabled=0 disabled=0 needs_reload=false
|
|
|
|
# Deletions: IDs in original (active or commented) but absent from edited entirely
|
|
for id in "${!all_orig_ids[@]}"; do
|
|
if [[ -z "${edited_jobs[$id]+x}" && -z "${edited_commented[$id]+x}" ]]; then
|
|
remove_job "${SCRIPT_NAME}_${id}"
|
|
echo "Deleted: $id"
|
|
deleted=$((deleted + 1))
|
|
needs_reload=true
|
|
fi
|
|
done
|
|
|
|
# Creations: "new" lines from edited file
|
|
for entry in "${new_jobs[@]}"; do
|
|
local new_flags new_rest sched cmd
|
|
new_flags="${entry%% *}"
|
|
new_rest="${entry#* }"
|
|
sched="${new_rest%%|*}"
|
|
cmd="${new_rest#*|}"
|
|
local result
|
|
result=$(create_job_from_edit "$sched" "$cmd" "$new_flags")
|
|
echo "Created: $result"
|
|
created=$((created + 1))
|
|
needs_reload=true
|
|
done
|
|
|
|
# Handle unknown IDs (in edited but not in original and not "new")
|
|
for id in "${!edited_jobs[@]}"; do
|
|
if [[ -z "${all_orig_ids[$id]+x}" ]]; then
|
|
warn "Ignoring unknown ID '$id' — use 'new' to create jobs"
|
|
fi
|
|
done
|
|
for id in "${!edited_commented[@]}"; do
|
|
if [[ -z "${all_orig_ids[$id]+x}" ]]; then
|
|
warn "Ignoring unknown commented ID '$id'"
|
|
fi
|
|
done
|
|
|
|
# Enable/disable transitions and updates
|
|
for id in "${!all_orig_ids[@]}"; do
|
|
local jname="${SCRIPT_NAME}_${id}"
|
|
local was_commented=false now_commented=false
|
|
[[ -n "${orig_commented[$id]+x}" ]] && was_commented=true
|
|
[[ -n "${edited_commented[$id]+x}" ]] && now_commented=true
|
|
|
|
# Skip if deleted
|
|
[[ -z "${edited_jobs[$id]+x}" && -z "${edited_commented[$id]+x}" ]] && continue
|
|
|
|
# Get old and new values (format: "flags\tsched|cmd")
|
|
local old_entry="" new_entry=""
|
|
if $was_commented; then
|
|
old_entry="${orig_commented[$id]}"
|
|
elif [[ -n "${orig_jobs[$id]+x}" ]]; then
|
|
old_entry="${orig_jobs[$id]}"
|
|
fi
|
|
if $now_commented; then
|
|
new_entry="${edited_commented[$id]}"
|
|
elif [[ -n "${edited_jobs[$id]+x}" ]]; then
|
|
new_entry="${edited_jobs[$id]}"
|
|
fi
|
|
|
|
# Split into flags and sched|cmd parts
|
|
local old_flags old_rest new_flags new_rest
|
|
old_flags="${old_entry%% *}"
|
|
old_rest="${old_entry#* }"
|
|
new_flags="${new_entry%% *}"
|
|
new_rest="${new_entry#* }"
|
|
|
|
# Handle enable/disable transitions
|
|
if ! $was_commented && $now_commented; then
|
|
disable_job "$jname"
|
|
echo "Disabled: $id"
|
|
disabled=$((disabled + 1))
|
|
needs_reload=true
|
|
elif $was_commented && ! $now_commented; then
|
|
enable_job "$jname"
|
|
echo "Enabled: $id"
|
|
enabled=$((enabled + 1))
|
|
needs_reload=true
|
|
fi
|
|
|
|
# Handle schedule/command/flags changes (whether active or commented)
|
|
if [[ "$old_entry" != "$new_entry" ]]; then
|
|
local old_sched new_sched old_cmd new_cmd
|
|
old_sched="${old_rest%%|*}"
|
|
new_sched="${new_rest%%|*}"
|
|
old_cmd="${old_rest#*|}"
|
|
new_cmd="${new_rest#*|}"
|
|
|
|
local timer_file="$SYSTEMD_USER_DIR/${jname}.timer"
|
|
local service_file="$SYSTEMD_USER_DIR/${jname}.service"
|
|
|
|
if [[ "$old_sched" != "$new_sched" && -f "$timer_file" ]]; then
|
|
new_sched=$(parse_time "$new_sched")
|
|
sed -i "s|^OnCalendar=.*|OnCalendar=$new_sched|" "$timer_file"
|
|
|
|
# Update one-time vs recurring settings
|
|
if is_recurring "$new_sched"; then
|
|
sed -i '/^Persistent=false$/d' "$timer_file"
|
|
sed -i '/^RemainAfterElapse=no$/d' "$timer_file"
|
|
else
|
|
if ! grep -q "^Persistent=false" "$timer_file"; then
|
|
sed -i "/^OnCalendar=/a Persistent=false\nRemainAfterElapse=no" "$timer_file"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [[ "$old_cmd" != "$new_cmd" && -f "$service_file" ]]; then
|
|
sed -i "s|^ExecStart=.*|ExecStart=${SHELL:-/bin/bash} -c '$new_cmd'|" "$service_file"
|
|
fi
|
|
|
|
# Handle flags changes
|
|
if [[ "$old_flags" != "$new_flags" && -f "$service_file" ]]; then
|
|
# Remove existing notification lines and flags comment
|
|
sed -i '/^ExecStopPost=/d' "$service_file"
|
|
sed -i '/^# SYSTAB_FLAGS=/d' "$service_file"
|
|
# Add new flags and notification lines
|
|
if [[ -n "$new_flags" ]]; then
|
|
echo "# SYSTAB_FLAGS=$new_flags" >> "$service_file"
|
|
parse_flags "$new_flags"
|
|
write_notify_lines "$id" "$_notify_flag" "$_email_addr" "$service_file" "$_output_lines" "$jname"
|
|
fi
|
|
fi
|
|
|
|
# Only restart if the job is active (not being disabled)
|
|
if ! $now_commented; then
|
|
systemctl --user restart "${jname}.timer" 2>/dev/null || true
|
|
fi
|
|
|
|
echo "Updated: $id"
|
|
updated=$((updated + 1))
|
|
needs_reload=true
|
|
fi
|
|
done
|
|
|
|
if $needs_reload; then
|
|
systemctl --user daemon-reload
|
|
fi
|
|
|
|
echo ""
|
|
echo "Summary: $created created, $updated updated, $deleted deleted, $enabled enabled, $disabled disabled"
|
|
}
|
|
|
|
# Show status of all managed jobs (or a single job if opt_jobid is set)
|
|
show_status() {
|
|
local job
|
|
local job_list=()
|
|
|
|
if [[ -n "$opt_jobid" ]]; then
|
|
job_list+=("${SCRIPT_NAME}_${opt_jobid}")
|
|
else
|
|
while IFS= read -r job; do
|
|
[[ -z "$job" ]] && continue
|
|
job_list+=("$job")
|
|
done < <(get_managed_units timer)
|
|
fi
|
|
|
|
if [[ ${#job_list[@]} -eq 0 ]]; then
|
|
echo "No managed jobs found."
|
|
return
|
|
fi
|
|
|
|
local count=${#job_list[@]}
|
|
echo "Managed Jobs Status - $count total"
|
|
echo "=========================================="
|
|
echo ""
|
|
|
|
for job in "${job_list[@]}"; do
|
|
local timer_file="$SYSTEMD_USER_DIR/${job}.timer"
|
|
local service_file="$SYSTEMD_USER_DIR/${job}.service"
|
|
|
|
echo "Job: $(job_id "$job")"
|
|
|
|
# Get schedule from timer file
|
|
if [[ -f "$timer_file" ]]; then
|
|
local schedule
|
|
schedule=$(grep "^OnCalendar=" "$timer_file" | cut -d= -f2-)
|
|
echo " Schedule: $schedule"
|
|
|
|
# Check if recurring
|
|
if grep -q "^Persistent=false" "$timer_file"; then
|
|
echo " Type: One-time"
|
|
else
|
|
echo " Type: Recurring"
|
|
fi
|
|
fi
|
|
|
|
# Get command from service file
|
|
if [[ -f "$service_file" ]]; then
|
|
local command
|
|
command=$(get_job_command "$service_file")
|
|
echo " Command: $command"
|
|
fi
|
|
|
|
# Timer status
|
|
if ! is_job_enabled "$job"; then
|
|
echo " Timer: Disabled"
|
|
elif systemctl --user is-active "${job}.timer" &>/dev/null; then
|
|
echo " Timer: Active"
|
|
|
|
local timer_line
|
|
timer_line=$(systemctl --user list-timers "${job}.timer" --no-pager 2>/dev/null | tail -n 2 | head -n 1)
|
|
local next_run last_run
|
|
next_run=$(awk '{print $1, $2, $3, $4, $5}' <<< "$timer_line")
|
|
last_run=$(awk '{for(i=6;i<=10;i++) printf $i" "; print ""}' <<< "$timer_line")
|
|
[[ -n "$next_run" ]] && echo " Next run: $next_run"
|
|
[[ -n "$last_run" && "$last_run" != "n/a "* ]] && echo " Last run: $last_run"
|
|
else
|
|
echo " Timer: Inactive/Completed"
|
|
fi
|
|
|
|
# Service status
|
|
if systemctl --user is-failed "${job}.service" &>/dev/null; then
|
|
echo " Service: Failed"
|
|
elif systemctl --user is-active "${job}.service" &>/dev/null; then
|
|
echo " Service: Running"
|
|
else
|
|
local exit_status
|
|
exit_status=$(systemctl --user show "${job}.service" -p ExecMainStatus --value 2>/dev/null)
|
|
if [[ -n "$exit_status" && "$exit_status" != "0" ]]; then
|
|
echo " Service: Completed with exit code $exit_status"
|
|
else
|
|
echo " Service: Completed"
|
|
fi
|
|
fi
|
|
|
|
echo ""
|
|
done
|
|
|
|
# Show systemctl list-timers summary
|
|
echo "Active Timers:"
|
|
echo "--------------"
|
|
systemctl --user list-timers "systab_*" --no-pager 2>/dev/null || echo "none active"
|
|
}
|
|
|
|
# List logs (or logs for a single job if opt_jobid is set)
|
|
list_logs() {
|
|
local job
|
|
local job_list=()
|
|
|
|
if [[ -n "$opt_jobid" ]]; then
|
|
job_list+=("${SCRIPT_NAME}_${opt_jobid}")
|
|
else
|
|
while IFS= read -r job; do
|
|
[[ -z "$job" ]] && continue
|
|
job_list+=("$job")
|
|
done < <(get_managed_units service)
|
|
fi
|
|
|
|
if [[ ${#job_list[@]} -eq 0 ]]; then
|
|
echo "No managed jobs found."
|
|
return
|
|
fi
|
|
|
|
echo "Found ${#job_list[@]} managed jobs"
|
|
echo ""
|
|
|
|
local grep_args=()
|
|
[[ -n "$opt_filter" ]] && grep_args=(--grep "$opt_filter")
|
|
|
|
for job in "${job_list[@]}"; do
|
|
echo "=== Logs for $(job_id "$job") ==="
|
|
|
|
if systemctl --user is-active "${job}.timer" &>/dev/null; then
|
|
echo "Status: Active"
|
|
systemctl --user status "${job}.timer" --no-pager -l | head -n 3
|
|
else
|
|
echo "Status: Inactive"
|
|
fi
|
|
echo ""
|
|
|
|
journalctl --user USER_UNIT="${job}.service" + SYSLOG_IDENTIFIER="$job" \
|
|
--no-pager "${grep_args[@]}" 2>/dev/null || echo "no logs yet"
|
|
echo ""
|
|
done
|
|
}
|
|
|
|
# Clean up completed jobs
|
|
clean_jobs() {
|
|
local job cleaned=0
|
|
|
|
while IFS= read -r job; do
|
|
[[ -z "$job" ]] && continue
|
|
|
|
local timer_file="$SYSTEMD_USER_DIR/${job}.timer"
|
|
|
|
# Check if it's a one-time job (has Persistent=false)
|
|
if grep -q "^Persistent=false" "$timer_file" 2>/dev/null; then
|
|
# Check if timer is in "elapsed" state (completed one-time timer)
|
|
local timer_state
|
|
timer_state=$(systemctl --user show "${job}.timer" -p SubState --value 2>/dev/null)
|
|
|
|
# One-time timers that have fired will be in "elapsed" or "dead" state
|
|
if [[ "$timer_state" == "elapsed" || "$timer_state" == "dead" ]]; then
|
|
# Get the command for display
|
|
local command
|
|
command=$(get_job_command "$SYSTEMD_USER_DIR/${job}.service" 2>/dev/null || echo "unknown")
|
|
|
|
read -p "Remove completed job '$job' (command: $command)? [y/N] " -n 1 -r </dev/tty
|
|
echo
|
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
|
systemctl --user stop "${job}.timer" 2>/dev/null || true
|
|
systemctl --user disable "${job}.timer" 2>/dev/null || true
|
|
rm -f "$SYSTEMD_USER_DIR/${job}.service" "$SYSTEMD_USER_DIR/${job}.timer"
|
|
echo "Removed: $job"
|
|
cleaned=$((cleaned + 1))
|
|
fi
|
|
fi
|
|
fi
|
|
done < <(get_managed_units timer)
|
|
|
|
if [[ $cleaned -gt 0 ]]; then
|
|
systemctl --user daemon-reload
|
|
echo "Cleaned up $cleaned jobs."
|
|
else
|
|
echo "No completed one-time jobs to clean up."
|
|
fi
|
|
}
|
|
|
|
# Parse command-line options
|
|
parse_options() {
|
|
while getopts "t:c:f:im:o:P:R:ELSCh" opt; do
|
|
case $opt in
|
|
t) opt_time="$OPTARG" ;;
|
|
c) opt_command="$OPTARG" ;;
|
|
f) opt_file="$OPTARG" ;;
|
|
i) opt_notify=true ;;
|
|
m) opt_email="$OPTARG" ;;
|
|
o) opt_output="$OPTARG" ;;
|
|
P) opt_pause="$OPTARG" ;;
|
|
R) opt_resume="$OPTARG" ;;
|
|
E) opt_edit=true ;;
|
|
L) opt_list=true ;;
|
|
S) opt_status=true ;;
|
|
C) opt_clean=true ;;
|
|
h) usage 0 ;;
|
|
*) usage 1 ;;
|
|
esac
|
|
done
|
|
|
|
# Check for trailing arguments after -S or -L
|
|
if ($opt_list || $opt_status) && [[ $OPTIND -le $# ]]; then
|
|
local trailing="${!OPTIND}"
|
|
# If it looks like a job ID and the job exists, use as job filter
|
|
if [[ "$trailing" =~ ^[0-9a-f]{6}$ ]] && [[ -f "$SYSTEMD_USER_DIR/${SCRIPT_NAME}_${trailing}.timer" ]]; then
|
|
opt_jobid="$trailing"
|
|
# For -L, check for a second trailing arg as text filter
|
|
if $opt_list; then
|
|
local next_idx=$((OPTIND + 1))
|
|
if [[ $next_idx -le $# ]]; then
|
|
opt_filter="${!next_idx}"
|
|
fi
|
|
fi
|
|
elif $opt_list; then
|
|
# Not a job ID — treat as text filter for backward compatibility
|
|
opt_filter="$trailing"
|
|
else
|
|
error "No job found with ID: $trailing"
|
|
fi
|
|
fi
|
|
|
|
# Validate mutually exclusive options
|
|
local manage_count=0
|
|
[[ -n "$opt_pause" ]] && manage_count=$((manage_count + 1))
|
|
[[ -n "$opt_resume" ]] && manage_count=$((manage_count + 1))
|
|
$opt_edit && manage_count=$((manage_count + 1))
|
|
$opt_list && manage_count=$((manage_count + 1))
|
|
$opt_status && manage_count=$((manage_count + 1))
|
|
$opt_clean && manage_count=$((manage_count + 1))
|
|
|
|
if [[ $manage_count -gt 1 ]]; then
|
|
error "Options -P, -R, -E, -L, -S, and -C are mutually exclusive"
|
|
fi
|
|
|
|
if [[ $manage_count -gt 0 && -n "$opt_time$opt_command$opt_file" ]]; then
|
|
error "Management options -P, -R, -E, -L, -S, and -C cannot be used with job creation options"
|
|
fi
|
|
|
|
if [[ -n "$opt_command" && -n "$opt_file" ]]; then
|
|
error "Options -c and -f are mutually exclusive"
|
|
fi
|
|
|
|
# Validate create mode requirements
|
|
local is_manage_mode=false
|
|
if [[ -n "$opt_pause$opt_resume" ]] || $opt_edit || $opt_list || $opt_status || $opt_clean; then
|
|
is_manage_mode=true
|
|
fi
|
|
|
|
if ! $is_manage_mode; then
|
|
[[ -n "$opt_time" ]] || error "Option -t is required for job creation"
|
|
fi
|
|
}
|
|
|
|
main() {
|
|
[[ $# -eq 0 ]] && usage 0
|
|
parse_options "$@"
|
|
|
|
# Determine mode based on options
|
|
if [[ -n "$opt_pause" ]]; then
|
|
pause_job "$opt_pause"
|
|
elif [[ -n "$opt_resume" ]]; then
|
|
resume_job "$opt_resume"
|
|
elif $opt_edit; then
|
|
edit_jobs
|
|
elif $opt_list; then
|
|
list_logs
|
|
elif $opt_status; then
|
|
show_status
|
|
elif $opt_clean; then
|
|
clean_jobs
|
|
else
|
|
create_job
|
|
fi
|
|
}
|
|
|
|
main "$@"
|