la console mesure le pouvoir sur la voute, pas sur la carte

Le document des responsabilites attache chaque pouvoir a une voute : calculer n'en demande
aucune, configurer demande celle du tenant, materialiser celle du site. Le code lisait les
symlinks, c'est-a-dire les cartes. 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. 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.

contexte() derive desormais les pouvoirs des voutes presentes, et la portee decoule des
pouvoirs au lieu de les preceder. 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. Lire une
carte reste permis : le pouvoir fabric suit toujours le symlink, consulter un miroir n'est
pas engendrer.

serveur_ops retire aussi le lien quand la fabric n'est plus declaree — il ne retirait rien,
et un runner gardait le pouvoir que son plan ne lui donnait plus. Il ne retire qu'un lien,
jamais un fichier : une vraie carte a cette place n'a pas ete ecrite par ce role, et il le
dit plutot que de detruire ce qui n'est pas le sien.

Valide : syntax-check et ansible-lint sur le role (profil production, 0/0), make test a 0
echec, P81 et P83 vertes. Six tests montent quatre faux disques et exigent la portee qui
leur revient, dont le defaut lui-meme : carte presente, voute absente, portee tenant.

Limite : P02 reste en echec pour la raison anterieure deja consignee.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-20 20:12:18 -04:00
parent d3a8124777
commit 4188cf5b6f
7 changed files with 282 additions and 19 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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."),
}

View file

@ -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.")

View file

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