Set-OPS-Public/scripts/tests/test_gui_intrants.py
Daniel Allaire db2d6f6bf0 resolveur : un par tenant, et non plus un par machine
Mesure avant de decider : ~21 Mo de RSS par VM pour 28 a 189 requetes servies.
Le gain de cache est negligeable ; ce qu'on recupere, c'est un demon au lieu de
cinq et un endroit a regarder au lieu de cinq.

Pas sur la frontiere : un resolveur de SITE devrait connaitre la zone interne de
chaque tenant, et un tenant dont le resolveur vit chez l'hebergeur ne peut plus
s'emanciper avec. La recursion est generique, la zone interne ne l'est pas.

L'autoritatif se replie sur 127.0.0.1:5300 -- derive de la colocalisation, pas
declare a la main. Son port devient `derive` dans meta/flux.yml : les deux lient
53 mais sur des adresses differentes.

Deux pieges. Unbound refuse d'interroger une loopback par defaut : sans lever
do-not-query-localhost, toute la zone rendait SERVFAIL. Et le plancher
/etc/hosts MASQUAIT la panne -- getent repondait, dig disait SERVFAIL. La tache
de validation du role avait raison contre moi.

client_unbound n'installant plus Unbound, son nom mentait : client_resolveur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 16:59:34 -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_resolveur`
# 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.")