temoins : l'instantane d'avant rasage est garde, et l'etat remis lui est compare

M4 a reconstruit Technolibre par le site qui la nomme ; les 7 jeux sont revenus
de l'instantane de 11:34, mais le premier depot d'apres reconstruction l'a chasse
par --keep-daily le jour meme : plus rien a quoi comparer.

Le depot d'avant rasage est etiquete avant-raser et garde jusqu'a la
reconstruction suivante. Le bilan dit ce qui a ete restaure et si c'est encore
au depot. setops-restaurer temoins et make temoins-etat comparent l'etat vivant
a cet instantane : une perte ou une identite changee est un ecart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-07 12:48:00 -04:00
parent a204ead8ab
commit 11b5bb5733
16 changed files with 986 additions and 35 deletions

View file

@ -1,5 +1,66 @@
# CHANGELOG — Set-OPS
## 2026-10-07 (101) — M4 : Technolibre reconstruite par le site qui la nomme ; l'instantané d'avant rasage ne survivait pas
**M4, la preuve par reconstruction** (`make reconstruire-locataire TENANT=OPS-Technolibre`,
lancée par l'exploitant, 44 min, journal `OPS-Technolibre/logs/reconstruction-20261007-113441.log`) :
- le runner du site tire `a204ead` ; `raser` s'annonce « Locataire nommé … d'après sa face
publiée », 13/13 VM détruites ; `creer` range les 13 VM dans le pool `OPS-Technolibre` ;
- insémination, armement, montage : `flux`, socle, `deployer-tout`, `valider` à 0 échec
(courriel de bout en bout, HTTPS par l'edge, Prometheus, restauration d'un instantané) ;
- pare-feu Proxmox : 13/13 actifs, `connectivite` saine, 0 critique dans Icinga ;
- **les 7 jeux d'état restaurés depuis l'instantané pris à 11:34:44**, à l'étape
`sauvegarder`, juste avant le rasage (marqueurs de chaque nœud, journal restic).
La matérialisation est donc prouvée sur le cluster : le site crée, rase et range un
locataire d'après sa face, sans monter son dépôt.
**Le défaut** : à 12:16, le premier dépôt de chaque machine reconstruite (gestionnaire
« Premier rapport à Icinga ») a **chassé l'instantané de 11:34** par `--keep-daily`, le
même jour, sur les 7 nœuds. Rien de perdu (le dépôt de 12:16 porte l'état remis), mais plus
rien à quoi le comparer, et plus moyen de refaire une restauration depuis l'avant-rasage.
Les témoins prévus après coup n'ont pas pu être pris.
Et une lecture trompeuse : le bilan affichait un « candidat » recalculé à l'heure du bilan
(l'instantané de la veille, une fois celui de 11:34 chassé), sans dire ce qui avait été
restauré. Je m'y suis trompé un moment.
**Fait** :
- **Le dépôt d'avant rasage est étiqueté** `avant-raser` (`setops-sauvegarder.sh
--avant-raser`, nouvelle unité `setops-sauvegarde-avant-raser.service`, lancée par
`make sauvegarder-maintenant` et l'étape `sauvegarder`). La rétention garde l'étiquette
(`--keep-tag`) **jusqu'à la reconstruction suivante** (décision de l'exploitant) : le dépôt
suivant la reçoit, l'ancien la perd, **après** le dépôt, jamais avant. Un nœud sans la
nouvelle unité refuse de déposer plutôt que de déposer sans étiquette.
- **Le bilan** (`setops-restaurer etat`) nomme l'instantané `avant-raser`, dit que le
candidat est « ce que choisirait une restauration lancée maintenant », et marque chaque
instantané restauré « (au dépôt) » ou « (RETIRÉ du dépôt) ».
- **Les témoins** : `setops-restaurer temoins` extrait l'instantané d'avant rasage (ou
`--candidat`), lit l'annuaire vivant, et `setops-temoins` compare. **Écart** = une perte
(fichier, courriel, entrée, base, rôle, table) ou une identité changée (clés et
certificats de l'AC, DKIM, `instanceid` de Nextcloud, `userPassword` d'une entrée).
Le reste est listé (« modifié », « retiré », « nouveau ») sans être un écart.
`make temoins-etat [HOTE=] [SOURCE=candidat]` ; l'étape `bilan` de la reconstruction les
lance et s'arrête sur un écart.
- `temoins-etat` et sa variable `SOURCE` entrent au registre des runbooks (la console les
proposera) ; le runbook d'exploitation et le README du rôle décrivent l'étiquette et les témoins.
**Éprouvé** :
- `test_temoins.py` (nouveau, dans `make test`) : 18 contrôles sur des arbres fabriqués, dans
les deux sens (la vie ordinaire passe ; clé de l'AC, DKIM, instanceid, `sysadmin` remis à
l'amorçage, entrée, base, rôle, table, courriel et fichier perdus échouent).
- `test_restauration.py` : les témoins prennent l'instantané **étiqueté**, même plus ancien
que le candidat ; un instantané chassé est dit « RETIRÉ » ; la sauvegarde étiquette, retire
l'étiquette au seul précédent et après le dépôt, et la rétention la garde aussi au dépôt
ordinaire.
- Témoins : `--keep-tag` retiré, ou les témoins ramenés au candidat, trois tests échouent.
- `shellcheck` des deux scripts rendus : rien, avant comme après.
- `make verifier` conforme, **94/94**.
**Reste** : déployer `client_backup` sur les deux locataires (les nœuds de Chezlepro n'ont pas
la nouvelle unité : `sauvegarder-maintenant` y refuserait), puis reconstruire Chezlepro avec
des témoins pris pour de vrai, puis étiqueter la release.
## 2026-10-07 (100) — Pré-vol de M4 : la face tient devant le cluster, deux en-têtes disaient faux
**Le pré-vol**, lancé par l'exploitant depuis le runner du site, en lecture seule :

View file

@ -317,6 +317,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
python3 scripts/tests/test_inventory_host.py
python3 scripts/tests/test_raser.py
python3 scripts/tests/test_raser_resultat.py
python3 scripts/tests/test_temoins.py
python3 scripts/tests/test_adressage_derive.py
python3 scripts/tests/test_gui_intrants.py
python3 scripts/tests/test_runbooks.py
@ -931,7 +932,7 @@ flux-verifier: ## Verifie que le registre des flux correspond aux meta/flux.yml
valider: ansible-runtime ## Passe la recette de validation sur la flotte
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/valider.yml
.PHONY: restauration-etat restauration-renoncer sauvegarder-maintenant
.PHONY: restauration-etat restauration-renoncer sauvegarder-maintenant temoins-etat
sauvegarder-maintenant: ansible-runtime ## Depose l'etat de chaque noeud TOUT DE SUITE (avant de raser) — HOTE=<hote> optionnel
@set -e; $(VAULT_UNE_FOIS) \
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \
@ -945,6 +946,15 @@ restauration-etat: ansible-runtime ## Ce qui attend dans le depot, et ce que cha
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \
$(if $(HOTE),--limit "$(HOTE)")
# LES TEMOINS (2026-10-07) — l'etat remis est-il celui d'avant ? Compare l'etat vivant a
# l'instantane etiquete au rasage (`sauvegarder-maintenant`). `SOURCE=candidat` compare au
# dernier instantane d'avant la naissance du noeud, faute d'etiquette. Lecture seule.
temoins-etat: ansible-runtime ## L'etat vivant est-il celui d'avant le rasage ? (lecture seule) — HOTE= SOURCE=candidat optionnels
@set -e; $(VAULT_UNE_FOIS) \
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \
-e restauration_action=temoins $(if $(SOURCE),-e restauration_source="$(SOURCE)") \
$(if $(HOTE),--limit "$(HOTE)")
restauration-renoncer: ansible-runtime ## Ecarte l'etat anterieur d'un jeu, sans le remettre — HOTE= JEU= CONFIRMER=true
@set -e; \
if [[ "$(CONFIRMER)" != "true" || -z "$(HOTE)" || -z "$(JEU)" ]]; then \

View file

@ -94,6 +94,10 @@ variables:
invite: "Armer sans marquer la pause"
valeurs: [oui]
facultatif: true
SOURCE:
invite: "Comparer a quel instantane (vide = celui d'avant rasage)"
valeurs: [candidat]
facultatif: true
VMID:
invite: "Identifiant Proxmox de la VM"
DIALECTE:
@ -519,7 +523,8 @@ runbooks:
pourquoi: >-
Deposer l'etat de chaque noeud JUSTE AVANT de raser. La reconstruction remet le
dernier instantane anterieur a la naissance des machines : sans ce depot, c'est
celui de la nuit, et la journee est perdue.
celui de la nuit, et la journee est perdue. Il est etiquete « avant-raser » et
garde jusqu'a la reconstruction suivante : c'est contre lui que jugent les temoins.
- cible: raser
nature: destructif
portee: poste
@ -542,6 +547,14 @@ runbooks:
pourquoi: >-
Ce que chaque jeu est devenu : restaure (et depuis quel instantane), neuf, en
place. Un jeu EN ATTENTE bloque la sauvegarde de son noeud — c'est voulu.
- cible: temoins-etat
nature: mesure
variables: [HOTE, SOURCE]
pourquoi: >-
L'etat vivant est-il celui d'avant le rasage ? Une perte, ou une identite changee
(cles de l'AC, DKIM, instanceid de Nextcloud, mot de passe d'une entree de
l'annuaire), est un ecart. Le bilan dit ce que les roles ont fait ; les temoins
mesurent ce qui est revenu.
- cible: restauration-renoncer
nature: destructif
variables: [HOTE, JEU]

View file

@ -142,16 +142,29 @@ marqueur dans `/etc/setops/restauration/<jeu>` ; ce qui a été remplacé est mi
écarté : sinon `restic forget --keep-daily` chasserait l'instantané d'avant au profit de
l'état neuf du même jour.
**L'instantané d'avant rasage est étiqueté** `avant-raser` (depuis le 2026-10-07) et la
rétention le garde **jusqu'à la reconstruction suivante**, qui lui retire l'étiquette. Sans
elle, le premier dépôt de la machine reconstruite le chassait le jour même : après la
reconstruction de Technolibre (M4), il ne restait rien à quoi comparer l'état remis.
**Les témoins** comparent l'état vivant à cet instantané. Un écart, c'est une **perte**
(fichier, courriel, entrée de l'annuaire, base, rôle, table) ou une **identité changée** :
clés et certificats de l'AC, clé DKIM, `instanceid` de Nextcloud, mot de passe d'une entrée
de l'annuaire. Le reste (un courriel reçu depuis, une table de sessions, le bayes de rspamd,
un cache) est listé sans être un écart. La reconstruction les lance à son étape `bilan`.
Les gestes :
```
make sauvegarder-maintenant # JUSTE AVANT de raser : sinon on perd la journée
make restauration-etat [HOTE=...] # ce que chaque jeu est devenu
make sauvegarder-maintenant # JUSTE AVANT de raser : étiqueté « avant-raser »
make restauration-etat [HOTE=...] # ce que chaque jeu est devenu, et si son instantané est encore au dépôt
make temoins-etat [HOTE=...] # l'état vivant contre l'instantané d'avant rasage
make temoins-etat SOURCE=candidat # faute d'étiquette : contre le dernier d'avant la naissance
make restauration-renoncer HOTE=.. JEU=.. CONFIRMER=true # écarter un état, sans le remettre
-e client_backup_restauration_instantane=<id> # imposer un instantané, par hôte
```
Sur un nœud, sans Ansible : `setops-restaurer etat`, et les répétitions qui ne touchent à
Sur un nœud, sans Ansible : `setops-restaurer etat`, `setops-restaurer temoins`, et les répétitions qui ne touchent à
rien — `setops-restaurer annuaire --essai <base_dn>`, `base <nom> --vers epreuve`,
`fichiers --vers /var/tmp/epreuve <chemin>`.

View file

@ -6,6 +6,10 @@
# make sauvegarder-maintenant [HOTE=...]
# deposer TOUT DE SUITE, et attendre que ce soit fait. A faire juste avant `raser` :
# sinon la reconstruction remet l'etat de la derniere nuit, et perd la journee ;
# make temoins-etat [HOTE=...] [SOURCE=candidat]
# l'etat VIVANT est-il celui d'avant le rasage ? Compare a l'instantane etiquete
# « avant-raser » (lecture seule). Une perte, ou une identite changee (cles de l'AC,
# DKIM, instanceid, mot de passe d'une entree de l'annuaire), fait echouer le jeu ;
# make restauration-renoncer HOTE=<hote> JEU=<jeu> CONFIRMER=true
# ECARTER un etat anterieur sans le remettre. La sauvegarde du noeud, qui refusait
# de deposer pour ne pas le chasser de la retention, reprend — et l'etat d'avant
@ -20,6 +24,7 @@
vars:
restauration_action: etat
restauration_jeu: ""
restauration_source: avant-raser
tasks:
- name: Refuser un renoncement sans jeu nomme
ansible.builtin.assert:
@ -49,14 +54,66 @@
register: restauration_unite
when: restauration_action == 'sauvegarder'
- name: Deposer maintenant
# LE DEPOT D'AVANT RASAGE EST ETIQUETE (2026-10-07), et la retention le garde jusqu'a la
# reconstruction suivante. Un noeud qui n'a pas encore cette unite deposerait SANS
# etiquette, et la premiere sauvegarde d'apres reconstruction le chasserait : c'est
# exactement ce qui s'est passe a M4. On refuse plutot que de deposer a moitie.
- name: L'unite du depot d'avant rasage est-elle deployee ?
ansible.builtin.stat:
path: /etc/systemd/system/setops-sauvegarde-avant-raser.service
register: restauration_unite_avant_raser
when:
- restauration_action == 'sauvegarder'
- restauration_unite.stat.exists
- name: Refuser un depot que la retention ne garderait pas
ansible.builtin.assert:
that:
- restauration_unite_avant_raser.stat.exists
fail_msg: >-
setops-sauvegarde-avant-raser.service absente : redeployer client_backup
(make appliquer GROUPE=client_backup) avant de raser.
when:
- restauration_action == 'sauvegarder'
- restauration_unite.stat.exists
- name: Deposer maintenant, etiquete avant rasage
ansible.builtin.systemd:
name: setops-sauvegarde.service
name: setops-sauvegarde-avant-raser.service
state: started
when:
- restauration_action == 'sauvegarder'
- restauration_unite.stat.exists
# Code 4 = rien a comparer (aucun etat, ou aucun instantane d'avant rasage) : ce n'est
# pas un echec. Code 1 = ecart : on affiche d'abord, on echoue ensuite.
- name: Temoins — l'etat vivant contre l'instantane d'avant rasage
ansible.builtin.command:
argv: >-
{{ ['/usr/local/sbin/setops-restaurer', 'temoins']
+ (['--candidat'] if restauration_source == 'candidat' else []) }}
register: restauration_temoins
changed_when: false
failed_when: false
when: restauration_action == 'temoins'
- name: Temoins
ansible.builtin.debug:
msg: "{{ (restauration_temoins.stdout_lines | default([])) + (restauration_temoins.stderr_lines | default([])) }}"
when: restauration_action == 'temoins'
- name: Temoins — verdict
ansible.builtin.assert:
that:
- restauration_temoins.rc in [0, 4]
fail_msg: >-
{{ 'ECART : une perte, ou une identite changee (voir ci-dessus).'
if restauration_temoins.rc == 1 else
'setops-restaurer temoins a echoue (code ' ~ restauration_temoins.rc ~ ') : '
~ 'client_backup est-il deploye ?' }}
quiet: true
when: restauration_action == 'temoins'
- name: Ecarter l'etat anterieur de ce jeu
ansible.builtin.command:
argv: [/usr/local/sbin/setops-restaurer, acter, "{{ restauration_jeu }}", abandonne]

View file

@ -45,6 +45,7 @@ Sans eux, le rôle refuse de s'exécuter (assertion).
| `client_backup_repo` | `sftp:restic@<cible>:<hôte>` | Un sous-dossier par nœud |
| `client_backup_staging` | `/var/backups/setops` | Préparation des dumps |
| `client_backup_retention` | `--keep-daily 7 --keep-weekly 4 --keep-monthly 6` | `restic forget` |
| `client_backup_etiquette_avant_raser` | `avant-raser` | étiquette du dépôt d'avant rasage, gardée par la rétention jusqu'à la reconstruction suivante |
| `client_backup_horaire` | `*-*-* 02:30:00` | Timer systemd |
| `client_backup_jobs` | `[]` | **À déclarer par nœud** |
@ -58,6 +59,8 @@ Sans eux, le rôle refuse de s'exécuter (assertion).
- Restauration : **faite par la reconstruction elle-même** depuis le 2026-09-30 — chaque rôle
propriétaire inclut `tasks/restaurer.yml` et remet l'état de l'incarnation précédente ;
l'outil de nœud est `setops-restaurer` (cf. `docs/runbooks-exploitation.md` §5.0).
- Témoins : `setops-restaurer temoins` (comparateur `setops-temoins`) confronte l'état vivant
à l'instantané d'avant rasage ; `make temoins-etat`, et l'étape `bilan` de la reconstruction.
## Prérequis
- `serveur_backup` déployé, et sa `serveur_backup_pubkey` correspondant à la clé privée de

View file

@ -20,6 +20,10 @@ client_backup_staging: "/var/backups/setops"
# Rétention (restic forget).
client_backup_retention: "--keep-daily 7 --keep-weekly 4 --keep-monthly 6"
# L'instantane d'avant rasage (`make sauvegarder-maintenant`) porte cette etiquette, et la
# retention la garde EN PLUS de la regle ci-dessus : jusqu'a la reconstruction suivante
# (decision de l'exploitant, 2026-10-07). Voir `templates/sauvegarder.sh.j2`.
client_backup_etiquette_avant_raser: "avant-raser"
# Planification (timer systemd).
client_backup_horaire: "*-*-* 02:30:00"

View file

@ -0,0 +1,309 @@
#!/usr/bin/env python3
# Géré par Set-OPS (rôle client_backup). Ne pas éditer à la main.
"""Témoins d'état : l'état VIVANT de ce noeud est-il celui qu'il avait avant le rasage ?
POURQUOI (2026-10-07). Le bilan d'une reconstruction dit ce que chaque rôle a FAIT
(« restauré depuis tel instantané ») : c'est le système qui juge son propre travail. Le
2026-09-30, les empreintes avaient été relevées à la main, avant et après ; rien ne le
gardait dans le dépôt. Le 2026-10-07 (M4), l'instantané d'avant le rasage a même disparu
du dépôt dans l'heure, chassé par la rétention : il ne restait rien à quoi comparer.
Ce script compare l'instantané extrait par `setops-restaurer temoins` (sous `--avant`) à ce
qui vit sur le noeud. Il ne lit que ; il n'écrit rien.
VERDICTS, PAR JEU :
IDENTIQUE rien ne diffère ;
CONFORME rien de perdu, l'identité est intacte ; des changements ordinaires, listés
(un courriel reçu depuis, une table de sessions, le bayes de rspamd) ;
ECART une PERTE (fichier, entrée, base, table), ou un objet d'IDENTITÉ changé :
clés et certificats de l'AC, clé DKIM, `instanceid` de Nextcloud, mot de
passe d'une entrée de l'annuaire. Exactement ce que la reconstruction du
2026-09-30 avait perdu sans que rien ne le dise.
Code de sortie : 0 sans écart, 1 avec au moins un écart, 2 erreur d'usage.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import re
import subprocess
import sys
from pathlib import Path
LISTE_MAX = 8 # détails affichés par catégorie ; le compte, lui, est toujours entier
# Attributs que l'annuaire tient lui-même : ils changent à chaque écriture, ou à chaque
# rejeu (`slapadd` les repose), sans que la donnée ait bougé.
OPERATIONNELS = {"entrycsn", "modifytimestamp", "modifiersname", "createtimestamp",
"creatorsname", "contextcsn"}
class Rapport:
def __init__(self, jeu: str) -> None:
self.jeu = jeu
self.ecarts: list[str] = [] # pertes et identités changées
self.changes: list[str] = [] # changements ordinaires
self.retires: list[str] = [] # disparitions ordinaires (un cache, une base compactee)
self.nouveaux: list[str] = []
self.compte = 0 # objets comparés
def verdict(self) -> str:
if self.ecarts:
return "ECART"
return "CONFORME" if (self.changes or self.retires or self.nouveaux) else "IDENTIQUE"
def lignes(self, unite: str) -> list[str]:
out = [f" {self.jeu:<12} {self.verdict():<10} {self.compte} {unite}"]
for titre, liste in (("ECART", self.ecarts), ("modifie", self.changes),
("retire", self.retires), ("nouveau", self.nouveaux)):
for x in liste[:LISTE_MAX]:
out.append(f" {titre:<8} {x}")
if len(liste) > LISTE_MAX:
out.append(f" {titre:<8} ... et {len(liste) - LISTE_MAX} autre(s)")
return out
def empreinte(p: Path) -> str:
h = hashlib.sha256()
with p.open("rb") as f:
for bloc in iter(lambda: f.read(1 << 20), b""):
h.update(bloc)
return h.hexdigest()
def arbre(racine: Path, normaliser=None) -> dict[str, str]:
"""{chemin relatif (normalisé) : sha256} des fichiers réguliers sous `racine`."""
if racine.is_file():
return {racine.name: empreinte(racine)}
out: dict[str, str] = {}
if not racine.is_dir():
return out
for p in sorted(racine.rglob("*")):
if p.is_file() and not p.is_symlink():
rel = p.relative_to(racine).as_posix()
out[normaliser(rel) if normaliser else rel] = empreinte(p)
return out
def comparer_arbres(r: Rapport, avant: dict[str, str], vivant: dict[str, str], *,
strict, perte_stricte) -> None:
"""`strict(rel)` : un changement est un écart ; `perte_stricte(rel)` : une perte aussi."""
r.compte += len(avant)
for rel, h in sorted(avant.items()):
if rel not in vivant:
if perte_stricte(rel):
r.ecarts.append(f"perdu : {rel}")
else:
r.retires.append(rel)
elif vivant[rel] != h:
(r.ecarts if strict(rel) else r.changes).append(f"{'IDENTITE ' if strict(rel) else ''}{rel}")
r.nouveaux.extend(sorted(set(vivant) - set(avant)))
def jamais(_rel: str) -> bool:
return False
def toujours(_rel: str) -> bool:
return True
# --- Les jeux de fichiers ------------------------------------------------------------
def maildir(rel: str) -> str:
"""Un message garde son nom de base : `new/` -> `cur/` à la lecture, `:2,S` aux drapeaux."""
rel = re.sub(r"(^|/)(new|cur)/", r"\1msg/", rel)
return re.sub(r":2,[A-Za-z]*$", "", rel)
def est_message(rel: str) -> bool:
return "/msg/" in f"/{rel}"
def jeu_fichiers(jeu: str, chemins: list[str], avant: Path) -> Rapport:
r = Rapport(jeu)
for c in chemins:
a_avant, a_vivant = avant / c.lstrip("/"), Path(c)
if jeu == "step_ca":
# Les clés et les certificats SONT l'AC : un octet de différence et plus rien
# de ce qu'elle a signé ne se vérifie. Sa base (badger) se compacte d'elle-même.
identite = lambda rel: rel.startswith(("certs/", "secrets/")) # noqa: E731
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=identite,
perte_stricte=identite)
elif jeu == "courriel":
comparer_arbres(r, arbre(a_avant, maildir), arbre(a_vivant, maildir),
strict=jamais, perte_stricte=est_message)
elif jeu == "rspamd":
# La clé DKIM est l'identité du domaine ; le bayes, lui, apprend tous les jours.
dkim = lambda rel: rel.startswith("dkim/") # noqa: E731
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=dkim, perte_stricte=dkim)
elif jeu == "nextcloud":
if c.rstrip("/").endswith("/config"):
instance_id(r, a_avant / "config.php", a_vivant / "config.php")
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=jamais,
perte_stricte=jamais)
else:
# Les fichiers des PERSONNES ; `appdata_*` est un cache que Nextcloud refait.
fichier = lambda rel: re.match(r"^(?!appdata_)[^/]+/files/", rel) is not None # noqa: E731
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=jamais,
perte_stricte=fichier)
else:
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=jamais,
perte_stricte=toujours)
return r
def instance_id(r: Rapport, avant: Path, vivant: Path) -> None:
def lire(p: Path) -> str:
m = re.search(r"'instanceid'\s*=>\s*'([^']+)'", p.read_text(errors="replace")) if p.is_file() else None
return m.group(1) if m else ""
a, v = lire(avant), lire(vivant)
if a and a != v:
r.ecarts.append(f"IDENTITE instanceid : {a} -> {v or '(absent)'}")
# --- L'annuaire ----------------------------------------------------------------------
def entrees_ldif(texte: str) -> dict[str, dict[str, list[str]]]:
"""{dn : {attribut : [valeurs triées]}}, attributs opérationnels exclus."""
deplie = re.sub(r"\n ", "", texte.replace("\r\n", "\n"))
out: dict[str, dict[str, list[str]]] = {}
for bloc in deplie.split("\n\n"):
attrs: dict[str, list[str]] = {}
dn = ""
for ligne in bloc.splitlines():
if not ligne or ligne.startswith("#") or ":" not in ligne:
continue
nom, _, val = ligne.partition(":")
if val.startswith(":"):
val = base64.b64decode(val[1:].strip()).decode("utf-8", "replace")
else:
val = val.strip()
if nom.lower() == "dn":
dn = val.lower()
elif nom.lower() not in OPERATIONNELS:
attrs.setdefault(nom.lower(), []).append(val)
if dn:
out[dn] = {k: sorted(v) for k, v in attrs.items()}
return out
def jeu_annuaire(avant_ldif: Path, vivant_ldif: Path) -> Rapport:
r = Rapport("openldap")
if not avant_ldif.is_file():
r.ecarts.append(f"l'instantane n'emporte pas d'annuaire ({avant_ldif.name})")
return r
a = entrees_ldif(avant_ldif.read_text(errors="replace"))
v = entrees_ldif(vivant_ldif.read_text(errors="replace")) if vivant_ldif.is_file() else {}
r.compte = len(a)
for dn, attrs in sorted(a.items()):
if dn not in v:
r.ecarts.append(f"perdue : {dn}")
continue
if attrs == v[dn]:
continue
# Le mot de passe d'une entrée, c'est ce que la reconstruction du 2026-09-30 avait
# remis à la valeur d'amorçage sans que rien ne le dise.
if attrs.get("userpassword") != v[dn].get("userpassword"):
r.ecarts.append(f"IDENTITE userPassword : {dn}")
diff = sorted(k for k in set(attrs) | set(v[dn])
if k != "userpassword" and attrs.get(k) != v[dn].get(k))
if diff:
r.changes.append(f"{dn} ({', '.join(diff)})")
r.nouveaux.extend(sorted(set(v) - set(a)))
return r
# --- PostgreSQL ----------------------------------------------------------------------
def lire_dump(texte: str) -> tuple[set[str], set[str], dict[tuple[str, str], int]]:
"""(bases, rôles, {(base, table) : lignes}) d'après un `pg_dumpall`."""
bases = set(re.findall(r"^CREATE DATABASE (\S+) ", texte, re.M))
roles = set(re.findall(r"^CREATE ROLE (\S+);", texte, re.M))
lignes: dict[tuple[str, str], int] = {}
base, table, n = "", None, 0
for ligne in texte.splitlines():
if table is not None:
if ligne == "\\.":
lignes[(base, table)] = n
table = None
else:
n += 1
elif ligne.startswith("\\connect "):
base = ligne.split()[-1]
else:
m = re.match(r"^COPY (\S+) .*FROM stdin;$", ligne)
if m:
table, n = m.group(1), 0
return bases, roles, lignes
def psql(base: str, sql: str) -> list[str] | None:
r = subprocess.run(["runuser", "-u", "postgres", "--", "psql", "-tAX", "-d", base, "-c", sql],
capture_output=True, text=True)
return r.stdout.splitlines() if r.returncode == 0 else None
def jeu_postgresql(dump: Path, interroger=psql) -> Rapport:
r = Rapport("postgresql")
if not dump.is_file():
r.ecarts.append(f"l'instantane n'emporte pas de dump ({dump.name})")
return r
bases, roles, lignes = lire_dump(dump.read_text(errors="replace"))
vivantes = set(interroger("postgres", "select datname from pg_database where not datistemplate") or [])
roles_vivants = set(interroger("postgres", "select rolname from pg_roles") or [])
r.compte = len(lignes)
for b in sorted(bases - vivantes):
r.ecarts.append(f"base perdue : {b}")
for x in sorted(roles - roles_vivants):
r.ecarts.append(f"role perdu : {x}")
for (b, t), n in sorted(lignes.items()):
if b not in vivantes:
continue
compte = interroger(b, f"select count(*) from {t}")
if compte is None:
r.ecarts.append(f"table perdue : {b}.{t}")
elif int(compte[0]) != n:
r.changes.append(f"{b}.{t} : {n} -> {compte[0]} ligne(s)")
r.nouveaux.extend(f"base {b}" for b in sorted(vivantes - bases - {"postgres"}))
return r
# --- Programme -----------------------------------------------------------------------
def main(argv: list[str] | None = None, interroger=psql) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--avant", required=True, type=Path, help="racine où l'instantané est extrait")
ap.add_argument("--instantane", required=True)
ap.add_argument("--heure", default="")
ap.add_argument("--hote", default="")
ap.add_argument("--jeu", action="append", default=[], help="NOM=CHEMIN[:CHEMIN...]")
ap.add_argument("--ldif", default="", help="chemin du LDIF dans l'instantané")
ap.add_argument("--ldif-vivant", type=Path, default=None)
ap.add_argument("--dump", default="", help="chemin du pg_dumpall dans l'instantané")
a = ap.parse_args(argv)
print(f"TEMOINS de {a.hote} : instantane {a.instantane} ({a.heure}) contre l'etat vivant")
if not a.jeu:
print(" SANS OBJET : ce noeud ne detient aucun etat.")
return 0
ecart = False
for spec in a.jeu:
nom, _, chemins = spec.partition("=")
if nom == "openldap":
r, unite = jeu_annuaire(a.avant / a.ldif.lstrip("/"), a.ldif_vivant or Path("/nonexistent")), "entree(s)"
elif nom == "postgresql":
r, unite = jeu_postgresql(a.avant / a.dump.lstrip("/"), interroger), "table(s)"
else:
r, unite = jeu_fichiers(nom, [c for c in chemins.split(":") if c], a.avant), "fichier(s)"
print("\n".join(r.lignes(unite)))
ecart = ecart or bool(r.ecarts)
print("ECART : une perte, ou une identite qui a change." if ecart
else "Aucun ecart : rien de perdu, l'identite est intacte.")
return 1 if ecart else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -66,3 +66,13 @@
owner: root
group: root
mode: "0700"
# LES TEMOINS (2026-10-07) : `setops-restaurer temoins` extrait l'instantane d'avant rasage,
# puis ce comparateur le confronte a l'etat vivant. Lecture seule.
- name: Déployer le comparateur des témoins (setops-temoins)
ansible.builtin.copy:
src: setops-temoins.py
dest: /usr/local/sbin/setops-temoins
owner: root
group: root
mode: "0700"

View file

@ -67,6 +67,7 @@
loop:
- { s: setops-sauvegarde.service.j2, d: setops-sauvegarde.service }
- { s: setops-sauvegarde.timer.j2, d: setops-sauvegarde.timer }
- { s: setops-sauvegarde-avant-raser.service.j2, d: setops-sauvegarde-avant-raser.service }
- name: Activer le timer de sauvegarde
when:
@ -117,6 +118,7 @@
- /usr/local/sbin/setops-sauvegarder.sh
- /etc/systemd/system/setops-sauvegarde.service
- /etc/systemd/system/setops-sauvegarde.timer
- /etc/systemd/system/setops-sauvegarde-avant-raser.service
- /usr/local/sbin/setops-verifier-mon-depot.sh
- /usr/local/sbin/setops-verifier-restauration.sh
- /etc/systemd/system/setops-verification-depot.service

View file

@ -34,6 +34,9 @@
# --remplacer --vers AUTRE_NOM (repetition dans une base jetable)
# acter JEU ETAT [INSTANTANE] poser le marqueur (restaure, neuf, en_place, abandonne)
# garde (pour la sauvegarde) refuser si un etat attend
# temoins [--candidat | --instantane ID] l'etat vivant contre l'instantane d'avant rasage
# (par defaut le dernier etiquete « avant-raser ») ; lecture seule ; code 1 = ecart,
# 4 = rien a comparer
# Options communes : --instantane ID.
set -euo pipefail
@ -52,6 +55,9 @@ CLE_HOTE="/etc/ssh/ssh_host_ed25519_key.pub"
LDIF="{{ client_backup_staging }}/openldap/annuaire.ldif"
DUMP_PG="{{ client_backup_staging }}/postgresql/toutes-bases.sql"
JEUX=({% for j in client_backup_jobs %} "{{ j.nom }}"{% endfor %} )
# Les chemins de chaque jeu, separes par « : » — ce que les temoins extraient et comparent.
declare -A CHEMINS_JEU=({% for j in client_backup_jobs %} ["{{ j.nom }}"]="{{ j.chemins | join(':') }}"{% endfor %} )
ETIQUETTE_AVANT_RASER="{{ client_backup_etiquette_avant_raser }}"
dire() { printf '%s\n' "$*" >&2; }
mourir() { dire "setops-restaurer: $*"; exit 1; }
@ -94,6 +100,27 @@ if c:
' "$(naissance)" "${impose}"
}
# « ID HEURE » du dernier instantane etiquete avant rasage, ou rien ; code 2 = depot illisible.
avant_raser() {
local json rc
set +e
json=$(restic snapshots --host "${MOI}" --tag "${ETIQUETTE_AVANT_RASER}" --json 2>/dev/null)
rc=$?
set -e
if (( rc == 10 )); then return 0; fi
if (( rc != 0 )); then dire "depot illisible (restic rc=${rc}) : ${RESTIC_REPOSITORY}"; return 2; fi
printf '%s' "${json}" | python3 -c '
import json, sys
import datetime, re
def t(x):
s = re.sub(r"(\.\d{6})\d+", r"\1", x["time"])
return datetime.datetime.fromisoformat(re.sub(r"Z$", "+00:00", s)).timestamp()
s = sorted(json.load(sys.stdin) or [], key=t)
if s:
print(s[-1]["short_id"], s[-1]["time"][:19])
'
}
# Instantane a utiliser : impose, sinon le candidat. Vide = rien a restaurer.
INSTANTANE=""
resoudre_instantane() {
@ -274,24 +301,78 @@ cmd_choisir() {
"${c%% *}" "$( [[ -n "${c}" ]] && printf '%s' "${c#* }" )" "$(naissance)"
}
# LE CANDIDAT EST CALCULE MAINTENANT (2026-10-07). Il dit ce qu'une restauration lancee a
# cette minute choisirait, pas ce qui a ete restaure : au bilan de M4, il nommait
# l'instantane de la veille, et l'on a cru un moment que la journee etait perdue. Ce qui a
# ete restaure est dans le marqueur de chaque jeu ; on dit desormais s'il est encore au depot.
cmd_etat() {
local c
local c a ids id present
echo "hote : ${MOI}"
echo "naissance : $(date -d "@$(naissance)" -Is)"
c=$(candidat "") || exit 2
# (Pas d'apostrophe dans `${c:-...}` : entre guillemets doubles, bash l'y lit comme un
# guillemet ouvrant, et tout le script devient illisible.)
if [[ -z "${c}" ]]; then c="aucun (premiere vie, ou rien avant la naissance)"; fi
echo "candidat : ${c}"
echo "candidat : ${c} (ce que choisirait une restauration lancee maintenant)"
a=$(avant_raser) || exit 2
echo "avant-raser: ${a:-aucun}"
ids=$(restic snapshots --host "${MOI}" --json 2>/dev/null \
| python3 -c 'import json, sys; print(" ".join(x["short_id"] for x in json.load(sys.stdin) or []))' \
2>/dev/null || true)
for j in "${JEUX[@]}"; do
if [[ -f "${MARQUES}/${j}" ]]; then
echo "jeu ${j} : $(tr '\n' ' ' < "${MARQUES}/${j}")"
id=$(sed -n 's/^instantane=//p' "${MARQUES}/${j}")
present=""
if [[ -n "${id}" ]]; then
if [[ " ${ids} " == *" ${id} "* ]]; then present="(au depot)"; else present="(RETIRE du depot)"; fi
fi
echo "jeu ${j} : $(tr '\n' ' ' < "${MARQUES}/${j}")${present}"
else
echo "jeu ${j} : EN ATTENTE (aucun marqueur)"
fi
done
}
# LES TEMOINS (2026-10-07). L'etat vivant est-il celui d'avant le rasage ? On extrait
# l'instantane d'avant (etiquete, sinon le candidat ou celui qu'on impose), on lit
# l'annuaire vivant, et `setops-temoins` compare. Rien n'est ecrit hors du repertoire jetable.
cmd_temoins() {
local source="avant-raser" c heure base chemins=() args=()
while (( $# )); do
case "$1" in
--candidat) source="candidat"; shift ;;
--instantane) INSTANTANE="$2"; source="impose"; shift 2 ;;
*) mourir "temoins : option inconnue : $1" ;;
esac
done
if [[ -z "${JEUX[*]:-}" ]]; then echo "SANS OBJET : ${MOI} ne detient aucun etat."; exit 4; fi
case "${source}" in
avant-raser) c=$(avant_raser) || exit 2 ;;
candidat) c=$(candidat "") || exit 2 ;;
impose) c=$(candidat "${INSTANTANE}") || exit 2 ;;
esac
if [[ -z "${c}" ]]; then echo "SANS OBJET : aucun instantane ${source} pour ${MOI}."; exit 4; fi
INSTANTANE="${c%% *}"
heure="${c#* }"
for j in "${JEUX[@]}"; do
IFS=: read -r -a morceaux <<< "${CHEMINS_JEU[${j}]}"
chemins+=("${morceaux[@]}")
args+=(--jeu "${j}=${CHEMINS_JEU[${j}]}")
done
extraire "${chemins[@]}"
if [[ -s "${TMP}${LDIF}" ]]; then
base=$(sed -n 's/^dn: //p' "${TMP}${LDIF}" | head -n 1)
slapcat -b "${base}" > "${TMP}/vivant-annuaire.ldif" || mourir "slapcat ${base} a echoue"
args+=(--ldif "${LDIF}" --ldif-vivant "${TMP}/vivant-annuaire.ldif")
fi
set +e
/usr/local/sbin/setops-temoins --avant "${TMP}" --instantane "${INSTANTANE}" \
--heure "${heure}" --hote "${MOI}" --dump "${DUMP_PG}" "${args[@]}"
local rc=$?
set -e
exit "${rc}"
}
cmd_garde() {
local attente=() c
for j in "${JEUX[@]}"; do
@ -319,5 +400,6 @@ case "${sous}" in
base) cmd_base "$@" ;;
acter) cmd_acter "$@" ;;
garde) cmd_garde ;;
temoins) cmd_temoins "$@" ;;
*) mourir "sous-commande inconnue : ${sous}" ;;
esac

View file

@ -11,6 +11,18 @@ export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
# instantane » ou « depot corrompu ». On attend le verrou au lieu de conclure.
restic() { command restic --retry-lock {{ client_backup_attente_verrou }} "$@"; }
# L'INSTANTANE D'AVANT RASAGE (2026-10-07). `--avant-raser` (unite
# `setops-sauvegarde-avant-raser`, lancee par `make sauvegarder-maintenant`) etiquette le
# depot « {{ client_backup_etiquette_avant_raser }} », et la retention garde cette etiquette.
# Sans elle, le premier depot de la machine reconstruite, le meme jour, chassait l'instantane
# d'avant par `--keep-daily` : apres la reconstruction de Technolibre (M4), il ne restait
# rien a quoi comparer l'etat remis. Un seul porte l'etiquette, le dernier : il est garde
# jusqu'a la reconstruction suivante, qui la lui retire.
AVANT_RASER=0
if [[ "${1:-}" == "--avant-raser" ]]; then AVANT_RASER=1; fi
ETIQUETTES=(--tag setops)
if (( AVANT_RASER )); then ETIQUETTES+=(--tag "{{ client_backup_etiquette_avant_raser }}"); fi
# 0. UN ETAT D'AVANT ATTEND-IL ? (2026-09-30)
# Sur une machine reconstruite, tant que l'etat de l'incarnation precedente n'a ete ni
# remis ni ecarte, deposer serait NUISIBLE : `restic forget --keep-daily` garde un
@ -60,7 +72,24 @@ if (( N_PRESENTS == 0 )); then
echo "aucun chemin declare n'existe encore sur cet hote — rien a sauvegarder." >&2
exit 0
fi
restic backup --host "{{ inventory_hostname }}" --tag setops "${PRESENTS[@]}"
restic backup --host "{{ inventory_hostname }}" "${ETIQUETTES[@]}" "${PRESENTS[@]}"
# 3 bis. L'etiquette passe au dernier depot d'avant rasage : les precedents rentrent dans
# la retention ordinaire. APRES le depot, jamais avant : s'il echoue, l'ancien reste garde.
if (( AVANT_RASER )); then
ANCIENS=$(restic snapshots --host "{{ inventory_hostname }}" --tag "{{ client_backup_etiquette_avant_raser }}" --json \
| python3 -c 'import json, sys
import datetime, re
def t(x):
s = re.sub(r"(\.\d{6})\d+", r"\1", x["time"])
return datetime.datetime.fromisoformat(re.sub(r"Z$", "+00:00", s)).timestamp()
s = sorted(json.load(sys.stdin) or [], key=t)
print(" ".join(x["id"] for x in s[:-1]))')
if [[ -n "${ANCIENS}" ]]; then
read -r -a ANCIENS_IDS <<< "${ANCIENS}"
restic tag --remove "{{ client_backup_etiquette_avant_raser }}" "${ANCIENS_IDS[@]}"
fi
fi
# 4. Rétention.
restic forget {{ client_backup_retention }} --prune
restic forget {{ client_backup_retention }} --keep-tag "{{ client_backup_etiquette_avant_raser }}" --prune

View file

@ -0,0 +1,13 @@
# Géré par Set-OPS (rôle client_backup). Ne pas éditer à la main.
# Le depot d'avant rasage : etiquete, garde jusqu'a la reconstruction suivante.
# Lance par `make sauvegarder-maintenant` (playbooks/backup/restauration.yml), jamais par un minuteur.
[Unit]
Description=Sauvegarde Set-OPS d'avant rasage (restic hors-nœud, etiquetee)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/setops-sauvegarder.sh --avant-raser
Nice=10
IOSchedulingClass=idle

View file

@ -14,7 +14,8 @@ pas les secrets du locataire, et c'est voulu : l'armement reste un geste du post
LES ETAPES, dans l'ordre (`--depuis` reprend a l'une d'elles apres un arret) :
sauvegarder poste deposer l'etat de chaque noeud MAINTENANT (sinon on perd la journee)
sauvegarder poste deposer l'etat de chaque noeud MAINTENANT (sinon on perd la journee),
etiquete « avant-raser » : garde jusqu'a la reconstruction suivante
raser runner du site detruire les VM
creer runner du site recreer les VM depuis le gabarit
inseminer runner du site amorcer le runner du locataire (socle + moteur, sans secret)
@ -22,7 +23,8 @@ LES ETAPES, dans l'ordre (`--depuis` reprend a l'une d'elles apres un arret) :
monter runner locataire `make monter-flotte` : socle, tout, recette ; chaque role
proprietaire d'un etat y remet celui d'avant
parefeu poste reactiver le pare-feu Proxmox, VM par VM, runner en dernier
bilan poste ce que chaque jeu d'etat est devenu
bilan poste ce que chaque jeu d'etat est devenu, puis les TEMOINS : l'etat
vivant contre l'instantane d'avant rasage (une perte = arret)
La premiere etape en echec arrete tout, et le dit. `raser` n'a pas d'autre condition que sa
confirmation (decision de l'exploitant) : c'est la PROCEDURE qui sauvegarde avant.
@ -264,7 +266,14 @@ def main() -> int:
env=env, filtre=(" ",))
else: # bilan
rc = lancer(["ansible-playbook", "-i", str(inv), "playbooks/backup/restauration.yml"], env=env,
filtre=("jeu ", "fatal:"))
filtre=("jeu ", "avant-raser:", "fatal:"))
# LES TEMOINS (2026-10-07) : le bilan dit ce que chaque role a FAIT ; les temoins
# mesurent ce qui est revenu, contre l'instantane depose a l'etape `sauvegarder`.
if rc == 0:
rc = lancer(["ansible-playbook", "-i", str(inv), "playbooks/backup/restauration.yml",
"-e", "restauration_action=temoins"], env=env,
filtre=("TEMOINS", "IDENTIQUE", "CONFORME", "ECART", "SANS OBJET",
"perdu", "IDENTITE", "fatal:"))
if rc != 0:
return arret(nom, rc)
dire(f" etape « {nom} » : OK")

View file

@ -36,6 +36,8 @@ import yaml
RACINE = Path(__file__).resolve().parents[2]
GABARIT = RACINE / "roles/client_backup/templates/restaurer.sh.j2"
SAUVEGARDE = RACINE / "roles/client_backup/templates/sauvegarder.sh.j2"
COMPARATEUR = RACINE / "roles/client_backup/files/setops-temoins.py"
ECHECS: list[str] = []
@ -120,9 +122,17 @@ def outil() -> None:
dump = snap / str(staging).lstrip("/") / "postgresql" / "toutes-bases.sql"
dump.parent.mkdir(parents=True)
dump.write_text(DUMP)
# Le troisieme, etiquete au rasage, est PLUS ANCIEN que le candidat : les temoins
# doivent le choisir quand meme (c'est l'etiquette qui designe, pas l'heure).
rasage = fixt / "rasage03" / str(d / "vmail").lstrip("/") / "boite" / "cur"
rasage.mkdir(parents=True)
(rasage / "1700.M1.h:2,S").write_text("le courriel d'avant")
snaps = [
{"short_id": "avant01", "id": "avant01" + "0" * 56, "time": "2026-09-29T23:50:50.123456789-04:00"},
{"short_id": "apres02", "id": "apres02" + "0" * 56, "time": "2099-01-01T00:00:00Z"},
{"short_id": "rasage03", "id": "rasage03" + "0" * 56, "time": "2026-09-29T10:00:00-04:00",
"tags": ["setops", "avant-raser"]},
{"short_id": "avant01", "id": "avant01" + "0" * 56, "time": "2026-09-29T23:50:50.123456789-04:00",
"tags": ["setops"]},
{"short_id": "apres02", "id": "apres02" + "0" * 56, "time": "2099-01-01T00:00:00Z", "tags": ["setops"]},
]
(d / "snapshots.json").write_text(json.dumps(snaps))
@ -134,7 +144,12 @@ if [[ -n "${{SETOPS_TEST_DEPOT_ABSENT:-}}" ]]; then exit 10; fi
[[ "$1" == --retry-lock && -n "$2" ]] || {{ echo "restic appele sans --retry-lock : $*" >&2; exit 99; }}
shift 2
case "$1" in
snapshots) cat "{d}/snapshots.json" ;;
snapshots)
etiquette=""
while (( $# )); do [[ "$1" == --tag ]] && etiquette="$2"; shift; done
python3 -c 'import json, sys; e = sys.argv[2]
print(json.dumps([x for x in json.load(open(sys.argv[1])) if not e or e in x.get("tags", [])]))' \
"{d}/snapshots.json" "$etiquette" ;;
restore)
id="$2"; shift 2; cible=""; inc=()
while (( $# )); do case "$1" in --target) cible="$2"; shift 2;; --include) inc+=("$2"); shift 2;; *) shift;; esac; done
@ -159,23 +174,31 @@ if (( ! avec_c )); then cat > "{log}/section.sql"; fi
executable(bin_ / "pg_dump", '#!/bin/bash\necho dump\n')
executable(bin_ / "dropdb", f'#!/bin/bash\necho "dropdb $*" >> "{log}/pg"\n')
rendu = jinja2.Environment(undefined=jinja2.StrictUndefined).from_string(
GABARIT.read_text()).render(
client_backup_repo="sftp:restic@depot:hote",
inventory_hostname="hote",
client_backup_restauration_marques=str(d / "marques"),
client_backup_restauration_mise_de_cote=str(d / "mis-de-cote"),
client_backup_staging=str(staging),
client_backup_jobs=[{"nom": "courriel"}, {"nom": "postgresql"}],
client_backup_attente_verrou="30m",
).replace("/etc/ssh/ssh_host_ed25519_key.pub", str(cle)) \
.replace("/etc/setops/restic.pass", str(d / "restic.pass"))
script = d / "setops-restaurer"
executable(script, rendu)
executable(bin_ / "setops-temoins", f'#!/bin/bash\nexec python3 "{COMPARATEUR}" "$@"\n')
def rendre(nom: str, jeux: list[dict]) -> Path:
rendu = jinja2.Environment(undefined=jinja2.StrictUndefined).from_string(
GABARIT.read_text()).render(
client_backup_repo="sftp:restic@depot:hote",
inventory_hostname="hote",
client_backup_restauration_marques=str(d / "marques"),
client_backup_restauration_mise_de_cote=str(d / "mis-de-cote"),
client_backup_staging=str(staging),
client_backup_jobs=jeux,
client_backup_attente_verrou="30m",
client_backup_etiquette_avant_raser="avant-raser",
).replace("/etc/ssh/ssh_host_ed25519_key.pub", str(cle)) \
.replace("/etc/setops/restic.pass", str(d / "restic.pass")) \
.replace("/usr/local/sbin/setops-temoins", str(bin_ / "setops-temoins"))
executable(d / nom, rendu)
return d / nom
vmail_jeu = {"nom": "courriel", "chemins": [str(d / "vmail")]}
script = rendre("setops-restaurer", [vmail_jeu, {"nom": "postgresql", "chemins": [str(staging / "postgresql")]}])
env = {**os.environ, "PATH": f"{bin_}:{os.environ['PATH']}"}
def lancer(*args: str, **extra: str) -> subprocess.CompletedProcess:
return subprocess.run(["bash", str(script), *args], env={**env, **extra},
def lancer(*args: str, outil: Path = script, **extra: str) -> subprocess.CompletedProcess:
return subprocess.run(["bash", str(outil), *args], env={**env, **extra},
capture_output=True, text=True)
r = lancer("choisir")
@ -198,6 +221,18 @@ if (( ! avec_c )); then cat > "{log}/section.sql"; fi
f"--remplacer remet l'etat d'avant, a l'identique ({r.stdout.strip() or r.stderr.strip()})")
verifier(any((d / "mis-de-cote").rglob("x")), "l'etat ecrase a ete mis de cote")
# LES TEMOINS (2026-10-07) : l'instantane etiquete au rasage, pas le candidat.
seul_courriel = rendre("setops-restaurer-courriel", [vmail_jeu])
r = lancer("temoins", outil=seul_courriel)
verifier(r.returncode == 1 and "instantane rasage03" in r.stdout and "perdu : boite/msg/1700.M1.h" in r.stdout,
f"les temoins comparent a l'instantane etiquete au rasage, et voient le courriel perdu "
f"(code {r.returncode}) {r.stdout.strip()[-200:] or r.stderr.strip()[-200:]}")
r = lancer("temoins", "--candidat", outil=seul_courriel)
verifier(r.returncode == 0 and "instantane avant01" in r.stdout,
f"--candidat compare au candidat, que la restauration a remis a l'identique ({r.stdout.strip()[-160:]})")
r = lancer("temoins", outil=rendre("setops-restaurer-vide", []))
verifier(r.returncode == 4 and "SANS OBJET" in r.stdout, "un noeud sans etat : rien a comparer (code 4)")
r = lancer("base", "nextcloud")
section = (log / "section.sql").read_text() if (log / "section.sql").exists() else ""
verifier(r.returncode == 0 and "oc_users" in section, f"la section nextcloud est rejouee ({r.stderr.strip()[:120]})")
@ -210,11 +245,19 @@ if (( ! avec_c )); then cat > "{log}/section.sql"; fi
r = lancer("base", "inexistante")
verifier(r.returncode == 0 and "ABSENTE" in r.stdout, "une base absente de l'instantane est dite, pas inventee")
for jeu in ("courriel", "postgresql"):
lancer("acter", jeu, "restaure", "avant01")
lancer("acter", "courriel", "restaure", "chasse09")
lancer("acter", "postgresql", "restaure", "avant01")
r = lancer("garde")
verifier(r.returncode == 0, "la sauvegarde reprend une fois chaque jeu acte")
# LE BILAN DIT CE QUI A ETE RESTAURE, ET S'IL EST ENCORE AU DEPOT (2026-10-07).
r = lancer("etat")
verifier("jeu courriel : etat=restaure instantane=chasse09" in r.stdout and "(RETIRE du depot)" in r.stdout,
"un instantane restaure puis chasse du depot est dit RETIRE")
verifier("instantane=avant01" in r.stdout and "(au depot)" in r.stdout, "un instantane encore la est dit au depot")
verifier("avant-raser: rasage03" in r.stdout and "lancee maintenant" in r.stdout,
"le bilan nomme l'instantane d'avant rasage, et dit que le candidat est calcule maintenant")
for f in (d / "marques").iterdir():
f.unlink()
r = lancer("garde", SETOPS_TEST_DEPOT_ABSENT="1")
@ -224,9 +267,70 @@ if (( ! avec_c )); then cat > "{log}/section.sql"; fi
"depot inexistant = premiere vie : chaque jeu est acte « neuf »")
# --- 3. Le depot d'avant rasage (2026-10-07) ---------------------------------------------
def sauvegarde() -> None:
"""`--avant-raser` etiquette le depot ; l'etiquette passe du precedent au nouveau ; la
retention la garde toujours. A M4, le premier depot d'apres reconstruction avait chasse
l'instantane d'avant par `--keep-daily` : plus rien a quoi comparer l'etat remis."""
with tempfile.TemporaryDirectory() as d:
d = Path(d)
bin_ = d / "bin"
bin_.mkdir()
(d / "vmail").mkdir()
journal = d / "restic.log"
# Deux instantanes etiquetes : l'ancien rasage, et celui qui vient d'etre depose.
etiquetes = [{"id": "ancien" + "0" * 58, "time": "2026-10-01T10:00:00-04:00"},
{"id": "nouveau" + "0" * 57, "time": "2026-10-07T11:34:44-04:00"}]
(d / "etiquetes.json").write_text(json.dumps(etiquetes))
executable(bin_ / "restic", f"""#!/bin/bash
[[ "$1" == --retry-lock ]] && shift 2
echo "$*" >> "{journal}"
if [[ "$1" == snapshots && "$*" == *--json* ]]; then cat "{d}/etiquetes.json"; fi
exit 0
""")
executable(bin_ / "setops-restaurer", "#!/bin/bash\nexit 0\n")
rendu = jinja2.Environment(undefined=jinja2.StrictUndefined).from_string(
SAUVEGARDE.read_text()).render(
client_backup_repo="sftp:restic@depot:hote", inventory_hostname="hote",
client_backup_attente_verrou="30m", client_backup_staging=str(d / "staging"),
client_backup_jobs=[{"nom": "courriel", "chemins": [str(d / "vmail")]}],
client_backup_retention="--keep-daily 7", client_backup_etiquette_avant_raser="avant-raser",
).replace("/usr/local/sbin/setops-restaurer", str(bin_ / "setops-restaurer"))
script = d / "setops-sauvegarder.sh"
executable(script, rendu)
env = {**os.environ, "PATH": f"{bin_}:{os.environ['PATH']}"}
def lancer(*args: str) -> list[str]:
journal.unlink(missing_ok=True)
r = subprocess.run(["bash", str(script), *args], env=env, capture_output=True, text=True)
verifier(r.returncode == 0, f"la sauvegarde {' '.join(args) or 'ordinaire'} aboutit ({r.stderr.strip()[-160:]})")
return journal.read_text().splitlines() if journal.exists() else []
appels = lancer("--avant-raser")
backup = next((a for a in appels if a.startswith("backup ")), "")
verifier("--tag avant-raser" in backup, f"le depot d'avant rasage est etiquete ({backup})")
retrait = [a for a in appels if a.startswith("tag ")]
verifier(retrait == ["tag --remove avant-raser " + etiquetes[0]["id"]],
f"l'etiquette est retiree au precedent, et a lui seul ({retrait})")
verifier(appels.index(backup) < appels.index(retrait[0]) if retrait else False,
"l'etiquette n'est retiree qu'APRES le nouveau depot")
forget = next((a for a in appels if a.startswith("forget ")), "")
verifier("--keep-tag avant-raser" in forget, f"la retention garde l'etiquette ({forget})")
appels = lancer()
backup = next((a for a in appels if a.startswith("backup ")), "")
verifier("avant-raser" not in backup and not any(a.startswith("tag ") for a in appels),
"un depot ordinaire n'est pas etiquete et ne touche pas l'etiquette")
forget = next((a for a in appels if a.startswith("forget ")), "")
verifier("--keep-tag avant-raser" in forget,
"un depot ordinaire garde lui aussi l'instantane d'avant rasage (c'est lui qui le chassait)")
def main() -> int:
couverture()
outil()
sauvegarde()
if ECHECS:
print(f"\n{len(ECHECS)} echec(s).")
return 1

View file

@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""`setops-temoins` : une perte ou une identité changée est un ÉCART ; la vie ordinaire non.
POURQUOI (2026-10-07). Les témoins d'une reconstruction se relevaient à la main ; après
M4, il ne restait même plus d'instantané d'avant à quoi comparer. Le comparateur qui les
remplace doit juger comme l'exploitant jugeait : la racine de l'AC, la clé DKIM,
l'`instanceid` de Nextcloud et le mot de passe de `sysadmin` ne bougent JAMAIS ; un
courriel lu (`new/` -> `cur/`, drapeau `:2,S`) n'est pas un courriel perdu ; une table de
sessions qui grossit n'est pas une base perdue.
Sur des arbres FABRIQUÉS (un « avant » extrait, un « vivant »), chaque règle est éprouvée
dans les deux sens : ce qui doit passer passe, ce qui doit échouer échoue.
"""
from __future__ import annotations
import base64
import contextlib
import io
import shutil
import sys
import tempfile
from pathlib import Path
RACINE = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(RACINE / "roles/client_backup/files"))
import importlib.util # noqa: E402
_spec = importlib.util.spec_from_file_location("setops_temoins", RACINE / "roles/client_backup/files/setops-temoins.py")
T = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(T)
ECHECS: list[str] = []
def verifier(cond: bool, msg: str) -> None:
print(("OK " if cond else "ECHEC ") + msg)
if not cond:
ECHECS.append(msg)
def ecrire(p: Path, texte: str) -> None:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(texte)
LDIF = """dn: dc=exemple,dc=internal
objectClass: domain
dc: exemple
entryCSN: 20261007153000.000000Z#000000#000#000000
dn: uid=sysadmin,ou=people,dc=exemple,dc=internal
objectClass: inetOrgPerson
uid: sysadmin
cn: Sysadmin
userPassword:: {mdp}
modifyTimestamp: 20261001000000Z
dn: uid=marie,ou=people,dc=exemple,dc=internal
objectClass: inetOrgPerson
uid: marie
cn: Marie
mail: marie@exemple.internal
"""
DUMP = r"""CREATE ROLE keycloak;
CREATE ROLE postgres;
CREATE DATABASE keycloak WITH TEMPLATE = template0 ENCODING = 'UTF8';
\connect keycloak
COPY public.realm (id, name) FROM stdin;
1 maitre
2 exemple
\.
COPY public.user_session (id) FROM stdin;
a
\.
"""
def ldif(mdp: str = "e1NTSEF9YW5jaWVu", marie: bool = True, mail: str = "marie@exemple.internal") -> str:
t = LDIF.replace("{mdp}", mdp).replace("marie@exemple.internal", mail)
if not marie:
t = t.split("dn: uid=marie")[0]
return t
def lancer(argv: list[str], interroger=None) -> tuple[int, str]:
sortie = io.StringIO()
with contextlib.redirect_stdout(sortie):
rc = T.main(argv, interroger=interroger or T.psql)
return rc, sortie.getvalue()
def fichiers() -> None:
with tempfile.TemporaryDirectory() as d:
d = Path(d)
avant, vivant = d / "avant", d / "vivant"
# L'AC : une cle, un certificat, et sa base qui se compacte d'elle-meme.
for racine in (avant / str(vivant).lstrip("/"), vivant):
ecrire(racine / "step-ca/certs/root_ca.crt", "RACINE")
ecrire(racine / "step-ca/secrets/root_ca_key", "CLE")
ecrire(racine / "vmail/marie/Maildir/cur/1700.M1.h,S=10:2,S", "bonjour")
ecrire(racine / "rspamd/dkim/setops.key", "DKIM")
ecrire(racine / "nc/config/config.php", "<?php $CONFIG = array ('instanceid' => 'oc1abc',);")
ecrire(racine / "nc/data/marie/files/rapport.odt", "rapport")
ecrire(avant / str(vivant).lstrip("/") / "step-ca/db/000001.vlog", "ancien journal")
ecrire(vivant / "step-ca/db/000002.vlog", "journal compacte")
ecrire(avant / str(vivant).lstrip("/") / "vmail/marie/Maildir/new/1800.M2.h,S=5", "salut")
ecrire(vivant / "vmail/marie/Maildir/cur/1800.M2.h,S=5:2,S", "salut") # lu depuis
ecrire(vivant / "vmail/marie/Maildir/new/1900.M3.h,S=4", "neuf") # recu depuis
ecrire(avant / str(vivant).lstrip("/") / "rspamd/bayes.spam.sqlite", "appris")
ecrire(vivant / "rspamd/bayes.spam.sqlite", "appris davantage")
ecrire(avant / str(vivant).lstrip("/") / "nc/data/appdata_oc1abc/preview/1.png", "cache")
jeux = [f"step_ca={vivant}/step-ca", f"courriel={vivant}/vmail", f"rspamd={vivant}/rspamd",
f"nextcloud={vivant}/nc/data:{vivant}/nc/config"]
base = ["--avant", str(avant), "--instantane", "abcd1234", "--hote", "h"]
argv = base + [x for j in jeux for x in ("--jeu", j)]
rc, out = lancer(argv)
verifier(rc == 0, f"la vie ordinaire n'est pas un ecart : base de l'AC compactee, courriel lu et "
f"recu, bayes appris, cache Nextcloud refait (code {rc})\n{out}")
verifier("step_ca CONFORME" in out and "rspamd CONFORME" in out,
"des changements ordinaires sont dits CONFORME, pas IDENTIQUE")
verifier("perdu : msg" not in out, "un courriel passe de new/ a cur/ avec un drapeau n'est pas perdu")
ecrire(vivant / "step-ca/secrets/root_ca_key", "AUTRE CLE")
rc, out = lancer(argv)
verifier(rc == 1 and "IDENTITE secrets/root_ca_key" in out, "une cle de l'AC changee est un ECART")
ecrire(vivant / "step-ca/secrets/root_ca_key", "CLE")
ecrire(vivant / "rspamd/dkim/setops.key", "AUTRE DKIM")
rc, out = lancer(argv)
verifier(rc == 1 and "IDENTITE dkim/setops.key" in out, "une cle DKIM changee est un ECART")
ecrire(vivant / "rspamd/dkim/setops.key", "DKIM")
ecrire(vivant / "nc/config/config.php", "<?php $CONFIG = array ('instanceid' => 'ocNEUF',);")
rc, out = lancer(argv)
verifier(rc == 1 and "instanceid : oc1abc -> ocNEUF" in out, "un instanceid Nextcloud change est un ECART")
ecrire(vivant / "nc/config/config.php", "<?php $CONFIG = array ('instanceid' => 'oc1abc',);")
(vivant / "nc/data/marie/files/rapport.odt").unlink()
rc, out = lancer(argv)
verifier(rc == 1 and "perdu : marie/files/rapport.odt" in out, "un fichier d'une personne perdu est un ECART")
ecrire(vivant / "nc/data/marie/files/rapport.odt", "rapport")
shutil.rmtree(vivant / "vmail/marie/Maildir/cur")
rc, out = lancer(argv)
verifier(rc == 1 and "perdu : marie/Maildir/msg/1700.M1.h,S=10" in out, "un courriel perdu est un ECART")
rc, out = lancer(base)
verifier(rc == 0 and "SANS OBJET" in out, "un noeud sans etat n'a rien a comparer")
def annuaire() -> None:
with tempfile.TemporaryDirectory() as d:
d = Path(d)
ecrire(d / "avant/var/backups/setops/openldap/annuaire.ldif", ldif())
vivant = d / "vivant.ldif"
argv = ["--avant", str(d / "avant"), "--instantane", "x", "--jeu", "openldap=/var/backups/setops/openldap",
"--ldif", "/var/backups/setops/openldap/annuaire.ldif", "--ldif-vivant", str(vivant)]
# slapadd repose les attributs operationnels, et une ligne pliee n'est pas une autre valeur.
ecrire(vivant, ldif().replace("modifyTimestamp: 20261001000000Z", "modifyTimestamp: 20261007160000Z")
.replace("mail: marie@exemple.internal", "mail: marie@exemple.inter\n nal"))
rc, out = lancer(argv)
verifier(rc == 0 and "openldap IDENTIQUE" in out,
f"attributs operationnels et pliage ignores : IDENTIQUE ({out.strip()[-160:]})")
ecrire(vivant, ldif(mdp=base64.b64encode(b"{SSHA}amorcage").decode()))
rc, out = lancer(argv)
verifier(rc == 1 and "IDENTITE userPassword : uid=sysadmin" in out,
"le mot de passe de sysadmin remis a l'amorcage est un ECART (la panne du 2026-09-30)")
ecrire(vivant, ldif(marie=False))
rc, out = lancer(argv)
verifier(rc == 1 and "perdue : uid=marie" in out, "une entree perdue est un ECART")
ecrire(vivant, ldif(mail="marie@ailleurs.internal"))
rc, out = lancer(argv)
verifier(rc == 0 and "CONFORME" in out and "(mail)" in out,
"un attribut ordinaire modifie est dit, sans ecart")
def postgresql() -> None:
with tempfile.TemporaryDirectory() as d:
d = Path(d)
ecrire(d / "avant/var/backups/setops/postgresql/toutes-bases.sql", DUMP)
argv = ["--avant", str(d / "avant"), "--instantane", "x", "--jeu", "postgresql=/var/backups/setops/postgresql",
"--dump", "/var/backups/setops/postgresql/toutes-bases.sql"]
def cluster(bases=("postgres", "keycloak"), roles=("postgres", "keycloak"), comptes=None):
comptes = comptes if comptes is not None else {"public.realm": "2", "public.user_session": "1"}
def interroger(base: str, sql: str):
if "pg_database" in sql:
return list(bases)
if "pg_roles" in sql:
return list(roles)
table = sql.rsplit(" ", 1)[-1]
return [comptes[table]] if table in comptes else None
return interroger
rc, out = lancer(argv, cluster())
verifier(rc == 0 and "postgresql IDENTIQUE 2 table(s)" in out, f"memes bases, roles et lignes : IDENTIQUE ({out.strip()[-120:]})")
rc, out = lancer(argv, cluster(comptes={"public.realm": "2", "public.user_session": "7"}))
verifier(rc == 0 and "user_session : 1 -> 7" in out, "des sessions ouvertes depuis : dit, sans ecart")
rc, out = lancer(argv, cluster(bases=("postgres",)))
verifier(rc == 1 and "base perdue : keycloak" in out, "une base perdue est un ECART")
rc, out = lancer(argv, cluster(roles=("postgres",)))
verifier(rc == 1 and "role perdu : keycloak" in out, "un role perdu est un ECART")
rc, out = lancer(argv, cluster(comptes={"public.user_session": "1"}))
verifier(rc == 1 and "table perdue : keycloak.public.realm" in out, "une table perdue est un ECART")
def main() -> int:
fichiers()
annuaire()
postgresql()
if ECHECS:
print(f"\n{len(ECHECS)} echec(s).")
return 1
print("\nLes temoins voient une perte ou une identite changee, et laissent passer la vie ordinaire.")
return 0
if __name__ == "__main__":
sys.exit(main())