assistants : cent trente-deux cibles, et aucune ne disait dans quel ordre
La console offrait des boutons sans sequence. Rien n'y apprenait que site-creer precede forge-amorcer, que le premier passage de site-deployer-tout s'arrete sur une forge vide sans que ce soit un echec, ni que rien n'est pret avant valider : cet ordre vivait en prose dans des documents que la console ne porte pas. La vue Assistants conduit 17 runbooks et 126 etapes. Les 132 cibles documentees y sont, chacune portee par un assistant ou exemptee avec son motif — une exemption muette est refusee. Le registre ne recopie pas le Makefile : il declare l'ordre, la nature, la portee et le pourquoi, et le libelle de chaque etape est lu dans le Makefile au moment de servir. P83 est ecrite en meme temps que la liste, pas apres, parce qu'une liste qui suit une autre prend du retard. Onze tests lui presentent des registres faux, un par forme de retard, et exigent qu'elle les refuse. Le navigateur ne nomme pas une commande, il nomme une place : la route lance ce que le registre declare a cet index-la, avec les seules variables declarees. L'index compte, le premier jour d'un site jouant site-deployer-tout deux fois. Une etape qui ecrit attend que la precedente ait reussi ; une mesure reste toujours offerte, parce que mesurer apres un echec est exactement ce qu'on fait ensuite. Valide : runbooks.py verifier a 0 ecart, make test a 0 echec, les 83 preuves rejouees, et la console lancee pour de vrai — 17 runbooks servis, six requetes malformees refusees une a une, une etape de mesure executee de bout en bout avec son journal. Limite, anterieure a ce travail : P02 (test_ecriture_plan) echoue sur domaines.yml, a l'identique sur une copie de HEAD. Ajouter ou retirer un domaine public depuis la vue Domaines leverait a l'enregistrement. Non corrige ici. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
bf94a304ff
commit
f33b5be151
11 changed files with 1859 additions and 4 deletions
|
|
@ -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 82 preuves de `make prouver` : elles lisent le dépôt, sans le
|
||||
C'est aussi ce que font les 83 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.**
|
||||
|
||||
|
|
|
|||
83
CHANGELOG.md
83
CHANGELOG.md
|
|
@ -1,5 +1,88 @@
|
|||
# CHANGELOG — Set-OPS
|
||||
|
||||
## 2026-09-20 (6) — Cent trente-deux cibles, et aucune ne dit dans quel ordre
|
||||
|
||||
**83 preuves (P83 est neuve), `make test` à 0 échec.** Le `Makefile` porte 132 cibles
|
||||
documentées. Chacune dit ce qu'elle fait ; aucune ne dit quand, ni après quoi, ni ce
|
||||
qu'il faut avoir mesuré avant. La console offrait donc des boutons sans séquence, et
|
||||
l'ordre des gestes vivait en prose dans des documents que la console ne porte pas.
|
||||
|
||||
### Un exploitant devant un site neuf n'avait pas de quoi commencer
|
||||
|
||||
Rien dans l'interface n'apprenait que `site-creer` précède `forge-amorcer`, que le premier
|
||||
passage de `site-deployer-tout` **s'arrête** sur une forge vide — et que ce n'est pas un
|
||||
échec mais le maillon suivant — ni que rien n'est « prêt » avant `valider`. C'est
|
||||
exactement ce que le principe fondateur exige : *un opérateur exploite Set-OPS sans IA*.
|
||||
Sans la séquence, la console ne tenait cette promesse qu'à moitié.
|
||||
|
||||
La vue **Assistants** conduit **17 runbooks, 126 étapes**, du premier jour d'un site à la
|
||||
remise au client. Les 132 cibles y sont : chacune portée par un assistant, ou **exemptée
|
||||
avec son motif**. Une exemption muette est refusée — c'est un oubli déguisé.
|
||||
|
||||
### Le registre ne recopie pas le Makefile, et la garde est écrite avec lui
|
||||
|
||||
`docs/runbooks-construction.yml` déclare seulement ce que le `Makefile` ne peut pas
|
||||
porter : l'ordre, la nature du geste, la portée, le pourquoi. **Le libellé de chaque étape
|
||||
est lu dans le `Makefile` au moment de servir** — jamais recopié. Une cible renommée se
|
||||
voit donc à l'écran, elle ne s'invente pas.
|
||||
|
||||
Ce fichier est malgré tout une seconde liste à côté de la première, et une liste qui suit
|
||||
une autre prend du retard : vu quatre fois dans ce dépôt en une seule journée. **P83 est
|
||||
donc écrite en même temps que la liste**, pas après. Elle refuse une étape qui vise une
|
||||
cible absente du `Makefile`, une cible documentée que nul runbook ne porte et que nul
|
||||
motif n'exempte, une cible à la fois portée et exemptée, et une portée que la console ne
|
||||
saurait pas traduire en pouvoir. Onze tests unitaires lui présentent des registres faux —
|
||||
un par forme de retard — et exigent qu'elle les refuse : une garde qui rendrait toujours
|
||||
« rien à signaler » passerait P83 tous les jours sans rien garder.
|
||||
|
||||
### Le navigateur ne nomme pas une commande, il nomme une place
|
||||
|
||||
`/api/runbook-etape` ne lance jamais la cible que la page demande : il lance ce que le
|
||||
registre déclare **à cet index-là**, avec les seules variables déclarées, et refuse tout
|
||||
le reste en disant pourquoi. Une page périmée — ou compromise — ne peut pas réclamer
|
||||
`raser` depuis un assistant de mesure. **L'index compte** : « Le premier jour d'un site »
|
||||
joue `site-deployer-tout` deux fois, et les deux places n'ont pas le même sens ; chercher
|
||||
par nom seul les confondrait.
|
||||
|
||||
Les valeurs imposées par une étape (`CONFIRMER=true` et compagnie) ne viennent jamais du
|
||||
corps de la requête : une confirmation qu'on peut s'envoyer à soi-même n'en est pas une.
|
||||
Ce qui protège, côté page, c'est d'**écrire `DETRUIRE`** — le seul garde-fou qui résiste à
|
||||
un clic distrait.
|
||||
|
||||
**La portée se dérive, elle ne se déclare pas.** Un assistant de site exige
|
||||
`materialiser`, un de locataire `configurer`, un de poste les deux. La garde de cette
|
||||
route est plus fine que celle des autres, et c'est voulu : un seul mot y serait soit trop
|
||||
laxiste, soit trop sévère. Un assistant hors de portée reste **visible et lisible** —
|
||||
savoir que le geste existe, et chez qui il se fait, fait partie du métier. Ce qui est
|
||||
refusé, c'est de le lancer.
|
||||
|
||||
### La séquence est un garde-fou, pas une décoration
|
||||
|
||||
Une étape qui **écrit** attend que la précédente non facultative ait réussi : sinon on
|
||||
bâtit sur un terrain qu'on n'a pas vérifié. Une étape qui **mesure** reste toujours
|
||||
offerte, même après un échec — mesurer pour comprendre est exactement ce qu'on fait
|
||||
ensuite. Cette asymétrie est la seule chose que l'interface impose d'elle-même.
|
||||
|
||||
### Ce qui a été vérifié, et ce qui ne l'a pas été
|
||||
|
||||
`python3 scripts/runbooks.py verifier` (0 écart), `make test` à 0 échec — 11 tests neufs
|
||||
compris — et les 83 preuves rejouées. La console a été lancée pour de vrai : `GET /`
|
||||
rend 200, `/api/runbooks` sert 17 runbooks avec les libellés lus au `Makefile`, six
|
||||
requêtes malformées sont refusées une à une (cible absente du runbook, runbook inconnu,
|
||||
bon nom au mauvais index, variable requise absente, valeur commençant par un tiret,
|
||||
valeur hors du vocabulaire déclaré), et une étape de mesure s'exécute de bout en bout
|
||||
avec son journal daté.
|
||||
|
||||
**`make verifier` n'est pas vert, et ce n'est pas de ce changement.** P02
|
||||
(`scripts/tests/test_ecriture_plan.py`) échoue sur `domaines.yml` — trois erreurs dans
|
||||
`_fusion_table`, où un bloc réindenté se relit comme une liste là où le code attend une
|
||||
table. Mesuré sur une copie de `HEAD` avec la même instance montée : **l'échec est
|
||||
identique avant ce travail**. Il reste ouvert, et il a une conséquence pour l'exploitant :
|
||||
ajouter ou retirer un domaine public depuis la vue Domaines lèverait au moment
|
||||
d'enregistrer. L'aller-retour sans modification, lui, fonctionne.
|
||||
|
||||
Aucun rôle, playbook ou réglage n'est modifié ; aucune VM touchée, aucune voûte ouverte.
|
||||
|
||||
## 2026-09-20 (5) — Celui qui pousse avance son propre clone
|
||||
|
||||
**82 preuves dans le rapport du 17 septembre, non rejouées ici.** Le correctif de
|
||||
|
|
|
|||
1
Makefile
1
Makefile
|
|
@ -310,6 +310,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
|
|||
python3 scripts/tests/test_raser_resultat.py
|
||||
python3 scripts/tests/test_adressage_derive.py
|
||||
python3 scripts/tests/test_gui_intrants.py
|
||||
python3 scripts/tests/test_runbooks.py
|
||||
python3 scripts/tests/test_devis_placement.py
|
||||
python3 scripts/tests/test_sonder.py
|
||||
|
||||
|
|
|
|||
|
|
@ -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 82 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
|
||||
`scripts/prouver.py` porte 83 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.
|
||||
|
|
|
|||
|
|
@ -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 (82 preuves, zéro réseau)
|
||||
make prouver le dépôt est-il cohérent avec lui-même (83 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ù
|
||||
|
|
|
|||
821
docs/runbooks-construction.yml
Normal file
821
docs/runbooks-construction.yml
Normal file
|
|
@ -0,0 +1,821 @@
|
|||
---
|
||||
# LES RUNBOOKS DE CONSTRUCTION — l'ordre des gestes, et pourquoi celui-la.
|
||||
#
|
||||
# CE QUE CE FICHIER AJOUTE AU MAKEFILE, ET CE QU'IL NE REPETE PAS.
|
||||
#
|
||||
# Les 132 cibles documentees du Makefile disent chacune CE QU'ELLE FAIT. Aucune ne dit
|
||||
# dans quel ORDRE, ni pourquoi maintenant, ni ce qu'il faut avoir mesure avant. Cette
|
||||
# connaissance-la vivait en prose dans `docs/runbooks-exploitation.md`,
|
||||
# `docs/implanter-un-tenant-sur-un-site.md` et `docs/preparer-un-site-hebergeur.md` — et
|
||||
# la console offrait des boutons sans sequence.
|
||||
#
|
||||
# ON NE RECOPIE PAS LE LIBELLE D'UNE CIBLE. `scripts/runbooks.py` va le lire dans le
|
||||
# Makefile au moment de servir. Un libelle recopie ici serait une seconde liste, et une
|
||||
# liste qui suit une autre prend du retard sur elle. Ce fichier ne porte donc que ce que
|
||||
# le Makefile ne peut pas porter : l'ordre, la nature du geste, la portee, le pourquoi.
|
||||
#
|
||||
# LA GARDE EST ECRITE AVEC LA LISTE, PAS APRES. `python3 scripts/runbooks.py verifier`
|
||||
# (et P83) refusent qu'une cible documentee ne soit ni portee par un runbook ni exemptee
|
||||
# avec un motif. Sans cela, la console cacherait des pouvoirs que le moteur possede.
|
||||
#
|
||||
# NATURE D'UNE ETAPE
|
||||
# mesure n'ecrit rien, rejouable sans consequence, proposee meme apres un echec
|
||||
# ecriture change l'etat du monde ; exige que l'etape precedente ait reussi
|
||||
# destructif detruit ; exige une confirmation ecrite en plus de CONFIRMER=true
|
||||
#
|
||||
# PORTEE D'UN RUNBOOK — ce que la MACHINE porte, au sens de `contexte()` :
|
||||
# tenant un ecosysteme est monte (`instance/`) : on le configure
|
||||
# site une fabric est montee (`underlay.yml`) : on materialise
|
||||
# poste les deux — l'atelier du mainteneur
|
||||
# toute ni l'un ni l'autre n'est requis
|
||||
|
||||
# CE QUE LA CONSOLE DOIT DEMANDER A L'EXPLOITANT. Une variable absente d'ici est refusee
|
||||
# par la garde : la console ne saurait pas quoi afficher, et un champ libre sans invite
|
||||
# est une invitation a se tromper.
|
||||
variables:
|
||||
HOTE:
|
||||
invite: "La machine"
|
||||
source: hotes # la liste vient de l'inventaire actif
|
||||
GROUPE:
|
||||
invite: "Le groupe (role)"
|
||||
source: groupes
|
||||
NOM:
|
||||
invite: "Nom du dossier de l'ecosysteme"
|
||||
exemple: "OPS-Machin"
|
||||
MODELE:
|
||||
invite: "Modele de depart"
|
||||
source: modeles
|
||||
INSTANCE:
|
||||
invite: "Nom de l'ecosysteme a detruire (il doit etre ecrit en toutes lettres)"
|
||||
SITE:
|
||||
invite: "Depot du site a detruire (ecrit en toutes lettres)"
|
||||
TENANT:
|
||||
invite: "Dossier du locataire a amorcer"
|
||||
exemple: "OPS-Machin"
|
||||
DEPOT:
|
||||
invite: "Un seul depot (vide = tous ceux que le runner porte)"
|
||||
facultatif: true
|
||||
VERS:
|
||||
invite: "Repertoire de destination (une cle chiffree, hors du poste)"
|
||||
ARCHIVE:
|
||||
invite: "Fichier d'archive a restaurer"
|
||||
CIBLE:
|
||||
invite: "Hote ou adresse a sonder"
|
||||
SERVICE:
|
||||
invite: "Le lien dont on prouve la coupure"
|
||||
valeurs: [artefacts, genome, resolveur]
|
||||
ROLE:
|
||||
invite: "Un seul role (vide = tous)"
|
||||
facultatif: true
|
||||
LIMITE:
|
||||
invite: "Motif d'hotes"
|
||||
facultatif: true
|
||||
RECU_PAR:
|
||||
invite: "Qui recoit la remise (nom complet)"
|
||||
COURRIEL:
|
||||
invite: "Courriel de la personne qui recoit"
|
||||
DANS:
|
||||
invite: "Jours avant le second temps"
|
||||
facultatif: true
|
||||
VMID:
|
||||
invite: "Identifiant Proxmox de la VM"
|
||||
DIALECTE:
|
||||
invite: "Dialecte du commutateur"
|
||||
valeurs: [cisco, binardat]
|
||||
facultatif: true
|
||||
PARALLELE:
|
||||
invite: "Combien de VM a la fois"
|
||||
facultatif: true
|
||||
|
||||
runbooks:
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
- id: site-premier-jour
|
||||
titre: "Le premier jour d'un site"
|
||||
portee: site
|
||||
doc: docs/runbooks-exploitation.md
|
||||
but: >-
|
||||
Faire naitre un site hebergeur depuis une fabric nue : les machines, puis le moteur,
|
||||
puis la forge qui permettra aux locataires de se reproduire. L'ordre n'est pas une
|
||||
preference — le premier passage s'ARRETE sur une forge vide, et c'est normal.
|
||||
etapes:
|
||||
- cible: underlay
|
||||
nature: mesure
|
||||
pourquoi: >-
|
||||
La fabric declaree tient-elle debout toute seule ? Rien ne sert de creer des
|
||||
machines sur un plan d'adressage qui se contredit.
|
||||
- cible: underlay-plan
|
||||
nature: mesure
|
||||
pourquoi: >-
|
||||
Le declare face au REEL, par l'API du cluster et une sonde TCP. C'est ici qu'on
|
||||
apprend qu'un pont n'existe pas, pas au milieu de la creation des VM.
|
||||
- cible: site-creer
|
||||
nature: ecriture
|
||||
fixes: {CONFIRMER: "true"}
|
||||
duree: "~9 min"
|
||||
pourquoi: >-
|
||||
Les machines du site, depuis l'underlay. Elles naissent ; elles ne repondent pas
|
||||
encore.
|
||||
- cible: site-deployer-tout
|
||||
nature: ecriture
|
||||
fixes: {CONFIRMER: "true"}
|
||||
duree: "~20 min"
|
||||
pourquoi: >-
|
||||
Premier passage. Il va jusqu'a `serveur_ops` et s'arrete sur la forge vide :
|
||||
ce n'est pas un echec, c'est le maillon suivant.
|
||||
- cible: forge-amorcer
|
||||
nature: ecriture
|
||||
fixes: {CONFIRMER: "true"}
|
||||
duree: "~45 s"
|
||||
pourquoi: >-
|
||||
La forge tourne et n'a ni organisation ni depot. Sans cet amorcage, le runner
|
||||
clone le vide et le deploiement ne finira jamais.
|
||||
- cible: site-deployer-tout
|
||||
nature: ecriture
|
||||
fixes: {CONFIRMER: "true"}
|
||||
duree: "~4 min"
|
||||
pourquoi: >-
|
||||
Second passage. Celui-ci doit finir a zero echec ; s'il n'y arrive pas, c'est
|
||||
un vrai defaut, plus un maillon manquant.
|
||||
- cible: genome-pousser
|
||||
nature: ecriture
|
||||
variables: [DEPOT]
|
||||
pourquoi: >-
|
||||
La forge du site FAIT AUTORITE : tant qu'elle est en retard, tout ecosysteme qui
|
||||
s'y reproduit reproduit un moteur perime.
|
||||
- cible: routes-fabric-etat
|
||||
nature: mesure
|
||||
pourquoi: >-
|
||||
Les hyperviseurs routent-ils toutes les zones ? Une zone non routee ne se voit
|
||||
qu'au moment ou un locataire y pose sa premiere machine.
|
||||
- cible: gabarit-etat
|
||||
nature: mesure
|
||||
pourquoi: >-
|
||||
Sans gabarit conforme, aucun locataire ne pourra cloner quoi que ce soit ici.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
- id: site-tenir
|
||||
titre: "Tenir un site en etat"
|
||||
portee: site
|
||||
doc: docs/hebergeur-exploitation.md
|
||||
but: >-
|
||||
Les gestes reguliers de l'hebergeur : porter le genome a jour, appliquer un role aux
|
||||
machines du site, et regarder ce que la fabric fait vraiment.
|
||||
etapes:
|
||||
- cible: genome-etat
|
||||
nature: mesure
|
||||
pourquoi: >-
|
||||
Ce que la forge porte, face au poste. Un ecart ici se paie chez TOUS les
|
||||
locataires qui clonent ensuite.
|
||||
- cible: genome-pousser
|
||||
nature: ecriture
|
||||
variables: [DEPOT]
|
||||
pourquoi: "Remettre la forge au niveau du poste, depot par depot si besoin."
|
||||
- cible: site-appliquer
|
||||
nature: ecriture
|
||||
variables: [GROUPE]
|
||||
pourquoi: >-
|
||||
Rejouer un role sur les machines du site — c'est ainsi qu'un runner reprend le
|
||||
genome courant, rien ne le tire tout seul.
|
||||
- cible: site-decrire
|
||||
nature: mesure
|
||||
pourquoi: "Ce que l'underlay declare comme machines de l'hebergeur."
|
||||
- cible: site-inventaire
|
||||
nature: mesure
|
||||
pourquoi: "L'inventaire dynamique du site, tel qu'Ansible le voit — pas tel qu'on l'imagine."
|
||||
- cible: site-intrants
|
||||
nature: mesure
|
||||
pourquoi: "Ce que ce site expose a ses locataires, derive et non declare deux fois."
|
||||
- cible: site-verifier
|
||||
nature: mesure
|
||||
pourquoi: "Le playbook du site correspond-il encore aux couches declarees ?"
|
||||
- cible: site
|
||||
nature: ecriture
|
||||
pourquoi: "Regenerer playbooks/site.yml quand les couches ont bouge."
|
||||
- cible: routes-fabric-etat
|
||||
nature: mesure
|
||||
pourquoi: "Une route de zone manquante est invisible jusqu'au premier invite qui la traverse."
|
||||
- cible: site-raser
|
||||
nature: destructif
|
||||
variables: [SITE]
|
||||
fixes: {CONFIRMER: "true"}
|
||||
pourquoi: >-
|
||||
Detruire les VM du SITE. A ne faire que sur un site de chantier : la
|
||||
reconstruction est prouvee, elle n'est pas gratuite.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
- id: locataire-naitre
|
||||
titre: "Faire naitre un ecosysteme de locataire"
|
||||
portee: poste
|
||||
doc: docs/multi-instances.md
|
||||
but: >-
|
||||
Du modele au plan monte : creer le dossier de l'ecosysteme, le rendre actif, puis
|
||||
generer son inventaire. Tout l'adressage descend du seed `index` — on ne l'ecrit
|
||||
jamais a la main.
|
||||
etapes:
|
||||
- cible: instance-modeles
|
||||
nature: mesure
|
||||
pourquoi: "Quels modeles de depart existent, avant d'en choisir un."
|
||||
- cible: instances
|
||||
nature: mesure
|
||||
pourquoi: >-
|
||||
Les ecosystemes deja decouverts, et surtout les COLLISIONS d'index : deux
|
||||
ecosystemes sur le meme seed se marcheraient dessus en silence.
|
||||
- cible: instance-creer
|
||||
nature: ecriture
|
||||
variables: [NOM, MODELE]
|
||||
pourquoi: "Le dossier de l'ecosysteme, depuis un modele, avec son index reserve."
|
||||
- cible: instance-utiliser
|
||||
nature: ecriture
|
||||
variables: [NOM]
|
||||
pourquoi: >-
|
||||
Basculer le symlink `instance/`. C'est lui qui decide QUEL ecosysteme la console
|
||||
configure — et quelle voute elle ouvrira.
|
||||
- cible: instance-courante
|
||||
nature: mesure
|
||||
pourquoi: "Verifier vers quoi on pointe avant d'ecrire quoi que ce soit."
|
||||
- cible: intrants-verifier
|
||||
nature: mesure
|
||||
pourquoi: >-
|
||||
Tout intrant qu'un role EXIGE est-il fourni ? Un intrant manquant ne se voit
|
||||
sinon qu'au milieu d'un deploiement.
|
||||
- cible: ports-verifier
|
||||
nature: mesure
|
||||
pourquoi: "Deux roles co-localises qui reclament le meme port ne cohabiteront pas."
|
||||
- cible: instancier
|
||||
nature: mesure
|
||||
pourquoi: >-
|
||||
Generer hosts.yml depuis le plan SANS l'appliquer — on regarde le diff avant de
|
||||
le prendre.
|
||||
- cible: instancier-appliquer
|
||||
nature: ecriture
|
||||
pourquoi: >-
|
||||
Prendre l'inventaire genere. `hosts.yml` est un ARTEFACT : on edite le plan, on
|
||||
ne le corrige jamais a la main.
|
||||
- cible: inventaire-verifier
|
||||
nature: mesure
|
||||
pourquoi: "L'inventaire se parse-t-il, voute dechiffree ? Sinon rien ne partira."
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
- id: locataire-materialiser
|
||||
titre: "Preparer le terrain d'un locataire sur la fabric"
|
||||
portee: site
|
||||
doc: docs/implanter-un-tenant-sur-un-site.md
|
||||
but: >-
|
||||
Tout ce que l'HEBERGEUR pose avant qu'un locataire puisse exister : le placement, les
|
||||
pools, le SDN, les pare-feux et la frontiere. Chaque devis se mesure avant de
|
||||
s'appliquer — un devis qu'on applique sans l'avoir lu est un pari.
|
||||
etapes:
|
||||
- cible: placement-plan
|
||||
nature: mesure
|
||||
pourquoi: >-
|
||||
Le noeud, le stockage, le pont et le gabarit existent-ils VRAIMENT sur ce
|
||||
cluster ? C'est la premiere chose qui manque, et la derniere qu'on regarde.
|
||||
- cible: devis-proxmox-pools
|
||||
nature: mesure
|
||||
pourquoi: "Un pool par locataire, derive du plan — la cloison la plus simple."
|
||||
- cible: devis-proxmox-pools-verifier
|
||||
nature: mesure
|
||||
pourquoi: "Le devis tient-il ses propres regles avant qu'on le pose ?"
|
||||
- cible: devis-sdn
|
||||
nature: mesure
|
||||
pourquoi: "Zone, VNets et sous-reseaux, derives du seed de l'ecosysteme."
|
||||
- cible: devis-sdn-verifier
|
||||
nature: mesure
|
||||
pourquoi: "Relire le devis SDN avant de toucher au reseau du cluster."
|
||||
- cible: sdn-plan
|
||||
nature: mesure
|
||||
pourquoi: "L'ecart entre le SDN en service et ce devis — ce qui manque, ce qui est perime."
|
||||
- cible: sdn-appliquer
|
||||
nature: ecriture
|
||||
fixes: {CONFIRMER: "true"}
|
||||
pourquoi: >-
|
||||
Reconcilier : creer ce qui manque et RETIRER ce qui est perime. Le retrait est la
|
||||
moitie qu'on oublie.
|
||||
- cible: flux
|
||||
nature: ecriture
|
||||
pourquoi: >-
|
||||
Regenerer le registre des flux et les regles nftables depuis les `meta/flux.yml`
|
||||
des roles. Tout ce qui suit en descend.
|
||||
- cible: devis-proxmox-fw
|
||||
nature: mesure
|
||||
pourquoi: "Le pare-feu est-ouest intra-locataire, derive du registre des flux."
|
||||
- cible: devis-proxmox-fw-verifier
|
||||
nature: mesure
|
||||
pourquoi: "Relire ce devis avant de le poser sur le cluster."
|
||||
- cible: proxmox-fw-plan
|
||||
nature: mesure
|
||||
pourquoi: "L'ecart entre le pare-feu en service et le devis."
|
||||
- cible: proxmox-fw-appliquer
|
||||
nature: ecriture
|
||||
fixes: {CONFIRMER: "true"}
|
||||
pourquoi: "Poser IPSets, groupes et affectations — et retirer ce qui ne se declare plus."
|
||||
- cible: devis-opnsense
|
||||
nature: mesure
|
||||
pourquoi: "La frontiere nord/sud, derivee du meme registre de flux."
|
||||
- cible: devis-opnsense-verifier
|
||||
nature: mesure
|
||||
pourquoi: "Relire le devis de frontiere : c'est la porte de l'exterieur."
|
||||
- cible: frontiere-plan
|
||||
nature: mesure
|
||||
pourquoi: "Ce que la frontiere porte aujourd'hui, face a ce devis."
|
||||
- cible: frontiere-appliquer
|
||||
nature: ecriture
|
||||
fixes: {CONFIRMER: "true"}
|
||||
pourquoi: >-
|
||||
Reconcilier la frontiere. Les routes creees ETEINTES ont deja coute une journee :
|
||||
une route eteinte compte « posee » et ne route rien.
|
||||
- cible: devis-reseau
|
||||
nature: mesure
|
||||
variables: [DIALECTE]
|
||||
pourquoi: >-
|
||||
Le devis des commutateurs physiques (VLANs, SVIs, ACLs). Il se pose a la main sur
|
||||
le materiel — le moteur ne configure pas les switches.
|
||||
- cible: frontiere-mesurer
|
||||
nature: mesure
|
||||
pourquoi: >-
|
||||
La frontiere refuse-t-elle ce qui n'est pas declare ? Un connect() qui aboutit ne
|
||||
prouve rien : seule la LIVRAISON compte.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
- id: locataire-deployer
|
||||
titre: "Materialiser et deployer la flotte d'un locataire"
|
||||
portee: poste
|
||||
doc: docs/vm-lifecycle.md
|
||||
but: >-
|
||||
Des VM au service rendu : creer les machines manquantes, deployer couche par couche,
|
||||
puis mesurer. Rien n'est « pret » avant la recette.
|
||||
etapes:
|
||||
- cible: flotte-creer
|
||||
nature: ecriture
|
||||
variables: [PARALLELE]
|
||||
pourquoi: >-
|
||||
Les VM manquantes, clonees depuis le gabarit dore. VMID, IP et VLAN sont DERIVES
|
||||
du plan : on ne les saisit nulle part.
|
||||
- cible: deployer-tout
|
||||
nature: ecriture
|
||||
duree: "long"
|
||||
pourquoi: >-
|
||||
Toute la flotte, dans l'ordre des couches. L'ordre vient du graphe de
|
||||
dependances, pas d'une liste tenue a la main.
|
||||
- cible: deployer-groupe
|
||||
nature: ecriture
|
||||
variables: [GROUPE]
|
||||
facultative: true
|
||||
pourquoi: >-
|
||||
Un seul role, sur toute la flotte. C'est le geste d'apres : quand un role a change
|
||||
et qu'on ne veut pas tout rejouer.
|
||||
- cible: appliquer
|
||||
nature: ecriture
|
||||
variables: [GROUPE]
|
||||
facultative: true
|
||||
pourquoi: >-
|
||||
Appliquer un groupe a la flotte. Meme usage que `deployer-groupe`, par le chemin
|
||||
court — sans le graphe des couches.
|
||||
- cible: verifier-deploiement
|
||||
nature: mesure
|
||||
pourquoi: "L'etat de la flotte apres coup — avant de croire que c'est fini."
|
||||
- cible: valider
|
||||
nature: mesure
|
||||
pourquoi: >-
|
||||
La recette de validation sur la flotte. C'est elle qui autorise le mot « pret »,
|
||||
pas l'absence d'erreur rouge.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
- id: machine-une
|
||||
titre: "Ajouter ou reprendre une seule machine"
|
||||
portee: poste
|
||||
doc: docs/vm-lifecycle.md
|
||||
but: >-
|
||||
Le cycle d'UNE machine, du plan au service : la voir derivee, la creer, la deployer,
|
||||
la verifier. Le meme chemin sert pour une machine neuve et pour une machine a
|
||||
reprendre.
|
||||
etapes:
|
||||
- cible: hote-afficher
|
||||
nature: mesure
|
||||
variables: [HOTE]
|
||||
pourquoi: >-
|
||||
Tout ce que le plan derive pour cette machine — avant de la creer, pour verifier
|
||||
qu'on va bien poser ce qu'on croit.
|
||||
- cible: creer-vm
|
||||
nature: ecriture
|
||||
variables: [HOTE]
|
||||
duree: "~4 min 30"
|
||||
pourquoi: "Cloner depuis le gabarit et ATTENDRE que la machine reponde."
|
||||
- cible: deployer
|
||||
nature: ecriture
|
||||
variables: [HOTE]
|
||||
pourquoi: "Les roles de cette machine, couche par couche, dans l'ordre du graphe."
|
||||
- cible: verifier-hote
|
||||
nature: mesure
|
||||
variables: [HOTE]
|
||||
pourquoi: "Le playbook de verification sur cette machine seule."
|
||||
- cible: cloner-vm
|
||||
nature: ecriture
|
||||
variables: [HOTE, VMID]
|
||||
facultative: true
|
||||
pourquoi: >-
|
||||
Cloner SANS passer par le plan. Chemin de reprise : a n'emprunter que lorsque le
|
||||
plan ne peut pas encore deriver la machine.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
- id: flotte-refaire
|
||||
titre: "Raser et reconstruire un ecosysteme"
|
||||
portee: poste
|
||||
doc: docs/vm-lifecycle.md
|
||||
but: >-
|
||||
La preuve la plus dure du moteur : detruire un ecosysteme et le refaire depuis le
|
||||
code seul. A ne lancer que sur un chantier — la prod vit ailleurs.
|
||||
etapes:
|
||||
- cible: raser
|
||||
nature: destructif
|
||||
variables: [INSTANCE]
|
||||
fixes: {CONFIRMER: "true"}
|
||||
pourquoi: >-
|
||||
Detruire les VM derivees du plan. Le nom de l'ecosysteme s'ecrit en toutes
|
||||
lettres : c'est le seul garde-fou qui resiste a un clic distrait.
|
||||
- cible: reconstruire
|
||||
nature: ecriture
|
||||
duree: "long"
|
||||
pourquoi: >-
|
||||
Refaire tout depuis zero : les VM, puis le deploiement complet. Si le code ne
|
||||
suffit pas, c'est ici qu'on l'apprend.
|
||||
- cible: valider
|
||||
nature: mesure
|
||||
pourquoi: "Une reconstruction sans recette n'a rien prouve."
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
- id: gabarit
|
||||
titre: "Le gabarit dore"
|
||||
portee: site
|
||||
doc: docs/procedure-template-debian13-proxmox.md
|
||||
but: >-
|
||||
La VM de reference que toute la flotte clone. Elle nait en q35/OVMF et ne se convertit
|
||||
jamais : convertir depuis i440fx casse interfaces et disques.
|
||||
etapes:
|
||||
- cible: gabarit-etat
|
||||
nature: mesure
|
||||
pourquoi: "Le gabarit porte-t-il ce que le SITE declare ? A regarder avant d'y toucher."
|
||||
- cible: preparer-modele
|
||||
nature: ecriture
|
||||
duree: "long"
|
||||
pourquoi: "Preparer la VM de reference, celle qui sera clonee pour chaque hote."
|
||||
- cible: verifier-modele
|
||||
nature: mesure
|
||||
pourquoi: "Le gabarit tient-il ses promesses avant qu'on le capture ?"
|
||||
- cible: nettoyer-modele
|
||||
nature: destructif
|
||||
fixes: {CONFIRMER: "true"}
|
||||
pourquoi: >-
|
||||
Nettoyer avant capture. Destructif pour la VM de reference : ce qui est efface ne
|
||||
se retrouve pas.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
- id: mesurer
|
||||
titre: "Mesurer sans rien ecrire"
|
||||
portee: tenant
|
||||
doc: docs/devis-services.md
|
||||
but: >-
|
||||
Les devis de service : ils confrontent ce que le plan derive a ce que le systeme rend
|
||||
VRAIMENT, et sortent en erreur s'il y a un ecart. Une tache verte ne prouve pas qu'un
|
||||
service rend son service.
|
||||
etapes:
|
||||
- cible: identite-plan
|
||||
nature: mesure
|
||||
pourquoi: "L'identite deployee face a ce que le plan derive."
|
||||
- cible: certificats-plan
|
||||
nature: mesure
|
||||
pourquoi: >-
|
||||
Les certificats sur disque face a ceux reellement SERVIS. Un cert renouvele mais
|
||||
non recharge reste perime en memoire.
|
||||
- cible: expositions-plan
|
||||
nature: mesure
|
||||
pourquoi: "Chaque exposition du plan repond-elle, depuis l'edge et depuis le poste ?"
|
||||
- cible: expositions-etat
|
||||
nature: mesure
|
||||
pourquoi: "Chaque exposition est-elle servie sous un certificat qui la porte ?"
|
||||
- cible: courriel-plan
|
||||
nature: mesure
|
||||
pourquoi: "La chaine Postfix → LDAP → Dovecot → IMAP, de bout en bout."
|
||||
- cible: postgresql-plan
|
||||
nature: mesure
|
||||
pourquoi: "Le chiffrement impose et la portee reelle des acces."
|
||||
- cible: mtu-mesurer
|
||||
nature: mesure
|
||||
pourquoi: "L'invite porte-t-il le MTU de sa zone ? Un MTU faux ne se voit qu'aux gros paquets."
|
||||
- cible: versions-mesurer
|
||||
nature: mesure
|
||||
pourquoi: "De combien nos epinglages ont-ils vieilli face aux amonts ?"
|
||||
- cible: ports-verifier
|
||||
nature: mesure
|
||||
pourquoi: "Deux roles co-localises revendiquent-ils le meme port ?"
|
||||
- cible: intrants-verifier
|
||||
nature: mesure
|
||||
pourquoi: "Un intrant exige et non fourni est une panne differee."
|
||||
- cible: flux-verifier
|
||||
nature: mesure
|
||||
pourquoi: "Le registre des flux correspond-il encore aux `meta/flux.yml` des roles ?"
|
||||
- cible: site-intrants-verifier
|
||||
nature: mesure
|
||||
pourquoi: "Le locataire monte suit-il encore les intrants de son site ?"
|
||||
- cible: sonder
|
||||
nature: mesure
|
||||
variables: [CIBLE]
|
||||
pourquoi: >-
|
||||
Sonder une cible et DIRE ce qui distingue absence, politique et frontiere. Un
|
||||
« echec » nu ecrase ces trois causes et envoie chercher la panne ailleurs.
|
||||
- cible: faits
|
||||
nature: mesure
|
||||
variables: [LIMITE]
|
||||
pourquoi: "Les faits Ansible de la flotte, quand une hypothese demande un fait."
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
- id: recette
|
||||
titre: "La recette — avant de dire « pret »"
|
||||
portee: tenant
|
||||
doc: docs/audit/plan-de-recette.md
|
||||
but: >-
|
||||
Ce qui separe « ca a tourne sans erreur » de « c'est livrable ». Les preuves lisent le
|
||||
depot ; la recette interroge la flotte. Il faut les deux.
|
||||
etapes:
|
||||
- cible: verifier
|
||||
nature: mesure
|
||||
pourquoi: "Rejouer les preuves sans reecrire le rapport — la verification rapide."
|
||||
- cible: prouver
|
||||
nature: mesure
|
||||
pourquoi: >-
|
||||
Executer les preuves et ECRIRE le rapport date. C'est le document qu'on montre,
|
||||
et celui qu'on relit dans six mois.
|
||||
- cible: valider
|
||||
nature: mesure
|
||||
pourquoi: "La recette de validation sur la flotte reelle."
|
||||
- cible: verifier-deploiement
|
||||
nature: mesure
|
||||
pourquoi: "L'etat de la flotte, apres coup."
|
||||
- cible: inventaire
|
||||
nature: mesure
|
||||
pourquoi: "L'inventaire se verifie et se montre — lab puis production."
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
- id: acces-admin
|
||||
titre: "L'acces d'administration (tunnel WireGuard)"
|
||||
portee: site
|
||||
doc: docs/acces-administration.md
|
||||
but: >-
|
||||
Un tunnel nominatif par locataire, declare par lui et borne a lui. Sans garde, un
|
||||
ecosysteme s'ouvrirait un acces chez son voisin depuis son propre plan.
|
||||
etapes:
|
||||
- cible: vpn-admin-plan
|
||||
nature: mesure
|
||||
pourquoi: "Ce que la frontiere porte aujourd'hui, face aux pairs declares au plan."
|
||||
- cible: vpn-admin-appliquer
|
||||
nature: ecriture
|
||||
fixes: {CONFIRMER: "true"}
|
||||
pourquoi: "Poser l'instance et les pairs declares — et retirer ceux qui ne le sont plus."
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
- id: dns-public
|
||||
titre: "Le DNS public et sa signature"
|
||||
portee: tenant
|
||||
doc: docs/dns-interne.md
|
||||
but: >-
|
||||
Publier les zones du locataire, signees avant d'etre exposees. Les DS partent chez le
|
||||
registraire : c'est le seul maillon que le moteur ne peut pas poser lui-meme.
|
||||
etapes:
|
||||
- cible: dnssec-verifier
|
||||
nature: mesure
|
||||
pourquoi: "Une cle en voute pour chaque zone signee, et des DS conformes au registre."
|
||||
- cible: dnssec-ds
|
||||
nature: mesure
|
||||
pourquoi: >-
|
||||
Les DS a remettre au registraire, calcules depuis la voute du locataire. A porter
|
||||
a la main chez le registraire : aucun automate ne le fera.
|
||||
- cible: dns-bascule-devis
|
||||
nature: mesure
|
||||
pourquoi: >-
|
||||
Basculer nos serveurs de noms changerait-il quelque chose ? Le plan face au DNS
|
||||
reellement en service, avant de toucher a la delegation.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
- id: filiation
|
||||
titre: "Filiation, insemination, emancipation"
|
||||
portee: tenant
|
||||
doc: docs/filiation-emancipation.md
|
||||
but: >-
|
||||
D'ou vient cet ecosysteme, de quoi depend-il encore, et que faut-il couper pour qu'il
|
||||
tienne seul. L'emancipation se mesure ; elle ne se decrete pas.
|
||||
etapes:
|
||||
- cible: genome
|
||||
nature: mesure
|
||||
pourquoi: "Les depots requis a la reproduction de cet ecosysteme, et leur etat."
|
||||
- cible: genome-inscrire
|
||||
nature: ecriture
|
||||
pourquoi: "Inscrire la parente dans l'instance — sans quoi la filiation n'est qu'un souvenir."
|
||||
- cible: genome-verifier
|
||||
nature: mesure
|
||||
pourquoi: "La parente inscrite tient-elle encore ? Le code de sortie repond."
|
||||
- cible: inseminer
|
||||
nature: ecriture
|
||||
variables: [TENANT, HOTE]
|
||||
pourquoi: >-
|
||||
Le SITE amorce le runner d'un locataire, SANS ses secrets. Trois murs connus :
|
||||
apt, pip, et le genome lui-meme.
|
||||
- cible: emancipation-prouver
|
||||
nature: mesure
|
||||
variables: [SERVICE, HOTE]
|
||||
fixes: {CONFIRMER: "true"}
|
||||
pourquoi: >-
|
||||
Prouver qu'un lien est coupe. La mesure instruit ; l'humain decide. Une
|
||||
emancipation automatique serait une expulsion.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
- id: remise
|
||||
titre: "La remise au client"
|
||||
portee: poste
|
||||
doc: docs/remise-au-client.md
|
||||
but: >-
|
||||
Deux temps qui ne se confondent pas : le paquet qu'on remet, puis le re-cle qui
|
||||
mesure la revocation reelle. Tant que le temps 2 n'est pas fait, on detient encore
|
||||
les cles de quelqu'un d'autre.
|
||||
etapes:
|
||||
- cible: remise-recenser
|
||||
nature: mesure
|
||||
pourquoi: "Ce qu'une remise emporterait, sans rien ecrire. A lire avant de fabriquer."
|
||||
- cible: remise-paquet
|
||||
nature: ecriture
|
||||
variables: [VERS]
|
||||
pourquoi: "Temps 1 : le paquet chiffre, et relu apres ecriture."
|
||||
- cible: remise-inscrire
|
||||
nature: ecriture
|
||||
variables: [RECU_PAR, COURRIEL, DANS]
|
||||
pourquoi: >-
|
||||
Inscrire la remise chez le locataire : qui a recu, quand, et dans combien de
|
||||
jours le second temps est du.
|
||||
- cible: remise-verifier
|
||||
nature: mesure
|
||||
pourquoi: "Temps 1 fait ? Temps 2 du, ou echu ? La seule reponse qui compte est datee."
|
||||
- cible: remise-recleer
|
||||
nature: ecriture
|
||||
fixes: {CONFIRMER: "true"}
|
||||
pourquoi: >-
|
||||
Temps 2 : mesurer la revocation REELLE, puis estampiller. On ne coche pas cette
|
||||
case, on la mesure.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
- id: cles
|
||||
titre: "Les cles hors du poste"
|
||||
portee: poste
|
||||
doc: docs/sortir-les-cles-du-poste.md
|
||||
but: >-
|
||||
Ce qui n'existe QUE sur le poste meurt avec lui. Les voutes sortent chiffrees, sur un
|
||||
support qu'on relit — et on refait l'operation a chaque voute nouvelle.
|
||||
etapes:
|
||||
- cible: cles-recenser
|
||||
nature: mesure
|
||||
pourquoi: "Ce qui n'existe que sur ce poste, sans rien ecrire. La liste fait peur, c'est le but."
|
||||
- cible: cles-exporter
|
||||
nature: ecriture
|
||||
variables: [VERS]
|
||||
pourquoi: "Sortir les cles, chiffrees, et les RELIRE apres ecriture."
|
||||
- cible: cles-compagnons
|
||||
nature: ecriture
|
||||
variables: [VERS]
|
||||
pourquoi: >-
|
||||
Deposer le script de restauration sur la cle. Une archive qu'on ne sait pas
|
||||
rouvrir dans cinq ans n'est pas une sauvegarde.
|
||||
- cible: cles-restaurer
|
||||
nature: ecriture
|
||||
variables: [ARCHIVE]
|
||||
pourquoi: "Remettre les cles en place. A eprouver AVANT d'en avoir besoin."
|
||||
- cible: depot-hors-site
|
||||
nature: ecriture
|
||||
variables: [VERS]
|
||||
pourquoi: >-
|
||||
Copier le depot de sauvegarde HORS du site. Le site heberge du chiffre et ne peut
|
||||
pas le juger : la verification suit la cle.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
- id: lire-le-plan
|
||||
titre: "Lire le plan et ce qu'il derive"
|
||||
portee: tenant
|
||||
doc: docs/plan-et-generation.md
|
||||
but: >-
|
||||
Tout ce qui se regarde sans rien changer : les registres du plan, leur validation, et
|
||||
l'inventaire qui en descend. Le premier geste devant un ecosysteme qu'on ne connait pas.
|
||||
etapes:
|
||||
- cible: serveurs
|
||||
nature: mesure
|
||||
pourquoi: "Les serveurs declares au plan."
|
||||
- cible: serveurs-verifier
|
||||
nature: mesure
|
||||
pourquoi: "Le registre des serveurs tient-il ses regles ?"
|
||||
- cible: applications
|
||||
nature: mesure
|
||||
pourquoi: "Les applications declarees au plan."
|
||||
- cible: applications-verifier
|
||||
nature: mesure
|
||||
pourquoi: "Le registre des applications tient-il ses regles ?"
|
||||
- cible: bases
|
||||
nature: mesure
|
||||
pourquoi: "Les bases de donnees declarees au plan."
|
||||
- cible: bases-verifier
|
||||
nature: mesure
|
||||
pourquoi: "Le registre des bases tient-il ses regles ?"
|
||||
- cible: domaines
|
||||
nature: mesure
|
||||
pourquoi: "Les domaines declares au plan."
|
||||
- cible: domaines-verifier
|
||||
nature: mesure
|
||||
pourquoi: "Le registre des domaines tient-il ses regles ?"
|
||||
- cible: inventaire-lister
|
||||
nature: mesure
|
||||
pourquoi: "L'inventaire complet, en JSON — la verite generee."
|
||||
- cible: inventaire-graphe
|
||||
nature: mesure
|
||||
pourquoi: "Le graphe des groupes : qui herite de quoi."
|
||||
- cible: inventaire-hote
|
||||
nature: mesure
|
||||
variables: [HOTE]
|
||||
pourquoi: "Les variables derivees d'une machine, telles qu'Ansible les verra."
|
||||
- cible: inventaire-lab
|
||||
nature: mesure
|
||||
pourquoi: "Le graphe de l'inventaire de laboratoire."
|
||||
- cible: inventaire-production
|
||||
nature: mesure
|
||||
pourquoi: "Le graphe de l'inventaire de production."
|
||||
- cible: config
|
||||
nature: mesure
|
||||
pourquoi: "La configuration Proxmox telle que le moteur la lit."
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
- id: depot-verifier
|
||||
titre: "Verifier le depot avant de livrer"
|
||||
portee: toute
|
||||
doc: AGENTS.md
|
||||
but: >-
|
||||
Ce que le depot se doit a lui-meme : syntaxe, lint, tests, schema, fiches. Rien n'est
|
||||
« pret » sans au moins la syntaxe du playbook touche et l'entree de CHANGELOG.
|
||||
etapes:
|
||||
- cible: syntaxe
|
||||
nature: mesure
|
||||
pourquoi: "La syntaxe de TOUS les playbooks. Jamais declarer pret si elle echoue."
|
||||
- cible: lint
|
||||
nature: mesure
|
||||
pourquoi: "`ansible-lint` sur tout le depot."
|
||||
- cible: test
|
||||
nature: mesure
|
||||
pourquoi: "Les tests unitaires de derivation — nomenclature et inventaire."
|
||||
- cible: ci
|
||||
nature: mesure
|
||||
pourquoi: >-
|
||||
Verifier le depot comme la CI, sur un modele public monte a l'ecart : sans jamais
|
||||
toucher a l'ecosysteme monte.
|
||||
- cible: schema
|
||||
nature: ecriture
|
||||
pourquoi: >-
|
||||
Regenerer le schema du plan depuis les registres et les validateurs. C'est lui qui
|
||||
construit les formulaires : un champ nouveau apparait sans toucher a l'interface.
|
||||
- cible: fiches
|
||||
nature: ecriture
|
||||
variables: [ROLE]
|
||||
pourquoi: "Regenerer la fiche de chaque role, depuis le role lui-meme."
|
||||
- cible: plan-recette
|
||||
nature: ecriture
|
||||
pourquoi: "Regenerer le plan de recette depuis le wiki."
|
||||
- cible: wiki-publier
|
||||
nature: ecriture
|
||||
pourquoi: "Publier le wiki vers la forge, une fois qu'il dit vrai."
|
||||
|
||||
# CE QUE LA CONSOLE N'OFFRE PAS, ET POURQUOI. Une exemption muette serait un oubli
|
||||
# deguise : chaque ligne porte son motif, et la garde refuse une exemption vide.
|
||||
hors_assistant:
|
||||
aide: >-
|
||||
Aide en ligne de commande. La console porte la meme information autrement — chaque
|
||||
etape affiche le libelle lu dans le Makefile.
|
||||
ansible-runtime: >-
|
||||
Prerequis interne, appele par les cibles qui deploient. L'offrir seul donnerait un
|
||||
bouton qui ne fait rien de visible.
|
||||
inventaire-ui: >-
|
||||
C'est cette console elle-meme. Un bouton qui la relance depuis elle-meme n'a pas d'objet.
|
||||
syntaxe-modele: "Couverte par `syntaxe`, qui passe tous les playbooks d'un coup."
|
||||
syntaxe-verification-modele: "Couverte par `syntaxe`."
|
||||
syntaxe-nettoyage: "Couverte par `syntaxe`."
|
||||
syntaxe-verification-hote: "Couverte par `syntaxe`."
|
||||
syntaxe-groupes: "Couverte par `syntaxe`."
|
||||
syntaxe-proxmox: "Couverte par `syntaxe`."
|
||||
model-creer: >-
|
||||
Fabrique un MODELE d'ecosysteme — un geste de mainteneur du moteur, pas d'exploitant.
|
||||
Il se fait au poste, en connaissance du catalogue des modeles.
|
||||
serveurs-bootstrap: >-
|
||||
Chemin de REPRISE : reconstitue le plan depuis un inventaire existant. Il ecrit par
|
||||
dessus le plan, et ne doit pas etre a un clic d'un exploitant qui explore.
|
||||
applications-bootstrap: >-
|
||||
Meme raison que `serveurs-bootstrap` : amorcage de reprise, pas geste courant.
|
||||
cacher-paquets: >-
|
||||
Se fait EN LIGNE depuis le poste, avant de partir sur un site hors ligne. Le runner,
|
||||
lui, est deja derriere la frontiere : le bouton serait au mauvais endroit.
|
||||
ca-racine: >-
|
||||
Recupere la racine de l'AC pour l'installer sur un poste. L'empreinte doit etre
|
||||
verifiee A LA MAIN avant installation — un bouton encouragerait a sauter ce controle.
|
||||
ca-empreinte: >-
|
||||
Le temoin de comparaison de `ca-racine`. Meme raison : il se lit, il ne se clique pas.
|
||||
|
|
@ -272,9 +272,30 @@ POUVOIR_REQUIS = {
|
|||
"/api/deployer": "configurer",
|
||||
"/api/pousser": "configurer",
|
||||
"/api/creer": "materialiser",
|
||||
# LA GARDE DE CETTE ROUTE EST PLUS FINE QUE LES AUTRES, ET C'EST VOULU. Un runbook de
|
||||
# site et un runbook de locataire n'exigent pas le meme pouvoir : un seul mot ici
|
||||
# serait soit trop laxiste (un site lancerait un geste de locataire), soit trop
|
||||
# severe (un locataire ne pourrait plus rien). La portee est DECLAREE au registre,
|
||||
# et c'est elle qu'on resout — jamais celle que le navigateur annonce.
|
||||
"/api/runbook-etape": "selon-le-runbook",
|
||||
}
|
||||
|
||||
|
||||
# CE QU'UNE PORTEE DE RUNBOOK EXIGE DE LA CONSOLE. Les deux pouvoirs de `contexte()`, et
|
||||
# rien d'autre : `poste` porte les deux liens, il doit donc les deux.
|
||||
POUVOIR_PAR_PORTEE = {
|
||||
"tenant": ("configurer",),
|
||||
"site": ("materialiser",),
|
||||
"poste": ("configurer", "materialiser"),
|
||||
"toute": (),
|
||||
}
|
||||
|
||||
# Ce qu'une valeur d'etape a le droit d'etre. Les commandes partent en LISTE (jamais par
|
||||
# un shell), donc l'injection n'est pas le risque ; ce qui l'est, c'est qu'une valeur se
|
||||
# fasse lire par `make` comme une option. On refuse donc ce qui commence par un tiret.
|
||||
MOTIF_VALEUR = re.compile(r"^[^-\s][\w .@:/+=,-]{0,199}$", re.UNICODE)
|
||||
|
||||
|
||||
def _fichier_frontiere() -> Path:
|
||||
"""Les intrants de la frontiere — a la RACINE du depot de site depuis le 2026-08-22.
|
||||
|
||||
|
|
@ -1727,7 +1748,29 @@ HTML = r"""<!doctype html>
|
|||
.re-msg { background: var(--surface-3); border-radius: 6px; padding: 4px 8px; margin-top: 3px; font-family: ui-monospace, monospace; font-size: 11.5px; white-space: pre-wrap; word-break: break-word; }
|
||||
/* Devis réseau (config switch) */
|
||||
.devis-reseau { margin: 8px 0 0; padding: 12px 14px; background: var(--surface-3); border-radius: 8px; font-family: ui-monospace, monospace; font-size: 11.5px; line-height: 1.5; white-space: pre; overflow: auto; max-height: calc(100vh - 210px); }
|
||||
</style>
|
||||
|
||||
/* --- LES ASSISTANTS (runbooks) ------------------------------------------------ */
|
||||
.rb-liste{display:flex;flex-wrap:wrap;gap:8px;margin:2px 0 14px}
|
||||
.rb-puce{border:1px solid var(--bord,#2a3340);background:#131a22;color:inherit;border-radius:999px;
|
||||
padding:6px 13px;font-size:12.5px;font-weight:700;cursor:pointer;display:flex;align-items:center;gap:7px}
|
||||
.rb-puce.on{background:#1d2a38;border-color:#3d6ea8}
|
||||
.rb-puce.hors{opacity:.55}
|
||||
.rb-etape{border:1px solid var(--bord,#2a3340);border-radius:10px;padding:11px 13px;margin-bottom:9px;
|
||||
display:grid;grid-template-columns:30px 1fr auto;gap:11px;align-items:start;background:#10161d}
|
||||
.rb-etape.faite{border-color:#2f6b45}
|
||||
.rb-etape.ratee{border-color:#8a3b3b}
|
||||
.rb-etape.bloquee{opacity:.5}
|
||||
.rb-num{font-weight:800;opacity:.55;text-align:right;padding-top:2px}
|
||||
.rb-cible{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;font-weight:700}
|
||||
.rb-libelle{font-size:12.5px;opacity:.9;margin-top:2px}
|
||||
.rb-pourquoi{font-size:12px;opacity:.72;margin-top:5px;line-height:1.45}
|
||||
.rb-nature{font-size:10.5px;font-weight:800;letter-spacing:.03em;padding:2px 7px;border-radius:999px;margin-left:7px}
|
||||
.rb-nature.mesure{background:#1b2c3a;color:#8fc4f0}
|
||||
.rb-nature.ecriture{background:#2e2716;color:#e6bf6a}
|
||||
.rb-nature.destructif{background:#3a1c1c;color:#f08a8a}
|
||||
.rb-meta{font-size:11.5px;opacity:.6;margin-top:4px}
|
||||
.rb-but{font-size:12.5px;line-height:1.5;opacity:.88}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toasts" id="toasts" aria-live="polite"></div>
|
||||
|
|
@ -1749,6 +1792,7 @@ HTML = r"""<!doctype html>
|
|||
<button type="button" id="btn-vue-flux" onclick="setVue('flux')">Flux</button>
|
||||
<button type="button" id="btn-vue-couches" onclick="setVue('couches')">Couches</button>
|
||||
<button type="button" id="btn-vue-reseau" onclick="setVue('reseau')">Réseau</button>
|
||||
<button type="button" id="btn-vue-assistants" onclick="setVue('assistants')" title="Les runbooks de construction : l'ordre des gestes, et pourquoi celui-là">Assistants</button>
|
||||
</div>
|
||||
<span class="recherche"><input id="filtre" type="search" placeholder="Filtrer…" oninput="filtrer(this.value)"></span>
|
||||
<button type="button" class="fantome" onclick="ouvrirIntrants()">⚙ Intrants</button>
|
||||
|
|
@ -1792,6 +1836,19 @@ HTML = r"""<!doctype html>
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modale" id="etape" hidden>
|
||||
<div class="modale-boite">
|
||||
<div class="modale-titre" id="etape-titre"></div>
|
||||
<div class="modale-texte" id="etape-texte"></div>
|
||||
<div id="etape-champs"></div>
|
||||
<label class="champ"><span>Mot de passe du vault Ansible (laisser vide si non requis)</span>
|
||||
<input type="password" id="etape-vault" autocomplete="off" placeholder="••••••••"></label>
|
||||
<div class="modale-actions">
|
||||
<button type="button" onclick="annulerEtape()">Annuler</button>
|
||||
<button type="button" class="primaire" id="etape-ok" onclick="confirmerEtape()">Lancer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="console" id="console" hidden>
|
||||
<div class="console-tete">
|
||||
<span class="console-titre" id="console-titre"></span>
|
||||
|
|
@ -2050,6 +2107,7 @@ HTML = r"""<!doctype html>
|
|||
document.getElementById('btn-vue-flux').classList.toggle('on', v === 'flux');
|
||||
document.getElementById('btn-vue-couches').classList.toggle('on', v === 'couches');
|
||||
document.getElementById('btn-vue-reseau').classList.toggle('on', v === 'reseau');
|
||||
document.getElementById('btn-vue-assistants').classList.toggle('on', v === 'assistants');
|
||||
dessinerGrilles();
|
||||
dessinerDetail(); // le panneau droit reflète la sélection de la vue courante
|
||||
majIndicateurSauvegarde();
|
||||
|
|
@ -2077,6 +2135,7 @@ HTML = r"""<!doctype html>
|
|||
if (vuePrincipale === 'flux') { dessinerFlux(); return; }
|
||||
if (vuePrincipale === 'couches') { dessinerCouches(); return; }
|
||||
if (vuePrincipale === 'reseau') { dessinerReseau(); return; }
|
||||
if (vuePrincipale === 'assistants') { dessinerAssistants(); return; }
|
||||
dessinerServeurs();
|
||||
}
|
||||
|
||||
|
|
@ -3431,6 +3490,7 @@ HTML = r"""<!doctype html>
|
|||
function dessinerDetail() {
|
||||
// Le panneau de droite reflète la sélection de la VUE COURANTE.
|
||||
if (vuePrincipale === 'flux' || vuePrincipale === 'couches' || vuePrincipale === 'reseau') return legendeLecture();
|
||||
if (vuePrincipale === 'assistants') return inspecteurRunbook();
|
||||
if (vuePrincipale === 'integrations') return legendeIntegrations();
|
||||
if (vuePrincipale === 'applications') return inspecteurApplication();
|
||||
if (vuePrincipale === 'bases') return inspecteurBase();
|
||||
|
|
@ -3718,6 +3778,221 @@ HTML = r"""<!doctype html>
|
|||
occupe = false; majBoutonsDeploiement();
|
||||
}
|
||||
|
||||
|
||||
// ═══ LES ASSISTANTS — un runbook, son ordre, et pourquoi cet ordre-la ═══════════
|
||||
//
|
||||
// CE QUE CETTE VUE AJOUTE. Les autres vues editent le PLAN ; celle-ci conduit des
|
||||
// GESTES. Le Makefile porte 132 cibles qui disent chacune ce qu'elle fait, et aucune
|
||||
// ne dit dans quel ordre. Cette connaissance vivait en prose dans les documents :
|
||||
// l'exploitant devait la lire ailleurs, puis revenir cliquer ici.
|
||||
//
|
||||
// RIEN N'EST ECRIT EN DUR ICI. La liste, les libelles, les natures et les variables
|
||||
// viennent de `/api/runbooks`, qui joint `docs/runbooks-construction.yml` au Makefile
|
||||
// au moment de servir. Une cible renommee se voit donc ici, elle ne s'invente pas.
|
||||
let runbooks = [], rbSel = null, rbEcarts = [], rbEtats = {};
|
||||
let etapeResolveur = null;
|
||||
|
||||
async function chargerRunbooks() {
|
||||
try {
|
||||
const r = await fetch('/api/runbooks', {headers: {'X-Jeton': JETON}});
|
||||
const d = await r.json();
|
||||
runbooks = d.runbooks || [];
|
||||
rbEcarts = d.ecarts || [];
|
||||
if (d.erreur) message('Registre des runbooks : ' + d.erreur, 'erreur', {duree: 9000});
|
||||
if (!rbSel && runbooks.length) rbSel = (runbooks.find(x => x.conduisible) || runbooks[0]).id;
|
||||
} catch (e) { message('Assistants indisponibles : ' + e.message, 'erreur'); }
|
||||
}
|
||||
|
||||
function rbCourant() { return runbooks.find(x => x.id === rbSel) || null; }
|
||||
function rbEtat(id, i) { return (rbEtats[id] || {})[i] || null; }
|
||||
|
||||
// LA SEQUENCE EST UN GARDE-FOU, PAS UNE DECORATION. Une etape qui ECRIT suppose que la
|
||||
// precedente a reussi — sinon on batit sur un terrain qu'on n'a pas verifie. Une
|
||||
// etape qui MESURE reste toujours offerte : mesurer apres un echec est exactement ce
|
||||
// qu'on fait pour comprendre. Les etapes facultatives ne bloquent personne.
|
||||
function etapeBloquee(rb, i) {
|
||||
if (rb.etapes[i].nature === 'mesure') return false;
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
if (rb.etapes[j].facultative) continue;
|
||||
return rbEtat(rb.id, j) !== 'ok';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function dessinerAssistants() {
|
||||
const g = document.getElementById('grilles');
|
||||
if (!runbooks.length) {
|
||||
g.innerHTML = '<div class="hint-dep">Chargement des assistants…</div>';
|
||||
chargerRunbooks().then(() => {
|
||||
if (vuePrincipale === 'assistants') { dessinerAssistants(); dessinerDetail(); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
const puces = runbooks.map(rb => {
|
||||
const n = rb.etapes.length;
|
||||
return '<button type="button" class="rb-puce' + (rb.id === rbSel ? ' on' : '')
|
||||
+ (rb.conduisible ? '' : ' hors') + '" onclick="choisirRunbook(\'' + rb.id + '\')"'
|
||||
+ ' title="' + echapper(rb.conduisible ? rb.but : rb.raison) + '">'
|
||||
+ echapper(rb.titre) + '<span class="chip">' + n + '</span></button>';
|
||||
}).join('');
|
||||
const rb = rbCourant();
|
||||
let corps = '';
|
||||
if (rb) {
|
||||
corps = rb.etapes.map((e, i) => {
|
||||
const etat = rbEtat(rb.id, i);
|
||||
const bloquee = etapeBloquee(rb, i) || !rb.conduisible;
|
||||
const cls = etat === 'ok' ? ' faite' : etat === 'echec' ? ' ratee' : bloquee ? ' bloquee' : '';
|
||||
const vars = (e.variables || []).map(v => v.nom + '=…').join(' ');
|
||||
const fixes = Object.entries(e.fixes || {}).map(([k, v]) => k + '=' + v).join(' ');
|
||||
const meta = [e.duree ? 'durée observée ' + e.duree : '',
|
||||
e.facultative ? 'facultative' : ''].filter(Boolean).join(' · ');
|
||||
return '<div class="rb-etape' + cls + '">'
|
||||
+ '<div class="rb-num">' + (i + 1) + '</div>'
|
||||
+ '<div><div><span class="rb-cible">make ' + echapper(e.cible)
|
||||
+ (vars ? ' ' + echapper(vars) : '') + (fixes ? ' ' + echapper(fixes) : '')
|
||||
+ '</span><span class="rb-nature ' + e.nature + '">' + e.nature + '</span></div>'
|
||||
+ '<div class="rb-libelle">' + echapper(e.libelle || '(aucun libellé au Makefile)') + '</div>'
|
||||
+ '<div class="rb-pourquoi">' + echapper(e.pourquoi) + '</div>'
|
||||
+ (meta ? '<div class="rb-meta">' + echapper(meta) + '</div>' : '')
|
||||
+ (etat === 'ok' ? '<div class="rb-meta">✔ réussie dans cette session</div>' : '')
|
||||
+ (etat === 'echec' ? '<div class="rb-meta">✘ échouée — la suite reste bloquée</div>' : '')
|
||||
+ '</div>'
|
||||
+ '<div><button type="button" class="' + (e.nature === 'destructif' ? '' : 'primaire')
|
||||
+ '" ' + (bloquee ? 'disabled' : '') + ' onclick="lancerEtape(' + i + ')">'
|
||||
+ (e.nature === 'mesure' ? 'Mesurer' : e.nature === 'destructif' ? 'Détruire…' : 'Lancer')
|
||||
+ '</button></div></div>';
|
||||
}).join('');
|
||||
}
|
||||
const alerte = rbEcarts.length
|
||||
? '<div class="hint-dep" style="border-color:#8a3b3b">Le registre des runbooks porte '
|
||||
+ rbEcarts.length + ' écart(s) avec le Makefile : <code>python3 scripts/runbooks.py verifier</code></div>'
|
||||
: '';
|
||||
g.innerHTML = alerte + '<div class="rb-liste">' + puces + '</div>' + corps;
|
||||
}
|
||||
|
||||
function choisirRunbook(id) { rbSel = id; dessinerAssistants(); dessinerDetail(); }
|
||||
|
||||
function inspecteurRunbook() {
|
||||
const d = document.getElementById('detail');
|
||||
const rb = rbCourant();
|
||||
if (!rb) { d.innerHTML = '<div class="detail"><div class="hint-dep">Aucun assistant.</div></div>'; return; }
|
||||
const compte = rb.etapes.filter((e, i) => rbEtat(rb.id, i) === 'ok').length;
|
||||
d.innerHTML = '<div class="detail"><div class="section-tete">' + echapper(rb.titre) + '</div>'
|
||||
+ '<div class="rb-but">' + echapper(rb.but) + '</div>'
|
||||
+ '<div class="hint-dep" style="margin-top:10px">Portée : <code>' + echapper(rb.portee)
|
||||
+ '</code> · ' + compte + '/' + rb.etapes.length + ' étape(s) réussie(s) dans cette session.</div>'
|
||||
+ (rb.conduisible ? '' : '<div class="hint-dep" style="margin-top:10px;border-color:#8a3b3b">'
|
||||
+ echapper(rb.raison) + '</div>')
|
||||
+ (rb.doc ? '<div class="hint-dep" style="margin-top:10px">Le récit complet : <code>'
|
||||
+ echapper(rb.doc) + '</code></div>' : '')
|
||||
+ '<div class="hint-dep" style="margin-top:10px">Les libellés viennent du <code>Makefile</code>, '
|
||||
+ 'lus à chaque affichage ; l\'ordre et le « pourquoi » viennent de '
|
||||
+ '<code>docs/runbooks-construction.yml</code>. Une étape d\'écriture attend que la '
|
||||
+ 'précédente ait réussi ; une mesure reste toujours offerte.</div></div>';
|
||||
}
|
||||
|
||||
// La modale d'etape : les champs que le REGISTRE declare, et rien d'autre.
|
||||
function ouvrirModaleEtape(rb, e, i) {
|
||||
document.getElementById('etape-titre').textContent = (i + 1) + '. make ' + e.cible;
|
||||
const avert = e.nature === 'destructif'
|
||||
? '⚠️ GESTE DESTRUCTIF. ' : e.nature === 'ecriture' ? 'Ce geste écrit. ' : 'Aucune écriture. ';
|
||||
document.getElementById('etape-texte').textContent = avert + e.pourquoi;
|
||||
const champs = document.getElementById('etape-champs');
|
||||
let html = (e.variables || []).map(v => {
|
||||
if (v.valeurs) {
|
||||
return '<label class="champ"><span>' + echapper(v.invite || v.nom)
|
||||
+ (v.facultatif ? ' (facultatif)' : '') + '</span><select id="var-' + v.nom + '">'
|
||||
+ (v.facultatif ? '<option value=""></option>' : '')
|
||||
+ v.valeurs.map(x => '<option>' + echapper(x) + '</option>').join('') + '</select></label>';
|
||||
}
|
||||
return '<label class="champ"><span>' + echapper(v.invite || v.nom)
|
||||
+ (v.facultatif ? ' (facultatif)' : '') + '</span><input id="var-' + v.nom
|
||||
+ '" autocomplete="off" placeholder="' + echapper(v.exemple || '') + '"></label>';
|
||||
}).join('');
|
||||
// LA CONFIRMATION ECRITE EST LE SEUL GARDE-FOU QUI RESISTE A UN CLIC DISTRAIT.
|
||||
if (e.nature === 'destructif') {
|
||||
html += '<label class="champ"><span>Pour confirmer, écris <b>DETRUIRE</b></span>'
|
||||
+ '<input id="var-__confirmation" autocomplete="off"></label>';
|
||||
}
|
||||
champs.innerHTML = html;
|
||||
document.getElementById('etape-vault').value = motVault || '';
|
||||
document.getElementById('etape').hidden = false;
|
||||
return new Promise(res => { etapeResolveur = res; });
|
||||
}
|
||||
function confirmerEtape() {
|
||||
const rb = rbCourant(); if (!rb) return;
|
||||
const r = etapeResolveur;
|
||||
const boite = document.getElementById('etape-champs');
|
||||
const valeurs = {};
|
||||
let confirmation = null;
|
||||
boite.querySelectorAll('input,select').forEach(el => {
|
||||
const nom = el.id.replace(/^var-/, '');
|
||||
if (nom === '__confirmation') confirmation = el.value.trim();
|
||||
else valeurs[nom] = el.value.trim();
|
||||
});
|
||||
document.getElementById('etape').hidden = true;
|
||||
etapeResolveur = null;
|
||||
if (r) r({valeurs, vault: document.getElementById('etape-vault').value, confirmation});
|
||||
}
|
||||
function annulerEtape() {
|
||||
document.getElementById('etape').hidden = true;
|
||||
const r = etapeResolveur; etapeResolveur = null;
|
||||
if (r) r(null);
|
||||
}
|
||||
|
||||
async function lancerEtape(i) {
|
||||
const rb = rbCourant(); if (!rb || occupe) return;
|
||||
const e = rb.etapes[i];
|
||||
if (!rb.conduisible) { message(rb.raison, 'erreur', {duree: 9000}); return; }
|
||||
if (etapeBloquee(rb, i)) { message('L\'étape précédente n\'a pas réussi.', 'erreur'); return; }
|
||||
const rep0 = await ouvrirModaleEtape(rb, e, i);
|
||||
if (rep0 === null) return;
|
||||
if (e.nature === 'destructif' && rep0.confirmation !== 'DETRUIRE') {
|
||||
message('Confirmation annulée : le mot ne concorde pas.', 'erreur'); return;
|
||||
}
|
||||
motVault = rep0.vault;
|
||||
occupe = true; majBoutonsDeploiement();
|
||||
ouvrirConsole(rb.titre + ' — étape ' + (i + 1), e.cible);
|
||||
try {
|
||||
const rep = await fetch('/api/runbook-etape', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON},
|
||||
body: JSON.stringify({runbook: rb.id, cible: e.cible, index: i,
|
||||
valeurs: rep0.valeurs, vault: rep0.vault})
|
||||
});
|
||||
if (!rep.ok || !rep.body) {
|
||||
const d = await rep.json().catch(() => ({}));
|
||||
ecrireConsole((d.erreur || 'Refusé.') + '\n'); finirConsole(1);
|
||||
occupe = false; majBoutonsDeploiement(); return;
|
||||
}
|
||||
const lecteur = rep.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let rc = null, brut = '';
|
||||
while (true) {
|
||||
const {value, done} = await lecteur.read();
|
||||
if (done) break;
|
||||
let texte = dec.decode(value, {stream: true});
|
||||
brut += texte;
|
||||
const m = texte.match(/__FIN__ rc=(-?\d+)/);
|
||||
if (m) rc = parseInt(m[1], 10);
|
||||
texte = texte.replace(/\n?__RESUME__ .*\n?/, '').replace(/\n?__FIN__ rc=-?\d+\n?/, '');
|
||||
if (texte) ecrireConsole(texte);
|
||||
}
|
||||
if (rc === null) rc = 1;
|
||||
const mr = brut.match(/__RESUME__ (.+)/);
|
||||
if (rc !== 0 && mr) { try { afficherResumeErreur(JSON.parse(mr[1])); } catch (err) {} }
|
||||
finirConsole(rc);
|
||||
rbEtats[rb.id] = rbEtats[rb.id] || {};
|
||||
rbEtats[rb.id][i] = rc === 0 ? 'ok' : 'echec';
|
||||
message(rc === 0 ? 'Étape ' + (i + 1) + ' réussie.' : 'Étape ' + (i + 1) + ' en échec.',
|
||||
rc === 0 ? 'ok' : 'erreur');
|
||||
} catch (err) {
|
||||
ecrireConsole('Erreur client : ' + err.message + '\n'); finirConsole(1);
|
||||
rbEtats[rb.id] = rbEtats[rb.id] || {}; rbEtats[rb.id][i] = 'echec';
|
||||
}
|
||||
occupe = false; majBoutonsDeploiement();
|
||||
if (vuePrincipale === 'assistants') { dessinerAssistants(); dessinerDetail(); }
|
||||
}
|
||||
|
||||
function sauvegarderVue() {
|
||||
if (occupe) return;
|
||||
if (vuePrincipale === 'serveurs' || vuePrincipale === 'integrations') sauvegarderServeurs();
|
||||
|
|
@ -4026,6 +4301,123 @@ class Gestionnaire(BaseHTTPRequestHandler):
|
|||
pass
|
||||
VERROU.release()
|
||||
|
||||
def executer_runbook(self, runbook: str, cible: str, valeurs: dict,
|
||||
vault: str | None = None, index: int | None = None) -> None:
|
||||
"""Une etape DECLAREE d'un runbook — et rien d'autre.
|
||||
|
||||
LE NAVIGATEUR NE NOMME PAS UNE COMMANDE, IL NOMME UNE PLACE. On ne lance pas
|
||||
« la cible que la page demande » : on lance ce que le registre declare a cette
|
||||
place-la, avec les seules variables qu'il declare. Une page compromise, ou
|
||||
simplement perimee, ne peut donc pas reclamer `raser` depuis un runbook de mesure.
|
||||
|
||||
C'est la meme discipline que `POUVOIR_REQUIS` : ce qui protege n'est pas un bouton
|
||||
grise, c'est un serveur qui refuse — et qui DIT pourquoi.
|
||||
"""
|
||||
try:
|
||||
import runbooks as _rb
|
||||
etape = _rb.etape_declaree(runbook, cible, RACINE, index)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.repondre_json(500, {"erreur": f"Registre des runbooks illisible : {exc}"})
|
||||
return
|
||||
if etape is None:
|
||||
self.repondre_json(409, {"erreur": f"« {cible} » n'est pas une etape declaree "
|
||||
f"du runbook « {runbook} ». Rien n'a ete lance."})
|
||||
return
|
||||
|
||||
# Les variables : celles du registre, dans l'ordre du registre.
|
||||
args: list[str] = []
|
||||
for v in etape["variables"]:
|
||||
brut = str(valeurs.get(v["nom"], "")).strip()
|
||||
if not brut:
|
||||
if v.get("facultatif"):
|
||||
continue
|
||||
self.repondre_json(400, {"erreur": f"{v['nom']} est requis pour cette etape."})
|
||||
return
|
||||
if not MOTIF_VALEUR.match(brut):
|
||||
self.repondre_json(400, {"erreur": f"{v['nom']} : valeur refusee (caracteres "
|
||||
f"interdits, ou commence par un tiret)."})
|
||||
return
|
||||
if v.get("valeurs") and brut not in v["valeurs"]:
|
||||
self.repondre_json(400, {"erreur": f"{v['nom']} : « {brut} » n'est pas une "
|
||||
f"valeur admise ({', '.join(v['valeurs'])})."})
|
||||
return
|
||||
args.append(f"{v['nom']}={brut}")
|
||||
# Les valeurs IMPOSEES par l'etape (CONFIRMER=true et compagnie) ne viennent
|
||||
# jamais du navigateur : une confirmation qu'on peut envoyer soi-meme n'en est pas
|
||||
# une. Le garde-fou reel est la confirmation ECRITE exigee cote page, plus la
|
||||
# garde propre a la cible.
|
||||
for clef, val in (etape["fixes"] or {}).items():
|
||||
args.append(f"{clef}={val}")
|
||||
|
||||
if not VERROU.acquire(blocking=False):
|
||||
self.repondre_json(409, {"erreur": "Une execution est deja en cours."})
|
||||
return
|
||||
fichier_vault = None
|
||||
journal = None
|
||||
try:
|
||||
env = dict(os.environ, PYTHONUNBUFFERED="1", ANSIBLE_FORCE_COLOR="0")
|
||||
if vault:
|
||||
fd, fichier_vault = tempfile.mkstemp(prefix="setops-vault-")
|
||||
os.write(fd, str(vault).encode("utf-8"))
|
||||
os.close(fd)
|
||||
env["ANSIBLE_VAULT_PASSWORD_FILE"] = fichier_vault
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
# Un journal par execution. Chez un locataire il vit dans l'instance ; sur un
|
||||
# runner de site il n'y a pas d'instance, et `tmp/` est deja hors depot.
|
||||
dossier_logs = (INSTANCE / "logs") if INSTANCE.exists() else (RACINE / "tmp" / "logs")
|
||||
dossier_logs.mkdir(parents=True, exist_ok=True)
|
||||
chemin_log = dossier_logs / f"{runbook}-{cible}-{time.strftime('%Y%m%dT%H%M%S')}.log"
|
||||
journal = open(chemin_log, "w", encoding="utf-8")
|
||||
entete = f"$ make {cible} {' '.join(args)}\n\n"
|
||||
journal.write(entete)
|
||||
self._ecrire_flux(entete)
|
||||
tache_courante = ""
|
||||
echecs: list[dict] = []
|
||||
proc = subprocess.Popen(
|
||||
["make", cible, *args],
|
||||
cwd=str(RACINE), env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1,
|
||||
)
|
||||
try:
|
||||
for ligne in proc.stdout:
|
||||
journal.write(ligne)
|
||||
if ligne.startswith("TASK ["):
|
||||
deb, fin = ligne.find("["), ligne.find("]")
|
||||
if -1 < deb < fin:
|
||||
tache_courante = ligne[deb + 1:fin]
|
||||
elif ("FAILED!" in ligne or "UNREACHABLE!" in ligne) and \
|
||||
("fatal:" in ligne or ligne.lstrip().startswith("failed:")):
|
||||
echec = _extraire_echec(ligne, tache_courante)
|
||||
if echec:
|
||||
echecs.append(echec)
|
||||
if not self._ecrire_flux(ligne):
|
||||
for reste in proc.stdout:
|
||||
journal.write(reste)
|
||||
break
|
||||
finally:
|
||||
rc = proc.wait()
|
||||
if rc != 0 and echecs:
|
||||
self._ecrire_flux("__RESUME__ " + json.dumps({"echecs": echecs[:6]},
|
||||
ensure_ascii=False) + "\n")
|
||||
journal.write(f"\n__FIN__ rc={rc}\n")
|
||||
self._ecrire_flux(f"\n[journal : {os.path.relpath(chemin_log, RACINE)}]\n__FIN__ rc={rc}\n")
|
||||
finally:
|
||||
if journal:
|
||||
try:
|
||||
journal.close()
|
||||
except OSError:
|
||||
pass
|
||||
if fichier_vault:
|
||||
try:
|
||||
os.remove(fichier_vault)
|
||||
except OSError:
|
||||
pass
|
||||
VERROU.release()
|
||||
|
||||
def do_GET(self) -> None:
|
||||
chemin = urlparse(self.path).path
|
||||
try:
|
||||
|
|
@ -4065,6 +4457,30 @@ class Gestionnaire(BaseHTTPRequestHandler):
|
|||
})
|
||||
except Exception as exc:
|
||||
self.repondre_json(200, {"config": "", "erreur": str(exc)})
|
||||
elif chemin == "/api/runbooks":
|
||||
# Import paresseux, comme les devis : un registre casse ne doit pas
|
||||
# emporter le reste de la console avec lui.
|
||||
try:
|
||||
import runbooks as _rb
|
||||
ctx = contexte()
|
||||
liste = []
|
||||
for rb in _rb.assembler(RACINE):
|
||||
exiges = POUVOIR_PAR_PORTEE.get(rb["portee"], ())
|
||||
manquants = [p for p in exiges if not ctx["peut"].get(p)]
|
||||
liste.append({
|
||||
**rb,
|
||||
# ON NE CACHE PAS CE QU'ON NE PEUT PAS FAIRE. Un runbook hors
|
||||
# de portee reste VISIBLE et se lit : savoir que le geste
|
||||
# existe, et chez qui il se fait, fait partie du metier.
|
||||
"conduisible": not manquants,
|
||||
"raison": ("" if not manquants else
|
||||
ctx["raison"].get(manquants[0], "Hors de portee "
|
||||
"de cette console.")),
|
||||
})
|
||||
self.repondre_json(200, {"runbooks": liste, "contexte": ctx,
|
||||
"ecarts": _rb.verifier(RACINE)})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.repondre_json(200, {"runbooks": [], "erreur": str(exc)})
|
||||
elif chemin == "/api/instances":
|
||||
import instances as _inst
|
||||
import instance_creer as _ic
|
||||
|
|
@ -4097,6 +4513,27 @@ class Gestionnaire(BaseHTTPRequestHandler):
|
|||
# sans raison envoie chercher une panne la ou il n'y en a pas.
|
||||
ctx = contexte()
|
||||
besoin = POUVOIR_REQUIS.get(chemin)
|
||||
if besoin == "selon-le-runbook":
|
||||
# LA PORTEE VIENT DU REGISTRE, PAS DU CORPS DE LA REQUETE. Le navigateur nomme
|
||||
# un runbook ; c'est le fichier declare qui dit ce que ce runbook exige.
|
||||
besoin = None
|
||||
try:
|
||||
import runbooks as _rb
|
||||
vise = str(donnees.get("runbook", ""))
|
||||
portee_rb = next((r["portee"] for r in _rb.assembler(RACINE)
|
||||
if r["id"] == vise), None)
|
||||
except Exception: # noqa: BLE001
|
||||
portee_rb = None
|
||||
if portee_rb is None:
|
||||
self.repondre_json(409, {"erreur": f"Runbook inconnu : « {donnees.get('runbook', '')} »."})
|
||||
return
|
||||
manquants = [p for p in POUVOIR_PAR_PORTEE.get(portee_rb, ())
|
||||
if not ctx["peut"].get(p)]
|
||||
if manquants:
|
||||
self.repondre_json(409, {"erreur": ctx["raison"].get(manquants[0],
|
||||
"Geste hors de la portee de cette console."),
|
||||
"portee": ctx["portee"], "pouvoir": manquants[0]})
|
||||
return
|
||||
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."),
|
||||
|
|
@ -4155,6 +4592,13 @@ class Gestionnaire(BaseHTTPRequestHandler):
|
|||
self.executer_flux(str(donnees.get("hote", "")), "deployer", donnees.get("vault"))
|
||||
elif chemin == "/api/creer":
|
||||
self.executer_flux(str(donnees.get("hote", "")), "creer", donnees.get("vault"))
|
||||
elif chemin == "/api/runbook-etape":
|
||||
_idx = donnees.get("index")
|
||||
self.executer_runbook(str(donnees.get("runbook", "")),
|
||||
str(donnees.get("cible", "")),
|
||||
donnees.get("valeurs") or {},
|
||||
donnees.get("vault"),
|
||||
int(_idx) if isinstance(_idx, int) else None)
|
||||
elif chemin == "/api/pousser":
|
||||
self.executer_flux(str(donnees.get("hote", "")), "pousser", donnees.get("vault"))
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -4559,6 +4559,59 @@ def preuve_dns_public_tient() -> tuple[bool, str]:
|
|||
f"cotes ; transfert ouvert a la cle seule ; aucune exposition sans DNSSEC.")
|
||||
|
||||
|
||||
# --- LES ASSISTANTS (P83) : un registre qui ne peut pas prendre de retard ------------
|
||||
|
||||
|
||||
def preuve_runbooks_suivent_le_makefile() -> tuple[bool, str]:
|
||||
"""Le registre des runbooks decrit-il encore le moteur reel ?
|
||||
|
||||
POURQUOI (2026-09-20). Le Makefile porte 132 cibles documentees ; aucune ne dit dans
|
||||
quel ORDRE elles se jouent. Cet ordre vivait en prose dans les documents, et la
|
||||
console offrait des boutons sans sequence. `docs/runbooks-construction.yml` le declare
|
||||
— et devient, par construction, une SECONDE LISTE a cote du Makefile.
|
||||
|
||||
Une liste qui suit une autre prend du retard sur elle : c'est arrive quatre fois dans
|
||||
ce depot en une seule journee. La regle qui en est sortie est d'ecrire la garde EN
|
||||
MEME TEMPS que la seconde liste. Cette preuve EST cette garde.
|
||||
|
||||
CE QU'ELLE REFUSE :
|
||||
|
||||
1. une etape qui vise une cible que le Makefile ne porte pas (cible renommee) ;
|
||||
2. une cible documentee que nul runbook ne porte et que nul motif n'exempte — la
|
||||
console cacherait alors un pouvoir que le moteur possede ;
|
||||
3. une cible a la fois portee et exemptee, ou une exemption sans motif ;
|
||||
4. une portee de runbook que la console ne saurait pas traduire en pouvoir : la
|
||||
garde fine de `/api/runbook-etape` laisserait passer un geste qu'elle ne sait
|
||||
pas peser.
|
||||
|
||||
CE QU'ELLE NE PROUVE PAS : qu'une etape REUSSIT. Elle lit le depot, elle ne lance
|
||||
rien — comme les 82 autres.
|
||||
"""
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
import runbooks as _rb
|
||||
import inventory_gui as _gui
|
||||
|
||||
ecarts = _rb.verifier(RACINE)
|
||||
|
||||
# La console doit savoir peser CHAQUE portee que le registre emploie.
|
||||
portees = {rb["portee"] for rb in _rb.assembler(RACINE)}
|
||||
inconnues = sorted(portees - set(_gui.POUVOIR_PAR_PORTEE))
|
||||
for p in inconnues:
|
||||
ecarts.append(f"portee « {p} » employee par un runbook mais absente de "
|
||||
f"POUVOIR_PAR_PORTEE : la garde de /api/runbook-etape ne saurait "
|
||||
f"pas quoi exiger.")
|
||||
|
||||
if ecarts:
|
||||
return False, (f"{len(ecarts)} ecart(s) entre le registre des runbooks et le "
|
||||
f"moteur : " + " | ".join(ecarts[:4]))
|
||||
|
||||
rbs = _rb.assembler(RACINE)
|
||||
etapes = sum(len(r["etapes"]) for r in rbs)
|
||||
cibles = len(_rb.cibles_du_makefile(RACINE))
|
||||
return True, (f"{len(rbs)} runbooks, {etapes} etapes, {cibles} cibles documentees : "
|
||||
f"chacune portee par un assistant ou exemptee avec son motif.")
|
||||
|
||||
|
||||
PREUVES: list[dict] = [
|
||||
{"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"],
|
||||
"cmds": [["ansible-lint", "-q"]]},
|
||||
|
|
@ -4743,6 +4796,8 @@ PREUVES: list[dict] = [
|
|||
"refs": [], "func": preuve_console_dit_sa_portee},
|
||||
{"id": "P82", "titre": "DNS public : les zones publiees sont servies, signees avant d'etre exposees",
|
||||
"refs": [], "func": preuve_dns_public_tient},
|
||||
{"id": "P83", "titre": "Assistants : le registre des runbooks ne prend pas de retard sur le Makefile",
|
||||
"refs": [], "func": preuve_runbooks_suivent_le_makefile},
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
260
scripts/runbooks.py
Normal file
260
scripts/runbooks.py
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Les runbooks de construction : l'ordre des gestes, et la garde qui l'empeche de mentir.
|
||||
|
||||
CE QUE CE MODULE RESOUT (2026-09-20). Le Makefile porte 132 cibles documentees. Elles
|
||||
disent chacune ce qu'elle fait, aucune ne dit DANS QUEL ORDRE ni POURQUOI MAINTENANT.
|
||||
Un exploitant qui decouvre un site n'a donc aucun moyen de savoir que `site-creer` vient
|
||||
avant `forge-amorcer`, que le premier passage s'arrete sur une forge vide, ni que rien
|
||||
n'est « pret » avant `valider`. Cette connaissance vit dans les documents, en prose, et
|
||||
la console ne la porte pas : elle offre des boutons sans sequence.
|
||||
|
||||
CE QUI EST DECLARE ICI, ET CE QUI NE L'EST PAS. `docs/runbooks-construction.yml` declare
|
||||
l'ORDRE, la NATURE (mesure / ecriture / destructif), la PORTEE et le POURQUOI de chaque
|
||||
geste. Il ne recopie JAMAIS le libelle d'une cible : celui-ci est lu dans le Makefile au
|
||||
moment de servir. Une seconde liste qui recopierait la premiere prendrait du retard sur
|
||||
elle — c'est arrive quatre fois dans ce depot en une seule journee, et la regle qui en
|
||||
est sortie est d'ecrire la garde EN MEME TEMPS que la seconde liste.
|
||||
|
||||
LA GARDE, C'EST `verifier()`. Elle refuse quatre etats :
|
||||
|
||||
- une etape qui vise une cible que le Makefile n'a pas (le registre a pris du retard) ;
|
||||
- une cible documentee que nul runbook ne porte et que nul motif n'exempte (la console
|
||||
cacherait un pouvoir que le moteur possede) ;
|
||||
- une cible a la fois portee et exemptee (deux verites, aucune) ;
|
||||
- une valeur hors vocabulaire : nature, portee, ou variable inconnue du catalogue.
|
||||
|
||||
P83 rejoue cette garde. Sans elle, ce fichier deviendrait une carte perimee — et une
|
||||
carte perimee est pire que pas de carte, parce qu'on la suit.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
RACINE = Path(__file__).resolve().parent.parent
|
||||
REGISTRE = RACINE / "docs/runbooks-construction.yml"
|
||||
|
||||
# Le vocabulaire ferme du registre. Une valeur hors de ces ensembles est refusee : mieux
|
||||
# vaut un registre qui bloque qu'une console qui interprete une nature qu'elle ignore.
|
||||
NATURES = {
|
||||
# `mesure` n'ecrit rien et peut etre rejouee sans consequence. La console la propose
|
||||
# toujours, meme quand l'etape precedente a echoue : mesurer pour comprendre est
|
||||
# exactement ce qu'on fait apres un echec.
|
||||
"mesure",
|
||||
# `ecriture` change l'etat du monde. La console exige que l'etape precedente ait
|
||||
# reussi, sinon on batit sur un terrain qu'on n'a pas verifie.
|
||||
"ecriture",
|
||||
# `destructif` detruit. La console exige une confirmation ecrite, en plus de la
|
||||
# variable CONFIRMER que la cible reclame deja d'elle-meme.
|
||||
"destructif",
|
||||
}
|
||||
|
||||
# La portee, au sens de `contexte()` dans la console : ce que la MACHINE porte.
|
||||
# tenant — un ecosysteme est monte (`instance/`), on a sa voute : on configure.
|
||||
# site — une fabric est montee (`underlay.yml`) : on materialise.
|
||||
# poste — les deux, l'atelier du mainteneur.
|
||||
# toute — ni l'un ni l'autre n'est requis (verifications du depot lui-meme).
|
||||
PORTEES = {"tenant", "site", "poste", "toute"}
|
||||
|
||||
MOTIF_CIBLE = re.compile(r"^([a-z0-9][a-z0-9_-]*):[^#\n]*##\s*(.+?)\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
def cibles_du_makefile(racine: Path = RACINE) -> dict[str, str]:
|
||||
"""Les cibles DOCUMENTEES du Makefile, avec leur libelle — la source unique.
|
||||
|
||||
Une cible sans `##` est un detail d'implementation (`_instance-requise`,
|
||||
`ansible-runtime`) : elle n'est pas offerte a l'exploitant, donc elle n'est pas
|
||||
attendue dans un runbook.
|
||||
"""
|
||||
texte = (racine / "Makefile").read_text(encoding="utf-8")
|
||||
return {nom: aide for nom, aide in MOTIF_CIBLE.findall(texte)}
|
||||
|
||||
|
||||
def charger(chemin: Path = REGISTRE) -> dict:
|
||||
"""Le registre brut, tel qu'il est ecrit. Aucune jointure, aucune validation."""
|
||||
if not chemin.is_file():
|
||||
raise FileNotFoundError(f"Registre des runbooks introuvable : {chemin}")
|
||||
data = yaml.safe_load(chemin.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"{chemin} ne contient pas une table YAML.")
|
||||
return data
|
||||
|
||||
|
||||
def assembler(racine: Path = RACINE, chemin: Path | None = None) -> list[dict]:
|
||||
"""Les runbooks, joints au Makefile — la forme que la console sert.
|
||||
|
||||
Le libelle de chaque etape vient d'`aide`, lue dans le Makefile a CET instant. Le
|
||||
registre n'en porte pas de copie, donc il ne peut pas en porter une perimee.
|
||||
"""
|
||||
data = charger(chemin or REGISTRE)
|
||||
aides = cibles_du_makefile(racine)
|
||||
catalogue = data.get("variables", {}) or {}
|
||||
sortie = []
|
||||
for rb in data.get("runbooks", []) or []:
|
||||
etapes = []
|
||||
for etape in rb.get("etapes", []) or []:
|
||||
cible = etape.get("cible", "")
|
||||
etapes.append({
|
||||
"cible": cible,
|
||||
# LE LIBELLE N'EST PAS DANS LE REGISTRE. Il est lu ici, chaque fois.
|
||||
"libelle": aides.get(cible, ""),
|
||||
"nature": etape.get("nature", "mesure"),
|
||||
"pourquoi": etape.get("pourquoi", ""),
|
||||
"duree": etape.get("duree", ""),
|
||||
"variables": [
|
||||
{"nom": v, **(catalogue.get(v, {}) or {})}
|
||||
for v in (etape.get("variables", []) or [])
|
||||
],
|
||||
"fixes": etape.get("fixes", {}) or {},
|
||||
"facultative": bool(etape.get("facultative", False)),
|
||||
})
|
||||
sortie.append({
|
||||
"id": rb.get("id", ""),
|
||||
"titre": rb.get("titre", ""),
|
||||
"portee": rb.get("portee", "toute"),
|
||||
"but": rb.get("but", ""),
|
||||
"doc": rb.get("doc", ""),
|
||||
"etapes": etapes,
|
||||
})
|
||||
return sortie
|
||||
|
||||
|
||||
def etape_declaree(runbook_id: str, cible: str, racine: Path = RACINE,
|
||||
index: int | None = None) -> dict | None:
|
||||
"""L'etape declaree, ou None — LE point de controle du serveur.
|
||||
|
||||
La console ne lance JAMAIS ce que le navigateur lui nomme : elle lance ce que le
|
||||
registre declare a cette place-la. Un navigateur qui reclamerait `raser` dans un
|
||||
runbook de mesure n'obtient rien, et c'est le seul verrou qui compte.
|
||||
|
||||
L'INDEX COMPTE PARCE QU'UNE CIBLE PEUT REVENIR. « Le premier jour d'un site » lance
|
||||
`site-deployer-tout` DEUX fois — le premier passage s'arrete sur la forge vide, le
|
||||
second doit finir a zero echec. Chercher par nom seul confondrait les deux places.
|
||||
Quand l'appelant donne un index, la cible doit s'y trouver : sinon la page et le
|
||||
registre ne parlent pas de la meme chose, et on ne lance rien.
|
||||
"""
|
||||
for rb in assembler(racine):
|
||||
if rb["id"] != runbook_id:
|
||||
continue
|
||||
etapes = rb["etapes"]
|
||||
if index is not None:
|
||||
if not (0 <= index < len(etapes)) or etapes[index]["cible"] != cible:
|
||||
return None
|
||||
return {**etapes[index], "portee": rb["portee"], "runbook": rb["titre"]}
|
||||
for etape in etapes:
|
||||
if etape["cible"] == cible:
|
||||
return {**etape, "portee": rb["portee"], "runbook": rb["titre"]}
|
||||
return None
|
||||
|
||||
|
||||
def verifier(racine: Path = RACINE, chemin: Path | None = None) -> list[str]:
|
||||
"""La garde. Rend la liste des ecarts — vide quand le registre dit vrai."""
|
||||
data = charger(chemin or REGISTRE)
|
||||
aides = cibles_du_makefile(racine)
|
||||
catalogue = data.get("variables", {}) or {}
|
||||
hors = data.get("hors_assistant", {}) or {}
|
||||
ecarts: list[str] = []
|
||||
portees_vues: dict[str, set[str]] = {}
|
||||
|
||||
vues: set[str] = set()
|
||||
ids: set[str] = set()
|
||||
for rb in data.get("runbooks", []) or []:
|
||||
rid = rb.get("id", "")
|
||||
if not rid:
|
||||
ecarts.append("Un runbook n'a pas d'identifiant.")
|
||||
continue
|
||||
if rid in ids:
|
||||
ecarts.append(f"{rid} : deux runbooks portent cet identifiant.")
|
||||
ids.add(rid)
|
||||
if rb.get("portee") not in PORTEES:
|
||||
ecarts.append(f"{rid} : portee « {rb.get('portee')} » hors vocabulaire "
|
||||
f"({', '.join(sorted(PORTEES))}).")
|
||||
if not rb.get("but"):
|
||||
ecarts.append(f"{rid} : aucun but declare — un runbook sans but ne se choisit pas.")
|
||||
if not (rb.get("etapes") or []):
|
||||
ecarts.append(f"{rid} : aucune etape.")
|
||||
for etape in rb.get("etapes", []) or []:
|
||||
cible = etape.get("cible", "")
|
||||
if cible not in aides:
|
||||
ecarts.append(f"{rid} → {cible} : le Makefile ne porte pas cette cible "
|
||||
f"(registre en retard, ou cible renommee).")
|
||||
vues.add(cible)
|
||||
portees_vues.setdefault(cible, set()).add(rb.get("portee", ""))
|
||||
if etape.get("nature") not in NATURES:
|
||||
ecarts.append(f"{rid} → {cible} : nature « {etape.get('nature')} » hors "
|
||||
f"vocabulaire ({', '.join(sorted(NATURES))}).")
|
||||
if not etape.get("pourquoi"):
|
||||
ecarts.append(f"{rid} → {cible} : aucun « pourquoi ». Une etape qui ne dit "
|
||||
f"pas pourquoi elle est la se fait sauter.")
|
||||
for v in etape.get("variables", []) or []:
|
||||
if v not in catalogue:
|
||||
ecarts.append(f"{rid} → {cible} : la variable {v} n'est pas au "
|
||||
f"catalogue — la console ne saurait pas quoi demander.")
|
||||
|
||||
# LA COUVERTURE, DANS LES DEUX SENS.
|
||||
for cible in sorted(set(aides) - vues - set(hors)):
|
||||
ecarts.append(f"{cible} : cible documentee que nul runbook ne porte et que nul "
|
||||
f"motif n'exempte. La console cacherait un pouvoir du moteur.")
|
||||
for cible in sorted(vues & set(hors)):
|
||||
ecarts.append(f"{cible} : portee par un runbook ET declaree hors assistant — "
|
||||
f"deux verites, aucune.")
|
||||
for cible in sorted(set(hors) - set(aides)):
|
||||
ecarts.append(f"{cible} : exemptee alors que le Makefile ne la porte pas "
|
||||
f"(exemption devenue sans objet).")
|
||||
for cible, motif in sorted(hors.items()):
|
||||
if not str(motif or "").strip():
|
||||
ecarts.append(f"{cible} : exemptee sans motif. Une exemption muette est un oubli.")
|
||||
return ecarts
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("action", choices=("verifier", "lister", "couverture"),
|
||||
nargs="?", default="verifier")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.action == "verifier":
|
||||
ecarts = verifier()
|
||||
if ecarts:
|
||||
print(f"Registre des runbooks : {len(ecarts)} ecart(s).\n")
|
||||
for e in ecarts:
|
||||
print(f" - {e}")
|
||||
return 1
|
||||
rbs = assembler()
|
||||
etapes = sum(len(r["etapes"]) for r in rbs)
|
||||
print(f"Registre des runbooks : {len(rbs)} runbooks, {etapes} etapes, "
|
||||
f"{len(cibles_du_makefile())} cibles documentees, aucun ecart.")
|
||||
return 0
|
||||
|
||||
if args.action == "lister":
|
||||
for rb in assembler():
|
||||
print(f"\n{rb['id']} [{rb['portee']}] {rb['titre']}")
|
||||
print(f" {rb['but']}")
|
||||
for i, e in enumerate(rb["etapes"], 1):
|
||||
marque = {"mesure": " ", "ecriture": "*", "destructif": "!"}[e["nature"]]
|
||||
var = (" " + " ".join(f"{v['nom']}=…" for v in e["variables"])) if e["variables"] else ""
|
||||
print(f" {marque} {i:2}. make {e['cible']}{var}")
|
||||
print(f" {e['libelle']}")
|
||||
return 0
|
||||
|
||||
# couverture
|
||||
aides = cibles_du_makefile()
|
||||
data = charger()
|
||||
vues = {e.get("cible") for rb in data.get("runbooks", []) or []
|
||||
for e in rb.get("etapes", []) or []}
|
||||
hors = set(data.get("hors_assistant", {}) or {})
|
||||
print(f"cibles documentees : {len(aides)}")
|
||||
print(f" portees par un runbook : {len(vues & set(aides))}")
|
||||
print(f" hors assistant (motivees) : {len(hors & set(aides))}")
|
||||
print(f" ni l'un ni l'autre : {len(set(aides) - vues - hors)}")
|
||||
for c in sorted(set(aides) - vues - hors):
|
||||
print(f" - {c} ({aides[c]})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
148
scripts/tests/test_runbooks.py
Normal file
148
scripts/tests/test_runbooks.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Le registre des runbooks : sa garde attrape-t-elle vraiment le retard ?
|
||||
|
||||
P83 verifie que le registre REEL dit vrai aujourd'hui. Ce n'est pas la meme chose que
|
||||
verifier que la GARDE fonctionne : un `verifier()` qui rendrait toujours une liste vide
|
||||
passerait P83 tous les jours, et le jour ou le registre prendrait du retard, personne ne
|
||||
le saurait. On lui presente donc quatre registres FAUX, un par forme de retard, et on
|
||||
exige qu'elle les refuse.
|
||||
|
||||
La cinquieme verification porte sur l'index : « Le premier jour d'un site » lance
|
||||
`site-deployer-tout` deux fois, et les deux places n'ont pas le meme sens. Chercher une
|
||||
etape par son nom seul confondrait les deux.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
RACINE = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
|
||||
import runbooks as rb # noqa: E402
|
||||
|
||||
SOURCE = (RACINE / "docs/runbooks-construction.yml").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _registre(texte: str) -> Path:
|
||||
fd, nom = tempfile.mkstemp(suffix=".yml")
|
||||
os.close(fd)
|
||||
p = Path(nom)
|
||||
p.write_text(texte, encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
class TestGardeDesRunbooks(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.temporaires: list[Path] = []
|
||||
|
||||
def tearDown(self):
|
||||
for p in self.temporaires:
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
def _verifier(self, texte: str) -> list[str]:
|
||||
p = _registre(texte)
|
||||
self.temporaires.append(p)
|
||||
return rb.verifier(RACINE, p)
|
||||
|
||||
def test_le_registre_reel_ne_porte_aucun_ecart(self):
|
||||
"""Le socle : ce que P83 mesure aussi, mais ici sans le harnais complet."""
|
||||
self.assertEqual(rb.verifier(RACINE), [])
|
||||
|
||||
def test_une_cible_que_le_makefile_ne_porte_plus_est_refusee(self):
|
||||
"""Le cas le plus courant : une cible renommee, un registre qui suit avec retard."""
|
||||
faux = SOURCE.replace("- cible: site-creer", "- cible: site-creer-ancien-nom", 1)
|
||||
ecarts = self._verifier(faux)
|
||||
self.assertTrue(any("ne porte pas cette cible" in e for e in ecarts), ecarts)
|
||||
|
||||
def test_une_cible_documentee_oubliee_est_refusee(self):
|
||||
"""L'oubli silencieux : la console cacherait un pouvoir que le moteur possede."""
|
||||
bloc = """ - cible: dns-bascule-devis
|
||||
nature: mesure
|
||||
pourquoi: >-
|
||||
Basculer nos serveurs de noms changerait-il quelque chose ? Le plan face au DNS
|
||||
reellement en service, avant de toucher a la delegation.
|
||||
"""
|
||||
self.assertIn(bloc, SOURCE)
|
||||
ecarts = self._verifier(SOURCE.replace(bloc, "", 1))
|
||||
self.assertTrue(any("nul runbook ne porte" in e for e in ecarts), ecarts)
|
||||
|
||||
def test_une_etape_sans_pourquoi_est_refusee(self):
|
||||
"""Une etape qui ne dit pas pourquoi elle est la se fait sauter par l'exploitant."""
|
||||
bloc = """ - cible: lint
|
||||
nature: mesure
|
||||
pourquoi: "`ansible-lint` sur tout le depot."
|
||||
"""
|
||||
self.assertIn(bloc, SOURCE)
|
||||
faux = SOURCE.replace(bloc, """ - cible: lint
|
||||
nature: mesure
|
||||
""", 1)
|
||||
ecarts = self._verifier(faux)
|
||||
self.assertTrue(any("aucun « pourquoi »" in e for e in ecarts), ecarts)
|
||||
|
||||
def test_une_exemption_sans_objet_est_refusee(self):
|
||||
"""Une exemption qui survit a sa cible devient un mensonge tranquille."""
|
||||
ecarts = self._verifier(SOURCE + '\n cible-qui-n-existe-pas: "motif quelconque"\n')
|
||||
self.assertTrue(any("exemption devenue sans objet" in e for e in ecarts), ecarts)
|
||||
|
||||
def test_une_nature_hors_vocabulaire_est_refusee(self):
|
||||
"""La console ne sait pas peser une nature qu'elle ignore : elle doit bloquer."""
|
||||
faux = SOURCE.replace(""" - cible: lint
|
||||
nature: mesure""", """ - cible: lint
|
||||
nature: peut-etre""", 1)
|
||||
ecarts = self._verifier(faux)
|
||||
self.assertTrue(any("hors" in e and "vocabulaire" in e for e in ecarts), ecarts)
|
||||
|
||||
def test_une_variable_hors_catalogue_est_refusee(self):
|
||||
"""Sans invite declaree, la console afficherait un champ libre sans le nommer."""
|
||||
faux = SOURCE.replace(" variables: [HOTE]",
|
||||
" variables: [MACHIN]", 1)
|
||||
ecarts = self._verifier(faux)
|
||||
self.assertTrue(any("n'est pas au catalogue" in e for e in ecarts), ecarts)
|
||||
|
||||
|
||||
class TestEtapeDeclaree(unittest.TestCase):
|
||||
"""Le point de controle du serveur : une PLACE, pas un nom."""
|
||||
|
||||
def test_une_cible_absente_du_runbook_ne_se_trouve_pas(self):
|
||||
self.assertIsNone(rb.etape_declaree("locataire-naitre", "raser", RACINE))
|
||||
|
||||
def test_l_index_doit_designer_la_meme_cible(self):
|
||||
etapes = next(r for r in rb.assembler(RACINE) if r["id"] == "locataire-naitre")["etapes"]
|
||||
i = next(i for i, e in enumerate(etapes) if e["cible"] == "instance-courante")
|
||||
self.assertIsNotNone(rb.etape_declaree("locataire-naitre", "instance-courante", RACINE, i))
|
||||
# Le meme nom, une autre place : on ne lance rien.
|
||||
autre = 0 if i != 0 else 1
|
||||
self.assertIsNone(rb.etape_declaree("locataire-naitre", "instance-courante", RACINE, autre))
|
||||
|
||||
def test_une_cible_qui_revient_garde_ses_deux_places(self):
|
||||
"""`site-deployer-tout` est joue DEUX fois, et les deux n'ont pas le meme sens."""
|
||||
rbk = next(r for r in rb.assembler(RACINE) if r["id"] == "site-premier-jour")
|
||||
places = [i for i, e in enumerate(rbk["etapes"]) if e["cible"] == "site-deployer-tout"]
|
||||
self.assertEqual(len(places), 2, "le runbook du premier jour doit porter deux passages")
|
||||
for i in places:
|
||||
self.assertIsNotNone(
|
||||
rb.etape_declaree("site-premier-jour", "site-deployer-tout", RACINE, i))
|
||||
|
||||
def test_le_libelle_vient_du_makefile_et_non_du_registre(self):
|
||||
"""La preuve que le registre ne recopie rien : le libelle est celui du Makefile."""
|
||||
aides = rb.cibles_du_makefile(RACINE)
|
||||
for rbk in rb.assembler(RACINE):
|
||||
for etape in rbk["etapes"]:
|
||||
self.assertEqual(etape["libelle"], aides[etape["cible"]])
|
||||
# Et le registre BRUT n'en porte aucune copie : on lit la donnee, pas le texte
|
||||
# (le mot « libelle » apparait dans les commentaires, qui expliquent justement
|
||||
# pourquoi il ne doit pas y etre).
|
||||
brut = rb.charger(RACINE / "docs/runbooks-construction.yml")
|
||||
for rbk in brut["runbooks"]:
|
||||
for etape in rbk["etapes"]:
|
||||
self.assertNotIn("libelle", etape,
|
||||
f"{rbk['id']} → {etape['cible']} recopie un libelle")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
|
@ -133,6 +133,46 @@ d'instance, devis de configuration des switches.
|
|||
|
||||
---
|
||||
|
||||
### La vue **Assistants** — l'ordre des gestes, pas seulement les gestes
|
||||
|
||||
Les huit autres vues éditent le **plan**. Celle-ci conduit des **gestes**, et elle répond à un
|
||||
trou précis : le `Makefile` porte **132 cibles documentées** qui disent chacune *ce qu'elle
|
||||
fait*, et **aucune ne dit dans quel ordre**. Un exploitant devant un site neuf n'avait aucun
|
||||
moyen d'apprendre, depuis la console, que `site-creer` précède `forge-amorcer`, que le premier
|
||||
passage **s'arrête** sur une forge vide, ni que rien n'est « prêt » avant `valider`. Cette
|
||||
connaissance vivait en prose dans les documents ; la console offrait des boutons sans séquence.
|
||||
|
||||
**Dix-sept assistants, 126 étapes, et la totalité des 132 cibles.** Chaque cible documentée est
|
||||
soit portée par un assistant, soit **exemptée avec son motif** — et `P83` refuse qu'une cible
|
||||
échappe aux deux. Sans cette garde, la console cacherait un pouvoir que le moteur possède.
|
||||
|
||||
**Le registre ne recopie pas le Makefile.** `docs/runbooks-construction.yml` déclare seulement
|
||||
ce que le Makefile ne peut pas porter : l'**ordre**, la **nature** du geste, la **portée** et le
|
||||
**pourquoi**. Le libellé de chaque étape est *lu dans le Makefile au moment de servir*. Une
|
||||
cible renommée se voit donc immédiatement, elle ne s'invente pas — c'est la règle « une liste
|
||||
qui suit une autre prend du retard », et la garde est écrite **en même temps** que la liste.
|
||||
|
||||
**Trois natures, trois comportements** :
|
||||
|
||||
| Nature | Ce que la console fait |
|
||||
|---|---|
|
||||
| `mesure` | n'écrit rien ; **toujours offerte**, même après un échec — mesurer pour comprendre est exactement ce qu'on fait ensuite |
|
||||
| `écriture` | exige que l'étape précédente **non facultative** ait réussi ; sinon on bâtit sur un terrain non vérifié |
|
||||
| `destructif` | exige d'**écrire `DETRUIRE`**, en plus du `CONFIRMER=true` que la cible réclame déjà |
|
||||
|
||||
**Le navigateur ne nomme pas une commande, il nomme une place.** `/api/runbook-etape` ne lance
|
||||
jamais « la cible que la page demande » : il lance ce que le registre déclare **à cet index-là**,
|
||||
avec les seules variables déclarées. Une page compromise — ou simplement périmée — ne peut donc
|
||||
pas réclamer `raser` depuis un assistant de mesure. L'index compte : « Le premier jour d'un
|
||||
site » lance `site-deployer-tout` **deux fois**, et les deux places n'ont pas le même sens.
|
||||
|
||||
**La portée décide, et elle se dérive.** Un assistant de site exige `materialiser`, un assistant
|
||||
de locataire `configurer`, un assistant de poste **les deux**. Un assistant hors de portée reste
|
||||
**visible et lisible** : savoir que le geste existe, et chez qui il se fait, fait partie du
|
||||
métier. Ce qui est refusé, c'est de le lancer — et le refus dit sa raison.
|
||||
|
||||
---
|
||||
|
||||
## ③ Pourquoi c'est transférable
|
||||
|
||||
| Set-OPS | Équivalents ailleurs |
|
||||
|
|
@ -168,5 +208,8 @@ saisie** — pas « le GUI de Set-OPS ».
|
|||
## Pour aller plus loin *(dépôt)*
|
||||
- Lancer : `make inventaire-ui` ; le code : `scripts/inventory_gui.py`.
|
||||
- Ce que le GUI sait écrire (et la preuve) : `scripts/couverture_gui.py` (P19).
|
||||
- Les assistants : `docs/runbooks-construction.yml`, lus par `scripts/runbooks.py` (P83).
|
||||
Vérifier le registre sans lancer la console : `python3 scripts/runbooks.py verifier`,
|
||||
et le lire en texte : `python3 scripts/runbooks.py lister`.
|
||||
- Le flux plan → apply : unité **[Infra as Code & idempotence](Infra-as-Code-et-idempotence)**.
|
||||
- La flotte : unité **[Multi-instance & fédération](Multi-instance-et-fédération)**.
|
||||
|
|
|
|||
Loading…
Reference in a new issue