clonage : quatre defauts que la premiere VM placee hors du noeud du gabarit a reveles

Toutes les VM naissaient sur le noeud du gabarit puis migraient. site-cache-01 est la
premiere a etre placee ailleurs, et elle a fait tomber quatre defauts enchaines :

1. l'URL de clonage visait le noeud de DESTINATION, alors que l'API veut celui qui DETIENT
   le gabarit ; la destination se dit par `target`. D'ou un 500 sur tout clonage
   inter-noeuds. Le noeud du gabarit se decouvre dans l'inventaire du cluster.
2. ce 500 etait avale par failed_when: false + no_log: true. Une assertion le releve
   desormais, message de l'API compris.
3. l'attente interrogeait nodes/<destination>/tasks/<UPID> ; la tache vit sur le noeud du
   gabarit. 60 tentatives x 10 s pour un clonage termine en 87 s, puis la suite qui reprend
   sans un mot. Le noeud se lit dans l'UPID lui-meme.
4. la garde d'apres-attente retombait sur `exitstatus | default('OK')` : une attente qui
   n'avait rien observe passait pour un succes. Elle exige d'avoir VU la tache s'arreter.

Et une taille de disque porte toujours son unite : `disque: 40` etait lu comme un
retrecissement, erreur toleree a raison — la machine naissait donc avec les 16 Go du
gabarit au lieu de 40, sans que rien ne le dise.

Le site declare enfin ce qu'il materialise (materialisation: gabarit, stockage,
clone_complet) au lieu de l'heriter du tenant actif.

Chrono : clonage reel 59 s et 87 s pour 3,3 Gio alloues.

42 preuves vertes, ansible-lint profil production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-25 02:44:21 -04:00
parent dc92f01fe4
commit 6715f0d5c5
4 changed files with 161 additions and 9 deletions

View file

@ -1,5 +1,56 @@
# CHANGELOG — Set-OPS # 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/<gabarit>/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/<destination>/tasks/<UPID>` ; 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:<nœud>:<pid>:…` — 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 ## 2026-08-25 — La frontière voit le site : `fabric`, alias et règles
### Un mot manquait au vocabulaire des flux ### Un mot manquait au vocabulaire des flux

View file

@ -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; \ if [[ -z "$$machines" ]]; then printf '%s\n' 'Refus: aucune machine declaree dans l underlay.'; exit 2; fi; \
for m in $$machines; do \ for m in $$machines; do \
printf '\n=== Machine du site: %s ===\n' "$$m"; \ printf '\n=== Machine du site: %s ===\n' "$$m"; \
t0=$$(date +%s); \
params="$$(python3 scripts/site_machines.py parametres-proxmox --machine "$$m")"; \ params="$$(python3 scripts/site_machines.py parametres-proxmox --machine "$$m")"; \
eval "$$params"; \ eval "$$params"; \
$(MAKE) cloner-vm \ $(MAKE) cloner-vm \
@ -766,8 +767,10 @@ site-creer: ## Cree les machines du site depuis l'underlay — CONFIRMER=true
COEURS="$$SETOPS_COEURS" \ COEURS="$$SETOPS_COEURS" \
MEMOIRE="$$SETOPS_MEMOIRE" \ MEMOIRE="$$SETOPS_MEMOIRE" \
NOEUD_PROXMOX="$$SETOPS_NOEUD" \ NOEUD_PROXMOX="$$SETOPS_NOEUD" \
VMID_MODELE="$(VMID_MODELE)" \ VMID_MODELE="$$SETOPS_VMID_MODELE" \
CLONE_COMPLET="$$SETOPS_CLONE_COMPLET" \
FORMAT_DISQUE="$(FORMAT_DISQUE)"; \ FORMAT_DISQUE="$(FORMAT_DISQUE)"; \
printf '=== %s : %s s ===\n' "$$m" "$$(( $$(date +%s) - t0 ))"; \
done; \ done; \
printf '\nLes machines du site sont creees.\n' printf '\nLes machines du site sont creees.\n'

View file

@ -214,11 +214,36 @@
| selectattr('vmid', 'equalto', proxmox_clone_vmid | int) | selectattr('vmid', 'equalto', proxmox_clone_vmid | int)
| list | length > 0 }} | 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) - name: Cloner la VM depuis le modele (API directe, cible par VMID)
vars: vars:
proxmox_api_racine: "https://{{ proxmox_api_host_effectif }}:{{ proxmox_api_port_effectif | default(8006, true) }}" proxmox_api_racine: "https://{{ proxmox_api_host_effectif }}:{{ proxmox_api_port_effectif | default(8006, true) }}"
ansible.builtin.uri: 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 method: POST
headers: headers:
Authorization: >- Authorization: >-
@ -236,6 +261,9 @@
# A la CREATION seulement : l'API ne sait pas changer le pool d'une VM # 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). # existante par cet appel (il faut passer par le membre du pool).
pool: "{{ proxmox_clone_pool | default(omit, true) }}" 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 }}" validate_certs: "{{ proxmox_validate_certs | default(false) | bool }}"
status_code: 200 status_code: 200
register: proxmox_clone_lance register: proxmox_clone_lance
@ -246,6 +274,23 @@
no_log: true no_log: true
when: not proxmox_clone_deja_la 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. # ATTENDRE QUE LE CLONE SOIT REELLEMENT FINI, pas seulement demande.
# #
# `POST .../clone` rend un UPID et la main IMMEDIATEMENT : Proxmox copie le disque en # `POST .../clone` rend un UPID et la main IMMEDIATEMENT : Proxmox copie le disque en
@ -262,8 +307,20 @@
vars: vars:
proxmox_api_racine: "https://{{ proxmox_api_host_effectif }}:{{ proxmox_api_port_effectif | default(8006, true) }}" proxmox_api_racine: "https://{{ proxmox_api_host_effectif }}:{{ proxmox_api_port_effectif | default(8006, true) }}"
ansible.builtin.uri: 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:<noeud>:<pid>:...`. Le noeud y est deja ; le redemander a
# une variable, c'est se donner une chance de se tromper.
url: >- 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 }}/tasks/{{ proxmox_clone_lance.json.data | urlencode }}/status
method: GET method: GET
headers: headers:
@ -288,10 +345,18 @@
Le clonage de {{ proxmox_clone_nom }} (VMID {{ proxmox_clone_vmid }}) ne s'est pas Le clonage de {{ proxmox_clone_nom }} (VMID {{ proxmox_clone_vmid }}) ne s'est pas
termine correctement : etat « {{ proxmox_clone_tache.json.data.status | default('?') termine correctement : etat « {{ proxmox_clone_tache.json.data.status | default('?')
}} », sortie « {{ proxmox_clone_tache.json.data.exitstatus | 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: when:
- not proxmox_clone_deja_la - 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 - name: Dire pourquoi le clonage a echoue, s'il a echoue
ansible.builtin.fail: ansible.builtin.fail:

View file

@ -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 # 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. # 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]: def _machines() -> dict[str, dict]:
@ -66,15 +95,19 @@ def parametres(nom: str) -> dict[str, str]:
"SETOPS_NOEUD": str(m["noeud"]), "SETOPS_NOEUD": str(m["noeud"]),
"SETOPS_COEURS": str(g["vcpu"]), "SETOPS_COEURS": str(g["vcpu"]),
"SETOPS_MEMOIRE": str(g["memoire"]), "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. # 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. # `segment_physique` le dit ; `vlan: 1` (le natif) ne s'etiquette pas davantage.
vlan = r.get("vlan") vlan = r.get("vlan")
if not r.get("segment_physique") and isinstance(vlan, int) and vlan > 1: if not r.get("segment_physique") and isinstance(vlan, int) and vlan > 1:
p["SETOPS_VLAN"] = str(vlan) p["SETOPS_VLAN"] = str(vlan)
if m.get("stockage"): mat = materialisation()
p["SETOPS_STOCKAGE"] = str(m["stockage"]) 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 return p