From 8eedeaf31f93caa0f405ec8b747b225b3d6d2365 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 11 Aug 2026 20:50:46 -0400 Subject: [PATCH] sauvegarde : le catalogue derive du groupe, et P36 le prouve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 52 ++++++++++++++++++++++ docs/audit/preuve-2026-08-11.md | 3 +- docs/autorisation.md | 14 +++--- roles/client_backup/defaults/main.yml | 62 ++++++++++++++++++++++++++- roles/client_backup/handlers/main.yml | 4 ++ roles/client_backup/tasks/main.yml | 46 +++++++++++++++++++- scripts/prouver.py | 54 +++++++++++++++++++++++ 7 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 roles/client_backup/handlers/main.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f7c5c0..d7644bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/audit/preuve-2026-08-11.md b/docs/audit/preuve-2026-08-11.md index 8862904..52c0c03 100644 --- a/docs/audit/preuve-2026-08-11.md +++ b/docs/audit/preuve-2026-08-11.md @@ -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 diff --git a/docs/autorisation.md b/docs/autorisation.md index 18a02da..8dd5afd 100644 --- a/docs/autorisation.md +++ b/docs/autorisation.md @@ -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 -m shell -a "slapcat -b dc= > /tmp/annuaire.ldif" -b diff --git a/roles/client_backup/defaults/main.yml b/roles/client_backup/defaults/main.yml index 27e32c4..ca9a78d 100644 --- a/roles/client_backup/defaults/main.yml +++ b/roles/client_backup/defaults/main.yml @@ -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 }} diff --git a/roles/client_backup/handlers/main.yml b/roles/client_backup/handlers/main.yml new file mode 100644 index 0000000..34a9aca --- /dev/null +++ b/roles/client_backup/handlers/main.yml @@ -0,0 +1,4 @@ +--- +- name: Recharger systemd + ansible.builtin.systemd: + daemon_reload: true diff --git a/roles/client_backup/tasks/main.yml b/roles/client_backup/tasks/main.yml index 42f57db..af2c7ec 100644 --- a/roles/client_backup/tasks/main.yml +++ b/roles/client_backup/tasks/main.yml @@ -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 diff --git a/scripts/prouver.py b/scripts/prouver.py index 6422e00..8ca2402 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -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"]]}, ]