diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a15fae..a04c1df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Makefile b/Makefile index 91ccef1..8b0afdc 100644 --- a/Makefile +++ b/Makefile @@ -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= [[ -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" \ diff --git a/docs/audit/preuve-2026-08-28.md b/docs/audit/preuve-2026-08-28.md index a3f7d9e..62339f5 100644 --- a/docs/audit/preuve-2026-08-28.md +++ b/docs/audit/preuve-2026-08-28.md @@ -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). | diff --git a/playbooks/proxmox/cloner_vm_debian.yml b/playbooks/proxmox/cloner_vm_debian.yml index de4d54a..3674025 100644 --- a/playbooks/proxmox/cloner_vm_debian.yml +++ b/playbooks/proxmox/cloner_vm_debian.yml @@ -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: >- diff --git a/scripts/inventory_host.py b/scripts/inventory_host.py index 95abd6f..da755da 100644 --- a/scripts/inventory_host.py +++ b/scripts/inventory_host.py @@ -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. diff --git a/scripts/tests/test_inventory_host.py b/scripts/tests/test_inventory_host.py index 646192d..ee34bf6 100644 --- a/scripts/tests/test_inventory_host.py +++ b/scripts/tests/test_inventory_host.py @@ -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__}")