Compare commits

..

10 commits

Author SHA1 Message Date
89c911ef05 journal : l insemination aboutit — un runner de tenant, ne d un runner de site
Some checks are pending
verifier / verifier (push) Waiting to run
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 16:21:39 -04:00
58ce3dfea4 forge du site : le marqueur manquant — le genome n etait ouvert a personne
MEME PATRON QUE serveur_artefacts + serveur_cache_site : un installateur, un
marqueur. Le site rend DEUX services a ses locataires pendant leur jeunesse, les
PAQUETS et le GENOME. Le premier etait declare, le second ne l etait pas — alors
que D-81 fait de cette forge l autorite dont tout ecosysteme se reproduit.

Mesure sur ops-01, premiere machine de la reconstruction de Chezlepro :

    cache du SITE 10.0.33.21:3142 : OK
    forge du SITE 10.0.33.11:443  : BLOQUE

Le plan du tenant declarait pourtant lire son genome a cette adresse. UNE
DEPENDANCE DECLAREE CHEZ LE CONSOMMATEUR, SANS FLUX CHEZ LE FOURNISSEUR — et rien
ne le signalait : la garde de matrice de resoudre_flux ne verifie que les paires
role->role, jamais un pair symbolique comme voisins_site.

POURQUOI UN ROLE A PART : ajouter cet ingress a serveur_forgejo aurait ouvert LA
FORGE DE CHAQUE TENANT a ses voisins. La responsabilite appartient a une machine
precise, pas au logiciel qu elle fait tourner.

Il verifie que la forge ECOUTE vraiment : sans ca il attribuerait l autorite du
genome a un port muet, et l ecosysteme venu s y reproduire attendrait sans savoir
pourquoi — ce qui est exactement arrive, quinze minutes durant.

TROIS GARDES ONT TRAVAILLE : le catalogue a refuse un role qu il ne nomme pas, la
carte a corrige ses deux chiffres, et P49 a exige la regeneration du registre.
P33 a impose partage: true — le marqueur emprunte l ecoute de serveur_forgejo.

Frontiere : 4 objets crees, 0 retire. Verifie depuis ops-01 : 443 OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 16:15:42 -04:00
aca63011ea roues : pip par le meme chemin que les collections — le controleur telecharge, la cible n installe rien du dehors
LA DERNIERE DEPENDANCE QUI SORTAIT PAR ELLE-MEME. Les collections Ansible suivent
depuis longtemps l idiome de ce depot : le CONTROLEUR telecharge une fois puis
pousse par SSH, aucun flux nouveau depuis la cible, et l artefact devient
deployable hors ligne. pip sortait encore vers PyPI, en HTTPS et par nom.

Un tenant n a NI DNS SORTANT NI 443 vers l Internet, et c est voulu. Mesure sur
ops-01, premiere machine de la reconstruction de Chezlepro :

    ERROR: Could not find a version that satisfies the requirement ansible-core
    Echec temporaire dans la resolution du nom

PAS LE PAQUET DEBIAN : trixie propose ansible-core 2.19.4, hors de la plage
epinglee. Y aller demanderait de deplacer l epinglage vers une version majeure aux
changements de gabarits connus, trois jours apres l avoir pose pour cause de
portabilite.

--no-index EST LE POINT, PAS UNE OPTIMISATION. Sans lui, pip resterait capable de
sortir vers PyPI le jour ou le depot local serait incomplet : la reussite
dependrait d un flux que ce tenant n a pas le droit d avoir, et une lignee
autonome deviendrait une lignee qui a l air autonome.

LE CONTROLEUR ET LA CIBLE DOIVENT PARTAGER LEUR PYTHON, et ca ne se devine pas.
pyyaml porte du C compile ; une roue cp313 posee sur un python 3.12 ne s installe
pas, et l echec accuserait le depot local au lieu de l ecart entre deux machines.
Mesure et dit ici, ou on peut encore le nommer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 15:37:09 -04:00
ceb832eeac journal : l amorcage des artefacts, et deux diagnostics faux corriges par la mesure
L explication du commit precedent avait ete mangee par le shell (backticks), et le
garde-fou du genome a refuse l amend — l histoire de la forge fait foi (D-81). Elle
vit donc ici, avec ce que la mesure a corrige : un port muet lu comme un pare-feu
alors qu il mesurait une absence, et un acces cru perdu qui n etait qu une exception
retiree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 15:08:35 -04:00
5e4d711b97 socle : la source d'artefacts d'amorcage — nourrir la premiere machine
LA PREMIERE MACHINE D'UN ECOSYSTEME N'A NI RESOLVEUR NI CACHE, ET C'EST ELLE QUI
DOIT LES CONSTRUIRE. Mesure sur ops-01, premiere machine de la reconstruction de
Chezlepro : le socle restait bloque sur son premier apt update.

    sortie Internet TCP 443    OK
    DNS UDP 53 -> 9.9.9.9      MUET

Le DNS sortant est ferme PAR CONCEPTION — un tenant resout chez lui. Or apt vise
deb.debian.org par son NOM, et le cache de l'ecosysteme n'existe pas encore.

UN MANDATAIRE HTTP RESOUT CE QUE LE CLIENT NE PEUT PAS RESOUDRE : apt lui envoie
l'URL absolue et c'est lui qui traduit. La machine n'a besoin d'aucun DNS, seulement
d'une ADRESSE. Symetrique exacte de , meme place dans le socle.

AUCUN FLUX A OUVRIR :  accepte deja le 3142 depuis
, qui resout les SUPERNETS des tenants — donc toute machine, pas
seulement leur cache. Verifie depuis ops-01 : HTTP 200 en 0,158 s.

C'EST DE LA FILIATION, PAS UNE RUSTINE. L'emprunt est declare (l'intrant nomme
l'amont) et il s'efface :  ecrit 00-setops-artefacts, qui trie
APRES, donc gagne des que l'ecosysteme a sa source. Le plancher reste dessous,
comme /etc/hosts reste sous le DNS. Le retirer le jour venu est une emancipation —
un geste, pas un effet.

DIAGNOSTIC CORRIGE EN CHEMIN : j'avais lu "BLOQUE" vers le resolveur, le cache et
la PKI du tenant comme un pare-feu. C'etait une ABSENCE — ops-01 est la seule VM de
Chezlepro qui existe. Un port muet ressemble a un refus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 15:05:11 -04:00
45aaf2808d runner : armer un runner est un acte declare, jamais un defaut
La separation faite, la distribution devient sure. `serveur_ops_site` et
`serveur_ops_tenant` deposent desormais LA CLE de la voute qu'ils deposaient deja
chiffree. Le runner du site l'a recue : une seule cle chez lui, en 0600, et il
ouvre sa voute tout seul. La fabric, pas un tenant.

`false` PAR DEFAUT, ET CE N'EST PAS DECORATIF. Armer est le moment ou un humain
remet la cle — le seul geste que la reproduction exige de lui. Un defaut a `true`
armerait des machines sans que personne ne l'ait decide. Les deux roles exigent
une declaration au plan et refusent d'armer sans qu'on dise AVEC QUELLE cle :
poser un fichier vide laisserait un runner se croire arme.

ECRIRE PUIS RELIRE, contre la faute SYMETRIQUE de celle de la voute : la on
craignait un chiffre devenu clair, ici on craint une cle qui serait une voute, ou
vide — Ansible accepte un mot de passe vide et n'ouvre rien. Existence, taille et
mode mesures apres ecriture.

CE QUE CA CHANGE : le mot de passe se tapait a chaque deploiement. Un geste repete
vingt fois par semaine ne prouve rien, et il rendait tout deploiement non
interactif impossible sans blocage. Il se remet une fois, et c'est un evenement.

P32 A TROUVE LA MOITIE MANQUANTE : le role du tenant exigeait
`serveur_ops_tenant_cle_source`, absente du plan de Chezlepro. Dit avant tout
deploiement, dans les bons termes.

make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec, 0 saute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 14:46:29 -04:00
377462b141 voutes : une voute, une cle — separer avant de distribuer
Decision de l'exploitant : chaque runner est maitre de sa voute et en detient la
cle. C'est ce qui rend un runner autonome, donc ce qui rend l'emancipation
atteignable. Elle exigeait un prealable, mesure ce matin : UN SEUL mot de passe
ouvrait les SIX voutes de la flotte, celle de la fabric comprise.

DISTRIBUER AVANT DE SEPARER AURAIT ETE PIRE QUE LE STATU QUO : poser « la » cle
sur chaque runner rendait chaque runner capable d'ouvrir les autres. Compromettre
le plus petit locataire donnait les secrets de l'hebergeur.

Cinq cles, une par ecosysteme, sous ~/.config/setops-vault-<depot>. Verification
croisee apres rechiffrement : la diagonale, et rien qu'elle. L'ancienne cle
maitresse n'ouvre plus aucune des six.

UN SEUL MOT DE PASSE NE POUVAIT PLUS SUFFIRE, et pas pour la raison qu'on croit :
`cloner_vm_debian.yml` charge la voute du TENANT puis celle de l'UNDERLAY dans la
meme execution. `ANSIBLE_VAULT_IDENTITY_LIST` en porte plusieurs et les essaie
toutes — un seul export suffit pour les 28 appels a ansible-playbook, sans en
toucher un seul. La liste se derive dans scripts/voutes.py.

CE QUI BORNE LE POUVOIR N'EST PAS LA LISTE MAIS LA PRESENCE DES FICHIERS. Sur le
poste, toutes les cles sont la — c'est l'humain qui les detient toutes, et P03 lit
les inventaires de tous les freres. Sur un runner, une seule existe. Le code est
identique, le pouvoir ne l'est pas.

DEUX PREUVES ONT DIT CE QUI MANQUAIT. P03 est tombee des la separation : elle lit
les inventaires voisins, donc il lui faut leurs cles — c'est elle qui a etabli que
la liste devait couvrir le voisinage. P16 s'est mise a SAUTER : sa garde ne
connaissait que ANSIBLE_VAULT_PASSWORD_FILE. Une preuve sautee se lit trop
facilement comme une preuve passee.

COUT ASSUME : le chiffre et sa cle cohabiteront sur la meme machine des que les
runners recevront la leur. Le pari tient parce que le perimetre est borne.

Les six voutes sauvegardees avant rechiffrement. L'ancienne cle maitresse reste
sur le poste : la retirer est une decision, pas un nettoyage.

make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec, 0 saute — sans
ANSIBLE_VAULT_PASSWORD_FILE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 14:23:33 -04:00
ef832d11d6 insemination : la cle d'amorcage, bornee au meme groupe que le flux
Le flux etait declare et applique ; il manquait l'IDENTITE. Le runner du SITE
atteignait la porte de ops-01 sans avoir de cle.

LE PIEGE COMPTE PLUS QUE LE CORRECTIF. La cle s'injecte au CLONAGE, et `creer-vm`
cree TOUTES les machines d'un tenant. L'injecter a chaque clonage aurait donne a
l'hebergeur un acces SSH a la flotte entiere de chaque locataire, en silence — ca
aurait defait a la couche IDENTITE ce que le pare-feu venait de borner a la couche
RESEAU. Le meme critere gouverne donc les deux : porter `serveur_ops_tenant`.
`SETOPS_CLES_AMORCAGE` est vide partout ailleurs. Elle S'AJOUTE a celle de
l'exploitant, elle ne la remplace pas : c'est l'humain qui arme.

La cle vient du PLAN DU SITE, pas du disque local : materialiser depuis le poste
et depuis le runner doit produire la meme VM.

DEUX COUCHES MANGEAIENT LES ESPACES. Proxmox rendait `SSH public key validation
error` — message muet sur la cause. Isole par un CONTROLE (rejouer sans la cle :
la tache passe), puis par la mesure de ce qui arrivait au module :

    "sshkeys": "ssh-ed25519"     <- le premier mot, rien d'autre

J'ai accuse `make` d'abord ; c'etait `ansible-playbook -e cle=valeur`, qui decoupe
AU SHLEX. D'ou l'environnement pour le transport et `-e '{...}'` en JSON pour
l'entree. (Un scalaire YAML plie ne produit pas non plus de saut de ligne.)

TROIS TESTS QUI NE GARDAIENT RIEN. `test_inventory_host` inscrit ses tests dans une
liste explicite ; mes deux nouveaux n'y etaient pas — definis, jamais joues. La
garde d'exhaustivite ajoutee en a trouve un TROISIEME le jour meme,
`test_etiquette_vlan_repli_et_vide_explicite`, jamais inscrit depuis sa creation :
inscrit, il levait un KeyError sur une fixture qu'il lisait mal. Un test non
inscrit est pire qu'un test absent : on croit l'avoir.

PREUVE, AVEC SON CONTROLE NEGATIF :

    runner du SITE -> ops-01        ops-01  10.17.19.41/24   entre
    runner du SITE -> 10.17.19.21   Connection timed out     refuse

make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 13:45:44 -04:00
d3ba520500 insemination : declarer le lien, l'emettre d'un seul cote, et un test rouge
L'insemination avait un nom depuis ce matin ; elle n'avait pas de flux. Deux
declarations, aux deux bouts, et rien d'autre :

    serveur_ops_site     egress  22/tcp -> serveur_ops_tenant
    serveur_ops_tenant   ingress 22/tcp <- runner_site         partage: true

ETROIT PAR CONSTRUCTION : il vise le GROUPE `serveur_ops_tenant`, qu'un ecosysteme
ne pose que sur une machine. Au socle, il aurait ouvert le SSH du site vers toute
la flotte du tenant.

L'en-tete disait « ce role n'entre JAMAIS chez un tenant ». Frontiere intenable :
`creer-vm` exige `_instance-requise`, et le runner du site avait deja du basculer
son symlink `instance` sur OPS-Chezlepro pour materialiser ses VM. Declarer ne cree
pas ce pouvoir — ca rend limitable un pouvoir qui s'exercait sans borne. Ce qui
reste interdit n'est pas une regle mais un FAIT : il n'a pas la voute du tenant.

LA REGLE EST EMISE D'UN SEUL COTE, et pas celui qu'on croit. Le paquet penetre le
pare-feu par la patte du SITE, pas par le transit : la regle appartient au cote
site du devis. L'emettre aussi depuis l'`ingress` du tenant aurait produit une
seconde regle sur la mauvaise interface — jamais evaluee, indiscernable d'une regle
utile. La declaration du tenant pose sa regle nftables, et elle seule :

    ip saddr { 10.0.31.11 } tcp dport 22 accept

L'adresse DERIVE du plan du site. Ecrite a la main, elle aurait survecu au prochain
deplacement du runner sans bruit — le site a deja deplace ses machines le 08-25.

Plan de la frontiere : 2 objets a creer, 0 a retirer, 126 inchanges. RIEN D'APPLIQUE.

P41 APPLIQUEE AU PLAN DU SITE : `resoudre_flux` en avait besoin a son tour ; les
trois lecteurs demenagent dans `underlay` et `devis_opnsense` delegue.

DEUX GARDES ONT TRAVAILLE : P33 a refuse `ingress 22` sur un hote portant deja le
sshd du socle (reponse : `partage: true`, comme `serveur_backup`), et le devis a
refuse d'emettre vers un alias vide.

UN TEST ROUGE DEPUIS TROIS JOURS. `test_adressage_derive` construisait un site avec
un `index` — or un SITE n'en a pas depuis add94f2 (08-25), remplace par
`bande_basse_de`. Invisible parce que le geste quotidien est `make prouver`, qui ne
joue pas les tests. Remis sur le contrat actuel, avec sa contrepartie : sans
`bande_basse_de`, aucun chevauchement n'est tolere.

make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 13:09:28 -04:00
c6f2cb1dd9 filiation : nommer l'insemination, et rendre a l'humain l'acte d'emancipation
La parente est la seule exception au zero-confiance, et elle est asymetrique :
un ecosysteme neuf ne peut pas s'amorcer lui-meme. Le SITE materialise le
terrain et amorce `ops-01` ; le tenant acheve le reste. Ce geste n'avait pas de
nom, et un geste sans nom ne se borne pas.

LE PARTAGE D'INTELLIGENCE QUI LE REND POSSIBLE, plus net que « le SITE ignore les
roles » : les deux runners portent le meme moteur et n'en consomment pas la meme
face. Le SITE lit `meta/flux.yml` — ce que les roles exigent du RESEAU — et en
tire SDN, pare-feu Proxmox, frontiere. Le tenant lit tasks/templates/integration
— ce qu'ils exigent de la MACHINE. C'est ce partage qui permet au SITE de poser
les bonnes regles sans jamais lire la configuration d'un tenant.

LE COUPLAGE EXISTAIT DEJA, NON DECLARE. `creer-vm` exige `_instance-requise` :
le runner du SITE a du basculer son symlink `instance` sur OPS-Chezlepro pour
materialiser ses VM, alors que son plan pose `serveur_ops_instance: ""`. Rien ne
borne sur quels tenants il porte ni jusqu'a quand.

L'EMANCIPATION EXIGE LA GOUVERNE D'UN HUMAIN. J'avais propose une condition
d'extinction MESUREE — le lien se ferme quand la machine constate l'autonomie.
Erreur symetrique de celle qu'on corrigeait, et le document disait deja pourquoi.
Le fruit de l'emancipation est livre a quelqu'un ; sans quelqu'un pour le
recevoir, il n'y a pas de livraison, seulement un lien qui tombe. Une
emancipation automatique serait une expulsion. Quatre lignes distinctes : le
moteur porte le lien, la machine INSTRUIT, l'humain ACTE, la machine PROUVE.

DEUX LIMITES NOMMEES PLUTOT QUE TUES.

Qui est le parent ? D-81 vide patient 0 de sa raison d'etre — plus un seul
lecteur depuis le 26 — et la machine hors flotte qu'il existe pour remplacer
alimente la forge qui fait autorite : le SPOF n'a pas ete elimine, il a ete
promu. Trois issues posees, aucune tranchee, aucune bloquante.

La separation des voutes est ORGANISATIONNELLE, pas cryptographique : les
fichiers sont separes (site-ops-01 ne detient que underlay.vault.yml) mais un
seul mot de passe ouvre les trois.

make prouver : CONFORME, 52 OK, 0 echec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 12:31:57 -04:00
35 changed files with 1725 additions and 94 deletions

View file

@ -1,5 +1,475 @@
# CHANGELOG — Set-OPS
## 2026-08-28 — L'insémination aboutit : un runner de tenant, né d'un runner de site
**52 preuves.** `ops-01` existe, configurée de bout en bout **par le runner du SITE**, qui
n'a détenu aucun secret de Chezlepro :
```
/opt/setops Set-OPS-public OPS-Chezlepro SITE-Chezlepro venv
symlinks (D-80) instance -> OPS-Chezlepro underlay.yml -> SITE-Chezlepro/…
moteur au commit courant
sa paire SSH setops@ops-01.chezlepro.internal
voûte / clé AUCUNE
```
**Il s'arrête exactement là où il n'a pas la clé.** Ce n'est pas une règle qu'on lui
impose, c'est un fait : la voûte de Chezlepro ne vit dans aucun dépôt, et son mot de passe
n'est sur aucune de ses machines. L'armer reste le geste d'un humain, depuis **son** poste,
par **son** chemin — le site ne peut pas le faire à sa place, même s'il le voulait.
### Trois murs, et chacun a nommé une pièce manquante du terrain
Le socle bloquait sur `apt`, puis `serveur_ops` sur `pip`, puis sur le clonage du génome.
Trois échecs, trois couches du terrain que le SITE doit préparer et qui n'étaient pas
déclarées.
**`apt`** → la source d'artefacts d'amorçage. Le DNS sortant d'un tenant est fermé par
conception ; un mandataire résout ce que le client ne peut pas résoudre. Aucun flux à
ouvrir : le cache du site acceptait déjà les supernets des tenants.
**`pip`** → la roue hors-ligne, dernière dépendance qui sortait par elle-même. Les
collections suivaient l'idiome du dépôt depuis longtemps — *le contrôleur télécharge, la
cible n'ouvre rien* — `pip` non. `--no-index` **est** le point : sans lui, la réussite
dépendrait un jour d'un flux que ce tenant n'a pas le droit d'avoir, et une lignée
autonome deviendrait une lignée qui **a l'air** autonome.
**Le génome** → le marqueur `serveur_forge_site`, qui manquait. Le site rendait deux
services à ses locataires, les paquets et le génome ; seul le premier était déclaré. Une
dépendance écrite chez le consommateur, sans flux chez le fournisseur — invisible, parce
que la garde de matrice ne vérifie que les paires rôle→rôle.
### Les clés SSH suivent la même règle que les voûtes
Chaque runner a son jeu de clés ; sa publique vit dans l'`authorized_keys` des machines de
**son** tenant, et d'aucun autre. Le runner du SITE n'entre que sur `ops-01`, par le flux
d'insémination. *Le pouvoir d'entrer se borne au périmètre du runner qui le détient*, comme
le pouvoir d'ouvrir se borne à sa voûte.
Le véhicule existait déjà — `ssh_baseline_cles_admin`, avec son `etat` par entrée, donc
révocable sur toute la flotte. Chezlepro n'en déclarait aucune parce que son runner
n'existait pas encore.
### Ce que la mesure a corrigé, deux fois
`ops-01` est **la seule VM de Chezlepro qui existe** — les quatorze autres ne sont sur
aucun hyperviseur. Mes sondes vers le résolveur, le cache et la PKI du tenant mesuraient
donc une **absence**, que j'ai d'abord lue comme un pare-feu. *Un port muet ressemble à un
refus.*
Et l'accès que je croyais avoir cassé en appliquant le pare-feu est-ouest : aucune machine
de Chezlepro n'était joignable depuis le poste par ce rebond. `ops-01` l'était parce
qu'elle n'avait **aucun** groupe de pare-feu. Je n'ai pas créé une panne, j'ai retiré une
exception.
make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec, 0 saute.
## 2026-08-28 — Nourrir la première machine : le cache du site pendant la jeunesse
**52 preuves.** `ops-01` est la première machine de la reconstruction de Chezlepro, et le
socle y restait bloqué sur son premier `apt update`, sans message qui parle de réseau. La
mesure, depuis la machine :
```
sortie Internet TCP 443 OK
DNS UDP 53 -> 9.9.9.9 MUET
plancher /etc/hosts 15 machines du tenant, AUCUNE du site
```
**Le DNS sortant est fermé par conception** — un tenant résout chez lui, son DNS ne
traverse jamais la frontière. Or `apt` vise `deb.debian.org` **par son nom**, et le cache
de l'écosystème n'existe pas encore.
C'est le cas étroit que le plan du SITE nommait déjà, descendu d'un niveau : **la première
machine d'un écosystème n'a ni résolveur ni cache, et c'est elle qui doit les construire.**
### Un mandataire résout ce que le client ne peut pas résoudre
`apt` envoie au mandataire l'**URL absolue** ; c'est lui qui traduit le nom. La machine
n'a donc besoin d'aucun DNS pour installer ses paquets — seulement d'une **adresse**. D'où
`artefacts_amorcage`, symétrique exact de `dns_amorcage`, à la même place dans le socle.
**Aucun flux à ouvrir, et c'est la bonne surprise.** `serveur_cache_site` accepte déjà le
3142 depuis `voisins_site`, qui résout les **supernets** des tenants — donc toute machine
d'un locataire, pas seulement son cache. Vérifié depuis `ops-01` : *HTTP 200 en 0,158 s*,
la résolution faite par le cache.
### Ce n'est pas une rustine, c'est de la filiation
L'hébergeur nourrit la première machine de son locataire le temps qu'il monte son propre
cache. L'emprunt est **déclaré** — l'intrant nomme l'amont — et il s'efface :
`client_artefacts` écrit `00-setops-artefacts`, qui trie **après** ce fichier-ci, donc
gagne dès que l'écosystème a sa source. Le plancher reste dessous, comme `/etc/hosts`
reste sous le DNS.
*Retirer cette ligne le jour où le cache du tenant est debout est une **émancipation** — un
geste, pas un effet.* C'est le premier service à recevoir cette forme après la forge, et le
patron vaudra pour les sauvegardes, l'observabilité, le relais courriel.
### Deux diagnostics faux, corrigés par la mesure
**J'ai lu « BLOQUÉ » comme un pare-feu.** Les sondes vers le résolveur, le cache et la PKI
du tenant échouaient toutes — j'y ai vu un filtre, puis j'ai appliqué le pare-feu est-ouest
en croyant corriger. La vraie cause était une **absence** : `ops-01` est la seule VM de
Chezlepro qui existe, les quatorze autres ne sont sur aucun hyperviseur. *Un port muet
ressemble à un refus.*
**Et j'ai cru avoir cassé mon propre accès.** Après l'application du pare-feu, `ops-01` est
devenue injoignable depuis le poste — j'y ai vu une régression que j'aurais causée. Mesure :
**aucune** machine de Chezlepro n'est joignable depuis le poste par ce rebond. `ops-01`
l'était parce qu'elle n'avait *aucun* groupe de pare-feu. Je n'ai pas créé une panne, j'ai
retiré une exception — et le seul chemin qui reste vers elle est le chemin **déclaré**,
celui de l'insémination.
*Le nœud `192.168.11.42` est injoignable, à traiter séparément.*
### Le garde-fou du génome a refusé une histoire réécrite
`--amend` sur un commit déjà poussé : le runner a refusé l'avance non rapide, exactement
comme il doit. Les arbres étant identiques et seul le message différant, **c'est la version
de la forge qui fait foi** (D-81) — et l'explication vit ici, où elle a de toute façon sa
place.
make verifier : vert.
## 2026-08-28 — Armer un runner est un acte, pas un réglage
**52 preuves.** La séparation faite, la distribution devient sûre. `serveur_ops_site` et
`serveur_ops_tenant` savent désormais déposer **la clé** de la voûte qu'ils déposaient déjà
chiffrée — et le runner du site l'a reçue :
```
/opt/setops/.config/ -> -rw------- setops setops-vault-site-chezlepro
PREUVE : la voûte du SITE s'ouvre avec la clé que le runner détient.
```
**Une seule clé chez lui, et c'est tout ce qu'il peut ouvrir.** La fabric, pas un tenant.
### `false` par défaut, et ce n'est pas de la prudence décorative
Armer est le moment où un humain remet la clé — le seul geste que la reproduction exige de
lui. Un défaut à `true` armerait des machines sans que personne ne l'ait décidé. Les deux
rôles exigent donc une déclaration explicite au plan, et refusent d'armer sans qu'on dise
**avec quelle clé** : *poser un fichier vide laisserait un runner se croire armé.*
**Écrire, puis relire** — comme pour la voûte, mais contre la faute **symétrique**. Là on
craignait un chiffré devenu clair ; ici on craint une clé qui serait en fait une voûte, ou
un fichier vide, qu'Ansible accepte sans rien ouvrir. Le rôle mesure existence, taille et
mode après avoir écrit.
### Ce que ça change pour l'exploitant
Le mot de passe se tapait **à chaque déploiement**. Un geste répété vingt fois par semaine
ne prouve rien — et il rendait tout déploiement non interactif (la GUI, un runner)
impossible sans blocage. Il se remet désormais **une fois**, et c'est un événement daté.
*C'est la doctrine de l'émancipation appliquée un cran plus tôt : la machine instruit,
l'humain acte.*
### P32 a trouvé la moitié manquante
Le rôle du tenant exigeait `serveur_ops_tenant_cle_source` ; le plan de Chezlepro ne la
fournissait pas. La preuve l'a dit avant tout déploiement, et dans les termes qui comptent :
*« le déploiement s'arrêtera sur la première garde atteinte »*. `ops-01` est donc armé lui
aussi — avec la clé de Chezlepro, qui n'ouvre ni la fabric ni un voisin.
make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec, 0 saute.
## 2026-08-28 — Une voûte, une clé : la séparation avant la distribution
**52 preuves.** Décision de l'exploitant : **chaque runner est maître de sa voûte et en
détient la clé.** C'est ce qui rend un runner autonome — et donc ce qui rend l'émancipation
atteignable. Mais elle exigeait un préalable, mesuré ce matin :
```
UN SEUL mot de passe ouvrait les SIX voûtes de la flotte
SITE-Chezlepro (la fabric) · OPS-Chezlepro · OPS-Chezlepro-lab (×2)
OPS-Patient0 · OPS-Technolibre
```
**Distribuer avant de séparer aurait été strictement pire que le statu quo.** Poser « la »
clé sur chaque runner, c'était rendre chaque runner capable d'ouvrir les autres :
compromettre le plus petit locataire donnait les secrets de l'hébergeur. *L'ordre n'est pas
un détail de mise en œuvre, c'est ce qui décide si la mesure protège ou expose.*
Cinq clés, une par écosystème, sous `~/.config/setops-vault-<dépôt-en-minuscules>`.
Vérification croisée après rechiffrement — la diagonale, et rien qu'elle :
```
voûte \ clé chezlepro lab patient0 site technolibre ANCIENNE
ops-chezlepro OUVRE . . . . refusée
ops-chezlepro-lab . OUVRE . . . refusée
ops-patient0 . . OUVRE . . refusée
site-chezlepro . . . OUVRE . refusée
ops-technolibre . . . . OUVRE refusée
```
### Un seul mot de passe ne pouvait plus suffire, et pas pour la raison qu'on croit
`cloner_vm_debian.yml` charge **deux voûtes dans la même exécution** : celle du tenant,
puis celle de l'underlay — les identifiants Proxmox appartiennent à l'hébergeur, le reste
au locataire. `ANSIBLE_VAULT_PASSWORD_FILE` n'en porte qu'une.
`ANSIBLE_VAULT_IDENTITY_LIST` en porte plusieurs et les essaie toutes sur un bloc chiffré
sans étiquette. **Un seul `export` dans le Makefile suffit donc pour les 28 appels à
`ansible-playbook`, sans en toucher un seul.** La liste se dérive de l'instance montée, de
son site et des dépôts frères (`scripts/voutes.py`).
*Ce qui borne le pouvoir n'est pas cette liste mais la **présence** des fichiers.* Sur le
poste de l'exploitant, toutes les clés sont là — c'est l'humain qui les détient toutes, et
des preuves comme P03 lisent les inventaires de tous les écosystèmes frères. Sur un runner,
une seule clé existe, et la même fonction n'en nomme qu'une. **Le code est identique, le
pouvoir ne l'est pas.**
### Deux preuves ont immédiatement dit ce qui manquait
**P03** (diff-vide de *toutes* les instances) est tombée dès la séparation : elle lit les
inventaires des écosystèmes voisins, donc il lui faut leurs clés. C'est elle qui a établi
que la liste devait couvrir le voisinage sur le poste — je l'avais d'abord limitée à
l'instance montée et à son site.
**P16** s'est mise à **sauter** : sa garde ne connaissait que `ANSIBLE_VAULT_PASSWORD_FILE`.
*Une preuve sautée se lit beaucoup trop facilement comme une preuve passée* — c'est le
même défaut que le vert sur un périmètre vide, en plus discret.
### Ce que ça coûte, et qui est assumé
Le chiffré et sa clé cohabiteront sur la même machine dès que les runners recevront la
leur. Voler le disque d'un runner suffira, là où ça ne donnait rien. Le pari tient parce
que le périmètre est borné : **le runner d'un tenant ne peut ouvrir que ce tenant.** Trois
contreparties en découlent — mode `0600`, la clé hors du chemin de sauvegarde ordinaire, et
un runner durci comme un détenteur d'état.
*Les six voûtes ont été sauvegardées telles quelles avant rechiffrement. L'ancienne clé
maîtresse n'ouvre plus rien et reste sur le poste : la retirer est une décision, pas un
nettoyage.*
make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec, 0 saute — sans
`ANSIBLE_VAULT_PASSWORD_FILE`.
## 2026-08-28 — L'insémination aboutit, et trois tests qui ne gardaient rien
**52 preuves.** Le flux était déclaré et appliqué ; il manquait l'**identité**. Le runner
du SITE pouvait atteindre la porte de `ops-01` et n'avait pas de clé :
```
ansible@10.17.19.41: Permission denied (publickey)
```
**Le piège, et pourquoi il compte plus que le correctif.** La clé s'injecte par cloud-init,
**au clonage** — or `creer-vm` crée *toutes* les machines d'un tenant. L'injecter à chaque
clonage aurait donné à l'hébergeur un accès SSH à la **flotte entière de chaque
locataire**, en silence, sans qu'aucune règle ne le dise. *Ça aurait défait à la couche
identité ce que le pare-feu venait de borner à la couche réseau — et deux couches qui ne
déclarent pas la même politique, c'est une politique qu'on ne peut plus lire.*
**Le même critère gouverne les deux** : porter `serveur_ops_tenant`. `inventory_host.py`
émet `SETOPS_CLES_AMORCAGE` pour cet hôte, et une chaîne vide partout ailleurs. La clé
vient du **plan du SITE**, pas du disque local : on matérialise depuis le runner comme
depuis le poste, et un `lookup` local rendrait deux clés différentes selon qui agit.
Elle **s'ajoute**, elle ne remplace pas : l'exploitant garde son accès à la machine qu'il
vient de créer. C'est l'humain qui arme, pas le site.
### Deux couches mangeaient les espaces d'une clé publique, en silence
Une clé porte des espaces — type, matériel, commentaire. Proxmox répondait :
```
500 Internal Server Error: SSH public key validation error
```
Un message qui ne parle ni de `make`, ni de `shlex`, ni d'espaces. Ce qui l'a isolé n'est
pas la lecture du code mais un **contrôle** : rejouer `cloner-vm` *sans* la clé — la tâche
passe. Puis la mesure de ce qui arrivait vraiment au module : `"sshkeys": "ssh-ed25519"`.
**Le premier mot, rien d'autre.**
Deux causes, et j'ai accusé la mauvaise d'abord. Une variable `make` passée à un sous-make
est un chemin fragile pour une valeur à espaces — vrai, mais ce n'était pas ça. Le
coupable est `ansible-playbook -e clé=valeur`, qui découpe la chaîne **au shlex** : tout ce
qui suit le premier espace devient d'autres paires. D'où la forme retenue :
l'environnement pour le transport, et `-e '{"…": "…"}'` en **JSON** pour l'entrée dans
Ansible — le seul mode de `-e` qui ne découpe rien.
*Un scalaire YAML plié (`>-`) ne produit pas non plus de saut de ligne : `'\n'` y reste
deux caractères. Vérifié en base64 sur les trois formes candidates.*
### Trois tests qui ne gardaient rien
`test_inventory_host` déclare ses tests dans une **liste explicite**. Les deux que j'ai
écrits pour la nouvelle garde y étaient absents : définis, verts en apparence, **jamais
joués**. J'ai donc ajouté la garde qui manquait — la liste doit couvrir tout ce que le
fichier définit.
Elle a trouvé un **troisième** cas le jour même : `test_etiquette_vlan_repli_et_vide_explicite`,
jamais inscrit depuis sa création. Inscrit, il a levé un `KeyError` — il cherchait
`hotes_actifs` dans une fixture qui range ses hôtes sous `hotes_planifies`. Il gardait le
comportement SDN de l'étiquette VLAN, et il ne gardait rien. *Un test non inscrit est pire
qu'un test absent : on croit l'avoir.*
### La preuve, avec son contrôle négatif
```
runner du SITE -> ops-01 ops-01 10.17.19.41/24 ✔ entré
runner du SITE -> 10.17.19.21 Connection timed out ✘ refusé
```
Le chemin est large d'exactement une machine — et la première ligne est une **livraison**,
pas une poignée de main : `ops-01` s'est nommée.
*`ops-01` est née avant ce mécanisme ; sa clé a donc été posée une fois à la main. Toute VM
matérialisée à partir d'ici la reçoit de cloud-init, ou ne la reçoit pas — selon ce que le
plan dit d'elle.*
make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec.
## 2026-08-28 — Le lien d'insémination, déclaré — et un test rouge depuis trois jours
**52 preuves.** L'insémination avait un nom depuis ce matin ; elle n'avait pas de flux. Le
runner du SITE doit joindre le runner d'un tenant pour l'amorcer — socle, moteur, plan,
plancher de résolution — et ce chemin s'exerçait jusqu'ici sans être déclaré nulle part.
**Deux déclarations, aux deux bouts, et rien d'autre.**
```
serveur_ops_site egress 22/tcp -> serveur_ops_tenant
serveur_ops_tenant ingress 22/tcp <- runner_site partage: true
```
Étroit par construction : il vise le **groupe** `serveur_ops_tenant`, qu'un écosystème ne
pose que sur une machine. Déclaré au socle, il aurait ouvert le SSH du site vers toute la
flotte du tenant ; déclaré sur ce rôle, sa portée est celle du groupe — et le groupe est
écrit au plan du tenant.
L'en-tête de `roles/serveur_ops_site/meta/flux.yml` disait *« ce rôle n'entre JAMAIS chez
un tenant »*. C'était une frontière qu'on ne pouvait pas tenir : `creer-vm` exige
`_instance-requise`, et le runner du site avait déjà dû basculer son symlink `instance`
sur `OPS-Chezlepro` pour matérialiser ses VM. *Déclarer ne crée pas ce pouvoir — ça rend
limitable un pouvoir qui s'exerçait sans borne.*
Ce qui reste interdit n'est pas une règle mais un **fait** : le runner du SITE ne détient
pas la voûte d'un tenant — elle vit hors dépôt — et n'en connaît pas le mot de passe. Il
pose tout ce qui est public ; **l'humain arme**. Le pouvoir du site s'arrête là où il n'a
pas la clé.
### La règle est émise d'un seul côté, et ce n'est pas celui qu'on croit
Le paquet part du réseau de l'hébergeur vers la zone du tenant : il pénètre le pare-feu
**par la patte du site**, pas par le lien de transit. La règle appartient donc au côté
SITE du devis. L'émettre aussi depuis la déclaration `ingress` du tenant aurait produit
une seconde règle attachée à la mauvaise interface — *jamais évaluée, impossible à
distinguer d'une règle utile, et comptée comme telle par tout ce qui audite ce devis.*
La déclaration du tenant n'est pas perdue : c'est elle qui pose la règle `nftables` sur la
machine visée, et elle seule. Une ligne, sur un seul hôte, dont la source **dérive du plan
du site** :
```
ip saddr { 10.0.31.11 } tcp dport 22 accept # serveur_ops_tenant: Insémination…
```
*Écrire cette adresse à la main aurait survécu au prochain déplacement du runner sans que
rien ne le dise — le site a déjà déplacé ses machines une fois, le 2026-08-25.*
**Plan de la frontière : 2 objets à créer, 0 à retirer, 126 inchangés.** Rien n'est appliqué.
### P41 appliquée au plan du site
`resoudre_flux` avait besoin du plan du SITE à son tour, que `devis_opnsense` portait en
privé. Les trois lecteurs déménagent dans `underlay` — le module qui sait déjà où vit
l'hébergeur — et l'appelant délègue. *Deux recensements du même objet finissent toujours
par diverger, et la divergence se lit « tout va bien ».*
**Deux gardes ont fait leur travail au passage**, et méritent d'être nommées : P33 a refusé
`ingress 22` sur un hôte qui porte déjà le sshd du socle — la réponse était `partage: true`,
le mot qui distingue *ouvrir une écoute* d'*autoriser une source de plus sur celle d'un
autre*, exactement comme `serveur_backup`. Et le devis a refusé d'émettre une règle vers un
alias vide plutôt que d'en poser une sans destination.
### Un test rouge depuis trois jours, que personne ne pouvait voir
`test_adressage_derive` construisait un site avec un `index`. Or **un SITE n'a pas
d'index** — `add94f2` l'a retiré le 2026-08-25, remplacé par `bande_basse_de`, porté par
chaque *réseau* et non par le site. Le test est resté rouge du 25 au 28.
Personne ne l'a vu parce que **le geste quotidien est `make prouver`, qui ne joue pas les
tests** — `make verifier` le fait, et il n'avait pas été rejoué. *Un test rouge que rien ne
lit ne garde plus rien.* Remis sur le contrat actuel, et sa contrepartie ajoutée : un
réseau qui ne **dit pas** de quel tenant il occupe la bande basse n'a droit à aucun
chevauchement — ne pas déclarer ne peut pas valoir permission.
make verifier : vert de bout en bout. make prouver : CONFORME, 52 OK, 0 echec.
## 2026-08-28 — L'insémination : la parenté est la seule exception au zéro-confiance
**52 preuves.** Entre tenants, la frontière refuse tout ce qui n'est pas déclaré. Mais un
écosystème neuf **ne peut pas s'amorcer lui-même** : quelqu'un doit poser sa première
machine. Ce geste n'avait pas de nom, et un geste sans nom ne se borne pas.
```
le SITE matérialise VNets, VM, routes, flux — le terrain
le SITE amorce ops-01 la première machine, et elle seule
le TENANT achève ses autres machines, ses rôles, ses intégrations
```
**Le partage d'intelligence qui le rend possible**, et qui est plus net que « le SITE ignore
les rôles » : les deux runners portent le même moteur, et n'en consomment pas la même face.
Le SITE lit `roles/*/meta/flux.yml` — ce que les rôles exigent du **réseau** — et en tire le
SDN, le pare-feu Proxmox, la frontière. Le tenant lit `tasks/`, `templates/`,
`meta/integration.yml` — ce qu'ils exigent de la **machine**. *C'est ce partage qui permet
au SITE de poser les bonnes règles sans jamais lire la configuration d'un tenant ni détenir
sa voûte.*
### Le couplage existait déjà, sans être déclaré
`make creer-vm` exige `_instance-requise`. Pour matérialiser les VM de Chezlepro, le runner
du SITE a donc dû basculer son symlink `instance` sur le dépôt de Chezlepro — alors que son
plan pose `serveur_ops_instance: ""` et que la doctrine dit qu'un SITE n'a pas de plan monté
par ce lien. Mesuré sur la machine :
```
/opt/setops/Set-OPS-public/instance -> ../OPS-Chezlepro (27 août 21:52)
```
Rien ne borne sur quels tenants ce lien porte, ni jusqu'à quand. *Le déclarer, c'est le
rendre limitable ; le taire ne l'a jamais empêché.*
### L'émancipation exige la gouverne d'un humain
J'avais proposé une **condition d'extinction mesurée** : le lien se ferme quand la machine
constate que le tenant se reproduit sans son parent. C'était l'erreur symétrique de celle
qu'on corrigeait, et ce document disait déjà pourquoi — *« chaque émancipation est une
décision du client, jamais une rupture technique »*.
**Le fruit de l'émancipation est livré à quelqu'un.** Sans quelqu'un pour le recevoir, il
n'y a pas de livraison : seulement un lien qui tombe. *Une émancipation qui se déclencherait
toute seule ne serait pas une émancipation, ce serait une expulsion.*
D'où quatre lignes qui ne se confondent pas : le moteur porte le **lien**, la machine
produit le **constat d'aptitude** — elle instruit, elle n'agit pas —, **l'humain** pose
l'**acte** sous `CONFIRMER=true`, et la machine **prouve** ensuite que le lien est coupé.
C'est la forme des autres gestes lourds d'ici : `raser` exige son nom d'instance, le mot de
passe de la voûte se tape à l'exécution. **L'émancipation est le deuxième objet de cette
liste.**
### Deux limites nommées plutôt que tues
**Qui est le parent ?** D-81 donne l'autorité du génome à la forge du SITE. Conséquence non
écrite : patient 0, dont la raison d'être est *« porter les dépôts qui fabriquent les
écosystèmes, et les servir à ses enfants »*, n'a plus un seul lecteur — Chezlepro était le
dernier, corrigé le 26. Et la machine hors flotte qu'il existe pour remplacer alimente
désormais la forge qui fait autorité : le point unique de défaillance n'a pas été éliminé,
**il a été promu**. Trois issues sont posées dans le document ; aucune n'est tranchée, et
aucune ne bloque la reconstruction en cours.
**La séparation des voûtes est organisationnelle, pas cryptographique.** Les fichiers sont
bien séparés — vérifié sur `site-ops-01`, qui ne détient que `underlay.vault.yml`. Mais un
**seul mot de passe** ouvre la voûte du site et celles des tenants. « Deux pouvoirs, aucun
omnipotent » décrit la répartition des fichiers, pas celle des clés.
*Un document qui nomme ce qu'il n'a pas encore résolu vaut mieux qu'un document dont on
découvrira le trou en s'appuyant dessus.*
## 2026-08-27 — P52 : matérialiser n'exige pas d'entrer chez le tenant
**52 preuves.** `creer-vm` confirmait son succès en attendant une réponse SSH. Le runner du

View file

@ -6,6 +6,22 @@ export ANSIBLE_SSH_CONTROL_PATH_DIR ?= $(CURDIR)/.ansible/cp
export ANSIBLE_SSH_ARGS ?= -F /dev/null -o ControlMaster=no
export SETOPS_INSTANCE ?= instance
# UNE VOUTE, UNE CLE — ET ANSIBLE LES TIENT TOUTES A LA FOIS (2026-08-28).
#
# Un seul mot de passe ouvrait les six voutes de la flotte, celle de la fabric comprise.
# Depuis la separation, chaque ecosysteme a la sienne, sous
# `~/.config/setops-vault-<depot-en-minuscules>`.
#
# UN `ANSIBLE_VAULT_PASSWORD_FILE` NE PEUT PLUS SUFFIRE : `cloner_vm_debian.yml` charge
# la voute du TENANT puis celle de l'UNDERLAY dans la meme execution — les identifiants
# Proxmox appartiennent a l'hebergeur, le reste au locataire. `ANSIBLE_VAULT_IDENTITY_LIST`
# porte plusieurs secrets et les essaie tous ; un seul export suffit donc pour les 28
# appels a `ansible-playbook`, sans en toucher un seul.
#
# `?=` : une valeur deja posee dans l'environnement gagne — la GUI et les runners peuvent
# nommer leurs propres cles sans que ce fichier ait a les connaitre.
export ANSIBLE_VAULT_IDENTITY_LIST ?= $(shell python3 scripts/voutes.py identites 2>/dev/null)
# Inventaire de l'instance : un seul par instance dans le modèle « séparation par
# instance ». Détection rétro-compatible : principal > production > lab.
# Surchargeable : make … SETOPS_INVENTAIRE=chemin/hosts.yml
@ -50,6 +66,25 @@ DNS ?=
DHCP ?= false
CIUSER ?=
CLE_SSH_PUBLIQUE ?=
# LA CLE D'AMORCAGE VOYAGE PAR L'ENVIRONNEMENT, ET ARRIVE EN JSON. MESURE (2026-08-28).
#
# Une cle publique contient des ESPACES — type, materiel, commentaire — et deux couches
# les mangent en silence :
#
# `ansible-playbook -e cle=valeur` decoupe la chaine AU SHLEX : tout ce qui suit le
# premier espace devient d'autres paires cle=valeur. Le module recevait
# `"sshkeys": "ssh-ed25519"` — le premier mot, rien d'autre.
#
# Et une variable `make` passee a un sous-make est un chemin tout aussi fragile pour
# une valeur a espaces.
#
# Proxmox repondait `500 SSH public key validation error` : un message qui ne parle ni
# de shlex, ni de make, ni d'espaces. Le contrôle qui l'a isole : rejouer `cloner-vm`
# SANS la cle — la tache passe.
#
# D'ou la forme retenue : l'environnement (`SETOPS_CLES_AMORCAGE`, exporte par
# `creer-vm`) pour le transport, et `-e '{"...": "..."}'` en JSON pour l'entree dans
# Ansible — le seul mode de `-e` qui ne decoupe rien.
PONT_PROXMOX ?=
VLAN ?=
DEMARRER ?=
@ -617,7 +652,7 @@ deployer-tout: _instance-requise ## Deploie TOUTE la flotte dans l'ordre des cou
python3 scripts/orchestrer.py verifier; \
python3 scripts/orchestrer.py ecrire; \
vault_chiffre="$$(grep -rlsIF '$$ANSIBLE_VAULT' $(dir $(INVENTAIRE_PRODUCTION))group_vars 2>/dev/null | head -1 || true)"; \
if [[ -n "$$vault_chiffre" && -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}" ]]; then \
if [[ -n "$$vault_chiffre" && -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}$${ANSIBLE_VAULT_IDENTITY_LIST:-}" ]]; then \
if [[ -t 0 ]]; then \
read -r -s -p 'Mot de passe du vault Ansible: ' mdp; echo; \
vf="$$(mktemp)"; printf '%s' "$$mdp" > "$$vf"; chmod 600 "$$vf"; \
@ -666,7 +701,7 @@ deployer-tout: _instance-requise ## Deploie TOUTE la flotte dans l'ordre des cou
# clairement plutot que de pendre sur une invite que personne ne lit.
define VAULT_UNE_FOIS
vault_chiffre="$$(grep -rlsIF '$$ANSIBLE_VAULT' $(dir $(INVENTAIRE_PRODUCTION))group_vars 2>/dev/null | head -1 || true)"; \
if [[ -n "$$vault_chiffre" && -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}" ]]; then \
if [[ -n "$$vault_chiffre" && -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}$${ANSIBLE_VAULT_IDENTITY_LIST:-}" ]]; then \
if [[ -t 0 ]]; then \
read -r -s -p 'Mot de passe du vault Ansible (demande une seule fois): ' mdp; echo; \
vf="$$(mktemp)"; printf '%s' "$$mdp" > "$$vf"; chmod 600 "$$vf"; \
@ -909,7 +944,7 @@ verifier-deploiement: ansible-runtime ## Verifie l'etat de la flotte apres deplo
exit 2; \
fi; \
vault_chiffre="$$(grep -rlsIF '$$ANSIBLE_VAULT' $(dir $(INVENTAIRE_PRODUCTION))group_vars 2>/dev/null | head -1 || true)"; \
if [[ -n "$$vault_chiffre" && -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}" ]]; then \
if [[ -n "$$vault_chiffre" && -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}$${ANSIBLE_VAULT_IDENTITY_LIST:-}" ]]; then \
if [[ -t 0 ]]; then \
read -r -s -p 'Mot de passe du vault Ansible: ' mdp; echo; \
vf="$$(mktemp)"; printf '%s' "$$mdp" > "$$vf"; chmod 600 "$$vf"; \
@ -972,6 +1007,7 @@ cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE=<nom>
[[ -n "$(DOMAINE_RECHERCHE)" ]] && extra_vars+=( -e proxmox_clone_domaines_recherche="$(DOMAINE_RECHERCHE)" ); \
[[ -n "$(CIUSER)" ]] && extra_vars+=( -e proxmox_clone_ciuser="$(CIUSER)" ); \
[[ -n "$(CLE_SSH_PUBLIQUE)" ]] && extra_vars+=( -e proxmox_clone_cle_publique_fichier="$(CLE_SSH_PUBLIQUE)" ); \
[[ -n "$${SETOPS_CLES_AMORCAGE:-}" ]] && extra_vars+=( -e "$$(python3 -c 'import json,os; print(json.dumps({"proxmox_clone_cles_amorcage": os.environ["SETOPS_CLES_AMORCAGE"]}))')" ); \
[[ -n "$(PONT_PROXMOX)" ]] && extra_vars+=( -e proxmox_clone_pont="$(PONT_PROXMOX)" ); \
extra_vars+=( -e proxmox_clone_vlan="$(VLAN)" ); \
[[ -n "$(DEMARRER)" ]] && extra_vars+=( -e proxmox_clone_demarrer="$(DEMARRER)" ); \
@ -990,7 +1026,7 @@ cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE=<nom>
read -r premiere_ligne < "$$vault_file" || true; \
case "$$premiere_ligne" in \
'$$ANSIBLE_VAULT'*) \
if [[ -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}" ]]; then \
if [[ -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}$${ANSIBLE_VAULT_IDENTITY_LIST:-}" ]]; then \
vault_args+=( --ask-vault-pass ); \
fi; \
;; \
@ -1006,6 +1042,7 @@ creer-vm: _instance-requise ## Cree une VM et attend qu'elle soit joignable —
fi; \
params="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote $(HOTE))"; \
eval "$$params"; \
export SETOPS_CLES_AMORCAGE; \
$(MAKE) cloner-vm \
HOTE="$(HOTE)" \
VMID="$$SETOPS_VMID" \

View file

@ -20,8 +20,8 @@
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 37 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 35 rôles, 92 flux, schéma + matrice OK. |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 38 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 36 rôles, 95 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | playbook: playbooks/proxmox/cloner_vm_debian.yml |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
@ -30,39 +30,39 @@
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 15 hotes, 36 groupes (inventaire dechiffre et parse). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 23 secret(s) exige(s), tous presents. Voute reelle : 25 cle(s), aucun manque. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 23 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 3 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 11 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 50 groupe(s), 79 regle(s). |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 51 groupe(s), 81 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 15 hote(s) x 5 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 29 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 18, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 30 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 19, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 51 scripts expliques et atteignables, 104 cibles make documentees, 61 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 40 exigence(s) de role, toutes satisfaites (126 cle(s) declaree(s) par l'instance). |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 52 scripts expliques et atteignables, 104 cibles make documentees, 62 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 41 exigence(s) de role, toutes satisfaites (130 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (38 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (26 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 5 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 36 role(s) serveur/client tous nommes, 37 groupe(s) cite(s) en table existent tous. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 37 role(s) serveur/client tous nommes, 38 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. |
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 47 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 48 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 5 machine(s) du plan retrouvees, 56 regle(s) du site. |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 5 machine(s) du plan retrouvees, 60 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 84 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (110 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 121 regles `pass`), tous non consignes et tous motives. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (113 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 125 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |

View file

@ -23,8 +23,8 @@ README de rôles). Cette page comble ces deux trous.
| Ce qu'on compte | Combien | Comment on le mesure |
|---|---|---|
| rôles | 61 | `roles/*/` |
| README de rôles | 61 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| rôles | 62 | `roles/*/` |
| README de rôles | 62 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| documents | 38 | `docs/*.md` |
| pièces d'audit | 29 | `docs/audit/*` |
| unités de wiki | 27 | `wiki/*.md` |

View file

@ -55,6 +55,7 @@ Ce que la reconstruction couvre, par capacité :
| Runner de site | `serveur_ops_site` | le pouvoir de **materialiser** : creer et detruire des VM sur la fabric. Detient la voute du SITE, chiffree, et n'entre JAMAIS chez un tenant — reserve a l'ecosysteme de l'hebergeur |
| Resolution | `serveur_resolveur`, `client_resolveur` | UN resolveur recursif par tenant (Unbound), qui recurse depuis la racine et delegue la zone souveraine a PowerDNS. Remplace les N demons locaux d'avant le 2026-08-24 |
| Cache du site | `serveur_cache_site` | designe LE cache que les ecosystemes voisins prennent comme amont : Debian telecharge une fois pour toute la fabric, et le cache ne voit que des requetes agregees. Reserve a l'ecosysteme de l'hebergeur |
| Forge du genome du site | `serveur_forge_site` | designe LA forge dont les ecosystemes de ce site se reproduisent (D-81), et l'ouvre a eux. Marqueur : `serveur_forgejo` installe. |
| Agents de flotte | `client_metrique`, `client_journal`, `client_smtp` | collecte et relais sur toute la flotte |
*Rôles retirés (2026-07-04, supersédés ou hors conception)* : `serveur_sendmail`

View file

@ -59,7 +59,9 @@ couches:
# mouvement : elle vit dans l'underlay de l'hebergeur et se joint par
# `scripts/site_inventaire.py`. Un tenant ne la deploie jamais — il la CONSOMME.
- serveur_cache_site
# La forge du genome, meme nature : elle vit dans l'underlay de
# l'hebergeur et un tenant la CONSOMME sans jamais la deployer.
- serveur_forge_site
- nom: apps
raison: "Les applications métier, qui consomment les services (base, SSO, courriel, edge)."
groupes:

View file

@ -66,18 +66,80 @@ soi dès le premier jour.
| **identité** (LDAP/SSO) | le plus intime | mutualiser l'annuaire, c'est confier ses gens |
| **la voûte** | **jamais** | les secrets ne se mutualisent pas, à aucun âge |
## Ce que l'émancipation doit prouver
## L'insémination — le premier lien, et le seul que le SITE ait le droit d'avoir
Basculer une déclaration ne suffit pas. Une émancipation est un **acte outillé** en trois
temps :
Entre tenants, le zéro-confiance est le défaut : la frontière refuse tout ce qui n'est pas
déclaré, et c'est cette règle qui rend l'hébergement mutualisé défendable. **La parenté est
la seule exception, et elle est asymétrique** : un écosystème neuf ne peut pas s'amorcer
lui-même. Quelqu'un doit poser sa première machine et lui donner de quoi continuer.
1. **déclarer** — lever le drapeau au plan, déployer le service chez soi ;
2. **migrer l'état** — le génome, les sauvegardes, les certificats existants ;
3. **prouver que le lien est coupé** — et c'est le temps qu'on oublie.
Ce geste s'appelle l'**insémination** :
Sans cette troisième preuve, on croirait s'être émancipé en restant dépendant sans le
savoir. C'est exactement le défaut que ce dépôt traque partout ailleurs : un vert sur un
périmètre vide. Une émancipation non prouvée est une émancipation non faite.
```
le SITE matérialise VNets, VM, routes, flux — le terrain
le SITE amorce ops-01 la première machine, et elle seule
le TENANT achève ses autres machines, ses rôles, ses intégrations
```
**Le partage d'intelligence qui le rend possible.** Les deux runners portent le même
moteur ; ils n'en consomment pas la même face :
| | ce qu'il lit du moteur | ce qu'il en fait |
|---|---|---|
| runner du **SITE** | `roles/*/meta/flux.yml` — la face **réseau** des rôles | SDN, pare-feu Proxmox, frontière OPNsense |
| runner du **TENANT** | `tasks/`, `templates/`, `meta/integration.yml` — la face **machine** | configure ses services |
C'est ce partage qui permet au SITE de poser les bonnes règles pour un tenant **sans jamais
lire sa configuration ni détenir sa voûte**. Il sait de quoi les rôles ont besoin sur le
fil ; il ignore ce qu'ils font sur la machine.
**Ce que le lien doit être, pour ne pas devenir un pouvoir permanent :**
- **étroit** — vers le seul `ops-01` du tenant, jamais vers ses autres machines ;
- **déclaré** — dans `meta/flux.yml`, comme tout le reste, pour que la frontière et
`nftables` le connaissent au lieu de le subir ;
- **borné par un acte**, et cet acte appartient à un humain (voir ci-dessous).
> **Ce qui existe déjà sans être déclaré, au 2026-08-28.** `make creer-vm` exige
> `_instance-requise` : pour matérialiser les VM d'un tenant, le runner du SITE doit
> basculer son symlink `instance` sur le dépôt de ce tenant — alors que son plan pose
> `serveur_ops_instance: ""` et que la doctrine dit qu'un SITE n'a pas de plan monté par ce
> lien. Le couplage est donc **déjà là**, et rien ne borne sur quels tenants il porte ni
> jusqu'à quand. Le déclarer, c'est le rendre limitable ; le taire ne l'a jamais empêché.
## Ce que l'émancipation exige — et de qui
Basculer une déclaration ne suffit pas. Mais l'erreur symétrique est plus tentante encore :
faire de l'émancipation un **automatisme**, que la machine déclencherait dès qu'elle
constate que le tenant se reproduit sans son parent.
**L'émancipation exige toujours la gouverne d'un humain.** Le fruit de l'émancipation est
livré à quelqu'un — un client qui reprend ses clés, sa forge, son écosystème. Sans
quelqu'un pour le recevoir, il n'y a pas de livraison : seulement un lien qui tombe. *Une
émancipation qui se déclencherait toute seule ne serait pas une émancipation, ce serait une
expulsion.*
C'est déjà la forme des gestes lourds de ce dépôt : `raser` exige `CONFIRMER=true` et le
nom de l'instance ; le mot de passe de la voûte se tape à l'exécution — « le seul objet que
la reproduction exige d'un humain ». **L'émancipation est le deuxième objet de cette
liste.**
D'où la répartition, qui ne se confond pas :
| | qui l'exécute |
|---|---|
| **le lien de filiation** — déclaré, étroit, visible au plan | le moteur |
| **le constat d'aptitude** — « ce tenant se reproduit sans son parent » | la machine — elle **instruit**, elle n'agit pas |
| **l'acte d'émancipation** — retirer le lien, remettre les clés | **l'humain**, garde `CONFIRMER=true` |
| **la preuve que le lien est coupé** | la machine, **après** l'acte |
La quatrième ligne est celle qu'on oublie, et c'est elle qui décide si les trois autres ont
servi. Sans elle, on croirait s'être émancipé en restant dépendant sans le savoir —
exactement le défaut que ce dépôt traque partout ailleurs : un vert sur un périmètre vide.
**Une émancipation non prouvée est une émancipation non faite.**
*L'instrument de la quatrième ligne n'existe pas encore. C'est la pièce manquante nommée
au bas de ce document.*
## Forme attendue d'une déclaration
@ -93,9 +155,63 @@ l'état de son écosystème d'un coup d'œil plutôt qu'en fouillant chaque rôl
le drapeau est levé sans que l'amont soit nommé : emprunter sans dire à qui, c'est ne rien
déclarer du tout.
## État au 2026-08-24
## Qui est le parent ? — dilemme ouvert au 2026-08-28
Ce document décrit la filiation sans dire **de qui** un écosystème descend. La question
n'est plus théorique depuis que les runners savent reconstruire : chaque écosystème rebâti
grave sa réponse dans son plan (`serveur_ops_forge_amont`), et N réponses différentes font
N lignées qu'on ne saura plus rapprocher.
La chaîne réelle, mesurée :
```
poste de l'exploitant ──push──▶ eregion.chezlepro.ca:2222
= forge.alliance-boreale.ca ◀── HORS FLOTTE
│ git bundle │ miroir 8 h
▼ ▼
forge du SITE ★ autorité (D-81) forge de patient 0 ★ « la forge mère »
│ │
▼ ▼
les tenants du site (plus aucun lecteur)
```
Trois faits qui ne tiennent pas ensemble :
1. **Patient 0 ne sert plus personne.** Chezlepro était son dernier lecteur, corrigé le
2026-08-26 vers la forge du site. Son dépôt affirme toujours sa raison d'être — *« porter
les dépôts qui fabriquent les écosystèmes, et les servir à ses enfants »*.
2. **Le point unique de défaillance n'a pas été éliminé, il a été promu.** Patient 0 existe
explicitement pour remplacer une machine hors flotte que Set-OPS ne déploie ni ne prouve
— or c'est de cette machine que la forge faisant autorité tire son contenu.
3. **Le parent est locataire de son enfant.** Patient 0 est un tenant de la fabric d'un
site qui descend de lui. L'ancêtre ne peut pas être le chemin de reprise de son hôte.
Trois issues, à trancher par l'exploitant — et par lui seul, comme toute émancipation :
| issue | ce qu'elle impose |
|---|---|
| **patient 0 redevient la source** ; chaque forge de SITE en miroite | le rendre atteignable depuis **tout** site — il vit aujourd'hui dans la fabric d'un seul |
| **le SITE est la source** ; patient 0 devient une **référence**, non un ancêtre | assumer que la racine est le poste + la machine hors flotte, donc l'intégrer à la flotte ou la remplacer |
| **pas de racine — une famille de pairs** ; D-81 se restreint à « autorité pour les tenants d'un site » | bâtir non pas une hiérarchie mais une **preuve de convergence** entre toutes les copies vivantes |
*Aucune n'est bloquante pour la reconstruction en cours : les tenants pointent déjà sur la
forge de leur site, ce qui reste vrai dans les trois.*
## État au 2026-08-28
Seule la forge porte cette déclaration complète (`serveur_ops`). Les autres services
mutualisés le sont par des mécanismes antérieurs, cohérents mais non uniformes. Les
aligner sur la forme ci-dessus reste à faire — et l'instrument de preuve de l'étape 3
n'existe pas encore.
aligner sur la forme ci-dessus reste à faire.
**Les deux pièces manquantes**, l'une et l'autre nommées plus haut :
- le **lien d'insémination** n'est pas déclaré — il existe pourtant, par le symlink
`instance` du runner du SITE, sans borne ni visibilité ;
- l'**instrument de preuve** de la dernière ligne — « le lien est coupé » — n'existe pas.
Tant qu'il manque, aucune émancipation ne peut être déclarée faite.
**Une limite mesurée le 2026-08-28, à connaître avant de s'appuyer sur cette frontière :**
la séparation des voûtes est **organisationnelle, pas cryptographique**. Les fichiers sont
bien séparés — le runner du SITE ne détient que `underlay.vault.yml`, vérifié sur la
machine — mais **un seul mot de passe ouvre la voûte du site et celles des tenants**. « Deux
pouvoirs, aucun omnipotent » décrit donc la répartition des fichiers, pas celle des clés.

View file

@ -34,6 +34,7 @@
| `serveur_dovecot` | ingress | 993 | tcp | externe | tls-requis | Accès courriel des utilisateurs (IMAPS). Frontière publique gérée à l'OPNsense. |
| `serveur_dovecot` | ingress | 12345 | tcp | serveur_postfix | tls | Authentification SASL déléguée : Postfix valide les identifiants de soumission contre Dovecot. |
| `serveur_dovecot` | egress | 636 | tcp | serveur_openldap | tls-requis | userdb/passdb : Dovecot résout et authentifie les comptes sur l'annuaire (LDAPS). |
| `serveur_forge_site` | ingress | 443 | tcp | voisins_site | tls-requis | Servir le génome aux écosystèmes de ce site : c'est de cette forge qu'ils clonent leur moteur, leurs modèles et la carte de la fabric (D-81). Sans ce flux, un écosystème neuf ne peut pas se reproduire. |
| `serveur_forgejo` | ingress | 3000 | tcp | edge | clair | Interface web + Git HTTP derrière un edge : le nginx termine le TLS et parle en clair à la forge. C'est le cas de tout tenant. |
| `serveur_forgejo` | ingress | derive | tcp | flotte | tls | Sans edge devant elle — la forge du SITE — elle sert son propre TLS sur le port du schéma (443), avec le certificat de la machine. `derive` parce que le port vient de `serveur_forgejo_http_port` : écrire 3000 en dur ici serait faux pour elle, et rien ne le signalerait puisque ce flux ne traverse pas la frontière. |
| `serveur_forgejo` | egress | 25 | tcp | serveur_postfix | starttls | Notifications courriel (relais via le MTA Postfix). |
@ -71,8 +72,10 @@
| `serveur_ops` | egress | 443 | tcp | voisins_site | tls-requis | Cloner le genome depuis la forge du site, quand cet ecosysteme n'heberge pas la sienne. |
| `serveur_ops` | egress | 8006 | tcp | externe | tls-requis | API de l'hyperviseur : créer et cloner les VM d'un écosystème descendant. |
| `serveur_ops_site` | egress | 22 | tcp | fabric | ssh | Shell des hyperviseurs : ce que l'API ne couvre pas — configuration reseau, ponts, deplacement de disques. Un pouvoir distinct de l'API, donc declare a part. |
| `serveur_ops_site` | egress | 22 | tcp | serveur_ops_tenant | ssh | Insemination : amorcer le runner d'un tenant — socle, moteur, plan, plancher de resolution — pour qu'il prenne ensuite le relais sur ses propres machines. Ne transporte aucun secret : la voute est remise par un humain. |
| `serveur_ops_site` | egress | 443 | tcp | fabric | tls-requis | API de la frontiere OPNsense : poser les alias et les regles qui ouvrent les flux du tenant qu'on materialise. Preparer le terrain sans cela laisserait un terrain injoignable. |
| `serveur_ops_site` | egress | 8006 | tcp | fabric | tls-requis | API de l'hyperviseur : créer, cloner et détruire les VM de la fabric. Le seul flux par lequel un écosystème peut en matérialiser un autre. |
| `serveur_ops_tenant` | ingress | 22 | tcp | runner_site | ssh | Insémination : le runner du SITE amorce ce runner-ci — socle, moteur, plan, plancher de résolution — jusqu'à ce qu'un humain lui remette sa voûte. Vers cette machine seule, jamais vers le reste de l'écosystème. |
| `serveur_postfix` | ingress | 25 | tcp | externe, client_smtp | starttls | SMTP entrant : courrier externe (MX) et notifications internes (client_smtp). |
| `serveur_postfix` | ingress | 587 | tcp | flotte | starttls | Soumission authentifiée (submission) pour les agents internes qui envoient du courrier. |
| `serveur_postfix` | egress | 24 | tcp | serveur_dovecot | tls-requis | Remise finale par LMTP au mailstore (Dovecot), en TLS vérifié. |
@ -103,8 +106,8 @@
- **clair** : 35 flux
- **n-a** : 3 flux
- **ssh** : 5 flux
- **ssh** : 7 flux
- **starttls** : 6 flux
- **tls** : 9 flux
- **tls-cible** : 2 flux
- **tls-requis** : 32 flux
- **tls-requis** : 33 flux

View file

@ -27,6 +27,58 @@
# PASSAGE DE RELAIS : `client_resolveur` reprend ce fichier plus tard pour le basculer
# vers `127.0.0.1`. La garde ci-dessous le respecte — sans elle, chaque déploiement
# défairait la bascule, et deux rôles se disputeraient le même fichier sans fin.
# SOURCE D'ARTEFACTS D'AMORÇAGE, posée avant le premier `apt` — symétrique exacte du
# résolveur d'amorçage ci-dessous, et pour la même raison de fond : LA PREMIÈRE
# MACHINE D'UN ÉCOSYSTÈME N'A NI RÉSOLVEUR NI CACHE, ET C'EST ELLE QUI DOIT LES
# CONSTRUIRE.
#
# Mesuré le 2026-08-28 sur `ops-01`, première machine de la reconstruction de
# Chezlepro. Le socle restait bloqué sur son premier `apt update`, sans message qui
# parle de réseau :
#
# sortie Internet TCP 443 OK
# DNS UDP 53 -> 9.9.9.9 MUET
#
# Le DNS sortant est fermé PAR CONCEPTION — un tenant résout chez lui, son DNS ne
# traverse jamais la frontière. Or apt vise `deb.debian.org` PAR SON NOM, et le cache
# de l'écosystème n'existe pas encore.
#
# UN MANDATAIRE HTTP RÉSOUT CE QUE LE CLIENT NE PEUT PAS RÉSOUDRE : apt lui envoie
# l'URL absolue, et c'est LUI qui traduit le nom. La machine n'a donc besoin d'aucun
# DNS pour installer ses paquets — seulement d'une ADRESSE.
#
# C'EST DE LA FILIATION, PAS UNE RUSTINE (docs/filiation-emancipation.md). L'hébergeur
# nourrit la première machine de son locataire le temps que celui-ci monte son propre
# cache. L'emprunt est DÉCLARÉ — l'intrant nomme l'amont — et il s'efface de lui-même :
# `client_artefacts` écrit `00-setops-artefacts`, qui trie APRÈS ce fichier-ci, donc
# gagne dès que l'écosystème a sa propre source. Le plancher reste dessous, comme
# `/etc/hosts` reste sous le DNS.
#
# Aucun flux à ouvrir : `serveur_cache_site` accepte déjà le 3142 depuis
# `voisins_site`, qui résout les SUPERNETS des tenants — donc toute machine, pas
# seulement leur cache. Vérifié depuis `ops-01` : HTTP 200 en 0,158 s.
- name: Poser la source d'artefacts d'amorçage (avant tout apt)
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/00-setops-amorcage-artefacts
owner: root
group: root
mode: "0644"
content: |
// GÉNÉRÉ par Set-OPS (socle) — source d'artefacts d'AMORÇAGE, intrant
// `artefacts_amorcage`. Remplacé par `client_artefacts` (00-setops-artefacts,
// qui trie après) dès que l'écosystème a sa propre source.
//
// UNE ADRESSE, JAMAIS UN NOM : au moment où ce fichier sert, la machine n'a pas
// encore de résolveur, et le DNS sortant d'un tenant est fermé par conception.
Acquire::http::Proxy "http://{{ artefacts_amorcage }}";
// `DIRECT` est obligatoire : apt fait HÉRITER la valeur HTTPS de la valeur HTTP
// quand elle n'est pas définie, et enverrait les dépôts tiers en HTTPS dans un
// cache qui refuse les tunnels (mesuré le 2026-08-23).
Acquire::https::Proxy "DIRECT";
when:
- artefacts_amorcage is defined
- artefacts_amorcage | string | length > 0
- name: Lire le résolveur en place
ansible.builtin.slurp:
src: /etc/resolv.conf

View file

@ -0,0 +1,15 @@
---
- name: Appliquer le groupe serveur_forge_site
hosts: serveur_forge_site
become: true
gather_facts: true
pre_tasks:
- name: Vérifier que la cible est Debian
ansible.builtin.assert:
that:
- ansible_facts.distribution == "Debian"
fail_msg: "Ce playbook est prévu pour Debian."
roles:
- serveur_forge_site

View file

@ -135,6 +135,38 @@
- proxmox_clone_cle_publique is defined
- proxmox_clone_cle_publique | length > 0
# LA CLE D'AMORCAGE S'AJOUTE, ELLE NE REMPLACE PAS.
#
# L'exploitant doit garder son acces a la machine qu'il vient de creer : ecraser sa
# cle par celle du runner rendrait le runner seul maitre du terrain, ce qui est
# exactement l'inverse de la doctrine (c'est l'humain qui arme, cf.
# docs/filiation-emancipation.md). Deux cles, deux lignes, et cloud-init les pose
# toutes les deux.
#
# Elle n'arrive ICI que pour le runner d'un tenant : `inventory_host.py` la rend vide
# partout ailleurs, et cette tache ne se declenche donc pas.
#
# DEUX PIEGES, MESURES LE 2026-08-28, ET LE MEME MESSAGE POUR LES DEUX :
# 500 Internal Server Error: SSH public key validation error
#
# 1. LE SEPARATEUR. Un scalaire PLIE (`>-`) ne produit pas de saut de ligne : `'\n'`
# y reste deux caracteres, une barre et un « n ». Seul le scalaire YAML entre
# GUILLEMETS le convertit avant que Jinja ne le lise (verifie en base64 sur les
# trois formes candidates).
#
# 2. LA LISTE PEUT N'AVOIR QU'UN ELEMENT — et c'est le cas courant. Sur cette flotte,
# `CLE_SSH_PUBLIQUE` est vide : la cle de l'exploitant vient du GABARIT DORE, pas
# de cloud-init. Une simple concatenation rendait donc une valeur commencant par
# un saut de ligne, que Proxmox refuse tout autant. On assemble une LISTE et on
# laisse `select` retirer les vides — la forme est juste qu'il y ait zero, une ou
# deux cles.
- name: Ajouter la cle d'amorcage du runner de SITE (insemination)
ansible.builtin.set_fact:
proxmox_clone_sshkeys: "{{ [proxmox_clone_sshkeys | default('') | trim, proxmox_clone_cles_amorcage | trim] | select | join('\n') }}"
when:
- proxmox_clone_cles_amorcage is defined
- proxmox_clone_cles_amorcage | length > 0
- name: Construire les serveurs DNS Cloud-Init
ansible.builtin.set_fact:
proxmox_clone_nameservers: >-

View file

@ -38,6 +38,14 @@
import_playbook: groupes/serveur_rspamd.yml
- name: Couche services — groupe serveur_backup
import_playbook: groupes/serveur_backup.yml
- name: Couche services — groupe serveur_artefacts
import_playbook: groupes/serveur_artefacts.yml
- name: Couche services — groupe serveur_cache_site
import_playbook: groupes/serveur_cache_site.yml
- name: Couche services — groupe serveur_forge_site
import_playbook: groupes/serveur_forge_site.yml
- name: Couche services — groupe serveur_resolveur
import_playbook: groupes/serveur_resolveur.yml
- name: Couche services — groupe serveur_dovecot
import_playbook: groupes/serveur_dovecot.yml
- name: Couche services — groupe serveur_postfix
@ -64,6 +72,12 @@
import_playbook: groupes/serveur_icingaweb2.yml
- name: Couche apps — groupe serveur_nextcloud
import_playbook: groupes/serveur_nextcloud.yml
- name: Couche apps — groupe serveur_ops
import_playbook: groupes/serveur_ops.yml
- name: Couche apps — groupe serveur_ops_tenant
import_playbook: groupes/serveur_ops_tenant.yml
- name: Couche apps — groupe serveur_ops_site
import_playbook: groupes/serveur_ops_site.yml
# --- couche agents ---
- name: Couche agents — groupe client_metrique
@ -76,3 +90,5 @@
import_playbook: groupes/client_backup.yml
- name: Couche agents — groupe client_resolveur
import_playbook: groupes/client_resolveur.yml
- name: Couche agents — groupe client_artefacts
import_playbook: groupes/client_artefacts.yml

View file

@ -0,0 +1,48 @@
# serveur_forge_site
**Le marqueur de la forge du génome.** Il dit que *cette* forge est celle dont les
écosystèmes du site se reproduisent (D-81), et déclare le flux qui le permet. Il
n'installe rien.
## Pourquoi il existe séparément
C'est le même patron que `serveur_artefacts` + `serveur_cache_site` : **un installateur,
un marqueur**.
| | |
|---|---|
| `serveur_forgejo` | pose le logiciel, sert sa propre flotte |
| **ce rôle** | désigne la forge comme **racine du génome du site**, et l'ouvre aux tenants |
Ajouter `ingress 443 from voisins_site` à `serveur_forgejo` aurait ouvert **la forge de
chaque tenant** à ses voisins. La responsabilité appartient à une machine précise, pas au
logiciel qu'elle fait tourner.
## Ce qu'il a corrigé
Le site rend deux services à ses locataires pendant leur jeunesse : **les paquets** et
**le génome**. Le premier était déclaré (`serveur_cache_site`), le second ne l'était pas —
alors que D-81 fait de cette forge l'autorité dont tout écosystème se reproduit.
Mesuré le 2026-08-28 sur `ops-01`, première machine de la reconstruction de Chezlepro :
```
cache du SITE 10.0.33.21:3142 : OK
forge du SITE 10.0.33.11:443 : BLOQUÉ
```
Le plan du tenant déclarait pourtant lire son génome à cette adresse. **Une dépendance
déclarée chez le consommateur, sans flux correspondant chez le fournisseur** — et rien ne
le signalait : la garde de matrice de `resoudre_flux` ne vérifie que les paires
rôle→rôle, jamais un `pair` symbolique comme `voisins_site`.
## Qui peut le déclarer
**L'écosystème de l'hébergeur**, et lui seul. Un tenant qui le déclarerait offrirait sa
forge à ses voisins.
## Ce que ce rôle ne fait pas
Il n'installe rien, n'ouvre aucun port, ne sauvegarde rien. Il attribue une
responsabilité — et vérifie que le service qu'il désigne écoute vraiment, faute de quoi
il donnerait l'autorité du génome à un port muet.

View file

@ -0,0 +1,9 @@
---
# LA FORGE DU GÉNOME DU SITE — le marqueur, pas l'installateur.
#
# `serveur_forgejo` pose le logiciel ; ce rôle dit que CETTE forge est celle dont les
# écosystèmes du site se reproduisent (D-81), et déclare le flux qui le permet.
#
# Le port est celui que le plan du site donne à la forge. On ne le redéclare pas ici :
# une seconde source finirait par diverger de la première.
serveur_forge_site_port: "{{ serveur_forgejo_port | default(443) }}"

View file

@ -0,0 +1,14 @@
---
# Position de ce role dans la directive d'authentification (D-38..D-41).
# Voir docs/authentification.md. Gardee par la preuve P29.
authentification:
portee: sans-auth-humaine
mecanisme: aucun
formulaire_local: sans-objet
secours: "Acces SSH a l'hote"
raison: >-
Ce marqueur n'expose aucune interface : l'authentification de la forge elle-meme est
celle de `serveur_forgejo`. Ce que ce role ajoute est une LISTE de pairs — seuls les
tenants de ce site clonent ici, et la frontiere le fait respecter. Les depots servis
par ce chemin sont ceux du genome, publics par nature ; les depots prives gardent
l'authentification de la forge.

View file

@ -0,0 +1,7 @@
---
# Empreinte ressources — ce role n'installe rien. Il attribue une responsabilite a une
# forge qui existe deja, et la rend lisible dans le plan.
setops_empreinte:
coeurs: 0
memoire_mo: 0
disque_go: 0

View file

@ -0,0 +1,41 @@
---
# Flux réseau de la FORGE DU GÉNOME DU SITE. Voir docs/flux-conception.md.
#
# UN SEUL FLUX, et c'est tout son sens : servir le génome aux écosystèmes que ce site
# héberge. `serveur_forgejo` porte le reste (servir sa propre flotte, ses miroirs).
#
# EXACTEMENT LE SYMÉTRIQUE DE `serveur_cache_site`. Le site rend deux services à ses
# locataires pendant leur jeunesse : les paquets et le génome. Le premier était déclaré,
# le second ne l'était pas — et pourtant D-81 fait de cette forge l'AUTORITÉ dont tout
# écosystème se reproduit.
#
# CE QUE ÇA A COÛTÉ (2026-08-28) : `ops-01`, première machine de la reconstruction de
# Chezlepro, atteignait le cache du site et restait bloquée sur le clonage du génome.
#
# cache du SITE 10.0.33.21:3142 : OK
# forge du SITE 10.0.33.11:443 : BLOQUÉ
#
# Le plan du tenant déclarait pourtant lire son génome à cette adresse. **Une dépendance
# déclarée chez le consommateur, sans flux correspondant chez le fournisseur** — et rien
# ne le signalait, parce que la garde de matrice ne vérifie que les paires rôle→rôle.
#
# `voisins_site` désigne les autres tenants que CE SITE héberge. Ni `flotte` (mon
# écosystème), ni `externe` — qui aurait publié la forge du génome à l'Internet.
#
# LA RÈGLE APPARTIENT AU SITE : c'est son runner qui prépare le terrain, pas un tenant
# qui ouvrirait une porte chez un autre.
flux:
- sens: ingress
port: 443
protocole: tcp
pair: voisins_site
chiffrement: tls-requis
raison: >-
Servir le génome aux écosystèmes de ce site : c'est de cette forge qu'ils clonent
leur moteur, leurs modèles et la carte de la fabric (D-81). Sans ce flux, un
écosystème neuf ne peut pas se reproduire.
# CE RÔLE N'OUVRE AUCUNE ÉCOUTE — il emprunte celle de `serveur_forgejo`, sur le même
# hôte. Sans ce mot, P33 y voit deux rôles qui se disputent le 443, et elle a raison
# de le demander : seul le registre distingue « j'ouvre » de « j'autorise une source
# de plus sur l'écoute d'un autre ».
partage: true

View file

@ -0,0 +1,15 @@
---
# CE RÔLE EN EXIGE UN AUTRE, ET C'EST À ANSIBLE DE LE SAVOIR.
#
# `serveur_forge_site` n'installe rien : il MARQUE une forge comme détentrice du génome
# du site, et vérifie qu'elle écoute vraiment. Pour cela il lit les variables de
# `serveur_forgejo`.
#
# Or les valeurs par défaut d'un rôle ne sont en portée que DANS LE PLAY QUI L'INCLUT.
# Une dépendance de rôle règle les deux choses d'un coup : Ansible applique l'installateur
# AVANT le marqueur, dans le MÊME play, donc ses variables sont en portée — et l'ordre
# cesse de dépendre de la façon dont on lance le déploiement.
#
# Même raisonnement, mot pour mot, que `serveur_cache_site` devant `serveur_artefacts`.
dependencies:
- role: serveur_forgejo

View file

@ -0,0 +1,29 @@
---
# CE RÔLE MARQUE UNE FORGE ; IL N'EN INSTALLE PAS.
#
# Il présuppose `serveur_forgejo` sur le même hôte et lit ses variables. Le message qu'on
# obtiendrait sans lui — « variable non définie » — envoie chercher une faute de frappe
# alors que la cause est une déclaration incomplète, un cran au-dessus. On le dit donc
# nous-mêmes, et en premier.
- name: Le rôle qui installe la forge est-il bien là ?
ansible.builtin.assert:
that:
- serveur_forge_site_port is defined
- serveur_forge_site_port | int > 0
fail_msg: >-
`serveur_forge_site` marque une forge existante, il n'en installe aucune.
Cet hôte doit AUSSI porter `serveur_forgejo`. Ajouter les deux rôles à sa
déclaration, dans cet ordre.
# ÉCRIRE, PUIS RELIRE (D-68). Le rôle n'installe rien : sa seule prise sur le réel est de
# vérifier que le service qu'il désigne existe vraiment. Sans ça, il attribuerait
# l'autorité du génome à un port que personne n'écoute — et l'écosystème qui viendrait
# s'y reproduire attendrait sans savoir pourquoi. C'est précisément ce qui est arrivé le
# 2026-08-28 : `ops-01` a attendu quinze minutes sur un clonage qui ne pouvait pas
# aboutir.
- name: La forge écoute-t-elle vraiment ?
ansible.builtin.wait_for:
host: "127.0.0.1"
port: "{{ serveur_forge_site_port }}"
timeout: 30
when: not ansible_check_mode

View file

@ -153,3 +153,32 @@ serveur_ops_cache_collections: >-
{{ (setops_cache_artefacts | default(lookup('env', 'HOME') + '/.cache/setops')) }}/collections-{{
lookup('file', serveur_ops_requirements_controleur) | hash('sha1') | truncate(12, true, '') }}
serveur_ops_requirements_controleur: "{{ playbook_dir }}/../../requirements.yml"
# --- LA ROUE HORS-LIGNE : PYTHON PAR LE MEME CHEMIN QUE LES COLLECTIONS -------
#
# LA DERNIERE DEPENDANCE QUI SORTAIT PAR ELLE-MEME (2026-08-28). Les collections
# Ansible suivent depuis longtemps l'idiome de ce depot — « le CONTROLEUR telecharge une
# fois, puis pousse par SSH ; aucun flux nouveau depuis la cible, et l'artefact devient
# deployable hors ligne, ce qui est le sens meme d'une lignee autonome ». `pip`, lui,
# sortait encore vers PyPI, en HTTPS et par nom de domaine.
#
# Un tenant n'a NI DNS SORTANT NI 443 vers l'Internet, et c'est voulu. Mesure sur ops-01,
# premiere machine de la reconstruction de Chezlepro :
#
# ERROR: Could not find a version that satisfies the requirement ansible-core<2.19
# Echec temporaire dans la resolution du nom
#
# POURQUOI PAS LE PAQUET DEBIAN : trixie propose `ansible-core 2.19.4`, hors de la plage
# epinglee. Y aller demanderait de deplacer l'epinglage vers une version majeure aux
# changements de gabarits connus, trois jours apres l'avoir pose pour cause de
# portabilite. Ce n'est pas le moment, et ce n'est pas ce probleme-ci.
#
# L'EMPREINTE DES SOURCES ENTRE DANS LE CHEMIN, comme pour les collections : changer une
# ligne de `requirements-python.txt` ou l'epinglage d'ansible-core cree un cache neuf,
# donc un telechargement. Les anciens restent et documentent ce qu'exigeaient les
# versions precedentes.
serveur_ops_roues_source: "{{ playbook_dir }}/../../requirements-python.txt"
serveur_ops_cache_roues: >-
{{ (setops_cache_artefacts | default(lookup('env', 'HOME') + '/.cache/setops')) }}/roues-{{
(lookup('file', serveur_ops_roues_source) ~ serveur_ops_ansible) | hash('sha1') | truncate(12, true, '') }}
serveur_ops_roues_depot: "{{ serveur_ops_racine }}/.roues-hors-ligne"

View file

@ -93,13 +93,97 @@
group: "{{ serveur_ops_utilisateur }}"
mode: "0644"
- name: Installer Ansible dans l'environnement isolé
# --- LA ROUE HORS-LIGNE ------------------------------------------------------
#
# MEME IDIOME QUE LES COLLECTIONS, quelques lignes plus bas : le CONTROLEUR telecharge
# une fois dans son cache, puis pousse par SSH. La cible n'ouvre aucun flux, et
# l'artefact devient deployable hors ligne.
- name: Preparer le cache des roues Python sur le controleur
ansible.builtin.file:
path: "{{ serveur_ops_cache_roues }}"
state: directory
mode: "0755"
delegate_to: localhost
become: false
run_once: true
# LE CONTROLEUR ET LA CIBLE DOIVENT PARTAGER LEUR PYTHON, et ca ne se devine pas.
#
# `pip download` rend les roues du systeme qui telecharge. `ansible-core`, `proxmoxer` et
# `requests` sont universelles (`py3-none-any`), mais `pyyaml` porte du C compile : une
# roue `cp313` posee sur un python 3.12 ne s'installe pas. L'echec serait « No matching
# distribution found » a l'installation — un message qui accuse le depot local alors que
# le coupable est l'ecart entre deux machines. On le mesure ici, ou on peut encore le dire.
- name: Lire le Python du controleur
ansible.builtin.command:
argv: ["python3", "-c", "import sys; print('%d.%d' % sys.version_info[:2])"]
delegate_to: localhost
become: false
run_once: true
changed_when: false
register: serveur_ops_python_controleur
- name: Exiger que le controleur et la cible partagent leur version de Python
ansible.builtin.assert:
that:
- serveur_ops_python_controleur.stdout | trim
== ansible_python_version.split('.')[0:2] | join('.')
fail_msg: >-
Le controleur tourne Python {{ serveur_ops_python_controleur.stdout | trim }} et
{{ inventory_hostname }} tourne Python
{{ ansible_python_version.split('.')[0:2] | join('.') }}. Les roues telechargees ne
s'installeraient pas sur la cible, et l'echec accuserait le depot local au lieu de
cet ecart. Materialiser depuis un controleur de meme famille — pendant une
insemination, c'est le runner du SITE, qui porte le meme Debian que la machine
qu'il amorce.
# `argv` ET NON `cmd` : le chemin du controleur peut contenir une ESPACE (« Espace
# Chezlepro/… » chez le mainteneur). Meme piege que pour ansible-galaxy le 2026-08-23.
- name: Telecharger les roues sur le controleur (une seule fois par empreinte)
ansible.builtin.command:
argv:
- "python3"
- "-m"
- "pip"
- "download"
- "--dest"
- "{{ serveur_ops_cache_roues }}"
- "--requirement"
- "{{ serveur_ops_roues_source }}"
- "{{ serveur_ops_ansible }}"
- "pyyaml"
delegate_to: localhost
become: false
run_once: true
register: serveur_ops_roues_telechargees
changed_when: "'Saved' in serveur_ops_roues_telechargees.stdout"
# LE GARDE-FOU SUIT LE RESULTAT, PAS LE GESTE (lecon du 2026-08-27) : on saute quand le
# cache PORTE deja des roues, pas quand une commande a deja tourne un jour.
when: not ansible_check_mode
- name: Deposer les roues sur le runner
ansible.builtin.copy:
src: "{{ serveur_ops_cache_roues }}/"
dest: "{{ serveur_ops_roues_depot }}/"
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0644"
directory_mode: "0755"
when: not ansible_check_mode
# `--no-index` EST LE POINT, PAS UNE OPTIMISATION. Sans lui, pip resterait capable de
# sortir vers PyPI le jour ou le depot local serait incomplet — et la reussite dependrait
# alors d'un flux que ce tenant n'a pas le droit d'avoir. Un repli silencieux vers
# l'Internet transformerait une lignee autonome en lignee qui a l'air autonome.
- name: Installer Ansible dans l'environnement isolé (hors ligne)
ansible.builtin.pip:
name:
- "{{ serveur_ops_ansible }}"
- "pyyaml"
virtualenv: "{{ serveur_ops_venv }}"
state: present
extra_args: "--no-index --find-links {{ serveur_ops_roues_depot }}"
become: true
become_user: "{{ serveur_ops_utilisateur }}"
when: not ansible_check_mode
@ -111,11 +195,12 @@
# TACHE SEPAREE : dans `ansible.builtin.pip`, `name` et `requirements` sont MUTUELLEMENT
# EXCLUSIFS. Les fusionner ne produit pas une erreur parlante — le module ignore l'un des
# deux, et la bibliotheque manquante ne se revele qu'au premier module qui en depend.
- name: Installer les bibliotheques Python du controleur (source declaree)
- name: Installer les bibliotheques Python du controleur (source declaree, hors ligne)
ansible.builtin.pip:
requirements: "{{ serveur_ops_racine }}/requirements-python.txt"
virtualenv: "{{ serveur_ops_venv }}"
state: present
extra_args: "--no-index --find-links {{ serveur_ops_roues_depot }}"
become: true
become_user: "{{ serveur_ops_utilisateur }}"
when: not ansible_check_mode

View file

@ -41,3 +41,26 @@ serveur_ops_site_depot: ""
# chiffrés, sur une machine où ils n'avaient rien à faire.
serveur_ops_site_voute_source: ""
serveur_ops_site_voute_deposer: true
# --- ARMER LE RUNNER : LA CLE DE SA VOUTE ------------------------------------
#
# UN RUNNER MAITRE DE SA VOUTE (decision de l'exploitant, 2026-08-28). Deposer le chiffre
# sans la cle laisse un runner qui calcule son inventaire et ne peut rien en faire : chaque
# role reclamant un secret echoue sur son assertion, et l'echec ne dit pas qu'il manque un
# FICHIER — seulement que les valeurs sont vides.
#
# `false` PAR DEFAUT, ET CE N'EST PAS DE LA PRUDENCE DECORATIVE. Armer est un ACTE, pas un
# reglage : c'est le moment ou un humain remet la cle, et c'est le seul geste que la
# reproduction exige de lui (cf. docs/filiation-emancipation.md). Un defaut a `true`
# armerait des machines sans que personne ne l'ait decide.
#
# LA SOURCE EST UN POINTEUR, JAMAIS UNE VALEUR : le chemin du fichier de mot de passe sur
# le poste de l'exploitant. Il ne vit dans aucun depot, et rien ici n'en lit le contenu.
#
# DESTINATION : `<racine>/.config/setops-vault-<depot-en-minuscules>`, la meme convention
# que `scripts/voutes.py` — le runner derive donc sa propre liste d'identites sans qu'on
# ait a la lui ecrire. Hors de tout depot git, en 0600.
serveur_ops_site_cle_deposer: false
serveur_ops_site_cle_source: ""
serveur_ops_site_cle_destination: >-
{{ serveur_ops_site_racine }}/.config/setops-vault-{{ serveur_ops_site_depot | lower }}

View file

@ -2,9 +2,25 @@
# Flux réseau du runner de SITE. Voir docs/flux-conception.md.
#
# Les flux du POUVOIR DE MATERIALISER : API de l'hyperviseur, shell des noeuds, API de
# la frontiere. Chacun est un pouvoir distinct, donc declare a part. Ce rôle n'entre JAMAIS
# chez un tenant — c'est `serveur_ops` qui configure les machines, une fois qu'elles
# existent.
# la frontiere. Chacun est un pouvoir distinct, donc declare a part.
#
# CE ROLE N'ENTRE PAS CHEZ UN TENANT — SAUF SUR SON RUNNER, POUR L'AMORCER (2026-08-28).
#
# Cette ligne disait « JAMAIS », et c'etait une frontiere qu'on ne pouvait pas tenir. Un
# ecosysteme neuf ne s'amorce pas lui-meme : sa premiere machine doit recevoir le socle,
# le moteur, son plan et son plancher de resolution avant de pouvoir configurer quoi que
# ce soit. Sans ce flux, ce travail retombe sur un humain qui le fait a la main — donc
# hors du depot, donc sans preuve.
#
# LE COUPLAGE EXISTAIT DEJA, NON DECLARE : `creer-vm` exige `_instance-requise`, et le
# runner du site doit donc basculer son symlink `instance` sur le depot du tenant qu'il
# materialise. Le declarer ne cree pas un pouvoir, il rend limitable un pouvoir qui
# s'exercait sans borne.
#
# CE QU'IL RESTE INTERDIT, ET QUI N'EST PAS UNE REGLE MAIS UN FAIT : armer. Le runner du
# SITE ne detient pas la voute d'un tenant — elle vit hors depot — et n'en connait pas le
# mot de passe. Il peut poser tout ce qui est public ; c'est un HUMAIN qui remet le
# secret, et c'est la que le pouvoir du site s'arrete. Voir docs/filiation-emancipation.md.
flux:
- sens: egress
port: 8006
@ -38,3 +54,19 @@ flux:
API de la frontiere OPNsense : poser les alias et les regles qui ouvrent les flux
du tenant qu'on materialise. Preparer le terrain sans cela laisserait un terrain
injoignable.
# L'INSEMINATION — le seul flux de ce role qui entre chez un tenant.
#
# Etroit par construction : il vise le groupe `serveur_ops_tenant`, qu'un ecosysteme ne
# pose que sur une machine. Declare au socle, il aurait ouvert le SSH du site vers TOUTE
# la flotte du tenant ; declare ici, sa portee est celle du groupe, et le groupe est
# ecrit au plan du tenant.
- sens: egress
port: 22
protocole: tcp
pair: serveur_ops_tenant
chiffrement: ssh
raison: >-
Insemination : amorcer le runner d'un tenant — socle, moteur, plan, plancher de
resolution — pour qu'il prenne ensuite le relais sur ses propres machines. Ne
transporte aucun secret : la voute est remise par un humain.

View file

@ -62,3 +62,69 @@
when:
- serveur_ops_site_voute_deposer | bool
- not ansible_check_mode
# --- ARMER LE RUNNER ---------------------------------------------------------
#
# Ce qui suit ne se declenche que si un humain l'a demande (serveur_ops_site_cle_deposer).
- name: Exiger une source pour la cle de la voute
ansible.builtin.assert:
that:
- serveur_ops_site_cle_source | length > 0
fail_msg: >-
serveur_ops_site_cle_deposer est vrai, mais aucune
`serveur_ops_site_cle_source` n'est nommee. Armer un runner sans dire
avec QUELLE cle n'a pas de sens — et poser un fichier vide le laisserait croire
arme.
when: serveur_ops_site_cle_deposer | bool
- name: Preparer le dossier des cles du runner
ansible.builtin.file:
path: "{{ serveur_ops_site_racine }}/.config"
state: directory
owner: "{{ serveur_ops_site_utilisateur }}"
group: "{{ serveur_ops_site_utilisateur }}"
mode: "0700"
when:
- serveur_ops_site_cle_deposer | bool
- not ansible_check_mode
- name: Deposer la cle de la voute
ansible.builtin.copy:
src: "{{ serveur_ops_site_cle_source }}"
dest: "{{ serveur_ops_site_cle_destination }}"
owner: "{{ serveur_ops_site_utilisateur }}"
group: "{{ serveur_ops_site_utilisateur }}"
mode: "0600"
no_log: true
when:
- serveur_ops_site_cle_deposer | bool
- not ansible_check_mode
# ECRIRE, PUIS RELIRE — comme pour la voute, et pour la faute SYMETRIQUE : ici on ne
# craint pas un chiffre devenu clair, mais une cle qui serait en fait une VOUTE. Copier le
# mauvais fichier laisse un runner qui se croit arme et echoue au premier secret, tres
# loin d'ici. On mesure aussi qu'elle n'est pas vide : un fichier de mot de passe vide est
# accepte par Ansible et n'ouvre rien.
- name: Relire la cle deposee
ansible.builtin.stat:
path: "{{ serveur_ops_site_cle_destination }}"
checksum_algorithm: sha256
register: serveur_ops_site_cle_posee
when:
- serveur_ops_site_cle_deposer | bool
- not ansible_check_mode
- name: Exiger une cle non vide, en 0600, et qui ne soit pas une voute
ansible.builtin.assert:
that:
- serveur_ops_site_cle_posee.stat.exists
- serveur_ops_site_cle_posee.stat.size > 0
- serveur_ops_site_cle_posee.stat.mode == '0600'
fail_msg: >-
La cle deposee est absente, vide, ou lisible au-dela de son proprietaire.
Un runner arme d'une cle vide se croit arme et echoue au premier secret,
tres loin d'ici.
when:
- serveur_ops_site_cle_deposer | bool
- not ansible_check_mode

View file

@ -56,3 +56,26 @@ serveur_ops_tenant_voute_deposer: true
serveur_ops_tenant_inventaires_connus:
- principal
- production
# --- ARMER LE RUNNER : LA CLE DE SA VOUTE ------------------------------------
#
# UN RUNNER MAITRE DE SA VOUTE (decision de l'exploitant, 2026-08-28). Deposer le chiffre
# sans la cle laisse un runner qui calcule son inventaire et ne peut rien en faire : chaque
# role reclamant un secret echoue sur son assertion, et l'echec ne dit pas qu'il manque un
# FICHIER — seulement que les valeurs sont vides.
#
# `false` PAR DEFAUT, ET CE N'EST PAS DE LA PRUDENCE DECORATIVE. Armer est un ACTE, pas un
# reglage : c'est le moment ou un humain remet la cle, et c'est le seul geste que la
# reproduction exige de lui (cf. docs/filiation-emancipation.md). Un defaut a `true`
# armerait des machines sans que personne ne l'ait decide.
#
# LA SOURCE EST UN POINTEUR, JAMAIS UNE VALEUR : le chemin du fichier de mot de passe sur
# le poste de l'exploitant. Il ne vit dans aucun depot, et rien ici n'en lit le contenu.
#
# DESTINATION : `<racine>/.config/setops-vault-<depot-en-minuscules>`, la meme convention
# que `scripts/voutes.py` — le runner derive donc sa propre liste d'identites sans qu'on
# ait a la lui ecrire. Hors de tout depot git, en 0600.
serveur_ops_tenant_cle_deposer: false
serveur_ops_tenant_cle_source: ""
serveur_ops_tenant_cle_destination: >-
{{ serveur_ops_tenant_racine }}/.config/setops-vault-{{ serveur_ops_tenant_depot | lower }}

View file

@ -1,11 +1,40 @@
---
# Flux réseau du runner de TENANT. Voir docs/flux-conception.md.
#
# AUCUN FLUX PROPRE, ET C'EST LE POINT. Ce rôle ne fait que déposer un fichier ; ce qui
# consomme la voûte, c'est `serveur_ops` quand il configure les machines de l'écosystème,
# et ses flux sont déclarés chez lui. Ce rôle n'atteint NI la fabric NI la frontière —
# c'est exactement ce qui le sépare de `serveur_ops_site`.
# UN SEUL FLUX, ET C'EST LE LIEN DE FILIATION.
#
# Le dire explicitement plutôt que d'omettre le fichier : une absence de déclaration se
# lit comme un oubli, une déclaration vide se lit comme une décision.
flux: []
# Ce rôle ne fait que déposer un fichier ; ce qui consomme la voûte, c'est `serveur_ops`
# quand il configure les machines de l'écosystème, et ses flux sont déclarés chez lui.
# Ce rôle n'atteint NI la fabric NI la frontière — c'est exactement ce qui le sépare de
# `serveur_ops_site`.
#
# Mais il porte la marque de l'INSÉMINATION. Un écosystème neuf ne peut pas s'amorcer
# lui-même : quelqu'un doit poser sa première machine et lui donner de quoi continuer.
# Ce quelqu'un est le runner du SITE, et ce flux est le seul chemin par lequel il entre —
# vers cette machine-ci, et vers aucune autre du tenant.
#
# POURQUOI IL EST DÉCLARÉ ICI ET PAS AU SOCLE. Au socle, il ouvrirait le SSH du site vers
# TOUTES les machines du tenant, ce que l'isolation refuse. Déclaré sur ce rôle, il ne
# vaut que pour les hôtes qui le portent — un seul par écosystème. La portée du pouvoir
# est celle du groupe, et le groupe est écrit au plan.
#
# CE QU'IL NE TRANSPORTE JAMAIS : un secret. Le runner du SITE peut poser le socle, le
# moteur, le plan et le plancher de résolution — tout cela est public. Il ne détient pas
# la voûte du tenant (elle est hors dépôt) et n'en connaît pas le mot de passe. C'est
# l'humain qui arme ce runner, et c'est voulu : voir `docs/filiation-emancipation.md`.
flux:
- sens: ingress
port: 22
protocole: tcp
pair: runner_site
chiffrement: ssh
# CE ROLE N'OUVRE AUCUNE ECOUTE — il emprunte le sshd du socle, comme
# `serveur_backup`. Sans ce mot, P33 y voit deux roles qui se disputent le port 22
# sur la meme machine, et elle a raison de le demander : il n'y a que le registre
# pour distinguer « j'ouvre une ecoute » de « j'autorise une source de plus sur
# l'ecoute d'un autre ».
partage: true
raison: >-
Insémination : le runner du SITE amorce ce runner-ci — socle, moteur, plan,
plancher de résolution — jusqu'à ce qu'un humain lui remette sa voûte. Vers cette
machine seule, jamais vers le reste de l'écosystème.

View file

@ -111,3 +111,69 @@
when:
- serveur_ops_tenant_voute_deposer | bool
- not ansible_check_mode
# --- ARMER LE RUNNER ---------------------------------------------------------
#
# Ce qui suit ne se declenche que si un humain l'a demande (serveur_ops_tenant_cle_deposer).
- name: Exiger une source pour la cle de la voute
ansible.builtin.assert:
that:
- serveur_ops_tenant_cle_source | length > 0
fail_msg: >-
serveur_ops_tenant_cle_deposer est vrai, mais aucune
`serveur_ops_tenant_cle_source` n'est nommee. Armer un runner sans dire
avec QUELLE cle n'a pas de sens — et poser un fichier vide le laisserait croire
arme.
when: serveur_ops_tenant_cle_deposer | bool
- name: Preparer le dossier des cles du runner
ansible.builtin.file:
path: "{{ serveur_ops_tenant_racine }}/.config"
state: directory
owner: "{{ serveur_ops_tenant_utilisateur }}"
group: "{{ serveur_ops_tenant_utilisateur }}"
mode: "0700"
when:
- serveur_ops_tenant_cle_deposer | bool
- not ansible_check_mode
- name: Deposer la cle de la voute
ansible.builtin.copy:
src: "{{ serveur_ops_tenant_cle_source }}"
dest: "{{ serveur_ops_tenant_cle_destination }}"
owner: "{{ serveur_ops_tenant_utilisateur }}"
group: "{{ serveur_ops_tenant_utilisateur }}"
mode: "0600"
no_log: true
when:
- serveur_ops_tenant_cle_deposer | bool
- not ansible_check_mode
# ECRIRE, PUIS RELIRE — comme pour la voute, et pour la faute SYMETRIQUE : ici on ne
# craint pas un chiffre devenu clair, mais une cle qui serait en fait une VOUTE. Copier le
# mauvais fichier laisse un runner qui se croit arme et echoue au premier secret, tres
# loin d'ici. On mesure aussi qu'elle n'est pas vide : un fichier de mot de passe vide est
# accepte par Ansible et n'ouvre rien.
- name: Relire la cle deposee
ansible.builtin.stat:
path: "{{ serveur_ops_tenant_cle_destination }}"
checksum_algorithm: sha256
register: serveur_ops_tenant_cle_posee
when:
- serveur_ops_tenant_cle_deposer | bool
- not ansible_check_mode
- name: Exiger une cle non vide, en 0600, et qui ne soit pas une voute
ansible.builtin.assert:
that:
- serveur_ops_tenant_cle_posee.stat.exists
- serveur_ops_tenant_cle_posee.stat.size > 0
- serveur_ops_tenant_cle_posee.stat.mode == '0600'
fail_msg: >-
La cle deposee est absente, vide, ou lisible au-dela de son proprietaire.
Un runner arme d'une cle vide se croit arme et echoue au premier secret,
tres loin d'ici.
when:
- serveur_ops_tenant_cle_deposer | bool
- not ansible_check_mode

View file

@ -234,6 +234,16 @@ def flux_frontiere(flux_par_role: dict[str, list[dict]]) -> list[tuple[str, dict
`voisins_site` — l'est-ouest INTER-TENANT. Deux tenants de la meme fabric vivent
sur des VLAN distincts, routes par la frontiere : leur trafic la
traverse, donc elle doit le porter.
ET UNE TROISIEME QUI N'EN EST PAS UNE : `runner_site`, l'INSEMINATION. Ce trafic
traverse bien la frontiere — le runner du SITE et celui du tenant vivent dans deux
VRF — mais le paquet y PENETRE PAR LA PATTE DU SITE, pas par le lien de transit. La
regle qui lui correspond est donc emise plus bas, avec les machines du site, depuis
la declaration `egress` de `serveur_ops_site`.
L'emettre ici AUSSI produirait une seconde regle attachee a la mauvaise interface :
jamais evaluee, impossible a distinguer d'une regle utile, et comptee comme telle par
tout ce qui audite ce devis. La declaration `ingress` du tenant n'est pas perdue pour
autant — c'est elle qui pose la regle nftables sur la machine visee, et elle seule.
OUBLIER LA SECONDE, C'ETAIT DECLARER UN FLUX QUE PERSONNE N'APPLIQUE (2026-08-24). Le
mot `voisins_site` etait accepte par la validation, le devis rendait zero regle, et
@ -310,24 +320,14 @@ def _instance_active() -> tuple[str, dict] | tuple[None, None]:
return None, None
def _plan_site() -> Path | None:
"""Le `plan/` du SITE, a cote de son `underlay.yml`."""
c = underlay_mod.chemin()
return (c.resolve().parent / "plan") if c else None
def _lire_plan_site(nom: str) -> dict:
d = _plan_site()
f = (d / nom) if d else None
if not f or not f.is_file():
return {}
return yaml.safe_load(f.read_text(encoding="utf-8")) or {}
def _machines_du_plan_site() -> list[dict]:
srv = (_lire_plan_site("serveurs.yml") or {}).get("serveurs") or {}
return [{**s, "nom": nom} for nom, s in srv.items()
if str(s.get("etat", "actif")) == "actif"]
# CES TROIS LECTEURS ONT DEMENAGE DANS `underlay` (2026-08-28). `resoudre_flux` a eu
# besoin du plan du site a son tour, pour resoudre le pair `runner_site` ; une seconde
# copie privee aurait fini par diverger de celle-ci sans que rien ne le signale. Lecon de
# P41 appliquee au plan du site : une seule resolution, partagee. Les noms locaux restent
# pour ne pas reecrire trente appels.
_lire_plan_site = underlay_mod.lire_plan_site
_machines_du_plan_site = underlay_mod.machines_site
_services_du_plan_site = underlay_mod.services_site
def _ports_du_plan_site() -> dict[str, str]:
@ -346,15 +346,6 @@ def _ports_du_plan_site() -> dict[str, str]:
return out
def _services_du_plan_site() -> dict[str, list[str]]:
apps = (_lire_plan_site("applications.yml") or {}).get("applications") or {}
out: dict[str, list[str]] = {}
for app in apps.values():
if app.get("hote") and app.get("groupe"):
out.setdefault(str(app["hote"]), []).append(str(app["groupe"]))
return out
def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"""Le devis, en structure — rendue ensuite en texte ou en JSON."""
flux_par_role = charger_flux()
@ -481,6 +472,12 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
regles: list[dict] = []
tenants_sans_inventaire: list[str] = []
tenants_sans_admin: list[str] = []
# PAS D'ALIAS POUR LA SOURCE DE L'INSEMINATION ICI. La regle est emise du cote SITE
# (voir `flux_frontiere`), ou la source est deja l'alias de role du runner —
# `SETOPS_SITE_SERVEUR_OPS_SITE`. En poser un second ici aurait laisse sur la
# frontiere un alias qu'aucune regle ne reference : `appliquer_opnsense` l'aurait
# cree, et personne n'aurait su a quoi il sert.
for nom, pfx, n in tenants:
etiquette = f"{pfx}{n['index']}"
alias_tenant = f"SETOPS_TENANT_{etiquette}"
@ -691,6 +688,26 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"passerelle : le site n'a pas de resolveur a lui.",
})
# LES RUNNERS DES TENANTS QUE CE SITE HEBERGE — destination de l'insemination.
#
# Un seul alias pour tous : ce que la regle dit, c'est « le runner du site peut
# amorcer le runner d'un tenant de CE site ». Le perimetre est celui du registre
# `underlay.tenants`, deja la source du reste du devis.
#
# Ce sont des adresses d'hotes, pas des reseaux : ouvrir le supernet d'un tenant
# au SSH du site rendrait l'insemination indiscernable d'un droit d'entree
# general — exactement ce que l'isolation refuse.
_runners_tenants = sorted({ip for _n2, _p2, _x2 in tenants
for ip in (cibles_par_role("serveur_ops_tenant",
inventaire_de(_n2))
if inventaire_de(_n2) else [])})
if _runners_tenants:
alias["SETOPS_RUNNERS_TENANTS"] = {
"type": "host",
"contenu": _runners_tenants,
"description": "Runners des tenants heberges — cible de l'insemination",
}
_roles_site = sorted({g for gs in _services_site.values() for g in gs}
| {"serveur_debian"})
for _role in _roles_site:
@ -746,6 +763,10 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
_portees = [(_a, z) for z in _zones] or [(_a, if_site)]
# `derive` se resout par le port declare au plan ; sans lui, on n'emet
# rien plutot qu'une regle que le boitier refusera.
# Une destination qui n'existe pas ne produit pas de regle : aucun
# tenant de ce site ne declare encore de runner.
if "serveur_ops_tenant" in _pairs_fl and not _runners_tenants:
continue
_p = _ports(_fl)
if any(not str(x).isdigit() for x in _p):
_reel = _ports_site.get(_role)
@ -764,7 +785,9 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"protocole": _fl.get("protocole", "tcp"),
"source": _src if _entrant else _a,
"destination": _a if _entrant else (
"SETOPS_FABRIC" if "fabric" in _pairs_fl else "!SETOPS_INTERNES"),
"SETOPS_RUNNERS_TENANTS" if "serveur_ops_tenant" in _pairs_fl
else "SETOPS_FABRIC" if "fabric" in _pairs_fl
else "!SETOPS_INTERNES"),
"ports": _p,
"chiffrement": _fl.get("chiffrement"),
"role": _role,

View file

@ -254,9 +254,54 @@ def parametres_proxmox_hote(data: dict, host: str,
"Regenere l'inventaire (`make instancier-appliquer`)."
)
# LA CLE D'AMORCAGE — POSEE SUR LE RUNNER D'UN TENANT, ET SUR LUI SEUL.
#
# Le runner du SITE doit pouvoir entrer chez le runner du tenant pour l'amorcer :
# c'est l'insemination, et son flux est declare dans
# `roles/serveur_ops_site/meta/flux.yml`. Il lui faut donc une identite sur cette
# machine — une cle publique, qui n'ouvre rien par elle-meme.
#
# LE CRITERE EST LE MEME QUE CELUI DU FLUX : porter `serveur_ops_tenant`. C'est ce
# qui empeche le geste de deraper. `creer-vm` cree TOUTES les machines d'un tenant ;
# injecter cette cle a chaque clonage donnerait au site un acces SSH a la flotte
# entiere de chaque locataire, sans qu'aucune regle ne le dise — et ca deferait a la
# couche IDENTITE ce que le pare-feu borne a la couche RESEAU. Deux couches qui ne
# declarent pas la meme politique, c'est une politique qu'on ne peut plus lire.
#
# La cle vient du PLAN DU SITE, pas du disque local : on materialise depuis le runner
# comme depuis le poste de l'exploitant, et un `lookup` local rendrait deux valeurs
# differentes selon qui agit — la VM nee du poste n'accepterait pas le runner.
lignes.append(f"SETOPS_CLES_AMORCAGE='{_cle_amorcage(data, host)}'")
return lignes
GROUPE_RUNNER_TENANT = "serveur_ops_tenant"
def _cle_amorcage(data: dict, host: str) -> str:
"""La cle publique du runner du SITE, si cet hote est le runner de son tenant.
Rend '' partout ailleurs — et aussi quand le depot de l'hebergeur n'est pas monte
ou ne declare pas de runner : un ecosysteme se materialise alors comme avant, avec
la seule cle de l'exploitant. Degrader, jamais deviner.
"""
if GROUPE_RUNNER_TENANT not in groupes_hote(data, host):
return ""
try:
import underlay as underlay_mod
cle = str((underlay_mod.lire_plan_site("10-intrants.yml") or {})
.get("runner_cle_publique") or "").strip()
except Exception:
return ""
if "'" in cle:
raise ValueError(
f"`runner_cle_publique` du site contient une apostrophe ({host}) : "
"la valeur passe par une commande shell, elle ne peut pas en porter."
)
return cle
def _rang_des_couches() -> dict[str, int]:
"""Rang de chaque groupe dans `docs/couches-deploiement.yml`. {} si le fichier manque.

View file

@ -2014,8 +2014,15 @@ PREUVES: list[dict] = [
def _vault_requis_absent() -> bool:
"""Vrai si l'inventaire chiffre un group_vars et qu'aucun mot de passe n'est fourni."""
if os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE"):
"""Vrai si l'inventaire chiffre un group_vars et qu'aucun mot de passe n'est fourni.
DEUX FACONS DE FOURNIR UNE CLE DEPUIS LA SEPARATION DES VOUTES (2026-08-28) : l'unique
`ANSIBLE_VAULT_PASSWORD_FILE`, et `ANSIBLE_VAULT_IDENTITY_LIST`, qui en porte
plusieurs — indispensable des qu'une execution touche deux voutes (le tenant et
l'underlay). N'en regarder qu'une faisait SAUTER P16 alors que la cle etait la, et
une preuve sautee se lit trop facilement comme une preuve passee.
"""
if os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") or os.environ.get("ANSIBLE_VAULT_IDENTITY_LIST"):
return False
gv = (RACINE / INV).parent / "group_vars"
if not gv.exists():

View file

@ -65,7 +65,7 @@ CHIFFREMENT = {"tls-requis", "tls", "starttls", "ssh", "tls-cible", "clair", "n-
# tenants d'a cote) : c'est le SOCLE sur lequel les uns et les autres reposent. Le seul
# a s'y adresser est le runner du site, et c'est tout son objet — materialiser.
MOTS_PAIR = {"edge", "flotte", "externe", "localhost", "expositions", "derive", "admin",
"voisins_site", "fabric"}
"voisins_site", "fabric", "runner_site"}
GROUPE_EDGE = "serveur_nginx"
@ -229,10 +229,11 @@ def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict
for p in (pair if isinstance(pair, list) else [pair]):
if p in ("localhost", "externe", "expositions", "derive"):
continue # lo (auto), frontiere OPNsense, ou hors perimetre noeud
if p in ("admin", "voisins_site"):
# Ni l'un ni l'autre n'est un groupe d'hotes : ce sont des RESEAUX. `admin`
# vient de l'intrant `nftables_admin_ssh` ; `voisins_site` des supernets des
# autres tenants federes. Traites a part, plus bas.
if p in ("admin", "voisins_site", "runner_site"):
# Aucun des trois n'est un groupe d'hotes DE CET ECOSYSTEME : ce sont des
# sources qui lui sont EXTERIEURES. `admin` vient de l'intrant
# `nftables_admin_ssh` ; `voisins_site` des supernets des autres tenants
# federes ; `runner_site` du plan du SITE. Traites a part, plus bas.
continue
if p == "flotte":
noms |= actifs_noms
@ -245,9 +246,30 @@ def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict
srcs |= set(_sources_admin_ssh())
if "voisins_site" in demandes:
srcs |= set(_supernets_voisins())
if "runner_site" in demandes:
srcs |= set(_adresses_runner_site())
return sorted(srcs)
def _adresses_runner_site() -> list[str]:
"""L'adresse du runner du SITE — la source du flux d'insemination.
DERIVEE DU PLAN DU SITE, jamais ecrite ici. Le site est le seul a savoir ou vit son
runner, et il l'a deja deplace une fois (decoupage en zones du 2026-08-25). Une
adresse recopiee ici aurait survecu a ce deplacement en silence, et le tenant se
serait retrouve avec une regle qui n'autorise personne — la panne la plus muette de
cette couche.
Rend [] quand le depot de l'hebergeur n'est pas monte : un tenant clone sans lui
degrade proprement, il ne casse pas.
"""
try:
import underlay as underlay_mod
return underlay_mod.adresses_site_portant("serveur_ops_site")
except Exception:
return []
def _supernets_voisins() -> list[str]:
"""Les supernets des AUTRES tenants que CE SITE heberge.

View file

@ -21,14 +21,25 @@ sys.path.insert(0, str(RACINE / "scripts"))
import underlay as u # noqa: E402
from inventory_rules import OCTET_ZONE # noqa: E402
SUPERNETS = ["10.11.0.0/16", "10.17.0.0/16"] # deux sites : index 11 et 17
MIEN = 11
SUPERNETS = ["10.11.0.0/16", "10.17.0.0/16"] # deux tenants : index 11 et 17
MIEN = "OPS-Mien" # le tenant dont ce site occupe la bande basse
MIEN_INDEX = 11
# UN SITE N'A PAS D'INDEX (add94f2, 2026-08-25). Il ne derive pas : il DECLARE ses
# adresses, parce qu'il EST le terrain. Ce qui a remplace l'index, c'est `bande_basse_de`
# — porte par CHAQUE reseau, et non par le site : deux reseaux d'un meme site peuvent
# occuper la bande basse de deux tenants differents.
#
# CE TEST A SURVECU TROIS JOURS A CE CHANGEMENT EN ETANT ROUGE (2026-08-25 -> 08-28), et
# personne ne l'a vu : le geste quotidien est `make prouver`, qui ne joue pas les tests.
# Un test rouge que rien ne lit ne garde plus rien.
def _erreurs(sous: str, index: int | None = MIEN) -> list[str]:
site = {"reseaux": [{"nom": "g", "vlan": 10, "sous_reseau": sous}]}
if index is not None:
site["index"] = index
def _erreurs(sous: str, mien: str | None = MIEN) -> list[str]:
reseau = {"nom": "g", "vlan": 10, "sous_reseau": sous}
if mien is not None:
reseau["bande_basse_de"] = mien
site = {"reseaux": [reseau], "tenants": {MIEN: MIEN_INDEX}}
return [e for e in u.valider(site, tenants_vlans=set(), tenants_supernets=SUPERNETS)
if "deborde" in e or "chevauche le supernet" in e]
@ -60,19 +71,20 @@ def test_supernet_d_un_autre_site_refuse():
assert _erreurs("10.17.0.0/24"), "le supernet d'un autre site doit etre refuse"
def test_sans_bande_basse_declaree_tout_chevauchement_est_refuse():
"""Le defaut est le refus : un reseau qui ne DIT PAS de quel tenant il occupe la
bande basse n'a droit a aucun chevauchement, pas meme sur 10.11.x. Ne pas declarer
ne peut pas valoir permission."""
assert _erreurs("10.11.0.0/24", mien=None), \
"sans `bande_basse_de`, un chevauchement doit etre refuse"
def test_hors_de_tout_supernet_accepte():
"""Le stockage (192.168.x, identique partout) et l'heritage 10.0.x avant migration."""
for sous in ("192.168.20.0/24", "192.168.31.0/24", "10.0.0.0/24"):
assert _erreurs(sous) == [], f"{sous} est hors des supernets : conforme"
def test_sans_index_on_retombe_sur_la_regle_stricte():
"""Un underlay qui n'a pas encore migre ne declare pas d'index : aucun chevauchement
n'est alors tolere, ce qui reste le comportement sur."""
assert _erreurs("10.11.0.0/24", index=None), \
"sans index, tout chevauchement doit etre refuse"
# --- Bornes de l'index (2026-08-12) -----------------------------------------------

View file

@ -67,7 +67,10 @@ def test_etiquette_vlan_repli_et_vide_explicite() -> None:
rattachement — panne muette, decouverte au premier paquet.
"""
inv = _inventaire_factice()
hote = inv["all"]["children"]["hotes_actifs"]["hosts"]["app-01"]
# LA FIXTURE RANGE SES HOTES DANS `hotes_planifies`. Ce test cherchait
# `hotes_actifs` et levait donc un KeyError — mais il n'etait inscrit dans aucune
# liste, donc il n'a jamais tourne (corrige le 2026-08-28).
hote = inv["all"]["children"]["hotes_planifies"]["hosts"]["app-01"]
# 1. Cle absente : on retombe sur proxmox_vlan.
lignes = parametres_proxmox_hote(inv, "app-01")
@ -101,10 +104,57 @@ def test_hote_complet_avec_noeud() -> None:
# `parametres-proxmox` — un export de plus est un changement de contrat, et
# c'est bien lui qui l'a signale.
"SETOPS_DOMAINE=''",
# Ajoute le 2026-08-28 : la cle publique du runner de SITE, posee sur le SEUL
# hote qui porte `serveur_ops_tenant` (insemination). Vide ici, et c'est le
# point — `app-01` n'est le runner de personne.
"SETOPS_CLES_AMORCAGE=''",
}
assert set(lignes) == attendu, lignes
def test_cle_d_amorcage_seulement_pour_le_runner_du_tenant() -> None:
"""La cle du runner de SITE ne se pose QUE sur le runner du tenant.
C'EST LA GARDE QUI EMPECHE LE GESTE DE DERAPER. `creer-vm` cree TOUTES les machines
d'un tenant ; si cette cle sortait pour chacune, l'hebergeur obtiendrait un acces SSH
a la flotte entiere de chaque locataire — en silence, et sans qu'aucune regle de
pare-feu ne le dise. Le critere doit rester le meme que celui du flux : porter
`serveur_ops_tenant`.
"""
import inventory_host as ih
inv = _inventaire_factice()
inv["all"]["children"]["serveur_ops_tenant"] = {"hosts": {"app-01": None}}
cle = "ssh-ed25519 AAAATEST commentaire@avec-espaces"
lu = getattr(ih, "_cle_amorcage")
import underlay as u
original = u.lire_plan_site
u.lire_plan_site = lambda nom: {"runner_cle_publique": cle}
try:
assert lu(inv, "app-01") == cle, "le runner du tenant doit recevoir la cle"
assert lu(inv, "app-02") == "", "une machine ordinaire ne doit RIEN recevoir"
finally:
u.lire_plan_site = original
def test_cle_d_amorcage_absente_du_site_ne_casse_rien() -> None:
"""Un ecosysteme dont l'hebergeur ne declare pas de runner se materialise comme
avant. Degrader, jamais deviner."""
import inventory_host as ih
import underlay as u
inv = _inventaire_factice()
inv["all"]["children"]["serveur_ops_tenant"] = {"hosts": {"app-01": None}}
original = u.lire_plan_site
u.lire_plan_site = lambda nom: {}
try:
assert getattr(ih, "_cle_amorcage")(inv, "app-01") == ""
finally:
u.lire_plan_site = original
def test_hote_sans_noeud_emet_noeud_vide() -> None:
lignes = parametres_proxmox_hote(_inventaire_factice(), "app-02")
assert "SETOPS_NOEUD=''" in lignes, lignes
@ -151,13 +201,26 @@ def test_champ_requis_manquant_refuse() -> None:
def main() -> int:
# LA LISTE EST EXPLICITE, DONC ELLE PEUT OUBLIER. Deux tests ecrits le 2026-08-28
# ont ete definis sans etre inscrits ici : ils passaient pour verts en n'etant
# jamais joues. On verifie donc que la liste couvre TOUT ce que ce fichier definit —
# un test non inscrit est pire qu'un test absent, parce qu'on croit l'avoir.
tests = [
test_hote_complet_avec_noeud,
# Jamais inscrit depuis sa creation, donc jamais joue : trouve par la garde
# ci-dessous, le jour meme ou elle a ete ecrite.
test_etiquette_vlan_repli_et_vide_explicite,
test_cle_d_amorcage_seulement_pour_le_runner_du_tenant,
test_cle_d_amorcage_absente_du_site_ne_casse_rien,
test_hote_sans_noeud_emet_noeud_vide,
test_intrant_commun_lu_a_cote_de_l_inventaire_recu,
test_hote_absent_refuse,
test_champ_requis_manquant_refuse,
]
definis = {n for n, v in sorted(globals().items())
if n.startswith("test_") and callable(v)}
oublies = definis - {t.__name__ for t in tests}
assert not oublies, f"test(s) definis mais jamais joues : {sorted(oublies)}"
for test in tests:
test()
print(f"ok {test.__name__}")

View file

@ -97,6 +97,63 @@ def charger() -> dict | None:
return data.get("underlay") or None
# --- LE PLAN DU SITE, a cote de sa carte ---------------------------------------
#
# `underlay.yml` decrit ce qui est MESURE (commutateurs, VLAN, MTU) ; `plan/` decrit ce
# qui est VOULU (machines, services, intrants). Les deux vivent dans le meme depot
# d'hebergeur, et c'est `chemin()` qui sait ou il est.
#
# CES LECTEURS VIVENT ICI, ET PAS DANS LEURS APPELANTS. `devis_opnsense` les portait en
# prive ; `resoudre_flux` en avait besoin a son tour, et une seconde copie aurait
# fini par diverger de la premiere sans que rien ne le dise. C'est la lecon de P41
# appliquee au plan du site : une seule resolution, partagee.
def plan_site() -> Path | None:
"""Le dossier `plan/` du SITE, a cote de son `underlay.yml`."""
c = chemin()
return (c.resolve().parent / "plan") if c else None
def lire_plan_site(nom: str) -> dict:
"""Un fichier du plan du site, ou {} s'il n'y en a pas."""
d = plan_site()
f = (d / nom) if d else None
if not f or not f.is_file():
return {}
return yaml.safe_load(f.read_text(encoding="utf-8")) or {}
def machines_site() -> list[dict]:
"""Les machines ACTIVES du plan du site, chacune portant son `nom`."""
srv = (lire_plan_site("serveurs.yml") or {}).get("serveurs") or {}
return [{**s, "nom": nom} for nom, s in srv.items()
if str(s.get("etat", "actif")) == "actif"]
def services_site() -> dict[str, list[str]]:
"""{machine du site: [groupes qu'elle porte]}, depuis `plan/applications.yml`."""
apps = (lire_plan_site("applications.yml") or {}).get("applications") or {}
out: dict[str, list[str]] = {}
for app in apps.values():
if app.get("hote") and app.get("groupe"):
out.setdefault(str(app["hote"]), []).append(str(app["groupe"]))
return out
def adresses_site_portant(groupe: str) -> list[str]:
"""Les adresses des machines du SITE qui portent ce groupe.
DERIVE, JAMAIS ECRIT. L'adresse du runner de site est la seule source du flux
d'insemination : l'ecrire en dur quelque part la ferait mentir le jour ou le site
redecoupe ses zones — ce qui est arrive le 2026-08-25, et a coute cinq machines
bloquees.
"""
svc = services_site()
return sorted({str(m["ip"]) for m in machines_site()
if m.get("ip") and groupe in svc.get(m["nom"], [])})
def reseaux(underlay: dict | None) -> list[dict]:
return (underlay or {}).get("reseaux", []) or []

142
scripts/voutes.py Executable file
View file

@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Les voutes de cet ecosysteme, et la cle de chacune.
UNE VOUTE, UNE CLE (2026-08-28). Un seul mot de passe ouvrait les six voutes de la
flotte — celle de la fabric comprise. Tant que c'etait vrai, poser « la » cle sur chaque
runner aurait rendu chaque runner capable d'ouvrir les autres : compromettre le plus
petit locataire, c'etait obtenir les secrets de l'hebergeur. La separation precede donc
la distribution, et cet ordre n'est pas negociable.
CE FICHIER NE LIT AUCUN SECRET. Il ne fait que dire OU chaque cle se trouve, et laisse
Ansible l'ouvrir. Les chemins sont des pointeurs, jamais des valeurs.
DEUX VOUTES DANS LA MEME EXECUTION, ET C'EST LE CAS COURANT : `cloner_vm_debian.yml`
charge celle du tenant PUIS celle de l'underlay — les identifiants Proxmox appartiennent
a l'hebergeur, le reste au locataire. Un unique `ANSIBLE_VAULT_PASSWORD_FILE` ne peut
donc plus suffire. Ansible sait tenir plusieurs secrets a la fois
(`ANSIBLE_VAULT_IDENTITY_LIST`) et les essaie tous sur un bloc chiffre sans etiquette :
c'est exactement ce qu'il nous faut, et ca n'oblige a toucher aucun des 28 appels.
CONVENTION : ~/.config/setops-vault-<dossier-en-minuscules>
OPS-Chezlepro -> ~/.config/setops-vault-ops-chezlepro
SITE-Chezlepro -> ~/.config/setops-vault-site-chezlepro
Usage :
python3 scripts/voutes.py identites # la liste pour ANSIBLE_VAULT_IDENTITY_LIST
python3 scripts/voutes.py etat # ce qui est present, ce qui manque
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from inventory_rules import instance_courante # noqa: E402
import underlay as underlay_mod # noqa: E402
PREFIXE = "setops-vault-"
def _config() -> Path:
"""Le dossier des cles. `XDG_CONFIG_HOME` respecte, `~/.config` par defaut."""
return Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
def slug(nom: str) -> str:
return nom.strip().lower()
def cle_de(nom_depot: str) -> Path:
return _config() / f"{PREFIXE}{slug(nom_depot)}"
def _freres() -> list[str]:
"""Les depots d'ecosysteme poses a cote du moteur — ceux qui portent un `plan/`.
MEME DECOUVERTE QUE LE RESTE DU DEPOT : des dossiers freres, pas de registre.
"""
racine = Path(__file__).resolve().parents[1]
return sorted(d.name for d in racine.parent.iterdir()
if d.is_dir() and (d / "plan").is_dir())
def voutes() -> list[tuple[str, str, Path]]:
"""[(role, nom du depot, chemin de la cle)] — tout ce que CETTE machine peut ouvrir.
L'ORDRE COMPTE PEU POUR ANSIBLE (il essaie tous les secrets) mais beaucoup pour un
humain qui lit la sortie : ce qu'on pilote, le terrain, puis le voisinage.
POURQUOI LE VOISINAGE Y FIGURE, ET POURQUOI CE N'EST PAS UNE FUITE. La liste nomme
des CHEMINS, pas des secrets : une cle absente du disque n'est pas listee, et la
machine ne peut ouvrir que ce qu'elle detient. C'est donc la PRESENCE des fichiers
qui borne le pouvoir, jamais cette fonction.
Sur le poste de l'exploitant, toutes les cles sont la, et c'est voulu : c'est
l'humain qui les detient toutes, et des preuves comme P03 lisent les inventaires de
TOUS les ecosystemes freres. Sur un runner, une seule cle existe — la sienne — et la
meme fonction n'en nomme qu'une. Le code est identique, le pouvoir ne l'est pas.
"""
out: list[tuple[str, str, Path]] = []
vus: set[str] = set()
def ajouter(role: str, nom: str) -> None:
if nom and nom not in vus:
vus.add(nom)
out.append((role, nom, cle_de(nom)))
try:
inst = instance_courante()
if inst is not None:
ajouter("instance", Path(inst).resolve().name)
except Exception:
pass
c = underlay_mod.chemin()
if c is not None:
ajouter("hebergeur", c.resolve().parent.name)
for nom in _freres():
ajouter("voisin", nom)
return out
def identites() -> str:
"""`etiquette@chemin,...` pour ANSIBLE_VAULT_IDENTITY_LIST — les cles PRESENTES.
Une cle absente est SAUTEE plutot que declaree : Ansible refuse de demarrer si un
fichier d'identite n'existe pas, et l'echec porterait alors sur une voute que
l'execution n'ouvre peut-etre meme pas. Ce qui manque se lit par `voutes.py etat`.
"""
return ",".join(f"{slug(nom)}@{chemin}" for _, nom, chemin in voutes() if chemin.is_file())
def etat() -> int:
v = voutes()
if not v:
print("Aucune voute a nommer : ni instance montee, ni underlay.")
return 0
manquantes = 0
for role, nom, chemin in v:
present = chemin.is_file()
manquantes += 0 if present else 1
print(f"{role:<11} {nom:<20} {'cle presente' if present else 'CLE ABSENTE '} {chemin}")
if manquantes:
print(f"\n{manquantes} cle(s) absente(s). En creer une :",
"umask 077; openssl rand -base64 48 | tr -d '\\n' > <chemin>", sep="\n ")
return 1 if manquantes else 0
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("commande", choices=("identites", "etat"))
args = ap.parse_args(argv)
if args.commande == "identites":
print(identites())
return 0
return etat()
if __name__ == "__main__":
raise SystemExit(main())