assistants : la portee se pese a l'etape, pas a la sequence

Mesure sur la console de TechnoLibre, portee tenant : 6 runbooks conduisibles sur 17, et
parmi les onze fermes, locataire-deployer et machine-une — c'est-a-dire le travail
quotidien d'un locataire. La cause : flotte-creer et creer-vm engendrent des VM et
exigent la fabric, et une portee declaree pour toute la sequence faisait basculer avec eux
des etapes voisines qui ne demandent que ce que le locataire possede deja.

Le runbook ne donne plus que le defaut ; l'etape qui exige davantage le declare. Le
locataire conduit sa sequence et bute precisement la ou il faut : sur la machine a
engendrer, pas sur le deploiement qui suit. La page ferme l'etape seule avec sa raison, et
la garde de la route lit la portee de l'etape visee par son index.

Un droit calcule sur l'ensemble se trompe toujours dans le meme sens : il refuse a
quelqu'un ce qu'il a le droit de faire, et le refus parait fonde puisqu'il nomme un vrai
manque. Il a fallu une console de locataire reelle pour le voir — sur le poste, qui porte
les deux liens, les dix-sept sequences s'affichaient conduisibles.

Valide : runbooks.py verifier a 0 ecart, make test a 0 echec (4 tests neufs, dont un qui
nomme le cas exact), P83 verte. 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 19:55:02 -04:00
parent f33b5be151
commit d3a8124777
5 changed files with 165 additions and 23 deletions

View file

@ -1,5 +1,50 @@
# CHANGELOG — Set-OPS
## 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 à
l'entrée `(6)` pesaient la portée **au runbook**. Une console de locataire le montre en
une lecture : `locataire-deployer` et `machine-une` y étaient hors de portée — donc un
locataire ne pouvait pas déployer sa propre flotte, ce qui est exactement son métier.
### Une seule étape qui matérialise fermait tout le reste
`flotte-creer` et `creer-vm` engendrent des VM : ils exigent la fabric, que le locataire
n'a pas. Déclarés dans une séquence dont la portée valait pour l'ensemble, ils faisaient
basculer en `poste` les étapes voisines — `deployer-tout`, `valider`, `verifier-hote` —
qui ne demandent pourtant que ce que le locataire possède déjà.
Mesuré sur la console de TechnoLibre, portée `tenant` : **6 runbooks conduisibles sur 17**,
et parmi les onze fermés, les deux qui décrivent son travail quotidien.
La portée se pèse désormais **à l'étape**. Le runbook n'en donne que le défaut ; l'étape
qui exige davantage le déclare. Le locataire conduit donc sa séquence et **bute
précisément là où il faut** : sur la machine à engendrer, pas sur le déploiement qui suit.
La page ferme l'étape seule, en disant sa raison sur cette étape ; l'inspecteur compte ce
qui est hors de portée plutôt que de barrer l'ensemble.
Côté serveur, la garde de `/api/runbook-etape` lit la portée de **l'étape visée par son
index**, et non celle du runbook : juger sur le runbook aurait refusé un geste que la
console avait le droit de faire, ou accepté l'inverse.
### Ce que cette correction dit du reste
**Un droit qui se calcule sur l'ensemble se trompe toujours dans le même sens** : il
refuse à quelqu'un ce qu'il a le droit de faire, et le refus paraît fondé puisqu'il
nomme un vrai manque. Il a fallu une console de locataire réelle pour le voir — sur le
poste, qui porte les deux liens, les dix-sept séquences s'affichaient conduisibles et
rien ne clochait.
Quatre tests neufs refusent le retour du défaut, dont un qui nomme le cas exact :
`deployer-tout` et `valider` restent à portée d'un locataire, `flotte-creer` non.
### Ce qui a été vérifié
`python3 scripts/runbooks.py verifier` (0 écart), `make test` à 0 échec, P83 verte. La
correction est mesurée sur les trois consoles après déploiement. Aucun rôle, playbook ou
réglage d'infrastructure modifié. **P02 reste en échec, pour la raison antérieure déjà
consignée à l'entrée `(6)`.**
## 2026-09-20 (6) — Cent trente-deux cibles, et aucune ne dit dans quel ordre
**83 preuves (P83 est neuve), `make test` à 0 échec.** Le `Makefile` porte 132 cibles

View file

@ -28,6 +28,13 @@
# site une fabric est montee (`underlay.yml`) : on materialise
# poste les deux — l'atelier du mainteneur
# toute ni l'un ni l'autre n'est requis
#
# LA PORTEE SE PESE A L'ETAPE (2026-09-20). Un runbook donne le defaut ; une etape qui
# exige davantage le declare avec son propre `portee:`. Mesure faite sur la console de
# TechnoLibre : declaree au seul runbook, une unique etape qui materialise (`flotte-creer`,
# `creer-vm`) faisait basculer TOUTE la sequence en `poste` — et un locataire ne pouvait
# plus deployer sa propre flotte, ce qui est exactement son metier. Le locataire conduit
# donc sa sequence, et bute precisement la ou il faut : sur la machine a engendrer.
# CE QUE LA CONSOLE DOIT DEMANDER A L'EXPLOITANT. Une variable absente d'ici est refusee
# par la garde : la console ne saurait pas quoi afficher, et un champ libre sans invite
@ -206,7 +213,7 @@ runbooks:
# ─────────────────────────────────────────────────────────────────────────────
- id: locataire-naitre
titre: "Faire naitre un ecosysteme de locataire"
portee: poste
portee: tenant
doc: docs/multi-instances.md
but: >-
Du modele au plan monte : creer le dossier de l'ecosysteme, le rendre actif, puis
@ -340,7 +347,7 @@ runbooks:
# ─────────────────────────────────────────────────────────────────────────────
- id: locataire-deployer
titre: "Materialiser et deployer la flotte d'un locataire"
portee: poste
portee: tenant
doc: docs/vm-lifecycle.md
but: >-
Des VM au service rendu : creer les machines manquantes, deployer couche par couche,
@ -348,6 +355,7 @@ runbooks:
etapes:
- cible: flotte-creer
nature: ecriture
portee: poste
variables: [PARALLELE]
pourquoi: >-
Les VM manquantes, clonees depuis le gabarit dore. VMID, IP et VLAN sont DERIVES
@ -384,7 +392,7 @@ runbooks:
# ─────────────────────────────────────────────────────────────────────────────
- id: machine-une
titre: "Ajouter ou reprendre une seule machine"
portee: poste
portee: tenant
doc: docs/vm-lifecycle.md
but: >-
Le cycle d'UNE machine, du plan au service : la voir derivee, la creer, la deployer,
@ -399,6 +407,7 @@ runbooks:
qu'on va bien poser ce qu'on croit.
- cible: creer-vm
nature: ecriture
portee: poste
variables: [HOTE]
duree: "~4 min 30"
pourquoi: "Cloner depuis le gabarit et ATTENDRE que la machine reponde."
@ -412,6 +421,7 @@ runbooks:
pourquoi: "Le playbook de verification sur cette machine seule."
- cible: cloner-vm
nature: ecriture
portee: poste
variables: [HOTE, VMID]
facultative: true
pourquoi: >-
@ -421,7 +431,7 @@ runbooks:
# ─────────────────────────────────────────────────────────────────────────────
- id: flotte-refaire
titre: "Raser et reconstruire un ecosysteme"
portee: poste
portee: tenant
doc: docs/vm-lifecycle.md
but: >-
La preuve la plus dure du moteur : detruire un ecosysteme et le refaire depuis le
@ -429,6 +439,7 @@ runbooks:
etapes:
- cible: raser
nature: destructif
portee: poste
variables: [INSTANCE]
fixes: {CONFIRMER: "true"}
pourquoi: >-
@ -436,6 +447,7 @@ runbooks:
lettres : c'est le seul garde-fou qui resiste a un clic distrait.
- cible: reconstruire
nature: ecriture
portee: poste
duree: "long"
pourquoi: >-
Refaire tout depuis zero : les VM, puis le deploiement complet. Si le code ne

View file

@ -3811,6 +3811,8 @@ HTML = r"""<!doctype html>
// etape qui MESURE reste toujours offerte : mesurer apres un echec est exactement ce
// qu'on fait pour comprendre. Les etapes facultatives ne bloquent personne.
function etapeBloquee(rb, i) {
// Hors de portee de CETTE console : l'etape se lit, elle ne se lance pas.
if (rb.etapes[i].conduisible === false) return true;
if (rb.etapes[i].nature === 'mesure') return false;
for (let j = i - 1; j >= 0; j--) {
if (rb.etapes[j].facultative) continue;
@ -3840,7 +3842,7 @@ HTML = r"""<!doctype html>
if (rb) {
corps = rb.etapes.map((e, i) => {
const etat = rbEtat(rb.id, i);
const bloquee = etapeBloquee(rb, i) || !rb.conduisible;
const bloquee = etapeBloquee(rb, i);
const cls = etat === 'ok' ? ' faite' : etat === 'echec' ? ' ratee' : bloquee ? ' bloquee' : '';
const vars = (e.variables || []).map(v => v.nom + '=…').join(' ');
const fixes = Object.entries(e.fixes || {}).map(([k, v]) => k + '=' + v).join(' ');
@ -3856,6 +3858,8 @@ HTML = r"""<!doctype html>
+ (meta ? '<div class="rb-meta">' + echapper(meta) + '</div>' : '')
+ (etat === 'ok' ? '<div class="rb-meta">✔ réussie dans cette session</div>' : '')
+ (etat === 'echec' ? '<div class="rb-meta">✘ échouée — la suite reste bloquée</div>' : '')
+ (e.conduisible === false ? '<div class="rb-meta">⊘ hors de portée de cette console — '
+ echapper(e.raison || '') + '</div>' : '')
+ '</div>'
+ '<div><button type="button" class="' + (e.nature === 'destructif' ? '' : 'primaire')
+ '" ' + (bloquee ? 'disabled' : '') + ' onclick="lancerEtape(' + i + ')">'
@ -3881,8 +3885,14 @@ HTML = r"""<!doctype html>
+ '<div class="rb-but">' + echapper(rb.but) + '</div>'
+ '<div class="hint-dep" style="margin-top:10px">Portée&nbsp;: <code>' + echapper(rb.portee)
+ '</code> · ' + compte + '/' + rb.etapes.length + ' étape(s) réussie(s) dans cette session.</div>'
+ (rb.conduisible ? '' : '<div class="hint-dep" style="margin-top:10px;border-color:#8a3b3b">'
+ echapper(rb.raison) + '</div>')
+ (function () {
const fermees = rb.etapes.filter(e => e.conduisible === false);
if (!fermees.length) return '';
return '<div class="hint-dep" style="margin-top:10px;border-color:#8a3b3b">'
+ fermees.length + ' étape(s) sur ' + rb.etapes.length + ' hors de portée ici&nbsp;: '
+ echapper(fermees.map(e => e.cible).join(', ')) + '. ' + echapper(fermees[0].raison || '')
+ '</div>';
})()
+ (rb.doc ? '<div class="hint-dep" style="margin-top:10px">Le récit complet&nbsp;: <code>'
+ echapper(rb.doc) + '</code></div>' : '')
+ '<div class="hint-dep" style="margin-top:10px">Les libellés viennent du <code>Makefile</code>, '
@ -3943,7 +3953,7 @@ HTML = r"""<!doctype html>
async function lancerEtape(i) {
const rb = rbCourant(); if (!rb || occupe) return;
const e = rb.etapes[i];
if (!rb.conduisible) { message(rb.raison, 'erreur', {duree: 9000}); return; }
if (e.conduisible === false) { message(e.raison || rb.raison, 'erreur', {duree: 9000}); return; }
if (etapeBloquee(rb, i)) { message('L\'étape précédente n\'a pas réussi.', 'erreur'); return; }
const rep0 = await ouvrirModaleEtape(rb, e, i);
if (rep0 === null) return;
@ -4463,19 +4473,33 @@ 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."))
liste = []
for rb in _rb.assembler(RACINE):
exiges = POUVOIR_PAR_PORTEE.get(rb["portee"], ())
manquants = [p for p in exiges if not ctx["peut"].get(p)]
# CHAQUE ETAPE SE PESE SEULE (2026-09-20, mesure sur TechnoLibre).
# Juge au seul runbook, une unique etape qui materialise fermait
# toute la sequence a un locataire — qui perdait le droit de
# deployer SA flotte. Il la conduit donc, et bute precisement la
# ou il faut : sur la machine a engendrer.
etapes = []
for etape in rb["etapes"]:
ok, pourquoi = _juger(etape["portee"])
etapes.append({**etape, "conduisible": ok, "raison": pourquoi})
ok_rb, pourquoi_rb = _juger(rb["portee"])
liste.append({
**rb,
"etapes": etapes,
# ON NE CACHE PAS CE QU'ON NE PEUT PAS FAIRE. Un runbook hors
# de portee reste VISIBLE et se lit : savoir que le geste
# existe, et chez qui il se fait, fait partie du metier.
"conduisible": not manquants,
"raison": ("" if not manquants else
ctx["raison"].get(manquants[0], "Hors de portee "
"de cette console.")),
"conduisible": any(e["conduisible"] for e in etapes),
"raison": pourquoi_rb if not ok_rb else "",
})
self.repondre_json(200, {"runbooks": liste, "contexte": ctx,
"ecarts": _rb.verifier(RACINE)})
@ -4520,15 +4544,23 @@ class Gestionnaire(BaseHTTPRequestHandler):
try:
import runbooks as _rb
vise = str(donnees.get("runbook", ""))
portee_rb = next((r["portee"] for r in _rb.assembler(RACINE)
if r["id"] == vise), None)
_i = donnees.get("index")
_etape = _rb.etape_declaree(vise, str(donnees.get("cible", "")), RACINE,
_i if isinstance(_i, int) else None)
# La portee de L'ETAPE. Celle du runbook n'est que son defaut, et juger
# sur elle fermerait a un locataire une sequence qui est la sienne.
portee_etape = _etape["portee"] if _etape else None
connu = any(r["id"] == vise for r in _rb.assembler(RACINE))
except Exception: # noqa: BLE001
portee_rb = None
if portee_rb is None:
portee_etape, connu = None, False
if not connu:
self.repondre_json(409, {"erreur": f"Runbook inconnu : « {donnees.get('runbook', '')} »."})
return
manquants = [p for p in POUVOIR_PAR_PORTEE.get(portee_rb, ())
if not ctx["peut"].get(p)]
# 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."),

View file

@ -102,6 +102,12 @@ def assembler(racine: Path = RACINE, chemin: Path | None = None) -> list[dict]:
"cible": cible,
# LE LIBELLE N'EST PAS DANS LE REGISTRE. Il est lu ici, chaque fois.
"libelle": aides.get(cible, ""),
# LA PORTEE SE PESE A L'ETAPE, PAS AU RUNBOOK (2026-09-20, mesure sur la
# console de TechnoLibre). Une seule etape qui materialise faisait basculer
# toute la sequence en `poste`, et un locataire ne pouvait plus deployer
# SA PROPRE flotte — exactement son metier. Le runbook donne le defaut ;
# l'etape qui exige davantage le declare.
"portee": etape.get("portee", rb.get("portee", "toute")),
"nature": etape.get("nature", "mesure"),
"pourquoi": etape.get("pourquoi", ""),
"duree": etape.get("duree", ""),
@ -144,10 +150,12 @@ def etape_declaree(runbook_id: str, cible: str, racine: Path = RACINE,
if index is not None:
if not (0 <= index < len(etapes)) or etapes[index]["cible"] != cible:
return None
return {**etapes[index], "portee": rb["portee"], "runbook": rb["titre"]}
return {**etapes[index], "runbook": rb["titre"],
"portee_runbook": rb["portee"]}
for etape in etapes:
if etape["cible"] == cible:
return {**etape, "portee": rb["portee"], "runbook": rb["titre"]}
return {**etape, "runbook": rb["titre"],
"portee_runbook": rb["portee"]}
return None
@ -184,6 +192,9 @@ def verifier(racine: Path = RACINE, chemin: Path | None = None) -> list[str]:
f"(registre en retard, ou cible renommee).")
vues.add(cible)
portees_vues.setdefault(cible, set()).add(rb.get("portee", ""))
if "portee" in etape and etape["portee"] not in PORTEES:
ecarts.append(f"{rid} → {cible} : portee d'etape « {etape['portee']} » hors "
f"vocabulaire ({', '.join(sorted(PORTEES))}).")
if etape.get("nature") not in NATURES:
ecarts.append(f"{rid} → {cible} : nature « {etape.get('nature')} » hors "
f"vocabulaire ({', '.join(sorted(NATURES))}).")
@ -237,7 +248,8 @@ def main() -> int:
for i, e in enumerate(rb["etapes"], 1):
marque = {"mesure": " ", "ecriture": "*", "destructif": "!"}[e["nature"]]
var = (" " + " ".join(f"{v['nom']}=…" for v in e["variables"])) if e["variables"] else ""
print(f" {marque} {i:2}. make {e['cible']}{var}")
sup = "" if e["portee"] == rb["portee"] else f" [{e['portee']}]"
print(f" {marque} {i:2}. make {e['cible']}{var}{sup}")
print(f" {e['libelle']}")
return 0

View file

@ -105,6 +105,47 @@ class TestGardeDesRunbooks(unittest.TestCase):
self.assertTrue(any("n'est pas au catalogue" in e for e in ecarts), ecarts)
class TestPorteeParEtape(unittest.TestCase):
"""La portee se pese a l'etape — mesure faite sur la console de TechnoLibre.
Declaree au seul runbook, une unique etape qui materialise fermait TOUTE la sequence
a un locataire : il ne pouvait plus deployer sa propre flotte, ce qui est exactement
son metier. Ces tests refusent le retour de ce defaut.
"""
def test_chaque_etape_porte_une_portee_connue(self):
for rbk in rb.assembler(RACINE):
for etape in rbk["etapes"]:
self.assertIn(etape["portee"], rb.PORTEES,
f"{rbk['id']} → {etape['cible']}")
def test_une_etape_muette_herite_de_son_runbook(self):
for rbk in rb.assembler(RACINE):
brut = next(r for r in rb.charger()["runbooks"] if r["id"] == rbk["id"])
for etape, cru in zip(rbk["etapes"], brut["etapes"]):
if "portee" not in cru:
self.assertEqual(etape["portee"], rbk["portee"],
f"{rbk['id']} → {etape['cible']}")
def test_un_locataire_conduit_le_deploiement_de_sa_flotte(self):
"""Le defaut exact, nomme : `deployer-tout` doit rester a portee d'un locataire."""
rbk = next(r for r in rb.assembler(RACINE) if r["id"] == "locataire-deployer")
self.assertEqual(rbk["portee"], "tenant")
par_cible = {e["cible"]: e["portee"] for e in rbk["etapes"]}
self.assertEqual(par_cible["deployer-tout"], "tenant")
self.assertEqual(par_cible["valider"], "tenant")
# Et il bute precisement la ou il faut : sur la machine a engendrer.
self.assertEqual(par_cible["flotte-creer"], "poste")
def test_engendrer_une_machine_exige_la_fabric(self):
rbk = next(r for r in rb.assembler(RACINE) if r["id"] == "machine-une")
par_cible = {e["cible"]: e["portee"] for e in rbk["etapes"]}
for cible in ("creer-vm", "cloner-vm"):
self.assertEqual(par_cible[cible], "poste", cible)
for cible in ("deployer", "verifier-hote", "hote-afficher"):
self.assertEqual(par_cible[cible], "tenant", cible)
class TestEtapeDeclaree(unittest.TestCase):
"""Le point de controle du serveur : une PLACE, pas un nom."""