Set-OPS-Public/playbooks/valider.yml
Daniel Allaire 35f3881218
Some checks are pending
verifier / verifier (push) Waiting to run
recette : un depot occupe n est pas une sauvegarde cassee
make valider rendait ECHEC sur data-sql-01, et la meme commande rejouee a la main
restaurait 5 fichiers. restic verrouille son depot pendant qu il ecrit : une recette
qui croise la fenetre de sauvegarde lit un echec de RESTAURATION la ou il n y a
qu une attente. Verdict juste sur l instant, faux sur le fond, et DEPENDANT DE
L HEURE a laquelle on la lance.

ON REESSAIE, MAIS ON NE MASQUE PAS. Trois tentatives espacees couvrent un verrou
d ecriture ; au-dela, l echec est REEL et la recette rend la RAISON que restic a
donnee, au lieu d un -1 muet qui obligeait a se connecter pour comprendre.

CONTROLE VERIFIE SUR LA MACHINE : cle de dechiffrement retiree ->
« ECHEC apres 3 tentatives — restic dit : Fatal: Resolving password failed ».
La recette attrape toujours une vraie panne, et elle en donne la cause.

DEUX FAUTES A MOI EN CHEMIN, CORRIGEES.

`[ "$f" -lt 0 ] && printf ...` : la DERNIERE commande d un script decide de son code
de sortie, et un test faux rend 1. La tache echouait donc exactement sur les
machines ou la restauration avait REUSSI — trois hotes verts declares en echec.

Puis la ligne `raison=` n etait emise qu en cas d echec : le gabarit cherchait un
champ absent, rendait None, et explosait sur les DIX machines saines. Elle est
desormais TOUJOURS emise, vide en cas de succes. Un champ toujours present coute un
octet et supprime un cas.

make valider : rc=0. make verifier : vert. make prouver : 55 OK, 0 echec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-31 20:41:39 -04:00

218 lines
11 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)
# LE DEPOT PEUT ETRE OCCUPE, ET CE N'EST PAS UNE SAUVEGARDE CASSEE (2026-08-31).
#
# `restic` verrouille son depot pendant qu'il ecrit. Une recette qui croise la
# fenetre de sauvegarde lit donc un echec de RESTAURATION la ou il n'y a qu'une
# attente — verdict juste sur l'instant, faux sur le fond, et dependant de
# l'heure a laquelle on l'a lancee. Constate sur `data-sql-01`, dont la
# sauvegarde de 02h38 tenait encore le depot : `ECHEC` au premier passage,
# `OK — 5 fichiers` en rejouant la meme commande a la main.
#
# ON REESSAIE, MAIS ON NE MASQUE PAS. Trois tentatives espacees couvrent
# largement un verrou d'ecriture ; au-dela, l'echec est REEL et on rend la
# RAISON que restic a donnee, au lieu d'un `-1` muet qui laisse chercher.
f=-1
err=""
for essai in 1 2 3; do
if err=$(restic restore latest --target "$tmp" 2>&1 >/dev/null); then
f=$(find "$tmp" -type f | wc -l)
break
fi
rm -rf "$tmp"; tmp=$(mktemp -d)
[ "$essai" -lt 3 ] && sleep 20
done
# La raison voyage sur une ligne a part : le verdict la cite, et l'exploitant
# n'a pas a se connecter pour savoir ce que restic a dit.
#
# `if` ET NON `[ ... ] && ...` : la DERNIERE commande d'un script decide de son
# code de sortie, et un test faux rend 1. Ecrit en `&&`, cette ligne faisait
# echouer la tache exactement sur les machines ou la restauration avait REUSSI
# — trois hotes verts declares en echec (2026-08-31).
# TOUJOURS EMISE, vide en cas de succes. Une ligne parfois absente oblige son
# lecteur a gerer l'absence, et c'est la que le gabarit s'est casse : la
# recherche rendait `None` et le verdict explosait sur les DIX machines saines
# (2026-08-31). Un champ toujours present coute un octet et supprime un cas.
printf 'raison=%s\n' "$(printf '%s' "$err" | tr '\n' ' ' | cut -c1-160)"
# 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 }}"
# Ce que restic a dit, quand il a refuse. Vide sinon — la ligne est toujours la.
raison: "{{ ((recette_restore.stdout | regex_search('raison=(.*)', '\\1')) or ['']) | first | trim }}"
# « 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é après 3 tentatives
(snapshots={{ snaps }}){{ raison | ternary(' — restic dit : ' ~ raison, '') }}{{
(rejouable | int == 0) | ternary(', et l annuaire restauré N EST PAS rejouable', '') }}.