--- # make valider — RECETTE D'ACCEPTATION : les services FONCTIONNENT (pas juste déployés). # Lecture seule / faible impact. Complète les *-verifier (statiques, sur le plan) # et verifier-hote (niveau hôte) par une vérif FONCTIONNELLE de bout en bout. # # v1 : cibles Prometheus UP + endpoints HTTPS exposés répondent. # À venir : courriel bout-en-bout (envoi→IMAP), restauration de sauvegarde. - name: "Recette — Prometheus : toutes les cibles de supervision UP" hosts: serveur_prometheus gather_facts: false become: false tasks: - name: Interroger l'API des cibles actives ansible.builtin.uri: url: "http://localhost:9090/api/v1/targets?state=active" return_content: true register: prom_targets - name: Analyser up / down vars: cibles: "{{ (prom_targets.content | from_json).data.activeTargets }}" en_panne: "{{ cibles | rejectattr('health', 'equalto', 'up') | list }}" ansible.builtin.set_fact: recette_prom_total: "{{ cibles | length }}" recette_prom_down: "{{ en_panne | map(attribute='labels.instance') | list }}" - name: "✔ Prometheus" ansible.builtin.assert: that: "recette_prom_down | length == 0" success_msg: "OK — {{ recette_prom_total }} cibles, toutes UP." fail_msg: "ÉCHEC — cibles DOWN : {{ recette_prom_down }}" - name: "Recette — Edge : les vhosts HTTPS exposés répondent" hosts: serveur_nginx gather_facts: false become: true tasks: - name: Lister les valeurs de la directive server_name (première colonne exacte) ansible.builtin.shell: cmd: >- set -o pipefail; nginx -T 2>/dev/null | awk '$1=="server_name"{for(i=2;i<=NF;i++){gsub(/;/,"",$i); print $i}}' | sort -u executable: /bin/bash register: recette_server_names changed_when: false - name: Retenir les vhosts du domaine interne (exclut défauts/placeholders) ansible.builtin.set_fact: recette_fqdns: >- {{ recette_server_names.stdout_lines | select('search', '\.' ~ (domaine_interne | regex_escape) ~ '$') | list }} - name: Interroger chaque vhost exposé en HTTPS (via l'edge) ansible.builtin.uri: url: "https://{{ item }}/" validate_certs: false status_code: [200, 301, 302, 401, 403] follow_redirects: none timeout: 10 loop: "{{ recette_fqdns }}" register: recette_http failed_when: false - name: "✔ Endpoints HTTPS" vars: echecs: "{{ recette_http.results | rejectattr('status', 'in', [200, 301, 302, 401, 403]) | list }}" ansible.builtin.assert: that: "echecs | length == 0" success_msg: "OK — {{ recette_http.results | length }} vhosts répondent : {{ recette_fqdns }}" fail_msg: "ÉCHEC — vhosts KO : {{ echecs | map(attribute='item') | list }} (statuts {{ echecs | map(attribute='status') | list }})" - name: "Recette — Courriel bout-en-bout (MTA -> LMTP -> Maildir)" hosts: serveur_dovecot gather_facts: true become: true vars: recette_mail_dest: "testmail@{{ domaine_interne }}" recette_mail_from: "recette@{{ domaine_interne }}" tasks: - name: Jeton unique du message de test ansible.builtin.set_fact: recette_mail_token: "SETOPS-VALIDER-{{ ansible_date_time.iso8601_basic_short }}-{{ 999999 | random }}" - name: Envoyer via le MTA (sendmail — flux réel postfix -> LDAP -> LMTP -> Dovecot) ansible.builtin.shell: cmd: >- set -o pipefail; printf 'Subject: %s\nFrom: %s\nTo: %s\n\nRecette d acceptation Set-OPS.\n' '{{ recette_mail_token }}' '{{ recette_mail_from }}' '{{ recette_mail_dest }}' | /usr/sbin/sendmail -f '{{ recette_mail_from }}' '{{ recette_mail_dest }}' executable: /bin/bash delegate_to: "{{ groups['serveur_postfix'][0] }}" changed_when: true - name: Attendre la remise dans la boîte (doveadm, LMTP asynchrone) ansible.builtin.command: cmd: "doveadm search -u {{ recette_mail_dest }} mailbox INBOX subject {{ recette_mail_token }}" register: recette_mail_search until: recette_mail_search.stdout | trim | length > 0 retries: 10 delay: 3 changed_when: false failed_when: false - name: "✔ Courriel" ansible.builtin.assert: that: "recette_mail_search.stdout | trim | length > 0" success_msg: "OK — courriel remis à {{ recette_mail_dest }} (envoi -> LMTP -> Maildir prouvé)." fail_msg: "ÉCHEC — courriel NON remis à {{ recette_mail_dest }} après 30 s." - name: Nettoyer le message de test (best-effort) ansible.builtin.command: cmd: "doveadm expunge -u {{ recette_mail_dest }} mailbox INBOX subject {{ recette_mail_token }}" changed_when: true failed_when: false - name: "Recette — Sauvegardes : le dernier snapshot restic se restaure" hosts: client_backup gather_facts: false become: true tasks: # Un nœud du groupe peut légitimement ne rien détenir de non régénérable (nginx, # PowerDNS, Icinga : leur configuration se REDÉPLOIE). `client_backup` n'y installe # alors aucune sauvegarde — et exiger une restauration là serait exiger de restaurer # le néant. On distingue donc « rien à sauvegarder » de « sauvegarde cassée ». - name: Restaurer le dernier snapshot dans un dossier temporaire (lecture seule sur le dépôt) ansible.builtin.shell: cmd: | set -o pipefail if [ ! -x /usr/local/sbin/setops-sauvegarder.sh ]; then echo "sansobjet=1 snapshots=0 restored_files=0 ldif_rejouable=-1"; exit 0 fi repo=$(grep -oP 'RESTIC_REPOSITORY="\K[^"]+' /usr/local/sbin/setops-sauvegarder.sh) export RESTIC_REPOSITORY="$repo" RESTIC_PASSWORD_FILE=/etc/setops/restic.pass n=$(restic snapshots 2>/dev/null | grep -cE '^[0-9a-f]{8} ' || echo 0) if [ "$n" -lt 1 ]; then echo "sansobjet=0 snapshots=0 restored_files=-1 ldif_rejouable=-1"; exit 0; fi tmp=$(mktemp -d) if restic restore latest --target "$tmp" >/dev/null 2>&1; then f=$(find "$tmp" -type f | wc -l) else f=-1 fi # L'annuaire est la seule charge dont on peut prouver ICI qu'elle est REJOUABLE, # sans rien écrire : `slapadd -u` est un essai à blanc. Éprouvé le 2026-08-12, # avec contrôle négatif (un LDIF corrompu sort en 1). ldif=$(find "$tmp" -name 'annuaire.ldif' 2>/dev/null | head -1) rejouable=-1 if [ -n "$ldif" ]; then base=$(sed -n 's/^dn: \(dc=.*\)$/\1/p' "$ldif" | head -1) if slapadd -u -b "$base" -l "$ldif" >/dev/null 2>&1; then rejouable=1; else rejouable=0; fi fi rm -rf "$tmp" echo "sansobjet=0 snapshots=$n restored_files=$f ldif_rejouable=$rejouable" executable: /bin/bash register: recette_restore changed_when: false - name: "✔ Sauvegardes" vars: sansobjet: "{{ recette_restore.stdout | regex_search('sansobjet=(\\d+)', '\\1') | first | int }}" restaures: "{{ recette_restore.stdout | regex_search('restored_files=(-?\\d+)', '\\1') | first | int }}" snaps: "{{ recette_restore.stdout | regex_search('snapshots=(\\d+)', '\\1') | first | int }}" rejouable: "{{ ((recette_restore.stdout | regex_search('ldif_rejouable=(-?\\d+)', '\\1')) or [-1]) | first | int }}" # « L'instantané n'emporte aucun fichier » n'est PAS un échec de restauration : la # restauration a parfaitement fonctionné, il n'y avait rien à rendre. La machine ne # peut pas distinguer « les données ont disparu » de « il n'y en a pas encore ». # La supervision (serveur_backup -> Icinga) tranche pareil : AVERTISSEMENT, à # confirmer par un humain. Deux verdicts opposés sur le même fait apprendraient à # ignorer l'un des deux. ansible.builtin.assert: that: - restaures | int >= 0 - rejouable | int != 0 success_msg: >- {{ (sansobjet | int == 1) | ternary( 'SANS OBJET — ce nœud ne détient aucun état non régénérable.', (restaures | int == 0) | ternary( 'À CONFIRMER — restauration OK mais l instantané n emporte AUCUN fichier (' ~ snaps ~ ' snapshot(s)). Légitime si ce nœud n a pas encore de données.', 'OK — ' ~ snaps ~ ' snapshot(s), restauration vérifiée (' ~ restaures ~ ' fichiers)' ~ ((rejouable | int == 1) | ternary(', annuaire REJOUABLE (slapadd -u).', '.')))) }} fail_msg: >- ÉCHEC — la RESTAURATION elle-même a échoué (snapshots={{ snaps }}){{ (rejouable | int == 0) | ternary(', et l annuaire restauré N EST PAS rejouable', '') }}.