diff --git a/AGENTS.md b/AGENTS.md index 8d1d306..7a7d6d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.** diff --git a/CHANGELOG.md b/CHANGELOG.md index b52111d..6b25360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/audit/preuve-2026-09-16.md b/docs/audit/preuve-2026-09-16.md index a36680b..b59e8f6 100644 --- a/docs/audit/preuve-2026-09-16.md +++ b/docs/audit/preuve-2026-09-16.md @@ -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 diff --git a/docs/devis-services.md b/docs/devis-services.md index 5d79071..d9903a4 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -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. diff --git a/docs/responsabilites-locataire-hebergeur.md b/docs/responsabilites-locataire-hebergeur.md index 3b71b32..bb45c8d 100644 --- a/docs/responsabilites-locataire-hebergeur.md +++ b/docs/responsabilites-locataire-hebergeur.md @@ -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ù diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 477dfcc..b17506f 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -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"""

Set-OPS

Votre artisan numérique
+
@@ -1717,9 +1884,48 @@ HTML = r""" 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. " diff --git a/scripts/prouver.py b/scripts/prouver.py index 640fe0e..d95a025 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -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}, ]