Set-OPS-Public/scripts/verifier_intrants.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

246 lines
10 KiB
Python
Executable file

#!/usr/bin/env python3
"""Tout intrant qu'un rôle EXIGE est-il fourni par l'instance ?
Un rôle qui `assert` une variable non vide déclare un **contrat** : sans cette valeur, le
déploiement s'arrête. Rien ne vérifiait que l'instance honore ces contrats — et le défaut
ne se voit qu'au moment où la tâche gardée s'exécute vraiment.
C'est ainsi que la première reconstruction from-zero s'est arrêtée sur
`amorcage_acces_courriel` (2026-08-08) : la valeur était obligatoire depuis le matin, aucun
tenant ne la déclarait, et personne ne pouvait le savoir parce que le compte existait déjà
— donc la garde n'avait jamais eu l'occasion de se déclencher.
**Un intrant est considéré comme SATISFAIT s'il est** :
- doté d'un défaut non vide dans `<role>/defaults/main.yml` (y compris un `{{ vault_* }}`,
dont la présence réelle est gardée par P18) ; ou
- posé à l'exécution par un `set_fact` d'un rôle (les résolveurs `resoudre_*`) ; ou
- déclaré en clair par l'inventaire de l'instance (`group_vars`, `host_vars`).
**Ce qu'il ne fait pas, et il faut le savoir** : il ne déchiffre aucune voûte, et il ne
tient pas compte des `when:` qui rendent une assertion conditionnelle. Un intrant exigé
seulement quand une option est active sera donc signalé même si l'option est éteinte.
Signaler à tort coûte une ligne de déclaration ; ne pas signaler coûte un déploiement.
"""
from __future__ import annotations
import argparse
import os
import re
import sys
from pathlib import Path
import yaml
from inventory_rules import instance_courante
RACINE = Path(__file__).resolve().parent.parent
# Les deux idiomes d'exigence du depot. `| bool` n'en est pas un : une valeur fausse est
# une reponse valable, pas une absence.
MOTIFS = (
re.compile(r"^([a-z][a-z0-9_]*)\s*\|\s*length\s*>\s*0\s*$"),
re.compile(r"^([a-z][a-z0-9_]*)\s+is\s+defined\s*$"),
)
def _assertions(chemin: Path) -> set[str]:
"""Variables exigees par les `assert` d'un fichier de taches."""
try:
taches = yaml.safe_load(chemin.read_text(encoding="utf-8")) or []
except yaml.YAMLError:
return set()
trouvees: set[str] = set()
for tache in taches if isinstance(taches, list) else []:
if not isinstance(tache, dict):
continue
bloc = tache.get("ansible.builtin.assert") or tache.get("assert")
if not isinstance(bloc, dict):
continue
conditions = bloc.get("that") or []
for c in [conditions] if isinstance(conditions, str) else conditions:
for motif in MOTIFS:
m = motif.match(str(c).strip())
if m:
trouvees.add(m.group(1))
return trouvees
def _defauts_non_vides(role: Path) -> set[str]:
f = role / "defaults" / "main.yml"
if not f.is_file():
return set()
try:
d = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
except yaml.YAMLError:
return set()
return {k for k, v in d.items() if v not in (None, "", [], {})}
def _poses_par_set_fact() -> set[str]:
"""Variables qu'un role FABRIQUE a l'execution (resolveurs)."""
poses: set[str] = set()
for f in RACINE.glob("roles/*/tasks/*.yml"):
try:
taches = yaml.safe_load(f.read_text(encoding="utf-8")) or []
except yaml.YAMLError:
continue
for tache in taches if isinstance(taches, list) else []:
if not isinstance(tache, dict):
continue
bloc = tache.get("ansible.builtin.set_fact") or tache.get("set_fact")
if isinstance(bloc, dict):
poses |= {k for k in bloc if k != "cacheable"}
return poses
def _cles_vars(noeud) -> set[str]:
"""Variables d'un inventaire YAML, a n'importe quelle profondeur.
DEUX emplacements, et oublier le second faisait crier au manque a tort : les blocs
`vars:` d'un groupe, ET les cles posees directement sous un nom d'hote dans `hosts:`.
C'est la seconde forme que `instancier` emploie pour les valeurs derivees du plan.
"""
trouvees: set[str] = set()
if isinstance(noeud, dict):
v = noeud.get("vars")
if isinstance(v, dict):
trouvees |= {k for k, val in v.items() if val not in (None, "", [], {})}
h = noeud.get("hosts")
if isinstance(h, dict):
for attributs in h.values():
if isinstance(attributs, dict):
trouvees |= {k for k, val in attributs.items()
if val not in (None, "", [], {})}
for sous in noeud.values():
trouvees |= _cles_vars(sous)
elif isinstance(noeud, list):
for sous in noeud:
trouvees |= _cles_vars(sous)
return trouvees
def _declares_par_instance(base: Path) -> set[str]:
"""Cles declarees EN CLAIR par l'inventaire. Les voutes ne sont jamais dechiffrees.
Le FICHIER d'inventaire compte autant que `group_vars/` : `instancier` y ecrit les
valeurs derivees du plan — les liens entre applications, notamment. Ne regarder que
les repertoires faisait crier au manque sur `serveur_postfix_mailstore_hote`, qui
etait bel et bien fourni. Mesure du 2026-08-09, avant meme la premiere execution
utile : un verificateur incomplet accuse le composant a la place de l'instrument.
"""
declares: set[str] = set()
for f in base.glob("inventories/*/hosts.yml"):
try:
declares |= _cles_vars(yaml.safe_load(f.read_text(encoding="utf-8")))
except yaml.YAMLError:
continue
for f in list(base.glob("inventories/*/group_vars/**/*.yml")) + \
list(base.glob("inventories/*/host_vars/**/*.yml")):
texte = f.read_text(encoding="utf-8", errors="ignore")
if texte.lstrip().startswith("$ANSIBLE_VAULT"):
continue
try:
d = yaml.safe_load(texte) or {}
except yaml.YAMLError:
continue
if isinstance(d, dict):
declares |= {k for k, v in d.items() if v not in (None, "", [], {})}
return declares
def _roles_joues_par(base: Path) -> set[str] | None:
"""Les roles qui tourneront REELLEMENT sur cette instance, ou None si indeterminable.
POURQUOI CE CADRAGE (mesure du 2026-08-19). Cette preuve parcourait `roles/*` — les
54 — sans regarder ce que l'instance deploie. Elle passait sur l'ecosysteme de
reference parce qu'il porte TOUT ; elle echouait sur le modele public en exigeant
`serveur_oauth2_proxy_client_id` d'une instance qui ne deploie pas oauth2-proxy, et
`serveur_postfix_mailstore_hote` d'une instance sans Postfix.
La consequence depassait le modele : les modeles sont des OFFRES. Toute offre plus
petite que l'ecosysteme complet — c'est-a-dire toute offre reelle — echouait son
propre harnais, pour des services qu'elle ne vend pas.
Le perimetre juste se lit du plan : les GROUPES de l'inventaire, puis les roles que
leur playbook homonyme compose (`serveur_durci` en enchaine onze sans role du meme
nom). None quand aucun inventaire n'est lisible : on retombe alors sur l'ancien
comportement, large, plutot que de ne rien verifier du tout.
"""
inventaires = sorted(base.glob("inventories/*/hosts.yml"))
if not inventaires:
return None
groupes: set[str] = set()
for f in inventaires:
inv = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
def marcher(noeud):
if not isinstance(noeud, dict):
return
for nom, contenu in (noeud.get("children") or {}).items():
groupes.add(nom)
marcher(contenu or {})
marcher(inv.get("all") or {})
if not groupes:
return None
joues: set[str] = set()
for groupe in groupes:
if (RACINE / "roles" / groupe).is_dir():
joues.add(groupe)
playbook = RACINE / "playbooks" / "groupes" / f"{groupe}.yml"
if not playbook.is_file():
continue
plays = yaml.safe_load(playbook.read_text(encoding="utf-8")) or []
for play in plays if isinstance(plays, list) else []:
for r in (play or {}).get("roles") or []:
joues.add(r if isinstance(r, str) else str((r or {}).get("role", "")))
return joues or None
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--instance", default=None, help="racine de l'instance (defaut : instance/)")
a = ap.parse_args(argv)
# SETOPS_INSTANCE d'abord : le symlink `instance/` n'est que le cas courant. Le coder
# en dur rendait cette preuve inapplicable des qu'on visait une autre instance.
base = Path(a.instance) if a.instance else instance_courante()
if not base.exists():
print("Aucune instance montee : rien a verifier.", file=sys.stderr)
return 2
poses = _poses_par_set_fact()
declares = _declares_par_instance(base)
joues = _roles_joues_par(base)
manquants: list[tuple[str, str]] = []
exiges = 0
for role in sorted(RACINE.glob("roles/*")):
if not role.is_dir():
continue
if joues is not None and role.name not in joues:
continue # ce role ne tournera jamais sur cette instance
defauts = _defauts_non_vides(role)
vus: set[str] = set()
for f in sorted(role.glob("tasks/*.yml")):
vus |= _assertions(f)
for var in sorted(vus):
exiges += 1
if var in defauts or var in poses or var in declares:
continue
manquants.append((role.name, var))
if manquants:
print(f"{len(manquants)} intrant(s) EXIGE(s) que l'instance ne fournit pas :\n")
for r, v in manquants:
print(f" {r:26} exige `{v}` — sans defaut, sans set_fact, absent de l'inventaire")
print("\nLe deploiement s'arretera sur la premiere garde atteinte.")
return 1
print(f"CONFORME : {exiges} exigence(s) de role, toutes satisfaites "
f"({len(declares)} cle(s) declaree(s) par l'instance).")
return 0
if __name__ == "__main__":
raise SystemExit(main())