#!/usr/bin/env bash
#
# backup-waechter – Totmannschalter für Sicherungsjobs
#
# Meldet per E-Mail, wenn eine Sicherung fehlschlägt, ausbleibt oder eine
# auffällige Datenmenge überträgt. Erfolgreiche Läufe lösen nichts aus.
#
# Der Zweck ist nicht, möglichst viel zu melden, sondern möglichst wenig:
# Ein Monitoring, dessen Mails ungelesen weggeräumt werden, ist wirkungslos
# und wiegt zusätzlich in Sicherheit. Deshalb gilt hier:
#
#   * Kein Erfolg wird einzeln gemeldet.
#   * Gemeldet wird der WECHSEL der Lage – nicht jeder Prüflauf.
#   * Solange ein Problem besteht, erinnert der Wächter höchstens einmal
#     täglich, nicht stündlich.
#   * Die Entwarnung wird gemeldet. Sonst weiß niemand, ob es vorbei ist.
#   * Einmal pro Woche kommt eine Übersicht – auch wenn alles in Ordnung
#     ist. Ohne sie wäre Schweigen nicht von einem toten Wächter zu
#     unterscheiden.
#
# Erkannt werden beide Fehlerklassen, auch die stille: Ein Job, der gar
# nicht mehr läuft, meldet sich nicht – und genau das Ausbleiben schlägt
# hier Alarm.
#
# ---------------------------------------------------------------------
# Verwendung
# ---------------------------------------------------------------------
#
#   backup-waechter melden --job <name> --erwartung <stunden> \
#                          [--karenz <stunden>] -- <befehl> [argumente...]
#
#       Führt den Befehl aus, misst die Laufzeit und merkt sich das
#       Ergebnis. Der Rückgabewert des Befehls wird durchgereicht.
#
#   backup-waechter melden --job <name> --erwartung <stunden> \
#                          --ergebnis <code> [--menge <bytes>]
#
#       Für Jobs, die sich nicht umschließen lassen (etwa PBS): Ergebnis
#       von Hand eintragen.
#
#   backup-waechter pruefen [--probelauf]
#
#       Bewertet alle bekannten Jobs und verschickt gegebenenfalls eine
#       Mail. Gehört in einen stündlichen Timer. --probelauf schreibt die
#       Mail auf die Standardausgabe, statt sie zu versenden, und ändert
#       nichts am gespeicherten Zustand.
#
#   backup-waechter zeigen
#
#       Übersicht aller Jobs für die monatliche Sichtkontrolle.
#
# ---------------------------------------------------------------------
# Einrichtung
# ---------------------------------------------------------------------
#
#   install -m 0755 backup-waechter.sh /usr/local/sbin/backup-waechter
#   install -d -m 0750 /var/lib/backup-waechter
#
# Im Sicherungsskript:
#
#   backup-waechter melden --job daten --erwartung 24 -- \
#       restic backup /daten
#
# Prüftimer (/etc/systemd/system/backup-waechter.timer):
#
#   [Timer]
#   OnCalendar=hourly
#   Persistent=true
#
# ---------------------------------------------------------------------

set -euo pipefail

# ---------------------------------------------------------------------
# Vorgaben. Alles davon lässt sich in /etc/backup-waechter.conf
# überschreiben – dieselbe Schreibweise, eine Zuweisung pro Zeile.
# ---------------------------------------------------------------------

SPOOL=/var/lib/backup-waechter
EMPFAENGER=root
ABSENDER=""

# MAIL_BEFEHL wird nur in Wörter zerlegt, NICHT von einer Shell ausgewertet.
# Pipes, Umleitungen, Ersetzungen und Semikola darin wirken deshalb nicht –
# sie landen als Argument beim Programm, und die Mail verschwindet still.
#
#   richtig:  MAIL_BEFEHL="/usr/sbin/sendmail -t -oi"
#   falsch:   MAIL_BEFEHL="cat >> /tmp/mail.txt"     (">>" wird ein Argument)
#
# Wer eine Pipeline braucht, legt sie in ein eigenes Skript und trägt dessen
# Pfad ein. `versende` weist solche Werte ausdrücklich ab, statt sie ins Leere
# laufen zu lassen: Ein Wächter, dessen Mails niemand bekommt, ist genau der
# stille Ausfall, gegen den dieses Werkzeug gebaut ist.
MAIL_BEFEHL="/usr/sbin/sendmail -t -oi"

# Wie oft an ein bestehendes Problem erinnert wird. 0 schaltet die
# Erinnerung ab – dann wird jedes Problem genau einmal gemeldet.
WIEDERHOLUNG_STUNDEN=24

# Wochenübersicht: Wochentag (1=Montag … 7=Sonntag, 0=aus) und Stunde.
WOCHENBERICHT_TAG=1
WOCHENBERICHT_STUNDE=8

# Ab welchem Vielfachen des üblichen Werts eine Übertragungsmenge als
# auffällig gilt. Greift erst, wenn genug Vergleichswerte vorliegen.
MENGE_FAKTOR=5
MENGE_MINDESTWERTE=5

KONFIG=${BACKUP_WAECHTER_KONFIG:-/etc/backup-waechter.conf}
# shellcheck source=/dev/null
[ -r "$KONFIG" ] && . "$KONFIG"

RECHNER=$(hostname -f 2>/dev/null || hostname)
[ -n "$ABSENDER" ] || ABSENDER="backup-waechter@${RECHNER}"

PROBELAUF=0

# ---------------------------------------------------------------------
# Kleinkram
# ---------------------------------------------------------------------

fehler() {
  echo "backup-waechter: $*" >&2
  exit 2
}

ist_zahl() {
  case "${1:-}" in
    '' | *[!0-9]*) return 1 ;;
    *) return 0 ;;
  esac
}

# Zustandsdateien werden gelesen, geändert und zurückgeschrieben. Ohne
# Sperre überschreiben zwei gleichzeitige Läufe einander: Beide schreiben
# in dieselbe Zwischendatei, das zweite `mv` findet nichts mehr vor und
# scheitert – der Lauf bricht ab und sein Ergebnis fehlt in der
# Zustandsdatei. Ein Sicherungslauf, der sich für erfolgreich hält und
# nirgends vermerkt ist, wäre danach überfällig, ohne es zu sein.
#
# Die Sperre wird erst NACH dem Sicherungsbefehl genommen, nicht davor.
# Sonst warteten parallele Sicherungen aufeinander.
sperre_nehmen() {
  command -v flock >/dev/null ||
    fehler "flock fehlt (Paket util-linux). Ohne Sperre wäre der Zustand nicht verlässlich."
  exec 9>"${SPOOL}/.sperre"
  flock 9
}

# Jobnamen begrenzen: Der Name wird zu einem Dateinamen. Ohne diese
# Prüfung liesse sich über "../" aus dem Spool-Verzeichnis herauslaufen.
pruefe_jobname() {
  case "$1" in
    '' | *[!a-zA-Z0-9_-]*) fehler "Ungültiger Jobname: '$1'" ;;
  esac
}

zeitpunkt() {
  [ "$1" -eq 0 ] && { echo "nie"; return; }
  date -d "@$1" '+%d.%m.%Y %H:%M' 2>/dev/null || echo "$1"
}

# Sekunden in eine Angabe, die man vorlesen kann.
dauer() {
  local s=$1
  if [ "$s" -lt 3600 ]; then
    echo "$((s / 60)) Minuten"
  elif [ "$s" -lt 172800 ]; then
    echo "$((s / 3600)) Stunden"
  else
    echo "$((s / 86400)) Tagen"
  fi
}

menschenlesbar() {
  local b=$1
  if [ "$b" -ge 1073741824 ]; then
    echo "$((b / 1073741824)) GiB"
  elif [ "$b" -ge 1048576 ]; then
    echo "$((b / 1048576)) MiB"
  else
    echo "$((b / 1024)) KiB"
  fi
}

# Zustandsdatei einlesen. Bewusst zeilenweise und nicht per `source`:
# Das Spool-Verzeichnis wäre sonst ein Weg, Befehle auszuführen.
lies_zustand() {
  z_letzter_lauf=0
  z_letzter_erfolg=0
  z_ergebnis=0
  z_dauer=0
  z_erwartung=24
  z_karenz=6
  z_lage=ok
  z_seit=0
  z_gemeldet=0

  # Zwei Flaggen, nicht eine: `z_beschaedigt` treibt die Bewertung und
  # überlebt in der Zustandsdatei; `z_neu_beschaedigt` sagt, dass der
  # unbrauchbare Inhalt JETZT vorliegt und noch gesichert werden kann.
  z_beschaedigt=0
  z_neu_beschaedigt=0

  [ -r "$1" ] || return 0

  # Jeder Zahlenwert wird geprüft, bevor er übernommen wird.
  #
  # Ohne diese Prüfung landet ein unsinniger Wert in der Bewertung, und
  # `$(( ))` versucht ihn als Variablennamen zu lesen – unter `set -u`
  # bricht damit der ganze Prüflauf ab, für alle Jobs, bei jedem
  # stündlichen Versuch aufs Neue. Statt eines lauten Fehlers hätte man
  # einen stillstehenden Wächter.
  local schluessel wert
  while IFS='=' read -r schluessel wert; do
    case "$schluessel" in
      lage)
        case "$wert" in
          ok | fehler | ueberfaellig | auffaellig) z_lage=$wert ;;
          *) z_beschaedigt=1; z_neu_beschaedigt=1 ;;
        esac
        continue
        ;;
    esac

    if ! ist_zahl "$wert"; then
      z_beschaedigt=1
      z_neu_beschaedigt=1
      continue
    fi

    case "$schluessel" in
      # Eine einmal festgestellte Beschädigung bleibt stehen, bis der Job
      # das nächste Mal etwas Gültiges meldet. Ohne diesen Schlüssel sähe
      # der nächste Prüflauf eine frisch geschriebene Datei mit Vorgaben,
      # bewertete den Job als bloß "überfällig" – und verschickte für
      # denselben Vorfall eine Stunde später eine zweite Mail.
      beschaedigt) [ "$wert" -eq 0 ] || z_beschaedigt=1 ;;
      letzter_lauf) z_letzter_lauf=$wert ;;
      letzter_erfolg) z_letzter_erfolg=$wert ;;
      ergebnis) z_ergebnis=$wert ;;
      dauer) z_dauer=$wert ;;
      erwartung) z_erwartung=$wert ;;
      karenz) z_karenz=$wert ;;
      seit) z_seit=$wert ;;
      gemeldet) z_gemeldet=$wert ;;
    esac
  done <"$1"
}

# Erst in eine Nachbardatei schreiben, dann umbenennen. Ein Absturz
# mittendrin hinterlässt sonst eine halbe Zustandsdatei – und der Wächter
# hielte den Job für unbekannt.
#
# Der Zwischenname trägt die Prozessnummer. Zusammen mit der Sperre ist
# das doppelt gesichert; ohne beides überschreiben sich gleichzeitige
# Läufe gegenseitig die Zwischendatei.
schreibe_zustand() {
  local datei=$1
  shift
  local zwischen="${datei}.$$"
  printf '%s\n' "$@" >"$zwischen"
  mv "$zwischen" "$datei"
}

# ---------------------------------------------------------------------
# melden
# ---------------------------------------------------------------------

unterbefehl_melden() {
  local job='' erwartung='' karenz=6 ergebnis='' menge='' befehl=0

  while [ $# -gt 0 ]; do
    case "$1" in
      --job) job=${2:?}; shift 2 ;;
      --erwartung) erwartung=${2:?}; shift 2 ;;
      --karenz) karenz=${2:?}; shift 2 ;;
      --ergebnis) ergebnis=${2:?}; shift 2 ;;
      --menge) menge=${2:?}; shift 2 ;;
      --) shift; befehl=1; break ;;
      *) fehler "Unbekannte Option: $1" ;;
    esac
  done

  [ -n "$job" ] || fehler "--job fehlt"
  [ -n "$erwartung" ] || fehler "--erwartung fehlt (Stunden zwischen zwei Läufen)"
  pruefe_jobname "$job"
  [ -d "$SPOOL" ] || fehler "Spool-Verzeichnis $SPOOL fehlt"

  # Zahlen prüfen, bevor sie in die Zustandsdatei gelangen. Ein defekter
  # Aufrufer soll hier auffallen und nicht später den Prüflauf lahmlegen.
  ist_zahl "$erwartung" || fehler "--erwartung erwartet eine Zahl, nicht '$erwartung'"
  ist_zahl "$karenz" || fehler "--karenz erwartet eine Zahl, nicht '$karenz'"
  [ -z "$ergebnis" ] || ist_zahl "$ergebnis" ||
    fehler "--ergebnis erwartet einen Rückgabewert als Zahl, nicht '$ergebnis'"
  [ -z "$menge" ] || ist_zahl "$menge" ||
    fehler "--menge erwartet Bytes als Zahl, nicht '$menge'"

  local start ende laufzeit
  start=$(date +%s)

  if [ "$befehl" -eq 1 ]; then
    [ $# -gt 0 ] || fehler "Nach -- fehlt der Befehl"

    # set +e um den Aufruf: Ein fehlgeschlagener Sicherungslauf ist der
    # Normalfall, den dieses Werkzeug festhalten soll – er darf den
    # Wächter nicht selbst beenden, bevor er etwas notiert hat.
    set +e
    "$@"
    ergebnis=$?
    set -e
  else
    [ -n "$ergebnis" ] || fehler "Ohne -- wird --ergebnis gebraucht"
  fi

  ende=$(date +%s)
  laufzeit=$((ende - start))

  # Ab hier wird der gemeinsame Zustand angefasst – erst jetzt sperren,
  # damit parallele Sicherungen nicht aufeinander warten.
  sperre_nehmen

  local datei="${SPOOL}/${job}.zustand"
  lies_zustand "$datei"

  local erfolg=$z_letzter_erfolg
  [ "$ergebnis" -eq 0 ] && erfolg=$ende

  schreibe_zustand "$datei" \
    "letzter_lauf=$ende" \
    "letzter_erfolg=$erfolg" \
    "ergebnis=$ergebnis" \
    "dauer=$laufzeit" \
    "erwartung=$erwartung" \
    "karenz=$karenz" \
    "lage=$z_lage" \
    "seit=$z_seit" \
    "gemeldet=$z_gemeldet"

  # Übertragungsmenge fortschreiben, wenn angegeben. Nur erfolgreiche
  # Läufe – die Menge eines abgebrochenen Laufs sagt nichts aus und würde
  # den Vergleichswert verderben.
  if [ -n "$menge" ] && [ "$ergebnis" -eq 0 ]; then
    local mengen="${SPOOL}/${job}.mengen"
    printf '%s\n' "$menge" >>"$mengen"
    tail -n 20 "$mengen" >"${mengen}.$$"
    mv "${mengen}.$$" "$mengen"
  fi

  # Laufzeiten mitschreiben – nur bei umschlossenem Befehl, sonst wäre die
  # gemessene Zeit die des Meldeaufrufs und nicht die der Sicherung.
  #
  # Wozu: Die Karenzzeit ist die einzige Einstellung, die man nicht raten
  # sollte. Sie muss die normale Schwankung überdecken, und die normale
  # Schwankung steht hier. `zeigen` gibt die längste beobachtete Laufzeit
  # aus; daraus lässt sich die Karenz ableiten, statt sie zu setzen.
  if [ "$befehl" -eq 1 ] && [ "$ergebnis" -eq 0 ]; then
    local dauern="${SPOOL}/${job}.dauern"
    printf '%s\n' "$laufzeit" >>"$dauern"
    tail -n 20 "$dauern" >"${dauern}.$$"
    mv "${dauern}.$$" "$dauern"
  fi

  return "$ergebnis"
}

# ---------------------------------------------------------------------
# Bewertung
# ---------------------------------------------------------------------

# Mittlerer Wert der bisherigen Übertragungsmengen. Median und nicht
# Durchschnitt: Ein einzelner Ausreißer soll den Vergleichswert nicht
# verschieben, sonst gewöhnt sich der Wächter an den Ausreißer.
median_menge() {
  local datei=$1 anzahl
  [ -r "$datei" ] || return 1
  # Nur Zahlen. Eine beschädigte Zeile darf die Rechnung nicht sprengen.
  anzahl=$(grep -cE '^[0-9]+$' "$datei" || true)
  [ "$anzahl" -ge "$MENGE_MINDESTWERTE" ] || return 1
  grep -E '^[0-9]+$' "$datei" | sort -n | sed -n "$(((anzahl + 1) / 2))p"
}

letzte_menge() {
  grep -E '^[0-9]+$' "$1" | tail -n 1
}

# Längste beobachtete Laufzeit in Sekunden, oder leer.
laengste_dauer() {
  [ -r "$1" ] || return 1
  grep -E '^[0-9]+$' "$1" | sort -n | tail -n 1
}

# Ergebnis in $lage und $grund.
bewerte() {
  local jetzt=$1 job=$2

  # Eine unlesbare Zustandsdatei ist selbst ein Alarm. Sie stillschweigend
  # auf Vorgabewerte zurückzusetzen hiesse, einen kaputten Wächter für
  # einen gesunden zu halten.
  if [ "$z_beschaedigt" -eq 1 ]; then
    lage=fehler
    grund="Die Zustandsdatei ${job}.zustand enthält unbrauchbare Werte. Der Wächter kann für diesen Job nichts aussagen, bis er das nächste Mal etwas Gültiges meldet."
    if [ -r "${SPOOL}/${job}.zustand.beschaedigt" ]; then
      grund="${grund} Der ursprüngliche Inhalt liegt zur Ansicht in ${SPOOL}/${job}.zustand.beschaedigt."
    fi
    # Ausdrücklich 0: Ein nacktes `return` gäbe den Status des letzten
    # Befehls zurück – hier der fehlgeschlagene Test – und beendete unter
    # `set -e` den ganzen Prüflauf für alle Jobs.
    return 0
  fi

  if [ "$z_ergebnis" -ne 0 ]; then
    lage=fehler
    grund="Der letzte Lauf am $(zeitpunkt "$z_letzter_lauf") ist mit Rückgabewert ${z_ergebnis} gescheitert."
    return
  fi

  local grenze=$(((z_erwartung + z_karenz) * 3600))
  if [ $((jetzt - z_letzter_erfolg)) -gt "$grenze" ]; then
    lage=ueberfaellig
    if [ "$z_letzter_erfolg" -eq 0 ]; then
      grund="Es liegt noch kein erfolgreicher Lauf vor."
    else
      grund="Seit $(dauer $((jetzt - z_letzter_erfolg))) kein erfolgreicher Lauf. Erwartet wird alle ${z_erwartung} Stunden, Karenz ${z_karenz} Stunden."
    fi
    return
  fi

  local mitte menge
  if mitte=$(median_menge "${SPOOL}/${job}.mengen"); then
    menge=$(letzte_menge "${SPOOL}/${job}.mengen")
    if [ "$menge" -gt $((mitte * MENGE_FAKTOR)) ]; then
      lage=auffaellig
      grund="Der letzte Lauf übertrug $(menschenlesbar "$menge") statt der üblichen $(menschenlesbar "$mitte"). Das kann einen harmlosen Grund haben – oder es sind verschlüsselte Dateien, die sich nicht mehr deduplizieren lassen."
      return
    fi
    if [ $((menge * MENGE_FAKTOR)) -lt "$mitte" ]; then
      lage=auffaellig
      grund="Der letzte Lauf übertrug nur $(menschenlesbar "$menge") statt der üblichen $(menschenlesbar "$mitte"). Häufigste Ursache: Ein Verzeichnis wird nicht mehr erfasst."
      return
    fi
  fi

  lage=ok
  grund=""
}

# ---------------------------------------------------------------------
# pruefen
# ---------------------------------------------------------------------

# Gibt 1 zurück, wenn die Zustellung gescheitert ist. Der Aufrufer darf
# in dem Fall nichts als „gemeldet" vermerken.
versende() {
  local betreff=$1 rumpf=$2

  # MAIL_BEFEHL wird nur in Wörter zerlegt (siehe Vorgaben oben). Steht ein
  # Shell-Zeichen darin, bekommt das Mailprogramm es als Argument: sendmail
  # sähe ">>" als Empfängeradresse, die Mail ginge nirgendwohin, und der
  # Rückgabewert wäre je nach Programm sogar 0. Das ist die schlimmste aller
  # Varianten – ein Wächter, der Vollzug meldet und nichts verschickt hat.
  # Deshalb hier abbrechen. Die Prüfung steht bewusst VOR dem Probelauf,
  # damit `pruefen --probelauf` den Konfigurationsfehler zeigt, bevor sich
  # jemand auf die Zustellung verlässt.
  case "$MAIL_BEFEHL" in
    *'|'* | *'>'* | *'<'* | *'&'* | *';'* | *'$'* | *'`'*)
      echo "backup-waechter: MAIL_BEFEHL enthält Shell-Zeichen, wird aber nicht von einer Shell ausgewertet:" >&2
      echo "                 '$MAIL_BEFEHL'" >&2
      echo "                 Erlaubt ist ein Programm mit Argumenten. Eine Pipeline gehört in ein eigenes Skript, dessen Pfad hier steht." >&2
      return 1
      ;;
  esac

  if [ "$PROBELAUF" -eq 1 ]; then
    printf -- '--- Betreff: %s\n%s\n---\n' "$betreff" "$rumpf"
    return 0
  fi

  {
    printf 'From: %s\n' "$ABSENDER"
    printf 'To: %s\n' "$EMPFAENGER"
    printf 'Subject: %s\n' "$betreff"
    printf 'Content-Type: text/plain; charset=UTF-8\n'
    printf 'Auto-Submitted: auto-generated\n'
    printf '\n%s\n' "$rumpf"
  } | $MAIL_BEFEHL || {
    echo "backup-waechter: Zustellung fehlgeschlagen (MAIL_BEFEHL='$MAIL_BEFEHL')" >&2
    return 1
  }
}

unterbefehl_pruefen() {
  [ "${1:-}" = "--probelauf" ] && PROBELAUF=1

  [ -d "$SPOOL" ] || fehler "Spool-Verzeichnis $SPOOL fehlt"
  sperre_nehmen

  local jetzt
  jetzt=$(date +%s)

  local meldungen="" anzahl_probleme=0 uebersicht=""
  local datei job lage grund melden

  # Was geschrieben werden soll, wird gesammelt und erst nach erfolgreicher
  # Zustellung festgeschrieben.
  #
  # Die frühere Fassung vermerkte „gemeldet" schon in der Schleife und
  # verschickte danach. Scheiterte der Versand – kaputter MAIL_BEFEHL, voller
  # Mailspool –, galt die Störung als mitgeteilt, ohne dass je eine Mail
  # ankam. Der nächste Prüflauf sah eine unveränderte Lage und schwieg
  # weitere 24 Stunden. Genau das ist der stille Ausfall, gegen den dieses
  # Werkzeug gebaut ist.
  local -a offen_datei=() offen_inhalt=()

  for datei in "$SPOOL"/*.zustand; do
    [ -e "$datei" ] || continue
    job=$(basename "$datei" .zustand)

    lies_zustand "$datei"

    # Den unbrauchbaren Inhalt beiseitelegen, BEVOR er weiter unten von der
    # Rückschreibung überschrieben wird. Ohne diesen Schritt meldet der
    # Wächter einen Defekt und vernichtet im selben Lauf das Einzige, woran
    # sich der Defekt untersuchen ließe. Nur einmal je Vorfall: Ist die
    # Beschädigung erst aus dem Schlüssel `beschaedigt` bekannt, steht der
    # Inhalt längst in der Kopie.
    if [ "$z_neu_beschaedigt" -eq 1 ] && [ "$PROBELAUF" -eq 0 ]; then
      cp -- "$datei" "${datei}.beschaedigt" || true
    fi

    bewerte "$jetzt" "$job"

    uebersicht="${uebersicht}
  ${job}: ${lage}, letzter Erfolg $(zeitpunkt "$z_letzter_erfolg")"

    [ "$lage" != ok ] && anzahl_probleme=$((anzahl_probleme + 1))

    # Gemeldet wird nur, wenn sich die Lage geändert hat oder die
    # Erinnerungsfrist abgelaufen ist. Ohne diese Bedingung schriebe der
    # Wächter bei stündlichem Timer 24 Mails am Tag – und wäre nach einer
    # Woche abgeschaltet.
    melden=0
    if [ "$lage" != "$z_lage" ]; then
      melden=1
      z_seit=$jetzt
    elif [ "$lage" != ok ] && [ "$WIEDERHOLUNG_STUNDEN" -gt 0 ] &&
      [ $((jetzt - z_gemeldet)) -ge $((WIEDERHOLUNG_STUNDEN * 3600)) ]; then
      melden=1
    fi

    [ "$melden" -eq 1 ] || continue

    if [ "$lage" = ok ]; then
      meldungen="${meldungen}
[Entwarnung] ${job}
  Wieder in Ordnung. Letzter erfolgreicher Lauf: $(zeitpunkt "$z_letzter_erfolg").
  Gestört seit $(zeitpunkt "$z_seit").
"
    else
      meldungen="${meldungen}
[${lage}] ${job}
  ${grund}
  Besteht seit $(zeitpunkt "$z_seit").
"
    fi

    offen_datei+=("$datei")
    offen_inhalt+=("letzter_lauf=$z_letzter_lauf
letzter_erfolg=$z_letzter_erfolg
ergebnis=$z_ergebnis
dauer=$z_dauer
erwartung=$z_erwartung
karenz=$z_karenz
lage=$lage
seit=$z_seit
gemeldet=$jetzt
beschaedigt=$z_beschaedigt")
  done

  local stoerung=0

  if [ -n "$meldungen" ]; then
    local betreff
    if [ "$anzahl_probleme" -eq 0 ]; then
      betreff="Sicherung ${RECHNER}: wieder in Ordnung"
    else
      betreff="Sicherung ${RECHNER}: ${anzahl_probleme} Job(s) gestört"
    fi

    if versende "$betreff" "Geänderte Lage auf ${RECHNER}:
${meldungen}
Stand aller Jobs:${uebersicht}"; then
      if [ "$PROBELAUF" -eq 0 ]; then
        local i
        for i in "${!offen_datei[@]}"; do
          schreibe_zustand "${offen_datei[$i]}" "${offen_inhalt[$i]}"
        done
      fi
    else
      # Nichts festschreiben. Der nächste Lauf sieht dieselbe Änderung und
      # versucht es erneut.
      stoerung=1
    fi
  fi

  wochenbericht "$jetzt" "$anzahl_probleme" "$uebersicht" || stoerung=1

  # Gestörte Jobs sind kein Fehler des Wächters – dafür ist die Mail da,
  # und ein dauerhaft roter Dienst würde niemanden mehr erreichen.
  # Rot wird es nur, wenn die Überwachung selbst nicht arbeiten konnte.
  return "$stoerung"
}

# Eine Übersicht pro Woche, auch wenn nichts ist.
#
# Ohne sie wäre Schweigen zweideutig: Es könnte heißen „alles in Ordnung"
# oder „der Wächter läuft nicht mehr". Genau diese Zweideutigkeit ist der
# Fehler, gegen den das ganze Werkzeug gebaut ist – sie darf nicht an
# seiner eigenen Wurzel wieder auftauchen.
wochenbericht() {
  local jetzt=$1 probleme=$2 uebersicht=$3

  [ "$WOCHENBERICHT_TAG" -eq 0 ] && return 0
  [ "$(date -d "@$jetzt" +%u)" -eq "$WOCHENBERICHT_TAG" ] || return 0
  [ "$(date -d "@$jetzt" +%-H)" -eq "$WOCHENBERICHT_STUNDE" ] || return 0

  # Nur einmal je Kalenderwoche, auch wenn der Timer in derselben Stunde
  # zweimal anläuft.
  local marke="${SPOOL}/.wochenbericht" woche
  woche=$(date -d "@$jetzt" +%G-%V)
  [ -r "$marke" ] && [ "$(cat "$marke")" = "$woche" ] && return 0

  local lage="alles in Ordnung"
  [ "$probleme" -gt 0 ] && lage="${probleme} Job(s) gestört"

  # Auch hier: Die Marke wird erst gesetzt, wenn die Übersicht draußen ist.
  # Sonst fiele genau die Mail aus, die belegen soll, dass der Wächter lebt.
  versende "Sicherung ${RECHNER}: Wochenübersicht – ${lage}" \
    "Wochenübersicht der Sicherungsjobs auf ${RECHNER}.

Diese Mail kommt einmal pro Woche, auch wenn nichts zu tun ist. Sie ist
der Nachweis, dass die Überwachung selbst noch läuft – bleibt sie aus,
stimmt etwas mit dem Wächter nicht.
${uebersicht}" || return 1

  [ "$PROBELAUF" -eq 0 ] && printf '%s\n' "$woche" >"$marke"
  return 0
}

# ---------------------------------------------------------------------
# zeigen
# ---------------------------------------------------------------------

unterbefehl_zeigen() {
  [ -d "$SPOOL" ] || fehler "Spool-Verzeichnis $SPOOL fehlt"

  local jetzt datei job lage grund spitze karenz
  jetzt=$(date +%s)

  # Die vorletzte Spalte ist die Grundlage für die Karenzzeit: Sie muss die
  # normale Schwankung überdecken. Wer sie kennt, muss die Karenz nicht
  # raten – siehe „Die Karenzzeit bestimmen" im zugehörigen Artikel.
  printf '%-16s %-12s %-18s %-10s %-8s %s\n' \
    JOB LAGE 'LETZTER ERFOLG' 'MAX LAUF' KARENZ ANMERKUNG
  for datei in "$SPOOL"/*.zustand; do
    [ -e "$datei" ] || continue
    job=$(basename "$datei" .zustand)
    lies_zustand "$datei"
    bewerte "$jetzt" "$job"

    # Bindestrich statt Gedankenstrich: printf zählt für die Spaltenbreite
    # Bytes, nicht Zeichen. Ein mehrbyteiges Zeichen verschiebt die Tabelle.
    if spitze=$(laengste_dauer "${SPOOL}/${job}.dauern"); then
      if [ "$spitze" -lt 60 ]; then
        spitze="<1 min"
      else
        spitze="$((spitze / 60)) min"
      fi
    else
      spitze="-"
    fi
    karenz="${z_karenz} h"

    printf '%-16s %-12s %-18s %-10s %-8s %s\n' \
      "$job" "$lage" "$(zeitpunkt "$z_letzter_erfolg")" \
      "$spitze" "$karenz" "$grund"
  done
}

# ---------------------------------------------------------------------

case "${1:-}" in
  melden) shift; unterbefehl_melden "$@" ;;
  pruefen) shift; unterbefehl_pruefen "$@" ;;
  zeigen) shift; unterbefehl_zeigen "$@" ;;
  *)
    fehler "Aufruf: backup-waechter {melden|pruefen|zeigen} ...
Siehe Kopf dieser Datei."
    ;;
esac
