Set-OPS-Public/scripts/couverture_gui.py
Daniel Allaire c18debc25e
Some checks are pending
verifier / verifier (push) Waiting to run
resolution d'instance : une seule, partagee — au lieu de neuf copies
Cinq jours, cinq defauts, tous de la meme famille : « quelle instance, quel inventaire ? »
Neuf modules portaient chacun leur reponse.

- 18 aout : P03 comparait chaque instance a l'inventaire d'une AUTRE ;
- 19 aout : verifier_ports codait `principal/` en dur ; verifier_intrants et
  _frontiere_absente lisaient le symlink au lieu de la variable ;
- 20 aout : devis_placement rendait un verdict juste sur le mauvais tenant ;
- 22 aout : P35, puis P36 — la dixieme, trouvee par la preuve elle-meme.

Aucune n'etait une faute d'inattention : chacune avait ete ecrite de bonne foi, a un
moment ou le besoin semblait local. C'est le mode de panne de la duplication — pas
l'erreur, mais la DERIVE, invisible depuis l'interieur d'un fichier.

LA RESOLUTION UNIQUE. `inventory_rules` porte instance_courante(), inventaire_de(),
dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le TROISIEME manquait a la
moitie des copies : un hosts.yml existant, puis un REPERTOIRE existant (instance neuve —
c'est ce qui faisait echouer `make instancier` sur le modele public), puis le defaut.
Vingt-huit modules y sont branches.

CE QUI REND CE REFACTOR SUR : avant de toucher quoi que ce soit, chaque module a ete
interroge sur ce qu'il resolvait, pour les DEUX ecosystemes. Apres refactor, meme mesure :
17 modules x 2 instances, diff VIDE. Aucune resolution n'a change — prouve, pas suppose.

P41 echoue des qu'un module reintroduit une copie. Eprouvee en negatif : une copie
replacee dans genome.py est signalee avec son numero de ligne. Trois exemptions nommees :
instances.py et inventory_gui.py manipulent le SYMLINK lui-meme (bascule d'instance), et
devis_opnsense lit deliberement quelle instance est ACTIVE. Elles parlent du lien, pas de
la resolution.

make verifier 41 OK, 0 echec, 0 saute ; make ci idem ; lint vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:19:55 -04:00

154 lines
6 KiB
Python

#!/usr/bin/env python3
"""Le GUI sait-il editer tout ce que les plans reels contiennent ?
Principe 10 : « un operateur humain exploite Set-OPS sans IA — la doc, `make` et
le GUI suffisent ». Ce principe se dement en silence des qu'un registre accepte un
champ que l'interface ne sait pas ecrire : l'operateur doit editer le YAML a la
main, et personne ne s'en apercoit avant de buter dessus.
Ce script rend le trou VISIBLE et mesurable. Il croise :
- les champs REELLEMENT presents dans les plans (instance courante + tous les
modeles decouverts par scripts/modeles.py) ;
- `CHAMPS_ECRITS_PAR_GUI` de scripts/inventory_gui.py, qui declare ce que les
fonctions de sauvegarde ecrivent.
Deux sens de verification :
1. champ dans un plan, absent du GUI -> TROU d'interface (signale) ;
2. champ declare au GUI, introuvable dans son source -> DERIVE de la
declaration (signale) — le tableau ment sur ce que le GUI fait.
Usage :
python3 scripts/couverture_gui.py lister
python3 scripts/couverture_gui.py verifier # rc=2 s'il reste un trou
python3 scripts/couverture_gui.py verifier --tolerer nomenclature
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
import yaml
from inventory_gui import CHAMPS_ECRITS_PAR_GUI
from modeles import decouvrir as decouvrir_modeles
from inventory_rules import instance_courante, instance_courante # noqa: E402
RACINE = Path(__file__).resolve().parents[1]
INSTANCE = instance_courante()
SOURCE_GUI = RACINE / "scripts" / "inventory_gui.py"
# (fichier du plan, cle racine, nom du registre) — les registres tables-de-tables.
REGISTRES = [
("serveurs.yml", "serveurs", "serveurs"),
("applications.yml", "applications", "applications"),
("bases-donnees.yml", "bases_donnees", "bases_donnees"),
("bases-donnees.yml", "serveurs_bd", "serveurs_bd"),
("domaines.yml", "domaines_publics", "domaines_publics"),
("nomenclature.yml", "fonctions", "nomenclature"),
]
def _plans() -> list[Path]:
"""Dossiers plan/ a inspecter : l'instance courante + tous les modeles."""
trouves = []
if (INSTANCE / "plan").is_dir():
trouves.append(INSTANCE / "plan")
trouves += [m / "plan" for m in decouvrir_modeles() if (m / "plan").is_dir()]
return trouves
def champs_utilises() -> dict[str, dict[str, set[str]]]:
"""{registre: {champ: {origines}}} — champs vus dans les plans reels."""
vus: dict[str, dict[str, set[str]]] = {r: {} for _, _, r in REGISTRES}
for plan in _plans():
etiquette = plan.parent.name
for fichier, racine_cle, registre in REGISTRES:
p = plan / fichier
if not p.is_file():
continue
try:
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
except yaml.YAMLError:
continue
entrees = (data or {}).get(racine_cle) or {}
if not isinstance(entrees, dict):
continue
for entree in entrees.values():
if isinstance(entree, dict):
for champ in entree:
vus[registre].setdefault(str(champ), set()).add(etiquette)
return vus
def declaration_derive() -> list[str]:
"""Champs declares au GUI mais introuvables dans son source (declaration qui ment)."""
src = SOURCE_GUI.read_text(encoding="utf-8")
# On coupe le tableau lui-meme : sa presence ne prouve rien.
debut = src.find("CHAMPS_ECRITS_PAR_GUI")
fin = src.find("\n}\n", debut)
ailleurs = src[:debut] + src[fin:] if debut != -1 and fin != -1 else src
absents = []
for registre, champs in CHAMPS_ECRITS_PAR_GUI.items():
for champ in sorted(champs):
if champ not in ailleurs:
absents.append(f"{registre}.{champ}")
return absents
def main() -> int:
parser = argparse.ArgumentParser(description="Couverture du schema du plan par le GUI.")
sub = parser.add_subparsers(dest="commande", required=True)
sub.add_parser("lister", help="Detaille champ par champ.")
pv = sub.add_parser("verifier", help="Echoue s'il reste un champ non editable.")
pv.add_argument("--tolerer", action="append", default=[], metavar="REGISTRE",
help="Registre dont les trous sont connus et assumes (repetable).")
args = parser.parse_args()
utilises = champs_utilises()
if args.commande == "lister":
for registre in sorted(utilises):
couverts = CHAMPS_ECRITS_PAR_GUI.get(registre, set())
print(f"\n== {registre} ==")
for champ in sorted(utilises[registre]):
etat = "GUI" if champ in couverts else "-- hors GUI --"
print(f" {champ:<18} {etat:<16} vu dans : "
f"{', '.join(sorted(utilises[registre][champ]))}")
return 0
tolere = set(args.tolerer)
trous: list[str] = []
for registre, champs in utilises.items():
if registre in tolere:
continue
couverts = CHAMPS_ECRITS_PAR_GUI.get(registre, set())
for champ in sorted(set(champs) - couverts):
trous.append(f"{registre}.{champ} (vu dans : "
f"{', '.join(sorted(champs[champ]))})")
derive = declaration_derive()
if trous:
print(f"erreur: {len(trous)} champ(s) present(s) dans un plan reel que le GUI "
f"ne sait pas ecrire :", file=sys.stderr)
for t in trous:
print(f" - {t}", file=sys.stderr)
if derive:
print(f"erreur: {len(derive)} champ(s) declare(s) dans CHAMPS_ECRITS_PAR_GUI mais "
f"introuvable(s) dans le source du GUI : {', '.join(derive)}", file=sys.stderr)
if trous or derive:
return 2
total = sum(len(c) for c in utilises.values())
print(f"GUI : les {total} champ(s) des plans reels sont editables "
f"({len(_plans())} plan(s) inspecte(s))"
+ (f", registres toleres : {', '.join(sorted(tolere))}." if tolere else "."))
return 0
if __name__ == "__main__":
raise SystemExit(main())