diff --git a/CHANGELOG.md b/CHANGELOG.md index e8e82a9..ebaa46a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # CHANGELOG — Set-OPS +## 2026-08-25 — Le clonage inter-nœuds : quatre défauts que la première VM ailleurs a révélés + +Toutes les VM naissaient jusqu'ici sur le nœud du gabarit, puis migraient. `site-cache-01` +est la première à être placée ailleurs — et elle a fait tomber quatre défauts enchaînés du +moteur de clonage. Aucun n'était visible avant, et aucun ne disait son nom. + +**1. Le clonage visait le nœud de destination.** L'URL était +`nodes/{{ proxmox_clone_noeud }}/qemu//clone`, alors que l'API veut le nœud qui +**détient** le modèle ; la destination se dit par `target`. Le gabarit vit sur asgard, +la VM allait sur gandalf → `500 Configuration file 'nodes/gandalf/qemu-server/99998.conf' +does not exist`. Le nœud du gabarit se découvre désormais dans l'inventaire du cluster. + +**2. Le refus de l'API était avalé.** `failed_when: false` + `no_log: true` protégeaient +l'en-tête d'authentification, mais masquaient aussi le 500. Une assertion le relève +maintenant, message de l'API compris — le masque reste, l'erreur sort. + +**3. L'attente cherchait la tâche au mauvais endroit.** Elle interrogeait +`nodes//tasks/` ; un clonage inter-nœuds s'exécute sur le nœud du +gabarit. L'UPID n'existait pas là, l'API répondait en erreur, `until` n'était jamais +satisfait : **60 tentatives × 10 s pour un clonage terminé en 87 s**, puis la suite qui +reprend sans un mot. Un UPID s'écrit `UPID:::…` — le nœud y est déjà, on le lit +là plutôt que de le redemander à une variable. + +**4. La garde d'après-attente ne pouvait pas échouer.** `exitstatus | default('OK')` +faisait passer pour un succès une attente qui n'avait **rien observé** : sans `json.data`, +pas d'`exitstatus`, donc « OK ». Elle exige désormais d'avoir vu la tâche s'arrêter, et +bien s'arrêter. + +### La taille des disques porte toujours son unité + +`disque: 40` produisait un `resize` à « 40 » que Proxmox lisait comme un **rétrécissement** +(`shrinking disks is not supported`). Le playbook tolère cette erreur — à raison, un disque +déjà assez grand n'est pas une panne — donc la machine naissait avec les 16 Go du gabarit +au lieu de 40, **sans que rien ne le dise**. Les plans des tenants écrivent `40G` depuis +toujours ; le site écrit pareil, et un entier nu se voit rattacher son unité. + +### Le site déclare ce qu'il matérialise + +Le playbook prenait le gabarit et le stockage dans les group_vars du **tenant actif** : +`make site-creer` aurait cloné depuis un modèle différent selon le symlink `instance`, +sans rien dire. `materialisation:` (vmid du gabarit, stockage, `clone_complet`) vit +désormais dans l'underlay du site, et `site-creer` les passe explicitement. + +### Ce que le chronomètre a dit + +Le clonage réel : **59 s et 87 s** pour 3,3 Gio effectivement alloués (le gabarit en +déclare 16). Ce n'était donc pas le disque du modèle qui coûtait — c'était les dix minutes +d'attente aveugle du défaut n° 3. Le clone lié reste le vrai levier (`rbd clone` depuis +`@__base__`, instantané), mais il exige `CephNVMe` en destination : `TrueNAS` est du LVM +épais, sans copy-on-write. + ## 2026-08-25 — La frontière voit le site : `fabric`, alias et règles ### Un mot manquait au vocabulaire des flux diff --git a/Makefile b/Makefile index 9daf024..abe4e8b 100644 --- a/Makefile +++ b/Makefile @@ -751,6 +751,7 @@ site-creer: ## Cree les machines du site depuis l'underlay — CONFIRMER=true if [[ -z "$$machines" ]]; then printf '%s\n' 'Refus: aucune machine declaree dans l underlay.'; exit 2; fi; \ for m in $$machines; do \ printf '\n=== Machine du site: %s ===\n' "$$m"; \ + t0=$$(date +%s); \ params="$$(python3 scripts/site_machines.py parametres-proxmox --machine "$$m")"; \ eval "$$params"; \ $(MAKE) cloner-vm \ @@ -766,8 +767,10 @@ site-creer: ## Cree les machines du site depuis l'underlay — CONFIRMER=true COEURS="$$SETOPS_COEURS" \ MEMOIRE="$$SETOPS_MEMOIRE" \ NOEUD_PROXMOX="$$SETOPS_NOEUD" \ - VMID_MODELE="$(VMID_MODELE)" \ + VMID_MODELE="$$SETOPS_VMID_MODELE" \ + CLONE_COMPLET="$$SETOPS_CLONE_COMPLET" \ FORMAT_DISQUE="$(FORMAT_DISQUE)"; \ + printf '=== %s : %s s ===\n' "$$m" "$$(( $$(date +%s) - t0 ))"; \ done; \ printf '\nLes machines du site sont creees.\n' diff --git a/playbooks/proxmox/cloner_vm_debian.yml b/playbooks/proxmox/cloner_vm_debian.yml index 7bd788d..506bcf4 100644 --- a/playbooks/proxmox/cloner_vm_debian.yml +++ b/playbooks/proxmox/cloner_vm_debian.yml @@ -214,11 +214,36 @@ | selectattr('vmid', 'equalto', proxmox_clone_vmid | int) | list | length > 0 }} + # OU VIT LE GABARIT — et ce n'est pas forcement ou va la VM (2026-08-25). + # + # L'URL de clonage visait `nodes/{{ proxmox_clone_noeud }}`, c'est-a-dire le noeud de + # DESTINATION. Or l'API veut le noeud qui DETIENT le modele ; la destination se dit + # par `target`. Cloner vers gandalf un gabarit qui vit sur asgard rendait donc : + # + # 500 Configuration file 'nodes/gandalf/qemu-server/99998.conf' does not exist + # + # Ca n'etait jamais apparu parce que toutes les VM naissaient sur le noeud du gabarit + # et migraient ensuite. La premiere machine placee ailleurs l'a revele. + - name: Localiser le noeud qui detient le gabarit + ansible.builtin.set_fact: + proxmox_noeud_gabarit: >- + {{ ((proxmox_inventaire_vm.json.data | default([])) + | selectattr('vmid', 'defined') + | selectattr('vmid', 'equalto', proxmox_clone_vmid_modele | int) + | map(attribute='node') | list | first) | default('', true) }} + + - name: "Un noeud de la grappe detient bien le gabarit {{ proxmox_clone_vmid_modele }}" + ansible.builtin.assert: + that: proxmox_noeud_gabarit | length > 0 + fail_msg: >- + Aucun noeud de la grappe ne detient le gabarit + {{ proxmox_clone_vmid_modele }}. Sans lui, rien a cloner. + - 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" + url: "{{ proxmox_api_racine }}/api2/json/nodes/{{ proxmox_noeud_gabarit }}/qemu/{{ proxmox_clone_vmid_modele | int }}/clone" method: POST headers: Authorization: >- @@ -236,6 +261,9 @@ # 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) }}" + # LA DESTINATION SE DIT ICI, pas dans l'URL. Omise quand elle coincide avec le + # noeud du gabarit : l'API refuse un `target` inutile sur stockage non partage. + target: "{{ proxmox_clone_noeud if proxmox_clone_noeud != proxmox_noeud_gabarit else omit }}" validate_certs: "{{ proxmox_validate_certs | default(false) | bool }}" status_code: 200 register: proxmox_clone_lance @@ -246,6 +274,23 @@ no_log: true when: not proxmox_clone_deja_la + # L'ECHEC DU POST ETAIT AVALE. `failed_when: false` protege du 500 « VMID deja pris », + # mais il avalait AUSSI un refus reel — et la tache d'attente qui suit se bloquait + # alors sur un clonage qui n'avait jamais commence. Un blocage sans message, la panne + # la plus chere a diagnostiquer. On garde le masque pour l'en-tete, et on releve + # l'echec ici, message de l'API compris. + - name: "Le clonage a bien ete accepte par l'API" + ansible.builtin.assert: + that: (proxmox_clone_lance.status | default(0)) == 200 + fail_msg: >- + L'API a refuse le clonage + ({{ proxmox_clone_lance.status | default('aucune reponse') }}) : + {{ proxmox_clone_lance.json.message + | default(proxmox_clone_lance.msg | default('sans message')) }} + — gabarit {{ proxmox_clone_vmid_modele }} sur « {{ proxmox_noeud_gabarit }} », + destination « {{ proxmox_clone_noeud }} ». + when: not proxmox_clone_deja_la + # ATTENDRE QUE LE CLONE SOIT REELLEMENT FINI, pas seulement demande. # # `POST .../clone` rend un UPID et la main IMMEDIATEMENT : Proxmox copie le disque en @@ -262,8 +307,20 @@ vars: proxmox_api_racine: "https://{{ proxmox_api_host_effectif }}:{{ proxmox_api_port_effectif | default(8006, true) }}" ansible.builtin.uri: + # LE NOEUD VIENT DE L'UPID LUI-MEME, et de nulle part ailleurs (2026-08-25). + # + # Cette attente interrogeait `nodes/{{ proxmox_clone_noeud }}` — la DESTINATION. + # Or un clonage inter-noeuds s'execute sur le noeud du GABARIT : l'UPID n'existait + # pas la ou on le cherchait, l'API repondait en erreur, `failed_when: false` + # l'avalait, et `until` n'etait jamais satisfait. Resultat : soixante tentatives a + # dix secondes pour un clonage deja termine en 87 s — dix minutes d'attente + # immobile, puis la suite qui reprend sans un mot. + # + # Un UPID s'ecrit `UPID:::...`. Le noeud y est deja ; le redemander a + # une variable, c'est se donner une chance de se tromper. url: >- - {{ proxmox_api_racine }}/api2/json/nodes/{{ proxmox_clone_noeud + {{ proxmox_api_racine }}/api2/json/nodes/{{ + proxmox_clone_lance.json.data.split(':')[1] }}/tasks/{{ proxmox_clone_lance.json.data | urlencode }}/status method: GET headers: @@ -288,10 +345,18 @@ Le clonage de {{ proxmox_clone_nom }} (VMID {{ proxmox_clone_vmid }}) ne s'est pas termine correctement : etat « {{ proxmox_clone_tache.json.data.status | default('?') }} », sortie « {{ proxmox_clone_tache.json.data.exitstatus | default('?') }} ». - Regarder la tache sur {{ proxmox_clone_noeud }}. + Regarder la tache sur + « {{ proxmox_clone_lance.json.data.split(':')[1] | default('?') }} ». when: - not proxmox_clone_deja_la - - proxmox_clone_tache.json.data.exitstatus | default('OK') != 'OK' + # `exitstatus | default('OK')` faisait passer pour un SUCCES une attente qui + # n'avait jamais rien observe : quand l'API repondait en erreur — le cas du + # clonage inter-noeuds interroge sur le mauvais noeud — il n'y avait aucun + # `json.data`, donc aucun `exitstatus`, donc « OK » par defaut. On exige + # desormais d'avoir VU la tache s'arreter, et bien s'arreter. + - >- + (proxmox_clone_tache.json.data.status | default('')) != 'stopped' + or (proxmox_clone_tache.json.data.exitstatus | default('')) != 'OK' - name: Dire pourquoi le clonage a echoue, s'il a echoue ansible.builtin.fail: diff --git a/scripts/site_machines.py b/scripts/site_machines.py index 47cef6d..c7cb5bb 100755 --- a/scripts/site_machines.py +++ b/scripts/site_machines.py @@ -29,7 +29,36 @@ import underlay as U # noqa: E402 # Ce que vaut une machine du site quand son `gabarit:` ne le dit pas. Volontairement # modeste : une machine de l'hebergeur sert UN role, elle n'heberge pas de charge. -GABARIT_DEFAUT = {"vcpu": 2, "memoire": 2048, "disque": 40} +GABARIT_DEFAUT = {"vcpu": 2, "memoire": 2048, "disque": "40G"} + + +def _taille(v) -> str: + """Une taille de disque PORTE TOUJOURS SON UNITE. + + `disque: 40` a produit un `resize` a « 40 » que Proxmox a lu comme un + RETRECISSEMENT : « shrinking disks is not supported ». Le playbook tolere cette + erreur — a raison, un disque deja assez grand n'est pas une panne — donc la machine + est nee avec les 16 Go du gabarit au lieu de 40, SANS QUE RIEN NE LE DISE. + Le plan des tenants ecrit `40G` depuis toujours ; le site ecrit pareil, et un entier + nu se voit rattacher son unite plutot que de repartir en silence. + """ + s = str(v).strip() + return f"{s}G" if s.isdigit() else s + + +def materialisation() -> dict: + """Gabarit, stockage et mode de clonage — DECLARES par le site. + + Sans ce bloc, le playbook de clonage prenait ces valeurs dans les group_vars du + TENANT ACTIF : `make site-creer` aurait clone depuis un gabarit different selon le + symlink `instance`, sans rien dire. Un accident silencieux vaut moins qu'un refus. + """ + m = (U.charger() or {}).get("materialisation") or {} + if not m.get("vmid_modele"): + raise SystemExit( + "underlay.yml : `materialisation.vmid_modele` est requis — sans lui, le site " + "clonerait le gabarit du tenant actif, ce qui depend du symlink `instance`.") + return m def _machines() -> dict[str, dict]: @@ -66,15 +95,19 @@ def parametres(nom: str) -> dict[str, str]: "SETOPS_NOEUD": str(m["noeud"]), "SETOPS_COEURS": str(g["vcpu"]), "SETOPS_MEMOIRE": str(g["memoire"]), - "SETOPS_DISQUE": str(g["disque"]), + "SETOPS_DISQUE": _taille(g["disque"]), } # UN SEGMENT NON ETIQUETE N'A PAS DE TAG, et lui en donner un le couperait du fil. # `segment_physique` le dit ; `vlan: 1` (le natif) ne s'etiquette pas davantage. vlan = r.get("vlan") if not r.get("segment_physique") and isinstance(vlan, int) and vlan > 1: p["SETOPS_VLAN"] = str(vlan) - if m.get("stockage"): - p["SETOPS_STOCKAGE"] = str(m["stockage"]) + mat = materialisation() + p["SETOPS_VMID_MODELE"] = str(mat["vmid_modele"]) + # L'explicite de la machine gagne sur le defaut du site : une machine peut demander + # un autre stockage sans que tout le site bascule. + p["SETOPS_STOCKAGE"] = str(m.get("stockage") or mat.get("stockage") or "") + p["SETOPS_CLONE_COMPLET"] = "true" if mat.get("clone_complet", True) else "false" return p