From ed84494c40ae8170786b4ab7ef3bc9a0d5e80664 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 18 Aug 2026 15:05:12 -0400 Subject: [PATCH] GUI : le panneau d'intrants effacait la memoire ecrite du depot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un enregistrement du panneau « Intrants de base », a 13:48, a emporte 121 lignes d'explications dans quatre fichiers — dont celle qui disait POURQUOI la valeur qu'on venait de changer avait ete choisie (le plan de gestion reste en 10.0.0.0/24 tant que la frontiere ne sait pas classer un second CIDR, D-61). Ces phrases sont la seule trace de raisonnements qu'aucun code ne redit. `safe_dump` les effacait toutes a chaque sauvegarde, en retriant les clefs au passage. LE DEPOT CONNAISSAIT DEJA LE GESTE JUSTE. `_ecrire_intrants_fabric` (underlay.yml) et `_ecrire_index_nomenclature` remplacent LA LIGNE sans toucher au reste ; leur commentaire dit meme « un safe_dump les effacerait toutes ». Les quatre fichiers d'intrants n'avaient jamais recu ce traitement. Ce qui manquait pour l'etendre : savoir remplacer une valeur de LISTE, qui tient sur plusieurs lignes. `_fusion_chirurgicale`, trois regles : - une clef dont la valeur ne change pas n'est PAS reecrite (zero bruit au diff) ; - les commentaires internes a un bloc remplace sont conserves, jamais juges ; - une clef absente du fichier est ajoutee a la fin, jamais inseree au hasard. La deuxieme est assumee : une explication devenue fausse survit a la valeur qu'elle explique. Corriger une phrase est un geste humain ; l'effacer parce qu'un champ a bouge, non. Meme principe que la fusion des clefs posee le 2026-08-10. EPROUVE SUR LE FICHIER REEL, pas sur un exemple : l'enregistrement de 13:48 rejoue sur la version d'avant (tiree de git) rend 41 lignes -> 41, 30 commentaires -> 30, 0 perdu, et un diff d'UNE ligne. Neuf tests dans scripts/tests/test_gui_intrants.py, branches sur `make test`. DEUX FOIS LE MEME GESTE DESTRUCTEUR, SUR LE MEME CHEMIN : le 10 aout ce panneau perdait des CLEFS (dns_amorcage, amorcage_acces_courriel — une VM qui nait sans resolution), le 18 des COMMENTAIRES. La premiere fois avait valu une fusion, pas un test. C'est le test qui manquait. Non touche, et dit comme tel : les registres (bases, applications, serveurs, domaines) passent toujours par safe_dump. Ils sont structures et n'ont pas perdu de commentaires au meme enregistrement — a reprendre si l'un d'eux en porte un jour. make test 9/9 sur le nouveau fichier ; prouver 37 OK, 0 echec, 0 saute. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 60 ++++++++++++ Makefile | 1 + scripts/inventory_gui.py | 83 ++++++++++++++++ scripts/tests/test_gui_intrants.py | 147 +++++++++++++++++++++++++++++ 4 files changed, 291 insertions(+) create mode 100644 scripts/tests/test_gui_intrants.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 98f5b4b..cd0d2d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,65 @@ # CHANGELOG — Set-OPS +## 2026-08-18 — Le panneau d'intrants effaçait la mémoire écrite du dépôt + +Un enregistrement du panneau « Intrants de base », à 13:48, a emporté **121 lignes +d'explications** dans quatre fichiers. Dont celle-ci, juste au-dessus de la valeur qu'on +venait de changer : + +```yaml +# POURQUOI PAS ENCORE 10.17.0.0/24 (essayé puis retiré le 2026-08-12) : `devis_opnsense` +# dérive l'interface d'une règle de l'ATTACHEMENT RÉEL de sa source (D-61)… un second +# CIDR est classé « distant », et la règle atterrit sur `wan` où elle ne peut JAMAIS +# correspondre. +- 10.0.0.0/24 +``` + +Ces phrases sont la seule trace de raisonnements qu'aucun code ne redit. `safe_dump` les +efface toutes, à chaque sauvegarde, en retriant les clés au passage — un diff illisible +par-dessus le marché. + +### Le dépôt connaissait déjà le geste juste + +`_ecrire_intrants_fabric` (underlay.yml) et `_ecrire_index_nomenclature` remplacent **la +ligne**, sans toucher au reste ; leur commentaire dit même *« un `safe_dump` les +effacerait toutes »*. Les quatre fichiers d'intrants, eux, n'avaient jamais reçu ce +traitement. Ce qui manquait pour l'étendre : savoir remplacer une valeur de **liste**, qui +tient sur plusieurs lignes. + +`_fusion_chirurgicale` le fait, sur trois règles : + +| | | +|---|---| +| une clé dont la valeur ne change pas | **n'est pas réécrite** — zéro bruit au diff | +| les commentaires internes à un bloc remplacé | **conservés**, jamais jugés | +| une clé absente du fichier | ajoutée **à la fin**, jamais insérée au hasard | + +La deuxième règle mérite d'être assumée : une explication devenue fausse survit à la +valeur qu'elle explique. C'est voulu. Corriger une phrase est un geste humain ; l'effacer +parce qu'un champ a bougé, non. Le même principe que la fusion des clés posée le 10 août : +*un panneau qui ne connaît pas une valeur n'a pas le droit de la détruire.* + +### Éprouvé sur le fichier réel, pas sur un exemple + +L'enregistrement du 13:48 rejoué sur la version d'avant, tirée de git : + +``` +lignes 41 -> 41 +commentaires 30 -> 30 perdus : 0 +diff 1 ligne - 10.0.0.0/24 → + 10.17.0.0/24 +``` + +Neuf tests dans `scripts/tests/test_gui_intrants.py`, branchés sur `make test` : le +commentaire qui survit au changement qu'il explique, la liste multi-lignes remplacée, la +clé inchangée non reformatée, la clé que le panneau ignore, la clé nouvelle, +l'idempotence, le garde-fou des clés sensibles, et la création d'un fichier neuf. + +> **Deux fois le même geste destructeur, sur le même chemin.** Le 10 août ce panneau +> perdait des **clés** (`dns_amorcage`, `amorcage_acces_courriel` — une VM qui naît sans +> résolution) ; le 18, des **commentaires**. La première fois avait valu une fusion, pas +> un test. C'est le test qui manquait. + + ## 2026-08-18 — P03 mesurait toutes les instances contre l'inventaire d'**une seule** Trouvé en validant une simple mise à jour du CHANGELOG. Deux invocations de la même diff --git a/Makefile b/Makefile index 0831ab7..7a1e546 100644 --- a/Makefile +++ b/Makefile @@ -212,6 +212,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire) python3 scripts/tests/test_raser.py python3 scripts/tests/test_raser_resultat.py python3 scripts/tests/test_adressage_derive.py + python3 scripts/tests/test_gui_intrants.py .PHONY: verifier verifier: lint test inventaire-verifier site-verifier flux-verifier syntaxe ## Rejoue les preuves SANS reecrire le rapport (verification rapide) diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index e1a5ea6..2f7e7c5 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -589,6 +589,84 @@ def _est_reference_voute(valeur: object) -> bool: return bool(_REFERENCE_VOUTE.match(str(valeur).strip())) +_CLE_RACINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*):") + + +def _rendre_cle(cle: str, valeur: object) -> list[str]: + """Les lignes que PyYAML ecrirait pour cette seule clef (listes en bloc incluses).""" + texte = yaml.safe_dump({cle: valeur}, default_flow_style=False, + allow_unicode=True, sort_keys=False) + return texte.rstrip("\n").split("\n") + + +def _fusion_chirurgicale(texte: str, valeurs: dict) -> str: + """Pose les valeurs dans le texte existant SANS reformater ce qui n'a pas change. + + POURQUOI (mesure du 2026-08-18). Ces fichiers portent la memoire ECRITE des + decisions : pourquoi `dns_amorcage` pointe sur un resolveur public, pourquoi le + plan de gestion est reste en 10.0.0.0/24, pourquoi la frontiere ecoute sur `lan`. + Chaque enregistrement du panneau les repassait au `safe_dump` : clefs retriees, + et TOUS les commentaires effaces. Un enregistrement de 13:48 a ainsi emporte + 121 lignes d'explications dans quatre fichiers — dont celle qui disait pourquoi + la valeur qu'on venait de changer avait ete choisie. + + Le depot connaissait deja le geste juste : `_ecrire_intrants_fabric` et + `_ecrire_index_nomenclature` remplacent la ligne sans toucher au reste. Ce qui + manquait ici, c'est de savoir remplacer une valeur de LISTE, qui tient sur + plusieurs lignes. + + Trois regles : + - une clef dont la valeur ne change pas n'est PAS reecrite (zero bruit au diff) ; + - les commentaires INTERNES a un bloc remplace sont conserves, jamais juges : une + explication devenue fausse se corrige a la main, elle ne se supprime pas toute + seule ; + - une clef absente du fichier est ajoutee a la fin, jamais inseree au hasard. + """ + actuel = yaml.safe_load(texte) + actuel = actuel if isinstance(actuel, dict) else {} + lignes = texte.split("\n") + sortie: list[str] = [] + vues: set[str] = set() + i = 0 + while i < len(lignes): + m = _CLE_RACINE.match(lignes[i]) + cle = m.group(1) if m else None + if cle is None or cle not in valeurs: + sortie.append(lignes[i]) + i += 1 + continue + # Le bloc de valeur : la ligne de clef, puis tout ce qui est indente ou item de + # liste. Les commentaires et lignes vides qui SUIVENT la derniere ligne de valeur + # appartiennent a la clef suivante — on les laisse ou ils sont. + fin = i + j = i + 1 + while j < len(lignes): + l = lignes[j] + if l.startswith((" ", "\t", "- ")) or l == "-": + fin = j + elif l.strip().startswith("#") or not l.strip(): + pass # peut etre interne au bloc : on tranche avec `fin` + else: + break + j += 1 + vues.add(cle) + if cle in actuel and actuel[cle] == valeurs[cle]: + sortie.extend(lignes[i:fin + 1]) # inchangee : pas un octet touche + else: + internes = [l for l in lignes[i + 1:fin + 1] if l.strip().startswith("#")] + rendu = _rendre_cle(cle, valeurs[cle]) + sortie.extend([rendu[0]] + internes + rendu[1:]) + i = fin + 1 + ajouts = [c for c in valeurs if c not in vues] + if ajouts: + while sortie and not sortie[-1].strip(): + sortie.pop() + for cle in ajouts: + sortie.extend(_rendre_cle(cle, valeurs[cle])) + sortie.append("") + return "\n".join(sortie) + + def _ecrire_intrants_fichier(path: Path, valeurs: dict, entete: str) -> None: # Une cle sensible n'est toleree que sous forme de reference de voute preservee. # Toute VALEUR reelle sur une de ces cles fait echouer l'ecriture. @@ -597,6 +675,11 @@ def _ecrire_intrants_fichier(path: Path, valeurs: dict, entete: str) -> None: if interdits: raise ValueError(f"Refus d'ecrire des cles sensibles via le GUI: {', '.join(interdits)}") path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + # Fichier existant : on pose les valeurs DANS son texte (commentaires preserves). + path.write_text(_fusion_chirurgicale(path.read_text(encoding="utf-8"), valeurs), + encoding="utf-8") + return with path.open("w", encoding="utf-8") as fichier: fichier.write(entete) yaml.safe_dump(valeurs, fichier, default_flow_style=False, sort_keys=True, allow_unicode=True) diff --git a/scripts/tests/test_gui_intrants.py b/scripts/tests/test_gui_intrants.py new file mode 100644 index 0000000..057457c --- /dev/null +++ b/scripts/tests/test_gui_intrants.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Le panneau « Intrants de base » ecrit SANS effacer la memoire du fichier. + +Ce que ce test garde, et pourquoi il existe. Les fichiers d'intrants portent, a cote +de leurs valeurs, l'explication des decisions : pourquoi ce resolveur d'amorcage, +pourquoi ce plan de gestion, pourquoi cette interface de frontiere. Ces phrases sont +la seule trace de raisonnements qu'aucun code ne redit — et elles etaient effacees a +chaque enregistrement du panneau, qui repassait le fichier entier au `safe_dump` +(mesure du 2026-08-18 : 121 lignes d'explications emportees en un clic, dans quatre +fichiers). + +Le meme fichier avait deja perdu des CLEFS de la meme facon, le 2026-08-10, avant que +la fusion ne soit posee. Deux fois le meme geste destructeur, sur le meme chemin : il +lui fallait un test. + +Ce qui est verifie ici est donc le contrat d'ecriture, pas l'apparence du YAML : +une valeur qui change, une liste qui change, une clef qu'on n'a pas touchee, une clef +que le panneau ne connait pas, une clef nouvelle — et l'idempotence. +""" +import sys +import tempfile +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(RACINE / "scripts")) + +import yaml # noqa: E402 + +import inventory_gui as gui # noqa: E402 + + +ENTETE = "# Intrants de test.\n---\n" + +# Un fichier realiste : commentaires d'entete, commentaire attache a une clef, +# commentaire INTERNE a une liste, et une clef que le panneau ne gere pas. +SOURCE = """# Intrants d'IDENTITE de l'instance — SOURCE UNIQUE. +--- +domaine_interne: chezlepro.internal + +# Resolveur d'AMORCAGE, pose par cloud-init. Il ne sert qu'une fois : `client_unbound` +# bascule ensuite /etc/resolv.conf vers 127.0.0.1. Sans lui, la VM nait sans resolution. +dns_amorcage: 9.9.9.9,149.112.112.112 +fuseau_horaire: America/Toronto +nftables_admin_ssh: +# Le VLAN 10 EST le reseau d'administration (D-54). L'ancienne valeur 192.168.255.0/24 +# decrivait le monde d'avant. +- 10.0.0.0/24 +- 192.168.254.2/32 + +# Adresse du compte d'amorcage — la SEULE valeur qui ne peut pas se deriver. +amorcage_acces_courriel: sysadmin@chezlepro.ca +cle_inconnue_du_panneau: gardee +""" + + +def _ecrire(source: str, valeurs: dict) -> str: + with tempfile.TemporaryDirectory() as tmp: + p = Path(tmp) / "10-intrants.yml" + p.write_text(source, encoding="utf-8") + fusion = gui._lire_yaml_dict(p) + fusion.update(valeurs) + gui._ecrire_intrants_fichier(p, fusion, ENTETE) + return p.read_text(encoding="utf-8") + + +def test_un_commentaire_survit_au_changement_de_la_valeur_qu_il_explique(): + sortie = _ecrire(SOURCE, {"dns_amorcage": "1.1.1.1"}) + assert "dns_amorcage: 1.1.1.1" in sortie + assert "bascule ensuite /etc/resolv.conf vers 127.0.0.1" in sortie + assert "Resolveur d'AMORCAGE" in sortie + + +def test_une_liste_multi_lignes_est_remplacee_et_ses_commentaires_restent(): + sortie = _ecrire(SOURCE, {"nftables_admin_ssh": ["10.17.0.0/24"]}) + data = yaml.safe_load(sortie) + assert data["nftables_admin_ssh"] == ["10.17.0.0/24"] + assert "192.168.254.2/32" not in sortie # l'ancienne valeur est bien partie + assert "Le VLAN 10 EST le reseau d'administration (D-54)" in sortie + + +def test_une_clef_inchangee_n_est_pas_reecrite(): + sortie = _ecrire(SOURCE, {"dns_amorcage": "1.1.1.1"}) + # Ni retriee, ni requotee, ni deplacee : la ligne est celle d'origine. + assert "fuseau_horaire: America/Toronto" in sortie + assert sortie.index("domaine_interne") < sortie.index("dns_amorcage") + assert sortie.splitlines()[0].startswith("# Intrants d'IDENTITE") + + +def test_une_clef_que_le_panneau_ignore_survit_avec_son_commentaire(): + sortie = _ecrire(SOURCE, {"fuseau_horaire": "America/Montreal"}) + data = yaml.safe_load(sortie) + assert data["cle_inconnue_du_panneau"] == "gardee" + assert data["amorcage_acces_courriel"] == "sysadmin@chezlepro.ca" + assert "la SEULE valeur qui ne peut pas se deriver" in sortie + + +def test_une_clef_nouvelle_est_ajoutee_a_la_fin(): + sortie = _ecrire(SOURCE, {"organisation": "Chezlepro"}) + data = yaml.safe_load(sortie) + assert data["organisation"] == "Chezlepro" + assert data["domaine_interne"] == "chezlepro.internal" + assert "Resolveur d'AMORCAGE" in sortie + + +def test_deux_enregistrements_identiques_ne_changent_rien(): + une = _ecrire(SOURCE, {"dns_amorcage": "1.1.1.1"}) + deux = _ecrire(une, {"dns_amorcage": "1.1.1.1"}) + assert une == deux + + +def test_toutes_les_valeurs_restent_lisibles_en_yaml(): + sortie = _ecrire(SOURCE, {"nftables_admin_ssh": ["10.17.0.0/24"], "dns_amorcage": "1.1.1.1"}) + data = yaml.safe_load(sortie) + assert data == { + "domaine_interne": "chezlepro.internal", + "dns_amorcage": "1.1.1.1", + "fuseau_horaire": "America/Toronto", + "nftables_admin_ssh": ["10.17.0.0/24"], + "amorcage_acces_courriel": "sysadmin@chezlepro.ca", + "cle_inconnue_du_panneau": "gardee", + } + + +def test_le_garde_fou_des_cles_sensibles_tient_toujours(): + interdite = sorted(gui.INTRANTS_CLES_INTERDITES)[0] + try: + _ecrire(SOURCE, {interdite: "valeur-en-clair"}) + except ValueError as e: + assert "sensibles" in str(e) + else: + raise AssertionError(f"une valeur en clair sur '{interdite}' aurait du etre refusee") + + +def test_un_fichier_absent_est_cree_avec_son_entete(): + with tempfile.TemporaryDirectory() as tmp: + p = Path(tmp) / "neuf" / "10-intrants.yml" + gui._ecrire_intrants_fichier(p, {"domaine_interne": "exemple.internal"}, ENTETE) + texte = p.read_text(encoding="utf-8") + assert texte.startswith("# Intrants de test.") + assert yaml.safe_load(texte) == {"domaine_interne": "exemple.internal"} + + +if __name__ == "__main__": + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + for t in tests: + t() + print(f"OK — {len(tests)} test(s) intrants du GUI : les commentaires survivent a l'ecriture.")