diff --git a/CHANGELOG.md b/CHANGELOG.md index 830695a..77c46b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,70 @@ # CHANGELOG — Set-OPS +## 2026-08-10 — Épreuve de portabilité : monter un SECOND tenant révèle trois défauts invisibles + +Les deux reconstructions from-zero de la semaine rebâtissaient **Chezlepro** sur son propre +matériel : une preuve de reproductibilité, pas de portabilité. La vraie épreuve est un +**second tenant** — Technolibre, index 11, plan distinct (`id-ldap-01`, `id-sso-01`, +`sup-01`… là où Chezlepro a `idm-01`, `mon-01`), voûte séparée, sur le même cluster. + +Elle a trouvé en une heure trois défauts qu'un seul tenant ne pouvait pas révéler. + +### 1. Le clonage résolvait par NOM — et ne faisait rien + +`community.general.proxmox_kvm` cherche d'abord une VM portant le `name` demandé. S'il en +trouve une, il conclut « elle existe déjà », rend **`ok`** et ne clone **rien** — aucune tâche +n'apparaît même côté cluster. Or les noms courts sont **volontairement identiques d'un tenant +à l'autre** : même fonction, même nom, c'est le pool qui restitue l'appartenance. Le premier +clone de Technolibre, `backup-01`, est donc tombé sur le `backup-01` de Chezlepro, n'a rien +fait, et l'attente a expiré sur une configuration qui n'existerait jamais. + +**Mesuré, pas déduit** : `id-ldap-01` et `sup-01` — noms que Chezlepro n'a pas — se sont +créés du premier coup ; `backup-01` échouait systématiquement. Zéro VM créée, zéro tâche +`qmclone` au cluster. + +Le clonage passe désormais par un appel d'API **ciblé par VMID** : recensement des VM, puis +`POST /nodes//qemu//clone` seulement si le VMID cible est libre. Plus aucune +résolution par nom. + +**Deux défauts de ce correctif, trouvés en le mesurant** — et tous deux du même genre que ce +qu'il corrige : + +- le corps de la requête était assemblé en Jinja avec `>-`, ce qui rend une **chaîne** : le + `pool` s'est perdu en route et la VM est née hors de son pool, sans un mot. Réécrit en + mapping YAML avec `omit` ; +- l'application du gabarit de calcul expirait à 5 s de lecture — le nœud vient de terminer un + clone complet. La VM restait aux valeurs du gabarit (2 cœurs / 2 Go au lieu du plan), **en + silence**. Six tentatives espacées de 10 s. + +Et `no_log: true` a masqué la cause au moment précis où elle servait : l'échec se lisait +« the output has been hidden », et il a fallu interroger le cluster à la main. Les deux +attentes disent maintenant ce qu'elles ont constaté, sans révéler l'en-tête d'autorisation. + +### 2. Le GUI détruisait des intrants + +Dans `ecrire_intrants`, la branche `identite` était **la seule sur quatre** à écrire par-dessus +le disque au lieu de fusionner. Un enregistrement du panneau a supprimé `dns_amorcage` et +`amorcage_acces_courriel` de Technolibre. Sans le premier, une VM naît sans résolution et +`apt` ne peut rien installer ; sans le second, le déploiement s'arrête sur la garde de +`amorcage_acces` (D-72). **Le même geste sur Chezlepro aurait mangé les mêmes clés.** + +### 3. Le verrou de `raser` n'était prouvé que pour un tenant + +Le faux cluster de `scripts/tests/test_raser.py` codait en dur les VMID de Chezlepro. Monté +sur un autre tenant, le test rendait `0` au lieu de `2` — « aucune VM du plan n'est présente, +rien à faire ». Le verrou de la seule commande destructive du moteur passait donc au vert +sans rien éprouver. Le faux cluster **fabrique désormais la collision sur le plan courant**, +quel qu'il soit. + +### Ce qui relevait des données du tenant, pas du moteur + +L'instance datait d'avant plusieurs évolutions, et les preuves statiques les ont toutes +attrapées **avant** le déploiement : `client_unbound` déclaré au plan alors qu'il est devenu +une intégration universelle ; `amorcage_acces_courriel` absent ; gabarit `99999` alors que le +recapturé porte `99998` — le premier clone aurait échoué ; `parefeu_interface: false`, qui +aurait laissé le pare-feu est-ouest inerte sans le dire ; `collab-01` à 1 cœur / 1 Go au lieu +du dimensionnement dérivé. + ## 2026-08-10 — P34 : la convention « chaque document déclare son lecteur » devient une garde La refonte de ce matin posait une convention. Une convention qu'on n'outille pas tient tant diff --git a/playbooks/proxmox/cloner_vm_debian.yml b/playbooks/proxmox/cloner_vm_debian.yml index 4919826..62a214f 100644 --- a/playbooks/proxmox/cloner_vm_debian.yml +++ b/playbooks/proxmox/cloner_vm_debian.yml @@ -158,27 +158,86 @@ state: present when: proxmox_clone_pool | default('', true) | length > 0 - - name: Cloner la VM depuis le modele - community.general.proxmox_kvm: - api_host: "{{ proxmox_api_host_effectif }}" - api_port: "{{ proxmox_api_port_effectif | int if proxmox_api_port_effectif | length > 0 else omit }}" - api_user: "{{ proxmox_api_user_effectif }}" - api_token_id: "{{ proxmox_api_token_id_effectif }}" - api_token_secret: "{{ proxmox_api_token_secret_effectif }}" + # LE CLONE SE CIBLE PAR VMID, JAMAIS PAR NOM — et c'est tout l'objet de ces trois + # taches. `community.general.proxmox_kvm` cherche d'abord une VM portant le `name` + # demande ; s'il en trouve une, il conclut « elle existe deja », rend `ok` et ne + # clone RIEN. Aucune tache n'apparait meme cote cluster. + # + # Or les noms courts sont VOLONTAIREMENT identiques d'un tenant a l'autre (meme + # fonction, meme nom) : c'est le pool qui restitue l'appartenance. Le premier clone + # de Technolibre — `backup-01` — est donc tombe sur le `backup-01` de Chezlepro et + # n'a rien fait, puis l'attente a expire sur une configuration qui n'existerait + # jamais. Mesure du 2026-08-10 : zero VM creee, zero tache `qmclone` au cluster. + # + # Le defaut etait invisible tant qu'un seul tenant existait. C'est exactement ce + # qu'une epreuve de portabilite doit trouver. + - name: Recenser les VM du cluster (pour cibler par VMID) + vars: + proxmox_api_racine: "https://{{ proxmox_api_host_effectif }}:{{ proxmox_api_port_effectif | default(8006, true) }}" + ansible.builtin.uri: + url: "{{ proxmox_api_racine }}/api2/json/cluster/resources?type=vm" + method: GET + headers: + Authorization: >- + PVEAPIToken={{ proxmox_api_user_effectif }}!{{ proxmox_api_token_id_effectif }}={{ proxmox_api_token_secret_effectif }} validate_certs: "{{ proxmox_validate_certs | default(false) | bool }}" - node: "{{ proxmox_clone_noeud }}" - clone: "{{ proxmox_clone_source_nom | default('modele-debian13') }}" - vmid: "{{ proxmox_clone_vmid_modele | int }}" - newid: "{{ proxmox_clone_vmid | int }}" - name: "{{ proxmox_clone_nom }}" - cores: "{{ proxmox_clone_coeurs | int if proxmox_clone_coeurs is defined and proxmox_clone_coeurs | string | length > 0 else omit }}" - memory: "{{ proxmox_clone_memoire | int if proxmox_clone_memoire is defined and proxmox_clone_memoire | string | length > 0 else omit }}" - storage: "{{ proxmox_clone_stockage | default(omit, true) }}" - format: "{{ proxmox_clone_format | default(omit, true) }}" - full: "{{ proxmox_clone_complet | default(true) | bool }}" - # A la CREATION seulement : l'API ne permet pas de le changer ensuite. - pool: "{{ proxmox_clone_pool | default(omit, true) }}" - timeout: "{{ proxmox_clone_timeout | default(600) | int }}" + status_code: 200 + register: proxmox_inventaire_vm + changed_when: false + no_log: true + + - name: Retenir si le VMID cible est deja pris + ansible.builtin.set_fact: + proxmox_clone_deja_la: >- + {{ (proxmox_inventaire_vm.json.data | default([])) + | selectattr('vmid', 'defined') + | selectattr('vmid', 'equalto', proxmox_clone_vmid | int) + | list | length > 0 }} + + - name: Cloner la VM depuis le modele (API directe, cible par VMID) + vars: + proxmox_api_racine: "https://{{ proxmox_api_host_effectif }}:{{ proxmox_api_port_effectif | default(8006, true) }}" + ansible.builtin.uri: + url: "{{ proxmox_api_racine }}/api2/json/nodes/{{ proxmox_clone_noeud }}/qemu/{{ proxmox_clone_vmid_modele | int }}/clone" + method: POST + headers: + Authorization: >- + PVEAPIToken={{ proxmox_api_user_effectif }}!{{ proxmox_api_token_id_effectif }}={{ proxmox_api_token_secret_effectif }} + body_format: form-urlencoded + # Corps ecrit en MAPPING YAML, pas assemble en Jinja : une expression `>-` rend + # une CHAINE, et le premier jet a ainsi perdu le `pool` en route — la VM est + # nee hors de son pool, sans un mot. `omit` retire proprement les cles vides. + body: + newid: "{{ proxmox_clone_vmid | int }}" + name: "{{ proxmox_clone_nom }}" + full: "{{ 1 if (proxmox_clone_complet | default(true) | bool) else 0 }}" + storage: "{{ proxmox_clone_stockage | default(omit, true) }}" + format: "{{ proxmox_clone_format | default(omit, true) }}" + # A la CREATION seulement : l'API ne sait pas changer le pool d'une VM + # existante par cet appel (il faut passer par le membre du pool). + pool: "{{ proxmox_clone_pool | default(omit, true) }}" + validate_certs: "{{ proxmox_validate_certs | default(false) | bool }}" + status_code: 200 + register: proxmox_clone_lance + # Le corps ne porte aucun secret, mais l'en-tete si : `no_log` masquerait alors + # l'erreur de l'API, qui est justement ce qu'on veut lire. On ne journalise donc + # que l'ECHEC, et sans les en-tetes. + failed_when: false + no_log: true + when: not proxmox_clone_deja_la + + - name: Dire pourquoi le clonage a echoue, s'il a echoue + ansible.builtin.fail: + msg: >- + Le clonage de {{ proxmox_clone_nom }} (VMID {{ proxmox_clone_vmid }}) depuis le + gabarit {{ proxmox_clone_vmid_modele }} a ete refuse par l'API Proxmox + (HTTP {{ proxmox_clone_lance.status | default('?') }}). + Verifier que le gabarit existe sur {{ proxmox_clone_noeud }}, que le pool + {{ proxmox_clone_pool | default('(aucun)', true) }} existe, et que le VMID + cible est libre. + when: + - not proxmox_clone_deja_la + - proxmox_clone_lance.status | default(0) != 200 # L'API de CLONAGE ignore `cores` et `memory` : le clone herite du gabarit, et le # plan est contredit sans un mot. Mesure du 2026-08-06 : plan 1 coeur / 1024 Mo, @@ -205,8 +264,23 @@ retries: 30 delay: 5 changed_when: false + failed_when: false no_log: true + # `no_log` masque la cause au moment ou on en a besoin : le 2026-08-10, l'echec de + # cette attente s'est lu « the output has been hidden », et il a fallu interroger le + # cluster a la main pour comprendre que le clone n'avait jamais eu lieu. On rend + # donc le verdict lisible ici, sans reveler l'en-tete d'autorisation. + - name: Dire ce que l'attente du clone a constate + ansible.builtin.fail: + msg: >- + La configuration de {{ proxmox_clone_nom }} (VMID {{ proxmox_clone_vmid }}) + n'existe toujours pas apres {{ 30 * 5 }} s sur {{ proxmox_clone_noeud }}. + Le clonage n'a donc pas abouti — verifier les taches du cluster + (`/cluster/tasks`) : si aucune tache de clonage n'apparait, c'est que rien + n'a ete demande a l'API. + when: proxmox_clone_conf.status | default(0) != 200 + - name: Appliquer le gabarit de calcul du plan (le clonage ne le fait pas) community.general.proxmox_kvm: api_host: "{{ proxmox_api_host_effectif }}" @@ -220,6 +294,14 @@ cores: "{{ proxmox_clone_coeurs | int }}" memory: "{{ proxmox_clone_memoire | int }}" update: true + # Le noeud vient de finir un clone COMPLET : son API repond encore lentement, et + # la lecture expire a 5 s. Mesure du 2026-08-10 : « Read timed out » sur le tout + # premier clone, VM creee mais laissee aux valeurs du gabarit — 2 coeurs / 2 Go au + # lieu du plan. Une VM sous-dimensionnee en silence est pire qu'un echec franc. + register: proxmox_gabarit_calcul + until: proxmox_gabarit_calcul is succeeded + retries: 6 + delay: 10 when: - proxmox_clone_coeurs is defined and proxmox_clone_coeurs | string | length > 0 - proxmox_clone_memoire is defined and proxmox_clone_memoire | string | length > 0 diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 40b3460..f231b64 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -613,8 +613,20 @@ def ecrire_intrants(payload: dict) -> None: par_fichier.setdefault(fichier_de[variable], {})[variable] = _coercer_intrant(types[variable], valeur) if "identite" in par_fichier: + # Fusion, comme les trois autres fichiers. C'etait la SEULE branche a ecrire + # par-dessus le disque, et elle perdait donc en silence tout intrant absent du + # schema du panneau. Mesure du 2026-08-10, au pre-vol de l'epreuve de + # portabilite : un enregistrement du panneau a supprime `dns_amorcage` et + # `amorcage_acces_courriel` de Technolibre. Le premier laisse une VM naitre sans + # resolution — `apt` ne peut alors rien installer ; le second arrete le + # deploiement sur la garde de `amorcage_acces` (D-72). + # + # Le meme geste sur Chezlepro aurait mange les memes cles. Un panneau qui ne + # connait pas une valeur ne doit pas avoir le droit de la detruire. + fusion = _lire_yaml_dict(INTRANTS_IDENTITE) # preserve les cles non gerees + fusion.update(par_fichier["identite"]) _ecrire_intrants_fichier( - INTRANTS_IDENTITE, par_fichier["identite"], + INTRANTS_IDENTITE, fusion, "# Intrants d'IDENTITE de l'instance — SOURCE UNIQUE, partagee par tous les envs.\n" "# Edite par le panneau « Intrants de base » du GUI (make inventaire-ui).\n" "# Reference via symlink depuis inventories//group_vars/all/10-intrants.yml.\n---\n") diff --git a/scripts/tests/test_raser.py b/scripts/tests/test_raser.py index 80e89cf..5d82083 100644 --- a/scripts/tests/test_raser.py +++ b/scripts/tests/test_raser.py @@ -7,24 +7,51 @@ donc la logique derriere un faux cluster. Le danger n'est pas theorique : le 2026-08-07, une VM heritee portait un VMID du plan sous le nom `web-frontal-01`, et `proxmox_kvm` avait rapporte `ok` sans rien faire. Rase sans ce controle, on detruisait la machine de quelqu'un d'autre. + +LE FAUX CLUSTER SE DERIVE DE L'INSTANCE MONTEE, il ne code aucun VMID en dur. La premiere +version en portait deux, ceux de Chezlepro (index 17) : le test passait pour ce tenant et +rendait 0 — « aucune VM du plan n'est presente, rien a faire » — des qu'on montait un tenant +dont le plan derive d'autres VMID. Trouve le 2026-08-10 en basculant sur Technolibre, au +premier pas de l'epreuve de portabilite. Un verrou prouve pour un seul tenant n'est pas +prouve : c'est exactement ce que le multi-instance est cense empecher. """ -import sys, pathlib +import pathlib +import sys + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) -import raser +import raser # noqa: E402 + +BASE, NOM = raser.instance_active() +COUPLES = raser.plan_derive(BASE) +if not COUPLES: + raise SystemExit("Plan sans serveur : rien a eprouver.") + +# La collision est FABRIQUEE sur le premier hote du plan courant, quel qu'il soit ; un +# second hote, correctement nomme, garantit que le refus vient bien de la collision et +# non d'un cluster vide. +(NOM_1, VMID_1), *reste = COUPLES +NOM_2, VMID_2 = reste[0] if reste else (NOM_1 + "-bis", VMID_1 + 1) + class FauxCluster: - """Le cluster repond que 117603101 s'appelle « vm-heritee-de-2019 ».""" + """Le cluster repond que `VMID_1` porte le nom d'une VM etrangere au plan.""" + @classmethod def depuis_hebergeur(cls): return cls(), None + def __call__(self, chemin, methode="GET", corps=None): - return [{"type": "qemu", "vmid": 117603101, "name": "vm-heritee-de-2019", "node": "asgard"}, - {"type": "qemu", "vmid": 117101101, "name": "infra-edge-01", "node": "asgard"}] + return [{"type": "qemu", "vmid": VMID_1, "name": "vm-heritee-de-2019", + "node": "asgard"}, + {"type": "qemu", "vmid": VMID_2, "name": NOM_2, "node": "asgard"}] + def rate(self, rep): return None + raser.Cluster = FauxCluster -code = raser.main(["--instance", "chezlepro", "--confirmer"]) -print(f"\n>>> code de sortie : {code} (2 = refus attendu)") +code = raser.main(["--instance", NOM, "--confirmer"]) +print(f"\n>>> instance montee : {NOM} | collision fabriquee sur {VMID_1} ({NOM_1})") +print(f">>> code de sortie : {code} (2 = refus attendu)") assert code == 2, "LE VERROU N'A PAS TENU" print(">>> le verrou tient : aucune VM n'aurait ete touchee")