Some checks are pending
verifier / verifier (push) Waiting to run
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
218 lines
11 KiB
YAML
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', '') }}.
|