#!/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()