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:
parent
a204ead8ab
commit
11b5bb5733
16 changed files with 986 additions and 35 deletions
61
CHANGELOG.md
61
CHANGELOG.md
|
|
@ -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 :
|
||||
|
|
|
|||
12
Makefile
12
Makefile
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
309
roles/client_backup/files/setops-temoins.py
Normal file
309
roles/client_backup/files/setops-temoins.py
Normal 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())
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
232
scripts/tests/test_temoins.py
Normal file
232
scripts/tests/test_temoins.py
Normal 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())
|
||||
Loading…
Reference in a new issue