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.")