diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f3de98..d00c339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,73 @@ # CHANGELOG — Set-OPS +## 2026-09-20 (8) — Un pouvoir se mesure sur ce qu'on peut ouvrir, pas sur ce qu'on peut lire + +**83 preuves, `make test` à 0 échec (21 tests neufs aujourd'hui).** La vue Assistants a +rendu visible un écart que les quatre boutons d'avant cachaient : le runner de +Chezlepro-**locataire** se déclarait `poste` et offrait 126 étapes sur 126, création et +destruction de machines comprises. + +### Ce que la console lisait, et ce que le document exigeait + +`docs/responsabilites-locataire-hebergeur.md` §2 attache chaque pouvoir à une **voûte** : +calculer ne demande aucun secret, configurer demande celle du tenant, matérialiser celle +du site. Le code, lui, lisait les **symlinks** — c'est-à-dire les cartes. + +Le runner de Chezlepro montait la carte du site sans en avoir jamais eu la voûte. Les +gestes de fabric qu'il proposait seraient **partis puis tombés sur un secret vide** : +un échec au milieu du chemin, là où un refus net aurait dit la vérité avant de commencer. +Et un refus qui arrive trop tard envoie chercher la panne dans la fabric, pas dans le +pouvoir. + +`contexte()` dérive désormais les pouvoirs des voûtes présentes, et la **portée découle +des pouvoirs** au lieu de les précéder. On ne prouve pas qu'une voûte s'ouvre — le mot de +passe se tape à l'exécution — mais son absence, elle, est décisive et se mesure sans rien +ouvrir : **une voûte absente ne s'ouvrira jamais**. Lire une carte reste permis : le +pouvoir `fabric` continue de suivre le symlink, parce que consulter un miroir n'est pas +engendrer. + +### Chezlepro-locataire est un locataire + +Il vit dans sa coquille et reçoit de l'hébergeur qui le porte — même quand cet hébergeur +est lui-même. Que le même humain tienne les deux rôles ne fusionne pas les deux pouvoirs : +ça rend la coupure **plus** nécessaire, puisque plus rien d'extérieur ne la rappelle. + +Son plan cesse donc de déclarer la fabric (`serveur_ops_underlay: ""`, comme TechnoLibre) +et de cloner le dépôt de son hébergeur. Il porte maintenant **sa propre copie** de la +racine d'autorité du site — un certificat public, identique à l'octet près à celui que +TechnoLibre porte déjà — au lieu de la dériver du symlink qu'il n'a plus. + +### Retirer une déclaration doit retirer l'artefact + +`serveur_ops_underlay` vide voulait dire « ce poste exploite sans engendrer », et le rôle +se contentait de **ne pas poser** le lien. Un runner qui l'avait déjà le gardait — avec le +pouvoir que son plan ne lui donnait plus. C'est la même forme que la liste qui suit une +autre : une déclaration qui ne vaut que dans un sens ne décrit plus rien. + +Le rôle retire maintenant le lien quand la fabric n'est plus déclarée. **Il ne retire +qu'un lien, jamais un fichier** : si une vraie carte occupe la place, elle n'a pas été +écrite par ce rôle, et il le dit plutôt que de détruire un contenu qui n'est pas le sien. +Le chemin de cette place est dérivé une seule fois, en défaut du rôle — trois copies d'un +même chemin finissent par ne plus désigner le même endroit. + +### Ce qui a été vérifié + +`--syntax-check` sur `playbooks/groupes/serveur_ops.yml`, `ansible-lint` sur le rôle +(profil `production`, 0 échec, 0 avertissement), `make test` à 0 échec, P81 et P83 vertes. +Six tests neufs montent quatre faux disques et exigent la portée qui leur revient, dont +**le défaut lui-même, nommé** : carte présente, voûte absente → `tenant`, jamais `poste`. + +L'état des trois runners est mesuré avant la correction — site : fabric + voûte du site ; +Chezlepro : instance + voûte du tenant + carte **sans** voûte du site ; TechnoLibre : +instance + voûte du tenant, aucune fabric. **Le premier relevé était faux** : `readlink -f` +rend un chemin même quand la cible n'existe pas, et faisait voir une fabric à TechnoLibre +qui n'en a pas. Refait avec un test d'existence. + +**P02 reste en échec**, pour la raison antérieure consignée à l'entrée `(6)`. Le clone +`SITE-Chezlepro` subsiste sur le runner du locataire : il ne donne plus aucun pouvoir une +fois le lien retiré, et le retirer serait détruire un dossier que personne n'a demandé de +détruire. + ## 2026-09-20 (7) — Une portée jugée trop haut ferme une séquence à qui elle appartient **83 preuves, `make test` à 0 échec (15 tests de runbooks).** Les assistants livrés à diff --git a/Makefile b/Makefile index 6e5cbdd..aab2f94 100644 --- a/Makefile +++ b/Makefile @@ -311,6 +311,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire) python3 scripts/tests/test_adressage_derive.py python3 scripts/tests/test_gui_intrants.py python3 scripts/tests/test_runbooks.py + python3 scripts/tests/test_portee_console.py python3 scripts/tests/test_devis_placement.py python3 scripts/tests/test_sonder.py diff --git a/roles/serveur_ops/defaults/main.yml b/roles/serveur_ops/defaults/main.yml index 7fe8c56..99f868c 100644 --- a/roles/serveur_ops/defaults/main.yml +++ b/roles/serveur_ops/defaults/main.yml @@ -233,6 +233,12 @@ serveur_ops_depot_moteur: >- {{ (serveur_ops_depots | selectattr('role', 'equalto', 'moteur') | map(attribute='dest') | first) | default('Set-OPS-public', true) }} +# LA PLACE DE LA FABRIC, derivee du dossier du moteur et ecrite UNE fois. Trois taches +# la visent — poser le lien, le retirer, dire qu'un fichier l'occupe — et trois copies +# d'un meme chemin finissent par ne plus designer le meme endroit. +serveur_ops_chemin_underlay: >- + {{ serveur_ops_racine }}/{{ serveur_ops_depot_moteur }}/underlay.yml + serveur_ops_gui_actif: false serveur_ops_gui_ecoute: "127.0.0.1" serveur_ops_gui_port: 8765 diff --git a/roles/serveur_ops/tasks/main.yml b/roles/serveur_ops/tasks/main.yml index 1e94982..624213b 100644 --- a/roles/serveur_ops/tasks/main.yml +++ b/roles/serveur_ops/tasks/main.yml @@ -475,8 +475,7 @@ - name: Désigner la fabric sur laquelle l'écosystème repose ansible.builtin.file: src: "{{ serveur_ops_racine }}/{{ serveur_ops_underlay }}/underlay.yml" - dest: >- - {{ serveur_ops_racine }}/{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur') | first).dest }}/underlay.yml + dest: "{{ serveur_ops_chemin_underlay }}" state: link owner: "{{ serveur_ops_utilisateur }}" group: "{{ serveur_ops_utilisateur }}" @@ -485,6 +484,47 @@ - serveur_ops_underlay | length > 0 - not ansible_check_mode +# LA DECLARATION VAUT DANS LES DEUX SENS (2026-09-20). +# +# `serveur_ops_underlay` vide dit « ce poste exploite sans engendrer ». Le role se +# contentait alors de NE PAS POSER le lien — et un runner qui l'avait deja le gardait, +# avec le pouvoir que son plan ne lui donnait plus. Mesure du jour : le runner de +# Chezlepro-LOCATAIRE montait encore la carte du site, et sa console offrait ses 126 +# etapes, creation et destruction de machines comprises. +# +# Retirer une declaration doit retirer l'artefact, sinon la declaration ne decrit plus +# rien. Meme patron que la sonde de console, qui suit son exposition dans les deux sens. +# +# ON NE RETIRE QU'UN LIEN, JAMAIS UN FICHIER. Sur un runner, `underlay.yml` a la racine du +# moteur n'est que le symlink pose ci-dessus. Si quelqu'un y a depose une vraie carte, ce +# n'est plus le meme objet — on le laisse et on le dit, plutot que de detruire un contenu +# qu'on n'a pas ecrit. +- name: Voir ce que porte la place de la fabric + ansible.builtin.stat: + path: "{{ serveur_ops_chemin_underlay }}" + register: serveur_ops_place_fabric + when: serveur_ops_underlay | length == 0 + +- name: Retirer la fabric quand cet écosystème ne l'engendre plus + ansible.builtin.file: + path: "{{ serveur_ops_chemin_underlay }}" + state: absent + when: + - serveur_ops_underlay | length == 0 + - serveur_ops_place_fabric.stat.islnk | default(false) + - not ansible_check_mode + +- name: Dire qu'une vraie carte occupe la place, au lieu de la détruire + ansible.builtin.debug: + msg: >- + {{ serveur_ops_chemin_underlay }} est un FICHIER, pas le lien que ce rôle pose. + Ce plan ne déclare plus de fabric ; ce fichier n'a donc pas été touché, et ce + runner garde de quoi lire une fabric qu'il ne déclare plus. À trancher à la main. + when: + - serveur_ops_underlay | length == 0 + - serveur_ops_place_fabric.stat.exists | default(false) + - not (serveur_ops_place_fabric.stat.islnk | default(false)) + # --- LA CLÉ DU POSTE --------------------------------------------------------- # # Le poste se fabrique sa propre paire, distincte de celle du mainteneur. Deux diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 886caf5..2402ece 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -148,29 +148,69 @@ def contexte() -> dict: ecosysteme = INSTANCE.resolve().name if a_instance else None fabric = depot_site.name if depot_site else None - portee = ("poste" if a_instance and a_fabric else - "tenant" if a_instance else - "site" if a_fabric else "orphelin") + # 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 = { - # Configurer et editer le plan supposent un ecosysteme monte ET sa voute. - "configurer": a_instance, + # 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, - # Materialiser suppose de savoir sur quelle fabric poser la VM. - "materialiser": a_fabric, + # 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. + # 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."), + "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."), + "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."), } diff --git a/scripts/prouver.py b/scripts/prouver.py index e569c32..f522453 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -4321,9 +4321,11 @@ def preuve_console_dit_sa_portee() -> tuple[bool, str]: CE QU'ELLE VERIFIE, hors ligne : - 1. La portee se DERIVE des deux symlinks — `instance/` (je configure), `underlay.yml` - (je materialise) — et jamais d'un reglage declare. Un reglage serait une seconde - liste, et une liste qui suit une autre prend du retard. + 1. La portee se DERIVE de ce que la machine porte, et jamais d'un reglage declare. + Un reglage serait une seconde liste, et une liste qui suit une autre prend du + retard. Depuis le 2026-09-20 elle se derive des VOUTES et non des seuls symlinks : + `configurer` exige la voute du tenant, `materialiser` celle du site. Une carte + lue ne vaut pas une voute ouverte — cf. `scripts/tests/test_portee_console.py`. 2. Une console qui a quelque chose a piloter nomme sa SOURCE d'inventaire. Aucune portee pilotable ne rend `inventaire: null`. 3. La source du SITE rend vraiment des machines quand le site en declare : c'est le @@ -4392,7 +4394,7 @@ def preuve_console_dit_sa_portee() -> tuple[bool, str]: if fautes: return False, ("La console ne dit pas ce qu'elle peut :\n - " + "\n - ".join(fautes)) - return True, (f"Portee `{ctx['portee']}` derivee des symlinks, source d'inventaire " + return True, (f"Portee `{ctx['portee']}` derivee des voutes portees, source d'inventaire " f"`{ctx['inventaire']}`, et {len(routes)} route(s) POST exigent toutes " f"un pouvoir.") diff --git a/scripts/tests/test_portee_console.py b/scripts/tests/test_portee_console.py new file mode 100644 index 0000000..aac4f8e --- /dev/null +++ b/scripts/tests/test_portee_console.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""La portee de la console : un pouvoir se mesure sur ce qu'on peut OUVRIR. + +`docs/responsabilites-locataire-hebergeur.md` §2 attache chaque pouvoir a une voute : + + calculer plan -> inventaire aucune voute + configurer des roles sur ses machines voute du TENANT + materialiser creer / detruire des VM voute du SITE + +Le code lisait les SYMLINKS — les cartes. Mesure du 2026-09-20 : le runner de +Chezlepro-locataire montait la carte du site sans en avoir jamais eu la voute ; sa +console se declarait « poste » et offrait 126 etapes sur 126, creation et destruction de +machines comprises. Ces gestes seraient partis, puis tombes sur un secret vide. + +Ces cas fabriquent les quatre situations sur disque et exigent la portee qui leur revient. +Le quatrieme est le defaut lui-meme, nomme pour qu'il ne revienne pas. +""" +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(RACINE / "scripts")) + +import inventory_gui as gui # noqa: E402 + + +class TestPorteeDeLaConsole(unittest.TestCase): + """Chaque cas monte un faux disque, puis lit ce que la console en conclut.""" + + def setUp(self): + self.dossier = Path(tempfile.mkdtemp(prefix="portee-")) + self._instance = gui.INSTANCE + self._underlay = gui.FICHIER_UNDERLAY + + def tearDown(self): + gui.INSTANCE = self._instance + gui.FICHIER_UNDERLAY = self._underlay + import shutil + shutil.rmtree(self.dossier, ignore_errors=True) + + def _monter(self, instance: bool, voute_tenant: bool, + fabric: bool, voute_site: bool) -> dict: + eco = self.dossier / "OPS-Essai" + site = self.dossier / "SITE-Essai" + if instance: + (eco / "inventories" / "principal" / "group_vars" / "all").mkdir(parents=True) + if voute_tenant: + (eco / "inventories/principal/group_vars/all/vault.yml").write_text( + "$ANSIBLE_VAULT;1.1;AES256\n", encoding="utf-8") + if fabric: + site.mkdir(parents=True, exist_ok=True) + (site / "underlay.yml").write_text("underlay: {}\n", encoding="utf-8") + if voute_site: + (site / "underlay.vault.yml").write_text( + "$ANSIBLE_VAULT;1.1;AES256\n", encoding="utf-8") + gui.INSTANCE = eco + gui.FICHIER_UNDERLAY = site / "underlay.yml" + return gui.contexte() + + def test_le_runner_du_site_materialise_et_ne_configure_pas(self): + ctx = self._monter(instance=False, voute_tenant=False, fabric=True, voute_site=True) + self.assertEqual(ctx["portee"], "site") + self.assertTrue(ctx["peut"]["materialiser"]) + self.assertFalse(ctx["peut"]["configurer"]) + + def test_le_runner_du_locataire_configure_et_n_engendre_pas(self): + ctx = self._monter(instance=True, voute_tenant=True, fabric=False, voute_site=False) + self.assertEqual(ctx["portee"], "tenant") + self.assertTrue(ctx["peut"]["configurer"]) + self.assertFalse(ctx["peut"]["materialiser"]) + + def test_le_poste_du_mainteneur_porte_les_deux_voutes(self): + ctx = self._monter(instance=True, voute_tenant=True, fabric=True, voute_site=True) + self.assertEqual(ctx["portee"], "poste") + self.assertTrue(all(ctx["peut"][p] for p in + ("configurer", "materialiser", "editer_le_plan", "fabric"))) + + def test_la_carte_sans_la_voute_n_engendre_rien(self): + """LE DEFAUT, NOMME. Chezlepro-locataire, le 2026-09-20 : la carte, pas les cles.""" + ctx = self._monter(instance=True, voute_tenant=True, fabric=True, voute_site=False) + self.assertEqual(ctx["portee"], "tenant", + "une carte lue ne vaut pas une voute ouverte") + self.assertFalse(ctx["peut"]["materialiser"]) + # Il LIT toujours la fabric — c'est un miroir, et le dire vaut mieux que le taire. + self.assertTrue(ctx["peut"]["fabric"]) + self.assertIn("voute", ctx["raison"]["materialiser"].lower()) + + def test_calculer_ne_demande_aucune_voute(self): + """La seule des trois portees qui se passe de secret : le plan suffit.""" + ctx = self._monter(instance=True, voute_tenant=False, fabric=False, voute_site=False) + self.assertTrue(ctx["peut"]["editer_le_plan"]) + self.assertFalse(ctx["peut"]["configurer"]) + self.assertEqual(ctx["portee"], "orphelin") + + def test_une_console_sans_rien_le_dit(self): + ctx = self._monter(instance=False, voute_tenant=False, fabric=False, voute_site=False) + self.assertEqual(ctx["portee"], "orphelin") + self.assertFalse(any(ctx["peut"].values())) + + +if __name__ == "__main__": + unittest.main(verbosity=2)