P32 / D-72 : tout intrant exige par un role est fourni par l'instance

Premier des trois chantiers rendus evidents par la reconstruction. Il aurait
trouve son defaut n1 — amorcage_acces_courriel — SANS RIEN DETRUIRE.

Un assert de role declare un contrat ; rien ne verifiait que l'instance
l'honore, et le manque ne se voit qu'au moment ou la garde s'execute — donc,
pour un intrant d'amorcage, seulement en repartant de rien.

Satisfait par : defaut non vide (vault_* compris, gardes par P18), set_fact de
resolveur, ou declaration de l'inventaire. Aucune voute dechiffree : la preuve
reste statique.

Deux fois mon instrument a accuse le composant a sa place, avant meme sa
premiere execution utile : il criait au manque sur
serveur_postfix_mailstore_hote, pourtant fourni — je ne lisais pas le fichier
d'inventaire, puis je n'y cherchais que les blocs vars: alors qu'instancier
ecrit sous le nom d'hote.

Verifie dans les deux sens : 30 exigences satisfaites sur le reel ; sur un
double sans la declaration d'hier, le defaut n1 est nomme, code 1.

Harnais : 32 preuves, 0 echec, 0 sautee.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-09 09:40:25 -04:00
parent cbb186d2fa
commit d52f256366
7 changed files with 236 additions and 5 deletions

View file

@ -1,5 +1,39 @@
# CHANGELOG — Set-OPS # CHANGELOG — Set-OPS
## 2026-08-09 — P32 : un `assert` de rôle est un contrat, et l'instance doit l'honorer
Le premier des trois chantiers que la reconstruction avait rendus évidents. Il aurait
trouvé son défaut nº 1 — `amorcage_acces_courriel`**sans rien détruire**.
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 les honore, et le manque ne se voit
qu'au moment où la garde s'exécute pour de vrai — c'est-à-dire, pour un intrant d'amorçage,
seulement quand on repart de rien.
**Un intrant est satisfait** par un défaut non vide dans le rôle (y compris un
`{{ vault_* }}`, dont la présence réelle relève de P18), par un `set_fact` de résolveur, ou
par une déclaration de l'inventaire. Aucune voûte n'est déchiffrée : la preuve reste
statique, comme les 31 autres.
**Deux fois mon instrument a accusé le composant à sa place**, et les deux fois avant la
première exécution utile. Il criait au manque sur `serveur_postfix_mailstore_hote`, qui est
pourtant bel et bien fourni — d'abord parce que je ne lisais que `group_vars/` et
`host_vars/` en oubliant le fichier d'inventaire lui-même, ensuite parce que je n'y
cherchais que les blocs `vars:` alors que `instancier` écrit les valeurs dérivées
**directement sous le nom d'hôte**. Un vérificateur incomplet est pire qu'absent : il fait
douter de ce qui marche.
**Vérifié dans les deux sens.** Sur l'instance réelle : 30 exigences, toutes satisfaites.
Sur un double où l'on retire la déclaration ajoutée la veille : `amorcage_acces_courriel`
nommé, code de sortie 1 — le défaut nº 1 reproduit à froid.
Ce qu'il ne fait pas, et c'est écrit dans son en-tête : il ignore les `when:` qui rendent
une assertion conditionnelle, donc il peut signaler un intrant exigé seulement quand une
option est active. Signaler à tort coûte une ligne de déclaration ; ne pas signaler coûte
un déploiement.
Harnais : **32 preuves, 0 échec, 0 sautée.**
## 2026-08-09 — La reconstruction from-zero est prouvée : cinq devis sur cinq ## 2026-08-09 — La reconstruction from-zero est prouvée : cinq devis sur cinq
Écosystème `chezlepro` détruit — 14 VM, disques compris — puis **rejoué depuis le plan Écosystème `chezlepro` détruit — 14 VM, disques compris — puis **rejoué depuis le plan

View file

@ -438,6 +438,9 @@ certificats-plan: ansible-runtime ## Ecart entre les certificats sur disque et c
@ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-certificats.yml >/dev/null @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-certificats.yml >/dev/null
@python3 scripts/devis_certificats.py @python3 scripts/devis_certificats.py
intrants-verifier: ## Tout intrant qu'un role EXIGE est-il fourni par l'instance ? (lecture seule)
@python3 scripts/verifier_intrants.py
identite-plan: ansible-runtime ## Ecart entre l'identite deployee et ce que le plan derive (aucune ecriture) identite-plan: ansible-runtime ## Ecart entre l'identite deployee et ce que le plan derive (aucune ecriture)
@ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-identite.yml >/dev/null @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-identite.yml >/dev/null
@python3 scripts/devis_identite.py @python3 scripts/devis_identite.py

View file

@ -7,7 +7,7 @@
> [`docs/audit/affirmations.md`](affirmations.md). > [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml` - **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (30 OK · 0 echec · 1 saute) - **Verdict** : ✅ CONFORME (32 OK · 0 echec · 0 saute)
## Preuves ## Preuves
@ -28,9 +28,9 @@
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. | | P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. | | P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. | | P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). | | P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 14 hotes, 31 groupes (inventaire dechiffre et parse). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. | | P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 25 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) | | P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 25 secret(s) exige(s), tous presents. Voute reelle : 28 cle(s), aucun manque. |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. | | P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. | | P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 2 instance(s) federee(s), aucun index en collision. | | P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 2 instance(s) federee(s), aucun index en collision. |
@ -43,7 +43,8 @@
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 2 pool(s) Proxmox, 28 VM placee(s), aucun nom ni VMID en collision. | | P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 2 pool(s) Proxmox, 28 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 23 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 12, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 23 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 12, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. | | P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 37 scripts expliques et atteignables, 87 cibles make documentees, 54 roles avec README. | | P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 38 scripts expliques et atteignables, 88 cibles make documentees, 54 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 30 exigence(s) de role, toutes satisfaites (126 cle(s) declaree(s) par l'instance). |
## Couverture des affirmations ✅ du registre ## Couverture des affirmations ✅ du registre

View file

@ -23,7 +23,7 @@ code + les README de rôles). Cette page comble ces deux trous.
| **Ordre de déploiement** | `docs/couches-deploiement.yml` (couches) + `docs/dependances-groupes.yml` (graphe) → `playbooks/site.yml` (**généré**, `make site`) | | **Ordre de déploiement** | `docs/couches-deploiement.yml` (couches) + `docs/dependances-groupes.yml` (graphe) → `playbooks/site.yml` (**généré**, `make site`) |
| **Conformité du déployé** | `docs/devis-services.md` — les **cinq devis de service** (`make identite-plan`, `certificats-plan`, `expositions-plan`, `postgresql-plan`, `courriel-plan`). Répondent à ce que `make prouver` ne demande jamais : *ce qui tourne correspond-il à ce qui est déclaré ?* | | **Conformité du déployé** | `docs/devis-services.md` — les **cinq devis de service** (`make identite-plan`, `certificats-plan`, `expositions-plan`, `postgresql-plan`, `courriel-plan`). Répondent à ce que `make prouver` ne demande jamais : *ce qui tourne correspond-il à ce qui est déclaré ?* |
| **Preuve / recette** | `docs/audit/affirmations.md` (registre), `make prouver``docs/audit/preuve-<date>.md`**statique** : lit le dépôt, aucun appel réseau ; la conformité du déployé est l'affaire des devis de service (ligne au-dessus), `docs/audit/plan-de-recette.md` (**généré** du wiki), `docs/audit/protocole-operateur-independant.md` | | **Preuve / recette** | `docs/audit/affirmations.md` (registre), `make prouver``docs/audit/preuve-<date>.md`**statique** : lit le dépôt, aucun appel réseau ; la conformité du déployé est l'affaire des devis de service (ligne au-dessus), `docs/audit/plan-de-recette.md` (**généré** du wiki), `docs/audit/protocole-operateur-independant.md` |
| **Décisions d'architecture** | `docs/decisions-architecture.md`**68 décisions en vigueur** (D-01 → D-71, 3 renversées), pourquoi, où lire le détail, et ce qui les garde ; plus les **décisions renversées** et leur cause | | **Décisions d'architecture** | `docs/decisions-architecture.md`**69 décisions en vigueur** (D-01 → D-72, 3 renversées), pourquoi, où lire le détail, et ce qui les garde ; plus les **décisions renversées** et leur cause |
| **SDN / routage** | `docs/sdn-evpn.md` — décision du 2026-08-02 : le routage inter-zone passe des commutateurs aux hyperviseurs (zones EVPN = VRF). **Non éprouvé** : spike avant génération | | **SDN / routage** | `docs/sdn-evpn.md` — décision du 2026-08-02 : le routage inter-zone passe des commutateurs aux hyperviseurs (zones EVPN = VRF). **Non éprouvé** : spike avant génération |
| **Migration de tenant** | `docs/migration-tenant.md` — recette en 8 étapes, machine à états, gardes ; le receveur se construit **avant** tout gel | | **Migration de tenant** | `docs/migration-tenant.md` — recette en 8 étapes, machine à états, gardes ; le receveur se construit **avant** tout gel |
| **Exploitation courante** | `docs/runbooks-exploitation.md`, `docs/intrants-communs.md`, `docs/intrants-base-gui-conception.md`, `docs/theme-forgejo-hors-flotte.md` | | **Exploitation courante** | `docs/runbooks-exploitation.md`, `docs/intrants-communs.md`, `docs/intrants-base-gui-conception.md`, `docs/theme-forgejo-hors-flotte.md` |

View file

@ -107,6 +107,7 @@ sont les seules vérifiables.
| **D-34** | Une **exemption** se dérive du **service rendu** (`sauf_role`), jamais d'un nom d'hôte | l'AC ne s'enrôle pas auprès d'elle-même ; l'exemption doit suivre step-ca si on le déplace | `roles/client_pki/meta/integration.yml` | P26 | | **D-34** | Une **exemption** se dérive du **service rendu** (`sauf_role`), jamais d'un nom d'hôte | l'AC ne s'enrôle pas auprès d'elle-même ; l'exemption doit suivre step-ca si on le déplace | `roles/client_pki/meta/integration.yml` | P26 |
| **D-68** | On **écrit, puis on relit et on compare** — quelle que soit l'interface ; on choisit celle dont le chemin de **lecture** parle le même langage que le chemin d'**écriture** | « toujours préférer l'API » n'aurait prédit aucune des pannes du 2026-08-08 : sur six familles de défauts, deux venaient d'un CLI, une d'un module Ansible (`ldap_entry` crée sans jamais modifier), une d'un `grep` de fichier, une de la précédence Ansible, une de mon comparateur. Le facteur commun est d'avoir écrit sans relire. Et la plupart de la flotte n'a **pas** d'API — Postfix, Dovecot, nginx, slapd, nftables : `postconf -h` / `postconf -e` sont symétriques, c'est tout ce qu'on demande | `devis-services.md` | les 5 devis | | **D-68** | On **écrit, puis on relit et on compare** — quelle que soit l'interface ; on choisit celle dont le chemin de **lecture** parle le même langage que le chemin d'**écriture** | « toujours préférer l'API » n'aurait prédit aucune des pannes du 2026-08-08 : sur six familles de défauts, deux venaient d'un CLI, une d'un module Ansible (`ldap_entry` crée sans jamais modifier), une d'un `grep` de fichier, une de la précédence Ansible, une de mon comparateur. Le facteur commun est d'avoir écrit sans relire. Et la plupart de la flotte n'a **pas** d'API — Postfix, Dovecot, nginx, slapd, nftables : `postconf -h` / `postconf -e` sont symétriques, c'est tout ce qu'on demande | `devis-services.md` | les 5 devis |
| **D-69** | Sur Keycloak : **l'API pour toute map ou collection** (`smtpServer`, `attributes`, `config`), `kcadm` pour les scalaires et les créations | `kcadm -s` sur une map accepte la commande, **sort en succès et n'écrit rien** — mesuré deux fois le 2026-08-08 (`smtpServer` resté vide après deux déploiements verts, puis `post.logout.redirect.uris`). Le CLI reste préféré ailleurs : c'est le vocabulaire de la documentation du produit, donc lisible sans IA | `roles/serveur_keycloak/tasks/` | `make identite-plan` | | **D-69** | Sur Keycloak : **l'API pour toute map ou collection** (`smtpServer`, `attributes`, `config`), `kcadm` pour les scalaires et les créations | `kcadm -s` sur une map accepte la commande, **sort en succès et n'écrit rien** — mesuré deux fois le 2026-08-08 (`smtpServer` resté vide après deux déploiements verts, puis `post.logout.redirect.uris`). Le CLI reste préféré ailleurs : c'est le vocabulaire de la documentation du produit, donc lisible sans IA | `roles/serveur_keycloak/tasks/` | `make identite-plan` |
| **D-72** | Un `assert` de rôle est un **contrat d'intrant**, et l'instance doit l'honorer — vérifié hors ligne | la première reconstruction from-zero s'est arrêtée sur `amorcage_acces_courriel` : obligatoire depuis le matin, déclaré par aucun tenant, et invisible parce que le compte existait déjà — la garde n'avait jamais eu l'occasion de se déclencher. Un intrant est satisfait par un **défaut non vide**, un `set_fact` de résolveur, ou une déclaration de l'inventaire (fichier `hosts.yml` compris) | `scripts/verifier_intrants.py`, `make intrants-verifier` | **P32** |
| **D-70** | La documentation **dit et explique tout ce que le dépôt fait** — et l'exigence est **outillée**, pas seulement énoncée | une exigence qu'on n'outille pas pourrit en silence : la carte annonçait « 28 décisions » quand il y en avait 66, et disait les accès « non construits » alors qu'ils tournaient en production. **P31** garde le couvert — chaque script s'explique et reste atteignable, chaque cible `make` porte son aide (sauf les internes préfixées `_`), chaque rôle a son README. Elle ne garde **pas** la qualité du « pourquoi » : ça se juge en revue, et ça vit dans `CHANGELOG.md` et ici | `devis-services.md`, `CHANGELOG.md` | **P31** | | **D-70** | La documentation **dit et explique tout ce que le dépôt fait** — et l'exigence est **outillée**, pas seulement énoncée | une exigence qu'on n'outille pas pourrit en silence : la carte annonçait « 28 décisions » quand il y en avait 66, et disait les accès « non construits » alors qu'ils tournaient en production. **P31** garde le couvert — chaque script s'explique et reste atteignable, chaque cible `make` porte son aide (sauf les internes préfixées `_`), chaque rôle a son README. Elle ne garde **pas** la qualité du « pourquoi » : ça se juge en revue, et ça vit dans `CHANGELOG.md` et ici | `devis-services.md`, `CHANGELOG.md` | **P31** |
| **D-71** | **Une PKI et un DNS fonctionnels avant toute chose** ; puis, par VM : socle → enrôlement PKI → enregistrement DNS (A **et** PTR) | `deployer-tout` déroule par COUCHES — correct, mais chaque VM réclame alors un certificat à une autorité pas encore debout, et l'échec se lit comme un défaut du rôle et non d'ordre. Les deux hôtes d'amorçage se **dérivent** de `applications.<step_ca\|powerdns>.hote` : déplacer l'autorité déplace l'amorçage. **Deux exceptions structurelles assumées** — l'AC s'auto-signe, le DNS pose son propre enregistrement | `Makefile` `_amorcer-socle`, `scripts/socle_amorcage.py` | — | | **D-71** | **Une PKI et un DNS fonctionnels avant toute chose** ; puis, par VM : socle → enrôlement PKI → enregistrement DNS (A **et** PTR) | `deployer-tout` déroule par COUCHES — correct, mais chaque VM réclame alors un certificat à une autorité pas encore debout, et l'échec se lit comme un défaut du rôle et non d'ordre. Les deux hôtes d'amorçage se **dérivent** de `applications.<step_ca\|powerdns>.hote` : déplacer l'autorité déplace l'amorçage. **Deux exceptions structurelles assumées** — l'AC s'auto-signe, le DNS pose son propre enregistrement | `Makefile` `_amorcer-socle`, `scripts/socle_amorcage.py` | — |

View file

@ -571,6 +571,8 @@ PREUVES: list[dict] = [
"cmds": [[sys.executable, "scripts/devis_sdn.py", "--verifier"]]}, "cmds": [[sys.executable, "scripts/devis_sdn.py", "--verifier"]]},
{"id": "P31", "titre": "Documentation : tout ce que le depot FAIT est nomme", "refs": [], {"id": "P31", "titre": "Documentation : tout ce que le depot FAIT est nomme", "refs": [],
"func": preuve_documentation_outillage}, "func": preuve_documentation_outillage},
{"id": "P32", "titre": "Intrants exiges par les roles : tous fournis", "refs": [],
"cmds": [[sys.executable, "scripts/verifier_intrants.py"]]},
] ]

190
scripts/verifier_intrants.py Executable file
View file

@ -0,0 +1,190 @@
#!/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 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 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)
base = Path(a.instance) if a.instance else (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)
manquants: list[tuple[str, str]] = []
exiges = 0
for role in sorted(RACINE.glob("roles/*")):
if not role.is_dir():
continue
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())