Some checks are pending
verifier / verifier (push) Waiting to run
Trente-huit preuves, des tests, un lint — et RIEN ne les executait sans qu'un humain tape `make`. Le meilleur atout du depot dependait de ne pas oublier. Il a desormais une CI (.forgejo/workflows/verifier.yml) et une cible qui la rejoue a l'identique : `make ci`. CE QUE LA CI A TROUVE AVANT D'EXISTER. Ecrire le workflow supposait de repondre a une question jamais posee : est-ce qu'un depot PUBLIC, seul, se tient ? Mesure sur un clone nu : non, a cinq endroits. - `make instancier` echouait sur le modele public — le tout premier geste du QUICKSTART. Le Makefile forcait `principal/hosts.yml` alors que le modele vit en `production/` ; sa precedence suit desormais celle du code (fichier, puis REPERTOIRE existant, puis defaut). - P32 parcourait les 54 roles sans regarder ce que l'instance deploie. Elle passait sur l'ecosysteme de reference PARCE QU'IL PORTE TOUT. Or les modeles sont des OFFRES : toute offre plus petite que l'ecosysteme complet echouait son propre harnais, pour des services qu'elle ne vend pas. Le perimetre se lit maintenant du plan (groupes de l'inventaire, puis roles composes par leur playbook). - P24 : le modele public ne declarait aucun reseau d'administration — une flotte qu'on construit et ou l'on n'entre plus. `nftables_admin_ssh` est pose, avec le pourquoi. - P33 : verifier_ports.py codait `instance/inventories/principal/hosts.yml` en dur. - P32 et P24 lisaient le symlink `instance/` au lieu de SETOPS_INSTANCE. Toutes de la MEME FAMILLE que P03 avant-hier : une resolution d'inventaire recopiee, une variable d'environnement qui deborde de sa portee. Le depot en compte SEPT ; deux de plus sont corrigees ici, et la septieme le dit en commentaire plutot que de le taire. `make ci` NE TOUCHE AUCUN SYMLINK : le modele public est monte comme instance jetable, vise par SETOPS_INSTANCE/SETOPS_UNDERLAY, detruit en sortant. Deux details mesures parce que devines faux d'abord : l'instance jetable est un DOSSIER FRERE (la federation se decouvre ainsi ; ailleurs, quatre preuves tombent) ; et SETOPS_UNDERLAY n'est pose QUE pour la verification, sinon l'inventaire est ecrit avec une fabric et regenere avec une autre — la commande fabriquait l'ecart qu'elle denonce. RESULTAT : clone nu sans instance ni frere -> 38 OK, 0 echec, 0 saute. Depot de l'exploitant avec ses 3 instances -> 38 OK, 0 echec, 0 saute. Aucun residu. Et le lint du depot a refuse mon propre fichier de CI avant qu'il ne tourne une seule fois (`on:` lu par YAML comme le booleen vrai). Le harnais mordait deja. A AJUSTER AU PREMIER PASSAGE, ecrit en tete du workflow : l'etiquette `runs-on` doit correspondre a un runner Forgejo enregistre, et le runner a besoin du reseau pour pip et ansible-galaxy. Le vert de cette CI dira que le moteur et son modele public se tiennent — pas que la flotte va bien : aucune VM jointe, aucune voute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
245 lines
10 KiB
Python
Executable file
245 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
|
|
|
|
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 \
|
|
Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance"))
|
|
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())
|