Compare commits
10 commits
35853ff5fb
...
89c911ef05
| Author | SHA1 | Date | |
|---|---|---|---|
| 89c911ef05 | |||
| 58ce3dfea4 | |||
| aca63011ea | |||
| ceb832eeac | |||
| 5e4d711b97 | |||
| 45aaf2808d | |||
| 377462b141 | |||
| ef832d11d6 | |||
| d3ba520500 | |||
| c6f2cb1dd9 |
35 changed files with 1725 additions and 94 deletions
470
CHANGELOG.md
470
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
45
Makefile
45
Makefile
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
15
playbooks/groupes/serveur_forge_site.yml
Normal file
15
playbooks/groupes/serveur_forge_site.yml
Normal 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
|
||||
|
|
@ -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: >-
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
48
roles/serveur_forge_site/README.md
Normal file
48
roles/serveur_forge_site/README.md
Normal 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.
|
||||
9
roles/serveur_forge_site/defaults/main.yml
Normal file
9
roles/serveur_forge_site/defaults/main.yml
Normal 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) }}"
|
||||
14
roles/serveur_forge_site/meta/authentification.yml
Normal file
14
roles/serveur_forge_site/meta/authentification.yml
Normal 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.
|
||||
7
roles/serveur_forge_site/meta/empreinte.yml
Normal file
7
roles/serveur_forge_site/meta/empreinte.yml
Normal 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
|
||||
41
roles/serveur_forge_site/meta/flux.yml
Normal file
41
roles/serveur_forge_site/meta/flux.yml
Normal 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
|
||||
15
roles/serveur_forge_site/meta/main.yml
Normal file
15
roles/serveur_forge_site/meta/main.yml
Normal 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
|
||||
29
roles/serveur_forge_site/tasks/main.yml
Normal file
29
roles/serveur_forge_site/tasks/main.yml
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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) -----------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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__}")
|
||||
|
|
|
|||
|
|
@ -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
142
scripts/voutes.py
Executable 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())
|
||||
Loading…
Reference in a new issue