diff --git a/roles/client_backup/defaults/main.yml b/roles/client_backup/defaults/main.yml index 331f943..b9313c0 100644 --- a/roles/client_backup/defaults/main.yml +++ b/roles/client_backup/defaults/main.yml @@ -120,3 +120,11 @@ client_backup_verification_horaire: "*-*-* 01/4:00:00" client_backup_ttl_icinga: 21600 client_backup_age_warn_h: 26 client_backup_age_crit_h: 50 + +# LE CONTROLE DE RESTAURATION (2026-09-28) — voir `templates/verifier-restauration.sh.j2`. +# Hebdomadaire : il relit une part du depot et restaure le dernier instantane pour de vrai. +client_backup_restauration_horaire: "Sun *-*-* 04:00:00" +client_backup_restauration_part: "10%" +# Huit jours : une semaine entre deux controles, plus une journee de marge. Au-dela sans +# nouvelle, Icinga passe le service au rouge (voir `serveur_icinga_fraicheur_restauration`). +client_backup_ttl_restauration: 691200 diff --git a/roles/client_backup/tasks/verifier.yml b/roles/client_backup/tasks/verifier.yml index d9ef9c4..7c72ced 100644 --- a/roles/client_backup/tasks/verifier.yml +++ b/roles/client_backup/tasks/verifier.yml @@ -76,3 +76,32 @@ enabled: true state: started daemon_reload: true + +# LE CONTROLE DE RESTAURATION (2026-09-28). Meme destinataire, meme compte, un service a +# part : « l'instantane existe » et « l'instantane se rouvre » ne sont pas la meme question. +- name: Déployer le contrôle de restauration + ansible.builtin.template: + src: verifier-restauration.sh.j2 + dest: /usr/local/sbin/setops-verifier-restauration.sh + owner: root + group: root + mode: "0700" + +- name: Déployer l'unité et le timer du contrôle de restauration + ansible.builtin.template: + src: "{{ item }}.j2" + dest: "/etc/systemd/system/{{ item }}" + owner: root + group: root + mode: "0644" + loop: + - setops-verification-restauration.service + - setops-verification-restauration.timer + +- name: Activer le timer du contrôle de restauration + ansible.builtin.systemd_service: + name: setops-verification-restauration.timer + enabled: true + state: started + daemon_reload: true + when: not ansible_check_mode diff --git a/roles/client_backup/templates/setops-verification-restauration.service.j2 b/roles/client_backup/templates/setops-verification-restauration.service.j2 new file mode 100644 index 0000000..8fc4fb0 --- /dev/null +++ b/roles/client_backup/templates/setops-verification-restauration.service.j2 @@ -0,0 +1,10 @@ +# GENERE par Set-OPS (role client_backup). Ne pas editer a la main. +[Unit] +Description=La sauvegarde se restaure-t-elle ? (controle hebdomadaire, rapport a Icinga) +After=network-online.target + +[Service] +Type=oneshot +Nice=10 +IOSchedulingClass=idle +ExecStart=/usr/local/sbin/setops-verifier-restauration.sh diff --git a/roles/client_backup/templates/setops-verification-restauration.timer.j2 b/roles/client_backup/templates/setops-verification-restauration.timer.j2 new file mode 100644 index 0000000..91d6ef9 --- /dev/null +++ b/roles/client_backup/templates/setops-verification-restauration.timer.j2 @@ -0,0 +1,11 @@ +# GENERE par Set-OPS (role client_backup). Ne pas editer a la main. +[Unit] +Description=Controle hebdomadaire de restauration + +[Timer] +OnCalendar={{ client_backup_restauration_horaire }} +RandomizedDelaySec=3600 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/roles/client_backup/templates/verifier-restauration.sh.j2 b/roles/client_backup/templates/verifier-restauration.sh.j2 new file mode 100644 index 0000000..04d00f5 --- /dev/null +++ b/roles/client_backup/templates/verifier-restauration.sh.j2 @@ -0,0 +1,82 @@ +#!/bin/bash +# GENERE par Set-OPS (role client_backup). Ne pas editer a la main. +# +# LA SAUVEGARDE SE RESTAURE-T-ELLE ? (2026-09-28) +# +# `setops-verification-depot` dit qu'un instantane EXISTE, qu'il est RECENT et qu'il n'est +# pas VIDE. Il ne dit pas qu'on saurait le rouvrir. Le 2026-09-28, c'est a la main qu'on a +# restaure les dumps PostgreSQL et LDAP pour le savoir — et le meme jour on decouvrait que +# deux locataires sur deux ne deposaient rien hors de leur flotte depuis des semaines. +# Une sauvegarde jamais restauree est une hypothese. +# +# CE QUE CE CONTROLE FAIT, chaque semaine, sur CE noeud (le seul a detenir la cle) : +# 1. `restic check --read-data-subset` : l'integrite du depot, en RELISANT une part des +# donnees — pas seulement l'index ; +# 2. une vraie restauration du dernier instantane dans un repertoire temporaire, avec +# `--verify` : le contenu restaure est recompare a l'instantane ; +# 3. le NOMBRE de fichiers restaures face a celui de l'instantane. +# Le repertoire est detruit ensuite, quoi qu'il arrive. +set -uo pipefail + +export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass" +export RESTIC_REPOSITORY="{{ client_backup_repo }}" +API="https://{{ client_backup_icinga_hote }}.{{ domaine_interne }}:5665" +TTL={{ client_backup_ttl_restauration }} +PART="{{ client_backup_restauration_part }}" +MOI="{{ inventory_hostname }}" +MOTDEPASSE="$(cat /etc/setops/icinga-api.pass)" + +rapporter() { # $1=code $2=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])}))' \ + "${MOI}!restauration" "$1" "$2" "${TTL}") + reponse=$(curl -sS --cacert "{{ client_backup_ca_verification }}" --max-time 20 \ + -u "{{ client_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) + if ! printf '%s' "${reponse}" | grep -q '"code": *200'; then + echo "ECHEC du rapport Icinga : ${reponse}" >&2 + exit 1 + fi +} + +CIBLE=$(mktemp -d /var/tmp/setops-restauration.XXXXXX) +trap 'rm -rf "${CIBLE}"' EXIT + +# 1. Integrite, en relisant les donnees. +if ! sortie=$(restic check --read-data-subset="${PART}" 2>&1); then + rapporter 2 "DEPOT CORROMPU OU ILLISIBLE : restic check (${PART} relus) a echoue — $(printf '%s' "${sortie}" | tail -1 | cut -c1-120)" + exit 0 +fi + +# 2. Ce que l'instantane contient. +attendus=$(restic ls latest --json 2>/dev/null | python3 -c ' +import json, sys +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 -1) +if (( attendus < 0 )); then + rapporter 2 "AUCUN INSTANTANE lisible dans ${RESTIC_REPOSITORY} — rien a restaurer." + exit 0 +fi + +# 3. Restaurer pour de vrai, et recomparer. +if ! sortie=$(restic restore latest --target "${CIBLE}" --verify 2>&1); then + rapporter 2 "RESTAURATION EN ECHEC : $(printf '%s' "${sortie}" | tail -1 | cut -c1-140)" + exit 0 +fi +obtenus=$(find "${CIBLE}" -type f | wc -l) +octets=$(du -sb "${CIBLE}" | cut -f1) + +if (( obtenus != attendus )); then + rapporter 2 "RESTAURATION INCOMPLETE : ${obtenus} fichier(s) restaure(s) sur ${attendus} dans l'instantane." +elif (( attendus == 0 )); then + rapporter 1 "Instantane restaurable mais VIDE : rien a restaurer. Legitime si ce noeud n'a pas encore de donnees." +else + rapporter 0 "Restauration verifiee : ${obtenus} fichier(s), ${octets} octets, contenu recompare ; integrite du depot relue sur ${PART}." +fi diff --git a/roles/serveur_icinga/defaults/main.yml b/roles/serveur_icinga/defaults/main.yml index d1a51c5..b3a74d2 100644 --- a/roles/serveur_icinga/defaults/main.yml +++ b/roles/serveur_icinga/defaults/main.yml @@ -95,6 +95,7 @@ serveur_icinga_api_motdepasse: "{{ vault_icinga_api_depot | default('') }}" serveur_icinga_fraicheur_sauvegarde: 21600 # = client_backup_ttl_icinga = serveur_backup_ttl_icinga serveur_icinga_fraicheur_sante: 2700 # = client_sante_ttl_icinga serveur_icinga_fraicheur_sondes: 5400 # = client_sante_ttl_sondes +serveur_icinga_fraicheur_restauration: 691200 # = client_backup_ttl_restauration (hebdomadaire + 1 jour) # Hote portant les depots : DERIVE du groupe, jamais ecrit en dur. serveur_icinga_hote_sauvegarde: "{{ (groups['serveur_backup'] | default([]) | first) | default('') }}" diff --git a/roles/serveur_icinga/templates/setops-api-users.conf.j2 b/roles/serveur_icinga/templates/setops-api-users.conf.j2 index c0c53ae..c8a5722 100644 --- a/roles/serveur_icinga/templates/setops-api-users.conf.j2 +++ b/roles/serveur_icinga/templates/setops-api-users.conf.j2 @@ -43,7 +43,7 @@ object ApiUser "{{ serveur_icinga_api_utilisateur }}" { found » sur un objet qui existe, comme le 2026-09-02. Une heure perdue. La portee reste etroite : des NOMS, jamais un joker. #} - filter = {{ '{{' }} match("sauvegarde: *", service.name) || service.name == "sauvegarde" || service.name == "sante"{% for role, sondes in (serveur_icinga_sondes | default({})) | dictsort %}{% for s in sondes %} || service.name == "{{ s.nom }}"{% endfor %}{% endfor %} {{ '}}' }} + filter = {{ '{{' }} match("sauvegarde: *", service.name) || service.name == "sauvegarde" || service.name == "restauration" || service.name == "sante"{% for role, sondes in (serveur_icinga_sondes | default({})) | dictsort %}{% for s in sondes %} || service.name == "{{ s.nom }}"{% endfor %}{% endfor %} {{ '}}' }} } ] } diff --git a/roles/serveur_icinga/templates/setops-sauvegardes.conf.j2 b/roles/serveur_icinga/templates/setops-sauvegardes.conf.j2 index 1de0e0e..b7e8b50 100644 --- a/roles/serveur_icinga/templates/setops-sauvegardes.conf.j2 +++ b/roles/serveur_icinga/templates/setops-sauvegardes.conf.j2 @@ -98,3 +98,18 @@ object Service "sauvegarde" { vars.setops_source = "{{ noeud }}" } {% endfor %} +{# + LA RESTAURATION, UN SERVICE A PART (2026-09-28). « L'instantane existe, recent, non + vide » (`sauvegarde`) et « il se rouvre, contenu recompare » ne sont pas la meme question. + Controle hebdomadaire : le seuil de fraicheur est de huit jours. +#} +{% for noeud in serveur_icinga_sauvegarde_attendue | sort %} +object Service "restauration" { + import "setops-rapport-attendu" + host_name = "{{ noeud }}" + check_interval = {{ serveur_icinga_fraicheur_restauration }}s + retry_interval = {{ serveur_icinga_fraicheur_restauration }}s + vars.dummy_text = "AUCUN CONTROLE DE RESTAURATION depuis {{ (serveur_icinga_fraicheur_restauration / 86400) | int }} jours : la sauvegarde n'a pas ete rouverte." + vars.setops_source = "{{ noeud }}" +} +{% endfor %} diff --git a/scripts/tests/test_fraicheur_icinga.py b/scripts/tests/test_fraicheur_icinga.py index 57e0ae0..f6721b7 100644 --- a/scripts/tests/test_fraicheur_icinga.py +++ b/scripts/tests/test_fraicheur_icinga.py @@ -33,6 +33,7 @@ def main() -> int: ("serveur_icinga_fraicheur_sauvegarde", "serveur_backup", "serveur_backup_ttl_icinga"), ("serveur_icinga_fraicheur_sante", "client_sante", "client_sante_ttl_icinga"), ("serveur_icinga_fraicheur_sondes", "client_sante", "client_sante_ttl_sondes"), + ("serveur_icinga_fraicheur_restauration", "client_backup", "client_backup_ttl_restauration"), ] echecs = 0 for seuil, role, ttl in paires: