Compare commits

..

No commits in common. "34b7ec3886e05b3e0151097b2d27b50ab58fdabe" and "6077b179afd92fe3cabc2a4a51677e5b255e9ed9" have entirely different histories.

13 changed files with 39 additions and 979 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 61 preuves de `make prouver` : elles lisent le dépôt, sans le
C'est aussi ce que font les 60 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,50 +1,5 @@
# CHANGELOG — Set-OPS
## 2026-09-08 — La forme des registres cesse d'etre recopiee : elle est derivee
**61 preuves (P01-P61, dont une conditionnelle). `make prouver` : CONFORME, 60 OK, 0 echec,
1 saute.**
### Le probleme
La forme du plan etait ecrite TROIS FOIS : dans les constantes du moteur (`ETATS_SERVEUR`,
`PORTEES_BD`, `AUTORITES_DNS`), dans les formulaires du GUI, et dans la documentation. Rien
ne les tenait ensemble. Ajouter une portee de base de donnees demandait trois gestes, et le
troisieme s'oubliait sans qu'aucun controle ne s'en apercoive.
### Ce qui change
`scripts/schema_plan.py` (nouveau) derive un JSON Schema des SIX registres du plan
— `serveurs`, `applications`, `bases_donnees`, `serveurs_bd`, `domaines_publics`,
`nomenclature` — en IMPORTANT les enumerations du moteur plutot qu'en les recopiant. Le
resultat est versionne dans `docs/audit/schema-plan.json` : 42 champs, regenerable par
`make schema`. Il est versionne, et non recalcule a chaud, pour qu'une divergence se voie
dans un diff.
Le schema porte ce que JSON Schema seul ne dit pas : `x-source-valeurs` (liste fermee
alimentee a l'execution depuis l'inventaire), `x-source-selon` (liste dependant d'un autre
champ), `x-clef` (l'attribut qui identifie l'entite), `x-requis`.
**Le formulaire des bases de donnees du GUI n'est plus ecrit a la main.** Il est construit
au chargement depuis le schema servi par `/api/inventaire`. Le chemin de SAUVEGARDE aussi
derive du schema : les champs ecrits sont ceux que le schema declare, avec ses valeurs par
defaut — plus une liste de champs recopiee dans le JS.
`P61` garde l'ensemble : le schema doit couvrir tout ce que les plans REELS contiennent.
Son controle negatif : retirer un champ du schema le fait echouer.
### La limite, dite franchement
**UN registre sur six est genere.** Les cinq autres formulaires restent ecrits a la main.
Et le trou connu reste ouvert : `couverture_gui.py verifier` echoue toujours sur
`nomenclature.categorie` et `nomenclature.service` — deux champs presents dans les plans
reels que le GUI ne sait pas ecrire. Le schema les DECRIT deja ; c'est le passage de la vue
nomenclature au generateur qui fermera le trou, par construction.
Enfin : le formulaire genere a ete eprouve en rendant son HTML avec la vraie reponse de
l'API, pas dans un navigateur.
## 2026-09-06 — Tournee des 74 documents : ce que le depot disait de lui-meme avait vieilli
**57 preuves (P01-P57, dont une conditionnelle). `make prouver` : CONFORME, 56 OK, 0 echec,

View file

@ -797,10 +797,6 @@ flux-verifier: ## Verifie que le registre des flux correspond aux meta/flux.yml
valider: ansible-runtime ## Passe la recette de validation sur la flotte
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/valider.yml
.PHONY: schema
schema: ## Regenere docs/audit/schema-plan.json depuis les registres et les validateurs
python3 scripts/schema_plan.py
.PHONY: wiki-publier
wiki-publier: ## Publie le wiki (wiki/) vers la forge
@set -e; \

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 (60 OK · 0 echec · 1 saute)
- **Verdict** : ✅ CONFORME (59 OK · 0 echec · 1 saute)
## Preuves
@ -43,7 +43,7 @@
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 34 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 32 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 21, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 58 scripts expliques et atteignables, 115 cibles make documentees, 65 roles avec README. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 57 scripts expliques et atteignables, 114 cibles make documentees, 65 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (131 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (37 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 43 document(s) declarent leur lecteur (34 genere(s) exempte(s)). |
@ -53,14 +53,14 @@
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 39 role(s) serveur/client tous nommes, 40 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. |
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 54 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 53 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 104 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 89 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 87 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (117 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 169 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
@ -69,11 +69,10 @@
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 10 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 14 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 14. |
| 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 (61 preuves, 65 roles, 40 groupes). |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (60 preuves, 65 roles, 40 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 `0eaceb1` (publie le 2026-09-08). |
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 42 champ(s) sur 6 registres, et couvre tout ce que les plans reels contiennent ; applications:1, domaines_publics:1, nomenclature:9, serveurs:3 |
| 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 `e2935ed` (publie le 2026-09-08). |
## Couverture des affirmations ✅ du registre

View file

@ -1,381 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "GENERE par scripts/schema_plan.py (make schema). Ne pas editer a la main. Decrit la FORME des registres ; la COHERENCE reste aux validateurs de inventory_rules.py.",
"registres": {
"applications": {
"entite": {
"additionalProperties": false,
"properties": {
"expose": {
"description": "FQDN publies par l'edge. Derivent le vhost, les SAN et le plancher /etc/hosts.",
"items": {
"type": "string"
},
"title": "Exposition publique",
"type": "array"
},
"groupe": {
"description": "La capacite appliquee. Doit avoir un playbook homonyme.",
"title": "Groupe (rôle)",
"type": "string",
"x-source-valeurs": "groupes_operationnels"
},
"hote": {
"description": "Une VM declaree au plan. Un hote inconnu est un « hote fantome » (P06).",
"title": "Hôte",
"type": "string",
"x-source-valeurs": "serveurs"
},
"liens": {
"description": "Roles acceptes par le role porteur (meta/liens.yml).",
"items": {
"type": "object"
},
"title": "Liens (bindings)",
"type": "array"
},
"port": {
"title": "Port",
"type": "integer"
},
"requiert": {
"description": "Dependance applicative. Indicative : l'ordre de deploiement vient des couches.",
"items": {
"type": "string"
},
"title": "Requiert",
"type": "array"
},
"websocket": {
"description": "L'edge doit relayer la mise a niveau de connexion.",
"title": "WebSocket",
"type": "boolean"
}
},
"required": [
"groupe",
"hote"
],
"type": "object"
},
"title": "Applications",
"x-clef": {
"description": "Nom de l'application dans le plan.",
"title": "Identifiant"
},
"x-fichier": "plan/applications.yml",
"x-racine": "applications"
},
"bases_donnees": {
"entite": {
"additionalProperties": false,
"properties": {
"base": {
"title": "Base",
"type": "string"
},
"consommateur": {
"description": "Qui utilise cette base. La liste depend de la portee.",
"title": "Consommateur",
"type": "string",
"x-source-selon": {
"cas": {
"application": "applications",
"hote": "serveurs"
},
"champ": "portee",
"defaut": "groupes_operationnels"
}
},
"portee": {
"default": "groupe",
"description": "Comment le consommateur est designe : par application, par groupe ou par hote.",
"enum": [
"application",
"groupe",
"hote"
],
"title": "Portée",
"type": "string"
},
"proprietaire": {
"title": "Propriétaire",
"type": "string"
},
"secret": {
"description": "NOM d'une variable Vault, jamais une valeur. Le secret ne quitte pas le role.",
"title": "Secret (Vault)",
"type": "string"
},
"serveur": {
"title": "Serveur de BD",
"type": "string",
"x-source-valeurs": "serveurs_bd"
},
"usage": {
"default": "principale",
"title": "Usage",
"type": "string"
}
},
"required": [
"base",
"consommateur",
"proprietaire",
"secret",
"serveur"
],
"type": "object"
},
"title": "Bases de donnees",
"x-clef": {
"description": "Nom de l'entree au registre, ex. `bd_forgejo`.",
"title": "Identifiant"
},
"x-fichier": "plan/bases-donnees.yml",
"x-racine": "bases_donnees"
},
"domaines_publics": {
"entite": {
"additionalProperties": false,
"properties": {
"autorite": {
"enum": [
"auto-heberge",
"delegue",
"primaire-cache"
],
"title": "Autorité DNS",
"type": "string"
},
"dnssec": {
"title": "DNSSEC",
"type": "boolean"
},
"edge": {
"title": "Edge",
"type": "string",
"x-source-valeurs": "serveurs"
},
"mail": {
"description": "La zone porte des enregistrements de messagerie.",
"title": "Courriel",
"type": "boolean"
},
"secondaires": {
"items": {
"type": "string"
},
"title": "Secondaires",
"type": "array"
}
},
"type": "object"
},
"title": "Domaines publics",
"x-clef": {
"description": "Le nom public, ex. `chezlepro.ca`.",
"title": "Domaine"
},
"x-fichier": "plan/domaines.yml",
"x-racine": "domaines_publics"
},
"nomenclature": {
"entite": {
"additionalProperties": false,
"properties": {
"categories": {
"additionalProperties": {
"additionalProperties": false,
"properties": {
"libelle": {
"description": "Nom lisible de la zone (Frontiere, Identite...).",
"title": "Libellé",
"type": "string"
}
},
"required": [
"libelle"
],
"type": "object"
},
"description": "Une zone de securite par cle. Le numero derive le 3e octet et le VLAN.",
"title": "Catégories (zones)",
"type": "object"
},
"cidr_hote": {
"title": "CIDR d'hote",
"type": "integer"
},
"fonctions": {
"additionalProperties": {
"additionalProperties": false,
"properties": {
"categorie": {
"description": "La zone. Fixe le 3e octet (15 + categorie) et le VLAN.",
"title": "Catégorie",
"type": "integer"
},
"service": {
"description": "Fixe le bloc d'adresses de l'hote dans la zone.",
"title": "Service",
"type": "integer"
}
},
"required": [
"categorie",
"service"
],
"type": "object"
},
"description": "categorie + service par fonction. VMID et IP en derivent.",
"title": "Fonctions",
"type": "object"
},
"index": {
"description": "LE seul champ d'adressage. Tout en derive ; P20 refuse d'en stocker un autre.",
"maximum": 255,
"minimum": 0,
"title": "Index (seed)",
"type": "integer"
},
"reservations": {
"additionalProperties": {
"additionalProperties": false,
"properties": {
"passerelle": {
"description": "Dernier octet de la passerelle. P23 refuse un SVI qui s'en ecarte.",
"title": "Passerelle",
"type": "integer"
},
"reserve_max": {
"title": "Réserve (max)",
"type": "integer"
},
"reserve_min": {
"title": "Réserve (min)",
"type": "integer"
}
},
"type": "object"
},
"description": "Adresses soustraites a la derivation dans chaque zone.",
"title": "Réservations",
"type": "object"
}
},
"required": [
"index"
],
"type": "object"
},
"title": "Nomenclature",
"x-fichier": "plan/nomenclature.yml",
"x-racine": null
},
"serveurs": {
"entite": {
"additionalProperties": false,
"properties": {
"coeurs": {
"description": "Vide = derive des empreintes des roles.",
"title": "Cœurs",
"type": "integer"
},
"disque": {
"description": "Ex. `32G`. Vide = derive des empreintes des roles.",
"title": "Disque",
"type": "string"
},
"etat": {
"description": "`planifie` = decrit mais pas deploye ; `actif` = joignable par Ansible.",
"enum": [
"actif",
"planifie"
],
"title": "État",
"type": "string"
},
"fonction": {
"description": "Determine VMID, IP, VLAN et passerelle. Doit exister dans la nomenclature.",
"title": "Fonction",
"type": "string",
"x-source-valeurs": "nomenclature.fonctions"
},
"integrations": {
"description": "Seulement les facultatives. Les universelles viennent du role et sont refusees ici.",
"items": {
"type": "string"
},
"title": "Intégrations facultatives",
"type": "array"
},
"memoire": {
"description": "Vide = derive des empreintes des roles.",
"title": "Mémoire (Mo)",
"type": "integer"
},
"noeud": {
"description": "Surcharge le defaut de `make config`.",
"title": "Nœud Proxmox",
"type": "string"
},
"stockage": {
"description": "Surcharge le defaut.",
"title": "Stockage",
"type": "string"
}
},
"required": [
"fonction"
],
"type": "object"
},
"title": "Serveurs (VM)",
"x-clef": {
"description": "Le nom court de la VM. Le rang final derive l'adresse.",
"title": "Nom d'hôte"
},
"x-fichier": "plan/serveurs.yml",
"x-racine": "serveurs"
},
"serveurs_bd": {
"entite": {
"additionalProperties": false,
"properties": {
"groupe": {
"title": "Groupe",
"type": "string",
"x-source-valeurs": "groupes_operationnels"
},
"hote": {
"title": "Hôte",
"type": "string",
"x-source-valeurs": "serveurs"
},
"port": {
"title": "Port",
"type": "integer"
},
"type": {
"title": "Moteur",
"type": "string"
}
},
"required": [
"hote",
"type"
],
"type": "object"
},
"title": "Serveurs de bases de donnees",
"x-clef": {
"description": "Nom du serveur de bases, ex. `pg-principal`.",
"title": "Nom"
},
"x-fichier": "plan/bases-donnees.yml",
"x-racine": "serveurs_bd"
}
},
"title": "Registres du plan Set-OPS"
}

View file

@ -1,5 +1,5 @@
---
# Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main.
remote: ssh://git@eregion.chezlepro.ca:2222/Alliance-Boreale/Set-OPS-Public.wiki.git
source: 0eaceb1
source: e2935ed
date: 2026-09-08

View file

@ -26,7 +26,7 @@ README de rôles). Cette page comble ces deux trous.
| rôles | 65 | `roles/*/` |
| README de rôles | 65 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| documents | 39 | `docs/*.md` |
| pièces d'audit | 39 | `docs/audit/*` |
| pièces d'audit | 38 | `docs/audit/*` |
| unités de wiki | 27 | `wiki/*.md` |
| décisions en vigueur | 81 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document |
@ -58,7 +58,6 @@ Ce que je re-découvre sinon. **Consulter avant de concevoir un nouveau mécanis
| Mécanisme | Ce que c'est | Où, dans le code | Doc |
|---|---|---|---|
| Plan → inventaire | `plan/*.yml` → `hosts.yml` généré | `scripts/instancier.py`, `scripts/inventory_rules.py` | `plan-et-generation.md` |
| **Schéma des registres** | la **forme** des six registres — champs, types, énumérations, requis — **dérivée** des validateurs, jamais écrite à la main. Sert à générer les formulaires plutôt qu'à les écrire. La **cohérence** reste aux `valider_*` : un schéma ne sait pas dire qu'un `consommateur` désigne une application inexistante | `scripts/schema_plan.py` (`make schema`) → `docs/audit/schema-plan.json` ; garde **P61** | `plan-et-generation.md` |
| Nomenclature dérivée | VMID / IP / VLAN / FQDN dérivés | `inventory_rules.deriver_nomenclature` + `plan/nomenclature.yml` | `nomenclature-vm.md` |
| Dimensionnement | RAM/CPU/disque sommés par logiciel | `roles/*/meta/empreinte.yml` → `deriver_ressources` | `dimensionnement-ressources.md` |
| **Bindings app→app** | lien **côté app** (`liens`) résolu en host_vars | `plan/applications.yml` `liens:` + `roles/*/meta/liens.yml` + `instancier.resoudre_liens` | `bindings-conception.md` |

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 61 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
`scripts/prouver.py` porte 60 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

@ -9,7 +9,7 @@ main, et personne ne s'en apercoit avant de buter dessus.
Ce script rend le trou VISIBLE et mesurable. Il croise :
- les champs REELLEMENT presents dans les plans (instance courante + tous les
modeles decouverts par scripts/modeles.py) ;
- `champs_ecrits_par_gui()` de scripts/inventory_gui.py, qui declare ce que les
- `CHAMPS_ECRITS_PAR_GUI` de scripts/inventory_gui.py, qui declare ce que les
fonctions de sauvegarde ecrivent.
Deux sens de verification :
@ -32,7 +32,7 @@ from pathlib import Path
import yaml
from inventory_gui import REGISTRES_GENERES, champs_ecrits_par_gui
from inventory_gui import CHAMPS_ECRITS_PAR_GUI
from modeles import decouvrir as decouvrir_modeles
from inventory_rules import instance_courante, instance_courante # noqa: E402
@ -88,18 +88,11 @@ def declaration_derive() -> list[str]:
"""Champs declares au GUI mais introuvables dans son source (declaration qui ment)."""
src = SOURCE_GUI.read_text(encoding="utf-8")
# On coupe le tableau lui-meme : sa presence ne prouve rien.
debut = src.find("champs_ecrits_par_gui()")
debut = src.find("CHAMPS_ECRITS_PAR_GUI")
fin = src.find("\n}\n", debut)
ailleurs = src[:debut] + src[fin:] if debut != -1 and fin != -1 else src
absents = []
for registre, champs in champs_ecrits_par_gui().items():
# UN REGISTRE GENERE N'A PLUS SES CHAMPS DANS LE SOURCE, et c'est le but : ils
# vivent dans le schema, et le formulaire en derive. Chercher `secret` dans le JS
# ferait crier cette garde sur precisement le progres qu'elle devrait constater.
# Pour ces registres-la, c'est P61 qui tient la promesse — elle verifie que le
# schema decrit tout ce que les plans contiennent.
if registre in REGISTRES_GENERES:
continue
for registre, champs in CHAMPS_ECRITS_PAR_GUI.items():
for champ in sorted(champs):
if champ not in ailleurs:
absents.append(f"{registre}.{champ}")
@ -119,7 +112,7 @@ def main() -> int:
if args.commande == "lister":
for registre in sorted(utilises):
couverts = champs_ecrits_par_gui().get(registre, set())
couverts = CHAMPS_ECRITS_PAR_GUI.get(registre, set())
print(f"\n== {registre} ==")
for champ in sorted(utilises[registre]):
etat = "GUI" if champ in couverts else "-- hors GUI --"
@ -132,7 +125,7 @@ def main() -> int:
for registre, champs in utilises.items():
if registre in tolere:
continue
couverts = champs_ecrits_par_gui().get(registre, set())
couverts = CHAMPS_ECRITS_PAR_GUI.get(registre, set())
for champ in sorted(set(champs) - couverts):
trous.append(f"{registre}.{champ} (vu dans : "
f"{', '.join(sorted(champs[champ]))})")
@ -145,7 +138,7 @@ def main() -> int:
for t in trous:
print(f" - {t}", file=sys.stderr)
if derive:
print(f"erreur: {len(derive)} champ(s) declare(s) dans champs_ecrits_par_gui() mais "
print(f"erreur: {len(derive)} champ(s) declare(s) dans CHAMPS_ECRITS_PAR_GUI mais "
f"introuvable(s) dans le source du GUI : {', '.join(derive)}", file=sys.stderr)
if trous or derive:

View file

@ -464,29 +464,9 @@ def inventaire_api(path: Path) -> dict:
"integrations_disponibles": integrations_disponibles(),
"integrations_universelles": integrations_universelles(RACINE),
"liens_acceptes": catalogue_liens(RACINE),
# LE SCHEMA VOYAGE AVEC LES DONNEES. Le navigateur genere ses formulaires depuis
# lui plutot que de les porter en dur : un champ ajoute au plan apparait a l'ecran
# sans toucher au JS. Genere par `make schema`, garde par P61.
"schema": schema_du_plan(),
}
def schema_du_plan() -> dict:
"""Le JSON Schema des registres, tel que `make schema` l'a ecrit.
Lu depuis le fichier plutot que recalcule : c'est l'artefact VERSIONNE qui fait foi,
et P61 refuse qu'il soit perime. Recalculer ici laisserait l'ecran afficher une forme
que personne n'a relue.
"""
f = RACINE / "docs" / "audit" / "schema-plan.json"
if not f.is_file():
return {}
try:
return json.loads(f.read_text(encoding="utf-8")).get("registres", {})
except (OSError, json.JSONDecodeError):
return {}
def _lire_yaml_dict(path: Path) -> dict:
if not path.exists():
return {}
@ -553,46 +533,20 @@ def intrants_courants() -> dict:
# A TENIR A JOUR avec sauvegarderServeurs / sauvegarderApplications /
# sauvegarderBases / sauvegarderDomaines. Le verificateur controle aussi que chaque
# champ declare ici apparait bien dans le source du GUI (garde anti-derive).
# CE QUE LE GUI SAIT ECRIRE — ET D'OU IL LE TIENT (2026-09-08).
#
# Cette table etait tenue A LA MAIN : quelqu'un devait penser a l'allonger quand un champ
# arrivait au plan, et P19 confrontait la liste au reel. Une copie gardee reste une copie.
#
# `bases_donnees` en est SORTIE la premiere : son formulaire est desormais GENERE depuis
# `docs/audit/schema-plan.json` (`make schema`), donc la liste s'en deduit au lieu d'etre
# recopiee. Les registres restants gardent leur declaration manuelle jusqu'a ce que leur
# formulaire passe au generateur — on ne bascule pas six vues d'un coup.
#
# P19 lit `champs_ecrits_par_gui()` et non plus cette constante : le jour ou la table sera
# vide, elle gardera un mecanisme au lieu d'une liste.
CHAMPS_ECRITS_A_LA_MAIN = {
CHAMPS_ECRITS_PAR_GUI = {
"serveurs": {"fonction", "etat", "noeud", "stockage", "disque", "memoire",
"coeurs", "integrations"},
"applications": {"groupe", "hote", "port", "requiert", "expose", "liens", "websocket"},
"bases_donnees": {"serveur", "base", "proprietaire", "secret", "consommateur",
"portee", "usage"},
"serveurs_bd": {"type", "hote", "port", "groupe"},
"domaines_publics": {"autorite", "edge", "secondaires", "dnssec", "mail"},
# nomenclature : AUCUN champ ecrit a la main. Le GUI la LIT (liste des fonctions) sans
# jamais l'ecrire — ajouter une fonction exige d'editer le YAML. Trou connu, que le
# passage au generateur fermera par construction.
# nomenclature : AUCUN champ. Le GUI la LIT (liste des fonctions) sans jamais
# l'ecrire — ajouter une fonction exige d'editer le YAML. Trou connu et assume ;
# `couverture_gui.py` le signale tant qu'il dure.
"nomenclature": set(),
}
# Les registres dont le formulaire est GENERE : leurs champs viennent du schema.
REGISTRES_GENERES = {"bases_donnees"}
def champs_ecrits_par_gui() -> dict:
"""Ce que le GUI sait ecrire : declare a la main, plus derive du schema.
Une seule fonction repond a la question, pour que P19 n'ait pas a savoir lequel des
deux mecanismes couvre quel registre.
"""
resultat = {nom: set(champs) for nom, champs in CHAMPS_ECRITS_A_LA_MAIN.items()}
for nom, reg in (schema_du_plan() or {}).items():
if nom in REGISTRES_GENERES:
resultat[nom] = set((reg.get("entite") or {}).get("properties") or {})
return resultat
def integrations_disponibles() -> list:
"""Roles client_* SELECTIONNABLES : ceux qui restent un choix.
@ -2013,85 +1967,6 @@ HTML = r"""<!doctype html>
applicationsModifie = false;
}
// ---- FORMULAIRES GENERES DEPUIS LE SCHEMA -------------------------------------
//
// Le schema (docs/audit/schema-plan.json, `make schema`) decrit la FORME des
// registres ; ces trois fonctions la rendent. Un champ ajoute au plan apparait donc
// a l'ecran sans qu'on touche a ce fichier — c'est tout l'objet.
//
// Ce qu'elles ne font PAS : valider. La coherence reste aux `valider_*` du serveur,
// qui refusent la sauvegarde. Un formulaire genere ne saurait pas dire qu'un
// consommateur designe une application inexistante.
function sourceDeValeurs(nom) {
// Les listes fermees, resolues a l'execution. C'est ce qui rend l'hote fantome
// IMPOSSIBLE A SAISIR, au lieu d'etre refuse apres coup.
if (nom === 'serveurs') return hotes.map(h => h.nom).filter(Boolean);
if (nom === 'applications') return applications.map(x => x.id).filter(Boolean);
if (nom === 'groupes_operationnels') return groupes;
if (nom === 'serveurs_bd') return bdServeurs.map(x => x.nom).filter(Boolean);
if (nom === 'nomenclature.fonctions') return Object.keys((data.nomenclature || {}).fonctions || {});
return [];
}
function champDepuisSchema(cle, spec, valeur, entite, surChangement) {
const titre = echapper(spec.title || cle);
const aide = spec.description ? ` title="${echapper(spec.description)}"` : '';
// Marqueur discret : le schema SAIT ce qui est requis, le formulaire ecrit a la
// main ne le montrait pas. Une asterisque plutot que le mot « requis », qui se
// collait au libelle et se lisait « Base requis ».
const req = (spec['x-requis'] ? '<abbr class="dsn" title="Champ requis" style="text-decoration:none"> *</abbr>' : '');
const appel = (expr) => `${surChangement}('${cle}', ${expr})`;
// Liste fermee : soit fixe (enum), soit alimentee a l'execution, soit dependante
// d'un autre champ de la meme entite.
let options = null;
if (spec.enum) options = spec.enum;
else if (spec['x-source-valeurs']) options = sourceDeValeurs(spec['x-source-valeurs']);
else if (spec['x-source-selon']) {
const s = spec['x-source-selon'];
const pivot = (entite || {})[s.champ];
options = sourceDeValeurs((s.cas || {})[pivot] || s.defaut);
}
if (options) {
const opts = ['<option value="">—</option>'].concat(options.map(v =>
`<option value="${echapper(v)}"${String(v) === String(valeur == null ? '' : valeur) ? ' selected' : ''}>${echapper(v)}</option>`)).join('');
return `<label class="champ"${aide}><span>${titre}${req}</span><select onchange="${appel('this.value')}">${opts}</select></label>`;
}
if (spec.type === 'boolean') {
return `<label class="champ"${aide}><span>${titre}${req}</span><input type="checkbox"${valeur ? ' checked' : ''} onchange="${appel('this.checked')}"></label>`;
}
if (spec.type === 'integer') {
return `<label class="champ"${aide}><span>${titre}${req}</span><input type="number" value="${echapper(valeur == null ? '' : valeur)}" oninput="${appel('this.value')}"></label>`;
}
if (spec.type === 'array') {
// Une liste de textes se saisit separee par des virgules : le YAML reste une
// liste, la saisie reste lisible.
const v = Array.isArray(valeur) ? valeur.join(', ') : '';
return `<label class="champ"${aide}><span>${titre}${req}</span><input value="${echapper(v)}" placeholder="a, b" oninput="${appel('this.value.split(\',\').map(x=>x.trim()).filter(Boolean)')}"></label>`;
}
return `<label class="champ"${aide}><span>${titre}${req}</span><input value="${echapper(valeur == null ? '' : valeur)}" oninput="${appel('this.value')}"></label>`;
}
function formulaireDepuisSchema(nomRegistre, entite, surChangement, cleCourante, surChangementCle) {
const reg = ((data.schema || {})[nomRegistre]) || null;
if (!reg) return '<div class="vide">Aucun schéma pour ce registre — lancer <code>make schema</code>.</div>';
const props = (reg.entite || {}).properties || {};
const requis = new Set((reg.entite || {}).required || []);
const morceaux = [];
if (reg['x-clef'] && surChangementCle) {
const k = reg['x-clef'];
morceaux.push(`<label class="champ" title="${echapper(k.description || '')}"><span>${echapper(k.title)}</span>` +
`<input value="${echapper(cleCourante == null ? '' : cleCourante)}" oninput="(${surChangementCle})(this.value)"></label>`);
}
Object.keys(props).forEach(cle => {
const spec = Object.assign({}, props[cle], {'x-requis': requis.has(cle)});
morceaux.push(champDepuisSchema(cle, spec, (entite || {})[cle], entite, surChangement));
});
return `<div class="grille">${morceaux.join('')}</div>`;
}
function dsnBase(a) {
const s = bdServeurs.find(x => x.nom === a.serveur);
if (!s) return '(serveur inconnu : ' + echapper(a.serveur || '—') + ')';
@ -2173,15 +2048,22 @@ HTML = r"""<!doctype html>
} else {
const a = bdApplis[info.i];
if (!a) { cible.innerHTML = '<div class="detail"><div class="vide">Introuvable.</div></div>'; return; }
// FORMULAIRE GENERE DEPUIS LE SCHEMA (2026-09-08) — plus aucun champ en dur.
// Les huit `<label>` qui vivaient ici sont derives de docs/audit/schema-plan.json :
// libelles, types, listes fermees, et la source CONDITIONNELLE du consommateur
// (elle depend de la portee) que le schema declare desormais au lieu de la laisser
// vivre dans ce fichier. Un champ ajoute au registre apparait sans toucher au JS,
// et P61 refuse qu'un champ du plan manque au schema.
const source = a.portee === 'hote' ? hotes.map(h => h.nom).filter(Boolean) : a.portee === 'application' ? applications.map(x => x.id) : groupes;
const optConso = '<option value="">—</option>' + source.map(v => `<option value="${echapper(v)}" ${v === a.consommateur ? 'selected' : ''}>${echapper(v)}</option>`).join('');
const optPortee = ['groupe', 'application', 'hote'].map(p => `<option value="${p}" ${p === (a.portee || 'groupe') ? 'selected' : ''}>${p}</option>`).join('');
const optServeur = '<option value="">—</option>' + bdServeurs.map(x => `<option value="${echapper(x.nom)}" ${x.nom === a.serveur ? 'selected' : ''}>${echapper(x.nom)}</option>`).join('');
cible.innerHTML = `<article class="detail">
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(a.cle || '(sans id)')}</span></div><span class="grandit"></span><button type="button" class="danger" title="Retirer" onclick="retirerBaseBd()">✕</button></div>
${formulaireDepuisSchema('bases_donnees', a, 'definirBaseBd', a.cle, "definirBaseBd.bind(null,'cle')")}
<div class="grille">
<label class="champ"><span>Identifiant</span><input value="${echapper(a.cle)}" placeholder="bd_forgejo" oninput="definirBaseBd('cle', this.value)"></label>
<label class="champ"><span>Portée</span><select onchange="definirBaseBd('portee', this.value)">${optPortee}</select></label>
<label class="champ"><span>Consommateur</span><select onchange="definirBaseBd('consommateur', this.value)">${optConso}</select></label>
<label class="champ"><span>Serveur</span><select onchange="definirBaseBd('serveur', this.value)">${optServeur}</select></label>
<label class="champ"><span>Base</span><input value="${echapper(a.base)}" placeholder="forgejo" oninput="definirBaseBd('base', this.value)"></label>
<label class="champ"><span>Propriétaire</span><input value="${echapper(a.proprietaire)}" oninput="definirBaseBd('proprietaire', this.value)"></label>
<label class="champ"><span>Usage</span><input value="${echapper(a.usage)}" placeholder="principale" oninput="definirBaseBd('usage', this.value)"></label>
<label class="champ"><span>Secret (Vault)</span><input value="${echapper(a.secret)}" placeholder="vault_bd_app" oninput="definirBaseBd('secret', this.value)"></label>
</div>
<div class="dsn mono" style="margin-top:8px">${echapper(dsnBase(a))}</div>
${(() => {
const srv = bdServeurs.find(x => x.nom === a.serveur);
@ -2198,20 +2080,7 @@ HTML = r"""<!doctype html>
const sd = {};
bdServeurs.forEach(s => { if (String(s.nom).trim()) sd[s.nom] = {type: s.type, hote: s.hote, port: parseInt(s.port, 10) || s.port, ...(s.groupe ? {groupe: s.groupe} : {})}; });
const bd = {};
// SAUVEGARDE DERIVEE DU SCHEMA (2026-09-08). Les sept champs etaient enumeres ici
// en dur : un champ ajoute au registre serait apparu au formulaire genere et
// aurait disparu SILENCIEUSEMENT a l'enregistrement — le pire des deux mondes.
// Les valeurs par defaut viennent du schema (`default`), plus du JS.
const propsBd = (((data.schema || {}).bases_donnees || {}).entite || {}).properties || {};
bdApplis.forEach(a => {
if (!String(a.cle).trim()) return;
const e = {};
Object.keys(propsBd).forEach(cle => {
const v = a[cle];
e[cle] = (v === undefined || v === '' || v === null) ? (propsBd[cle].default ?? v) : v;
});
bd[a.cle] = e;
});
bdApplis.forEach(a => { if (String(a.cle).trim()) bd[a.cle] = {serveur: a.serveur, base: a.base, proprietaire: a.proprietaire, secret: a.secret, consommateur: a.consommateur, portee: a.portee || 'groupe', usage: a.usage || 'principale'}; });
const rep = await fetch('/api/bases', {
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: JSON.stringify({serveurs_bd: sd, bases_donnees: bd})
});

View file

@ -251,85 +251,6 @@ def _lignes_premiere_table(texte: str) -> int:
return n
def preuve_schema_du_plan() -> tuple[bool, str]:
"""Le schema des registres decrit tout ce que les plans reels contiennent.
CE QU'IL REMPLACE (2026-09-08). Le GUI portait `CHAMPS_ECRITS_PAR_GUI`, une liste
tenue A LA MAIN de ce qu'il savait ecrire, et P19 la confrontait au reel. C'etait une
copie — gardee, donc honnete, mais une copie : quelqu'un devait penser a l'allonger.
Le schema la remplace comme SOURCE : les formulaires en derivent, et cette preuve
verifie que le schema, lui, n'a rien oublie.
DEUX SENS, ET LE SECOND EST LE MOINS EVIDENT :
1. tout champ present dans un plan reel (instance courante ET tous les modeles) est
DECRIT par le schema — sinon l'operateur devra editer le YAML a la main, et le
principe 10 se dement en silence ;
2. tout champ decrit est REELLEMENT admis quelque part — un champ inventé dans le
schema ferait apparaitre a l'ecran une case que rien ne consomme.
Le second sens n'est PAS une erreur en soi : `mail`, `coeurs`, `noeud` et `stockage`
sont legitimes et simplement inutilises dans les plans d'aujourd'hui. On les COMPTE
donc, sans refuser — la meme mesure que les lacunes nommees de P29. Refuser
obligerait a retirer du schema un champ valide des que plus personne ne s'en sert.
Et le fichier genere doit etre a jour : un schema perime ferait generer des
formulaires d'hier.
"""
import subprocess as _sp
r = _sp.run([sys.executable, "scripts/schema_plan.py", "--verifier"],
capture_output=True, text=True, cwd=str(RACINE))
if r.returncode != 0:
return False, (r.stderr or r.stdout).strip()
import json as _json
schema = _json.loads((RACINE / "docs" / "audit" / "schema-plan.json").read_text(encoding="utf-8"))
def _noms(props: dict) -> set:
"""Tous les champs decrits, TABLES IMBRIQUEES COMPRISES.
`couverture_gui.champs_utilises()` aplatit : il descend dans
`nomenclature.fonctions.*` et rend `categorie` et `service` comme s'ils etaient
des champs de la nomenclature. Comparer a plat d'un cote et en arbre de l'autre
faisait crier la preuve sur un schema correct — l'instrument mesurait autre chose
que la cible. On aplatit donc des deux cotes.
"""
vus = set()
for nom, p in (props or {}).items():
vus.add(nom)
sous = p.get("additionalProperties")
if isinstance(sous, dict):
vus |= _noms(sous.get("properties"))
return vus
decrit = {nom: _noms(reg["entite"]["properties"])
for nom, reg in schema["registres"].items()}
sys.path.insert(0, str(RACINE / "scripts"))
import couverture_gui as _cg
observes = {nom: set(champs) for nom, champs in _cg.champs_utilises().items()}
trous, inutilises = [], []
for nom, champs in observes.items():
manquants = sorted(champs - decrit.get(nom, set()))
if manquants:
trous.append(f"{nom} : {', '.join(manquants)} present(s) au plan, absent(s) du schema")
for nom, champs in decrit.items():
jamais = sorted(champs - observes.get(nom, set()))
if jamais:
inutilises.append(f"{nom}:{len(jamais)}")
if trous:
return False, ("Le schema ne decrit pas tout ce que les plans contiennent :\n - "
+ "\n - ".join(trous)
+ "\n (ajouter le champ a REGISTRES dans scripts/schema_plan.py, "
"puis `make schema`)")
n = sum(len(c) for c in decrit.values())
suffixe = (f" ; {', '.join(inutilises)} champ(s) decrits et inutilises dans les plans "
f"d'aujourd'hui (legitime)") if inutilises else ""
return True, (f"Le schema decrit {n} champ(s) sur {len(decrit)} registres, et couvre "
f"tout ce que les plans reels contiennent{suffixe}.")
def preuve_wiki_publie_a_jour() -> tuple[bool, str]:
"""Le wiki publie sur la forge correspond a `wiki/` dans le depot.
@ -2837,8 +2758,6 @@ PREUVES: list[dict] = [
"refs": [], "func": preuve_enumerations_annoncees},
{"id": "P60", "titre": "Wiki publie : la forge sert ce que le depot dit",
"refs": ["AFF-002"], "func": preuve_wiki_publie_a_jour},
{"id": "P61", "titre": "Schema du plan : il decrit tout ce que les plans contiennent",
"refs": ["AFF-033"], "func": preuve_schema_du_plan},
{"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [],
"func": preuve_devis_frontiere_du_site},
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],

View file

@ -1,289 +0,0 @@
#!/usr/bin/env python3
"""JSON Schema des registres du plan — DERIVE, jamais ecrit a la main.
CE QUE CE SCHEMA EST, ET CE QU'IL N'EST PAS.
Il decrit la **forme** des six registres du plan : quels champs existent, de quel type,
lesquels sont requis, quelles valeurs sont admises. Il sert a **generer les formulaires**
du GUI plutot qu'a les ecrire un par un.
Il ne remplace PAS les validateurs (`inventory_rules.valider_*`). Ceux-ci portent la
**coherence** — un `consommateur` qui designe une application inexistante, une `fonction`
absente de la nomenclature, une integration universelle recopiee dans le plan. Aucune de
ces regles ne s'exprime en JSON Schema, et vouloir les y mettre creerait la seconde source
de verite que tout ce depot refuse.
schema -> la FORME -> genere les champs du formulaire
validateurs -> la COHERENCE -> refusent une saisie incoherente
D'OU VIENNENT LES VALEURS ADMISES. Elles sont **importees** des constantes que les
validateurs appliquent (`ETATS_SERVEUR`, `PORTEES_BD`, `AUTORITES_DNS`), jamais recopiees.
Une enumeration recopiee diverge : c'est la lecon des neuf resolutions d'instance que P41
garde depuis.
CE QUI RESTE DECLARE ICI, ET POURQUOI. Le libelle, l'aide et le fait qu'un champ soit
**derive** (donc non saisissable) ne se lisent nulle part ailleurs : ce sont des decisions
d'interface. Elles vivent donc dans `REGISTRES` ci-dessous, a cote de la seule chose qui
les justifie. La preuve **P61** confronte cette declaration aux champs REELLEMENT presents
dans tous les plans et modeles : un champ oublie ici et utilise la-bas fait echouer le
harnais.
Usage :
python3 scripts/schema_plan.py # ecrit docs/audit/schema-plan.json
python3 scripts/schema_plan.py --verifier # compare sans ecrire (code 1 si perime)
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from inventory_rules import ( # noqa: E402
AUTORITES_DNS,
ETATS_SERVEUR,
PORTEES_BD,
ecriture_atomique,
)
RACINE = Path(__file__).resolve().parents[1]
CIBLE = RACINE / "docs" / "audit" / "schema-plan.json"
# `derive: true` = le moteur le calcule, l'interface l'AFFICHE sans le proposer a la
# saisie. C'est la difference entre montrer et laisser modifier, et elle est structurante :
# P20 refuse tout adressage stocke, donc un formulaire qui offrirait `vlan` ou `vmid`
# inviterait a violer une regle que le harnais refuse ensuite.
REGISTRES: dict = {
"serveurs": {
"titre": "Serveurs (VM)",
"fichier": "plan/serveurs.yml",
"racine": "serveurs",
"clef": {"libelle": "Nom d'hôte", "aide": "Le nom court de la VM. Le rang final derive l'adresse."},
"champs": {
"fonction": {"type": "string", "requis": True,
"libelle": "Fonction",
"aide": "Determine VMID, IP, VLAN et passerelle. Doit exister dans la nomenclature.",
"source_valeurs": "nomenclature.fonctions"},
"etat": {"type": "string", "enum": sorted(ETATS_SERVEUR),
"libelle": "État",
"aide": "`planifie` = decrit mais pas deploye ; `actif` = joignable par Ansible."},
"integrations": {"type": "array", "items": {"type": "string"},
"libelle": "Intégrations facultatives",
"aide": "Seulement les facultatives. Les universelles viennent du role et sont refusees ici."},
"noeud": {"type": "string", "libelle": "Nœud Proxmox",
"aide": "Surcharge le defaut de `make config`."},
"stockage": {"type": "string", "libelle": "Stockage", "aide": "Surcharge le defaut."},
"disque": {"type": "string", "libelle": "Disque",
"aide": "Ex. `32G`. Vide = derive des empreintes des roles."},
"memoire": {"type": "integer", "libelle": "Mémoire (Mo)",
"aide": "Vide = derive des empreintes des roles."},
"coeurs": {"type": "integer", "libelle": "Cœurs",
"aide": "Vide = derive des empreintes des roles."},
},
},
"applications": {
"titre": "Applications",
"fichier": "plan/applications.yml",
"racine": "applications",
"clef": {"libelle": "Identifiant", "aide": "Nom de l'application dans le plan."},
"champs": {
"groupe": {"type": "string", "requis": True, "libelle": "Groupe (rôle)",
"aide": "La capacite appliquee. Doit avoir un playbook homonyme.",
"source_valeurs": "groupes_operationnels"},
"hote": {"type": "string", "requis": True, "libelle": "Hôte",
"aide": "Une VM declaree au plan. Un hote inconnu est un « hote fantome » (P06).",
"source_valeurs": "serveurs"},
"port": {"type": "integer", "libelle": "Port"},
"expose": {"type": "array", "items": {"type": "string"},
"libelle": "Exposition publique",
"aide": "FQDN publies par l'edge. Derivent le vhost, les SAN et le plancher /etc/hosts."},
"requiert": {"type": "array", "items": {"type": "string"},
"libelle": "Requiert",
"aide": "Dependance applicative. Indicative : l'ordre de deploiement vient des couches."},
"liens": {"type": "array", "items": {"type": "object"},
"libelle": "Liens (bindings)",
"aide": "Roles acceptes par le role porteur (meta/liens.yml)."},
"websocket": {"type": "boolean", "libelle": "WebSocket",
"aide": "L'edge doit relayer la mise a niveau de connexion."},
},
},
"bases_donnees": {
"titre": "Bases de donnees",
"fichier": "plan/bases-donnees.yml",
"racine": "bases_donnees",
"clef": {"libelle": "Identifiant", "aide": "Nom de l'entree au registre, ex. `bd_forgejo`."},
"champs": {
"serveur": {"type": "string", "requis": True, "libelle": "Serveur de BD",
"source_valeurs": "serveurs_bd"},
"base": {"type": "string", "requis": True, "libelle": "Base"},
"proprietaire": {"type": "string", "requis": True, "libelle": "Propriétaire"},
"secret": {"type": "string", "requis": True, "libelle": "Secret (Vault)",
"aide": "NOM d'une variable Vault, jamais une valeur. Le secret ne quitte pas le role."},
# SOURCE CONDITIONNELLE : ce qu'on peut consommer depend de la PORTEE.
# Le formulaire ecrit a la main faisait deja ce choix ; le schema le declare
# au lieu de le laisser vivre dans le JS. C'est ce qui permet de generer le
# champ sans perdre la regle.
"consommateur": {"type": "string", "requis": True, "libelle": "Consommateur",
"aide": "Qui utilise cette base. La liste depend de la portee.",
"source_selon": {"champ": "portee",
"cas": {"hote": "serveurs",
"application": "applications"},
"defaut": "groupes_operationnels"}},
"portee": {"type": "string", "enum": sorted(PORTEES_BD), "libelle": "Portée",
"defaut": "groupe",
"aide": "Comment le consommateur est designe : par application, par groupe ou par hote."},
"usage": {"type": "string", "libelle": "Usage", "defaut": "principale"},
},
},
"serveurs_bd": {
"titre": "Serveurs de bases de donnees",
"fichier": "plan/bases-donnees.yml",
"racine": "serveurs_bd",
"clef": {"libelle": "Nom", "aide": "Nom du serveur de bases, ex. `pg-principal`."},
"champs": {
"type": {"type": "string", "requis": True, "libelle": "Moteur"},
"hote": {"type": "string", "requis": True, "libelle": "Hôte", "source_valeurs": "serveurs"},
"port": {"type": "integer", "libelle": "Port"},
"groupe": {"type": "string", "libelle": "Groupe", "source_valeurs": "groupes_operationnels"},
},
},
"domaines_publics": {
"titre": "Domaines publics",
"fichier": "plan/domaines.yml",
"racine": "domaines_publics",
"clef": {"libelle": "Domaine", "aide": "Le nom public, ex. `chezlepro.ca`."},
"champs": {
"autorite": {"type": "string", "enum": sorted(AUTORITES_DNS), "libelle": "Autorité DNS"},
"edge": {"type": "string", "libelle": "Edge", "source_valeurs": "serveurs"},
"secondaires": {"type": "array", "items": {"type": "string"}, "libelle": "Secondaires"},
"dnssec": {"type": "boolean", "libelle": "DNSSEC"},
"mail": {"type": "boolean", "libelle": "Courriel",
"aide": "La zone porte des enregistrements de messagerie."},
},
},
"nomenclature": {
"titre": "Nomenclature",
"fichier": "plan/nomenclature.yml",
"racine": None, # pas d'entites : des clefs a la racine
"champs": {
"index": {"type": "integer", "requis": True, "libelle": "Index (seed)",
"minimum": 0, "maximum": 255,
"aide": "LE seul champ d'adressage. Tout en derive ; P20 refuse d'en stocker un autre."},
"cidr_hote": {"type": "integer", "libelle": "CIDR d'hote"},
# LES TROIS TABLES IMBRIQUEES SONT DECRITES, PAS RESUMEES EN « object ».
# C'est P61 qui l'a impose : `categorie` et `service` etaient presents dans
# tous les plans et absents du schema — donc un formulaire genere n'aurait
# jamais su les editer, exactement le trou que la nomenclature avait deja
# dans `CHAMPS_ECRITS_PAR_GUI` (zero champ declare).
"categories": {"type": "object", "libelle": "Catégories (zones)",
"aide": "Une zone de securite par cle. Le numero derive le 3e octet et le VLAN.",
"entree": {
"libelle": {"type": "string", "requis": True,
"libelle": "Libellé",
"aide": "Nom lisible de la zone (Frontiere, Identite...)."}}},
"fonctions": {"type": "object", "libelle": "Fonctions",
"aide": "categorie + service par fonction. VMID et IP en derivent.",
"entree": {
"categorie": {"type": "integer", "requis": True,
"libelle": "Catégorie",
"aide": "La zone. Fixe le 3e octet (15 + categorie) et le VLAN."},
"service": {"type": "integer", "requis": True,
"libelle": "Service",
"aide": "Fixe le bloc d'adresses de l'hote dans la zone."}}},
"reservations": {"type": "object", "libelle": "Réservations",
"aide": "Adresses soustraites a la derivation dans chaque zone.",
"entree": {
"passerelle": {"type": "integer", "libelle": "Passerelle",
"aide": "Dernier octet de la passerelle. P23 refuse un SVI qui s'en ecarte."},
"reserve_min": {"type": "integer", "libelle": "Réserve (min)"},
"reserve_max": {"type": "integer", "libelle": "Réserve (max)"}}},
},
},
}
def construire() -> dict:
"""Le JSON Schema, derive de REGISTRES et des constantes des validateurs."""
defs = {}
for nom, reg in REGISTRES.items():
props, requis = {}, []
for champ, d in reg["champs"].items():
p = {"type": d["type"], "title": d["libelle"]}
for cle_src, cle_dst in (("aide", "description"), ("enum", "enum"),
("items", "items"), ("minimum", "minimum"),
("maximum", "maximum"), ("defaut", "default")):
if cle_src in d:
p[cle_dst] = d[cle_src]
if "entree" in d:
# Une table dont chaque VALEUR a une forme connue : le formulaire genere
# une sous-fiche par cle, au lieu d'offrir un bloc YAML libre.
sous, sous_requis = {}, []
for sc, sd in d["entree"].items():
q = {"type": sd["type"], "title": sd["libelle"]}
if "aide" in sd:
q["description"] = sd["aide"]
sous[sc] = q
if sd.get("requis"):
sous_requis.append(sc)
p["additionalProperties"] = {"type": "object", "properties": sous,
"additionalProperties": False}
if sous_requis:
p["additionalProperties"]["required"] = sorted(sous_requis)
if "source_selon" in d:
# Meme intention que `x-source-valeurs`, mais la liste change selon la
# valeur d'un autre champ de la meme entite.
p["x-source-selon"] = d["source_selon"]
if "source_valeurs" in d:
# Extension hors JSON Schema : dit au formulaire d'offrir une LISTE
# fermee, alimentee a l'execution. C'est ce qui rend l'hote fantome
# impossible a saisir plutot que refuse apres coup.
p["x-source-valeurs"] = d["source_valeurs"]
props[champ] = p
if d.get("requis"):
requis.append(champ)
entite = {"type": "object", "properties": props,
"additionalProperties": False}
if requis:
entite["required"] = sorted(requis)
defs[nom] = {"title": reg["titre"], "x-fichier": reg["fichier"],
"x-racine": reg["racine"], "entite": entite}
if reg.get("clef"):
# La CLEF n'est pas une propriete de l'entite — c'est son nom dans la table.
# Le formulaire doit pourtant l'offrir : sans elle, on ne peut pas creer.
defs[nom]["x-clef"] = {"title": reg["clef"]["libelle"],
"description": reg["clef"]["aide"]}
return {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Registres du plan Set-OPS",
"description": ("GENERE par scripts/schema_plan.py (make schema). Ne pas editer a "
"la main. Decrit la FORME des registres ; la COHERENCE reste aux "
"validateurs de inventory_rules.py."),
"registres": defs,
}
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--verifier", action="store_true",
help="compare sans ecrire ; code 1 si le fichier est perime")
args = ap.parse_args(argv)
rendu = json.dumps(construire(), ensure_ascii=False, indent=2, sort_keys=True) + "\n"
if args.verifier:
actuel = CIBLE.read_text(encoding="utf-8") if CIBLE.is_file() else ""
if actuel != rendu:
print(f"erreur: {CIBLE.relative_to(RACINE)} est PERIME. Regenerer : make schema",
file=sys.stderr)
return 1
n = sum(len(r["champs"]) for r in REGISTRES.values())
print(f"CONFORME : {len(REGISTRES)} registres, {n} champs decrits.")
return 0
with ecriture_atomique(CIBLE) as f:
f.write(rendu)
print(f"Ecrit : {CIBLE.relative_to(RACINE)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -26,7 +26,7 @@ Trois idées la portent :
- **Le registre** : `docs/audit/affirmations.md` — chaque affirmation du dépôt (README, docs,
aide `make`, GUI) reliée à une preuve et un statut (✅/🟡/❌/⚪).
- **Le harnais** : `make prouver` rejoue les preuves automatisables (**P01–P61**, sans trou dans la série) et écrit
- **Le harnais** : `make prouver` rejoue les preuves automatisables (**P01–P60**, sans trou dans la série) et écrit
`docs/audit/preuve-<date>.md`. `make verifier` les inclut : il **échoue** si une preuve échoue.
- **Chaque preuve garde une classe d'erreur.** Extrait :