Set-OPS-Public/scripts/tests/test_gui_intrants.py
Daniel Allaire ed84494c40 GUI : le panneau d'intrants effacait la memoire ecrite du depot
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 <noreply@anthropic.com>
2026-08-18 15:05:12 -04:00

147 lines
5.8 KiB
Python

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