#!/bin/bash # Gere par Set-OPS (role serveur_backup). Ne pas editer a la main. # # Verifie les depots restic et POUSSE un resultat passif par noeud vers l'API Icinga. # # Pourquoi ici et pas sur le noeud source. Le noeud sait s'il a LANCE sa sauvegarde ; il # ne sait pas si elle est ARRIVEE. Le depot, lui, voit ce qui existe reellement — et c'est # la seule chose qui compte le jour d'une restauration. Le 2026-08-11, onze noeuds # lancaient chaque nuit une sauvegarde qui n'emportait RIEN : une unite verte sur un depot # vide. Trois criteres, donc, et pas un seul : # # 1. l'instantane EXISTE (sinon : la sauvegarde n'arrive pas) # 2. il est RECENT (sinon : elle a cesse d'arriver) # 3. il n'est pas VIDE (sinon : elle arrive mais ne porte rien) # # Le `ttl` envoye a Icinga fait la fraicheur : si ce script cesse de tourner, Icinga passe # tout seul en « expire ». C'est le SILENCE qui alerte — c'est lui qui n'a alerte personne. set -uo pipefail RACINE="{{ serveur_backup_racine }}" API="https://{{ serveur_backup_icinga_hote }}.{{ domaine_interne }}:5665" TTL={{ serveur_backup_ttl_icinga }} AGE_WARN={{ serveur_backup_age_warn_h }} AGE_CRIT={{ serveur_backup_age_crit_h }} export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass" # L'API Icinga veut du JSON : un envoi `--data-urlencode` est refuse en « Bad Request » # et le corps est perdu (mesure le 2026-08-11). On construit donc la charge avec python3, # qui echappe correctement le texte du diagnostic. ECHECS=0 rapporter() { # $1=noeud $2=code $3=texte local charge reponse charge=$(python3 -c 'import json,sys; print(json.dumps({ "type": "Service", "service": sys.argv[1], "exit_status": int(sys.argv[2]), "plugin_output": sys.argv[3], "ttl": int(sys.argv[4])}))' \ "${HOTE_DEPOT}!sauvegarde: $1" "$2" "$3" "${TTL}") reponse=$(curl -sS -k --max-time 20 \ -u "{{ serveur_backup_icinga_utilisateur }}:${MOTDEPASSE}" \ -H 'Accept: application/json' -H 'Content-Type: application/json' \ -X POST "${API}/v1/actions/process-check-result" -d "${charge}" 2>&1) # Ne PAS avaler l'echec : un rapporteur muet recreerait exactement le defaut qu'on # corrige. Le `ttl` reste le filet — si ce script cesse d'aboutir, Icinga perime les # services de lui-meme et c'est le silence qui alerte. if ! printf '%s' "${reponse}" | grep -q '"code": *200'; then echo "ECHEC du rapport Icinga pour $1 : ${reponse}" >&2 ECHECS=$((ECHECS + 1)) fi } MOTDEPASSE="$(cat /etc/setops/icinga-api.pass)" HOTE_DEPOT="{{ inventory_hostname }}" maintenant=$(date +%s) for noeud in {{ serveur_backup_noeuds_attendus | sort | join(' ') }}; do depot="${RACINE}/${noeud}" if [[ ! -d "${depot}" ]]; then rapporter "${noeud}" 2 "AUCUN DEPOT : ${depot} n'existe pas — ce noeud n'a jamais depose." continue fi json=$(restic -r "${depot}" snapshots --latest 1 --json 2>/dev/null) horodatage=$(printf '%s' "${json}" | python3 -c \ 'import sys,json;d=json.load(sys.stdin);print(d[0]["time"] if d else "")' 2>/dev/null) if [[ -z "${horodatage}" ]]; then rapporter "${noeud}" 2 "AUCUN INSTANTANE dans ${depot} — depot present mais vide." continue fi # restic ecrit des fractions de seconde a precision variable : on tronque avant %z. secondes=$(date -d "$(printf '%s' "${horodatage}" | sed -E 's/\.[0-9]+//')" +%s 2>/dev/null || echo 0) age_h=$(( (maintenant - secondes) / 3600 )) taille=$(restic -r "${depot}" stats latest --mode raw-data --json 2>/dev/null | python3 -c \ 'import sys,json;print(json.load(sys.stdin).get("total_size",0))' 2>/dev/null || echo 0) # « Vide » se mesure en FICHIERS, pas en octets. Un seuil en octets est un mauvais # critere : l'export LDIF d'un annuaire a un compte pese 2,3 Ko et se ferait signaler a # tort (mesure le 2026-08-11). Un instantane qui ne contient AUCUN fichier — seulement # les repertoires traverses — n'emporte rien, et c'est exact quelle que soit la taille. fichiers=$(restic -r "${depot}" ls latest --json 2>/dev/null | python3 -c \ 'import sys,json n=0 for l in sys.stdin: try: d=json.loads(l) except ValueError: continue if d.get("struct_type")=="node" and d.get("type")=="file": n+=1 print(n)' 2>/dev/null || echo 0) if (( fichiers == 0 )); then # AVERTISSEMENT et non CRITIQUE : la machine ne peut pas distinguer « les donnees ont # disparu » de « il n'y en a pas encore » (un /var/vmail sans courriel, un /srv/web # sans webapp). C'est a un humain de trancher — mais il doit le VOIR. rapporter "${noeud}" 1 \ "N'EMPORTE RIEN : instantane sans aucun fichier (${taille} octets). Legitime si ce noeud n'a pas encore de donnees — a confirmer." elif (( age_h >= AGE_CRIT )); then rapporter "${noeud}" 2 "PERIME : dernier instantane il y a ${age_h} h (seuil ${AGE_CRIT} h), ${fichiers} fichier(s)." elif (( age_h >= AGE_WARN )); then rapporter "${noeud}" 1 "EN RETARD : dernier instantane il y a ${age_h} h (seuil ${AGE_WARN} h), ${fichiers} fichier(s)." else rapporter "${noeud}" 0 "OK : instantane il y a ${age_h} h, ${fichiers} fichier(s), ${taille} octets." fi done if (( ECHECS > 0 )); then echo "${ECHECS} rapport(s) non deposes : Icinga ne sait donc pas ou en sont ces sauvegardes." >&2 exit 1 fi