recette : la donnee REVIENT — restauration eprouvee, pas seulement sauvegarde
On savait que la donnee partait et arrivait. Pas qu'elle revenait.
Trois charges critiques eprouvees :
- cles de l'AC (infra-pki-01) : restauration + comparaison OCTET POUR OCTET,
12 fichiers, 11 identiques, root_ca_key et intermediate_ca_key compris. Seul
ecart : db/000000.vlog, le journal badger de step-ca, qui avance a chaque
emission. Attendu.
- annuaire (idm-01) : slapadd -u (essai a blanc) sur le LDIF restaure —
REJOUABLE, 7 entrees dont uid=sysadmin.
- bases (data-sql-01) : section forgejo REJOUEE dans une base d'epreuve —
0 erreur, 130 tables, comptes reels. Production verifiee intacte apres.
Controles negatifs : un LDIF corrompu fait sortir slapadd en 1 ; la garde SQL a
refuse une section mal decoupee.
LE PIEGE pg_dumpall : il ecrit CREATE DATABASE <suivante> AVANT le \connect
correspondant. Decouper « du \connect X au \connect suivant » emporte un ordre
visant une AUTRE base. Ma premiere decoupe l'a fait ; la garde a refuse de
rejouer. Consigne dans runbooks-exploitation.md §5.
valider.yml ne ment plus : il exigeait une restauration de TOUS les noeuds
client_backup, donc echouait sur ceux qui ne detiennent rien et sur les depots
vides. Quatre verdicts desormais (OK / A CONFIRMER / SANS OBJET / ECHEC),
ALIGNES sur ceux de la supervision. Et il prouve que l'annuaire est rejouable,
pas seulement present.
make valider : 0 echec sur toute la flotte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
81a4cd28f4
commit
f802e96b42
3 changed files with 133 additions and 5 deletions
39
CHANGELOG.md
39
CHANGELOG.md
|
|
@ -1,5 +1,44 @@
|
||||||
# CHANGELOG — Set-OPS
|
# 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 <suivante>` **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
|
## 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
|
Objectif : que le rapporteur **vérifie** le pair en appelant l'API Icinga, au lieu de
|
||||||
|
|
|
||||||
|
|
@ -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
|
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).
|
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**.
|
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 <suivante>` **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.
|
||||||
|
|
|
||||||
|
|
@ -121,31 +121,66 @@
|
||||||
gather_facts: false
|
gather_facts: false
|
||||||
become: true
|
become: true
|
||||||
tasks:
|
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)
|
- name: Restaurer le dernier snapshot dans un dossier temporaire (lecture seule sur le dépôt)
|
||||||
ansible.builtin.shell:
|
ansible.builtin.shell:
|
||||||
cmd: |
|
cmd: |
|
||||||
set -o pipefail
|
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)
|
repo=$(grep -oP 'RESTIC_REPOSITORY="\K[^"]+' /usr/local/sbin/setops-sauvegarder.sh)
|
||||||
export RESTIC_REPOSITORY="$repo" RESTIC_PASSWORD_FILE=/etc/setops/restic.pass
|
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)
|
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)
|
tmp=$(mktemp -d)
|
||||||
if restic restore latest --target "$tmp" >/dev/null 2>&1; then
|
if restic restore latest --target "$tmp" >/dev/null 2>&1; then
|
||||||
f=$(find "$tmp" -type f | wc -l)
|
f=$(find "$tmp" -type f | wc -l)
|
||||||
else
|
else
|
||||||
f=-1
|
f=-1
|
||||||
fi
|
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"
|
rm -rf "$tmp"
|
||||||
echo "snapshots=$n restored_files=$f"
|
echo "sansobjet=0 snapshots=$n restored_files=$f ldif_rejouable=$rejouable"
|
||||||
executable: /bin/bash
|
executable: /bin/bash
|
||||||
register: recette_restore
|
register: recette_restore
|
||||||
changed_when: false
|
changed_when: false
|
||||||
|
|
||||||
- name: "✔ Sauvegardes"
|
- name: "✔ Sauvegardes"
|
||||||
vars:
|
vars:
|
||||||
|
sansobjet: "{{ recette_restore.stdout | regex_search('sansobjet=(\\d+)', '\\1') | first | int }}"
|
||||||
restaures: "{{ recette_restore.stdout | regex_search('restored_files=(-?\\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 }}"
|
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:
|
ansible.builtin.assert:
|
||||||
that: "restaures | int > 0"
|
that:
|
||||||
success_msg: "OK — {{ snaps }} snapshot(s), restauration du dernier vérifiée ({{ restaures }} fichiers)."
|
- restaures | int >= 0
|
||||||
fail_msg: "ÉCHEC — restauration KO (snapshots={{ snaps }}, fichiers restaurés={{ restaures }})."
|
- 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', '') }}.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue