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.