refactor: pos system event-trigger → pos system schedule — per-job timers, notify policies, legacy migrate

The single-timer threshold monitor generalizes into a scheduler: each job is a
chmod-600 file in schedule.d/<name>.env (INTERVAL 5m..59m/1h..23h/hourly/daily/
weekly/OnCalendar=..., NOTIFY policy, optional MSG, RULE for threshold,
COMMAND = literal rest of line) with its own systemd user timer pair
(pos-schedule-<name>.timer + oneshot .service, Persistent, reconciled on
enable/disable — orphan units + the legacy pos-event-trigger timer
auto-removed). Policies: always (full output every run), onchange (diff vs
last run, first run sends), onerror (non-zero exit or empty output),
threshold (old event-trigger behavior: first numeric vs RULE, alert on
false→true + recovery, per-job firing state), never (silent side-effect jobs).
run [name|all], list, config (interactive add/edit/remove/enable/disable with
validation), enable/disable [name|all], status, migrate (converts legacy
event.env rules → rule-N.env threshold jobs, verbatim LHS as COMMAND, adopts
the legacy timer's OnCalendar or 5m, removes the old timer). Per-run logs +
state in ~/.local/share/linux_post_install/schedule/{logs,state}/.
config/event.env + event-rules.template → config/schedule.d/ starter jobs
(nvme-health, cpu-temp, disk-root, silent log-cleanup); postinstall installs
them no-clobber into an empty schedule.d/ (legacy event.env users get a
migrate hint instead). bin/pos EXAMPLES + INTERACTIVE_CMDS
(system-schedule config) updated; install.sh ships the renamed tool+lib.
This commit is contained in:
Your Name
2026-08-13 02:14:25 -04:00
parent 8b3435a8d5
commit 414a990801
18 changed files with 1134 additions and 727 deletions
+9 -8
View File
@@ -22,7 +22,7 @@
| ## 11. Coding Conventions | 472504 |
| ## 12. Development Workflow | 505557 |
| ## 13. Key File Quick Reference | 558614 |
| ## 14. Common Tasks for Agents | 615643 |
| ## 14. Common Tasks for Agents | 615644 |
<!-- GEN:END docmap -->
## 1. Project Overview
@@ -87,9 +87,9 @@ Linux_post_install/
│ ├── pos-share-usb-server # USB Redirector server control (--ls, --share; prompts when args omitted)
│ ├── pos-ssh-load-keys # Load all SSH keys into the agent
│ ├── pos-system-backup # Encrypted (AES-256) folder snapshots (tar + gpg)
│ ├── pos-system-event-trigger # State-based rule monitors; alerts via notify when a check crosses a threshold
│ ├── pos-system-firewall # Interactive UFW management
│ ├── pos-system-health # Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL
│ ├── pos-system-schedule # Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently
│ ├── pos-tree # Show the pos CLI command tree: categories, commands, and subcommands
<!-- GEN:END tree -->
│ ├── flag-reader # Inspect feature flags (list/status/--raw)
@@ -288,9 +288,9 @@ All non-interactive `pos` commands log output to `~/.local/share/linux_post_inst
| share | usb-server | `pos-share-usb-server` | USB Redirector server control (--ls, --share; prompts when args omitted) |
| ssh | load-keys | `pos-ssh-load-keys` | Load all SSH keys into the agent |
| system | backup | `pos-system-backup` | Encrypted (AES-256) folder snapshots (tar + gpg) |
| system | event-trigger | `pos-system-event-trigger` | State-based rule monitors; alerts via notify when a check crosses a threshold |
| system | firewall | `pos-system-firewall` | Interactive UFW management |
| system | health | `pos-system-health` | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL |
| system | schedule | `pos-system-schedule` | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently |
| | tree | `pos-tree` | Show the pos CLI command tree: categories, commands, and subcommands |
<!-- GEN:END dispatch -->
@@ -566,13 +566,13 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
| `lib/flags.sh` | 60 | Feature flag store (set/clear/is_set/value/list/status) |
| `lib/notify.sh` | 76 | Multi-platform alerting (`notify_send`) — opt-in source, silent-fails |
| `lib/entertainment-lib.sh` | 350 | Entertainment module lib (ENABLED parsing, scheduler sync) |
| `lib/eventer-lib.sh` | 312 | Eventer lib (rule parsing, float compare, per-rule state, user-timer sync) |
| `lib/scheduler-lib.sh` | 822 | Scheduler lib (job parsing, notify policies, per-job user timers, legacy migrate) |
| `bin/flag-reader` | 58 | Inspect flags (list/status/`--raw`) |
| `bin/flag-set` | 21 | Set a flag (optionally with a value) |
| `bin/flag-clear` | 21 | Unset a flag |
| `features/autostart.sh` | 14 | Boot-time feature (moved from `bin/`, flag-gated service) |
<!-- GEN:START filetable -->
| `bin/pos` | 291 | CLI dispatcher with smart arg matching + logging + category help |
| `bin/pos` | 292 | CLI dispatcher with smart arg matching + logging + category help |
| `bin/pos-ai-gemini` | 311 | Chat with Google Gemini (ask, chat, models, sessions) |
| `bin/pos-communication-matrix-listener` | 565 | Matrix listener: map /command → bash, run them on room messages |
| `bin/pos-communication-matrix-sender` | 224 | Send messages to a Matrix room via the client-server API (send, test, login) |
@@ -591,20 +591,20 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
| `bin/pos-media-mp3` | 80 | Download audio as MP3 (yt-dlp) |
| `bin/pos-media-mp4` | 126 | Download video as MP4 (smart/interactive format select) |
| `bin/pos-network-checkport` | 496 | Check TCP/UDP port reachability (nmap, or bash/nc fallback) + local interface view |
| `bin/pos-network-download` | 874 | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) |
| `bin/pos-network-download` | 949 | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) |
| `bin/pos-network-hotspot` | 93 | Wi-Fi hotspot via create_ap + wihotspot-gui |
| `bin/pos-network-ip` | 69 | Show interfaces, routes, public IP + location |
| `bin/pos-network-scan` | 271 | Parallel ping sweep of CIDR |
| `bin/pos-share-nfs-client` | 138 | Mount NFS shares (ephemeral or persistent systemd mount units) |
| `bin/pos-share-nfs-server` | 134 | Manage the NFS kernel server (status, share/unshare exports, enable/disable) |
| `bin/pos-share-smb-client` | 183 | Mount SMB/CIFS shares (ephemeral or persistent systemd mount units) |
| `bin/pos-share-smb-server` | 226 | Manage the Samba server (status, share/unshare exports, users, enable/disable) |
| `bin/pos-share-smb-server` | 253 | Manage the Samba server (status, share/unshare exports, users, enable/disable) |
| `bin/pos-share-usb-server` | 218 | USB Redirector server control (--ls, --share; prompts when args omitted) |
| `bin/pos-ssh-load-keys` | 31 | Load all SSH keys into the agent |
| `bin/pos-system-backup` | 126 | Encrypted (AES-256) folder snapshots (tar + gpg) |
| `bin/pos-system-event-trigger` | 219 | State-based rule monitors; alerts via notify when a check crosses a threshold |
| `bin/pos-system-firewall` | 291 | Interactive UFW management |
| `bin/pos-system-health` | 209 | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL |
| `bin/pos-system-schedule` | 81 | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently |
| `bin/pos-tree` | 112 | Show the pos CLI command tree: categories, commands, and subcommands |
| `completions/pos.bash` | 291 | Dynamic bash completion |
<!-- GEN:END filetable -->
@@ -635,6 +635,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
| Modify aria2 download daemon / queue logic | Edit `bin/pos-network-download` |
| Modify NFS share logic | Edit `bin/pos-share-nfs-server` / `bin/pos-share-nfs-client` |
| Modify SMB share logic | Edit `bin/pos-share-smb-server` / `bin/pos-share-smb-client` |
| Modify the scheduler / scheduled jobs | Edit `bin/pos-system-schedule` / `lib/scheduler-lib.sh` (jobs in `~/.config/linux_post_install/schedule.d/`) |
| Modify AI/Gemini logic | Edit `bin/pos-ai-gemini` (config scope `ai` via `pos config ai`; `AI_GEMINI_API_KEY`/`AI_GEMINI_MODEL` in `~/.config/linux_post_install/ai.env`) |
| Modify UFW/firewall logic | Edit `bin/pos-system-firewall` |
| Modify pos logging | Edit log setup in `bin/pos` |
+4 -1
View File
@@ -14,7 +14,7 @@ authoritative one-line reference (every command + flag), see
| `pos docker` | Compose services, container dashboards, disposable VMs | [docker](howto/docker.md) |
| `pos media` | Download audio/video via yt-dlp | [media](howto/media.md) |
| `pos system` | Backups, firewall, health dashboard | [system](howto/system.md) |
| `pos system event-trigger` | Threshold-rule monitors that alert on crossing | [event-trigger](howto/event-trigger.md) |
| `pos system schedule` | Scheduled jobs: run a command on a timer, notify on threshold/change/error or silently | [schedule](howto/schedule.md) |
| `pos ssh` | Load keys into the agent | [ssh](howto/ssh.md) |
| `pos share` | Share USB devices & filesystems over the network (USB, NFS, SMB) | [share](howto/share.md) |
| `pos communication` | Send Telegram/Matrix messages & alerts, /command listeners | [communication](howto/communication.md) |
@@ -42,6 +42,7 @@ templates (without overwriting an existing file):
| `compose.env` | `pos docker compose` | `TS_AUTHKEY`, `TZ`, `DNS_SERVER`, `SERVICES_BASE` |
| `entertainment.env` | `pos entertainment *` | plugin keys (`WEATHER_LAT`…), `ENABLED` |
| `ai.env` | `pos ai gemini` | `AI_GEMINI_API_KEY`, `AI_GEMINI_MODEL` |
| `schedule.d/` | `pos system schedule` | one `<name>.env` per job: `INTERVAL`, `NOTIFY`, `MSG`, `RULE`, `COMMAND` |
```bash
pos config telegram # set TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID
@@ -73,6 +74,8 @@ Adding another platform = create `bin/pos-communication-<p>` implementing
enabled by postinstall once `telegram.env` exists. See [system](howto/system.md).
- **Entertainment auto-triggers** — per-plugin `pos entertainment enable <plugin> <interval>`,
uses systemd user timers (or cron fallback). See [entertainment](howto/entertainment.md).
- **`pos system schedule` jobs** — run any command on a per-job timer and notify
on threshold/change/error/always or silently. See [schedule](howto/schedule.md).
### Gotcha: run from anywhere
-88
View File
@@ -1,88 +0,0 @@
# How-To: `pos system event-trigger`
State-based threshold monitors: each line of `event.env` is an independent rule;
when a rule's check crosses its threshold you get one alert (plus one recovery
message when it clears). Alerts go through `lib/notify.sh` — Telegram by
default, `NOTIFY_PLATFORM` for more.
```bash
pos system event-trigger config # interactive rule editor
pos system event-trigger list # rules + live check values
pos system event-trigger enable 5m # evaluate every 5 minutes via systemd
pos system event-trigger status # timer + rule count
pos system event-trigger disable # stop monitoring
pos system event-trigger run --dry-run # preview what would fire
```
---
## Rule format
One rule per line in `~/.config/linux_post_install/event.env` (chmod 600):
```
["<message>" if ] <check-command> <op> <threshold>
```
| Part | Meaning |
|------|---------|
| `"<message>" if` | Optional custom alert text (quote-stripped); without it the message is auto-composed |
| `<check-command>` | Any shell command; its **first numeric output** is the value (pipes/args fine) |
| `<op>` | `>` `<` `>=` `<=` `==` `!=` |
| `<threshold>` | Number with optional unit suffix — `60c`, `80%`, `10g` all work |
The operator is detected as the rightmost `op threshold` pair in the line, so
check commands containing their own `>`/`<` (redirection, awk) don't confuse it.
Examples:
```
# event.env
"CPU too hot" if sensors -u coretemp-isa-0000 | awk '/Package id 0:/{f=1} f && /temp1_input:/{print $2; exit}' > 60c
"Disk nearly full" if df -P / | awk 'NR==2{print $5+0}' > 80%
"Load high" if uptime | sed 's/.*load average: //; s/,.*//' >= 4
```
A ready-made starter set (CPU, memory, disk space/health, NVMe, network,
processes) lives in `config/event-rules.template` — copy it to
`~/.config/linux_post_install/event.env` or add the lines you want via
`pos system event-trigger config`.
Behavior:
- The check runs on every pass. Non-numeric/empty output, or an unparseable
line → the rule is skipped with a warning (other rules still run). The
`run` summary reports how many rules evaluated and how many were skipped.
- `sensors` rules need `lm-sensors` installed (`preinstall.sh` installs it;
verify with `sensors -u` before adding a rule — sensor names differ by
chip, check `sensors -u` output for the real `*_input` key). **Gotcha:**
a bare `grep -m1 temp1_input` can match a *different* chip's `temp1_input`
first (e.g. `acpitz`'s case temp) instead of the CPU — pin the chip with
`sensors -u <chip>` (find it in `sensors -u`, e.g. `coretemp-isa-0000`)
as in the example above.
- Alerts fire **once** when the condition turns true, and once more when it
recovers — a hot CPU for two hours is one message, not twenty.
- State is tracked per rule in `~/.local/share/linux_post_install/eventer/state/`
(keyed by a hash of the rule line — editing a rule resets its state).
## Scheduling
`enable [interval]` installs a systemd **user timer** (`pos-event-trigger.timer`
+ oneshot `.service` that runs `pos system event-trigger run`). Intervals:
`5m 10m 15m 30m 45m hourly 2h 6h 12h daily weekly`, or a raw `OnCalendar=…`.
Requires a reachable user systemd manager; run
`sudo loginctl enable-linger $USER` once so timers fire without login (the tool
tries this and warns if it can't).
## Alerting
`run` sends via `lib/notify.sh notify_send`, which delivers to every platform in
`NOTIFY_PLATFORM` (default `telegram`; comma-separated = fan out). Adding a
Matrix/Synapse sender later needs no changes here — see DOC/DEV.md → Alerting.
---
## Related
- Reference: [DOC/POS.md → system](../POS.md#system)
- Notifications: [communication](howto/communication.md) / `lib/notify.sh`
+151
View File
@@ -0,0 +1,151 @@
# How-To: `pos system schedule`
Scheduled jobs: run any shell command on a timer and have the result sent via
`lib/notify.sh` (Telegram by default, `NOTIFY_PLATFORM` for more) — or run it
silently. Unlike the old `event-trigger` threshold monitors, each job has its
own interval and its own notify policy.
```bash
pos system schedule list # jobs + notify policy + last run
pos system schedule enable # start all job timers (per-job intervals)
pos system schedule status # per-job timer state + next run
pos system schedule run nvme-health # run one job now
pos system schedule run all --dry-run # preview everything
pos system schedule disable cpu-temp # stop a job, keep the file
pos system schedule migrate # convert old event.env rules
pos system schedule config # interactive job editor
```
---
## Job format
One file per job in `~/.config/linux_post_install/schedule.d/<name>.env`
(chmod 600):
```
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: 60c, 80%)
COMMAND=… # literal rest of the line — pipes/quotes/sudo fine
ENABLED=false # written by `disable`; default enabled
```
The `COMMAND=` value is everything after the prefix — no quoting/escaping of
pipes, quotes, or `sudo`. The job runs it with `bash -c` and observes the
exit code. Only `INTERVAL` is required (default `5m`); `NOTIFY` defaults to
`threshold` when `RULE` is present, otherwise `onchange`.
### Notify policies
| Policy | Behavior |
|--------|----------|
| `threshold` | Runs the command, compares its **first numeric output** against `RULE` (float-safe). Alerts once on false→true plus one recovery message on true→false — a hot CPU for two hours is one message, not twenty. `MSG` is the alert text (auto-composed when absent) |
| `onchange` | Sends the output only when it differs from the last run. First run always sends. Best for "tell me when this changes" reports |
| `always` | Sends the full output on every run |
| `onerror` | Sends only when the command fails (non-zero exit or empty output), with the `rc` |
| `never` | Runs and logs, never notifies — side-effect jobs (cleanups, backups) |
Every run also writes a per-job log + last-run record
(`~/.local/share/linux_post_install/schedule/{logs,state}/`) so `list`/`status`
show the result even for `never` jobs.
## Example — NVMe health every hour
Your exact use case:
```
# ~/.config/linux_post_install/schedule.d/nvme-health.env
INTERVAL=hourly
NOTIFY=onchange
MSG=NVMe health
COMMAND=sudo -n smartctl -a /dev/nvme0n1 | grep -Ei 'critical_warning|temperature|available_spare|percentage_used|media_errors|error_information'
```
Two setup notes:
1. **Passwordless sudo for smartctl.** User timers have no tty, so `sudo` needs
a NOPASSWD rule (smartmontools is in `preinstall.sh` PACKAGES):
```bash
echo '%sudo ALL=(ALL) NOPASSWD: /usr/sbin/smartctl' | sudo tee /etc/sudoers.d/smartctl
```
(Use `/usr/sbin/smartctl` — check `command -v smartctl`.) The `-n` flag makes
`sudo` fail instead of hanging if the rule is missing, which `onerror` will
report.
2. **`temperature` drifts**, so an `onchange` job on this grep will fire most
hours. If you only want fault-field alerts, drop the `temperature`
alternative: `grep -Ei 'critical_warning|available_spare|percentage_used|media_errors|error_information'`.
## Example — threshold rule (old event-trigger style)
```
# cpu-temp.env
INTERVAL=5m
NOTIFY=threshold
MSG=CPU too hot
RULE="> 60c"
COMMAND=sensors -u coretemp-isa-0000 | awk '/Package id 0:/{f=1} f && /temp1_input:/{print $2; exit}'
```
Gotchas carried over from event-trigger:
- Non-numeric/empty output → the job is skipped with a warning (others still run).
- **Pin the chip in `sensors` rules** — a bare `grep -m1 temp1_input` can match a
different chip's `temp1_input` first (e.g. `acpitz`'s case temp). Find the
real name in `sensors -u` (e.g. `coretemp-isa-0000`) and pin it as above.
- Unit suffixes on the threshold are fine: `60c`, `80%`, `10g`.
## Example — silent side-effect job
```
# log-cleanup.env — never notifies
INTERVAL=weekly
NOTIFY=never
COMMAND=find ~/.local/share/linux_post_install/logs -type f -name '*_pos_*.log' -mtime +30 -delete
```
## Scheduling
Each enabled job gets its own systemd **user timer pair**
(`pos-schedule-<name>.timer` + oneshot `.service` running
`pos system schedule run <name>`, `Persistent=true` — missed runs fire on next
boot/login). `enable`/`disable` reconcile the timers with `schedule.d/` and
remove orphaned units; the legacy single `pos-event-trigger` timer is cleaned
up automatically. Requires a reachable user systemd manager; run
`sudo loginctl enable-linger $USER` once so timers fire without login (the tool
tries this and warns if it can't).
## Migrating from event-trigger
If you have rules in `~/.config/linux_post_install/event.env`, convert them in
place:
```bash
pos system schedule migrate
```
Each rule becomes `schedule.d/rule-N.env` with `NOTIFY=threshold`, the legacy
timer's interval (or `5m`), and the rule split into `MSG`/`RULE`/`COMMAND`.
The old timer is disabled and removed; run `pos system schedule enable` to
start the migrated jobs.
`migrate` copies the rule's left side **verbatim** as `COMMAND` — the old tool
ran it literally and never had `disk root`/`loadavg`-style shorthands. A rule
like `disk root > 80%` migrates, but its job will log "produced no number" on
every run. After migrating, rewrite such jobs with a real command, e.g.
`COMMAND=df -P / | awk 'NR==2{print $5+0}'` (see the `config/schedule.d/`
starter jobs for patterns).
## Alerting
`run` sends via `lib/notify.sh notify_send`, which delivers to every platform
in `NOTIFY_PLATFORM` (default `telegram`; comma-separated = fan out). Adding a
Matrix/Synapse sender needs no changes here — see DOC/DEV.md → Alerting.
---
## Related
- Reference: [DOC/POS.md → system](../POS.md#system)
- Notifications: [communication](howto/communication.md) / `lib/notify.sh`
+4 -3
View File
@@ -184,8 +184,9 @@ EXAMPLES
pos entertainment enable weather 5m Auto-send weather to Telegram every 5 min
pos entertainment status Show enabled plugins + next run
pos system event-trigger config Edit threshold rules (event.env)
pos system event-trigger enable 5m Monitor rules on a 5-minute timer
pos system schedule list Show scheduled jobs + last results
pos system schedule enable Start all job timers (per-job intervals)
pos system schedule migrate Convert old event.env rules to jobs
pos ai gemini ask "Explain DNS in one line"
Ask Google Gemini a one-shot question
@@ -255,7 +256,7 @@ MAIN_LOG="$LOG_DIR/pos.log"
log_cmd() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $* → exit $2" >> "$MAIN_LOG"; }
# Commands that read from stdin interactively — only log invocation
INTERACTIVE_CMDS="system-firewall media-mp4 system-backup share-usb-server share-smb-server share-smb-client communication-telegram-listener communication-matrix-listener ai-gemini system-event-trigger config"
INTERACTIVE_CMDS="system-firewall media-mp4 system-backup share-usb-server share-smb-server share-smb-client communication-telegram-listener communication-matrix-listener ai-gemini system-schedule config"
for ((i=n-1; i>=0; i--)); do
cmd="pos"
-219
View File
@@ -1,219 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: system event-trigger — State-based rule monitors; alerts via notify when a check crosses a threshold
# POS_SUBCMDS: run config list enable disable status
# POS_FLAGS: --dry-run
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/eventer-lib.sh" 2>/dev/null || source "$(dirname "$0")/eventer-lib.sh"
source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh"
usage() {
cat <<EOF
Usage: pos system event-trigger <subcommand> [--dry-run]
State-based rule monitors. Each line in event.env is an independent rule:
"<message>" if <check-command> <op> <threshold>
<check-command> <op> <threshold>
The check command is run on every pass; when its first numeric output
crosses the threshold the message is sent via lib/notify.sh (Telegram by
default). Rules fire on false→true only, plus one recovery message.
Subcommands:
run Evaluate every rule once (the systemd timer calls this)
config Interactive editor: add / remove <n> / edit <n> / quit
list Show rules with their live check values
enable [interval] Install + start the user timer (default 5m; also:
10m 15m 30m 45m hourly 2h 6h 12h daily weekly, OnCalendar=…)
disable Stop + remove the timer
status Timer + rule count
Options:
--dry-run Preview what would fire / be written, without sending
-h, --help This help
Rules file: $CONFIG_FILE (chmod 600)
Examples:
pos system event-trigger list
pos system event-trigger config
pos system event-trigger enable 5m
pos system event-trigger run --dry-run
EOF
exit 0
}
case "${1:-}" in
-h|--help|"") usage ;;
esac
DRY_RUN=0
args=()
for a in "$@"; do
case "$a" in
--dry-run) DRY_RUN=1 ;;
*) args+=("$a") ;;
esac
done
set -- "${args[@]}"
[ -f "$CONFIG_FILE" ] || { log "no rules file yet — run 'pos system event-trigger config'"; }
# ── run ──────────────────────────────────────────────────────────
run_rules() {
eventer_read_rules
local n fired hash msg line skipped
n=0
skipped=0
for line in "${RULES[@]}"; do
case "$line" in
""|\#*) continue ;;
esac
eventer_parse_rule "$line" || { warn "skipping unparseable rule: $line"; skipped=$((skipped + 1)); continue; }
eventer_eval_check || { warn "check produced no number — $RULE_CMD"; skipped=$((skipped + 1)); continue; }
n=$((n + 1))
if eventer_compare "$VALUE_NUM" "$RULE_OP" "$RULE_NUM"; then fired=1; else fired=0; fi
hash="$(eventer_hash "$line")"
if [ "$fired" -eq 1 ] && ! eventer_state_is_firing "$hash"; then
msg="$(eventer_alert_msg)"
if [ "$DRY_RUN" -eq 1 ]; then
log "(dry-run) TRIGGER: $msg"
else
notify_send "$msg"
eventer_state_set "$hash" 1
fi
elif [ "$fired" -eq 0 ] && eventer_state_is_firing "$hash"; then
msg="$(eventer_recover_msg)"
if [ "$DRY_RUN" -eq 1 ]; then
log "(dry-run) RECOVER: $msg"
else
notify_send "$msg"
eventer_state_set "$hash" 0
fi
elif [ "$DRY_RUN" -eq 1 ]; then
log "(dry-run) OK: $RULE_CMD = $VALUE_DISPLAY (no change)"
else
log "OK: $RULE_CMD = $VALUE_DISPLAY (no change)"
fi
done
if [ "$skipped" -gt 0 ]; then
log "evaluated $n rule(s), skipped $skipped (no number / unparseable)"
else
log "evaluated $n rule(s)"
fi
}
# ── list ─────────────────────────────────────────────────────────
list_rules() {
eventer_read_rules
local i=0 line
for line in "${RULES[@]}"; do
i=$((i + 1))
case "$line" in
""|\#*) printf '%2d (comment/blank)\n' "$i"; continue ;;
esac
if eventer_parse_rule "$line"; then
if eventer_eval_check; then
printf '%2d [%s %s %s] %s = %s\n' "$i" "$RULE_CMD" "$RULE_OP" "$RULE_THRESH" \
"${RULE_MSG:+msg: $RULE_MSG; }" "$VALUE_DISPLAY"
else
printf '%2d [%s %s %s] (check produced no number)\n' "$i" "$RULE_CMD" "$RULE_OP" "$RULE_THRESH"
fi
else
printf '%2d INVALID — %s\n' "$i" "$line"
fi
done
[ "$i" -gt 0 ] || echo "(no rules — run 'pos system event-trigger config')"
}
# ── config editor ────────────────────────────────────────────────
config_editor() {
eventer_read_rules
local n rule action ok
while true; do
echo "── rules in $CONFIG_FILE ──" >&2
if [ "${#RULES[@]}" -eq 0 ]; then
echo " (empty)" >&2
else
for i in "${!RULES[@]}"; do
printf ' %2d %s\n' "$((i + 1))" "${RULES[i]}" >&2
done
fi
read -rp "action [add / remove <n> / edit <n> / quit]: " action
case "$action" in
quit|q) break ;;
add)
read -rp "Rule (\"msg\" if <cmd> <op> <thr>): " rule
[ -n "$rule" ] || continue
if ! eventer_parse_rule "$rule"; then
warn "not a valid rule — need: <command> <op> <threshold> (op: > < >= <= == !=)"
continue
fi
if ! eventer_eval_check; then
warn "check '$RULE_CMD' produced no number — rule added anyway (it will warn on run)"
else
ok "check '$RULE_CMD' currently reports $VALUE_DISPLAY"
fi
if grep -Fxq "$rule" "$CONFIG_FILE" 2>/dev/null; then
warn "rule already present, not added"
elif [ "$DRY_RUN" -eq 1 ]; then
log "(dry-run) would add: $rule"
else
mkdir -p "$(dirname "$CONFIG_FILE")"
printf '%s\n' "$rule" >>"$CONFIG_FILE"
chmod 600 "$CONFIG_FILE"
eventer_read_rules
fi
;;
remove*)
n="${action#remove }"
[[ "$n" =~ ^[0-9]+$ ]] || { warn "use: remove <line-number>"; continue; }
if [ "$n" -lt 1 ] || [ "$n" -gt "${#RULES[@]}" ]; then
warn "no line $n"; continue
fi
read -rp "remove line $n: '${RULES[$((n - 1))]}'? [y/N]: " ok
[[ "$ok" =~ ^[Yy] ]] || continue
if [ "$DRY_RUN" -eq 1 ]; then
log "(dry-run) would remove line $n"
else
unset "RULES[$((n - 1))]"
RULES=("${RULES[@]}")
eventer_write_rules
fi
;;
edit*)
n="${action#edit }"
[[ "$n" =~ ^[0-9]+$ ]] || { warn "use: edit <line-number>"; continue; }
if [ "$n" -lt 1 ] || [ "$n" -gt "${#RULES[@]}" ]; then
warn "no line $n"; continue
fi
printf ' current: %s\n' "${RULES[$((n - 1))]}" >&2
read -rp " new rule (blank = keep): " rule
[ -n "$rule" ] || continue
if ! eventer_parse_rule "$rule"; then
warn "not a valid rule — edit cancelled"
continue
fi
if [ "$DRY_RUN" -eq 1 ]; then
log "(dry-run) would replace line $n"
else
RULES[$((n - 1))]="$rule"
eventer_write_rules
fi
;;
*) warn "unknown action (add | remove <n> | edit <n> | quit)" ;;
esac
done
}
case "${1:-}" in
run) run_rules ;;
config) config_editor ;;
list) list_rules ;;
enable) eventer_enable "${2:-5m}" ;;
disable) eventer_disable ;;
status) eventer_status ;;
*) err "unknown subcommand '${1:-}' (see --help)" ;;
esac
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: system schedule — Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently
# POS_SUBCMDS: run list config enable disable status migrate
# POS_FLAGS: --dry-run
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/scheduler-lib.sh" 2>/dev/null || source "$(dirname "$0")/scheduler-lib.sh"
source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh"
usage() {
cat <<EOF
Usage: pos system schedule <subcommand> [--dry-run]
Scheduled jobs. Each job is a file in $SCHEDULE_DIR/<name>.env:
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: 60c, 80%)
COMMAND=… literal rest of the line — pipes/quotes/sudo fine
NOTIFY policies:
always send the full output on every run
onchange send only when the 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
never run only — no notification (side-effect jobs)
Default: threshold if RULE is set, otherwise onchange.
Subcommands:
run [name|all] Execute job(s) now (the systemd timers call 'run <name>')
list Jobs + notify policy + interval + last run
config Interactive editor: add / edit / remove / enable / disable
enable [name|all] Install + start the job's user timer (default: all)
disable [name|all] Stop the job, keeping the job file (default: all)
status Per-job timer state + next run
migrate Convert legacy event.env rules into schedule.d jobs (threshold)
Options:
--dry-run Preview what would run / be written / be sent
-h, --help This help
Jobs dir: $SCHEDULE_DIR (chmod 600 per file)
Examples:
pos system schedule list
pos system schedule enable
pos system schedule run nvme-health --dry-run
pos system schedule migrate
EOF
exit 0
}
case "${1:-}" in
-h|--help|"") usage ;;
esac
DRY_RUN=0
args=()
for a in "$@"; do
case "$a" in
--dry-run) DRY_RUN=1 ;;
*) args+=("$a") ;;
esac
done
set -- "${args[@]}"
[ -d "$SCHEDULE_DIR" ] || log "no jobs yet — add one with 'pos system schedule config'"
case "${1:-}" in
run) sched_run "${2:-all}" ;;
list) sched_list ;;
config) sched_config_editor ;;
enable) sched_enable "${2:-all}" ;;
disable) sched_disable "${2:-all}" ;;
status) sched_status ;;
migrate) sched_migrate ;;
*) err "unknown subcommand '${1:-}' (see --help)" ;;
esac
+3 -3
View File
@@ -15,7 +15,7 @@ _pos_flags[network-download]="--dir --out --split --seed --force --upload --gid
_pos_flags[network-hotspot]="--foreground"
_pos_flags[share-usb-server]="--ls --ls-shared --share --unshare --auto-share --callback --close-callback --auto-connect --disconnect --nickname --timeout --port --info --version"
_pos_flags[system-backup]="--service"
_pos_flags[system-event-trigger]="--dry-run"
_pos_flags[system-schedule]="--dry-run"
_pos_flags[tree]="--depth"
# GEN:END posflags
# GEN:START possubcmds
@@ -25,8 +25,8 @@ _pos_subcmds[communication-matrix-sender]="send test login"
_pos_subcmds[communication-telegram-sender]="send test"
_pos_subcmds[docker-compose]="ls installed up down restart logs update config"
_pos_subcmds[docker-vbox]="create enter stop start rm ls"
_pos_subcmds[network-download]="start stop status add torrent metalink list info files peers pause resume remove purge move limit set watch restart retry"
_pos_subcmds[system-event-trigger]="run config list enable disable status"
_pos_subcmds[network-download]="start stop status add torrent metalink list info files peers pause resume remove purge move limit set watch restart retry replace"
_pos_subcmds[system-schedule]="run list config enable disable status migrate"
# GEN:END possubcmds
# GEN:START posconfigscopes
declare -a _pos_config_scopes=(ai compose entertainment matrix notify system telegram)
-54
View File
@@ -1,54 +0,0 @@
# eventer rules template — copy/paste into ~/.config/linux_post_install/event.env
# (or add each line via: pos system event-trigger config).
#
# One independent rule per line. Grammar:
#
# "<message>" if <check-command> <op> <threshold>
# <check-command> <op> <threshold> (no message → auto-composed)
#
# <message> optional quoted alert text (quote-stripped)
# <check-command> any shell command; first numeric output is the value
# <op> > < >= <= == !=
# <threshold> number with optional unit suffix: 60c, 80%, 10g
#
# Rules alert once on false→true (plus one recovery message on true→false)
# via lib/notify.sh — Telegram by default. State is tracked per rule in
# ~/.local/share/linux_post_install/eventer/state/ (keyed by rule-line hash).
#
# Verify each check on your machine first (adjust device names):
# sensors -u coretemp-isa-0000 | awk '/Package id 0:/{f=1} f && /temp1_input:/{print $2; exit}'
# smartctl -A /dev/sda | awk '/Temperature_Celsius/{print $10}'
# nvme smart-log /dev/nvme0
# vnstat -d 1
#
# ── CPU (lm-sensors) ──────────────────────────────────────────────
"CPU too hot" if sensors -u coretemp-isa-0000 | awk '/Package id 0:/{f=1} f && /temp1_input:/{print $2; exit}' > 60c
"CPU hot warning" if sensors -u coretemp-isa-0000 | awk '/Package id 0:/{f=1} f && /temp1_input:/{print $2; exit}' > 55c
"Load high" if uptime | sed 's/.*load average: //' | awk -F'[, ]+' '{print $1}' > 4
# ── Memory (procps, always present) ───────────────────────────────
"RAM high" if free -m | awk '/^Mem:/{print int($3/$2*100)}' > 90%
"Swap in use" if free -m | awk '/^Swap:/{print $3+0}' > 1000
# ── Disk space ───────────────────────────────────────────────────
"Disk / nearly full" if df -P / | awk 'NR==2{print $5+0}' > 80%
"Disk /srv full" if df -P /srv | awk 'NR==2{print $5+0}' > 85%
# ── Disk health (smartmontools) ──────────────────────────────────
"Disk sda SMART fail" if smartctl -H /dev/sda | grep -q PASSED && echo 0 || echo 1 == 1
"SDA hot" if smartctl -A /dev/sda | awk '/Temperature_Celsius/{print $10}' > 50c
# ── NVMe (nvme-cli) ──────────────────────────────────────────────
"NVMe hot" if nvme smart-log /dev/nvme0 | awk '/^temperature/{print $3}' > 65c
"NVMe wear-out" if nvme smart-log /dev/nvme0 | awk '/percentage used/{print $NF+0}' > 80%
# ── Network (vnstat needs ~1 day of collected data) ──────────────
"Big traffic day" if vnstat -d 1 | awk '/today/{print $10+0}' > 20000
"Link eth0 down" if ethtool eth0 | grep -q 'Link detected: yes' && echo 0 || echo 1 == 1
# ── Processes ────────────────────────────────────────────────────
"Top CPU 80%" if top -bn1 | awk 'NR==8{print $9+0}' > 80%
"Top MEM 90%" if top -bn1 | awk 'NR==8{print $10+0}' > 90%
# Manage rules interactively: pos system event-trigger config
# Schedule the timer: pos system event-trigger enable 5m
-21
View File
@@ -1,21 +0,0 @@
# eventer rules — evaluated by 'pos system event-trigger run' (systemd timer).
# Copied to ~/.config/linux_post_install/event.env on install (no clobber).
# One independent rule per line. Grammar:
#
# "<message>" if <check-command> <op> <threshold>
# <check-command> <op> <threshold> (no message → auto-composed)
#
# The check command is run; its first numeric output is compared against the
# threshold (float-safe, unit suffix on the threshold is fine: 60c, 80%, 10g).
# Operators: > < >= <= == !=
#
# Rules alert once on false→true (plus one recovery message when the
# condition clears) via lib/notify.sh — Telegram by default.
#
# Examples:
# "CPU too hot" if sensors -u coretemp-isa-0000 | awk '/Package id 0:/{f=1} f && /temp1_input:/{print $2; exit}' > 60c
# "Disk nearly full" if df -P / | awk 'NR==2{print $5+0}' > 80%
# "Load high" if uptime | sed 's/.*load average: //; s/,.*//' >= 4
#
# Manage rules interactively: pos system event-trigger config
# Schedule the timer: pos system event-trigger enable 5m
+9
View File
@@ -0,0 +1,9 @@
# CPU temperature threshold — alerts once while over 60c, one recovery message.
# lm-sensors (in preinstall PACKAGES). Chip names differ per machine — check
# `sensors -u` for the real key and pin the chip (a bare grep can match a
# different chip's temp1_input first).
INTERVAL=5m
NOTIFY=threshold
MSG=CPU too hot
RULE="> 60c"
COMMAND=sensors -u coretemp-isa-0000 | awk '/Package id 0:/{f=1} f && /temp1_input:/{print $2; exit}'
+6
View File
@@ -0,0 +1,6 @@
# Root filesystem usage threshold — alerts once while over 80%, one recovery.
INTERVAL=30m
NOTIFY=threshold
MSG=Disk / nearly full
RULE="> 80%"
COMMAND=df -P / | awk 'NR==2{print $5+0}'
+5
View File
@@ -0,0 +1,5 @@
# Side-effect job: silently prune old pos invocation logs every week.
# NOTIFY=never — runs and does its thing, no notification.
INTERVAL=weekly
NOTIFY=never
COMMAND=find ~/.local/share/linux_post_install/logs -type f -name '*_pos_*.log' -mtime +30 -delete
+11
View File
@@ -0,0 +1,11 @@
# NVMe SMART health — notify when any monitored field changes.
# Needs smartmontools (in preinstall PACKAGES) + passwordless sudo for
# smartctl. Set it up once:
# echo '%sudo ALL=(ALL) NOPASSWD: /usr/sbin/smartctl' | sudo tee /etc/sudoers.d/smartctl
# Adjust /dev/nvme0n1 if your disk has a different device name.
# Note: 'temperature' drifts, so onchange will fire most hours — drop the
# temperature alternative from the grep if you only want fault-field alerts.
INTERVAL=hourly
NOTIFY=onchange
MSG=NVMe health
COMMAND=sudo -n smartctl -a /dev/nvme0n1 | grep -Ei 'critical_warning|temperature|available_spare|percentage_used|media_errors|error_information'
+1 -1
View File
@@ -117,7 +117,7 @@ if should_run 2 scripts; then
run sudo install -m 644 lib/flags.sh /usr/local/bin/flags.sh
run sudo install -m 644 lib/notify.sh /usr/local/bin/notify.sh
run sudo install -m 644 lib/entertainment-lib.sh /usr/local/bin/entertainment-lib.sh
run sudo install -m 644 lib/eventer-lib.sh /usr/local/bin/eventer-lib.sh
run sudo install -m 644 lib/scheduler-lib.sh /usr/local/bin/scheduler-lib.sh
run sudo install -m 644 lib/config-ui.sh /usr/local/bin/config-ui.sh
# ── Entertainment plugins ────────────────────────────────
-313
View File
@@ -1,313 +0,0 @@
# lib/eventer-lib.sh — shared library for `pos system event-trigger`.
# Sourced by bin/pos-system-event-trigger AFTER lib/common.sh.
#
# eventer reads a list of independent RULES from event.env, one per line:
#
# "High CPU temp" if sensors -u | grep -m1 temp1_input | awk '{print $2}' > 60c
# disk root > 80%
# loadavg >= 4
#
# Grammar: ["<message>" if ] <inline-check-command> <op> <threshold>
# - optional message before the last ' if ' (quote-stripped) = alert text
# - the operator is the RIGHTMOST '>= <= == != > <' whose RHS parses as a
# number (a unit suffix like 60c / 80% is fine) — everything before it is
# the check command, the number is the threshold
# - the check command is run and its FIRST numeric token is compared (float).
# State-based alerting: notify on false→true, one recovery on true→false.
#
# NOTE: rules are arbitrary shell commands (the user's own config, chmod 600),
# the same trust model as the Telegram listener's command map.
CONFIG_FILE="${EVENT_CONFIG:-$HOME/.config/linux_post_install/event.env}"
EVENT_STATE_DIR="${EVENT_STATE_DIR:-$HOME/.local/share/linux_post_install/eventer/state}"
USER_SYSTEMD_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
EVENT_UNIT="pos-event-trigger"
EVENT_RUNNER="$(command -v pos-system-event-trigger 2>/dev/null || echo /usr/local/bin/pos-system-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 "[+] $*"; }
# ── Rule file helpers ────────────────────────────────────────────
# Fills the global array RULES with every line of the file (raw).
eventer_read_rules() {
RULES=()
[ -f "$CONFIG_FILE" ] || return 0
local line
while IFS= read -r line || [ -n "$line" ]; do
RULES+=("$line")
done < "$CONFIG_FILE"
}
eventer_write_rules() {
mkdir -p "$(dirname "$CONFIG_FILE")"
local tmp
tmp="$(mktemp)"
printf '%s\n' "${RULES[@]}" >"$tmp"
mv "$tmp" "$CONFIG_FILE"
chmod 600 "$CONFIG_FILE"
}
eventer_count_rules() {
local i n=0 line
for line in "${RULES[@]}"; do
line="${line#"${line%%[![:space:]]*}"}" # trim leading whitespace
[ -n "$line" ] && [[ "$line" != \#* ]] && n=$((n + 1))
done
printf '%s' "$n"
}
# ── Rule parsing ─────────────────────────────────────────────────
# eventer_parse_rule "<line>" — on success sets RULE_MSG, RULE_CMD,
# RULE_OP, RULE_THRESH, RULE_NUM and returns 0; otherwise returns 1.
# Also supports bare "CMD OP THRESH" (no message) and "MSG if CMD OP THRESH".
eventer_parse_rule() {
local line="$1" cond="" msg="" s m parts="" i
s="$line"
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
local -a tokens=()
read -r -a tokens <<< "$cond"
local n=${#tokens[@]}
[ "$n" -ge 2 ] || return 1
for ((i=n-2; i>=0; i--)); do
case "${tokens[i]}" in
">="|"<="|"=="|"!="|">"|"<")
if eventer_numeric "${tokens[i+1]}" >/dev/null 2>&1; then
RULE_OP="${tokens[i]}"
RULE_THRESH="${tokens[i+1]}"
RULE_NUM="$(eventer_numeric "${tokens[i+1]}")"
if [ "$i" -eq 0 ]; then
RULE_CMD="${tokens[0]}"
else
RULE_CMD="$(printf '%s ' "${tokens[@]:0:i}")"
RULE_CMD="${RULE_CMD% }"
fi
RULE_MSG="$(eventer_strip_quotes "$msg")"
return 0
fi ;;
esac
done
return 1
}
# First numeric prefix of a string (allows a unit suffix: 60c, 80%, 10g).
eventer_numeric() {
if [[ "$1" =~ ^[+-]?([0-9]+([.][0-9]+)?|[.][0-9]+) ]]; then
printf '%s' "${BASH_REMATCH[0]}"
return 0
fi
return 1
}
eventer_strip_quotes() {
local v="$1"
v="${v#\"}"; v="${v%\"}"
v="${v#\'}"; v="${v%\'}"
printf '%s' "$v"
}
# Float-safe numeric comparison. Returns 0 if $1 $2 $3 is TRUE.
eventer_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
}'
}
# ── Running a check ──────────────────────────────────────────────
# eventer_eval_check — runs RULE_CMD, sets VALUE_DISPLAY (first numeric
# token as printed) and VALUE_NUM (its numeric prefix). Returns 0 on success.
eventer_eval_check() {
local out tok line
out="$(bash -c "$RULE_CMD" 2>/dev/null || true)"
[ -n "$out" ] || return 1
line="$(printf '%s' "$out" | sed -n '1p')"
read -r -a tokens <<< "$line"
for tok in "${tokens[@]}"; do
if eventer_numeric "$tok" >/dev/null 2>&1; then
VALUE_NUM="$(eventer_numeric "$tok")"
VALUE_DISPLAY="$tok"
return 0
fi
done
return 1
}
# ── Alert messages ───────────────────────────────────────────────
eventer_alert_msg() {
if [ -n "$RULE_MSG" ]; then
printf '%s (now %s)' "$RULE_MSG" "$VALUE_DISPLAY"
else
printf '%s now %s is %s %s' "${RULE_CMD%% *}" "$VALUE_DISPLAY" "$RULE_OP" "$RULE_THRESH"
fi
}
eventer_recover_msg() {
if [ -n "$RULE_MSG" ]; then
printf '%s recovered (now %s)' "$RULE_MSG" "$VALUE_DISPLAY"
else
printf '%s OK (now %s)' "${RULE_CMD%% *}" "$VALUE_DISPLAY"
fi
}
# ── State (per rule, keyed by a short hash of the rule line) ─────
eventer_hash() { printf '%s' "$1" | sha256sum | cut -c1-16; }
eventer_state_is_firing() { # $1 = hash
[ -f "$EVENT_STATE_DIR/$1" ] && grep -q '^1' "$EVENT_STATE_DIR/$1"
}
eventer_state_set() { # $1 = hash, $2 = 0|1
mkdir -p "$EVENT_STATE_DIR"
printf '%s\n' "$2" >"$EVENT_STATE_DIR/$1"
}
# ── Interval → systemd OnCalendar (same list as entertainment) ──
eventer_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
}
eventer_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 timer ───────────────────────────────────────────
eventer_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
}
eventer_write_units() {
local oncal="$1"
mkdir -p "$USER_SYSTEMD_DIR"
cat >"$USER_SYSTEMD_DIR/$EVENT_UNIT.service" <<EOF
[Unit]
Description=pos system event-trigger — evaluate event.env rules
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=$EVENT_RUNNER run
[Install]
WantedBy=timers.target
EOF
cat >"$USER_SYSTEMD_DIR/$EVENT_UNIT.timer" <<EOF
[Unit]
Description=Schedule: pos system event-trigger
[Timer]
OnCalendar=$oncal
Persistent=true
[Install]
WantedBy=timers.target
EOF
chmod 644 "$USER_SYSTEMD_DIR/$EVENT_UNIT.service" "$USER_SYSTEMD_DIR/$EVENT_UNIT.timer"
}
eventer_enable() {
local interval="${1:-5m}" oncal
oncal="$(eventer_interval_to_oncalendar "$interval")" || {
warn "invalid interval '$interval' (use 5m 10m 15m 30m 45m hourly 2h 6h 12h daily weekly, or OnCalendar=…)"
return 1
}
if [ "${DRY_RUN:-0}" -eq 1 ]; then
log "(dry-run) write $USER_SYSTEMD_DIR/$EVENT_UNIT.timer (OnCalendar=$oncal)"
return 0
fi
eventer_write_units "$oncal"
systemctl --user daemon-reload >/dev/null 2>&1 || warn "daemon-reload failed (user systemd running?)"
if systemctl --user enable --now "$EVENT_UNIT.timer" >/dev/null 2>&1; then
ok "$EVENT_UNIT.timer enabled ($(eventer_interval_label "$interval"))"
eventer_ensure_linger
else
warn "could not enable $EVENT_UNIT.timer (is the user systemd manager running?)"
fi
}
eventer_disable() {
if [ "${DRY_RUN:-0}" -eq 1 ]; then
log "(dry-run) remove $USER_SYSTEMD_DIR/$EVENT_UNIT.timer + .service"
return 0
fi
if [ -f "$USER_SYSTEMD_DIR/$EVENT_UNIT.timer" ]; then
systemctl --user disable --now "$EVENT_UNIT.timer" >/dev/null 2>&1 || true
rm -f "$USER_SYSTEMD_DIR/$EVENT_UNIT.timer" "$USER_SYSTEMD_DIR/$EVENT_UNIT.service"
systemctl --user daemon-reload >/dev/null 2>&1 || true
ok "$EVENT_UNIT.timer disabled"
else
warn "$EVENT_UNIT.timer is not enabled"
fi
}
eventer_status() {
if [ -f "$USER_SYSTEMD_DIR/$EVENT_UNIT.timer" ] && systemctl --user show-environment >/dev/null 2>&1 \
&& systemctl --user is-enabled "$EVENT_UNIT.timer" >/dev/null 2>&1; then
eventer_read_rules
local oncal next
oncal="$(sed -n 's/^OnCalendar=//p' "$USER_SYSTEMD_DIR/$EVENT_UNIT.timer")"
next="$(systemctl --user list-timers "$EVENT_UNIT.timer" --no-legend 2>/dev/null | awk '{print $1, $2}' | head -1)"
echo "enabled — schedule: ${oncal}${next:+ (next: $next)}"
echo "rules: $(eventer_count_rules) in $CONFIG_FILE"
else
echo "not enabled (run: pos system event-trigger enable [interval])"
fi
}
+829
View File
@@ -0,0 +1,829 @@
# 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
[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
}
+21 -16
View File
@@ -37,7 +37,7 @@ fi
# ── system + notify config templates ───────────────────────────
# Copied only if the user has not already created their own (no clobber).
run mkdir -p "$ENT_DIR"
for tpl in system.env notify.env ai.env event.env; do
for tpl in system.env notify.env ai.env; do
if [ -f "config/$tpl" ]; then
if [ -f "$ENT_DIR/$tpl" ]; then
log "$tpl already exists, keeping it"
@@ -49,23 +49,28 @@ for tpl in system.env notify.env ai.env event.env; do
fi
done
# ── event-trigger starter rules (interactive) ──────────────────
# config/event-rules.template has a ready-made rule set (CPU, memory,
# disk, NVMe, network, processes). Ask the user before copying — only if
# event.env has no rules yet (never overwrite existing rules).
if [ -f config/event-rules.template ] && [ -f "$ENT_DIR/event.env" ]; then
if ! grep -vE '^[[:space:]]*(#.*)?$' "$ENT_DIR/event.env" >/dev/null 2>&1; then
if confirm "Copy the starter event-trigger rules into event.env?"; then
if [ "${DRY_RUN:-0}" -eq 1 ]; then
log "(dry-run) would copy config/event-rules.template → $ENT_DIR/event.env"
else
run cp config/event-rules.template "$ENT_DIR/event.env"
run chmod 600 "$ENT_DIR/event.env"
log "Installed starter rules — check them with 'pos system event-trigger list'"
fi
# ── schedule starter jobs (interactive) ────────────────────────
# config/schedule.d has ready-made example jobs (NVMe health, CPU/disk
# thresholds, silent log cleanup). Ask before copying — only into an empty
# schedule.d/ (never overwrite existing jobs). Users with legacy event.env
# rules are pointed at `pos system schedule migrate` instead.
if [ -d config/schedule.d ] && [ ! -d "$ENT_DIR/schedule.d" ]; then
if [ -f "$ENT_DIR/event.env" ] && grep -vE '^[[:space:]]*(#.*)?$' "$ENT_DIR/event.env" >/dev/null 2>&1; then
log "legacy event.env rules found — convert them with 'pos system schedule migrate'"
elif confirm "Install the starter schedule jobs (NVMe health, CPU/disk thresholds, log cleanup)?"; then
if [ "${DRY_RUN:-0}" -eq 1 ]; then
log "(dry-run) would copy config/schedule.d/* → $ENT_DIR/schedule.d/"
else
log "Skipping starter rules"
run mkdir -p "$ENT_DIR/schedule.d"
for jf in config/schedule.d/*.env; do
[ -f "$jf" ] || continue
run cp "$jf" "$ENT_DIR/schedule.d/"
run chmod 600 "$ENT_DIR/schedule.d/${jf##*/}"
done
log "Installed starter schedule jobs — check them with 'pos system schedule list'"
fi
else
log "Skipping starter schedule jobs"
fi
fi