#!/usr/bin/env bash # althing-notify-failure — report a systemd unit entering FAILED state to the # althing infra-ops inbox. # # WHY THIS EXISTS. An uptime check cannot see the dangerous failure on this box. # svos-dev measured it on 2026-09-22: a config change on 09-19 made svos.service # refuse to boot, the RUNNING process predated the change and kept serving, and # the service sat one restart away from dark for three days. Every uptime probe # was green and correct the whole time -- the thing was up. The signal that # would have fired is failed-START, not down. # # A count after that conversation: 13 running user units on nh3-dev, ZERO with # an OnFailure hook. Including althing-po-herald, whose silent failure cuts # infra-ops's own mail delivery -- a blind spot in the notification path that # every other alarm on this fleet depends on. # # WHY OnFailure IS THE RIGHT TRIGGER AND NOT NOISE. It does not fire on a clean # restart or a deliberate stop. svos-dev's four restarts and three deploys in # one day would have produced zero alerts. With Restart=on-failure a unit enters # failed state only after exhausting its start-limit burst, so a crash-loop # yields ONE message per episode, not one per attempt. set -uo pipefail UNIT="${1:-unknown.service}" HOST="$(hostname)" SPOOL="${HOME}/.local/state/althing-notify-failure" POSTBOX="${POSTBOX:-${HOME}/.local/bin/postbox}" RECIPIENT="${ALERT_RECIPIENT:-infra-ops}" # ⚠ NEVER report on ourselves. Without this, a notifier that fails while # reporting a failure would be reported by another notifier, and so on. The # template deliberately carries no OnFailure of its own either -- belt and # braces, because a notification loop is the one bug that pages you forever. case "$UNIT" in althing-notify-failure@*) exit 0 ;; esac state=$(systemctl --user show "$UNIT" -p ActiveState --value 2>/dev/null) sub=$(systemctl --user show "$UNIT" -p SubState --value 2>/dev/null) result=$(systemctl --user show "$UNIT" -p Result --value 2>/dev/null) code=$(systemctl --user show "$UNIT" -p ExecMainStatus --value 2>/dev/null) desc=$(systemctl --user show "$UNIT" -p Description --value 2>/dev/null) since=$(systemctl --user show "$UNIT" -p ExecMainExitTimestamp --value 2>/dev/null) body=$(cat </dev/null | sed 's/^/ /') Triage: systemctl --user status ${UNIT} journalctl --user -u ${UNIT} -n 100 --no-pager EOF ) # ---- duplicate suppression --------------------------------------------------- # # OnFailure fires on every transition INTO failed, not once per incident. # svos-dev measured five such transitions for one 2026-09-19 boot-gate refusal # on svos.service (StartLimitBurst=3, StartLimitIntervalSec=5min), and the # operator saw five messages for one outage. # # MEASURED HERE, because the claim deserved checking rather than adopting: a # simple crash-loop inside ONE start-limit window fires the notifier exactly # ONCE -- a unit with Restart=on-failure, burst 3, interval 30s produced 7 # journal failure lines and 1 notifier invocation. So the multiplier is not # universal; it needs retries that span windows, or an external restarter. # Both exist on this box, so the guard is cheap insurance rather than a fix for # something already proven here. # # KEYED ON THE CAUSE, NOT THE UNIT. A genuinely different failure inside the # window is a NEW FACT and must still page -- suppressing by unit name alone # would hide a second, worse failure behind the first. The fingerprint is the # unit plus its result, exit status, and the shape of its last error lines. # # SUPPRESSION IS LOGGED, NEVER SILENT. An alarm that quietly declines to fire is # indistinguishable from one that is broken, and this whole mechanism exists # because a thing that looked fine was not. COOLDOWN="${NOTIFY_COOLDOWN_SECONDS:-900}" mkdir -p "$SPOOL" fingerprint=$(printf '%s|%s|%s|%s' "$UNIT" "${result:-}" "${code:-}" \ "$(journalctl --user -u "$UNIT" -n 10 --no-pager -o cat 2>/dev/null \ | grep -iE 'error|fail|refus|cannot|denied' | head -5)" \ | sha256sum | cut -c1-16) guard="${SPOOL}/.cooldown-${fingerprint}" now=$(date +%s) if [ -f "$guard" ]; then last=$(cat "$guard" 2>/dev/null || echo 0) age=$(( now - last )) if [ "$age" -lt "$COOLDOWN" ]; then printf 'SUPPRESSED duplicate for %s (same cause %s, %ss into a %ss cooldown); not sending\n' \ "$UNIT" "$fingerprint" "$age" "$COOLDOWN" printf '%s suppressed %s age=%ss\n' "$(date -Is)" "$UNIT" "$age" \ >> "${SPOOL}/suppressed.log" exit 0 fi fi printf '%s' "$now" > "$guard" # Durable local record FIRST, so the signal survives a post-office outage. # postbox has no outbox: a send that cannot reach the post office is dropped, # and this alarm exists precisely for the cases nobody is watching. mkdir -p "$SPOOL" stamp=$(date -u +%Y%m%dT%H%M%SZ) printf '%s\n' "$body" > "${SPOOL}/${stamp}-${UNIT}.txt" if [ -x "$POSTBOX" ]; then if printf '%s\n' "$body" | ALTHING_HANDLE="${ALTHING_HANDLE:-infra-ops}" \ "$POSTBOX" send --to "$RECIPIENT" \ --subject "[systemd] ${UNIT} FAILED on ${HOST}" >/dev/null 2>&1; then printf 'delivered %s to %s\n' "$UNIT" "$RECIPIENT" else # ⚠ If althing-po-herald is the unit that failed, the message still # REACHES the post office (postbox talks to it directly; the herald only # delivers inbound pokes) -- it just will not be pushed into a live # session. It is stored and the next `postbox read` finds it. A memo to # the successor, which is the whole point. printf 'POSTBOX DELIVERY FAILED for %s; spooled at %s\n' "$UNIT" "$SPOOL" >&2 fi else printf 'postbox not executable at %s; spooled only\n' "$POSTBOX" >&2 fi # Always succeed. A notifier that exits non-zero is itself a failed unit, and # this one must never become the thing that needs reporting. exit 0