Set-OPS-Public/playbooks/valider.yml
Daniel Allaire f802e96b42 recette : la donnee REVIENT — restauration eprouvee, pas seulement sauvegarde
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>
2026-08-12 09:50:51 -04:00

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', '') }}.