From cd0f94a50b6a9e4a1edd8c8dac8b3084c3bcf2be Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sun, 20 Sep 2026 21:55:49 -0400 Subject: [PATCH] =?UTF-8?q?console=20:=20un=20tronc,=20deux=20branches=20?= =?UTF-8?q?=E2=80=94=20assembler=20deux=20fois=20est=20ce=20qui=20fait=20d?= =?UTF-8?q?eriver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il y avait deux assembleurs de charge, un par sorte de console. Ils ont derive deux fois le meme jour : en forme (serveurs valait [] d'un cote et {} de l'autre, et charger() levait), puis en contenu (cinq registres servis vides alors que le site a SON plan — 9 serveurs, 21 applications, 2 bases, 1 domaine, tous invisibles). Console assemble, une seule fois, et fixe les clefs et leurs formes. Les branches ne decident que de ce qui leur appartient : d'ou vient le plan, d'ou vient l'inventaire, quels pouvoirs elles portent. ConsoleLocataire configure, ConsoleSite materialise et sert desormais son propre plan, ConsolePoste herite du locataire et sait en plus sur quelle fabric poser. Le role et la portee ne se confondent pas : le role est une propriete de la classe, la portee se calcule depuis les pouvoirs. Un poste prive de la voute du site reste l'atelier du mainteneur et n'engendre pourtant rien. Le decoupage a montre un trou aussitot : ConsolePoste heritait du refus d'un locataire — « elle ne sait pas sur quelle fabric poser » — alors qu'il monte la carte ; ce qui lui manque est la voute, et accuser la mauvaise absence fait chercher au mauvais endroit. Le jugement des assistants remonte dans le tronc : il etait ecrit dans la route qui liste ET dans celle qui execute, et celle qui se trompe est toujours celle qui execute. Valide : make test a 0 echec, 8 tests de rendu sous node dont deux neufs, console lancee pour de vrai (13 serveurs, 24 applications, 17 runbooks, 127 etapes, 0 ecart). make verifier a aussi attrape une faute que j'avais laissee passer sur depots_perimes.yml — risky-shell-pipe, corrige. P02 et P60 restent, et P60 demande une republication du wiki. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 70 +++ playbooks/maintenance/depots_perimes.yml | 16 +- scripts/inventory_gui.py | 596 ++++++++++++++++------- scripts/tests/test_rendu_gui.py | 49 +- 4 files changed, 529 insertions(+), 202 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41baccb..1747c7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,75 @@ # CHANGELOG — Set-OPS +## 2026-09-20 (11) — Deux fonctions qui doivent rendre la même forme finissent par ne plus la rendre + +**83 preuves, `make test` à 0 échec, 8 tests de rendu.** La console avait deux +assembleurs de charge — `inventaire_api` pour un locataire, `inventaire_api_du_site` pour +un site. Ils ont dérivé deux fois le même jour, et ce n'était pas une malchance : c'est +ce qui arrive toujours à deux copies d'une même obligation. + +### Ce que la duplication a coûté, mesuré + +**En forme.** `serveurs` valait `[]` d'un côté et `{}` de l'autre. Les deux « vides », et +la page ne les lit pas pareil : `charger()` levait, et la console d'un site mourait avant +sa première vue. + +**En contenu.** Cinq registres étaient servis vides — « pour ne pas fabriquer un faux plan +de tenant ». Le site n'a pas de faux plan : il a **le sien**, au même format, que les +lecteurs du moteur lisent sans broncher. Mesure : **9 serveurs, 21 applications, 2 bases, +1 domaine**, tous invisibles à l'écran. + +### Un tronc, deux branches, et le rôle distinct de la portée + +`Console` assemble — **un seul endroit** où la charge se compose, et c'est lui qui fixe +les clés et leurs formes. Les branches ne décident que de ce qui leur appartient : d'où +vient le plan, d'où vient l'inventaire, quels pouvoirs elles portent. + +| Branche | Ce qu'elle est | Son plan | Son inventaire | +|---|---|---|---| +| `ConsoleLocataire` | configure ses services | dérivé d'un seed | artefact généré | +| `ConsoleSite` | matérialise le terrain | **déclaré** (`ip`, `vmid`, `noeud`) | un script | +| `ConsolePoste` | l'atelier — hérite du locataire | idem locataire | idem locataire | +| `Console` | ne pilote rien, et le dit | aucun | vide | + +**Le rôle et la portée ne se confondent pas.** Le rôle est une propriété de la classe : +ce pour quoi cette console existe. La portée se **calcule** depuis les pouvoirs : ce +qu'elle peut vraiment, ici et maintenant. Un poste privé de la voûte du site reste +l'atelier du mainteneur et n'engendre pourtant rien — figer la portée sur la classe lui +aurait rendu un pouvoir que la mesure lui refuse. + +Ce découpage a immédiatement montré un trou : `ConsolePoste` héritait du refus d'un +locataire — *« elle ne sait pas sur quelle fabric poser »* — alors qu'il **monte** la +carte. Ce qui lui manque, c'est la voûte. Un message qui accuse la mauvaise absence fait +chercher au mauvais endroit. + +### Le jugement des assistants remonte dans le tronc + +Il était écrit deux fois : dans la route qui **liste** les assistants, et dans celle qui +**exécute** une étape. Deux copies d'une même règle finissent par ne plus dire la même +chose — et **celle qui se trompe est toujours celle qui exécute, parce que c'est la moins +relue**. `Console.peut_conduire()` répond aux deux. + +Le gestionnaire ne demande plus « est-ce un site ? » pour choisir un assembleur : il +demande sa charge à sa console, et ignore laquelle lui répond. + +### Ce qui a été vérifié + +`make test` à 0 échec, les 8 tests de rendu sous `node` — dont deux neufs : la console de +site sert bien son plan, et les deux charges portent les mêmes types. La console a été +lancée pour de vrai : contexte `poste`, 13 serveurs, 24 applications, 17 runbooks, +127 étapes conduisibles, 0 écart. + +**Et `make verifier` a trouvé une faute que j'avais laissée passer** : le playbook +`depots_perimes.yml` livré une heure plus tôt violait `risky-shell-pipe`. J'avais passé +`--syntax-check` dessus et `ansible-lint` seulement sur le rôle voisin. Corrigé — les +tubes retirés, `pipefail` armé sans `-e` pour qu'un `git` qui échoue laisse le script +répondre au lieu de faire tomber la tâche. + +**Deux échecs restent.** P02, antérieur, consigné à l'entrée `(6)`. Et **P60** : le wiki +publié est en retard d'un commit depuis que l'entrée `(6)` a documenté les Assistants — +`make wiki-publier WIKI_REMOTE=…` le republie, et l'adresse publique n'est celle d'aucun +plan, donc elle ne se devine pas. + ## 2026-09-20 (10) — Un vide doit avoir la forme de ce qu'il remplace **83 preuves, `make test` à 0 échec, 7 tests de rendu.** La console de diff --git a/playbooks/maintenance/depots_perimes.yml b/playbooks/maintenance/depots_perimes.yml index 9d3593a..4b9f89c 100644 --- a/playbooks/maintenance/depots_perimes.yml +++ b/playbooks/maintenance/depots_perimes.yml @@ -69,12 +69,22 @@ - name: Regarder ce que chaque dépôt périmé porte d'unique ansible.builtin.shell: cmd: | - set -u + # `pipefail` SANS `-e` : la regle `risky-shell-pipe` l'exige des qu'un `|` + # parait — ici ce sont des `||`, mais l'armer ne coute rien et dit la bonne + # intention. Pas de `-e` : un `git` qui echoue sur un dossier douteux doit + # laisser ce script REPONDRE (le depot sera juge « pas un depot git »), pas + # faire tomber la tache sans rien dire. + set -uo pipefail d={{ (racine ~ '/' ~ item) | quote }} if [ ! -d "$d/.git" ]; then echo '{"git": false}'; exit 0; fi - sale=$(git -C "$d" status --porcelain 2>/dev/null | head -c 2000) + # AUCUN TUBE ICI, ET C'EST VOULU. `… | head -c 2000` bornait la sortie ; avec + # `pipefail` — qu'ansible-lint exige a juste titre — `head` qui ferme tot fait + # echouer la commande, et sans `pipefail` c'est l'echec de `git` qui se perd. + # On ne borne pas ce qu'on ne lit pas : seule la VACUITE de ces deux sorties + # est interrogee, jamais leur contenu. + sale=$(git -C "$d" status --porcelain 2>/dev/null) # Des commits qu'AUCUN distant ne porte : ce qui n'existe qu'ici. - seuls=$(git -C "$d" log --branches --not --remotes --oneline 2>/dev/null | head -c 2000) + seuls=$(git -C "$d" log --branches --not --remotes --oneline 2>/dev/null) printf '{"git": true, "sale": %s, "seuls": %s}\n' \ "$([ -n "$sale" ] && echo true || echo false)" \ "$([ -n "$seuls" ] && echo true || echo false)" diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index ebbb9b1..a474cf3 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -133,105 +133,13 @@ def _depot_hebergeur() -> Path | None: # `docs/responsabilites-locataire-hebergeur.md` nomme : calculer, configurer, materialiser. def contexte() -> dict: - """La portee de cette console, et la raison de chaque interdit. + """La portee de cette console — DELEGUEE a la classe qui la sert. - Quatre cas, dont deux seulement se rencontrent sur une machine deployee : - - poste les deux liens — l'atelier du mainteneur, rien n'est retire - tenant `instance/` seul — il configure, il ne materialise pas - site `underlay.yml` seul — il materialise, il n'entre chez personne - orphelin aucun des deux — il ne pilote rien, et c'est ce qu'il doit dire + Ce que cette fonction faisait a la main vit desormais dans `Console` et ses deux + branches. Elle reste parce que le harnais et les preuves l'appellent par ce nom, et + parce qu'un appelant n'a pas a savoir laquelle des deux consoles lui repond. """ - a_instance = INSTANCE.exists() - a_fabric = FICHIER_UNDERLAY.exists() - depot_site = _depot_hebergeur() - ecosysteme = INSTANCE.resolve().name if a_instance else None - fabric = depot_site.name if depot_site else None - - # LE POUVOIR SE MESURE SUR CE QU'ON PEUT OUVRIR, PAS SUR CE QU'ON PEUT LIRE - # (2026-09-20). - # - # `docs/responsabilites-locataire-hebergeur.md` §2 attache chaque pouvoir a une - # VOUTE, et le code ne lisait que les symlinks — c'est-a-dire les CARTES : - # - # calculer plan -> inventaire aucune voute - # configurer des roles sur ses machines voute du TENANT - # materialiser creer / detruire des VM voute du SITE - # - # CE QUE L'ECART PRODUISAIT. Le runner de Chezlepro-locataire montait la carte du - # site sans en avoir jamais eu la voute. Sa console se declarait donc « poste » et - # offrait 126 etapes sur 126, creation et destruction de machines comprises. Ces - # gestes seraient PARTIS puis tombes sur un secret vide — un echec au milieu du - # chemin, la ou un refus net aurait dit la verite avant de commencer. - # - # ON NE PROUVE PAS QU'UNE VOUTE S'OUVRE, on constate qu'elle est LA. Le mot de passe - # se tape a l'execution et n'est pas connu ici. Mais l'absence du fichier, elle, est - # decisive et se mesure sans rien ouvrir : une voute absente ne s'ouvrira jamais. - voute_tenant = (INSTANCE / "inventories" / "principal" / "group_vars" / "all" / - "vault.yml") if a_instance else None - voute_site = (depot_site / "underlay.vault.yml") if depot_site else None - a_voute_tenant = bool(voute_tenant and voute_tenant.is_file()) - a_voute_site = bool(voute_site and voute_site.is_file()) - - peut = { - # CALCULER ne demande aucune voute : le plan suffit, et n'importe quel runner en - # a le droit. C'est la seule des trois portees qui se passe de secret. - "editer_le_plan": a_instance, - # CONFIGURER, c'est poser des roles sur SES machines : la voute du tenant. - "configurer": a_instance and a_voute_tenant, - # MATERIALISER, c'est creer et detruire des VM : la voute du SITE. - "materialiser": a_fabric and a_voute_site, - # Les devis de fabric (commutateurs, frontiere, SDN, pools) appartiennent a - # l'hebergeur. Un locataire les LIT — ils restent affiches — mais la copie qu'il - # porte est un miroir, et le dire vaut mieux que le laisser croire. Lire une - # carte ne demande pas de l'ouvrir : ce pouvoir-la reste attache au symlink. - "fabric": a_fabric, - } - - # LA PORTEE DECOULE DES POUVOIRS, elle ne les precede plus. Une console qui nomme sa - # portee d'apres ses symlinks annoncerait « poste » en ne pouvant rien engendrer. - portee = ("poste" if peut["configurer"] and peut["materialiser"] else - "tenant" if peut["configurer"] else - "site" if peut["materialiser"] else "orphelin") - - raison = { - "configurer": ( - "Cette console materialise le terrain ; elle n'a la voute d'aucun " - "locataire, donc elle ne configure aucun de leurs services." - if not a_instance else - "Un ecosysteme est monte, mais sa voute n'est pas sur cette machine : " - "calculer un inventaire ne demande aucun secret, configurer un service si."), - "editer_le_plan": ("Aucun ecosysteme n'est monte ici : il n'y a pas de plan a " - "editer. L'inventaire de cette machine est DYNAMIQUE."), - "materialiser": ( - "Cette console configure un ecosysteme ; elle ne sait pas sur " - "quelle fabric poser une VM, et c'est voulu : exploiter n'est " - "pas engendrer." - if not a_fabric else - "Cette console LIT la carte d'une fabric dont elle n'a pas la voute. Elle " - "peut la consulter ; creer ou detruire une machine demande le secret du " - "site, et il n'est pas ici."), - "fabric": ("La fabric appartient a l'hebergeur. Ce qui s'affiche ici vient d'une " - "copie locale, et une copie avait deja diverge."), - } - titres = { - "poste": f"Poste du mainteneur — {ecosysteme} sur {fabric}", - "tenant": f"Console du locataire — {ecosysteme}", - "site": f"Console du SITE — {fabric}", - "orphelin": "Console sans ecosysteme — rien a piloter", - } - return { - "portee": portee, - "titre": titres[portee], - "ecosysteme": ecosysteme, - "fabric": fabric, - "peut": peut, - "raison": raison, - # QUELLE SOURCE D'INVENTAIRE, et c'est le coeur du correctif : un site n'a pas de - # `hosts.yml`, son inventaire est un SCRIPT. - "inventaire": ("site" if portee == "site" else - "instance" if a_instance else None), - } + return console().contexte() def inventaire_du_site() -> dict: @@ -256,46 +164,6 @@ def inventaire_du_site() -> dict: return {"all": {"children": enfants_site}} -def inventaire_api_du_site(ctx: dict) -> dict: - """La charge utile d'une console de SITE : ses machines, et AUCUN plan a editer. - - On ne fabrique pas un faux plan de tenant pour remplir l'ecran. Les registres partent - vides, et le contexte dit POURQUOI — un vide explique n'est plus un vide silencieux. - """ - data = inventaire_du_site() - return { - "flux": flux_api(), - "couches": couches_api(), - "inventaire": f"{ctx['fabric']} (inventaire dynamique)", - "production": True, - "groupes": groupes_disponibles(data), - "dependances": charger_dependances(FICHIER_DEPENDANCES), - "nomenclature": {}, - "chaine": roles_des_groupes(), - "bases": {}, - "applications": {}, - "domaines": {}, - # UNE LISTE VIDE, PAS UNE TABLE VIDE (2026-09-20). - # - # `inventaire_api` sert `serveurs` en LISTE ; ici c'etait une table. Les deux sont - # « vides », et la page ne les lit pas pareil : `(data.serveurs || []).map(...)` - # trouve `{}` — truthy, sans `.map` — et `charger()` LEVE. Toute la console d'un - # site mourait donc avant de dessiner quoi que ce soit, et le message d'erreur - # accusait une methode manquante plutot qu'une forme qui ment. - # - # Un vide doit avoir la FORME de ce qu'il remplace. Sinon ce n'est pas un vide, - # c'est un autre objet qui se fait passer pour lui. - "serveurs": [], - "hotes": liste_hotes(data), - "intrants": None, - "integrations_disponibles": integrations_disponibles(), - "integrations_universelles": integrations_universelles(RACINE), - "liens_acceptes": catalogue_liens(RACINE), - "schema": schema_du_plan(), - "contexte": ctx, - } - - def sonder_donnees(data: dict) -> dict: """Meme sonde que `sonder_hotes`, sur un inventaire DEJA charge (le site).""" cibles = [(h.get("nom"), h.get("adresse_ip")) for h in liste_hotes(data)] @@ -679,32 +547,398 @@ def couches_api() -> list: return [] +# ═══ LES DEUX CONSOLES, ET LE TRONC QU'ELLES PARTAGENT ═══════════════════════════════ +# +# POURQUOI DEUX CLASSES, ET PAS DEUX FONCTIONS (2026-09-20). +# +# Il y avait deux fonctions : `inventaire_api` pour un locataire, `inventaire_api_du_site` +# pour un site. Elles assemblaient LA MEME charge, chacune de son cote — et elles ont +# derive, deux fois le meme jour : +# +# en FORME `serveurs` valait `[]` d'un cote et `{}` de l'autre. Les deux « vides », +# et la page ne les lit pas pareil : `charger()` LEVAIT, et la console d'un +# site mourait avant sa premiere vue. +# en CONTENU cinq registres etaient servis vides « pour ne pas fabriquer un faux plan +# de tenant » — alors que le site a SON plan, au meme format, que les +# lecteurs du moteur lisent sans broncher : 9 serveurs, 21 applications. +# +# Deux fonctions qui doivent rendre la meme forme finissent toujours par ne plus la +# rendre. Un tronc qui assemble, et des branches qui ne decident que de CE QUI LEUR +# APPARTIENT, rendent cette derive impossible : il n'y a plus qu'un seul endroit ou la +# charge se compose. +# +# CE QUE CHAQUE BRANCHE EST, au sens de `docs/responsabilites-locataire-hebergeur.md` : +# +# ConsoleLocataire configure SES services — voute du tenant, plan derive d'un seed +# ConsoleSite materialise le terrain — voute du site, plan DECLARE (ip, vmid) +# ConsoleOrpheline ne pilote rien, et c'est ce qu'elle doit dire +# +# Aucune n'est omnipotente, et la classe le porte au lieu d'un `if portee == ...` seme +# dans le code. + + +class Console: + """Le tronc : ce qu'une console EST, quelle que soit la machine qui la sert.""" + + # LE ROLE est une propriete de la CLASSE — ce pour quoi cette console existe. + # LA PORTEE se CALCULE — ce qu'elle peut vraiment, ici et maintenant. Les deux ne se + # confondent pas : un poste prive de la voute du site reste l'atelier du mainteneur, + # et n'engendre pourtant rien. Figer la portee sur la classe aurait rendu a ce + # poste-la un pouvoir que la mesure lui refuse. + role = "aucun" + + @property + def portee(self) -> str: + p = self.pouvoirs() + return ("poste" if p["configurer"] and p["materialiser"] else + "tenant" if p["configurer"] else + "site" if p["materialiser"] else "orphelin") + + @property + def titre(self) -> str: + return { + "poste": f"Poste du mainteneur — {self.ecosysteme()} sur {self.fabric()}", + "tenant": f"Console du locataire — {self.ecosysteme()}", + "site": f"Console du SITE — {self.fabric()}", + }.get(self.portee, "Console sans pouvoir — elle ne pilote rien") + + # LES REGISTRES DU PLAN, declares UNE fois : leur nom a l'ecran, leur fichier, leur + # lecteur. Une branche qui n'a pas de plan les rend vides — de la BONNE forme, parce + # que c'est le tronc qui decide de la forme, pas la branche. + REGISTRES = ( + ("nomenclature", "nomenclature.yml", charger_nomenclature), + ("bases", "bases-donnees.yml", charger_bases_donnees), + ("applications", "applications.yml", charger_applications), + ("domaines", "domaines.yml", charger_domaines), + ) + + # --- CE QUE CHAQUE BRANCHE DECIDE POUR ELLE --------------------------------------- + + def racine_du_plan(self) -> Path | None: + """Le dossier `plan/` que cette console lit. None = elle n'en a pas.""" + return None + + def inventaire_brut(self) -> dict: + """L'inventaire, dans la forme d'un `hosts.yml`. Vide par defaut.""" + return {"all": {"children": {}}} + + def nom_inventaire(self) -> str: + return "" + + def est_production(self) -> bool: + return False + + def serveurs(self, data: dict) -> list: + """Les serveurs, TOUJOURS en liste. D'ou ils viennent regarde la branche.""" + return [] + + def intrants(self): + return None + + def pouvoirs(self) -> dict: + return {"editer_le_plan": False, "configurer": False, + "materialiser": False, "fabric": False} + + def raisons(self) -> dict: + return { + "configurer": "Cette console ne porte la voute d'aucun ecosysteme.", + "editer_le_plan": "Aucun plan n'est monte ici.", + "materialiser": "Cette console ne sait sur quelle fabric poser une VM.", + "fabric": "Aucune fabric n'est montee ici.", + } + + def ecosysteme(self) -> str | None: + return None + + def fabric(self) -> str | None: + return None + + # --- LE TRONC : UN SEUL ENDROIT OU LA CHARGE SE COMPOSE --------------------------- + + def contexte(self) -> dict: + return { + "portee": self.portee, + "role": self.role, + "titre": self.titre, + "ecosysteme": self.ecosysteme(), + "fabric": self.fabric(), + "peut": self.pouvoirs(), + "raison": self.raisons(), + # QUELLE SOURCE D'INVENTAIRE : un site n'a pas de `hosts.yml`, son inventaire + # est un SCRIPT. Une console qui a quelque chose a piloter le NOMME. + "inventaire": self.nom_inventaire() or None, + } + + def peut_conduire(self, portee_etape: str) -> tuple[bool, str]: + """Cette console peut-elle conduire une etape de CETTE portee — et sinon, pourquoi ? + + LE JUGEMENT VIT ICI, PAS DANS LES ROUTES. Il etait ecrit deux fois : dans la route + qui LISTE les assistants et dans celle qui EXECUTE une etape. Deux copies d'une + meme regle finissent par ne plus dire la meme chose — et celle qui se trompe est + toujours celle qui execute, parce que c'est la moins relue. + """ + peut = self.pouvoirs() + manquants = [p for p in POUVOIR_PAR_PORTEE.get(portee_etape, ()) + if not peut.get(p)] + if not manquants: + return True, "" + return False, self.raisons().get(manquants[0], + "Geste hors de la portee de cette console.") + + def charge(self) -> dict: + """LA charge servie a la page — la seule, pour les deux consoles. + + Les clefs et leurs FORMES sont fixees ici. Une branche choisit ce qu'elle met + dedans, jamais la forme de ce qu'elle met : c'est exactement ce qui a manque. + """ + data = self.inventaire_brut() + plan = self.racine_du_plan() + registres = {} + for clef, fichier, lecteur in self.REGISTRES: + chemin = (plan / fichier) if plan else None + registres[clef] = lecteur(chemin) if (chemin and chemin.is_file()) else {} + return { + "flux": flux_api(), + "couches": couches_api(), + "inventaire": self.nom_inventaire(), + "production": self.est_production(), + "groupes": groupes_disponibles(data), + "dependances": charger_dependances(FICHIER_DEPENDANCES), + "chaine": roles_des_groupes(), + **registres, + "serveurs": self.serveurs(data), + "hotes": liste_hotes(data), + "intrants": self.intrants(), + "integrations_disponibles": integrations_disponibles(), + "integrations_universelles": integrations_universelles(RACINE), + "liens_acceptes": catalogue_liens(RACINE), + # LE SCHEMA VOYAGE AVEC LES DONNEES. Le navigateur genere ses formulaires + # depuis lui plutot que de les porter en dur. Genere par `make schema`, P61. + "schema": schema_du_plan(), + "contexte": self.contexte(), + } + + +class ConsoleLocataire(Console): + """Elle CONFIGURE : des roles sur ses machines, avec la voute du tenant. + + Son plan DERIVE tout d'un seed `index` — VMID, adresse, VLAN. Elle edite donc le + plan, et l'inventaire qu'elle lit est un artefact genere depuis lui. + """ + + role = "locataire" + + def __init__(self, instance: Path, inventaire: Path, a_voute: bool): + self._instance = instance + self._inventaire = inventaire + self._a_voute = a_voute + + def ecosysteme(self): + return self._instance.resolve().name + + def racine_du_plan(self): + return self._instance / "plan" + + def inventaire_brut(self): + return charger_yaml(self._inventaire) + + def nom_inventaire(self): + return os.path.relpath(self._inventaire, RACINE) + + def est_production(self): + return instance_est_production(self._inventaire) + + def serveurs(self, data): + return serveurs_reconcilies(data) + + def intrants(self): + return intrants_api() + + def pouvoirs(self): + return {"editer_le_plan": True, "configurer": self._a_voute, + "materialiser": False, "fabric": False} + + def raisons(self): + r = super().raisons() + r["materialiser"] = ("Cette console configure un ecosysteme ; elle ne sait pas " + "sur quelle fabric poser une VM, et c'est voulu : exploiter " + "n'est pas engendrer.") + r["configurer"] = ("Un ecosysteme est monte, mais sa voute n'est pas sur cette " + "machine : calculer un inventaire ne demande aucun secret, " + "configurer un service si.") + r["fabric"] = ("La fabric appartient a l'hebergeur. Cette console n'en monte " + "aucune carte.") + return r + + +class ConsoleSite(Console): + """Elle MATERIALISE : cree et detruit des VM, pose le reseau, avec la voute du site. + + SON PLAN EST DECLARE, PAS DERIVE. Un site est le terrain : il ecrit `ip`, `vmid` et + `noeud`, parce qu'il n'a aucun seed dont les faire descendre. Son inventaire n'est + pas un fichier mais un SCRIPT. + + ELLE A UN PLAN, ET ON LE LUI SERT. La charge du site servait cinq registres vides + « pour ne pas fabriquer un faux plan de tenant ». Le site n'a pas de faux plan : il a + LE SIEN, au meme format, que les lecteurs du moteur lisent sans broncher — mesure du + 2026-09-20 : 9 serveurs, 21 applications, 2 bases, 1 domaine, tous invisibles. + """ + + role = "site" + + def __init__(self, depot: Path, a_voute: bool): + self._depot = depot + self._a_voute = a_voute + + def fabric(self): + return self._depot.name + + def racine_du_plan(self): + return self._depot / "plan" + + def inventaire_brut(self): + return inventaire_du_site() + + def nom_inventaire(self): + return f"{self.fabric()} (inventaire dynamique)" + + def est_production(self): + return True + + def serveurs(self, data): + """Les serveurs DECLARES du site, dans la forme que la page sait lire. + + On ne les reconcilie pas contre une nomenclature : un site n'en a pas, ses + valeurs sont ecrites. Le statut se lit donc contre l'inventaire REEL — declare + ici, present la-bas, ou pas. + """ + chemin = self.racine_du_plan() / "serveurs.yml" + if not chemin.is_file(): + return [] + registre = (charger_serveurs(chemin) or {}).get("serveurs") or {} + vivants = {h.get("nom") for h in liste_hotes(data)} + sortie = [] + for nom, srv in registre.items(): + gabarit = srv.get("gabarit") or {} + sortie.append({ + "nom": nom, + # Un site ne derive d'aucune fonction : il nomme le RESEAU qui le porte. + "fonction": srv.get("reseau", ""), + "etat": srv.get("etat", ""), + "noeud": srv.get("noeud", ""), + "stockage": srv.get("stockage", ""), + "disque": gabarit.get("disque", ""), + "memoire": gabarit.get("memoire", ""), + "coeurs": gabarit.get("vcpu", ""), + "integrations": srv.get("integrations", []), + "vmid": srv.get("vmid", ""), + "adresse_ip": srv.get("ip", ""), + "vlan": "", + "statut": "reconcilie" if nom in vivants else "absent", + "divergences": [], + }) + return sortie + + def pouvoirs(self): + # EDITER : pas encore. Les formulaires sont generes depuis le schema du plan d'un + # LOCATAIRE, ou l'adressage se derive ; le plan d'un site l'ECRIT. Offrir un + # formulaire qui ecrirait de travers serait pire que ne rien offrir. On LIT. + return {"editer_le_plan": False, "configurer": False, + "materialiser": self._a_voute, "fabric": True} + + def raisons(self): + r = super().raisons() + r["configurer"] = ("Cette console materialise le terrain ; elle n'a la voute " + "d'aucun locataire, donc elle ne configure aucun de leurs " + "services.") + r["editer_le_plan"] = ("Le plan de ce site se LIT ici. Ses adresses sont ECRITES, " + "pas derivees d'un seed, et les formulaires sont generes " + "depuis le schema d'un plan de locataire : ils ecriraient " + "de travers.") + r["materialiser"] = ("Cette console LIT la carte d'une fabric dont elle n'a pas " + "la voute. Creer ou detruire une machine demande le secret " + "du site, et il n'est pas ici.") + r["fabric"] = ("La fabric appartient a l'hebergeur. Ce qui s'affiche ici vient " + "d'une copie locale, et une copie avait deja diverge.") + return r + + +class ConsolePoste(ConsoleLocataire): + """L'atelier du mainteneur : les deux voutes, donc les deux pouvoirs. + + Elle EST une console de locataire — meme plan, meme inventaire — qui sait en plus sur + quelle fabric poser une VM. L'heritage dit exactement ca, et evite une troisieme + copie de la meme charge. + """ + + role = "poste" + + def __init__(self, instance, inventaire, depot, a_voute_tenant, a_voute_site): + super().__init__(instance, inventaire, a_voute_tenant) + self._depot = depot + self._a_voute_site = a_voute_site + + def fabric(self): + return self._depot.name + + def pouvoirs(self): + p = super().pouvoirs() + p["materialiser"] = self._a_voute_site + p["fabric"] = True + return p + + def raisons(self): + # CE POSTE SAIT SUR QUELLE FABRIC POSER — il en monte la carte. Ce qui lui manque, + # le cas echeant, c'est la VOUTE. Heriter du refus d'un locataire (« elle ne sait + # pas sur quelle fabric ») enverrait chercher une carte qui est pourtant la, et + # ferait perdre le temps qu'un message juste economise. + r = super().raisons() + r["materialiser"] = ("Cette console LIT la carte d'une fabric dont elle n'a pas " + "la voute. Creer ou detruire une machine demande le secret " + "du site, et il n'est pas ici.") + r["fabric"] = ("La fabric appartient a l'hebergeur. Ce qui s'affiche ici vient " + "d'une copie locale, et une copie avait deja diverge.") + return r + + +def console(inventaire: Path | None = None) -> Console: + """QUELLE console sert cette machine — derive de ce qu'elle peut OUVRIR. + + `docs/responsabilites-locataire-hebergeur.md` attache chaque pouvoir a une voute : + calculer n'en demande aucune, configurer celle du tenant, materialiser celle du site. + On ne prouve pas qu'une voute s'ouvre — le mot de passe se tape a l'execution — mais + son ABSENCE est decisive et se mesure sans rien ouvrir. + """ + a_instance = INSTANCE.exists() + depot = _depot_hebergeur() + inv = inventaire or INVENTAIRE_DEFAUT + + voute_tenant = (INSTANCE / "inventories" / "principal" / "group_vars" / "all" / + "vault.yml") if a_instance else None + a_voute_tenant = bool(voute_tenant and voute_tenant.is_file()) + a_voute_site = bool(depot and (depot / "underlay.vault.yml").is_file()) + + # LA BRANCHE SUIT LES LIENS — ce que cette machine est faite pour faire. La PORTEE, + # elle, suivra les voutes : une console de locataire sans sa voute reste une console + # de locataire (elle calcule son inventaire), et se dira « orpheline » parce qu'elle + # ne peut rien configurer. Le role et le pouvoir sont deux questions distinctes. + if a_instance and depot: + return ConsolePoste(INSTANCE, inv, depot, a_voute_tenant, a_voute_site) + if a_instance: + return ConsoleLocataire(INSTANCE, inv, a_voute_tenant) + if depot: + return ConsoleSite(depot, a_voute_site) + return Console() + + def inventaire_api(path: Path) -> dict: - data = charger_yaml(path) - dependencies = charger_dependances(FICHIER_DEPENDANCES) - return { - "flux": flux_api(), - "couches": couches_api(), - "inventaire": os.path.relpath(path, RACINE), - "production": instance_est_production(path), - "groupes": groupes_disponibles(data), - "dependances": dependencies, - "nomenclature": charger_nomenclature(FICHIER_NOMENCLATURE), - "chaine": roles_des_groupes(), - "bases": charger_bases_donnees(FICHIER_BASES), - "applications": charger_applications(FICHIER_APPLICATIONS), - "domaines": charger_domaines(FICHIER_DOMAINES), - "serveurs": serveurs_reconcilies(data), - "hotes": liste_hotes(data), - "intrants": intrants_api(), - "integrations_disponibles": integrations_disponibles(), - "integrations_universelles": integrations_universelles(RACINE), - "liens_acceptes": catalogue_liens(RACINE), - # LE SCHEMA VOYAGE AVEC LES DONNEES. Le navigateur genere ses formulaires depuis - # lui plutot que de les porter en dur : un champ ajoute au plan apparait a l'ecran - # sans toucher au JS. Genere par `make schema`, garde par P61. - "schema": schema_du_plan(), - } + """La charge d'une console de LOCATAIRE — deleguee au tronc. + + Elle reste parce que le harnais et les tests l'appellent par ce nom. Elle n'assemble + plus rien : assembler deux fois est exactement ce qui a fait deriver la charge du + site de celle du locataire. + """ + return ConsoleLocataire(INSTANCE, path, True).charge() def schema_du_plan() -> dict: @@ -4569,17 +4803,13 @@ class Gestionnaire(BaseHTTPRequestHandler): elif chemin == "/api/contexte": self.repondre_json(200, contexte()) elif chemin == "/api/inventaire": - ctx = contexte() - if ctx["inventaire"] == "site": - self.repondre_json(200, inventaire_api_du_site(ctx)) - else: - self.repondre_json(200, {**inventaire_api(self.inventaire), - "contexte": ctx}) + # LE GESTIONNAIRE NE SAIT PLUS LAQUELLE LUI REPOND, et c'est le but : il + # demandait « est-ce un site ? » pour choisir entre deux assembleurs, et + # les deux ont derive. Il demande maintenant sa charge a sa console. + self.repondre_json(200, console(self.inventaire).charge()) elif chemin == "/api/sondes": - if contexte()["inventaire"] == "site": - self.repondre_json(200, sonder_donnees(inventaire_du_site())) - else: - self.repondre_json(200, sonder_hotes(self.inventaire)) + self.repondre_json(200, + sonder_donnees(console(self.inventaire).inventaire_brut())) elif chemin == "/api/devis-reseau": tenants = devis_reseau.decouvrir() self.repondre_json(200, { @@ -4603,13 +4833,8 @@ class Gestionnaire(BaseHTTPRequestHandler): try: import runbooks as _rb ctx = contexte() - def _juger(portee): - manquants = [p for p in POUVOIR_PAR_PORTEE.get(portee, ()) - if not ctx["peut"].get(p)] - return (not manquants, - "" if not manquants else - ctx["raison"].get(manquants[0], - "Hors de portee de cette console.")) + _c = console(self.inventaire) + _juger = _c.peut_conduire liste = [] for rb in _rb.assembler(RACINE): # CHAQUE ETAPE SE PESE SEULE (2026-09-20, mesure sur TechnoLibre). @@ -4688,14 +4913,11 @@ class Gestionnaire(BaseHTTPRequestHandler): return # Etape introuvable a cette place : `executer_runbook` le dira mieux que nous, # avec le nom et la place. On le laisse refuser, sans rien lancer. - manquants = ([] if portee_etape is None else - [p for p in POUVOIR_PAR_PORTEE.get(portee_etape, ()) - if not ctx["peut"].get(p)]) - if manquants: - self.repondre_json(409, {"erreur": ctx["raison"].get(manquants[0], - "Geste hors de la portee de cette console."), - "portee": ctx["portee"], "pouvoir": manquants[0]}) - return + if portee_etape is not None: + ok_etape, pourquoi = console(self.inventaire).peut_conduire(portee_etape) + if not ok_etape: + self.repondre_json(409, {"erreur": pourquoi, "portee": ctx["portee"]}) + return if besoin and not ctx["peut"].get(besoin): self.repondre_json(409, {"erreur": ctx["raison"].get(besoin, "Geste hors de " "la portee de cette console."), diff --git a/scripts/tests/test_rendu_gui.py b/scripts/tests/test_rendu_gui.py index 5a6c5fb..fb68b62 100644 --- a/scripts/tests/test_rendu_gui.py +++ b/scripts/tests/test_rendu_gui.py @@ -168,9 +168,12 @@ charger().then(function () { class TestFormeDesCharges(unittest.TestCase): """Les deux charges que la page consomme ont-elles la MEME FORME ? - Une console de locataire recoit `inventaire_api`, une console de site - `inventaire_api_du_site`. La page est la MEME : ce qu'elle sait lire d'un cote, elle - doit pouvoir le lire de l'autre. + `ConsoleLocataire` et `ConsoleSite` servent la meme page : ce qu'elle sait lire d'un + cote, elle doit pouvoir le lire de l'autre. Depuis le decoupage en classes, c'est le + TRONC qui assemble — une seule `charge()` pour les deux — donc cette garde ne devrait + plus jamais se declencher. On la garde parce qu'une branche peut surcharger `charge()` + un jour, et parce qu'un test qui ne se declenche plus est precisement celui qui + prouve que la refonte tient. Le 2026-09-20, `serveurs` valait `[]` d'un cote et `{}` de l'autre. Les deux sont « vides », et la page ne les lit pas pareil : `(data.serveurs || []).map(...)` trouve @@ -191,10 +194,8 @@ class TestFormeDesCharges(unittest.TestCase): def test_les_deux_charges_portent_les_memes_types(self): if not g.FICHIER_UNDERLAY.exists(): self.skipTest("aucune fabric montee : pas de charge de site a comparer") - ctx = dict(g.contexte()) - ctx.update(portee="site", ecosysteme=None) - site = g.inventaire_api_du_site(ctx) - tenant = g.inventaire_api(g.INVENTAIRE_DEFAUT) + site = g.ConsoleSite(g._depot_hebergeur(), True).charge() + tenant = g.ConsoleLocataire(g.INSTANCE, g.INVENTAIRE_DEFAUT, True).charge() ecarts = [] for clef in sorted(set(tenant) & set(site)): @@ -263,11 +264,7 @@ class TestRenduGui(unittest.TestCase): """ if not g.FICHIER_UNDERLAY.exists(): self.skipTest("aucune fabric montee : pas de console de site a simuler") - ctx = dict(g.contexte()) - ctx.update(portee="site", ecosysteme=None, - peut={"editer_le_plan": False, "configurer": False, - "materialiser": True, "fabric": True}) - payload = g.inventaire_api_du_site(ctx) + payload = g.ConsoleSite(g._depot_hebergeur(), True).charge() if not payload["hotes"]: self.skipTest("l'inventaire du site ne rend aucune machine ici") @@ -288,6 +285,34 @@ class TestRenduGui(unittest.TestCase): self.assertIn("matérialise", r["detail"]) self.assertIn(noms[0], r["detailChoisi"]) + def test_une_console_de_site_sert_son_propre_plan(self): + """UN SITE A UN PLAN, ET ON LE LUI SERVAIT VIDE (2026-09-20). + + Cinq registres partaient vides « pour ne pas fabriquer un faux plan de tenant ». + Le site n'a pas de faux plan : il a LE SIEN, au meme format, que les lecteurs du + moteur lisent sans broncher. Mesure du jour : 9 serveurs, 21 applications. + """ + if not g.FICHIER_UNDERLAY.exists(): + self.skipTest("aucune fabric montee") + depot = g._depot_hebergeur() + charge = g.ConsoleSite(depot, True).charge() + for registre, fichier in (("applications", "applications.yml"), + ("bases", "bases-donnees.yml"), + ("domaines", "domaines.yml")): + if not (depot / "plan" / fichier).is_file(): + continue + interne = charge[registre].get(registre) or charge[registre] + self.assertTrue(interne, + f"{registre} est servi vide alors que {fichier} en declare") + if (depot / "plan/serveurs.yml").is_file(): + self.assertTrue(charge["serveurs"], + "le site declare des serveurs et la charge n'en porte aucun") + # Et leur FORME est celle que la page sait lire, pas la table du plan. + self.assertIsInstance(charge["serveurs"], list) + premier = charge["serveurs"][0] + for clef in ("nom", "etat", "adresse_ip", "vmid", "statut"): + self.assertIn(clef, premier) + def test_controle_negatif_reference_absente(self): """LE CONTROLE. Une lecture d'identifiant inexistant DOIT faire echouer le banc.