la console dit sa portee : un site n affichait aucune de ses machines

Servie par le runner d un SITE, la console montrait zero serveur sans une erreur :
serveur_ops retire le lien instance sur un hebergeur, charger_yaml rend un
inventaire vide sur un fichier absent, et la page dessinait ce vide comme un plan
vide. La portee se DERIVE des deux symlinks (instance = je configure, underlay =
je materialise), jamais d un reglage declare. Une console de site sert desormais
son inventaire dynamique ; POUVOIR_REQUIS exige un pouvoir pour chaque route POST
et le refus dit pourquoi ; la page nomme la console. P81 refuse une portee sans
source d inventaire, un site sans machines, et toute route qui echapperait a la
table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-16 14:36:59 -04:00
parent a5152a5162
commit e46fa198fd
7 changed files with 366 additions and 7 deletions

View file

@ -188,7 +188,7 @@ Si `ansible-lint` n’est pas disponible, le signaler clairement. Ne pas invente
## Écrire, puis relire (D-68)
`--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**.
C'est aussi ce que font les 80 preuves de `make prouver` : elles lisent le dépôt, sans le
C'est aussi ce que font les 81 preuves de `make prouver` : elles lisent le dépôt, sans le
moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le
dépôt annonce.**

View file

@ -1,5 +1,60 @@
# CHANGELOG — Set-OPS
## 2026-09-16 (3) — La console affichait un ecosysteme vide, et c'etait le site
Servie par le runner d'un SITE, la console d'exploitation montrait **zero serveur, zero
application, zero base** — sans une erreur, sans un avertissement. Le site a sept
machines.
Trois faits corrects, mis bout a bout, produisaient un mensonge :
serveur_ops RETIRE le lien `instance` sur un runner d'hebergeur
(juste : un lien perime vers un tenant serait pire)
charger_yaml rend {"all": {"children": {}}} sur un fichier absent
(juste : une console sans plan ne doit pas tomber)
la page dessine ce vide comme un plan vide
(faux : l'inventaire d'un site est un SCRIPT, pas un hosts.yml)
C'est la forme exacte que **P79** garde partout ailleurs — *une derivation qui ne trouve
rien ne se distingue pas d'une derivation qui n'a rien a trouver* — sous son pire visage :
celui d'un ecosysteme qu'on croit sans machines.
### La portee se DERIVE, elle ne se declare pas
Un `serveur_ops_gui_mode: site|tenant` au plan aurait ete une SECONDE liste, qui prend du
retard des qu'on reconfigure un runner sans y penser. Les deux symlinks, eux, SONT le
pouvoir — et `roles/serveur_ops` exige deja de savoir lequel il est :
instance/ -> je configure CET ecosysteme (j'ai sa voute)
underlay.yml -> je materialise sur CETTE fabric (j'ai celle du site)
les deux -> poste du mainteneur instance seul -> console de locataire
underlay seul -> console de SITE aucun -> rien a piloter, et elle le dit
Meme coupure que les trois portees de `docs/responsabilites-locataire-hebergeur.md` :
calculer, configurer, materialiser.
### Ce que ca change
Une console de SITE sert desormais l'inventaire **dynamique** de ses machines
(`site_inventaire.inventaire()`, importe — pas un sous-processus par page). Ses registres
de plan partent vides **avec leur raison** : on ne fabrique pas un faux plan de tenant
pour remplir un ecran.
`POUVOIR_REQUIS` exige un pouvoir pour CHAQUE route POST, et le refus **dit pourquoi** :
« interdit » sans raison envoie chercher une panne la ou il n'y en a pas. La garde est au
serveur ; le bouton grise n'est qu'une politesse.
Et la page **nomme** la console. Trois consoles identiques a trois URL differentes etaient
un piege pour qui en ouvre deux.
### P81
Elle refuse une portee sans source d'inventaire, un site dont l'inventaire ne rend aucune
machine, et **toute route POST absente de la table** — c'est la garde anti-derive : une
route d'ecriture ajoutee demain s'executerait sinon sur une console qui n'en a pas le
pouvoir, et personne ne le verrait avant l'incident.
## 2026-09-16 (2) — Les responsabilites, deduites des pouvoirs
Un partage de responsabilites se redige d'habitude en distribuant des devoirs. Celui-ci

View file

@ -7,7 +7,7 @@
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (79 OK · 0 echec · 1 saute)
- **Verdict** : ✅ CONFORME (80 OK · 0 echec · 1 saute)
## Preuves
@ -69,7 +69,7 @@
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. |
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (80 preuves, 68 roles, 41 groupes). |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (81 preuves, 68 roles, 41 groupes). |
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `392da4d` (publie le 2026-09-14). |
@ -93,6 +93,7 @@
| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). |
| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 6 ecosysteme(s) (instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0, SITE-Chezlepro) : pattes, edges, certificats, rechargemen |
| P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. |
| P81 | La console dit sa portee, et ne sert pas un inventaire vide en silence | — | ✅ OK | Portee `poste` derivee des symlinks, source d'inventaire `instance`, et 14 route(s) POST exigent toutes un pouvoir. |
## Couverture des affirmations ✅ du registre

View file

@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met
## Le trou qu'il comble
`scripts/prouver.py` porte 80 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
`scripts/prouver.py` porte 81 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est
cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
propriétaire, que rien n'est codé en dur.

View file

@ -99,7 +99,7 @@ Nommer un point ouvert vaut mieux qu'une ligne rassurante sans mécanisme derri
Aucune de ces responsabilités n'est laissée à la parole :
```
make prouver le dépôt est-il cohérent avec lui-même (80 preuves, zéro réseau)
make prouver le dépôt est-il cohérent avec lui-même (81 preuves, zéro réseau)
make remise-verifier le second temps de la remise est-il fait, ou en retard ? (P80)
make certificats-plan ce que le disque porte contre ce que la mémoire sert
make expositions-plan chaque service publié répond-il, et depuis où

View file

@ -110,6 +110,171 @@ def _depot_hebergeur() -> Path | None:
return FICHIER_UNDERLAY.resolve().parent
# --- CE QUE CETTE CONSOLE PEUT, DERIVE DE CE QUE SA MACHINE PORTE --------------------
#
# LE DEFAUT QUI A RENDU CECI NECESSAIRE (2026-09-16). Servie par le runner d'un SITE,
# cette console affichait ZERO serveur, ZERO application, ZERO base — sans une erreur.
# `serveur_ops` RETIRE le lien `instance` sur un runner d'hebergeur (a juste titre : un
# lien perime vers un tenant serait pire), `charger_yaml` rend `{"all": {"children": {}}}`
# sur un fichier absent, et la page dessinait ce vide comme un plan vide.
#
# C'est la forme exacte que P79 garde partout ailleurs : une derivation qui ne trouve rien
# ne se distingue pas d'une derivation qui n'a rien a trouver. Ici elle avait le pire
# visage possible — celui d'un ecosysteme qu'on croit sans machines.
#
# ON DERIVE, ON NE DECLARE PAS. Un `serveur_ops_gui_mode: site|tenant` au plan serait une
# SECONDE liste, et une liste qui suit une autre prend du retard : il suffirait de
# reconfigurer un runner sans y penser. Les deux symlinks, eux, SONT le pouvoir :
#
# instance/ -> je configure CET ecosysteme (j'ai sa voute)
# underlay.yml -> je materialise sur CETTE fabric (j'ai celle du site)
#
# C'est la meme coupure que `roles/serveur_ops` exige deja dans son assertion, et que
# `docs/responsabilites-locataire-hebergeur.md` nomme : calculer, configurer, materialiser.
def contexte() -> dict:
"""La portee de cette console, et la raison de chaque interdit.
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
"""
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
portee = ("poste" if a_instance and a_fabric else
"tenant" if a_instance else
"site" if a_fabric else "orphelin")
peut = {
# Configurer et editer le plan supposent un ecosysteme monte ET sa voute.
"configurer": a_instance,
"editer_le_plan": a_instance,
# Materialiser suppose de savoir sur quelle fabric poser la VM.
"materialiser": a_fabric,
# 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.
"fabric": a_fabric,
}
raison = {
"configurer": ("Cette console materialise le terrain ; elle n'a la voute d'aucun "
"locataire, donc elle ne configure aucun de leurs services."),
"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."),
"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),
}
def inventaire_du_site() -> dict:
"""Les machines du SITE, mises dans la forme d'un `hosts.yml`.
L'inventaire d'un site est un SCRIPT (`site_inventaire.py`) : on l'EXECUTE — en
l'important, sans sous-processus a chaque page — et l'on traduit son resultat dans la
forme que le reste de ce fichier sait lire. Juger sa source reviendrait a relire le
raisonnement au lieu du resultat.
"""
import site_inventaire
brut = site_inventaire.inventaire() or {}
hostvars = ((brut.get("_meta") or {}).get("hostvars") or {})
enfants_site: dict = {}
for groupe, valeur in brut.items():
if groupe == "_meta" or not isinstance(valeur, dict):
continue
hotes = valeur.get("hosts") or []
if not hotes:
continue
enfants_site[groupe] = {"hosts": {h: dict(hostvars.get(h) or {}) for h in hotes}}
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": {},
"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)]
with ThreadPoolExecutor(max_workers=16) as executeur:
resultats = executeur.map(lambda c: (c[0], _joignable(c[1])), cibles)
return {nom: ok for nom, ok in resultats if nom}
# CE QUE CHAQUE POINT D'ECRITURE EXIGE. Toute route POST y figure — P81 refuse qu'une
# route nouvelle echappe a la garde en silence, parce qu'une garde qui suit une liste
# prend du retard sur elle.
POUVOIR_REQUIS = {
"/api/inventaire": "editer_le_plan",
"/api/bases": "editer_le_plan",
"/api/applications": "editer_le_plan",
"/api/serveurs": "editer_le_plan",
"/api/nomenclature": "editer_le_plan",
"/api/domaines": "editer_le_plan",
"/api/intrants": "editer_le_plan",
"/api/instancier": "editer_le_plan",
"/api/instance-creer": "editer_le_plan",
"/api/instance-utiliser": "editer_le_plan",
"/api/verifier": "configurer",
"/api/deployer": "configurer",
"/api/pousser": "configurer",
"/api/creer": "materialiser",
}
def _fichier_frontiere() -> Path:
"""Les intrants de la frontiere — a la RACINE du depot de site depuis le 2026-08-22.
@ -1569,6 +1734,8 @@ HTML = r"""<!doctype html>
<header>
<div class="marque"><span class="pastille"></span><div class="titre"><h1>Set-OPS</h1><span class="slogan">Votre artisan numérique</span></div></div>
<span class="chemin" id="chemin"></span>
<span id="portee" style="font-size:11.5px;font-weight:800;letter-spacing:.02em;padding:3px 9px;border-radius:999px;white-space:nowrap"
title="Ce que cette console peut faire — dérivé de ce que sa machine porte, pas d'un réglage"></span>
<div class="grandit"></div>
<div class="resume" id="resume"></div>
<div class="barre" title="Raccourcis clavier — 1-3 : changer de vue · j/k : naviguer les serveurs · v : vérifier · d : déployer · / : filtrer · Ctrl+S : sauvegarder la vue">
@ -1717,9 +1884,48 @@ HTML = r"""<!doctype html>
setTimeout(() => { if (t.parentNode) t.parentNode.removeChild(t); }, 260);
}
let contexte = null;
// LA CONSOLE DIT CE QU'ELLE EST. Trois consoles identiques à trois URL différentes
// sont un piège pour qui en ouvre deux : celle d'un site et celle d'un locataire ne
// peuvent pas les mêmes gestes, et rien ne le disait.
function appliquerContexte(ctx) {
contexte = ctx || null;
const badge = document.getElementById('portee');
if (!badge || !ctx) return;
const couleurs = {
site: ['#7c4a02', 'color-mix(in srgb, #b9860b 18%, var(--surface))'],
tenant: ['#0d3b34', 'color-mix(in srgb, var(--teal) 16%, var(--surface))'],
poste: ['var(--muted)', 'var(--surface-3)'],
orphelin: ['var(--rouge)', 'color-mix(in srgb, var(--rouge) 14%, var(--surface))'],
};
const [fg, bg] = couleurs[ctx.portee] || couleurs.poste;
badge.textContent = ctx.titre;
badge.style.color = fg;
badge.style.background = bg;
document.body.dataset.portee = ctx.portee;
// Ce que la portée interdit ne se propose pas. Le serveur refuse de toute façon
// (POUVOIR_REQUIS) ; ceci évite d'aller au refus pour l'apprendre.
const verrou = (el, permis, pourquoi) => {
if (!el) return;
el.disabled = !permis;
el.title = permis ? (el.title || '') : pourquoi;
el.style.opacity = permis ? '' : '.45';
};
verrou(document.getElementById('btn-sauver'), ctx.peut.editer_le_plan,
ctx.raison.editer_le_plan);
document.querySelectorAll('button[onclick^="ouvrirIntrants"]').forEach(
b => verrou(b, ctx.peut.editer_le_plan, ctx.raison.editer_le_plan));
}
async function charger() {
try {
const ctxRep = await fetch('/api/contexte');
if (ctxRep.ok) appliquerContexte(await ctxRep.json());
} catch (e) { /* une console sans contexte reste utilisable, en aveugle */ }
const reponse = await fetch('/api/inventaire');
const data = await reponse.json();
if (data.contexte) appliquerContexte(data.contexte);
groupes = data.groupes;
dependances = data.dependances || {};
nomenclature = data.nomenclature || {};
@ -3779,10 +3985,20 @@ class Gestionnaire(BaseHTTPRequestHandler):
self.repondre(200, page.encode("utf-8"), "text/html; charset=utf-8")
elif chemin == "/favicon.ico":
self.repondre(204, b"", "image/x-icon")
elif chemin == "/api/contexte":
self.repondre_json(200, contexte())
elif chemin == "/api/inventaire":
self.repondre_json(200, inventaire_api(self.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})
elif chemin == "/api/sondes":
self.repondre_json(200, sonder_hotes(self.inventaire))
if contexte()["inventaire"] == "site":
self.repondre_json(200, sonder_donnees(inventaire_du_site()))
else:
self.repondre_json(200, sonder_hotes(self.inventaire))
elif chemin == "/api/devis-reseau":
tenants = devis_reseau.decouvrir()
self.repondre_json(200, {
@ -3827,6 +4043,16 @@ class Gestionnaire(BaseHTTPRequestHandler):
except json.JSONDecodeError:
self.repondre_json(400, {"erreur": "Corps JSON invalide."})
return
# LA GARDE EST ICI, PAS DANS LE NAVIGATEUR. Un bouton grise est une politesse ;
# ce qui protege, c'est un serveur qui refuse. Et il DIT pourquoi : « interdit »
# sans raison envoie chercher une panne la ou il n'y en a pas.
ctx = contexte()
besoin = POUVOIR_REQUIS.get(chemin)
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."),
"portee": ctx["portee"], "pouvoir": besoin})
return
try:
if chemin == "/api/inventaire":
self.repondre_json(409, {"erreur": "hosts.yml est genere depuis le plan. "

View file

@ -4310,6 +4310,81 @@ def preuve_remise_tenue() -> tuple[bool, str]:
return True, f"{len(tenus)} remise(s) tenue(s) : {', '.join(tenus)}."
def preuve_console_dit_sa_portee() -> tuple[bool, str]:
"""Une console ne sert jamais un inventaire vide en silence, et rien n'echappe a sa garde.
POURQUOI (2026-09-16). Servie par le runner d'un SITE, la console d'exploitation
affichait ZERO serveur, ZERO application, ZERO base — sans une erreur. `serveur_ops`
retire le lien `instance` sur un runner d'hebergeur (a juste titre), `charger_yaml`
rend un inventaire vide sur un fichier absent, et la page dessinait ce vide comme un
plan vide. Le site a sept machines ; sa console n'en montrait aucune.
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.
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
defaut d'origine, et il ne doit pas pouvoir revenir.
4. TOUTE route POST figure dans `POUVOIR_REQUIS`. C'est la garde anti-derive : une
route d'ecriture ajoutee demain sans y penser s'executerait sur une console qui
n'en a pas le pouvoir, et personne ne le verrait avant l'incident.
CE QU'ELLE NE PROUVE PAS : qu'une console REFUSE vraiment. Cela demande de la lancer et
de lui parler ; ici on verifie qu'elle a de quoi refuser, et que rien n'y echappe.
"""
sys.path.insert(0, str(RACINE / "scripts"))
import inventory_gui as gui
fautes: list[str] = []
ctx = gui.contexte()
if ctx["portee"] not in ("poste", "tenant", "site", "orphelin"):
fautes.append(f"portee inconnue : {ctx['portee']}")
if ctx["portee"] != "orphelin" and not ctx.get("inventaire"):
fautes.append(f"portee `{ctx['portee']}` sans source d'inventaire nommee — c'est "
f"exactement le vide silencieux que cette preuve existe pour refuser")
# La source du site, verifiee CHEZ le site, quand un underlay est monte.
if gui.FICHIER_UNDERLAY.exists():
try:
data = gui.inventaire_du_site()
hotes = {h for g in (data.get("all") or {}).get("children", {}).values()
for h in (g.get("hosts") or {})}
except Exception as e: # noqa: BLE001
hotes = set()
fautes.append(f"l'inventaire du site ne s'execute pas ({type(e).__name__}) : "
f"une console de site n'aurait rien a montrer")
if not hotes:
fautes.append("un underlay est monte et l'inventaire du site ne rend AUCUNE "
"machine — la console de site afficherait un ecran vide")
# Toute route POST doit exiger un pouvoir. On lit la SOURCE, pas la table : une table
# qui se decrirait elle-meme ne prouverait rien.
source = (RACINE / "scripts" / "inventory_gui.py").read_text(encoding="utf-8")
debut = source.find("def do_POST")
routes = set(re.findall(r'chemin == "(/api/[a-z-]+)"', source[debut:])) if debut >= 0 else set()
if not routes:
fautes.append("aucune route POST trouvee dans le GUI : la lecture de la source a "
"change de forme, et cette preuve ne prouve plus rien")
manquantes = sorted(routes - set(gui.POUVOIR_REQUIS))
if manquantes:
fautes.append(f"route(s) POST sans pouvoir exige : {', '.join(manquantes)} — elles "
f"s'executeraient sur une console qui n'en a pas le droit")
inutiles = sorted(set(gui.POUVOIR_REQUIS) - routes)
if inutiles:
fautes.append(f"`POUVOIR_REQUIS` nomme des routes qui n'existent plus : "
f"{', '.join(inutiles)}")
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 "
f"`{ctx['inventaire']}`, et {len(routes)} route(s) POST exigent toutes "
f"un pouvoir.")
PREUVES: list[dict] = [
{"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"],
"cmds": [["ansible-lint", "-q"]]},
@ -4490,6 +4565,8 @@ PREUVES: list[dict] = [
"refs": [], "func": preuve_une_derivation_vide_se_dit},
{"id": "P80", "titre": "Remise au client : inscrite, nommee, et son second temps a l'heure",
"refs": [], "func": preuve_remise_tenue},
{"id": "P81", "titre": "La console dit sa portee, et ne sert pas un inventaire vide en silence",
"refs": [], "func": preuve_console_dit_sa_portee},
]