dependances : un ecosysteme minimal n'est pas un ecosysteme incomplet
Some checks are pending
verifier / verifier (push) Waiting to run

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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-22 14:54:00 -04:00
parent 5bb503be45
commit 5be3fbd5e1
5 changed files with 181 additions and 14 deletions

View file

@ -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 ?*

View file

@ -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:

View file

@ -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})

View file

@ -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

View file

@ -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 `<role>_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 [])