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 # 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 ## 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 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). > [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml` - **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 ## 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). | | 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)). | | 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). | | 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 ## 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 pas posséder. C'est la contrepartie exacte du régime qui protège ces décisions du prochain
`make deployer`. `make deployer`.
> **Mesure du 2026-08-11 — il n'y a rien à restaurer.** `client_backup_jobs` vaut `[]` par > **Il y a désormais quelque chose à restaurer — depuis le 2026-08-11 seulement.** Jusque-là
> défaut et rien ne le surcharge dans l'instance. Sur 11 hôtes, `setops-sauvegarde.service` > `client_backup_jobs` valait `[]` et rien ne le surchargeait : onze hôtes lançaient chaque
> échoue chaque nuit (`Fatal: nothing to backup`) ; sur `infra-pki-01`, `obs-01` et > nuit un timer qui échouait sur `Fatal: nothing to backup`, et `infra-pki-01` — les clés de
> `backup-01`, aucune sauvegarde n'est déployée — et `infra-pki-01` porte les clés de l'AC. > l'AC — n'avait aucune sauvegarde. `client_backup` dérive maintenant ses jeux de
> **Tant que ce défaut n'est pas corrigé, raser un hôte d'identité est irréversible.** > 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 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 # nom : identifiant
# commande : (optionnel) dump écrivant dans {{ staging }}/{{ nom }} # commande : (optionnel) dump écrivant dans {{ staging }}/{{ nom }}
# chemins : liste de chemins à inclure dans le snapshot restic # 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 - client_backup_ssh_privkey | length > 0
fail_msg: "vault_restic_password et vault_backup_ssh_privkey requis." 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 - name: Installer restic
ansible.builtin.apt: ansible.builtin.apt:
name: "{{ client_backup_paquets }}" name: "{{ client_backup_paquets }}"
@ -70,6 +87,7 @@
mode: "0700" mode: "0700"
- name: Déployer le script de sauvegarde - name: Déployer le script de sauvegarde
when: client_backup_jobs | length > 0
ansible.builtin.template: ansible.builtin.template:
src: sauvegarder.sh.j2 src: sauvegarder.sh.j2
dest: /usr/local/sbin/setops-sauvegarder.sh dest: /usr/local/sbin/setops-sauvegarder.sh
@ -78,6 +96,7 @@
mode: "0700" mode: "0700"
- name: Déployer l'unité et le timer systemd - name: Déployer l'unité et le timer systemd
when: client_backup_jobs | length > 0
ansible.builtin.template: ansible.builtin.template:
src: "{{ item.s }}" src: "{{ item.s }}"
dest: "/etc/systemd/system/{{ item.d }}" dest: "/etc/systemd/system/{{ item.d }}"
@ -89,9 +108,34 @@
- { s: setops-sauvegarde.timer.j2, d: setops-sauvegarde.timer } - { s: setops-sauvegarde.timer.j2, d: setops-sauvegarde.timer }
- name: Activer le timer de sauvegarde - name: Activer le timer de sauvegarde
when: not ansible_check_mode when:
- client_backup_jobs | length > 0
- not ansible_check_mode
ansible.builtin.systemd: ansible.builtin.systemd:
name: setops-sauvegarde.timer name: setops-sauvegarde.timer
enabled: true enabled: true
state: started state: started
daemon_reload: true 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).") 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]: def preuve_lecteur_declare() -> tuple[bool, str]:
"""Chaque document de `docs/` declare son lecteur des sa premiere ligne. """Chaque document de `docs/` declare son lecteur des sa premiere ligne.
@ -722,6 +774,8 @@ PREUVES: list[dict] = [
"func": preuve_lecteur_declare}, "func": preuve_lecteur_declare},
{"id": "P35", "titre": "Toute application exigeant une base en a une au plan", "refs": [], {"id": "P35", "titre": "Toute application exigeant une base en a une au plan", "refs": [],
"func": preuve_base_par_consommateur}, "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": [], {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],
"cmds": [[sys.executable, "scripts/verifier_ports.py"]]}, "cmds": [[sys.executable, "scripts/verifier_ports.py"]]},
] ]