831 lines
30 KiB
Bash
831 lines
30 KiB
Bash
# lib/scheduler-lib.sh — shared library for `pos system schedule`.
|
|
# Sourced by bin/pos-system-schedule AFTER lib/common.sh.
|
|
#
|
|
# A job is a chmod-600 file in $SCHEDULE_DIR (<name>.env) with keys:
|
|
#
|
|
# INTERVAL=hourly (5m..59m | 1h..23h | hourly daily weekly | OnCalendar=…)
|
|
# NOTIFY=onchange (always | onchange | onerror | threshold | never)
|
|
# MSG="NVMe health" (optional label; threshold alert text when NOTIFY=threshold)
|
|
# RULE="> 60c" (threshold only: op + threshold, unit suffix fine)
|
|
# COMMAND=… literal rest of the line — pipes/quotes/sudo fine
|
|
# ENABLED=false (written by `disable`; default enabled)
|
|
#
|
|
# NOTIFY policies (run <name> executes the command and acts on the result):
|
|
# always send the full output on every run
|
|
# onchange send only when output differs from the last run (first run sends)
|
|
# onerror send only when the command fails (non-zero exit or empty output)
|
|
# threshold compare the command's first numeric output with RULE; alert on
|
|
# false→true plus one recovery message (the old event-trigger behavior)
|
|
# never run only — no notification (side-effect jobs)
|
|
#
|
|
# Default NOTIFY: threshold if RULE is present, else onchange.
|
|
#
|
|
# State lives in $SCHEDULE_STATE_DIR/<name>/ (firing flag, last output,
|
|
# last rc/timestamp) and per-job run logs in $SCHEDULE_LOG_DIR/<name>.log.
|
|
|
|
SCHEDULE_DIR="${SCHEDULE_DIR:-$HOME/.config/linux_post_install/schedule.d}"
|
|
SCHEDULE_STATE_DIR="${SCHEDULE_STATE_DIR:-$HOME/.local/share/linux_post_install/schedule/state}"
|
|
SCHEDULE_LOG_DIR="${SCHEDULE_LOG_DIR:-$HOME/.local/share/linux_post_install/schedule/logs}"
|
|
USER_SYSTEMD_DIR="${USER_SYSTEMD_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user}"
|
|
SCHED_PREFIX="pos-schedule"
|
|
SCHED_RUNNER="$(command -v pos-system-schedule 2>/dev/null || echo /usr/local/bin/pos-system-schedule)"
|
|
SCHED_DEFAULT_INTERVAL="5m"
|
|
# Legacy event-trigger leftovers (migrate reads the old rules file, sync
|
|
# removes the old single timer).
|
|
SCHED_LEGACY_ENV="${SCHED_LEGACY_ENV:-$HOME/.config/linux_post_install/event.env}"
|
|
SCHED_LEGACY_UNIT="pos-event-trigger"
|
|
|
|
# common.sh helpers (guarded so the lib is safe if common.sh wasn't loaded)
|
|
declare -F err >/dev/null || err() { echo "ERROR: $*" >&2; exit 1; }
|
|
declare -F warn >/dev/null || warn() { echo "[!] $*"; }
|
|
declare -F ok >/dev/null || ok() { echo " OK $*"; }
|
|
declare -F log >/dev/null || log() { echo "[+] $*"; }
|
|
|
|
# ── Job discovery / naming ───────────────────────────────────────
|
|
sched_valid_name() {
|
|
[[ "$1" =~ ^[a-z0-9][a-z0-9_-]*$ ]]
|
|
}
|
|
|
|
sched_job_file() {
|
|
sched_valid_name "$1" || err "invalid job name '$1' (lowercase letters, digits, - and _)"
|
|
printf '%s/%s.env\n' "$SCHEDULE_DIR" "$1"
|
|
}
|
|
|
|
sched_list_jobs() {
|
|
local f n
|
|
for f in "$SCHEDULE_DIR"/*.env; do
|
|
[ -f "$f" ] || continue
|
|
n="${f##*/}"
|
|
printf '%s\n' "${n%.env}"
|
|
done | sort
|
|
}
|
|
|
|
# ── Reading a job file ───────────────────────────────────────────
|
|
# sched_read_job <name> — fills JOB_NAME, JOB_INTERVAL, JOB_NOTIFY, JOB_MSG,
|
|
# JOB_RULE, JOB_RULE_OP, JOB_RULE_NUM, JOB_RULE_THRESH, JOB_COMMAND,
|
|
# JOB_ENABLED. Returns 1 for a missing/invalid job.
|
|
sched_read_job() {
|
|
local name="$1" f="$SCHEDULE_DIR/$1.env" line k v
|
|
[ -f "$f" ] || return 1
|
|
JOB_NAME="$name"
|
|
JOB_INTERVAL="" JOB_NOTIFY="" JOB_MSG="" JOB_RULE=""
|
|
JOB_RULE_OP="" JOB_RULE_NUM="" JOB_RULE_THRESH=""
|
|
JOB_COMMAND="" JOB_ENABLED=""
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
case "$line" in
|
|
""|\#*) continue ;;
|
|
esac
|
|
k="${line%%=*}"
|
|
v="${line#*=}"
|
|
case "$k" in
|
|
INTERVAL) JOB_INTERVAL="$(sched_strip_quotes "$v")" ;;
|
|
NOTIFY) JOB_NOTIFY="$(sched_strip_quotes "$v")" ;;
|
|
MSG) JOB_MSG="$(sched_strip_quotes "$v")" ;;
|
|
RULE) JOB_RULE="$(sched_strip_quotes "$v")" ;;
|
|
COMMAND) JOB_COMMAND="$v" ;; # literal remainder — never quote-stripped
|
|
ENABLED) JOB_ENABLED="$(sched_strip_quotes "$v")" ;;
|
|
esac
|
|
done < "$f"
|
|
JOB_INTERVAL="${JOB_INTERVAL:-$SCHED_DEFAULT_INTERVAL}"
|
|
if [ -z "$JOB_NOTIFY" ]; then
|
|
[ -n "$JOB_RULE" ] && JOB_NOTIFY="threshold" || JOB_NOTIFY="onchange"
|
|
fi
|
|
[ -n "$JOB_COMMAND" ] || return 1
|
|
if [ "$JOB_NOTIFY" = "threshold" ]; then
|
|
sched_parse_rule "$JOB_RULE" || return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
sched_write_job() { # $1 = name — writes the JOB_* globals to schedule.d/<name>.env
|
|
local f tmp
|
|
f="$(sched_job_file "$1")"
|
|
mkdir -p "$SCHEDULE_DIR"
|
|
tmp="$(mktemp)"
|
|
{
|
|
printf '# pos system schedule job — %s\n' "$1"
|
|
[ -z "$JOB_INTERVAL" ] || printf 'INTERVAL=%s\n' "$JOB_INTERVAL"
|
|
printf 'NOTIFY=%s\n' "$JOB_NOTIFY"
|
|
[ -z "$JOB_MSG" ] || printf 'MSG=%s\n' "$JOB_MSG"
|
|
[ -z "$JOB_RULE" ] || printf 'RULE=%s\n' "$JOB_RULE"
|
|
printf 'COMMAND=%s\n' "$JOB_COMMAND"
|
|
} >"$tmp"
|
|
mv "$tmp" "$f"
|
|
chmod 600 "$f"
|
|
}
|
|
|
|
# ── Small helpers ────────────────────────────────────────────────
|
|
sched_strip_quotes() {
|
|
local v="$1"
|
|
v="${v#\"}"; v="${v%\"}"
|
|
v="${v#\'}"; v="${v%\'}"
|
|
printf '%s' "$v"
|
|
}
|
|
|
|
# First numeric prefix of a string (allows a unit suffix: 60c, 80%, 10g).
|
|
sched_numeric() {
|
|
if [[ "$1" =~ ^[+-]?([0-9]+([.][0-9]+)?|[.][0-9]+) ]]; then
|
|
printf '%s' "${BASH_REMATCH[0]}"
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# Float-safe numeric comparison. Returns 0 if $1 $2 $3 is TRUE.
|
|
sched_compare() {
|
|
awk -v a="$1" -v op="$2" -v b="$3" 'BEGIN{
|
|
if (op==">") exit (a>b) ? 0 : 1
|
|
if (op=="<") exit (a<b) ? 0 : 1
|
|
if (op==">=") exit (a>=b) ? 0 : 1
|
|
if (op=="<=") exit (a<=b) ? 0 : 1
|
|
if (op=="==") exit (a==b) ? 0 : 1
|
|
if (op=="!=") exit (a!=b) ? 0 : 1
|
|
exit 1
|
|
}'
|
|
}
|
|
|
|
# ── Threshold rule parsing (RULE key: "<op> <number>[unit]") ────
|
|
# Sets RULE_OP, RULE_THRESH, RULE_NUM. Returns 1 on unparseable input.
|
|
sched_parse_rule() {
|
|
local -a t=()
|
|
read -r -a t <<< "$1"
|
|
local n=${#t[@]} i
|
|
[ "$n" -ge 2 ] || return 1
|
|
for ((i=n-2; i>=0; i--)); do
|
|
case "${t[i]}" in
|
|
">="|"<="|"=="|"!="|">"|"<")
|
|
if sched_numeric "${t[i+1]}" >/dev/null 2>&1; then
|
|
RULE_OP="${t[i]}"
|
|
RULE_THRESH="${t[i+1]}"
|
|
RULE_NUM="$(sched_numeric "${t[i+1]}")"
|
|
return 0
|
|
fi ;;
|
|
esac
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# ── Running the job's command ────────────────────────────────────
|
|
# Sets JOB_OUT (stdout+stderr) and JOB_RC (the pipeline's exit code).
|
|
sched_run_command() {
|
|
local out rc
|
|
if out="$(bash -c "$JOB_COMMAND" 2>&1)"; then rc=0; else rc=$?; fi
|
|
JOB_OUT="$out"
|
|
JOB_RC="$rc"
|
|
}
|
|
|
|
# First numeric token of JOB_OUT's first line → VALUE_NUM + VALUE_DISPLAY.
|
|
sched_first_numeric() {
|
|
local line tok
|
|
line="$(printf '%s' "$JOB_OUT" | sed -n '1p')"
|
|
read -r -a tokens <<< "$line"
|
|
for tok in "${tokens[@]:-}"; do
|
|
if sched_numeric "$tok" >/dev/null 2>&1; then
|
|
VALUE_NUM="$(sched_numeric "$tok")"
|
|
VALUE_DISPLAY="$tok"
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# Cap a message (telegram listener precedent: ~3800 chars).
|
|
sched_truncate() {
|
|
local n="${1:-3800}" out
|
|
out="$(cat)"
|
|
if [ "${#out}" -gt "$n" ]; then
|
|
out="${out:0:$n}"
|
|
printf '%s\n…(truncated)' "$out"
|
|
else
|
|
printf '%s' "$out"
|
|
fi
|
|
}
|
|
|
|
# DRY_RUN-aware notify (the only place scheduler messages leave).
|
|
sched_notify() {
|
|
if [ "${DRY_RUN:-0}" -eq 1 ]; then
|
|
log "(dry-run) would notify: $1"
|
|
else
|
|
notify_send "$1"
|
|
fi
|
|
}
|
|
|
|
# ── Per-job state ────────────────────────────────────────────────
|
|
sched_state_firing() { # $1 = job name
|
|
[ -f "$SCHEDULE_STATE_DIR/$1/firing" ] && grep -q '^1' "$SCHEDULE_STATE_DIR/$1/firing"
|
|
}
|
|
|
|
sched_state_set_firing() { # $1 = job name, $2 = 0|1
|
|
mkdir -p "$SCHEDULE_STATE_DIR/$1"
|
|
printf '%s\n' "$2" >"$SCHEDULE_STATE_DIR/$1/firing"
|
|
}
|
|
|
|
sched_save_run() { # $1 = job name, $2 = output, $3 = rc
|
|
local dir="$SCHEDULE_STATE_DIR/$1" logf="$SCHEDULE_LOG_DIR/$1.log"
|
|
mkdir -p "$dir" "$SCHEDULE_LOG_DIR"
|
|
printf '%s' "$2" >"$dir/out"
|
|
printf 'rc=%s\nts=%s\n' "$3" "$(date +%s)" >"$dir/meta"
|
|
{
|
|
printf '\n== %s rc=%s ==\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$3"
|
|
printf '%s\n' "$2"
|
|
} >>"$logf"
|
|
# Keep the run log bounded (last 500 lines).
|
|
if [ "$(wc -l <"$logf" 2>/dev/null || echo 0)" -gt 500 ]; then
|
|
tail -n 500 "$logf" >"$logf.tmp" && mv "$logf.tmp" "$logf"
|
|
fi
|
|
}
|
|
|
|
sched_last_run_str() { # $1 = job name → "rc=N <MM-DD HH:MM>"
|
|
local f="$SCHEDULE_STATE_DIR/$1/meta" rc ts
|
|
[ -f "$f" ] || { printf 'never'; return; }
|
|
rc="$(sed -n 's/^rc=//p' "$f" | head -1)"
|
|
ts="$(sed -n 's/^ts=//p' "$f" | head -1)"
|
|
printf 'rc=%s %s' "$rc" "$(date -d "@$ts" '+%m-%d %H:%M' 2>/dev/null || printf '?')"
|
|
}
|
|
|
|
# ── Notify policies ──────────────────────────────────────────────
|
|
sched_msg_alert() {
|
|
if [ -n "$JOB_MSG" ]; then
|
|
printf '%s (now %s)' "$JOB_MSG" "$VALUE_DISPLAY"
|
|
else
|
|
printf '%s now %s is %s %s' "${JOB_COMMAND%% *}" "$VALUE_DISPLAY" "$RULE_OP" "$RULE_THRESH"
|
|
fi
|
|
}
|
|
|
|
sched_msg_recover() {
|
|
if [ -n "$JOB_MSG" ]; then
|
|
printf '%s recovered (now %s)' "$JOB_MSG" "$VALUE_DISPLAY"
|
|
else
|
|
printf '%s OK (now %s)' "${JOB_COMMAND%% *}" "$VALUE_DISPLAY"
|
|
fi
|
|
}
|
|
|
|
sched_report_msg() { # $1 = label ("changed" | "run" | "failed (rc N)")
|
|
{
|
|
printf 'sched %s%s — %s\n' "$JOB_NAME" "${JOB_MSG:+ ($JOB_MSG)}" "$1"
|
|
[ -z "$JOB_OUT" ] || printf '%s\n' "$JOB_OUT"
|
|
} | sched_truncate
|
|
}
|
|
|
|
sched_policy_threshold() {
|
|
if ! sched_first_numeric; then
|
|
warn "job '$JOB_NAME' check produced no number — skipped"
|
|
return 0
|
|
fi
|
|
local fired=0
|
|
sched_compare "$VALUE_NUM" "$RULE_OP" "$RULE_NUM" && fired=1
|
|
if [ "$fired" -eq 1 ] && ! sched_state_firing "$JOB_NAME"; then
|
|
sched_notify "$(sched_msg_alert)"
|
|
sched_state_set_firing "$JOB_NAME" 1
|
|
log "alert: $JOB_NAME = $VALUE_DISPLAY (was below $RULE_OP $RULE_THRESH)"
|
|
elif [ "$fired" -eq 0 ] && sched_state_firing "$JOB_NAME"; then
|
|
sched_notify "$(sched_msg_recover)"
|
|
sched_state_set_firing "$JOB_NAME" 0
|
|
log "recovered: $JOB_NAME = $VALUE_DISPLAY"
|
|
else
|
|
log "ok: $JOB_NAME = $VALUE_DISPLAY (no change)"
|
|
fi
|
|
}
|
|
|
|
sched_policy_onchange() {
|
|
local dir="$SCHEDULE_STATE_DIR/$JOB_NAME" changed=0
|
|
if [ ! -f "$dir/out" ]; then
|
|
changed=1 # first run always notifies
|
|
elif ! printf '%s' "$JOB_OUT" | cmp -s - "$dir/out"; then
|
|
changed=1
|
|
fi
|
|
if [ "$changed" -eq 1 ]; then
|
|
sched_notify "$(sched_report_msg 'changed')"
|
|
log "change detected — notified"
|
|
else
|
|
log "no change — not notifying"
|
|
fi
|
|
}
|
|
|
|
sched_policy_always() {
|
|
sched_notify "$(sched_report_msg 'run')"
|
|
log "notified (always)"
|
|
}
|
|
|
|
sched_policy_onerror() {
|
|
if [ "$JOB_RC" -ne 0 ] || [ -z "$JOB_OUT" ]; then
|
|
sched_notify "$(sched_report_msg "failed (rc $JOB_RC)")"
|
|
log "error detected (rc $JOB_RC) — notified"
|
|
else
|
|
log "ok (rc 0) — not notifying"
|
|
fi
|
|
}
|
|
|
|
sched_policy_never() {
|
|
log "ran silently (rc $JOB_RC) — no notify"
|
|
}
|
|
|
|
# ── Run one job ──────────────────────────────────────────────────
|
|
sched_run_job() { # $1 = job name
|
|
if ! sched_read_job "$1"; then
|
|
warn "job '$1' unparseable — skipping"
|
|
return 1
|
|
fi
|
|
sched_run_command
|
|
case "$JOB_NOTIFY" in
|
|
threshold) sched_policy_threshold ;;
|
|
onchange) sched_policy_onchange ;;
|
|
always) sched_policy_always ;;
|
|
onerror) sched_policy_onerror ;;
|
|
never) sched_policy_never ;;
|
|
*)
|
|
warn "job '$1': unknown NOTIFY '$JOB_NOTIFY' (always|onchange|onerror|threshold|never) — ran silently"
|
|
sched_policy_never ;;
|
|
esac
|
|
# dry-run previews without mutating state, logs, or the onchange baseline
|
|
[ "${DRY_RUN:-0}" -ne 1 ] && sched_save_run "$1" "$JOB_OUT" "$JOB_RC"
|
|
return 0
|
|
}
|
|
# ── Interval → systemd OnCalendar (same list as entertainment) ──
|
|
sched_interval_to_oncalendar() {
|
|
local i="$1"
|
|
case "$i" in
|
|
OnCalendar=*) printf '%s' "${i#OnCalendar=}"; return 0 ;;
|
|
esac
|
|
if [[ "$i" =~ ^([0-9]+)m$ ]]; then
|
|
local n="${BASH_REMATCH[1]}"
|
|
[ "$n" -ge 1 ] && [ "$n" -le 59 ] || return 1
|
|
printf '*:00/%s:00' "$n"; return 0
|
|
fi
|
|
if [[ "$i" =~ ^([0-9]+)h$ ]]; then
|
|
local n="${BASH_REMATCH[1]}"
|
|
[ "$n" -ge 1 ] && [ "$n" -le 23 ] || return 1
|
|
printf '*-*-* 00/%s:00:00' "$n"; return 0
|
|
fi
|
|
case "$i" in
|
|
hourly) printf '*-*-* *:00:00' ;;
|
|
daily) printf '*-*-* 08:00:00' ;;
|
|
weekly) printf 'Mon *-*-* 08:00:00' ;;
|
|
*) return 1 ;;
|
|
esac
|
|
return 0
|
|
}
|
|
|
|
sched_interval_label() {
|
|
case "$1" in
|
|
daily) echo "daily (08:00)" ;;
|
|
weekly) echo "weekly (Mon 08:00)" ;;
|
|
hourly) echo "hourly" ;;
|
|
OnCalendar=*) echo "${1#OnCalendar=}" ;;
|
|
*m|*h) echo "every $1" ;;
|
|
*) echo "$1" ;;
|
|
esac
|
|
}
|
|
|
|
# ── systemd user timers (one pair per job) ──────────────────────
|
|
sched_unit() { printf '%s-%s\n' "$SCHED_PREFIX" "$1"; }
|
|
|
|
sched_ensure_linger() {
|
|
local user
|
|
user="$(id -un)"
|
|
[ "$user" = "root" ] && { warn "running as root — enable linger for your real user: sudo loginctl enable-linger <user>"; return 0; }
|
|
command -v loginctl >/dev/null 2>&1 || return 0
|
|
if loginctl show-user "$user" 2>/dev/null | grep -q '^Linger=yes'; then
|
|
return 0
|
|
fi
|
|
if sudo -n loginctl enable-linger "$user" 2>/dev/null; then
|
|
ok "enabled linger for $user (timers fire without login)"
|
|
else
|
|
warn "run once so timers fire without login: sudo loginctl enable-linger $user"
|
|
fi
|
|
}
|
|
|
|
sched_write_units() { # $1 = job name, $2 = OnCalendar
|
|
local name="$1" oncal="$2"
|
|
local base="$USER_SYSTEMD_DIR/$(sched_unit "$name")"
|
|
mkdir -p "$USER_SYSTEMD_DIR"
|
|
cat >"$base.service" <<EOF
|
|
[Unit]
|
|
Description=pos system schedule — run job $name
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
ExecStart=$SCHED_RUNNER run $name
|
|
TimeoutStopSec=5s
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
EOF
|
|
cat >"$base.timer" <<EOF
|
|
[Unit]
|
|
Description=Schedule: pos system schedule — $name
|
|
|
|
[Timer]
|
|
OnCalendar=$oncal
|
|
Persistent=true
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
EOF
|
|
chmod 644 "$base.service" "$base.timer"
|
|
}
|
|
|
|
sched_job_active() { # $1 = job name — valid AND not disabled
|
|
sched_read_job "$1" >/dev/null 2>&1 || return 1
|
|
[ "$JOB_ENABLED" = "false" ] && return 1
|
|
return 0
|
|
}
|
|
|
|
# Reconcile schedule.d with the per-job user timers.
|
|
sched_sync() {
|
|
local -a jobs=() name oncal wrote=0 n=0
|
|
mapfile -t jobs < <(sched_list_jobs)
|
|
[ "${#jobs[@]}" -eq 0 ] && log "no jobs in $SCHEDULE_DIR — add one with 'pos system schedule config'"
|
|
for name in "${jobs[@]}"; do
|
|
if ! sched_read_job "$name" >/dev/null 2>&1; then
|
|
warn "job '$name' unparseable — skipping"
|
|
continue
|
|
fi
|
|
[ "$JOB_ENABLED" = "false" ] && continue # disabled: orphan cleanup drops its timer
|
|
if ! oncal="$(sched_interval_to_oncalendar "$JOB_INTERVAL")"; then
|
|
warn "invalid interval '$JOB_INTERVAL' for '$name' — skipping"
|
|
continue
|
|
fi
|
|
if [ "${DRY_RUN:-0}" -eq 1 ]; then
|
|
log "(dry-run) write $(sched_unit "$name").timer (OnCalendar=$oncal)"
|
|
else
|
|
sched_write_units "$name" "$oncal"
|
|
fi
|
|
wrote=1; n=$((n + 1))
|
|
done
|
|
[ "${DRY_RUN:-0}" -eq 1 ] && return 0
|
|
[ "$wrote" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1 || true
|
|
[ "$n" -gt 0 ] && sched_ensure_linger
|
|
for name in "${jobs[@]}"; do
|
|
if ! sched_read_job "$name" >/dev/null 2>&1; then
|
|
continue
|
|
fi
|
|
[ "$JOB_ENABLED" = "false" ] && continue
|
|
sched_interval_to_oncalendar "$JOB_INTERVAL" >/dev/null 2>&1 || continue
|
|
local t="$(sched_unit "$name").timer"
|
|
if systemctl --user is-enabled "$t" >/dev/null 2>&1; then
|
|
systemctl --user restart "$t" >/dev/null 2>&1 || true
|
|
else
|
|
systemctl --user enable --now "$t" >/dev/null 2>&1 || \
|
|
warn "could not enable timer '$t' (is the user systemd manager running?)"
|
|
fi
|
|
done
|
|
sched_remove_orphans
|
|
sched_cleanup_legacy
|
|
}
|
|
|
|
# Remove timers whose job no longer exists or was disabled.
|
|
sched_remove_orphans() {
|
|
local f p removed=0
|
|
for f in "$USER_SYSTEMD_DIR"/${SCHED_PREFIX}-*.timer; do
|
|
[ -f "$f" ] || continue
|
|
p="${f##*/}"; p="${p#${SCHED_PREFIX}-}"; p="${p%.timer}"
|
|
if ! sched_job_active "$p"; then
|
|
systemctl --user disable --now "$(sched_unit "$p").timer" >/dev/null 2>&1 || true
|
|
rm -f "$USER_SYSTEMD_DIR/$(sched_unit "$p").timer" "$USER_SYSTEMD_DIR/$(sched_unit "$p").service"
|
|
log "removed timer for '$p'"
|
|
removed=1
|
|
fi
|
|
done
|
|
[ "$removed" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1 || true
|
|
}
|
|
|
|
# Remove the legacy single event-trigger timer if still present.
|
|
sched_cleanup_legacy() {
|
|
if [ -f "$USER_SYSTEMD_DIR/${SCHED_LEGACY_UNIT}.timer" ]; then
|
|
systemctl --user disable --now "$SCHED_LEGACY_UNIT.timer" >/dev/null 2>&1 || true
|
|
rm -f "$USER_SYSTEMD_DIR/${SCHED_LEGACY_UNIT}.timer" "$USER_SYSTEMD_DIR/${SCHED_LEGACY_UNIT}.service"
|
|
systemctl --user daemon-reload >/dev/null 2>&1 || true
|
|
log "removed legacy $SCHED_LEGACY_UNIT timer (superseded by per-job timers)"
|
|
fi
|
|
}
|
|
|
|
# Toggle a job's enabled flag (keeps the job file). $1 = name, $2 = true|false.
|
|
sched_set_enabled() {
|
|
local f tmp
|
|
f="$(sched_job_file "$1")"
|
|
[ -f "$f" ] || err "no such job '$1' (see: pos system schedule list)"
|
|
if [ "${DRY_RUN:-0}" -eq 1 ]; then
|
|
log "(dry-run) set ENABLED=$2 in $f"
|
|
return 0
|
|
fi
|
|
tmp="$(mktemp)"
|
|
grep -v '^ENABLED=' "$f" >"$tmp" || true
|
|
printf 'ENABLED=%s\n' "$2" >>"$tmp"
|
|
mv "$tmp" "$f"
|
|
chmod 600 "$f"
|
|
}
|
|
|
|
# ── Legacy migration (event.env rules → schedule.d jobs) ─────────
|
|
# Parses one old event.env rule line into MIG_MSG / MIG_CMD / MIG_RULE.
|
|
sched_parse_legacy_rule() {
|
|
local line="$1" s="$1" cond="" msg="" parts="" m
|
|
local -a tokens=()
|
|
while [[ "$s" == *" if "* ]]; do
|
|
m="${s%% if *}"
|
|
s="${s#*" if "}"
|
|
if [[ "$s" == *" if "* ]]; then parts+="${m} if "; else parts+="${m}"; fi
|
|
done
|
|
msg="$parts"
|
|
cond="$s"
|
|
[ -n "$cond" ] || return 1
|
|
read -r -a tokens <<< "$cond"
|
|
local n=${#tokens[@]} i
|
|
[ "$n" -ge 2 ] || return 1
|
|
for ((i=n-2; i>=0; i--)); do
|
|
case "${tokens[i]}" in
|
|
">="|"<="|"=="|"!="|">"|"<")
|
|
if sched_numeric "${tokens[i+1]}" >/dev/null 2>&1; then
|
|
if [ "$i" -eq 0 ]; then
|
|
MIG_CMD="${tokens[0]}"
|
|
else
|
|
MIG_CMD="$(printf '%s ' "${tokens[@]:0:i}")"
|
|
MIG_CMD="${MIG_CMD% }"
|
|
fi
|
|
MIG_MSG="$(sched_strip_quotes "$msg")"
|
|
MIG_RULE="${tokens[i]} ${tokens[i+1]}"
|
|
return 0
|
|
fi ;;
|
|
esac
|
|
done
|
|
return 1
|
|
}
|
|
|
|
sched_migrate() {
|
|
local legacy="$SCHED_LEGACY_ENV"
|
|
if [ ! -f "$legacy" ]; then
|
|
log "no legacy rules file at $legacy — nothing to migrate"
|
|
return 0
|
|
fi
|
|
local line name n=0 migrated=0 oncal=""
|
|
if [ -f "$USER_SYSTEMD_DIR/${SCHED_LEGACY_UNIT}.timer" ]; then
|
|
oncal="$(sed -n 's/^OnCalendar=//p' "$USER_SYSTEMD_DIR/${SCHED_LEGACY_UNIT}.timer" | head -1)"
|
|
fi
|
|
local interval="${oncal:+OnCalendar=$oncal}"
|
|
interval="${interval:-$SCHED_DEFAULT_INTERVAL}"
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
case "$line" in
|
|
""|\#*) continue ;;
|
|
esac
|
|
if ! sched_parse_legacy_rule "$line"; then
|
|
warn "skipping unparseable rule: $line"
|
|
continue
|
|
fi
|
|
n=$((n + 1))
|
|
name="rule-$n"
|
|
if [ -f "$SCHEDULE_DIR/$name.env" ]; then
|
|
log "job '$name' already exists — skipping (remove it to re-migrate)"
|
|
migrated=1
|
|
continue
|
|
fi
|
|
if [ "${DRY_RUN:-0}" -eq 1 ]; then
|
|
log "(dry-run) would write job '$name' (NOTIFY=threshold, INTERVAL=$interval): $MIG_CMD"
|
|
else
|
|
JOB_INTERVAL="$interval" JOB_NOTIFY="threshold" JOB_MSG="$MIG_MSG" JOB_RULE="$MIG_RULE" JOB_COMMAND="$MIG_CMD"
|
|
sched_write_job "$name"
|
|
migrated=1
|
|
fi
|
|
done < "$legacy"
|
|
if [ "$migrated" -eq 0 ]; then
|
|
log "no rules found in $legacy"
|
|
return 0
|
|
fi
|
|
log "migrated $n rule(s) → $SCHEDULE_DIR (NOTIFY=threshold, INTERVAL=$interval)"
|
|
if [ "${DRY_RUN:-0}" -ne 1 ]; then
|
|
sched_cleanup_legacy
|
|
log "legacy timer removed — run 'pos system schedule enable' to start the migrated jobs"
|
|
fi
|
|
}
|
|
|
|
# ── Subcommand implementations ───────────────────────────────────
|
|
sched_run() { # $1 = job name | all
|
|
local name
|
|
if [ "$1" = "all" ]; then
|
|
local -a jobs=() ran=0
|
|
mapfile -t jobs < <(sched_list_jobs)
|
|
if [ "${#jobs[@]}" -eq 0 ]; then
|
|
warn "no jobs in $SCHEDULE_DIR — add one with 'pos system schedule config'"
|
|
return 0
|
|
fi
|
|
for name in "${jobs[@]}"; do
|
|
sched_run_job "$name" && ran=$((ran + 1))
|
|
done
|
|
log "ran $ran job(s)"
|
|
return 0
|
|
fi
|
|
sched_valid_name "$1" || err "invalid job name '$1'"
|
|
[ -f "$SCHEDULE_DIR/$1.env" ] || err "no such job '$1' (see: pos system schedule list)"
|
|
sched_run_job "$1"
|
|
}
|
|
|
|
sched_list() {
|
|
local -a jobs=() name
|
|
mapfile -t jobs < <(sched_list_jobs)
|
|
if [ "${#jobs[@]}" -eq 0 ]; then
|
|
echo "(no jobs — run 'pos system schedule config' to add one)"
|
|
return 0
|
|
fi
|
|
printf '%-18s %-9s %-9s %-8s %s\n' JOB NOTIFY INTERVAL STATE LAST
|
|
for name in "${jobs[@]}"; do
|
|
if ! sched_read_job "$name" >/dev/null 2>&1; then
|
|
printf '%-18s %s\n' "$name" "INVALID job file"
|
|
continue
|
|
fi
|
|
local st="enabled"
|
|
[ "$JOB_ENABLED" = "false" ] && st="disabled"
|
|
printf '%-18s %-9s %-9s %-8s %s\n' "$name" "$JOB_NOTIFY" "$JOB_INTERVAL" "$st" "$(sched_last_run_str "$name")"
|
|
done
|
|
echo
|
|
log "run 'pos system schedule enable' to start the enabled jobs"
|
|
}
|
|
|
|
sched_enable() { # $1 = job name | all
|
|
local name
|
|
if [ "$1" = "all" ]; then
|
|
local -a jobs=()
|
|
mapfile -t jobs < <(sched_list_jobs)
|
|
for name in "${jobs[@]}"; do
|
|
sched_set_enabled "$name" true
|
|
done
|
|
log "enabled all jobs"
|
|
else
|
|
sched_set_enabled "$1" true
|
|
log "job '$1' enabled"
|
|
fi
|
|
sched_sync
|
|
}
|
|
|
|
sched_disable() { # $1 = job name | all
|
|
local name
|
|
if [ "$1" = "all" ]; then
|
|
local -a jobs=()
|
|
mapfile -t jobs < <(sched_list_jobs)
|
|
for name in "${jobs[@]}"; do
|
|
sched_set_enabled "$name" false
|
|
done
|
|
log "disabled all jobs (job files kept)"
|
|
else
|
|
sched_set_enabled "$1" false
|
|
log "job '$1' disabled (job file kept)"
|
|
fi
|
|
sched_sync
|
|
}
|
|
|
|
sched_status() {
|
|
local -a jobs=() name
|
|
mapfile -t jobs < <(sched_list_jobs)
|
|
if [ "${#jobs[@]}" -eq 0 ]; then
|
|
echo "no jobs defined in $SCHEDULE_DIR"
|
|
echo "add one: pos system schedule config"
|
|
return 0
|
|
fi
|
|
if ! systemctl --user show-environment >/dev/null 2>&1; then
|
|
warn "no user systemd manager reachable — timers cannot run"
|
|
fi
|
|
echo "jobs: ${#jobs[@]} in $SCHEDULE_DIR"
|
|
for name in "${jobs[@]}"; do
|
|
if ! sched_read_job "$name" >/dev/null 2>&1; then
|
|
printf ' %-18s INVALID job file\n' "$name"
|
|
continue
|
|
fi
|
|
local st="enabled" oncal next=""
|
|
[ "$JOB_ENABLED" = "false" ] && st="disabled"
|
|
if [ "$st" = "enabled" ]; then
|
|
if oncal="$(sched_interval_to_oncalendar "$JOB_INTERVAL")"; then
|
|
next="$(systemctl --user list-timers "$(sched_unit "$name").timer" --no-legend 2>/dev/null | awk '{print $1, $2}' | head -1)"
|
|
else
|
|
oncal="(invalid interval: $JOB_INTERVAL)"
|
|
fi
|
|
printf ' %-18s %-9s %-8s %s%s\n' "$name" "$JOB_NOTIFY" "$st" "$oncal${next:+ (next: $next)}"
|
|
else
|
|
printf ' %-18s %-9s %-8s\n' "$name" "$JOB_NOTIFY" "$st"
|
|
fi
|
|
done
|
|
}
|
|
|
|
# ── Interactive job editor (config) ─────────────────────────────
|
|
sched_config_editor() {
|
|
local -a jobs=() name action n ok
|
|
while true; do
|
|
mapfile -t jobs < <(sched_list_jobs)
|
|
echo "── jobs in $SCHEDULE_DIR ──" >&2
|
|
if [ "${#jobs[@]}" -eq 0 ]; then
|
|
echo " (empty)" >&2
|
|
else
|
|
for i in "${!jobs[@]}"; do
|
|
if sched_read_job "${jobs[i]}" >/dev/null 2>&1; then
|
|
printf ' %2d %-18s %s, %s, %s\n' "$((i + 1))" "${jobs[i]}" "$JOB_NOTIFY" "$JOB_INTERVAL" \
|
|
"$([ "$JOB_ENABLED" = "false" ] && echo disabled || echo enabled)" >&2
|
|
else
|
|
printf ' %2d %-18s INVALID\n' "$((i + 1))" "${jobs[i]}" >&2
|
|
fi
|
|
done
|
|
fi
|
|
read -rp "action [add / edit <n> / remove <n> / enable <n> / disable <n> / quit]: " action
|
|
case "$action" in
|
|
quit|q) break ;;
|
|
add) sched_editor_add ;;
|
|
remove*) sched_editor_remove "${action#remove }" ;;
|
|
edit*) sched_editor_edit "${action#edit }" ;;
|
|
enable*) sched_editor_toggle enable "${action#enable }" ;;
|
|
disable*) sched_editor_toggle disable "${action#disable }" ;;
|
|
*) warn "unknown action (add | edit <n> | remove <n> | enable <n> | disable <n> | quit)" ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
sched_editor_add() {
|
|
local name interval notify msg rule cmd
|
|
read -rp "name [a-z0-9_-]: " name
|
|
sched_valid_name "$name" || { warn "invalid name — lowercase letters, digits, - and _"; return; }
|
|
[ -f "$SCHEDULE_DIR/$name.env" ] && { warn "job '$name' already exists — use edit"; return; }
|
|
read -rp "interval [5m..59m | 1h..23h | hourly daily weekly | OnCalendar=…] (blank = $SCHED_DEFAULT_INTERVAL): " interval
|
|
interval="${interval:-$SCHED_DEFAULT_INTERVAL}"
|
|
sched_interval_to_oncalendar "$interval" >/dev/null 2>&1 || warn "interval '$interval' not recognized — saved anyway, 'enable' will skip it"
|
|
read -rp "notify [always|onchange|onerror|threshold|never] (blank = onchange): " notify
|
|
notify="${notify:-onchange}"
|
|
case "$notify" in always|onchange|onerror|threshold|never) ;; *) warn "unknown policy '$notify' — job will run silently" ;; esac
|
|
read -rp "message (optional, blank = none): " msg
|
|
rule=""
|
|
if [ "$notify" = "threshold" ]; then
|
|
read -rp "rule (op + threshold, e.g. '> 60c'): " rule
|
|
[ -n "$rule" ] || warn "threshold jobs need RULE — job will be skipped"
|
|
fi
|
|
read -rp "command: " cmd
|
|
[ -n "$cmd" ] || { warn "empty command — not added"; return; }
|
|
if [ "${DRY_RUN:-0}" -eq 1 ]; then
|
|
log "(dry-run) would add job '$name'"
|
|
return
|
|
fi
|
|
JOB_INTERVAL="$interval" JOB_NOTIFY="$notify" JOB_MSG="$msg" JOB_RULE="$rule" JOB_COMMAND="$cmd"
|
|
sched_write_job "$name"
|
|
ok "job '$name' added — apply with 'pos system schedule enable'"
|
|
}
|
|
|
|
sched_editor_edit() {
|
|
local n="$1" name
|
|
[[ "$n" =~ ^[0-9]+$ ]] || { warn "use: edit <number>"; return; }
|
|
local -a jobs=()
|
|
mapfile -t jobs < <(sched_list_jobs)
|
|
[ "$n" -ge 1 ] && [ "$n" -le "${#jobs[@]}" ] || { warn "no job $n"; return; }
|
|
name="${jobs[$((n - 1))]}"
|
|
sched_read_job "$name" || { warn "job '$name' is invalid — remove and re-add it"; return; }
|
|
local interval notify msg rule cmd
|
|
read -rp "interval (blank keeps, current: $JOB_INTERVAL): " interval
|
|
interval="${interval:-$JOB_INTERVAL}"
|
|
sched_interval_to_oncalendar "$interval" >/dev/null 2>&1 || warn "interval '$interval' not recognized — saved anyway, 'enable' will skip it"
|
|
read -rp "notify (blank keeps, current: $JOB_NOTIFY): " notify
|
|
notify="${notify:-$JOB_NOTIFY}"
|
|
read -rp "message (blank keeps, current: ${JOB_MSG:-<none>}): " msg
|
|
[ -n "$msg" ] || msg="$JOB_MSG"
|
|
rule=""
|
|
if [ "$notify" = "threshold" ]; then
|
|
read -rp "rule (blank keeps, current: $JOB_RULE): " rule
|
|
[ -n "$rule" ] || rule="$JOB_RULE"
|
|
fi
|
|
read -rp "command (blank keeps, current: $JOB_COMMAND): " cmd
|
|
[ -n "$cmd" ] || cmd="$JOB_COMMAND"
|
|
if [ "${DRY_RUN:-0}" -eq 1 ]; then
|
|
log "(dry-run) would update job '$name'"
|
|
return
|
|
fi
|
|
JOB_INTERVAL="$interval" JOB_NOTIFY="$notify" JOB_MSG="$msg" JOB_RULE="$rule" JOB_COMMAND="$cmd"
|
|
sched_write_job "$name"
|
|
ok "job '$name' updated"
|
|
}
|
|
|
|
sched_editor_remove() {
|
|
local n="$1" name ok
|
|
[[ "$n" =~ ^[0-9]+$ ]] || { warn "use: remove <number>"; return; }
|
|
local -a jobs=()
|
|
mapfile -t jobs < <(sched_list_jobs)
|
|
[ "$n" -ge 1 ] && [ "$n" -le "${#jobs[@]}" ] || { warn "no job $n"; return; }
|
|
name="${jobs[$((n - 1))]}"
|
|
read -rp "remove job '$name'? [y/N]: " ok
|
|
[[ "$ok" =~ ^[Yy] ]] || return
|
|
if [ "${DRY_RUN:-0}" -eq 1 ]; then
|
|
log "(dry-run) would remove job '$name'"
|
|
return
|
|
fi
|
|
rm -f "$SCHEDULE_DIR/$name.env"
|
|
ok "job '$name' removed (apply with 'pos system schedule enable')"
|
|
}
|
|
|
|
sched_editor_toggle() {
|
|
local mode="$1" n="$2" name
|
|
[[ "$n" =~ ^[0-9]+$ ]] || { warn "use: $mode <number>"; return; }
|
|
local -a jobs=()
|
|
mapfile -t jobs < <(sched_list_jobs)
|
|
[ "$n" -ge 1 ] && [ "$n" -le "${#jobs[@]}" ] || { warn "no job $n"; return; }
|
|
name="${jobs[$((n - 1))]}"
|
|
if [ "$mode" = "enable" ]; then
|
|
sched_set_enabled "$name" true
|
|
ok "job '$name' enabled (apply with 'pos system schedule enable')"
|
|
else
|
|
sched_set_enabled "$name" false
|
|
ok "job '$name' disabled (apply with 'pos system schedule enable')"
|
|
fi
|
|
}
|