insemination : la cle d'amorcage, bornee au meme groupe que le flux

Le flux etait declare et applique ; il manquait l'IDENTITE. Le runner du SITE
atteignait la porte de ops-01 sans avoir de cle.

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

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

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

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

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

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

PREUVE, AVEC SON CONTROLE NEGATIF :

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
This commit is contained in:
Daniel Allaire 2026-08-28 13:45:44 -04:00
parent d3ba520500
commit ef832d11d6
6 changed files with 241 additions and 4 deletions

View file

@ -1,5 +1,81 @@
# CHANGELOG — Set-OPS
## 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

View file

@ -50,6 +50,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 ?=
@ -972,6 +991,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)" ); \
@ -1006,6 +1026,7 @@ creer-vm: _instance-requise ## Cree une VM et attend qu'elle soit joignable —
fi; \
params="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote $(HOTE))"; \
eval "$$params"; \
export SETOPS_CLES_AMORCAGE; \
$(MAKE) cloner-vm \
HOTE="$(HOTE)" \
VMID="$$SETOPS_VMID" \

View file

@ -6,8 +6,8 @@
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml`
- **Verdict** : ❌ NON CONFORME (51 OK · 1 echec · 0 saute)
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (52 OK · 0 echec · 0 saute)
## Preuves
@ -45,7 +45,7 @@
| 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). |
| P33 | Aucune collision de port entre roles co-localises | — | ❌ ECHEC | rc=1 : decrivent la MEME ecoute. |
| 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). |

View file

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

View file

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

View file

@ -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__}")