On savait que la donnee partait et arrivait. Pas qu'elle revenait.
Trois charges critiques eprouvees :
- cles de l'AC (infra-pki-01) : restauration + comparaison OCTET POUR OCTET,
12 fichiers, 11 identiques, root_ca_key et intermediate_ca_key compris. Seul
ecart : db/000000.vlog, le journal badger de step-ca, qui avance a chaque
emission. Attendu.
- annuaire (idm-01) : slapadd -u (essai a blanc) sur le LDIF restaure —
REJOUABLE, 7 entrees dont uid=sysadmin.
- bases (data-sql-01) : section forgejo REJOUEE dans une base d'epreuve —
0 erreur, 130 tables, comptes reels. Production verifiee intacte apres.
Controles negatifs : un LDIF corrompu fait sortir slapadd en 1 ; la garde SQL a
refuse une section mal decoupee.
LE PIEGE pg_dumpall : il ecrit CREATE DATABASE <suivante> AVANT le \connect
correspondant. Decouper « du \connect X au \connect suivant » emporte un ordre
visant une AUTRE base. Ma premiere decoupe l'a fait ; la garde a refuse de
rejouer. Consigne dans runbooks-exploitation.md §5.
valider.yml ne ment plus : il exigeait une restauration de TOUS les noeuds
client_backup, donc echouait sur ceux qui ne detiennent rien et sur les depots
vides. Quatre verdicts desormais (OK / A CONFIRMER / SANS OBJET / ECHEC),
ALIGNES sur ceux de la supervision. Et il prouve que l'annuaire est rejouable,
pas seulement present.
make valider : 0 echec sur toute la flotte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
186 lines
8.8 KiB
YAML
186 lines
8.8 KiB
YAML
---
|
|
# 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', '') }}.
|