restic snapshots --latest 1 rend un instantane par groupe (machine et chemins) : la sonde gardait le premier, l'ancien, depuis qu'un jeu a ete ajoute a site-mon-01 (EN RETARD a tort, PERIME promis a 50 h). Meme motif cote serveur_backup. Le temoin de Loki ne compte plus l'index compacte. Journal (114), (115). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
432 lines
20 KiB
Python
432 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")
|
|
# LES MORCEAUX, PAS L'INDEX (2026-10-08). `chunks/index/` loge l'index TSDB et la base du
|
|
# compacteur : il les fusionne et les remplace. A la reconstruction de Technolibre, trois
|
|
# de ces fichiers « d'avant » manquaient, et le temoin les disait retires « (retention ?) »
|
|
# — aucun journal ne l'etait (Loki rendait ceux d'avant le rasage, minute par minute).
|
|
morceaux = lambda racine: {rel for rel in arbre(racine / "chunks") if not rel.startswith("index/")} # noqa: E731
|
|
a = morceaux(avant)
|
|
v = morceaux(vivant)
|
|
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())
|