_fusion_table traversait mal les commentaires interieurs a une entree : chezlepro.ca (DNSSEC) etait coupee et ses enregistrements pris pour des entrees. make verifier : CONFORME, 83 OK, 0 echec. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
186 lines
8.4 KiB
Python
186 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Sauvegarder depuis le GUI ne doit emporter aucun commentaire du plan.
|
|
|
|
CE QUI ETAIT VRAI JUSQU'AU 2026-09-08, mesure sur les fichiers reels de Chezlepro :
|
|
|
|
domaines.yml 6 lignes de commentaire -> 3 (PERD 3)
|
|
applications.yml 27 -> 5 (PERD 22)
|
|
serveurs.yml 18 -> 3 (PERD 15)
|
|
bases-donnees.yml 4 -> 4 (intact — il n'en portait pas)
|
|
|
|
Quarante lignes, detruites par n'importe quel clic sur « Sauvegarder » dans les vues
|
|
Serveurs, Applications ou Domaines. Parmi elles, celle qui explique pourquoi `backup-01`
|
|
a ete retire — « une supervision creuse est pire qu'aucune : elle est verte » — et celle
|
|
qui dit dans quel ORDRE les deux roles du runner s'appliquent.
|
|
|
|
C'etait l'incident du 2026-08-18 (94 lignes perdues dans les fichiers d'intrants), jamais
|
|
corrige pour les registres du plan : `_fusion_chirurgicale` avait ete ecrite pour les
|
|
intrants seuls, et les quatre `ecrire_*` etaient restes au `safe_dump`.
|
|
|
|
Ce banc mesure les quatre ecrivains sur les VRAIS fichiers du depot, et non sur un
|
|
gabarit invente : c'est la seule facon de constater ce que l'operateur perdrait.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
RACINE = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
import yaml # noqa: E402
|
|
|
|
import inventory_gui as g # noqa: E402
|
|
|
|
# (nom, fichier source, ecrivain, chargeur, clef racine)
|
|
REGISTRES = [
|
|
("domaines.yml", g.FICHIER_DOMAINES, g.ecrire_domaines, g.charger_domaines, "domaines_publics"),
|
|
("bases-donnees.yml", g.FICHIER_BASES, g.ecrire_bases, g.charger_bases_donnees, "bases_donnees"),
|
|
("applications.yml", g.FICHIER_APPLICATIONS, g.ecrire_applications, g.charger_applications, "applications"),
|
|
("serveurs.yml", g.FICHIER_SERVEURS, g.ecrire_serveurs, g.charger_serveurs, "serveurs"),
|
|
]
|
|
|
|
|
|
def commentaires(texte: str) -> list[str]:
|
|
return [l.strip() for l in texte.splitlines() if l.strip().startswith("#")]
|
|
|
|
|
|
def commentaires_hors_entree(texte: str, cle: str) -> list[str]:
|
|
"""Les commentaires du fichier, MOINS ceux qui vivent DANS l'entree `cle`.
|
|
|
|
Un commentaire interieur a une entree (2026-09-16 : `chezlepro.ca` explique sa
|
|
signature DNSSEC entre `secondaires:` et `dnssec:`) part avec elle : laisse en place,
|
|
il expliquerait l'entree voisine. Ceux qui l'ENTOURENT restent. Meme regle d'etendue
|
|
que `_fusion_table` : un commentaire suivi d'une ligne encore plus indentee est dedans.
|
|
"""
|
|
lignes = texte.splitlines()
|
|
debut = next((i for i, l in enumerate(lignes)
|
|
if re.match(rf"^(\s+){re.escape(cle)}:", l)), None)
|
|
if debut is None:
|
|
return commentaires(texte)
|
|
indent = len(lignes[debut]) - len(lignes[debut].lstrip())
|
|
dedans, j = set(), debut + 1
|
|
while j < len(lignes):
|
|
l = lignes[j]
|
|
if not l.strip() or l.strip().startswith("#"):
|
|
k = j
|
|
while k < len(lignes) and (not lignes[k].strip() or lignes[k].strip().startswith("#")):
|
|
k += 1
|
|
if k < len(lignes) and len(lignes[k]) - len(lignes[k].lstrip()) > indent:
|
|
dedans.update(range(j, k))
|
|
j = k
|
|
continue
|
|
break
|
|
if len(l) - len(l.lstrip()) <= indent:
|
|
break
|
|
j += 1
|
|
return [l.strip() for i, l in enumerate(lignes)
|
|
if l.strip().startswith("#") and i not in dedans]
|
|
|
|
|
|
class TestEcriturePlan(unittest.TestCase):
|
|
|
|
def setUp(self):
|
|
self.dossier = Path(tempfile.mkdtemp(prefix="plan-"))
|
|
|
|
def tearDown(self):
|
|
shutil.rmtree(self.dossier, ignore_errors=True)
|
|
|
|
def _copie(self, nom, source):
|
|
cible = self.dossier / nom
|
|
shutil.copy(source, cible)
|
|
return cible
|
|
|
|
def test_aller_retour_a_vide_est_identique(self):
|
|
"""Ecrire ce qu'on vient de lire ne doit pas changer un octet.
|
|
|
|
C'est la propriete forte : sans elle, toute sauvegarde produit du bruit au diff,
|
|
et le bruit finit par cacher le vrai changement.
|
|
"""
|
|
for nom, source, ecrire, charger, _ in REGISTRES:
|
|
with self.subTest(registre=nom):
|
|
cible = self._copie(nom, source)
|
|
avant = cible.read_text(encoding="utf-8")
|
|
ecrire(cible, charger(source))
|
|
self.assertEqual(avant, cible.read_text(encoding="utf-8"))
|
|
|
|
def test_aucun_commentaire_perdu_apres_modification(self):
|
|
for nom, source, ecrire, charger, racine in REGISTRES:
|
|
with self.subTest(registre=nom):
|
|
cible = self._copie(nom, source)
|
|
avant = cible.read_text(encoding="utf-8")
|
|
registre = charger(source)
|
|
registre[racine]["essai-banc"] = dict(
|
|
next(iter(registre[racine].values()), {}) or {})
|
|
ecrire(cible, registre)
|
|
self.assertEqual(commentaires(avant), commentaires(cible.read_text(encoding="utf-8")))
|
|
|
|
def test_une_entite_ajoutee_arrive_bien(self):
|
|
for nom, source, ecrire, charger, racine in REGISTRES:
|
|
with self.subTest(registre=nom):
|
|
cible = self._copie(nom, source)
|
|
registre = charger(source)
|
|
modele = dict(next(iter(registre[racine].values()), {}) or {})
|
|
registre[racine]["essai-banc"] = modele
|
|
ecrire(cible, registre)
|
|
relu = yaml.safe_load(cible.read_text(encoding="utf-8"))
|
|
self.assertIn("essai-banc", relu[racine])
|
|
self.assertEqual(relu[racine]["essai-banc"], modele)
|
|
|
|
def test_une_entite_retiree_disparait(self):
|
|
for nom, source, ecrire, charger, racine in REGISTRES:
|
|
with self.subTest(registre=nom):
|
|
cible = self._copie(nom, source)
|
|
avant = cible.read_text(encoding="utf-8")
|
|
registre = charger(source)
|
|
if len(registre[racine]) < 2:
|
|
continue
|
|
victime = sorted(registre[racine])[0]
|
|
del registre[racine][victime]
|
|
ecrire(cible, registre)
|
|
apres = cible.read_text(encoding="utf-8")
|
|
relu = yaml.safe_load(apres)
|
|
self.assertNotIn(victime, relu[racine])
|
|
# Les autres entites restent, et les commentaires aussi : retirer une
|
|
# entite n'est pas une raison d'effacer ce qui l'entourait.
|
|
self.assertEqual(sorted(relu[racine]), sorted(registre[racine]))
|
|
# Ce qui ENTOURAIT l'entite reste ; ce qui etait DEDANS part avec elle.
|
|
self.assertEqual(commentaires_hors_entree(avant, victime), commentaires(apres))
|
|
|
|
def test_controle_negatif_safe_dump_detruit(self):
|
|
"""LE CONTROLE. L'ancienne ecriture DOIT perdre des commentaires.
|
|
|
|
Sans lui, les tests ci-dessus passeraient sur un fichier qui n'en porte aucun —
|
|
ils ne mesureraient rien. On rejoue le `safe_dump` d'avant sur les memes fichiers
|
|
et on exige la perte.
|
|
"""
|
|
perdants = []
|
|
for nom, source, _, charger, racine in REGISTRES:
|
|
avant = source.read_text(encoding="utf-8")
|
|
naif = "---\n" + yaml.safe_dump({racine: charger(source)[racine]},
|
|
default_flow_style=False, sort_keys=False,
|
|
allow_unicode=True)
|
|
if len(commentaires(naif)) < len(commentaires(avant)):
|
|
perdants.append(nom)
|
|
self.assertIn("serveurs.yml", perdants,
|
|
"le controle negatif ne reproduit plus le defaut : serveurs.yml "
|
|
"ne porte-t-il plus de commentaire interne ?")
|
|
self.assertGreaterEqual(len(perdants), 3, f"seuls {perdants} perdent — banc suspect")
|
|
|
|
def test_un_fichier_absent_recoit_son_entete(self):
|
|
"""Rien a preserver : on ecrit l'entete et le contenu."""
|
|
cible = self.dossier / "neuf.yml"
|
|
g.ecrire_domaines(cible, {"domaines_publics": {"exemple.ca": {"autorite": "delegue",
|
|
"edge": "serveur_nginx"}}})
|
|
texte = cible.read_text(encoding="utf-8")
|
|
self.assertIn("# Registre des domaines publics", texte)
|
|
self.assertIn("exemple.ca", yaml.safe_load(texte)["domaines_publics"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|