Set-OPS-Public/scripts/voute.py
Daniel Allaire 7e190e0a57 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>
2026-07-22 21:32:42 -04:00

155 lines
5.5 KiB
Python

#!/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())