#!/usr/bin/env python3 """Orchestrateur Set-OPS : couches + graphe -> playbooks/site.yml ordonne. Genere le point d'entree general (site.yml) comme une SEQUENCE ORDONNEE d'`import_playbook` des groupes, dans l'ordre de reconstruction : couche (docs/couches-deploiement.yml, cle primaire) puis tri topologique INTRA-couche (docs/dependances-groupes.yml). C'est un artefact du MOTEUR (deterministe : univers des playbooks de groupe x couches x graphe) — aucune donnee d'instance. Ansible saute tout seul les groupes sans hote actif, donc le meme site.yml sert toute instance. Deux gardes de coherence (le generateur REFUSE sinon) : 1. bijection : chaque groupe deployable (playbooks/groupes/.yml operationnel) figure dans exactement une couche, et chaque groupe d'une couche a un playbook ; 2. pas d'arete « en arriere » : un prerequis (graphe) doit vivre dans une couche <= celle du groupe qui en depend (sinon la classification est fausse). Usage : python3 scripts/orchestrer.py verifier # valide, n'ecrit rien (sortie != 0 si erreur) python3 scripts/orchestrer.py ordre # imprime l'ordre (couche : groupes) python3 scripts/orchestrer.py ecrire # (re)genere playbooks/site.yml """ from __future__ import annotations import sys from pathlib import Path import yaml from inventory_rules import charger_dependances, est_groupe_operationnel RACINE = Path(__file__).resolve().parents[1] DOSSIER_GROUPES = RACINE / "playbooks" / "groupes" FICHIER_COUCHES = RACINE / "docs" / "couches-deploiement.yml" FICHIER_DEPENDANCES = RACINE / "docs" / "dependances-groupes.yml" SITE = RACINE / "playbooks" / "site.yml" class ErreurOrchestration(Exception): """Incoherence entre l'univers des groupes, les couches et le graphe.""" def univers_groupes() -> set[str]: """Les groupes deployables : un playbooks/groupes/.yml operationnel.""" return { p.stem for p in DOSSIER_GROUPES.glob("*.yml") if est_groupe_operationnel(p.stem) } def charger_couches(path: Path = FICHIER_COUCHES) -> list[dict]: if not path.exists(): raise ErreurOrchestration(f"Registre des couches introuvable : {path}") data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} couches = data.get("couches") if not isinstance(couches, list) or not couches: raise ErreurOrchestration(f"{path} doit contenir une liste 'couches' non vide.") for couche in couches: if not isinstance(couche, dict) or "nom" not in couche: raise ErreurOrchestration(f"Couche invalide (nom requis) dans {path}.") if not isinstance(couche.get("groupes"), list): raise ErreurOrchestration(f"Couche {couche.get('nom')!r} : 'groupes' doit etre une liste.") return couches def indice_couche(couches: list[dict]) -> dict[str, int]: """groupe -> indice de sa couche (0 = premiere deployee).""" indice: dict[str, int] = {} for i, couche in enumerate(couches): for groupe in couche["groupes"]: if groupe in indice: raise ErreurOrchestration( f"Groupe {groupe!r} classe dans deux couches " f"({couches[indice[groupe]]['nom']} et {couche['nom']})." ) indice[groupe] = i return indice def valider(couches: list[dict], dependances: dict) -> dict[str, int]: """Bijection univers<->couches + aucune arete en arriere. Renvoie groupe->couche.""" univers = univers_groupes() indice = indice_couche(couches) classes = set(indice) non_classes = sorted(univers - classes) if non_classes: raise ErreurOrchestration( "Groupes deployables non classes dans une couche : " + ", ".join(non_classes) + f"\n -> ajouter chacun a une couche dans {FICHIER_COUCHES.name}." ) fantomes = sorted(classes - univers) if fantomes: raise ErreurOrchestration( "Couches referencant des groupes sans playbook operationnel : " + ", ".join(fantomes) + f"\n -> playbooks/groupes/.yml attendu, ou retirer du registre." ) # Arete « en arriere » : n requiert m, mais m est deploye APRES n. arrieres: list[str] = [] for groupe, config in dependances.items(): if groupe not in indice: continue for requis in config.get("requiert_groupes_actifs", []): if requis in indice and indice[requis] > indice[groupe]: arrieres.append( f"{groupe} (couche {couches[indice[groupe]]['nom']}) " f"requiert {requis} (couche {couches[indice[requis]]['nom']}, plus tardive)" ) if arrieres: raise ErreurOrchestration( "Dependances incoherentes avec les couches (prerequis deploye trop tard) :\n - " + "\n - ".join(arrieres) ) return indice def _tri_topo_intra(groupes: list[str], dependances: dict) -> list[str]: """Ordonne des groupes d'UNE MEME couche : un prerequis (present ici) d'abord. Depart stable = l'ordre du registre (couches-deploiement.yml). """ ensemble = set(groupes) rang = {g: i for i, g in enumerate(groupes)} # ordre du registre # aretes intra-couche : m -> n (m requis par n) ; on veut m avant n. restant = { n: {m for m in dependances.get(n, {}).get("requiert_groupes_actifs", []) if m in ensemble} for n in groupes } ordre: list[str] = [] while restant: prets = sorted((g for g, deps in restant.items() if not deps), key=lambda g: rang[g]) if not prets: raise ErreurOrchestration( "Cycle de dependances intra-couche : " + ", ".join(sorted(restant)) ) for g in prets: ordre.append(g) del restant[g] for deps in restant.values(): deps.discard(g) return ordre def ordre_deploiement(couches: list[dict] | None = None, dependances: dict | None = None) -> list[tuple[str, list[str]]]: """Renvoie [(nom_couche, [groupes ordonnes]), ...] dans l'ordre de deploiement.""" couches = couches or charger_couches() dependances = dependances if dependances is not None else charger_dependances(FICHIER_DEPENDANCES) valider(couches, dependances) resultat: list[tuple[str, list[str]]] = [] for couche in couches: ordonnes = _tri_topo_intra(list(couche["groupes"]), dependances) resultat.append((couche["nom"], ordonnes)) return resultat def rendre_site(ordre: list[tuple[str, list[str]]]) -> str: lignes = [ "---", "# GENERE par scripts/orchestrer.py (make site) — NE PAS EDITER A LA MAIN.", "# Point d'entree ordonne : deploie l'ecosysteme couche par couche, dans", "# l'ordre de reconstruction. Regenerer apres tout changement de couches", "# (docs/couches-deploiement.yml) ou du graphe (docs/dependances-groupes.yml).", "# Ansible saute les groupes sans hote actif : le meme fichier sert toute instance.", "", ] for nom, groupes in ordre: lignes.append(f"# --- couche {nom} ---") for groupe in groupes: lignes.append(f"- name: Couche {nom} — groupe {groupe}") lignes.append(f" import_playbook: groupes/{groupe}.yml") lignes.append("") return "\n".join(lignes).rstrip() + "\n" def ecrire(path: Path = SITE) -> None: contenu = rendre_site(ordre_deploiement()) path.write_text(contenu, encoding="utf-8") print(f"{path.relative_to(RACINE)} (re)genere. git est le filet : git diff / git checkout.") def imprimer_ordre() -> None: for i, (nom, groupes) in enumerate(ordre_deploiement()): print(f"{i}. couche {nom}") for groupe in groupes: print(f" {groupe}") def main(argv: list[str]) -> int: commande = argv[0] if argv else "verifier" try: if commande == "verifier": ordre_deploiement() # leve si incoherent total = sum(len(g) for _, g in ordre_deploiement()) print(f"Orchestration coherente : {total} groupes classes, aucun cycle, aucune arete en arriere.") elif commande == "ordre": imprimer_ordre() elif commande == "ecrire": ecrire() else: print(f"Commande inconnue : {commande} (verifier | ordre | ecrire)", file=sys.stderr) return 2 except ErreurOrchestration as erreur: print(f"ERREUR orchestration :\n{erreur}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))