Skip to content

feat(health): VM/CT health view and ntfy/e-mail alerts (v2.13.0) - #38

Merged
TimInTech merged 8 commits into
mainfrom
feature/health-alerts
Sep 19, 2026
Merged

TimInTech merged 8 commits into
mainfrom
feature/health-alerts

Conversation

@TimInTech

@TimInTech TimInTech commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds per-guest health monitoring and opt-in alerting to pman (v2.13.0).

  • --health — health overview for all VMs/CTs on the local node: status, CPU %, RAM %, root disk %,
    uptime and an OK/WARN/CRIT level with findings. Boxed table in a TTY, plain with --list, JSON with
    --json. --filter / --name apply. Interactive: key h in the main menu, extra Health: line in
    the status action.
  • --check — cron-friendly evaluation with Nagios exit codes (0 OK / 1 WARN / 2 CRIT / 3 UNKNOWN),
    flock lock, versioned state file (/var/lib/pman/health.state, 0600 in 0700 dir), alerts only on
    state changes (new / escalated / improved / RESOLVED).
    • Resource thresholds (HEALTH_{CPU,MEM,DISK}_{WARN,CRIT}, 0 disables; CPU needs
      HEALTH_CPU_RUNS consecutive hits)
    • Unexpectedly stopped guests (onboot=1, or running → stopped without an explaining lifecycle
      task such as shutdown, suspend, migrate or stop-mode vzdump)
    • Failed Proxmox tasks (/nodes/<node>/tasks --source all), deduplicated via watermark + UPID,
      baseline on first run
  • Notifications — one aggregated message per run via ntfy (curl, token only from a 0600 file,
    passed via stdin config, HTTPS enforced with token, no redirects, -q) and e-mail (sendmail -t -oi).
    Unsent transitions are retried on the next run. --test-notify sends a test message; --dry-run
    prints the message without sending or writing state.
  • Config allowlist extended (inline NTFY_TOKEN is rejected); guest names / task texts are sanitized
    for TTY, JSON, ntfy and mail.
  • Docs: README section "Health monitoring & alerts" incl. cron example, CHANGELOG, completions
    (also adds the previously missing --name). CI now runs tests/run.sh.

Parsing uses python3 (ships with PVE) like the existing IP lookup; no new dependencies.

Type of Change

  • Bug fix
  • New feature / flag
  • Refactor / performance improvement
  • Documentation only (docs updated alongside the feature)
  • CI / tooling (CI runs tests/run.sh)

Checklist

  • shellcheck proxmox-manager.sh passes locally with no warnings
  • Tests pass: tests/run.sh — all green
  • CHANGELOG.md updated (as release section [2.13.0] - 2026-09-19)
  • New flags documented in usage() / --help output
  • No generated files, scan outputs, or binaries committed
  • Commit messages follow type(scope): summary convention

Reviews

Implemented in work packages, reviewed by a logic reviewer (12 findings, all fixed: sticky metric alerts
after stop, false "unexpected stop" due to pvestatd delay, tasks --source all, JSON with --force,
exit code on early SIGTERM, …) and a security reviewer (no must-fix; 8 hardening items applied: token-file
TOCTOU, lock file open mode, timeouts, curl -q, json_escape control chars, …).

Testing

  • shellcheck -x proxmox-manager.sh tests/run.sh tests/mock-bin/* — clean
  • shfmt -d proxmox-manager.sh — clean
  • bash tests/run.sh — 131 passed, 0 failed (41 before), new mocks: pvesh, curl, sendmail, uname
    and fixtures under tests/fixtures/

Verified on a real node (PVE 9.2.20, kernel 7.0.14-15-pve, 12 guests; script copied to a temp dir, installed pman untouched):

  • --health / --list / --json: values match raw pvesh data (e.g. VM 100 mem 5.70/6.44 GB → 89 %), VM disk null, ~1 s runtime
  • TTY table: frames consistent at 60/80/100 columns (found and fixed a pre-existing _term_cols bug, see 73f60ae)
  • --check: baseline, exit codes 0/1/2, state dir 0700 / files 0600, --dry-run writes nothing, ~2 s runtime
  • ntfy (ntfy.sh, random topic): --test-notify, WARN push (prio 4) → no duplicate on repeat run → RESOLVED push (prio 2)
  • sendmail handoff: correct To/From/Subject/MIME/UTF-8/Auto-Submitted/Date headers in the Postfix queue
  • Stop scenarios with a test CT: Proxmox shutdown → no alert; in-guest poweroff → WARN "stopped unexpectedly" (latched)
  • Failed tasks: real qmsnapshot failure from the node's task history reported as CRIT (exit 2)
  • Interactive: status action shows the Health: line

Not covered live: end-to-end mail delivery (node has no working relay), onboot=1 CRIT path (mock-tested only).

Screenshots in docs/ still show v2.12.1 and need regeneration on a real host.

🤖 Generated with Claude Code

Tim Baumann and others added 7 commits September 19, 2026 18:37
- New allowlisted config keys: HEALTH_{CPU,MEM,DISK}_{WARN,CRIT},
  HEALTH_CPU_RUNS, HEALTH_IGNORE_IDS, HEALTH_STATE_DIR, NTFY_URL,
  NTFY_TOKEN_FILE, HEALTH_MAIL_TO, HEALTH_MAIL_FROM; inline NTFY_TOKEN
  is ignored and rejected in notification modes.
- _validate_health_config runs only in health/check/test-notify modes.
- New flags --health, --check, --dry-run, --test-notify with mode
  resolution and invalid-combination checks; fatal setup errors exit 3
  in --check mode.
- Extract _fmt_duration and _owner_mode_ok helpers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Read local guests from `pvesh get /cluster/resources` (30 s timeout),
  parse JSON on stdin with python3 into TSV; templates and guests on
  other nodes are skipped, names sanitized.
- _health_checks is the single source for cpu/mem/disk/down levels;
  stopped guests with onboot=1 are CRIT.
- --health (boxed), --health --list (plain) with findings and totals,
  --health --json with null for n/a metrics; --filter/--name apply.
- Main menu key "h" opens the health overview; the status action adds a
  "Health:" line and stays silent without pvesh/python3.
- Tests: pvesh mock with fixtures, onboot support in qm/pct mocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- --check evaluates all local guests plus recent node tasks and alerts
  only on changes: new/escalated, improved, RESOLVED with duration.
- CPU needs HEALTH_CPU_RUNS consecutive runs; mem/disk alert at once; a
  metric that is n/a keeps its previous state; a running guest that
  stops without a successful stop/shutdown/destroy/migrate task is WARN
  (latched), onboot=1 and stopped is CRIT.
- Failed tasks since the last run are one-shot events (WARNINGS -> WARN,
  otherwise CRIT); the first run is a baseline; watermark by endtime+UPID.
- Versioned TSV state (0600 in a 0700 dir), regex-validated on load,
  never sourced, written atomically; flock against parallel runs; INT/TERM
  and unexpected aborts exit 3.
- Nagios-style summary; exit 0/1/2/3. --dry-run prints the composed
  message and writes neither state nor lock nor notifications.
- _notify_all is a stub until the ntfy/sendmail senders land; with a
  channel configured it reports failure so state is kept for a retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- _notify_ntfy: curl with URL, Title/Priority/Tags and the optional
  Bearer token passed only via the stdin config; body from a private
  temp file; no redirects; only the host is logged. A token is never
  sent over plain http (channel fails instead).
- _notify_mail: local sendmail (fallback /usr/sbin/sendmail) with
  validated single-line headers, UTF-8 body and Auto-Submitted.
- _notify_all tries every configured channel and fails only when all
  of them fail, so --check keeps its state and retries next run.
- --test-notify sends a real test message.
- A running or successful vzdump task explains a stopped guest
  (backup stop mode).
- Tests: curl/sendmail mocks, delivery, dedup, recovery, partial and
  total failure, token handling, header injection, vzdump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Version header 2.13.0 with changelog line; CHANGELOG [2.13.0] with
  Added, Changed and Security.
- README: features and usage rows, new "Health monitoring & alerts"
  section (checks, thresholds, exit codes, state, ntfy/mail setup, cron
  example, limitations), full config key list with chmod 600 advice,
  updated security note, requirements, test count and banner version.
- Bash/Zsh completions: --name, --health, --check, --dry-run,
  --test-notify.
- CI runs tests/run.sh after shfmt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Metric alerts resolve when a guest stops (CPU counter restarts); an
  n/a metric on a running guest keeps its state without being reported.
- Unexpected-stop detection uses the most recent lifecycle task per
  guest (no time window, OK or WARNINGS; suspend, reboot in progress and
  stop-mode backups included); a stop task clears an existing latch.
  onboot=1 guests stopped by a task are WARN instead of CRIT.
- Tasks are read with --source all so running tasks are visible.
- --force warning goes to stderr for --json/--health --json/--check.
- --check: INT/TERM and usage errors exit 3 from the very start.
- Total delivery failure saves the state but keeps the unsent changes
  (and the task watermark) pending, so they fire once on the next run.
- Lock on fd 9, closed for pvesh/python3/qm/pct/curl/sendmail.
- onboot read from /etc/pve/{qemu-server,lxc}/ID.conf (current section)
  with qm/pct fallback; node name from `uname -n` up to the first dot.
- Findings shorten the guest name, never the text; narrow terminals drop
  UPTIME/DISK%/TYPE; ignored guests are not counted; an empty node is
  not an error for --health.
- Regression tests for each item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- http:// NTFY_URL together with NTFY_TOKEN_FILE is a config error
  (--check exits 3, --test-notify exits 1).
- Token file: directory must be owned by root/current user and not
  group/world-writable; the token is read through one opened descriptor
  that is verified via /proc/self/fd (regular file, owner, mode 0600).
- Lock file opened for append (never truncated); the parent of
  HEALTH_STATE_DIR must not be group/world-writable.
- qm/pct config fallback runs with timeout 10.
- curl: -q first, --proto =https when a token is used, no --retry.
- json_escape escapes remaining control characters as \u00XX.
- --test-notify removes its temp body file on exit and on INT/TERM.
- _truncate keeps UTF-8 characters intact.
- Tests for each item where practical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 24d5812a2d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread proxmox-manager.sh
Comment on lines +2027 to +2030
qmstop | qmshutdown | vzstop | vzshutdown | qmsuspend | vzsuspend | qmdestroy | vzdestroy | \
qmigrate | vzmigrate | vzdump)
if ((lc_end[$k] == 0)) || [[ "${lc_status[$k]}" == "OK" || "${lc_status[$k]}" == WARNINGS* ]]; then
excused["$k"]=1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop completed backups from masking later guest crashes

When a VM has completed a normal per-guest vzdump and later crashes before another lifecycle task occurs, this branch still marks the VM as excused because the successful backup remains its latest lifecycle task. Consequently, even if the previous check recorded the VM as running, _check_process_states suppresses the unexpected-stop warning indefinitely; completed backups should only excuse a stop when correlated with that transition, while a currently running backup can remain an unconditional excuse.

Useful? React with 👍 / 👎.

Comment thread proxmox-manager.sh
# path between validation and use cannot leak another file; its directory must be trusted.
_read_ntfy_token() {
local fd info line=''
if [[ -L "$NTFY_TOKEN_FILE" ]] || ! _trusted_dir "$(dirname -- "$NTFY_TOKEN_FILE")"; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Verify every ancestor before reading the ntfy token

When the token's immediate directory is private but one of its ancestors is non-sticky and world-writable, another user can rename that directory between this check and the subsequent open, then replace it with a symlink to a root-owned directory. If the resulting same-named target is a root-owned 0600 file whose first line matches the token syntax, the descriptor checks accept it and transmit that secret as the bearer token. Checking only dirname therefore does not establish the stated no-swap guarantee; verify the complete path chain or open it through trusted directory descriptors with no-follow semantics.

Useful? React with 👍 / 👎.

_term_cols is always called as $(_term_cols), where stdout is a pipe,
so tput was never consulted and the width was fixed at 80. Also check
stderr for a terminal. Found while verifying --health on a real PVE 9.2
node at 60 columns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TimInTech
TimInTech merged commit 37a4a7e into main Sep 19, 2026
5 checks passed
@TimInTech
TimInTech deleted the feature/health-alerts branch September 19, 2026 17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant