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 [])