diff --git a/CHANGELOG.md b/CHANGELOG.md index 8386fb9..241ed86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # CHANGELOG — Set-OPS +## 2026-08-12 — La donnée revient : restauration éprouvée, pas seulement sauvegarde + +On savait que la donnée partait et arrivait. On ne savait pas qu'elle **revenait** — et +c'est le seul test qui compte le jour venu. + +### Les trois charges critiques, éprouvées pour de vrai + +| Charge | Preuve | Résultat | +|---|---|---| +| **Clés de l'AC** (`infra-pki-01`) | restauration + comparaison **octet pour octet** avec le vivant | 12 fichiers, **11 identiques**, `root_ca_key` et `intermediate_ca_key` compris | +| **Annuaire** (`idm-01`) | `slapadd -u` (essai à blanc) sur le LDIF restauré | **rejouable**, 7 entrées dont `uid=sysadmin` | +| **Bases** (`data-sql-01`) | section `forgejo` **rejouée** dans une base d'épreuve | **0 erreur, 130 tables**, comptes réels (`forgejo-admin`, `sysadmin`) | + +Le seul écart sur l'AC est `db/000000.vlog` — le journal badger de step-ca, qui avance à +chaque émission de certificat. Attendu, pas un défaut. + +**Contrôles négatifs, parce qu'un test qui dit toujours oui ne teste rien** : un LDIF +volontairement corrompu fait sortir `slapadd` en 1 ; la garde SQL a refusé une section mal +découpée (voir ci-dessous). Production vérifiée intacte après le rejeu. + +### Le piège de `pg_dumpall`, trouvé par la garde + +`pg_dumpall` écrit `CREATE DATABASE ` **avant** le `\connect` correspondant. +Découper « du `\connect X` au `\connect` suivant » emporte donc un ordre visant une +**autre** base. Ma première découpe l'a fait ; la garde a refusé de rejouer. Sans elle, un +essai de restauration aurait touché `icingadb`. Consigné dans `runbooks-exploitation.md` §5. + +### La recette ne ment plus + +`playbooks/valider.yml` exigeait une restauration de **tous** les nœuds `client_backup` — +elle échouait donc sur ceux qui ne détiennent légitimement rien, et sur les dépôts vides. +Elle distingue désormais quatre verdicts : `OK`, `À CONFIRMER` (restauré mais vide), +`SANS OBJET`, `ÉCHEC` (la restauration elle-même). **Alignés sur ceux de la supervision** : +deux verdicts opposés sur le même fait apprendraient à en ignorer un. + +Elle prouve en outre que l'annuaire restauré est **rejouable**, pas seulement présent. + +`make valider` : **0 échec** sur toute la flotte. + ## 2026-08-12 — Le `curl -k` est mort, mais pas comme prévu Objectif : que le rapporteur **vérifie** le pair en appelant l'API Icinga, au lieu de diff --git a/docs/runbooks-exploitation.md b/docs/runbooks-exploitation.md index 287112c..9d57ee4 100644 --- a/docs/runbooks-exploitation.md +++ b/docs/runbooks-exploitation.md @@ -102,3 +102,57 @@ serveur_forgejo_meta_description: "…" Puis redéployer. Le rôle déploie le dossier `custom/` officiel (logo/favicon aurore, accent CSS par variables, page d'accueil brandée) — léger, résistant aux MAJ (aucune classe interne touchée). Note : ne s'applique qu'aux Forgejo **gérées par Set-OPS**. + +## 5. Restaurer — et d'abord : prouver qu'on peut + +> Éprouvé le 2026-08-12 sur Chezlepro. `make valider` rejoue la partie automatisable ; +> la restauration d'une base reste manuelle, et porte un piège décrit plus bas. + +**Ce que `make valider` prouve tout seul**, pour chaque nœud détenteur d'état : le dernier +instantané se restaure, il en sort des fichiers, et — pour l'annuaire — qu'il est +**rejouable** (`slapadd -u`, essai à blanc, rien n'est écrit). Verdicts possibles : + +| Verdict | Sens | +|---|---| +| `OK` | restauré, et non vide | +| `À CONFIRMER` | restauré, mais l'instantané n'emporte **aucun fichier** — légitime si ce nœud n'a pas encore de données, à trancher par un humain | +| `SANS OBJET` | ce nœud ne détient rien de non régénérable | +| `ÉCHEC` | la restauration elle-même a échoué | + +### Restaurer les clés de l'autorité (`infra-pki-01`) + +``` +export RESTIC_REPOSITORY=$(grep -oP 'RESTIC_REPOSITORY="\K[^"]+' /usr/local/sbin/setops-sauvegarder.sh) +export RESTIC_PASSWORD_FILE=/etc/setops/restic.pass +t=$(mktemp -d); restic restore latest --target "$t" +diff -r "$t/etc/step-ca" /etc/step-ca +``` + +Mesuré : **12 fichiers, 11 identiques octet pour octet**, `root_ca_key` et +`intermediate_ca_key` compris. Le seul écart attendu est `db/000000.vlog` — le journal de +la base badger de step-ca, qui avance à **chaque** émission de certificat. + +### Rejouer une base depuis `pg_dumpall` — LE PIÈGE + +`pg_dumpall` écrit `CREATE DATABASE ` **avant** le `\connect` correspondant. +Découper « du `\connect X` au `\connect` suivant » emporte donc un ordre qui vise une +**autre** base. Couper aussi sur `CREATE DATABASE`, et **vérifier avant de rejouer** : + +``` +awk '/^\\connect forgejo$/{f=1;next} f && (/^\\connect /||/^CREATE DATABASE /){exit} f' \ + toutes-bases.sql > section.sql + +grep -qE '^(DROP|CREATE|ALTER) DATABASE|^\\connect' section.sql \ + && { echo "REFUS : ordre hors-perimetre"; exit 1; } + +runuser -u postgres -- createdb epreuve_restauration +runuser -u postgres -- psql -q -d epreuve_restauration -f section.sql +runuser -u postgres -- psql -tAd epreuve_restauration -c "select count(*) from information_schema.tables where table_schema='public'" +runuser -u postgres -- dropdb epreuve_restauration +``` + +Mesuré sur `forgejo` : rejeu en **0 erreur**, **130 tables**, et les comptes réels +(`forgejo-admin`, `sysadmin`). Production vérifiée intacte après coup. + +**Ne jamais rejouer un `pg_dumpall` entier sur un cluster vivant** : il contient les +`DROP DATABASE` de toutes les bases. Une restauration réelle se fait sur un cluster neuf. diff --git a/playbooks/valider.yml b/playbooks/valider.yml index 9e1beeb..5df9ed3 100644 --- a/playbooks/valider.yml +++ b/playbooks/valider.yml @@ -121,31 +121,66 @@ 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 "snapshots=0 restored_files=0"; exit 0; fi + 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 "snapshots=$n restored_files=$f" + 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" - success_msg: "OK — {{ snaps }} snapshot(s), restauration du dernier vérifiée ({{ restaures }} fichiers)." - fail_msg: "ÉCHEC — restauration KO (snapshots={{ snaps }}, fichiers restaurés={{ restaures }})." + 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', '') }}.