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>
230 lines
12 KiB
YAML
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', '') }}.
|