Set-OPS-Public/roles/client_backup/files/setops-temoins.py
Daniel Allaire 4660a8892a prometheus et loki : copie a froid, restauration et temoins
Les metriques et les journaux repartaient de zero a chaque reconstruction. setops-copie-a-froid arrete le service le temps de copier et le relance quoi qu il arrive ; les roles remettent la copie avant de demarrer ; les temoins jugent la couverture, pas les fichiers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:57:04 -04:00

427 lines
20 KiB
Python

#!/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, AC d'Icinga et son environnement, clé DKIM,
`instanceid` de Nextcloud, mot de passe d'une entrée de l'annuaire ; une
base dont plus aucune ligne d'avant ne subsiste ; une ligne d'historique
perdue ; des métriques qui ne couvrent plus l'avant-rasage ; des journaux
dont aucun morceau d'avant ne subsiste. 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 collections
import hashlib
import json
import os
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 == "icinga":
# L'AC d'Icinga et l'environnement qu'elle derive : changes, chaque identifiant
# d'Icinga DB change, et tout l'historique restaure devient orphelin.
identite = lambda rel: rel in ("ca.crt", "ca.key", "icingadb.env") # 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)'}")
# --- Les bases copiees a froid : Prometheus et Loki (2026-10-07) ----------------------
#
# Leurs FICHIERS ne se comparent pas : Prometheus fusionne ses blocs, Loki compacte son
# index, et la retention (15 j, 31 j) en retire chaque jour. On compare ce qui compte.
def blocs_prometheus(racine: Path) -> list[tuple[int, int]]:
out = []
for meta in racine.glob("01*/meta.json") if racine.is_dir() else []:
try:
m = json.loads(meta.read_text())
out.append((int(m["minTime"]), int(m["maxTime"])))
except (ValueError, KeyError):
continue
return sorted(out)
def segments_wal(racine: Path) -> list[str]:
return sorted(p.name for p in (racine / "wal").glob("[0-9]*")) if (racine / "wal").is_dir() else []
def jeu_prometheus(avant: Path, vivant: Path) -> Rapport:
"""Les métriques vivantes couvrent-elles encore l'avant-rasage ?
Le repère : le DÉBUT du bloc le plus récent de l'instantané — quelques heures avant le
rasage. Un bloc vivant doit couvrir cet instant. La rétention ne l'a pas retiré (il a
moins de 15 jours) ; une base qui n'a pas été restaurée ne peut pas le couvrir : elle
est née après la reconstruction. Une base de moins de deux heures n'a pas encore de
bloc : son WAL fait alors le repère, et doit se retrouver dans la base vivante, ou y
avoir été fermé en bloc.
"""
r = Rapport("prometheus")
a, v = blocs_prometheus(avant), blocs_prometheus(vivant)
r.compte = len(a)
if a:
t = max(debut for debut, _ in a)
if not any(debut <= t < fin for debut, fin in v):
r.ecarts.append(f"aucun bloc vivant ne couvre {t} (debut du dernier bloc d'avant) : "
f"metriques d'avant non restaurees")
if v and min(d for d, _ in v) > min(d for d, _ in a):
r.changes.append(f"plus ancienne donnee : {min(d for d, _ in a)} -> {min(d for d, _ in v)} (retention)")
else:
wal = segments_wal(avant)
if wal and not v and wal[0] not in segments_wal(vivant):
r.ecarts.append(f"le WAL d'avant ({wal[0]}...) n'est pas dans la base vivante, et elle n'a "
f"aucun bloc : metriques d'avant non restaurees")
if len(v) != len(a):
r.changes.append(f"{len(a)} bloc(s) avant, {len(v)} maintenant (fusions, retention, nouveaux)")
return r
def jeu_loki(avant: Path, vivant: Path) -> Rapport:
"""Des morceaux de journal d'avant subsistent-ils ? Aucun : non restaurés.
La rétention (31 j) en retire chaque jour : une perte PARTIELLE est listée. Une perte
TOTALE, elle, ne vient que d'une base qui n'a pas été remise.
"""
r = Rapport("loki")
a = set(arbre(avant / "chunks"))
v = set(arbre(vivant / "chunks"))
r.compte = len(a)
if a and not (a & v):
r.ecarts.append(f"aucun des {len(a)} morceau(x) de journal d'avant ne subsiste : journaux non restaures")
elif a - v:
r.retires.append(f"{len(a - v)} morceau(x) d'avant retire(s) (retention ?)")
if v - a:
r.nouveaux.append(f"{len(v - a)} morceau(x) nouveau(x)")
return r
# --- 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, str], list[str]]]:
"""(bases, rôles, {(base, table, 1re colonne) : [valeurs]}) 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))
cles: dict[tuple[str, str, str], list[str]] = {}
base, table = "", None
for ligne in texte.splitlines():
if table is not None:
if ligne == "\\.":
table = None
else:
cles[table].append(ligne.split("\t", 1)[0])
elif ligne.startswith("\\connect "):
base = ligne.split()[-1]
else:
m = re.match(r"^COPY (\S+) \(([^,)]+)[,)].*FROM stdin;$", ligne)
if m:
table = (base, m.group(1), m.group(2).strip())
cles[table] = []
return bases, roles, cles
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, env=dict(os.environ, PAGER="cat"))
return r.stdout.splitlines() if r.returncode == 0 else None
def jeu_postgresql(dump: Path, interroger=psql) -> Rapport:
"""Par CLÉ, pas par nombre de lignes (2026-10-07).
Compter ne voyait rien : l'historique d'Icinga DB, entièrement remplacé (412 lignes
d'avant, toutes perdues ; 380 neuves), passait pour « 412 -> 380 ». On compare donc la
première colonne de chaque table, lue au même format que le dump (`COPY ... TO STDOUT`).
Ce qui est un ÉCART, d'après la mesure sur Chezlepro (avant-raser contre vivant) :
- une base dont AUCUNE ligne d'avant ne subsiste, toutes tables confondues : elle n'a
pas été restaurée (c'était icingadb, ses 30 tables renouvelées) ;
- une ligne perdue dans une table d'HISTORIQUE (`*history*`) : par nature, on y
ajoute, on n'y retire pas.
Le reste est listé : la découverte de grappe de Keycloak (`jgroups_ping`), ses sessions,
une configuration de composant régénérée renouvellent leurs clés dans la vie ordinaire.
"""
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, cles = lire_dump(dump.read_text(errors="replace"))
vivantes = set(interroger("postgres", "select datname from pg_database") or [])
roles_vivants = set(interroger("postgres", "select rolname from pg_roles") or [])
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}")
par_base: dict[str, list[int]] = collections.defaultdict(lambda: [0, 0])
for (b, t, col), avant in sorted(cles.items()):
if b not in vivantes or not avant:
continue
r.compte += 1
vivant = interroger(b, f"COPY (select {col} from {t}) TO STDOUT")
if vivant is None:
r.ecarts.append(f"table perdue : {b}.{t}")
continue
a, v = set(avant), set(vivant)
communes = len(a & v)
par_base[b][0] += len(a)
par_base[b][1] += communes
perdues, nouvelles = len(a) - communes, len(v - a)
if perdues and "history" in t:
r.ecarts.append(f"historique perdu : {b}.{t} : {perdues} ligne(s) d'avant sur {len(a)}")
elif perdues or nouvelles:
r.changes.append(f"{b}.{t} : {perdues} retiree(s), {nouvelles} nouvelle(s) (avant {len(a)})")
for b, (n, communes) in sorted(par_base.items()):
if n and not communes:
r.ecarts.insert(0, f"base {b} : aucune des {n} ligne(s) d'avant ne subsiste — non restauree")
# `template0` n'est jamais dans un `pg_dumpall` : il n'est pas « nouveau », il est hors jeu.
r.nouveaux.extend(f"base {b}" for b in sorted(vivantes - bases - {"postgres", "template0"}))
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("--vivant", action="append", default=[],
help="NOM=CHEMIN : ou vit la base d'un jeu copie a froid")
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
vivants = dict(v.partition("=")[::2] for v in a.vivant)
for spec in a.jeu:
nom, _, chemins = spec.partition("=")
# La copie a froid est le SEUL sous-dossier de la preparation du jeu.
copie = lambda: next((d for d in sorted((a.avant / chemins.split(":")[0].lstrip("/")).glob("*")) # noqa: E731
if d.is_dir()), a.avant / "absent")
if nom == "prometheus" and nom in vivants:
r, unite = jeu_prometheus(copie(), Path(vivants[nom])), "bloc(s)"
elif nom == "loki" and nom in vivants:
r, unite = jeu_loki(copie(), Path(vivants[nom])), "morceau(x)"
elif 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())