From 5be3fbd5e1820e0ae0a543e5736279336df30164 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sat, 22 Aug 2026 14:54:00 -0400 Subject: [PATCH] dependances : un ecosysteme minimal n'est pas un ecosysteme incomplet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le controle de dependances a REFUSE le deploiement de patient 0 : client_journal requiert serveur_loki, client_metrique requiert serveur_prometheus, serveur_forgejo requiert serveur_postgresql et serveur_postfix. Quatre refus, une seule racine — le moteur suppose que tout ecosysteme porte tous les services. Apres les intrants (P32) et les bases (P35), troisieme manifestation en cinq jours. Et le mur que rencontrerait toute offre plus petite que l'ecosysteme de reference. UNE INTEGRATION UNIVERSELLE A BESOIN D'UN INTERLOCUTEUR. « Tout hote est mesure » est vrai dans un ecosysteme qui porte un Prometheus ; ailleurs, la meme phrase pose sur chaque machine un client qui n'a personne a qui parler. La regle est desormais DERIVEE : le service central d'une integration est celui que le registre des dependances lui donne deja. Rien de neuf a tenir a jour, donc rien de neuf a oublier. Chezlepro -> diff VIDE (tous ses services existent, rien ne change) patient 0 -> client_backup, client_pki, client_unbound UNE EXIGENCE N'EST PAS TOUJOURS ABSOLUE. Deux notions manquaient au registre : `sauf_si` l'exigence tombe sous condition (Forgejo + SQLite) `utilise_si_present` un agrement, jamais bloquant (Forgejo notifie SI un MTA existe) Les confondre obligeait une forge a deployer une pile courriel entiere pour exister. ET LA LECON D'HIER A SERVI : trois lecteurs avaient besoin le meme jour de lire une variable d'instance (P35, les clauses sauf_si, le generateur). Trois copies auraient recommence ce qu'on venait de refermer. Il y en a UNE, dans inventory_rules. make verifier 41/41 ; make ci 41/41 ; inventaire de Chezlepro identique. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 59 +++++++++++++++++++++++++++++++++ docs/dependances-groupes.yml | 11 ++++++- scripts/instancier.py | 5 ++- scripts/inventory_host.py | 56 ++++++++++++++++++++++++------- scripts/inventory_rules.py | 64 +++++++++++++++++++++++++++++++++++- 5 files changed, 181 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adbd1ce..585d220 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,64 @@ # CHANGELOG — Set-OPS +## 2026-08-22 — Le moteur supposait l'écosystème complet + +Troisième manifestation de la même hypothèse en cinq jours, et cette fois elle **bloquait +un déploiement**. Le contrôle de dépendances a refusé patient 0 : + +``` +client_journal requiert serveur_loki actif +client_metrique requiert serveur_prometheus actif +serveur_forgejo requiert serveur_postgresql actif +serveur_forgejo requiert serveur_postfix actif +``` + +Quatre refus, une seule racine : **le moteur suppose que tout écosystème porte tous les +services**. Après les intrants (P32, le 20) et les bases (P35, ce matin), c'est au tour des +dépendances causales et des intégrations universelles. + +Ce n'est pas un problème de patient 0. C'est le mur que rencontrerait **toute offre plus +petite que l'écosystème de référence** — c'est-à-dire toute offre réelle. + +### Une intégration universelle a besoin d'un interlocuteur + +*« Tout hôte est mesuré »* est vrai dans un écosystème qui porte un Prometheus. Dans un +écosystème qui n'en a pas, la même phrase pose sur chaque machine un client qui **n'a +personne à qui parler**. + +La règle est désormais **dérivée, pas déclarée** : le service central d'une intégration est +celui que le registre des dépendances lui donne déjà. Rien de neuf à tenir à jour, donc +rien de neuf à oublier. + +``` +Chezlepro → diff VIDE : tous ses services centraux existent, rien ne change +patient 0 → client_backup, client_pki, client_unbound (journal et métrique tombent) +``` + +### Une exigence n'est pas toujours absolue + +Deux notions manquaient au registre, et les confondre coûtait cher : + +| | | +|---|---| +| `sauf_si` | l'exigence tombe sous condition — Forgejo n'exige PostgreSQL que s'il n'a pas choisi SQLite | +| `utilise_si_present` | un **agrément**, jamais bloquant — Forgejo notifie si un MTA existe, et s'en passe sinon | + +Confondre les deux obligeait une forge à déployer **une pile courriel entière pour +exister**. + +### Et la leçon d'hier a servi + +Trois lecteurs avaient besoin, le même jour, de lire une variable d'instance : P35 pour +l'interrupteur `_bd`, le contrôle de dépendances pour `sauf_si`, et le générateur. Trois +copies auraient recommencé exactement ce qu'on venait de refermer. Il y en a **une**, dans +`inventory_rules`. + +`make verifier` : 41 OK, 0 échec, 0 sauté. Chezlepro : inventaire **identique**. + +> **Un écosystème minimal n'est pas un écosystème incomplet.** Le moteur confondait les +> deux — et refusait de déployer ce qui n'a besoin de rien de plus. + + ## 2026-08-22 — Neuf copies d'une même question, et la pièce qui manquait Cinq jours, cinq défauts, tous de la même famille : *quelle instance, quel inventaire ?* diff --git a/docs/dependances-groupes.yml b/docs/dependances-groupes.yml index 261cfaa..b8762cc 100644 --- a/docs/dependances-groupes.yml +++ b/docs/dependances-groupes.yml @@ -68,8 +68,17 @@ groupes: requiert_groupes_actifs: - serveur_postgresql - serveur_nginx + # UNE EXIGENCE PEUT ETRE CONDITIONNELLE (2026-08-22). Depuis que le role sait tenir sa + # base dans un fichier, exiger un serveur PostgreSQL est faux pour qui a choisi SQLite + # — et bloquait le deploiement d'un ecosysteme parfaitement coherent. + sauf_si: + serveur_postgresql: { variable: serveur_forgejo_bd, vaut: sqlite } + # UTILISE SI PRESENT : Forgejo envoie des notifications quand un MTA existe, et s'en + # passe sinon. Ce n'etait pas une EXIGENCE — le confondre avec une exigence obligeait + # une forge a deployer une pile courriel pour exister. + utilise_si_present: - serveur_postfix - raison: "Forgejo depend d'une base, d'une publication HTTP(S) et d'un relais courriel (MTA Postfix)." + raison: "Forgejo depend d'une base (serveur ou fichier) et d'une publication HTTP(S) ; le courriel est un agrement." surveillance: "Verifier HTTP(S), base, files Git et envoi courriel." serveur_nextcloud: diff --git a/scripts/instancier.py b/scripts/instancier.py index ed6b5d5..d963f0a 100644 --- a/scripts/instancier.py +++ b/scripts/instancier.py @@ -155,6 +155,9 @@ def generer() -> dict: # Politique d'integration : lue une fois, appliquee a tous (voir D-33). universelles = integrations_universelles(RACINE) + # TOUS les groupes de service du plan : une integration universelle dont le + # service central n'existe pas ici n'a personne a qui parler (2026-08-22). + services_du_plan = {g for gs in services_par_hote.values() for g in gs} # Rattachement reseau d'une VM. En SDN, elle appartient a son VNet — pas a un pont # de l'hyperviseur avec une etiquette. Le nom du VNet se DERIVE (index + zone), @@ -211,7 +214,7 @@ def generer() -> dict: for var, val in injections.get(nom, {}).items(): hostvars[var] = val services = services_par_hote.get(nom, set()) - groupes = set(GROUPES_SOCLE) | services | integrations_de(srv, services, universelles) + groupes = set(GROUPES_SOCLE) | services | integrations_de(srv, services, universelles, services_du_plan=services_du_plan) # SANs d'exposition : les FQDN que cet edge sert (derives du plan) -> cert edge # (client_pki_sans), au lieu d'une liste manuelle. Vide si l'hote n'est pas un edge. sans = sorted({e["fqdn"] for e in expositions if e.get("edge") in groupes}) diff --git a/scripts/inventory_host.py b/scripts/inventory_host.py index f7ea667..a7c1f8b 100644 --- a/scripts/inventory_host.py +++ b/scripts/inventory_host.py @@ -10,6 +10,7 @@ import sys import yaml from inventory_rules import ( + variable_effective, GROUPE_HOTES_ACTIFS, GROUPE_HOTES_PLANIFIES, GROUPES_ETAT_HOTE, @@ -336,13 +337,38 @@ def groupe_a_hote_actif(data: dict, group: str) -> bool: return bool(active_hosts & group_hosts) -def verifier_dependances_groupes(data: dict, dependencies: dict, groups: list[str]) -> None: +def _exigence_levee(config: dict, required_group: str, group_vars: Path | None) -> bool: + """La clause `sauf_si` retire-t-elle cette exigence pour CETTE instance ? + + Une exigence n'est pas toujours absolue (2026-08-22) : `serveur_forgejo` exige un + serveur PostgreSQL — sauf quand l'instance a choisi `serveur_forgejo_bd: sqlite`, et sa + base tient alors dans un fichier. Sans cette lecture, un ecosysteme parfaitement + coherent se voyait refuser le deploiement au nom d'un service dont il n'a pas besoin. + + La condition est lue dans les group_vars de l'instance, a defaut dans le defaut du + role : meme resolution que partout ailleurs (`inventory_rules.variable_effective`). + """ + clause = (config.get("sauf_si") or {}).get(required_group) + if not clause: + return False + valeur = variable_effective(str(clause.get("variable")), group_vars, + role_defaut=str(clause.get("role") or "")) + return valeur is not None and valeur == str(clause.get("vaut")) + + +def verifier_dependances_groupes(data: dict, dependencies: dict, groups: list[str], + group_vars: Path | None = None) -> None: missing: list[str] = [] for group in sorted(set(groups)): - for required_group in dependencies.get(group, {}).get("requiert_groupes_actifs", []): + config = dependencies.get(group, {}) + for required_group in config.get("requiert_groupes_actifs", []): + if _exigence_levee(config, required_group, group_vars): + continue if not groupe_a_hote_actif(data, required_group): missing.append(f"{group} requiert {required_group} actif") + # `utilise_si_present` ne bloque JAMAIS : c'est un agrement, pas une exigence. + # Les confondre obligeait une forge a deployer une pile courriel pour exister. if missing: raise ValueError("Dependances manquantes: " + "; ".join(missing)) @@ -355,12 +381,14 @@ def exiger_dependances(dependencies: dict, path: Path | None) -> None: raise ValueError(f"Aucune dependance chargee depuis {path}.") -def verifier_dependances_hote(data: dict, dependencies: dict, host: str) -> None: +def verifier_dependances_hote(data: dict, dependencies: dict, host: str, + group_vars: Path | None = None) -> None: groups = [group for group in groupes_hote(data, host) if est_groupe_operationnel(group)] - verifier_dependances_groupes(data, dependencies, groups) + verifier_dependances_groupes(data, dependencies, groups, group_vars) -def verifier_dependances_actives(data: dict, dependencies: dict) -> None: +def verifier_dependances_actives(data: dict, dependencies: dict, + group_vars: Path | None = None) -> None: active_hosts = set(enfants(data).get(GROUPE_HOTES_ACTIFS, {}).get("hosts", {})) active_groups: set[str] = set() @@ -370,7 +398,7 @@ def verifier_dependances_actives(data: dict, dependencies: dict) -> None: if active_hosts & set(group_data.get("hosts", {})): active_groups.add(group) - verifier_dependances_groupes(data, dependencies, sorted(active_groups)) + verifier_dependances_groupes(data, dependencies, sorted(active_groups), group_vars) def verifier_dependances_connues(data: dict, dependencies: dict, playbook_dir: Path | None) -> None: @@ -513,7 +541,8 @@ def main() -> int: verifier_playbooks_groupes(data, args.dossier_playbooks) if args.dependances: verifier_dependances_connues(data, dependencies, args.dossier_playbooks) - verifier_dependances_actives(data, dependencies) + verifier_dependances_actives( + data, dependencies, Path(args.inventaire).parent / "group_vars") ecrire_inventaire(args.inventaire, data) elif args.command == "definir-groupes": definir_groupes_hote(data, args.hote, analyser_groupes(args.groupes)) @@ -521,7 +550,8 @@ def main() -> int: verifier_playbooks_groupes(data, args.dossier_playbooks) if args.dependances: verifier_dependances_connues(data, dependencies, args.dossier_playbooks) - verifier_dependances_actives(data, dependencies) + verifier_dependances_actives( + data, dependencies, Path(args.inventaire).parent / "group_vars") ecrire_inventaire(args.inventaire, data) elif args.command == "afficher": afficher_hote(data, args.hote) @@ -537,16 +567,20 @@ def main() -> int: print(hote) elif args.command == "verifier-dependances-hote": exiger_dependances(dependencies, args.dependances) - verifier_dependances_hote(data, dependencies, args.hote) + verifier_dependances_hote(data, dependencies, args.hote, + Path(args.inventaire).parent / 'group_vars') elif args.command == "verifier-dependances-groupe": exiger_dependances(dependencies, args.dependances) - verifier_dependances_groupes(data, dependencies, [args.groupe]) + verifier_dependances_groupes( + data, dependencies, [args.groupe], + Path(args.inventaire).parent / "group_vars") elif args.command == "verifier-playbooks": verifier_playbooks_groupes(data, args.dossier_playbooks) elif args.command == "verifier-dependances": exiger_dependances(dependencies, args.dependances) verifier_dependances_connues(data, dependencies, args.dossier_playbooks) - verifier_dependances_actives(data, dependencies) + verifier_dependances_actives( + data, dependencies, Path(args.inventaire).parent / "group_vars") except Exception as exc: print(f"erreur: {exc}", file=sys.stderr) return 2 diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index 68ca110..ea01b58 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -22,6 +22,46 @@ ORDRE_INVENTAIRE = ("principal", "production") ORDRE_INVENTAIRE_MODELE = ("lab", "principal", "production") +def dependances_groupes(racine: Path | None = None) -> dict: + """Le registre des dependances causales entre groupes (docs/dependances-groupes.yml).""" + f = (racine or RACINE_DEPOT) / "docs" / "dependances-groupes.yml" + if not f.is_file(): + return {} + return (yaml.safe_load(f.read_text(encoding="utf-8")) or {}).get("groupes") or {} + + +def variable_effective(cle: str, dossier_group_vars: Path | None = None, + role_defaut: str | None = None, + racine: Path | None = None) -> str | None: + """La valeur d'une variable pour une instance : ses group_vars, sinon le defaut du role. + + SOURCE UNIQUE (2026-08-22). Trois lecteurs en avaient besoin le meme jour — P35 pour + l'interrupteur `_bd`, le controle de dependances pour les clauses `sauf_si`, et + le generateur d'inventaire. Trois copies auraient recommence exactement ce qu'on vient + de refermer avec la resolution d'instance. + + Lecture TEXTUELLE : aucune voute n'est dechiffree, aucun Ansible n'est lance. Ces + controles doivent rester instantanes — c'est ce qui fait qu'on les lance. + """ + base = racine or RACINE_DEPOT + dossier = dossier_group_vars or (dossier_inventaire() / "group_vars") + if dossier.is_dir(): + for f in sorted(dossier.glob("**/*.yml")): + try: + d = yaml.safe_load(f.read_text(encoding="utf-8")) or {} + except (OSError, yaml.YAMLError): + continue + if isinstance(d, dict) and cle in d: + return str(d[cle]) + if role_defaut: + f = base / "roles" / role_defaut / "defaults" / "main.yml" + if f.is_file(): + d = yaml.safe_load(f.read_text(encoding="utf-8")) or {} + if isinstance(d, dict) and cle in d: + return str(d[cle]) + return None + + def instance_courante() -> Path: """L'instance VISEE : `SETOPS_INSTANCE`, sinon le symlink `instance/`. @@ -164,19 +204,41 @@ def integrations_universelles(racine: Path | None = None) -> dict[str, dict]: def integrations_de(srv: dict, services_hote: set[str] | None = None, universelles: dict[str, dict] | None = None, - racine: Path | None = None) -> set[str]: + racine: Path | None = None, + services_du_plan: set[str] | None = None) -> set[str]: """Integrations effectives d'un serveur : les universelles + celles du plan. `services_hote` = groupes de service que cet hote rend (derives des applications). Il sert aux exemptions : `sauf_role` retire l'integration a l'hote qui rend le service lui-meme — l'AC ne s'enrole pas aupres d'elle-meme. L'exemption suit donc le service, jamais un nom d'hote. + + `services_du_plan` = TOUS les groupes de service de cet ecosysteme. UNE INTEGRATION + UNIVERSELLE N'EST POSEE QUE SI SON SERVICE CENTRAL EXISTE ICI (2026-08-22). + + Pourquoi cette regle a manque. « Tout hote est mesure » est vrai dans un ecosysteme qui + porte un Prometheus. Dans un ecosysteme qui n'en a pas — un patient 0 minimal, une + petite offre — la meme phrase pose sur chaque machine un client qui n'a personne a qui + parler, et le controle de dependances refuse le deploiement. Le moteur supposait + l'ecosysteme COMPLET ; c'est la troisieme fois que cette hypothese se paie, apres les + intrants (P32) et les bases (P35). + + La regle est DERIVEE, pas declaree : le service central d'une integration est celui que + le registre des dependances lui donne deja (`requiert_groupes_actifs`). Rien de neuf a + tenir a jour, donc rien de neuf a oublier. """ if universelles is None: universelles = integrations_universelles(racine) rendus = services_hote or set() effectives = {role for role, p in universelles.items() if not (p["sauf_role"] and p["sauf_role"] in rendus)} + if services_du_plan is not None: + deps = dependances_groupes(racine) + effectives = { + role for role in effectives + if all(exige in services_du_plan + for exige in (deps.get(role, {}).get("requiert_groupes_actifs") or [])) + } return effectives | set(srv.get("integrations") or [])