Set-OPS-Public/playbooks/valider.yml
Daniel Allaire 9ca9a5ffe1 valider : la recette Prometheus attend une minute au plus que ses cibles soient relevees
La reconstruction de Technolibre s'est arretee : la recette a juge Prometheus 18 s apres sa relance par le premier depot d'obs-01. Elle redemande desormais, puis juge. Journal (112) Keycloak et (113).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:20:45 -04:00

230 lines
12 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:
# UN PROMETHEUS QUI VIENT DE REDEMARRER N'A PAS ENCORE RELEVE SES CIBLES (2026-10-08).
# Depuis qu'obs-01 est sauvegarde, son premier depot (copie a froid, en fin de
# deploiement) relance Prometheus : a la reconstruction de Technolibre, la recette l'a
# interroge 18 s apres, et deux cibles n'avaient pas encore de releve reussi — saines
# quelques minutes plus tard. On redemande donc, une minute au plus, avant de juger :
# une vraie panne echoue toujours, une minute plus tard.
- name: Interroger l'API des cibles actives (jusqu'a ce que toutes soient relevees, une minute au plus)
ansible.builtin.uri:
url: "http://localhost:9090/api/v1/targets?state=active"
return_content: true
register: prom_targets
until: >-
((prom_targets.content | default('{}') | from_json).get('data', {}).get('activeTargets', [])
| rejectattr('health', 'equalto', 'up') | list | length == 0)
or (prom_targets.attempts | default(0) | int >= 6)
retries: 6
delay: 10
- 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', '') }}.