sauvegarde : le catalogue derive du groupe, et P36 le prouve

Correction : l'entree precedente attribuait le defaut a la reconstruction
from-zero. C'est faux — le commit fondateur 7476a54 (2026-07-03) disait lui-meme
« Reste : jobs Tier 1 (pg_dump/slapcat/vmail/forgejo) ». Ils n'ont jamais ete
ecrits, et infra-pki-01 a ensuite perdu son integration client_backup.

Le role qui POSSEDE la donnee dit comment la sortir : client_backup_jobs est
l'intersection du catalogue et des group_names du noeud. Un tenant qui deplace un
service emporte sa sauvegarde avec lui. On sauvegarde l'etat NON REGENERABLE :
ni zones PowerDNS ni tableaux Grafana, ils se redeploient.

L'unite qui ment est RETIREE, pas rendue bloquante : refuser le deploiement aurait
casse infra-edge-01, infra-dns-01 et mon-01, qui ne detiennent legitimement rien.
Le defaut etait le timer qui echouait chaque nuit en donnant l'apparence d'une
sauvegarde.

P36 (D-75) lit les groupes detenteurs dans le catalogue : ajouter un role au
catalogue etend la preuve du meme geste. Elle a attrape infra-pki-01 — les cles
de l'AC — corrige au plan.

Mesure hors-noeud : collab-01 64,0 MiB/272, edge-mta-01 4,4 MiB/139,
data-sql-01 1,0 MiB (pg_dumpall complet), forge-01 26,4 KiB/68,
infra-pki-01 20,1 KiB/21, idm-01 2,3 KiB/5 (slapcat). 9 hotes, 9 success.

Reste : rien ne surveille l'unite — c'est ce silence qui a laisse le defaut
vivre un mois.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-11 20:50:46 -04:00
parent 334fc65d60
commit 8eedeaf31f
7 changed files with 225 additions and 10 deletions

View file

@ -1,5 +1,57 @@
# CHANGELOG — Set-OPS
## 2026-08-11 — La sauvegarde emporte enfin quelque chose
**Correction de l'entrée précédente** : j'y attribuais le défaut à la reconstruction
from-zero. C'est faux. Le commit fondateur `7476a54` (2026-07-03) le disait lui-même —
*« Reste : jobs Tier 1 (pg_dump/slapcat/vmail/forgejo) »*. Ces jeux n'ont jamais été
écrits. Le Tier 0 était prouvé sur `infra-pki-01`, mais l'hôte a ensuite perdu son
intégration `client_backup` sans que rien ne le dise.
### Le catalogue vit dans le rôle, dérivé de l'appartenance aux groupes
C'est le rôle qui **possède** la donnée qui dit comment la sortir. `client_backup_jobs` est
l'intersection du catalogue et des `group_names` du nœud : un tenant qui déplace un service
emporte sa sauvegarde avec lui, sans rien redéclarer. On sauvegarde l'**état non
régénérable** — ni les zones PowerDNS ni les tableaux de bord Grafana n'y figurent, ils se
redéploient.
Les chemins ne peuvent pas référencer les defaults du rôle propriétaire : `make deployer`
déroule un play par groupe, et ceux de `serveur_forgejo` ne sont pas chargés pendant le play
de `client_backup`. D'où la forme `var | default(littéral)`.
### L'unité qui ment est retirée, pas rendue bloquante
Refuser le déploiement d'un nœud sans jeu aurait cassé `infra-edge-01`, `infra-dns-01` et
`mon-01`, qui ne détiennent légitimement rien. Le défaut n'était pas là : il était dans le
timer qui échouait chaque nuit en donnant l'apparence d'une sauvegarde. Le rôle installe
donc la sauvegarde **si et seulement si** un jeu s'applique, et **retire** celle qui
existerait. *Une sauvegarde qui ne sauvegarde rien est pire que pas de sauvegarde : elle
rassure.*
### P36 — tout détenteur d'état porte une sauvegarde
L'écart était lisible dans le plan depuis un mois (D-75). La preuve lit les groupes
détenteurs dans `client_backup_catalogue` : ajouter un rôle au catalogue étend la preuve du
même geste. Elle a immédiatement attrapé `infra-pki-01`, corrigé au plan.
### Mesuré, hors-nœud
| Hôte | Emporté |
|---|---|
| `collab-01` | 64,0 MiB · 272 fichiers |
| `edge-mta-01` | 4,4 MiB · 139 (bayes rspamd appris) |
| `data-sql-01` | 1,0 MiB · `pg_dumpall` de **toutes** les bases |
| `forge-01` | 26,4 KiB · 68 |
| `infra-pki-01` | 20,1 KiB · 21 — **les clés de l'AC** |
| `idm-01` | 2,3 KiB · 5 — l'annuaire par `slapcat` |
| `infra-mail-01`, `web-frontal-01`, `web-dorsal-01` | vides, et c'est exact : `/var/vmail`, `/srv/web` et `/srv/webapp` n'ont rien depuis la reconstruction du 2026-08-10 |
9 hôtes, 9 `success`, 5 hôtes sans sauvegarde parce qu'ils ne détiennent rien.
**Ce qui reste** : rien ne surveille encore l'unité. C'est ce silence qui a laissé le défaut
vivre un mois — Icinga devrait voir une unité systemd en échec.
## 2026-08-11 — En consignant l'effet du rasage, la sauvegarde s'est révélée vide
Il s'agissait d'écrire une conséquence connue : raser l'hôte qui porte `openldap` détruit

View file

@ -7,7 +7,7 @@
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (35 OK · 0 echec · 0 saute)
- **Verdict** : ✅ CONFORME (36 OK · 0 echec · 0 saute)
## Preuves
@ -48,6 +48,7 @@
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 32 revendication(s) de port, aucune collision entre roles co-localises (33 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 38 document(s) declarent leur lecteur (14 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 5 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
## Couverture des affirmations ✅ du registre

View file

@ -84,13 +84,15 @@ seront pas recréés**. Le code ne peut pas reconstruire ce qu'il a délibérém
pas posséder. C'est la contrepartie exacte du régime qui protège ces décisions du prochain
`make deployer`.
> **Mesure du 2026-08-11 — il n'y a rien à restaurer.** `client_backup_jobs` vaut `[]` par
> défaut et rien ne le surcharge dans l'instance. Sur 11 hôtes, `setops-sauvegarde.service`
> échoue chaque nuit (`Fatal: nothing to backup`) ; sur `infra-pki-01`, `obs-01` et
> `backup-01`, aucune sauvegarde n'est déployée — et `infra-pki-01` porte les clés de l'AC.
> **Tant que ce défaut n'est pas corrigé, raser un hôte d'identité est irréversible.**
> **Il y a désormais quelque chose à restaurer — depuis le 2026-08-11 seulement.** Jusque-là
> `client_backup_jobs` valait `[]` et rien ne le surchargeait : onze hôtes lançaient chaque
> nuit un timer qui échouait sur `Fatal: nothing to backup`, et `infra-pki-01` — les clés de
> l'AC — n'avait aucune sauvegarde. `client_backup` dérive maintenant ses jeux de
> l'appartenance aux groupes, et **P36** prouve statiquement que tout détenteur d'état porte
> `client_backup`. L'annuaire de `idm-01` est sorti par `slapcat` à chaque exécution.
Avant de raser un hôte d'identité, sortir l'annuaire à la main :
La sauvegarde ne dispense pas de la vérifier avant un geste destructeur. Sortir l'annuaire à
la main reste le réflexe juste avant de raser :
```
ansible <hote_openldap> -m shell -a "slapcat -b dc=<domaine> > /tmp/annuaire.ldif" -b

View file

@ -26,5 +26,63 @@ client_backup_horaire: "*-*-* 02:30:00"
# nom : identifiant
# commande : (optionnel) dump écrivant dans {{ staging }}/{{ nom }}
# chemins : liste de chemins à inclure dans le snapshot restic
# Défini par nœud (group_vars). Ex. step_ca : { nom: step_ca, chemins: [/etc/step-ca] }.
client_backup_jobs: []
#
# CATALOGUE PAR GROUPE — c'est le rôle qui POSSEDE la donnée qui dit comment la sortir.
# On sauvegarde l'ETAT NON REGENERABLE, pas ce que le code reconstruit : ni les zones
# PowerDNS ni les tableaux de bord Grafana ne figurent ici, ils se redéploient.
#
# Les chemins reprennent les defauts du role proprietaire, mais NE PEUVENT PAS y faire
# reference : `make deployer` deroule un play par groupe, et les defaults de
# `serveur_forgejo` ne sont pas charges pendant le play de `client_backup`. D'ou la forme
# `var | default(litteral)` — la variable gagne si l'inventaire la definit, sinon le
# defaut du role proprietaire, recopie ici et a garder aligne avec lui.
client_backup_catalogue:
serveur_step_ca:
nom: step_ca
chemins:
- "{{ serveur_step_ca_steppath | default('/etc/step-ca') }}"
serveur_openldap:
nom: openldap
# slapcat lit la base a plat : pas d'authentification, coherent meme slapd arrete.
commande: >-
slapcat -b '{{ serveur_openldap_base_dn | default("dc=" + domaine_interne.split(".") | join(",dc=")) }}'
> {{ client_backup_staging }}/openldap/annuaire.ldif
chemins: ["{{ client_backup_staging }}/openldap"]
serveur_postgresql:
nom: postgresql
# pg_dumpall : TOUTES les bases + les roles et leurs mots de passe. Une base oubliee
# ici serait une base perdue — d'ou le dump global plutot qu'une liste a maintenir.
commande: >-
runuser -u postgres -- pg_dumpall --clean
> {{ client_backup_staging }}/postgresql/toutes-bases.sql
chemins: ["{{ client_backup_staging }}/postgresql"]
serveur_dovecot:
nom: courriel
chemins: ["{{ serveur_dovecot_vmail_base | default('/var/vmail') }}"]
serveur_forgejo:
nom: forgejo
chemins:
- "{{ serveur_forgejo_data | default('/var/lib/forgejo') }}"
- "{{ serveur_forgejo_config_dir | default('/etc/forgejo') }}"
serveur_nextcloud:
nom: nextcloud
chemins:
- "{{ serveur_nextcloud_data_dir | default('/var/www/nextcloud/data') }}"
- "{{ (serveur_nextcloud_racine | default('/var/www/nextcloud')) + '/config' }}"
serveur_rspamd:
# Le bayes APPRIS est de l'etat : reconstruire le noeud reinstalle rspamd, pas ce
# qu'il a appris. Les regles, elles, se redeploient et ne sont pas ici.
nom: rspamd
chemins: ["/var/lib/rspamd"]
serveur_web_frontal:
nom: web_frontal
chemins: ["{{ serveur_web_frontal_racine_base | default('/srv/web') }}"]
serveur_web_dorsal:
nom: web_dorsal
chemins: ["{{ serveur_web_dorsal_racine | default('/srv/webapp') }}"]
# Jeux retenus pour CE noeud : intersection du catalogue et de ses groupes. Derive, donc
# un tenant qui deplace un service emporte sa sauvegarde avec lui, sans rien re-declarer.
client_backup_jobs: >-
{{ client_backup_catalogue | dict2items
| selectattr('key', 'in', group_names) | map(attribute='value') | list }}

View file

@ -0,0 +1,4 @@
---
- name: Recharger systemd
ansible.builtin.systemd:
daemon_reload: true

View file

@ -6,6 +6,23 @@
- client_backup_ssh_privkey | length > 0
fail_msg: "vault_restic_password et vault_backup_ssh_privkey requis."
# Un noeud peut legitimement ne rien detenir de non regenerable (nginx, PowerDNS, Icinga :
# leur configuration se REDEPLOIE). Le defaut mesure le 2026-08-11 n'etait pas la, il etait
# dans l'unite qui MENT : 11 hotes deployaient un timer qui echouait chaque nuit sur
# « nothing to backup », invisible depuis le 2026-07-03. Une sauvegarde qui ne sauvegarde
# rien est pire que pas de sauvegarde — elle rassure.
#
# On ne refuse donc pas le deploiement : on refuse d'installer une sauvegarde vide, et on
# RETIRE celle qui existerait. Que tout detenteur d'etat porte bien `client_backup` est
# lisible dans le plan, donc prouve statiquement (D-75, P36) — pas ici.
- name: Etat de la sauvegarde pour ce noeud
ansible.builtin.debug:
msg: >-
{{ (client_backup_jobs | length > 0)
| ternary(client_backup_jobs | length | string + ' jeu(x) : '
+ (client_backup_jobs | map(attribute='nom') | join(', ')),
'aucun etat non regenerable — aucune sauvegarde installee') }}
- name: Installer restic
ansible.builtin.apt:
name: "{{ client_backup_paquets }}"
@ -70,6 +87,7 @@
mode: "0700"
- name: Déployer le script de sauvegarde
when: client_backup_jobs | length > 0
ansible.builtin.template:
src: sauvegarder.sh.j2
dest: /usr/local/sbin/setops-sauvegarder.sh
@ -78,6 +96,7 @@
mode: "0700"
- name: Déployer l'unité et le timer systemd
when: client_backup_jobs | length > 0
ansible.builtin.template:
src: "{{ item.s }}"
dest: "/etc/systemd/system/{{ item.d }}"
@ -89,9 +108,34 @@
- { s: setops-sauvegarde.timer.j2, d: setops-sauvegarde.timer }
- name: Activer le timer de sauvegarde
when: not ansible_check_mode
when:
- client_backup_jobs | length > 0
- not ansible_check_mode
ansible.builtin.systemd:
name: setops-sauvegarde.timer
enabled: true
state: started
daemon_reload: true
# --- Retrait d'une sauvegarde vide heritee (le noeud ne detient plus rien) ---
- name: Arreter le timer d'une sauvegarde devenue vide
when:
- client_backup_jobs | length == 0
- not ansible_check_mode
ansible.builtin.systemd:
name: setops-sauvegarde.timer
enabled: false
state: stopped
failed_when: false
- name: Retirer le script et les unites d'une sauvegarde vide
when: client_backup_jobs | length == 0
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /usr/local/sbin/setops-sauvegarder.sh
- /etc/systemd/system/setops-sauvegarde.service
- /etc/systemd/system/setops-sauvegarde.timer
notify: Recharger systemd

View file

@ -472,6 +472,58 @@ def preuve_base_par_consommateur() -> tuple[bool, str]:
f"({len(bases)} entree(s) au registre).")
def preuve_etat_sauvegarde() -> tuple[bool, str]:
"""Tout hote qui detient de l'etat non regenerable porte `client_backup`.
Pourquoi statiquement. Le 2026-08-11, la mesure a montre que RIEN n'etait sauvegarde
dans l'ecosysteme : onze hotes lancaient chaque nuit un timer qui echouait sur
« nothing to backup », et `infra-pki-01` — qui porte les CLES DE L'AC, et qui avait
servi a prouver le Tier 0 le 2026-07-03 — n'avait aucune sauvegarde du tout. L'ecart
etait entierement lisible dans le plan depuis un mois. D-75 : ce qui se lit
statiquement se prouve statiquement, sinon on l'apprend le jour de la restauration.
RIEN N'EST CODE EN DUR : la liste des groupes detenteurs d'etat est LUE dans
`client_backup_catalogue` (defaults du role). Ajouter un role au catalogue etend donc
la preuve du meme geste — on ne peut pas declarer une donnee sauvegardable et oublier
d'exiger qu'elle le soit.
CE QU'ELLE NE TESTE PAS : que la sauvegarde s'execute, ni qu'elle contienne quoi que
ce soit. Une unite verte sur un depot vide resterait invisible ici — c'est au reel de
le dire, pas au depot.
"""
plan = RACINE / "instance" / "plan"
defauts = RACINE / "roles" / "client_backup" / "defaults" / "main.yml"
if not (plan / "serveurs.yml").is_file() or not defauts.is_file():
return True, "Aucun plan ou aucun role client_backup : rien a verifier."
# Le catalogue porte du Jinja : on ne lit que les CLES, sans rendre les valeurs.
catalogue = set(re.findall(r"^ (serveur_[a-z0-9_]+):\s*$",
defauts.read_text(encoding="utf-8"), re.M))
if not catalogue:
return False, ("`client_backup_catalogue` est vide ou illisible : aucun groupe "
"detenteur d'etat n'est declare.")
apps = (yaml.safe_load((plan / "applications.yml").read_text(encoding="utf-8"))
or {}).get("applications") or {}
serveurs = (yaml.safe_load((plan / "serveurs.yml").read_text(encoding="utf-8"))
or {}).get("serveurs") or {}
detenu: dict[str, set[str]] = {}
for app in apps.values():
groupe = str((app or {}).get("groupe") or "")
if groupe in catalogue:
detenu.setdefault(str((app or {}).get("hote") or ""), set()).add(groupe)
manques = [f"{hote} detient {sorted(groupes)}"
for hote, groupes in sorted(detenu.items())
if "client_backup" not in ((serveurs.get(hote) or {}).get("integrations") or [])]
if manques:
return False, (f"{len(manques)} hote(s) detiennent de l'etat sans sauvegarde : "
+ " | ".join(manques)
+ " — ajouter `client_backup` a leurs `integrations` dans "
"`plan/serveurs.yml`.")
return True, (f"{len(detenu)} hote(s) detiennent de l'etat, tous porteurs de "
f"`client_backup` ({len(catalogue)} groupe(s) au catalogue).")
def preuve_lecteur_declare() -> tuple[bool, str]:
"""Chaque document de `docs/` declare son lecteur des sa premiere ligne.
@ -722,6 +774,8 @@ PREUVES: list[dict] = [
"func": preuve_lecteur_declare},
{"id": "P35", "titre": "Toute application exigeant une base en a une au plan", "refs": [],
"func": preuve_base_par_consommateur},
{"id": "P36", "titre": "Tout detenteur d'etat porte une sauvegarde", "refs": [],
"func": preuve_etat_sauvegarde},
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],
"cmds": [[sys.executable, "scripts/verifier_ports.py"]]},
]