Trois preuves qui regardent au-dela d'une seule instance + champ liens/websocket au GUI

Le harnais ne verifiait qu'UNE instance et le seul modele socle. Tout ce qui vit
a cote du moteur echappait au controle. Trois preuves ferment ces angles morts :

- P17 (scripts/modeles.py) : TOUS les modeles valident, pas seulement socle.
  SETOPS_MODELES=../Set-OPS-Modeles inclut les modeles assembles prives. A trouve
  6 modeles invalides sur 7 (corriges dans Set-OPS-Modeles).
- P18 (scripts/voute.py) : le gabarit vault.yml.example couvre EXACTEMENT les secrets
  que le plan exige (bases + roles actifs + group_vars). Ne dechiffre jamais la vraie
  voute : compare des noms.
- P19 (scripts/couverture_gui.py) : tout champ present dans un plan reel est editable
  par le GUI. A trouve applications.websocket (comble). Nomenclature toleree (trou connu).

GUI :
- champ « Liens (bindings) » dans l'inspecteur d'application : role -> cible en listes
  deroulantes, les roles proposes = ceux que le role porteur accepte (meta/liens.yml).
  Comble un manque : les bindings ne se declaraient qu'en editant le YAML a la main.
- champ « WebSocket » (Collabora).
- CHAMPS_ECRITS_PAR_GUI : declaration de ce que le GUI sait ecrire, verifiee par P19.

Garde-fou de fond : valider_applications refuse une application posee sur un hote non
declare (l'hote fantome exact qu'integral portait). Cable partout + POST du GUI.

liens_acceptes()/catalogue_liens() dans inventory_rules : source unique partagee par le
validateur, le GUI et instancier.py (dont la copie locale est retiree).

Valide : make verifier rc=0, CONFORME 19/19, ansible-lint 0 echec, 7 modeles valident,
DIFF VIDE, node --check du GUI OK. Piece justificative : docs/audit/preuve-2026-07-22.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-07-22 21:32:42 -04:00
parent 131a30075d
commit 7e190e0a57
12 changed files with 763 additions and 23 deletions

View file

@ -1,5 +1,77 @@
# CHANGELOG — Set-OPS
## 2026-07-22 (suite)
### Ajouté — trois preuves qui ferment les angles morts du harnais
Le constat : `make prouver` ne vérifiait qu'*une* instance et le seul modèle `socle`. Tout
ce qui vit à côté du moteur dérivait en silence — d'où l'hôte fantôme d'`integral`, les
neuf secrets absents du gabarit Chezlepro, les champs de plan que le GUI ne sait pas écrire.
- **P17 — tous les modèles valident** (`scripts/modeles.py`). Rejoue les 4 validateurs de
registres sur chaque modèle découvert (les `exemples/modeles/*` du dépôt, plus les chemins
de `SETOPS_MODELES`, ex. le dépôt privé). **A immédiatement trouvé 6 modèles invalides
sur 7** : cinq portaient `autorite: interne` (valeur périmée jamais propagée depuis la
correction de `socle` en phase 5), `presence-web` manquait son domaine interne. Corrigés.
- **P18 — gabarit de voûte complet** (`scripts/voute.py`). Confronte `vault.yml.example`
aux secrets réellement exigés par le plan (champ `secret` des bases + références `vault_*`
des rôles actifs et des group_vars). Ne déchiffre jamais la vraie voûte : compare des noms.
`--strict` signale aussi les clés devenues inutiles.
- **P19 — le GUI couvre le plan** (`scripts/couverture_gui.py`). Croise les champs présents
dans les plans réels (instance + modèles) avec `CHAMPS_ECRITS_PAR_GUI`, nouveau tableau de
`inventory_gui.py` déclarant ce que les fonctions de sauvegarde écrivent. Signale tout
champ non éditable par le GUI (**a trouvé `applications.websocket`**, comblé — case à
cocher ajoutée), et toute dérive entre le tableau et le source du GUI. La nomenclature
reste un trou connu (lecture seule), tolérable via `--tolerer nomenclature`.
### Corrigé
- **Garde-fou contre l'hôte fantôme** : `valider_applications` accepte désormais le registre
des serveurs et **refuse une application posée sur un hôte non déclaré** — la faute exacte
qu'`integral` portait. Câblé dans `scripts/applications.py` (les 4 appels), au POST du GUI,
et vérifié : re-teste le bug d'origine → rejeté avec un message actionnable.
- **6 modèles de `Set-OPS-Modeles`** : `autorite: interne``auto-heberge` (collaboration,
forge, identite, integral, observabilite) ; `presence-web` gagne son domaine interne pour
ses expositions `site.*`/`app.*`.
- **Champ `websocket` dans le GUI** (inspecteur d'application) — Collabora en a besoin.
Validé : `make verifier`**rc=0, CONFORME 19/16→19** (P01P19), `ansible-lint` 0 échec,
les 7 modèles valident (`SETOPS_MODELES` posé), gabarit de voûte complet (23/23), couverture
GUI 27/27 champs (nomenclature tolérée), DIFF VIDE conservé, JS du GUI valide.
## 2026-07-22
### Ajouté
- **Champ « Liens (bindings) » dans le GUI** (`scripts/inventory_gui.py`). L'inspecteur
d'application porte une section dédiée : une ligne par lien, `rôle → cible`, les deux en
listes déroulantes, avec bouton d'ajout et de retrait. Les **rôles proposés sont
exactement ceux que le rôle porteur déclare accepter** (`roles/<groupe>/meta/liens.yml`),
et les cibles sont les autres applications du plan. Chaque ligne affiche les **variables
que le lien injectera**. Changer le groupe d'une application vide les rôles de lien
devenus inacceptés. Comble un manque relevé à l'audit du GUI : les bindings ne pouvaient
se déclarer qu'en éditant `plan/applications.yml` à la main, ce qui rendait la
configuration du courriel (Postfix → Dovecot, Postfix → rspamd) inaccessible sans éditeur.
- **`liens_acceptes(groupe)` et `catalogue_liens()`** dans `scripts/inventory_rules.py`
source **unique** de « quels liens un rôle accepte », partagée par le validateur, le GUI
et `instancier.py`. Nouvelle clé `liens_acceptes` dans la charge utile de `/api/inventaire`.
### Modifié
- **`valider_applications` valide désormais les `liens`** : liste de tables `{vers, role}`,
champs non vides, cible connue, pas de lien vers soi-même, et **rôle accepté par le rôle
porteur** (avec la liste des rôles acceptés dans le message d'erreur). Un rôle sans
`meta/liens.yml` reste toléré au validateur — `instancier.py` tranche avec le même message.
Bénéficie aussi à `make inventaire-verifier` et à `scripts/applications.py verifier`.
- **`scripts/instancier.py`** — sa copie locale `_liens_acceptes()` est retirée au profit de
la fonction partagée. Un seul endroit lit `meta/liens.yml`.
- **`docs/bindings-conception.md`** — la Phase 4 passe à 🟡 : l'éditeur est fait, le
**graphe des liens reste à faire**.
Validé : `make verifier`**rc=0**, `ansible-lint` 0 échec sur 485 fichiers,
`prouver.py --verifier` → CONFORME 16/16, `verifier_gui.py` (node --check) OK,
**DIFF VIDE** conservé et les deux variables de binding du courriel toujours générées
(`serveur_postfix_mailstore_hote`, `serveur_postfix_rspamd_milter`). Aller-retour
d'écriture testé : les liens survivent au cycle chargement → sauvegarde → relecture.
Sept garde-fous du validateur éprouvés (rôle inconnu, cible inexistante, lien vers
soi-même, champ manquant, types invalides).
## 2026-07-21 (suite)
### Modifié

View file

@ -54,6 +54,15 @@ make prouver
| P14 | Chemins d'inventaire | aucun `instance/inventories/lab/group_vars` codé en dur. |
| P15 | Modèle public `socle` | ses registres (domaines/serveurs/applications/bases) valident. |
| P16 | Inventaire Ansible (voûte) | `ansible-inventory --list` — sauté sans mot de passe Vault. |
| P17 | **Tous** les modèles d'instance | chaque modèle découvert valide (pas seulement `socle`). `SETOPS_MODELES=../Set-OPS-Modeles` inclut les modèles assemblés privés. |
| P18 | Gabarit de voûte complet | `vault.yml.example` couvre **exactement** les secrets que le plan exige (rôles actifs + bases + group_vars). |
| P19 | GUI couvre le plan | tout champ présent dans un plan réel est éditable par le GUI (nomenclature tolérée : trou connu). |
> **P17, P18, P19 ferment les angles morts du harnais** : il ne vérifiait qu'*une* instance
> et le seul modèle `socle`. P17 aurait attrapé l'hôte fantôme d'`integral` ; P18, les neuf
> secrets absents du gabarit Chezlepro ; P19, les champs `liens` / `websocket` que le GUI ne
> savait pas écrire. Chacune a une CLI dédiée (`scripts/modeles.py`, `voute.py`,
> `couverture_gui.py`) utilisable seule.
Le rapport relie chaque preuve aux **affirmations** qu'elle couvre (colonne « Affirmations »),
et liste à part les **déclarations d'intention** (⚪ invérifiables localement : AFF-036,

View file

@ -0,0 +1,55 @@
# Preuve de conformite — Set-OPS — 2026-07-22
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (19 OK · 0 echec · 0 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | |
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | 4 tests passes. |
| P03 | Diff-vide du plan (inventaire genere) | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | DIFF VIDE : le plan reproduit exactement l'inventaire actuel. Bascule possible. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 30 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 29 rôles, 63 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | playbook: playbooks/proxmox/cloner_vm_debian.yml |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
| 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. |
| 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 | ✅ OK | 14 hotes, 31 groupes (inventaire dechiffre et parse). |
| P17 | Tous les modeles d'instance valident | AFF-022, AFF-099 | ✅ OK | Les 7 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 23 secret(s) exige(s), tous presents. |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 27 champ(s) des plans reels sont editables (8 plan(s) inspecte(s)), registres toleres : nomenclature. |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-07-22._

View file

@ -187,9 +187,16 @@ Keycloak** (qui utilise ce mécanisme), pour valider le DRY en le déployant. Cf
## 7. Interface (GUI)
- vue « Liens » / éditeur de bindings par application ;
- **graphe des liens** — visualisation naturelle (le GUI a déjà un graphe d'inventaire), très
parlante pour la **démo de portabilité** (déplacer un nœud, voir les arêtes suivre).
- **éditeur de bindings par application** — ✅ **fait** (2026-07-22). Section « Liens
(bindings) » de l'inspecteur d'application : une ligne par lien, `rôle → cible`, les deux
en listes déroulantes. Les **rôles proposés sont exactement ceux que le rôle porteur
déclare accepter** (`roles/<groupe>/meta/liens.yml`, exposé par l'API sous
`liens_acceptes`) ; les cibles sont les autres applications du plan. Chaque ligne affiche
les **variables injectées** par le lien. Changer le groupe d'une application vide les
rôles de lien devenus inacceptés.
- **graphe des liens***reste à faire*. Visualisation naturelle (le GUI a déjà un graphe
d'inventaire), très parlante pour la **démo de portabilité** (déplacer un nœud, voir les
arêtes suivre).
## 8. Preuve de migration (les 3 liens mail)
@ -212,7 +219,8 @@ Premier cas concret, à faire en régression (les mêmes variables doivent être
validé en déployant Keycloak.
- **Phase 3** : exposition / domaines (vhosts nginx dérivés des liens ; machinerie `expose` /
`expositions_des_applications` déjà présente).
- **Phase 4** : vue GUI des liens + graphe.
- **Phase 4** : vue GUI des liens + graphe. 🟡 **éditeur fait** (2026-07-22, cf. §7) ;
**graphe des liens reste à faire**.
## 10. Décisions ouvertes

View file

@ -18,6 +18,7 @@ from inventory_rules import (
charger_applications,
charger_bases_donnees,
charger_domaines,
charger_serveurs,
valider_applications,
)
@ -26,6 +27,7 @@ INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance"))
FICHIER = INSTANCE / "plan/applications.yml"
FICHIER_BASES = INSTANCE / "plan/bases-donnees.yml"
FICHIER_DOMAINES = INSTANCE / "plan/domaines.yml"
FICHIER_SERVEURS = INSTANCE / "plan/serveurs.yml"
def _inventaire(instance: Path, *noms: str) -> Path:
forced = os.environ.get("SETOPS_INVENTAIRE")
if forced:
@ -132,14 +134,15 @@ def main() -> int:
try:
registre = charger_applications(FICHIER)
domaines = charger_domaines(FICHIER_DOMAINES)
serveurs = charger_serveurs(FICHIER_SERVEURS)
if args.commande == "lister":
lister(registre, charger_bases_donnees(FICHIER_BASES))
elif args.commande == "verifier":
valider_applications(registre, domaines)
valider_applications(registre, domaines, serveurs)
print("Registre des applications valide.")
elif args.commande == "bootstrap":
registre = bootstrap(registre)
valider_applications(registre, domaines)
valider_applications(registre, domaines, serveurs)
ecrire(registre)
print(f"instance/plan/applications.yml genere depuis l'inventaire ({len(registre['applications'])} applications).")
elif args.commande == "ajouter":
@ -151,12 +154,12 @@ def main() -> int:
if _liste(args.expose):
app["expose"] = _liste(args.expose)
registre["applications"][args.id] = app
valider_applications(registre, domaines)
valider_applications(registre, domaines, serveurs)
ecrire(registre)
print(f"Application '{args.id}' enregistree.")
elif args.commande == "retirer":
registre["applications"].pop(args.id, None)
valider_applications(registre, domaines)
valider_applications(registre, domaines, serveurs)
ecrire(registre)
print(f"Application '{args.id}' retiree.")
except Exception as exc:

152
scripts/couverture_gui.py Normal file
View file

@ -0,0 +1,152 @@
#!/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
RACINE = Path(__file__).resolve().parents[1]
INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance"))
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())

View file

@ -34,6 +34,7 @@ from inventory_rules import (
deriver_ressources,
expositions_des_applications,
fonction_seq,
liens_acceptes,
)
RACINE = Path(__file__).resolve().parents[1]
@ -91,14 +92,6 @@ def _domaine_interne() -> str:
return ""
def _liens_acceptes(groupe: str) -> dict:
"""meta/liens.yml d'un role -> setops_liens.accepte (liens que le role accepte)."""
fichier = ROLES / groupe / "meta" / "liens.yml"
if not fichier.is_file():
return {}
data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {}
return ((data.get("setops_liens") or {}).get("accepte")) or {}
def _attributs_cible(vers: str, apps: dict, serveurs: dict, nomenclature: dict, domaine: str) -> dict | None:
"""Resout une cible de lien en attributs substituables. Phase 1 : applications."""
@ -134,7 +127,7 @@ def resoudre_liens(apps: dict, serveurs: dict, nomenclature: dict) -> dict:
for lien in (app.get("liens") or []):
vers, role = lien.get("vers"), lien.get("role")
if accepte is None:
accepte = _liens_acceptes(str(app.get("groupe", "")))
accepte = liens_acceptes(str(app.get("groupe", "")), RACINE)
spec = accepte.get(role)
if not spec:
raise ValueError(

View file

@ -36,6 +36,7 @@ from inventory_rules import (
groupes_operationnels_connus,
reconcilier_serveur,
valider_applications,
catalogue_liens,
valider_bases,
valider_domaines,
valider_serveurs,
@ -355,6 +356,7 @@ def inventaire_api(path: Path) -> dict:
"hotes": liste_hotes(data),
"intrants": intrants_api(),
"integrations_disponibles": integrations_disponibles(),
"liens_acceptes": catalogue_liens(RACINE),
}
@ -401,6 +403,31 @@ def intrants_courants() -> dict:
return valeurs
# --- Couverture du schema du plan par le GUI -------------------------------------
#
# Ce que les fonctions de sauvegarde JS ecrivent REELLEMENT, registre par registre.
# Source unique verifiee par `scripts/couverture_gui.py` : tout champ present dans
# un plan reel (instance ou modele) mais absent d'ici signale un trou de l'interface
# — l'operateur devrait editer le YAML a la main, ce que la doctrine refuse.
#
# A TENIR A JOUR avec sauvegarderServeurs / sauvegarderApplications /
# sauvegarderBases / sauvegarderDomaines. Le verificateur controle aussi que chaque
# champ declare ici apparait bien dans le source du GUI (garde anti-derive).
CHAMPS_ECRITS_PAR_GUI = {
"serveurs": {"fonction", "etat", "noeud", "stockage", "disque", "memoire",
"coeurs", "integrations"},
"applications": {"groupe", "hote", "port", "requiert", "expose", "liens", "websocket"},
"bases_donnees": {"serveur", "base", "proprietaire", "secret", "consommateur",
"portee", "usage"},
"serveurs_bd": {"type", "hote", "port", "groupe"},
"domaines_publics": {"autorite", "edge", "secondaires", "dnssec", "mail"},
# nomenclature : AUCUN champ. Le GUI la LIT (liste des fonctions) sans jamais
# l'ecrire — ajouter une fonction exige d'editer le YAML. Trou connu et assume ;
# `couverture_gui.py` le signale tant qu'il dure.
"nomenclature": set(),
}
def integrations_disponibles() -> list:
"""Roles client_* presents dans roles/ : source des integrations selectionnables."""
base = RACINE / "roles"
@ -495,6 +522,8 @@ def ecrire_applications(path: Path, registre: dict) -> None:
"# Registre des applications Set-OPS (application = entite pivot).\n"
"# Edite par make inventaire-ui ou scripts/applications.py.\n"
"# Une VM peut porter plusieurs applications ; une base se lie a une application.\n"
"# 'liens' = bindings declaratifs [{vers, role}] resolus par scripts/instancier.py ;\n"
"# les roles acceptes sont ceux de roles/<groupe>/meta/liens.yml.\n"
"---\n"
)
with path.open("w", encoding="utf-8") as fichier:
@ -1029,6 +1058,7 @@ HTML = r"""<!doctype html>
let modaleResolveur = null;
let intrants = null;
let integrationsDispo = [];
let liensAcceptes = {};
let sondes = {}; // état RÉEL par hôte : joignable (SSH) ou non distinct du plan
let chronoConsole = null;
let consoleDebut = 0;
@ -1076,6 +1106,7 @@ HTML = r"""<!doctype html>
couchesRegistre = data.couches || [];
intrants = data.intrants || null;
integrationsDispo = data.integrations_disponibles || [];
liensAcceptes = data.liens_acceptes || {};
chargerBases(data);
chargerApplications(data);
chargerDomaines(data);
@ -1387,7 +1418,9 @@ HTML = r"""<!doctype html>
const a = (data.applications || {}).applications || {};
applications = Object.entries(a).map(([id, x]) => ({
id, groupe: x.groupe || '', hote: x.hote || '', port: x.port || '',
requiert: (x.requiert || []).join(', '), expose: (x.expose || []).join(', ')}));
requiert: (x.requiert || []).join(', '), expose: (x.expose || []).join(', '),
websocket: !!x.websocket,
liens: (x.liens || []).map(l => ({vers: l.vers || '', role: l.role || ''}))}));
applicationsModifie = false;
}
@ -1521,10 +1554,15 @@ HTML = r"""<!doctype html>
const a = applications[selApp]; if (!a) return;
a[champ] = v; marquerApplicationsModifie();
if (champ === 'id') { const t = document.querySelector('#tuile-app-' + selApp + ' .carte-nom'); if (t) { t.textContent = v || '(sans id)'; t.className = 'carte-nom' + (v ? '' : ' sans'); } }
else if (champ === 'hote' || champ === 'groupe' || champ === 'port') dessinerDetail();
else if (champ === 'groupe') {
const ok = Object.keys(liensAcceptes[a.groupe] || {});
(a.liens || []).forEach(l => { if (l.role && !ok.includes(l.role)) l.role = ''; });
dessinerDetail();
}
else if (champ === 'hote' || champ === 'port') dessinerDetail();
}
function ajouterApplication() {
applications.unshift({id: '', groupe: (groupes[0] || ''), hote: '', port: '', requiert: '', expose: ''});
applications.unshift({id: '', groupe: (groupes[0] || ''), hote: '', port: '', requiert: '', expose: '', websocket: false, liens: []});
selApp = 0; marquerApplicationsModifie(); dessiner();
const c = document.querySelector('#detail .grille input'); if (c) c.focus();
}
@ -1536,6 +1574,52 @@ HTML = r"""<!doctype html>
marquerApplicationsModifie(); dessiner();
}
function ajouterLien() {
const a = applications[selApp]; if (!a) return;
if (!a.liens) a.liens = [];
const roles = Object.keys(liensAcceptes[a.groupe] || {});
a.liens.push({vers: '', role: roles.length === 1 ? roles[0] : ''});
marquerApplicationsModifie(); dessinerDetail();
}
function retirerLien(i) {
const a = applications[selApp]; if (!a || !a.liens) return;
a.liens.splice(i, 1); marquerApplicationsModifie(); dessinerDetail();
}
function definirLien(i, champ, v) {
const a = applications[selApp]; if (!a || !a.liens || !a.liens[i]) return;
a.liens[i][champ] = v; marquerApplicationsModifie();
if (champ === 'role') dessinerDetail();
}
function blocLiens(a) {
const accepte = liensAcceptes[a.groupe] || {};
const roles = Object.keys(accepte);
if (!roles.length) {
return `<div class="ch-stub">Le rôle « ${echapper(a.groupe || '')} » n'accepte aucun lien
(aucun <span class="mono">roles/${echapper(a.groupe || '?')}/meta/liens.yml</span>).</div>`;
}
const cibles = applications.map(x => x.id).filter(x => x && x !== a.id);
const lignes = (a.liens || []).map((l, i) => {
const spec = accepte[l.role] || {};
const vars = Object.keys(spec.variables || {});
const optV = '<option value="">— cible —</option>' + cibles.map(c =>
`<option value="${echapper(c)}" ${c === l.vers ? 'selected' : ''}>${echapper(c)}</option>`).join('');
const optR = '<option value="">— rôle —</option>' + roles.map(r =>
`<option value="${echapper(r)}" ${r === l.role ? 'selected' : ''}>${echapper(r)}</option>`).join('');
const inconnue = l.vers && !cibles.includes(l.vers);
return `<div class="ch-base" style="gap:8px;align-items:center">
<select style="max-width:12rem" onchange="definirLien(${i}, 'role', this.value)">${optR}</select>
<span class="mono"></span>
<select style="max-width:12rem" onchange="definirLien(${i}, 'vers', this.value)">${optV}</select>
${inconnue ? `<span class="tag" style="color:#b45309">cible « ${echapper(l.vers)} » inconnue</span>` : ''}
${vars.length ? `<span class="tag" title="Variables injectées sur cette application">${echapper(vars.join(', '))}</span>` : ''}
<span class="grandit"></span>
<button type="button" class="danger" title="Retirer ce lien" onclick="retirerLien(${i})"></button>
</div>`;
}).join('');
return `${lignes || '<div class="ch-stub">aucun lien déclaré</div>'}
<div style="margin-top:6px"><button type="button" class="fantome" onclick="ajouterLien()">+ Lien</button></div>`;
}
function carteApp(a, i) {
const exp = listeDepuisTexte(a.expose);
return `<button type="button" class="carte ${i === selApp ? 'selectionnee' : ''}" id="tuile-app-${i}" onclick="choisirApp(${i})">
@ -1576,9 +1660,13 @@ HTML = r"""<!doctype html>
<label class="champ"><span>Groupe (rôle)</span><select onchange="definirApplication('groupe', this.value)">${optGroupe}</select></label>
<label class="champ"><span>Hôte (VM)</span><select onchange="definirApplication('hote', this.value)">${optHote}</select></label>
<label class="champ"><span>Port</span><input type="number" value="${echapper(a.port)}" placeholder="3000" oninput="definirApplication('port', this.value)"></label>
<label class="champ"><span>WebSocket</span><span style="display:flex;align-items:center;gap:8px;height:2.1rem"><input type="checkbox" ${a.websocket ? 'checked' : ''} onchange="definirApplication('websocket', this.checked)"><span class="hint-dep">vhost compatible WebSocket (Collabora)</span></span></label>
<label class="champ" style="grid-column:1/-1"><span>Requiert (applis)</span><input value="${echapper(a.requiert)}" placeholder="pg-principal, …" oninput="definirApplication('requiert', this.value)"></label>
<label class="champ" style="grid-column:1/-1"><span>Expose (FQDN)</span><input value="${echapper(a.expose)}" placeholder="forge.exemple.ca" oninput="definirApplication('expose', this.value)"></label>
</div>
<div class="section-tete" style="margin:12px 2px 4px">Liens (bindings)
<span class="compte">${(a.liens || []).length}</span></div>
${blocLiens(a)}
<div class="section-tete" style="margin:12px 2px 4px">Bases consommées</div>
${liens}
${(() => {
@ -1602,6 +1690,11 @@ HTML = r"""<!doctype html>
if (String(a.port).trim()) o.port = parseInt(a.port, 10) || a.port;
const req = listeDepuisTexte(a.requiert); if (req.length) o.requiert = req;
const exp = listeDepuisTexte(a.expose); if (exp.length) o.expose = exp;
if (a.websocket) o.websocket = true;
const lns = (a.liens || [])
.filter(l => String(l.vers).trim() && String(l.role).trim())
.map(l => ({vers: l.vers, role: l.role}));
if (lns.length) o.liens = lns;
apps[a.id] = o;
});
const rep = await fetch('/api/applications', {
@ -2405,7 +2498,8 @@ class Gestionnaire(BaseHTTPRequestHandler):
ecrire_bases(FICHIER_BASES, donnees)
self.repondre_json(200, inventaire_api(self.inventaire))
elif chemin == "/api/applications":
valider_applications(donnees, charger_domaines(FICHIER_DOMAINES))
valider_applications(donnees, charger_domaines(FICHIER_DOMAINES),
charger_serveurs(FICHIER_SERVEURS))
ecrire_applications(FICHIER_APPLICATIONS, donnees)
self.repondre_json(200, inventaire_api(self.inventaire))
elif chemin == "/api/serveurs":

View file

@ -14,6 +14,38 @@ GROUPE_HOTES_ACTIFS = "hotes_actifs"
GROUPE_HOTES_PLANIFIES = "hotes_planifies"
GROUPES_ETAT_HOTE = {GROUPE_HOTES_ACTIFS, GROUPE_HOTES_PLANIFIES}
RACINE_DEPOT = Path(__file__).resolve().parents[1]
def liens_acceptes(groupe: str, racine: Path | None = None) -> dict:
"""Liens qu'un role accepte : roles/<groupe>/meta/liens.yml -> setops_liens.accepte.
Source unique partagee par le validateur, le GUI et scripts/instancier.py.
Retourne {} si le role n'en declare aucun.
"""
fichier = (racine or RACINE_DEPOT) / "roles" / groupe / "meta" / "liens.yml"
if not fichier.is_file():
return {}
data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {}
return ((data.get("setops_liens") or {}).get("accepte")) or {}
def catalogue_liens(racine: Path | None = None) -> dict:
"""{groupe: {role_de_lien: {cible, injecte_sur, variables}}} pour tous les roles.
Alimente la liste deroulante « rôle du lien » du GUI : on ne propose que ce
que le role porteur declare accepter.
"""
base = (racine or RACINE_DEPOT) / "roles"
if not base.is_dir():
return {}
catalogue = {}
for meta in sorted(base.glob("*/meta/liens.yml")):
accepte = liens_acceptes(meta.parents[1].name, racine)
if accepte:
catalogue[meta.parents[1].name] = accepte
return catalogue
def est_groupe_operationnel(groupe: str) -> bool:
return groupe.startswith(GROUPES_OPERATIONNELS_PREFIXES)
@ -128,11 +160,17 @@ def charger_applications(path: Path | None) -> dict:
return data
def valider_applications(registre: dict, domaines: dict | None = None) -> None:
def valider_applications(registre: dict, domaines: dict | None = None,
serveurs: dict | None = None) -> None:
"""Valide la coherence du registre des applications.
Si `domaines` (le registre des domaines publics) est fourni, chaque FQDN
de `expose` doit avoir un domaine parent declare.
Si `serveurs` (le registre des serveurs) est fourni, l'hote porteur de chaque
application doit y etre declare c'est le garde-fou contre l'hote FANTOME
(une application posee sur une VM qui n'existe pas). Sans lui, le plan passe
la validation et casse au deploiement.
"""
apps = registre.get("applications") or {}
doms = (domaines or {}).get("domaines_publics") or {}
@ -144,6 +182,12 @@ def valider_applications(registre: dict, domaines: dict | None = None) -> None:
for champ in ("groupe", "hote"):
if not str(app.get(champ, "")).strip():
raise ValueError(f"Application '{nom}': champ '{champ}' requis.")
hotes_connus = (serveurs or {}).get("serveurs")
if hotes_connus and app["hote"] not in hotes_connus:
raise ValueError(
f"Application '{nom}': hote '{app['hote']}' absent du registre des "
f"serveurs (hote fantome). Declare-le dans plan/serveurs.yml, ou "
f"deplace l'application sur un hote existant.")
if not est_groupe_operationnel(app["groupe"]):
raise ValueError(f"Application '{nom}': groupe '{app['groupe']}' non operationnel.")
requiert = app.get("requiert") or []
@ -161,6 +205,30 @@ def valider_applications(registre: dict, domaines: dict | None = None) -> None:
port = app.get("port")
if port is not None and not str(port).strip().isdigit():
raise ValueError(f"Application '{nom}': 'port' doit etre un entier.")
liens = app.get("liens") or []
if not isinstance(liens, list):
raise ValueError(f"Application '{nom}': 'liens' doit etre une liste.")
accepte = None
for lien in liens:
if not isinstance(lien, dict):
raise ValueError(f"Application '{nom}': chaque lien doit etre une table {{vers, role}}.")
vers = str(lien.get("vers", "")).strip()
role = str(lien.get("role", "")).strip()
if not vers or not role:
raise ValueError(f"Application '{nom}': lien incomplet (champs 'vers' et 'role' requis).")
if vers == nom:
raise ValueError(f"Application '{nom}': lien vers elle-meme.")
if vers not in apps:
raise ValueError(f"Application '{nom}': cible de lien '{vers}' inconnue.")
if accepte is None:
accepte = liens_acceptes(str(app.get("groupe", "")))
# Un role sans meta/liens.yml n'accepte aucun lien : on ne bloque que
# s'il en declare (sinon instancier.py tranchera avec le meme message).
if accepte and role not in accepte:
raise ValueError(
f"Application '{nom}': role de lien '{role}' non accepte par "
f"'{app.get('groupe')}' (roles/{app.get('groupe')}/meta/liens.yml). "
f"Acceptes : {', '.join(sorted(accepte))}.")
def applications_de_hote(registre: dict, hote: str) -> list[dict]:

122
scripts/modeles.py Normal file
View file

@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""Valide TOUS les modeles d'instance, pas seulement le socle public.
Un modele est un dossier portant un `plan/` (serveurs, applications, bases,
domaines, nomenclature). Ce script rejoue sur chacun les MEMES validateurs que
sur une instance reelle il ne reimplemente aucune regle.
Pourquoi : les modeles assembles vivent dans un depot separe (actif commercial).
Rien ne les verifiait, et `integral` a porte pendant des semaines une application
posee sur un hote jamais declare. Un modele defectueux se propage a chaque
locataire cree depuis lui.
Decouverte des modeles :
- `exemples/modeles/*` du depot (le socle public) ;
- plus les chemins de la variable d'environnement SETOPS_MODELES
(separes par ':'). Un chemin peut designer un modele OU un dossier de
modeles. Absent = ignore en silence (le depot prive n'est pas toujours la).
Usage :
python3 scripts/modeles.py lister
python3 scripts/modeles.py verifier
SETOPS_MODELES=../Set-OPS-Modeles python3 scripts/modeles.py verifier
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
from pathlib import Path
RACINE = Path(__file__).resolve().parents[1]
MODELES_DEPOT = RACINE / "exemples" / "modeles"
VALIDATEURS = ("domaines", "serveurs", "applications", "bases_donnees")
def _est_modele(p: Path) -> bool:
return p.is_dir() and (p / "plan").is_dir()
def decouvrir() -> list[Path]:
"""Modeles du depot + ceux designes par SETOPS_MODELES. Tries, dedoublonnes."""
trouves: list[Path] = []
if MODELES_DEPOT.is_dir():
trouves += [p for p in sorted(MODELES_DEPOT.iterdir()) if _est_modele(p)]
for brut in (os.environ.get("SETOPS_MODELES") or "").split(":"):
brut = brut.strip()
if not brut:
continue
chemin = Path(brut)
if not chemin.is_absolute():
chemin = (RACINE / chemin).resolve()
if not chemin.is_dir():
continue
if _est_modele(chemin):
trouves.append(chemin)
else:
trouves += [p for p in sorted(chemin.iterdir()) if _est_modele(p)]
vus: dict[str, Path] = {}
for p in trouves:
vus.setdefault(str(p.resolve()), p)
return list(vus.values())
def verifier_un(modele: Path) -> list[str]:
"""Rejoue les 4 validateurs sur un modele. Renvoie la liste des erreurs."""
erreurs: list[str] = []
env = {**os.environ, "SETOPS_INSTANCE": str(modele)}
env.pop("SETOPS_INVENTAIRE", None)
for nom in VALIDATEURS:
r = subprocess.run([sys.executable, f"scripts/{nom}.py", "verifier"],
cwd=RACINE, env=env, capture_output=True, text=True)
if r.returncode != 0:
lignes = (r.stdout + r.stderr).strip().splitlines()
erreurs.append(f"{nom}: {lignes[-1] if lignes else 'rc!=0'}")
return erreurs
def main() -> int:
parser = argparse.ArgumentParser(description="Validation des modeles d'instance Set-OPS.")
sub = parser.add_subparsers(dest="commande", required=True)
sub.add_parser("lister", help="Liste les modeles decouverts.")
sub.add_parser("verifier", help="Valide les registres de chaque modele.")
args = parser.parse_args()
modeles = decouvrir()
if args.commande == "lister":
if not modeles:
print("Aucun modele decouvert.")
return 0
for m in modeles:
try:
rel = m.relative_to(RACINE)
except ValueError:
rel = m
print(f"{m.name} ({rel})")
return 0
if not modeles:
print("Aucun modele decouvert : rien a verifier.")
return 0
en_faute = 0
for m in modeles:
erreurs = verifier_un(m)
if erreurs:
en_faute += 1
print(f"MODELE {m.name} : INVALIDE", file=sys.stderr)
for e in erreurs:
print(f" - {e}", file=sys.stderr)
else:
print(f"MODELE {m.name} : valide")
if en_faute:
print(f"erreur: {en_faute} modele(s) invalide(s) sur {len(modeles)}.", file=sys.stderr)
return 2
print(f"Les {len(modeles)} modele(s) decouvert(s) valident.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -240,6 +240,15 @@ PREUVES: list[dict] = [
"func": preuve_pas_lab_code_en_dur},
{"id": "P15", "titre": "Modele public socle valide", "refs": ["AFF-022", "AFF-099"],
"func": preuve_modele_socle},
{"id": "P17", "titre": "Tous les modeles d'instance valident",
"refs": ["AFF-022", "AFF-099"],
"cmds": [[sys.executable, "scripts/modeles.py", "verifier"]]},
{"id": "P18", "titre": "Gabarit de voute complet", "refs": ["AFF-026"],
"cmds": [[sys.executable, "scripts/voute.py", "verifier"]]},
{"id": "P19", "titre": "Le GUI couvre le schema du plan",
"refs": ["AFF-002", "AFF-095"],
"cmds": [[sys.executable, "scripts/couverture_gui.py", "verifier",
"--tolerer", "nomenclature"]]},
]
@ -298,7 +307,7 @@ def main(argv: list[str] | None = None) -> int:
statut, detail = _executer(preuve)
resultats.append((preuve, statut, detail))
# Tri : garder l'ordre d'execution (P01..P15) ; la preuve sautee a ete inseree tot.
# Tri : garder l'ordre d'execution (P01..P19) ; la preuve sautee a ete inseree tot.
resultats.sort(key=lambda r: r[0]["id"])
n_ok = sum(1 for _, s, _ in resultats if s == "OK")

155
scripts/voute.py Normal file
View file

@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Confronte le GABARIT de voute d'une instance aux secrets que son plan exige.
Le gabarit `group_vars/all/vault.yml.example` est la seule liste que l'operateur
voit avant de creer sa voute. S'il est incomplet, le deploiement casse au milieu,
sur une variable indefinie ; s'il est bavard, l'operateur genere des secrets morts.
Les secrets EXIGES sont recenses a trois endroits, sans rien deviner :
1. `plan/bases-donnees.yml` -> le champ `secret` de chaque base ;
2. les roles des groupes actifs du plan -> toute reference `vault_*` ;
3. les `group_vars` de l'instance -> toute reference `vault_*`.
La voute REELLE (vault.yml) n'est jamais lue ni dechiffree : on ne compare que
des NOMS, et seulement ceux du gabarit en clair.
Usage :
python3 scripts/voute.py lister
python3 scripts/voute.py verifier # rc=2 si le gabarit est incomplet
python3 scripts/voute.py verifier --strict # rc=2 aussi si des cles sont inutiles
"""
from __future__ import annotations
import argparse
import os
import re
import sys
from pathlib import Path
import yaml
from inventory_rules import charger_applications, charger_bases_donnees, charger_serveurs
RACINE = Path(__file__).resolve().parents[1]
INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance"))
ROLES = RACINE / "roles"
MOTIF_VAULT = re.compile(r"\b(vault_[a-z0-9_]+)")
# Secrets d'acces a l'hyperviseur : attendus au gabarit, mais hors motif vault_*.
SECRETS_HORS_MOTIF = {"proxmox_api_token_id", "proxmox_api_token_secret"}
def _inventaire_dir() -> Path | None:
for nom in ("principal", "production", "lab"):
d = INSTANCE / "inventories" / nom
if d.is_dir():
return d
return None
def gabarit() -> Path | None:
d = _inventaire_dir()
if not d:
return None
p = d / "group_vars" / "all" / "vault.yml.example"
return p if p.is_file() else None
def _refs_vault(chemin: Path) -> set[str]:
try:
return set(MOTIF_VAULT.findall(chemin.read_text(encoding="utf-8", errors="ignore")))
except OSError:
return set()
def groupes_du_plan() -> set[str]:
apps = charger_applications(INSTANCE / "plan/applications.yml")
srv = charger_serveurs(INSTANCE / "plan/serveurs.yml")
groupes = {a.get("groupe") for a in (apps.get("applications") or {}).values() if a.get("groupe")}
for s in (srv.get("serveurs") or {}).values():
groupes.update(s.get("integrations") or [])
return groupes
def secrets_exiges() -> dict[str, set[str]]:
"""{secret: {origines}} — d'ou vient chaque exigence, pour un message utile."""
exiges: dict[str, set[str]] = {}
def noter(nom: str, origine: str) -> None:
exiges.setdefault(nom, set()).add(origine)
bases = charger_bases_donnees(INSTANCE / "plan/bases-donnees.yml")
for cle, base in (bases.get("bases_donnees") or {}).items():
if base.get("secret"):
noter(str(base["secret"]), f"base '{cle}'")
for groupe in sorted(groupes_du_plan()):
dossier = ROLES / groupe
if not dossier.is_dir():
continue
for f in list(dossier.rglob("*.yml")) + list(dossier.rglob("*.j2")):
for nom in _refs_vault(f):
noter(nom, f"role {groupe}")
d = _inventaire_dir()
if d and (d / "group_vars").is_dir():
for f in (d / "group_vars").rglob("*.yml"):
if f.name.endswith(".example"):
continue
for nom in _refs_vault(f):
noter(nom, f"group_vars/{f.name}")
return exiges
def cles_gabarit(path: Path) -> set[str]:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
return set(data) if isinstance(data, dict) else set()
def main() -> int:
parser = argparse.ArgumentParser(description="Couverture du gabarit de voute Set-OPS.")
sub = parser.add_subparsers(dest="commande", required=True)
sub.add_parser("lister", help="Liste les secrets exiges et leur origine.")
pv = sub.add_parser("verifier", help="Le gabarit couvre-t-il les secrets exiges ?")
pv.add_argument("--strict", action="store_true",
help="Echouer aussi si le gabarit porte des cles inutiles.")
args = parser.parse_args()
g = gabarit()
if not g:
print("Aucun gabarit vault.yml.example : rien a verifier.")
return 0
exiges = secrets_exiges()
presentes = cles_gabarit(g)
if args.commande == "lister":
for nom in sorted(exiges):
etat = "au gabarit" if nom in presentes else "ABSENT du gabarit"
print(f"{nom:<34} {etat:<18} <- {', '.join(sorted(exiges[nom]))}")
return 0
manquants = sorted(set(exiges) - presentes)
inutiles = sorted(presentes - set(exiges) - SECRETS_HORS_MOTIF)
if manquants:
print(f"erreur: {len(manquants)} secret(s) exige(s) par le plan mais ABSENT(s) "
f"du gabarit {g.relative_to(RACINE) if g.is_relative_to(RACINE) else g} :",
file=sys.stderr)
for nom in manquants:
print(f" - {nom} <- {', '.join(sorted(exiges[nom]))}", file=sys.stderr)
if inutiles:
flux = sys.stderr if args.strict else sys.stdout
print(f"{'erreur' if args.strict else 'note'}: {len(inutiles)} cle(s) du gabarit "
f"que le plan n'exige plus : {', '.join(inutiles)}", file=flux)
if manquants or (inutiles and args.strict):
return 2
print(f"Gabarit de voute complet : {len(exiges)} secret(s) exige(s), tous presents.")
return 0
if __name__ == "__main__":
raise SystemExit(main())