Set-OPS-Public/playbooks/proxmox/cloner_vm_debian.yml
Daniel Allaire bd0e40753b memoire : ce que l hote a le droit de reprendre, et pourquoi il ne le pouvait pas
61 Go declares a 21 machines, 12 reellement utilises. balloon valait 0 partout,
ce qui ne desactive pas le ballooning : cela RETIRE le peripherique de la ligne
de commande QEMU. Le moniteur repond "No balloon device has been activated".

Le moteur derive desormais un plancher par machine sur les deux chemins de
creation, et le playbook de clonage le pose a cote de memory.

La table des planchers a ete corrigee deux fois par la mesure : shared_buffers
vaut 128 Mo et non une part de la RAM, une JVM tient 605 Mo, Redis 16 Mo. Ces
machines tiennent du cache de pages, pas un jeu de donnees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-13 19:57:11 -04:00

745 lines
41 KiB
YAML

---
- name: Cloner une VM Debian depuis le modele Proxmox
hosts: localhost
connection: local
gather_facts: false
become: false
vars:
# Config Proxmox de l'instance : on cherche dans l'inventaire de l'instance,
# quel que soit son nom (lab > principal > production). Compatible « par instance ».
setops_inventaires: [lab, principal, production]
# Voute UNIQUE de l'instance : all/vault.yml porte TOUS les secrets, jeton
# d'hyperviseur compris (D-19). `proxmox.vault.yml` a ete retire le 2026-08-03 :
# tolere "en compatibilite", il restait le SEUL porteur du jeton chez un tenant,
# et il etait gitignore — une voute unique qui ne l'etait pas, dans un fichier
# qui ne voyageait avec aucun depot. P18 ne pouvait pas le voir : il verifie le
# gabarit, pas le contenu reel.
# `proxmox.local.yml` reste une surcharge locale volontaire, non versionnee.
setops_gv_proxmox: [proxmox.yml, proxmox.local.yml, all/vault.yml]
# Le CLUSTER appartient a l'HEBERGEUR (API, noeuds, stockages, ponts). Son fichier
# vit dans le depot de l'hebergeur, que le symlink `underlay.yml` designe deja
# (D-14/D-17) : on en DERIVE le chemin plutot que de redeclarer l'hebergeur. Absent
# (depot sans underlay monte), le `stat` echoue simplement et rien n'est charge.
setops_proxmox_hebergeur: "{{ (playbook_dir ~ '/../../underlay.yml') | realpath | dirname }}/proxmox-hebergeur.yml"
# LA VOUTE DE L'UNDERLAY — les secrets du MONDE PHYSIQUE (jeton d'API du cluster).
# Meme derivation que le fichier ci-dessus : le symlink `underlay.yml` designe deja
# l'hebergeur, on n'a rien a redeclarer. Depuis le 2026-08-22 ces secrets ne vivent
# plus dans la voute d'un tenant : recopies chez chacun, ils ne pouvaient plus etre
# revoques isolement (docs/frontiere-physique-virtuel.md).
setops_voute_underlay: "{{ (playbook_dir ~ '/../../underlay.yml') | realpath | dirname }}/underlay.vault.yml"
tasks:
- name: Verifier les fichiers de variables Proxmox optionnels
ansible.builtin.stat:
path: "{{ playbook_dir }}/../../instance/inventories/{{ item.0 }}/group_vars/{{ item.1 }}"
loop: "{{ setops_inventaires | product(setops_gv_proxmox) | list }}"
loop_control:
label: "{{ item.0 }}/{{ item.1 }}"
register: proxmox_fichiers_variables
- name: Charger les variables Proxmox non sensibles disponibles
ansible.builtin.include_vars:
file: "{{ item.stat.path }}"
loop: "{{ proxmox_fichiers_variables.results }}"
when:
- item.stat.exists
- item.stat.path is not search('vault')
- name: Localiser le fichier Proxmox de l'hebergeur
ansible.builtin.stat:
path: "{{ setops_proxmox_hebergeur }}"
register: proxmox_fichier_hebergeur
- name: Charger le cluster de l'hebergeur (autoritaire sur ses propres cles)
ansible.builtin.include_vars:
file: "{{ setops_proxmox_hebergeur }}"
when: proxmox_fichier_hebergeur.stat.exists
- name: Verifier la voute de l'underlay
ansible.builtin.stat:
path: "{{ setops_voute_underlay }}"
register: proxmox_voute_underlay
- name: Charger les secrets Proxmox Vault
block:
- name: Charger les secrets de la voute Proxmox
ansible.builtin.include_vars:
file: "{{ item.stat.path }}"
loop: "{{ proxmox_fichiers_variables.results }}"
when:
- item.stat.exists
- item.stat.path is search('vault')
no_log: true
# APRES celle du tenant, donc elle GAGNE : quand les deux portent la cle, c'est
# celle de l'hebergeur qui fait foi. C'est le sens de la frontiere entre les deux
# mondes, et ca rend la migration d'un site sans coupure.
- name: Charger les secrets du monde physique (voute de l'underlay)
ansible.builtin.include_vars:
file: "{{ setops_voute_underlay }}"
when: proxmox_voute_underlay.stat.exists
no_log: true
rescue:
- name: Expliquer l echec de chargement du Vault
ansible.builtin.fail:
msg: >-
Impossible de charger la voute Proxmox (group_vars/all/vault.yml ou
proxmox.vault.yml). Si elle est chiffree avec Ansible Vault, fournir son
mot de passe ou definir ANSIBLE_VAULT_PASSWORD_FILE avant de relancer make creer-vm.
- name: Appliquer les valeurs Proxmox avec repli environnement
ansible.builtin.set_fact:
proxmox_api_host_effectif: "{{ proxmox_api_host | default(lookup('env', 'PROXMOX_API_HOST'), true) }}"
proxmox_api_user_effectif: "{{ proxmox_api_user | default(lookup('env', 'PROXMOX_API_USER'), true) }}"
# `split('!')[-1]` : proxmoxer recompose lui-meme `utilisateur!nom` a partir de
# `api_user` et `api_token_id`. Lui passer la forme COMPLETE produit
# `ansible@pve!ansible@pve!nom` et un 401 muet — le meme jeton fonctionne en
# HTTP direct, ce qui rend le diagnostic trompeur. Verifie contre le cluster le
# 2026-08-03 : forme complete = 401, forme courte = OK. Sans effet si la voute
# stocke deja le nom seul, donc les deux ecritures restent acceptees.
proxmox_api_token_id_effectif: "{{ (proxmox_api_token_id | default(lookup('env', 'PROXMOX_API_TOKEN_ID'), true)) | string | split('!') | last }}"
proxmox_api_token_secret_effectif: "{{ proxmox_api_token_secret | default(lookup('env', 'PROXMOX_API_TOKEN_SECRET'), true) }}"
proxmox_api_port_effectif: "{{ proxmox_api_port | default(lookup('env', 'PROXMOX_API_PORT'), true) }}"
no_log: true
- name: Valider les parametres obligatoires Proxmox
ansible.builtin.assert:
that:
- proxmox_api_host_effectif | length > 0
- proxmox_api_user_effectif | length > 0
- proxmox_api_token_id_effectif | length > 0
- proxmox_api_token_secret_effectif | length > 0
- proxmox_clone_nom is defined
- proxmox_clone_nom | length > 0
- proxmox_clone_vmid_modele is defined
- proxmox_clone_vmid is defined
- proxmox_clone_noeud is defined
- proxmox_clone_noeud | length > 0
- proxmox_clone_ipconfig0 is defined
- proxmox_clone_ipconfig0 | length > 0
fail_msg: "Parametres Proxmox incomplets. Verifier proxmox.yml, la voute (all/vault.yml ou proxmox.vault.yml) et les variables make propres a la VM."
- name: Lire la cle publique SSH depuis le fichier fourni
ansible.builtin.set_fact:
proxmox_clone_sshkeys: "{{ lookup('ansible.builtin.file', proxmox_clone_cle_publique_fichier) }}"
when:
- proxmox_clone_cle_publique is not defined
- proxmox_clone_cle_publique_fichier is defined
- proxmox_clone_cle_publique_fichier | length > 0
- name: Utiliser la cle publique SSH fournie directement
ansible.builtin.set_fact:
proxmox_clone_sshkeys: "{{ proxmox_clone_cle_publique }}"
when:
- 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: >-
{{
(proxmox_clone_dns | default('') | split(','))
| map('trim')
| reject('equalto', '')
| list
}}
- name: Verifier la bibliotheque Python proxmoxer locale
ansible.builtin.command:
cmd: "{{ ansible_playbook_python }} -c 'import proxmoxer'"
changed_when: false
failed_when: false
register: proxmoxer_verification
- name: Refuser si proxmoxer est absent
ansible.builtin.fail:
msg: >-
La bibliotheque Python proxmoxer est absente pour {{ ansible_playbook_python }}.
Installer le paquet systeme approprie, par exemple
sudo apt install python3-proxmoxer, ou installer proxmoxer dans
l'environnement Python utilise par Ansible, puis relancer make creer-vm.
when: proxmoxer_verification.rc != 0
- name: S'assurer que le pool du tenant existe
# Un pool par tenant : la console Proxmox affiche le NOM, et onze serveurs
# portent le meme d'un tenant a l'autre. Le pool restitue l'appartenance sans
# rien renommer. Il doit exister AVANT le clone — `proxmox_kvm` echoue sur un
# pool inconnu, et l'API ne sait pas changer le pool d'une VM deja creee.
community.general.proxmox_pool:
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 }}"
validate_certs: "{{ proxmox_validate_certs | default(false) | bool }}"
poolid: "{{ proxmox_clone_pool }}"
comment: "Tenant Set-OPS — genere, ne pas renommer a la main"
state: present
when: proxmox_clone_pool | default('', true) | length > 0
# 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 }}"
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 }}
# 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_noeud_gabarit }}/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) }}"
# 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
# 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
# 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
# tache de fond. `proxmox_kvm`, qu'on a remplace, attendait la fin (`timeout: 600`) — la
# reecriture par API avait perdu cette attente sans que ca se voie, parce qu'en
# sequentiel l'attente de SSH qui suit l'absorbait.
#
# En PARALLELE, c'est tout autre chose : `make creer-vm` rend la main pendant que le
# disque se copie, la limite de concurrence ne retient plus que des processus vides, et
# les clones s'empilent. Mesure du 2026-08-10 : limite a 4, DOUZE copies integrales du
# gabarit simultanees sur le meme stockage. Le CPU de l'hyperviseur a 2 %, et pourtant
# tout ramait — ce n'etait pas la machine, c'etait TrueNAS.
- name: Attendre la fin reelle du clonage
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:<noeud>:<pid>:...`. 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_lance.json.data.split(':')[1]
}}/tasks/{{ proxmox_clone_lance.json.data | urlencode }}/status
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 }}"
status_code: [200]
register: proxmox_clone_tache
until: (proxmox_clone_tache.json.data.status | default('running')) == 'stopped'
retries: "{{ ((proxmox_clone_timeout | default(600) | int) / 10) | round | int }}"
delay: 10
changed_when: false
failed_when: false
no_log: true
when:
- not proxmox_clone_deja_la
- proxmox_clone_lance.json.data is defined
# UN AVERTISSEMENT N'EST PAS UN ECHEC — ET IL NE DOIT PAS DISPARAITRE NON PLUS.
#
# Proxmox rend trois formes d'`exitstatus` : `OK`, `WARNINGS: <n>` pour une tache
# QUI A ABOUTI en signalant quelque chose, et un texte d'erreur pour un vrai echec.
# La garde n'acceptait que `OK` : elle declarait donc perdus des clonages termines.
#
# MESURE DU 2026-09-01, RECONSTRUCTION DE CHEZLEPRO. Cinq VM sur quinze refusees,
# toutes avec `WARNINGS: 1`. Le journal de la tache disait :
#
# transferred 16.0 GiB of 16.0 GiB (100.00%)
# can't deactivate LV 'vm-9006-disk-1': Logical volume in use.
# WARN: volume deactivation failed
#
# Le disque etait transfere INTEGRALEMENT. Proxmox essaie ensuite de desactiver le
# volume DU GABARIT, et n'y parvient pas tant qu'un autre clonage parallele s'en
# sert. C'est la consequence NORMALE de cloner un meme gabarit en parallele — la
# condition meme que `flotte-creer` recherche pour aller vite.
#
# Ce que ca coutait : la reconstruction s'arretait sur cinq VM parfaitement clonees,
# et le message parlait d'« etat stopped » — l'etat de la TACHE, qui signifie
# « terminee ». On lit « la VM est arretee » et on cherche un probleme de demarrage
# qui n'existe pas. Le message dit desormais de quoi il parle.
- name: Le clonage a-t-il signale quelque chose en aboutissant ?
ansible.builtin.debug:
msg: >-
Clonage de {{ proxmox_clone_nom }} abouti AVEC AVERTISSEMENT
({{ proxmox_clone_tache.json.data.exitstatus }}). Le plus courant est
« volume deactivation failed » sur le disque du gabarit : un autre clonage
parallele s'en servait encore. Le journal complet de la tache est sur
« {{ proxmox_clone_lance.json.data.split(':')[1] | default('?') }} ».
when:
- not proxmox_clone_deja_la
- (proxmox_clone_tache.json.data.status | default('')) == 'stopped'
- (proxmox_clone_tache.json.data.exitstatus | default('')) is match('WARNINGS')
- name: Dire si le clonage ne s'est pas termine correctement
ansible.builtin.fail:
msg: >-
Le clonage de {{ proxmox_clone_nom }} (VMID {{ proxmox_clone_vmid }}) ne s'est pas
termine correctement : la TACHE est « {{ proxmox_clone_tache.json.data.status
| default('?') }} » (« stopped » = terminee, ce n'est pas l'etat de la VM) et sa
sortie est « {{ proxmox_clone_tache.json.data.exitstatus | default('?') }} ».
Regarder la tache sur
« {{ proxmox_clone_lance.json.data.split(':')[1] | default('?') }} ».
when:
- not proxmox_clone_deja_la
# `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.
# `OK` et `WARNINGS: <n>` disent tous deux que la tache a ABOUTI. Tout autre
# texte est un vrai echec, et l'absence de sortie signifie qu'on n'a jamais vu
# la tache s'arreter — le cas que la note ci-dessus decrit.
- >-
(proxmox_clone_tache.json.data.status | default('')) != 'stopped'
or not ((proxmox_clone_tache.json.data.exitstatus | default('')) == 'OK'
or (proxmox_clone_tache.json.data.exitstatus | default('')) is match('WARNINGS'))
- 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,
# VM creee avec 2 / 2048 — ceux du gabarit. Il faut donc les reposer APRES le clone.
# Le clone COMPLET rend la main avant que sa configuration ne soit ecrite : sur
# un stockage lent, `qm clone` retourne, et le `.conf` n'existe pas encore. La
# tache suivante echouait alors sur « Configuration file ... does not exist ».
# Observe deux fois sur quinze clones (2026-08-07) — une course, pas un hasard.
- name: Attendre que la configuration du clone existe
vars:
# Assemblee dans une variable : un `>-` replie les retours en ESPACES, ce qui
# inserait une espace au milieu de l'URL. Le lint passait, la requete non.
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 | int }}/config"
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 }}"
status_code: 200
register: proxmox_clone_conf
until: proxmox_clone_conf.status == 200
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 }}"
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 }}"
validate_certs: "{{ proxmox_validate_certs | default(false) | bool }}"
node: "{{ proxmox_clone_noeud }}"
vmid: "{{ proxmox_clone_vmid | int }}"
cores: "{{ proxmox_clone_coeurs | int }}"
memory: "{{ proxmox_clone_memoire | int }}"
# LE PLANCHER DE MEMOIRE — ce que l'hote peut REPRENDRE, pas ce qu'il donne.
#
# `balloon` vaut 0 sur toute la flotte, c'est-a-dire ballooning DESACTIVE : l'hote
# ne peut jamais reprendre une page, meme jamais touchee. Mesure : 40 Go alloues
# a un ecosysteme pour 15,5 Go reellement utilises — 39 %. Les 24,5 Go restants
# sont tenus sans etre lus.
#
# CE QUE LE BALLOONING N'EST PAS : une compression. L'invite voit `memory` au
# demarrage et s'y dimensionne — `shared_buffers` de PostgreSQL, tas d'une JVM,
# series en memoire d'un collecteur. Reprendre en dessous de ce qu'il a reserve
# le fait swapper A L'INTERIEUR, ce qui coute plus que la RAM gagnee.
#
# D'ou un PLANCHER DECLARE PAR MACHINE plutot qu'un ratio global : les machines
# qui se dimensionnent au demarrage gardent un plancher haut, les autres non.
balloon: "{{ proxmox_clone_memoire_min | 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
- proxmox_clone_memoire_min is defined and proxmox_clone_memoire_min | string | length > 0
# Un desarmement doit se VOIR. Sans ce message, l'ecart entre ce que l'ecosysteme
# declare et ce que la VM recoit resterait muet — et c'est precisement le genre de
# silence qui a coute une soiree de diagnostic le 2026-08-25.
- name: Dire que le pare-feu Proxmox reste desarme (pont classique, pas un VNet SDN)
ansible.builtin.debug:
msg: >-
`proxmox_clone_parefeu_interface` vaut vrai, mais {{ proxmox_clone_nom }} nait
sur `{{ proxmox_clone_pont }}` — un pont Linux, pas un VNet SDN. Le pare-feu
Proxmox y jetterait le retour des flux en epingle (`ctstate INVALID`) : il reste
desarme. La frontiere continue de policer ce segment.
when:
- proxmox_clone_parefeu_interface | default(false) | bool
- proxmox_clone_pont is defined
- proxmox_clone_pont is match('^vmbr[0-9]+$')
- name: Ajuster le reseau de la VM clonee
community.general.proxmox_nic:
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 }}"
validate_certs: "{{ proxmox_validate_certs | default(false) | bool }}"
vmid: "{{ proxmox_clone_vmid | int }}"
interface: "{{ proxmox_clone_interface | default('net0') }}"
model: virtio
bridge: "{{ proxmox_clone_pont }}"
tag: "{{ proxmox_clone_vlan | int if proxmox_clone_vlan is defined and proxmox_clone_vlan | string | length > 0 else omit }}"
# LE PARE-FEU PROXMOX NE S'ARME QUE SUR UN VNET SDN (2026-08-25).
#
# `firewall=1` fait passer tout le trafic ponte par conntrack. Sur un VNET SDN
# c'est sans consequence : en EVPN le routage inter-VNet se fait dans le VRF, SUR
# LE NOEUD. Le flux ne quitte jamais l'hyperviseur, conntrack en voit un chemin
# coherent, et le devis est-ouest (`devis_proxmox_fw.py`) peut le policer.
#
# Sur un PONT CLASSIQUE route par une frontiere externe, le meme reglage devient
# un piege. Deux VM du meme noeud dans deux VLAN differents ne se parlent qu'en
# EPINGLE : la trame sort par le lien physique, la frontiere la route, et elle
# revient sur LE MEME PONT. La meme table conntrack voit alors les deux moities de
# la connexion, classe le retour `INVALID`, et `PVEFW-FORWARD` le jette.
#
# MESURE DU 2026-08-25, sur le site decoupe en quatre zones : `site-ops-01` et
# `site-pki-01` (asgard), `site-dns-01` et `site-cache-01` (gandalf) ne se
# joignaient plus — 0/8 — alors que TOUTES les paires entre noeuds differents
# etaient a 8/8. Douze tentatives faisaient monter le compteur `ctstate INVALID`
# de +112 sur asgard et +116 sur gandalf ; `firewall=0` pose, il ne bouge plus.
#
# Le defaut se deguise en panne reseau : la poignee TCP aboutit (SYN/SYN-ACK
# creent l'etat), et ce sont les paquets de DONNEES qui disparaissent. On accuse
# le MTU, la frontiere, la zone. Rien de tout cela n'y est pour quelque chose.
#
# L'INTENTION DECLAREE NE SUFFIT DONC PAS : un tenant declare
# `proxmox_clone_parefeu_interface: true` avec `proxmox_clone_pont: vmbr1` comme
# valeur PAR DEFAUT, chaque hote la remplacant par son VNet derive. L'hote qui
# retombe sur `vmbr1` naitrait arme sur un pont classique. On croise donc
# l'intention avec le pont REELLEMENT utilise. Un pont Linux se nomme `vmbrN` ;
# un VNet SDN porte le nom derive de sa zone (`t29serv`).
firewall: "{{ (proxmox_clone_parefeu_interface | default(false) | bool) and (proxmox_clone_pont is not match('^vmbr[0-9]+$')) }}"
# `mtu: 1` est la valeur Proxmox pour « HERITE DU PONT » (virtio uniquement).
#
# Sans elle, l'invite nait a 1500 quel que soit le pont. En SDN EVPN la zone est a
# 1450 (VXLAN coute 50 octets) : la VM emet donc des trames que son propre chemin
# ne peut pas encapsuler. Ca ne casse pas franchement — la connexion s'etablit,
# les petites requetes passent, les grosses reponses restent suspendues. C'est
# exactement la panne que le registre des flux decrit comme « la plus couteuse a
# diagnostiquer », et pourquoi il declare l'ICMP « fragmentation necessaire ».
#
# Mesure du 2026-08-10 : zones `t11` et `t17` a 1450 sur le cluster, les 14 invites
# a 1500. Invisible tant que toutes les VM vivent sur le MEME hyperviseur — elles
# communiquent alors par le pont local, sans encapsulation. La panne apparaitrait
# au premier eclatement de la flotte sur plusieurs noeuds.
#
# HERITER plutot qu'ecrire 1450 : hors SDN le pont est a 1500 et la VM suit. Une
# seule source de verite — celle du pont auquel elle est reellement attachee — et
# la valeur reste juste le jour ou la fabric passera aux trames jumbo.
#
# Le GABARIT le porte aussi (`net0 ... mtu=1`), ce qui couvre les clonages qui ne
# passent pas par ce playbook — un clone fait a la main dans l'interface, par
# exemple. On garde neanmoins la ligne ici : un gabarit se RECAPTURE (fait le
# 2026-08-09), et ce qui n'est pas versionne se perd en silence. Le playbook est
# la garantie qui survit a la recapture ; `make mtu-mesurer` verifie le resultat.
#
# Cette tache s'execute AVANT le demarrage du clone (voir « Demarrer le clone »
# plus bas) : aucun risque de rebranchement a chaud. Applique a une VM EN MARCHE,
# le meme changement detache et rebranche la carte sans que l'invite reconfigure
# son interface — 14 machines coupees d'un coup le 2026-08-10, et il a fallu les
# redemarrer. Sur une VM deja en service : poser la config, puis redemarrer.
mtu: 1
state: present
when:
- proxmox_clone_pont is defined
- proxmox_clone_pont | length > 0
- name: Agrandir le disque principal du clone (grow-only)
community.general.proxmox_disk:
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 }}"
validate_certs: "{{ proxmox_validate_certs | default(false) | bool }}"
vmid: "{{ proxmox_clone_vmid | int }}"
disk: "{{ proxmox_clone_disque | default('scsi0') }}"
size: "{{ proxmox_clone_taille_disque }}"
state: resized
register: proxmox_redim_disque
# Le disque derive du plan est un MINIMUM. Si le template est deja plus grand,
# Proxmox refuse de retrecir : on tolere ce cas (grow-only), la VM garde le
# disque du template. Toute autre erreur reste bloquante.
failed_when:
- proxmox_redim_disque is failed
- "'shrinking disks is not supported' not in (proxmox_redim_disque.msg | default(''))"
when:
- proxmox_clone_taille_disque is defined
- proxmox_clone_taille_disque | length > 0
- name: Configurer Cloud-Init sur le clone
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 }}"
validate_certs: "{{ proxmox_validate_certs | default(false) | bool }}"
node: "{{ proxmox_clone_noeud }}"
vmid: "{{ proxmox_clone_vmid | int }}"
name: "{{ proxmox_clone_nom }}"
update: true
ciuser: "{{ proxmox_clone_ciuser | default(omit, true) }}"
sshkeys: "{{ proxmox_clone_sshkeys | default(omit) }}"
ipconfig:
ipconfig0: "{{ proxmox_clone_ipconfig0 }}"
nameservers: "{{ proxmox_clone_nameservers if proxmox_clone_nameservers | length > 0 else omit }}"
searchdomains: "{{ proxmox_clone_domaines_recherche | default(omit, true) }}"
agent: "enabled=1"
# LE DEMARRAGE DECLARAIT EN PANNE CE QUI FONCTIONNAIT (2026-08-29).
#
# Quinze VM creees trois a la fois : l'une d'elles a depasse le delai du module
# PENDANT QUE PROXMOX GENERAIT ENCORE SON ISO CLOUD-INIT.
#
# fatal: Reached timeout while waiting for starting VM.
# Last line in task before timeout: 'generating cloud-init ISO'
#
# Elle a demarre juste apres. Le module avait abandonne, pas l'hyperviseur — et
# `flotte-creer` a rendu « au moins une VM n'a pas ete creee » alors que les quinze
# tournaient. CE FAUX ECHEC ARRETE UNE RECONSTRUCTION : `reconstruire` enchaine
# `flotte-creer` puis `deployer-tout`, et se serait arrete la.
#
# DEUX CORRECTIONS, ET LA SECONDE EST LA VRAIE. Un delai plus genereux (le defaut du
# module est court, et la generation d'ISO sur un stockage partage se met en file
# quand trois clonages tombent ensemble). Mais un delai reste un pari : on ne fait
# donc plus de son expiration un verdict. C'EST L'HYPERVISEUR QUI JUGE, en repondant
# ce qu'il fait tourner — meme principe que P52, ou l'agent invite juge la
# materialisation a la place d'une reponse SSH.
- name: Demarrer le clone
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 }}"
validate_certs: "{{ proxmox_validate_certs | default(false) | bool }}"
node: "{{ proxmox_clone_noeud }}"
vmid: "{{ proxmox_clone_vmid | int }}"
name: "{{ proxmox_clone_nom }}"
state: started
timeout: "{{ proxmox_clone_delai_demarrage | default(180) | int }}"
register: proxmox_clone_demarrage
failed_when: false
when: proxmox_clone_demarrer | default(true) | bool
# L'HYPERVISEUR EST LE JUGE. On lui demande ce qu'il fait tourner, on ne deduit rien
# du sort de la tache precedente. Les tentatives couvrent le cas ou il finissait
# encore son travail au moment ou le module a renonce.
- name: Confirmer aupres de l'hyperviseur que la VM tourne
community.general.proxmox_vm_info:
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 }}"
validate_certs: "{{ proxmox_validate_certs | default(false) | bool }}"
node: "{{ proxmox_clone_noeud }}"
vmid: "{{ proxmox_clone_vmid | int }}"
type: qemu
register: proxmox_clone_etat
until: (proxmox_clone_etat.proxmox_vms | default([])
| map(attribute='status') | first | default('')) == 'running'
retries: 12
delay: 10
changed_when: false
when: proxmox_clone_demarrer | default(true) | bool
- name: Refuser si la VM ne tourne toujours pas
ansible.builtin.assert:
that:
- (proxmox_clone_etat.proxmox_vms | default([])
| map(attribute='status') | first | default('')) == 'running'
fail_msg: >-
{{ proxmox_clone_nom }} (VMID {{ proxmox_clone_vmid }}) ne tourne pas apres
deux minutes d'attente. Ce n'est plus un delai trop court : l'hyperviseur lui-meme
la rapporte
« {{ proxmox_clone_etat.proxmox_vms | default([]) | map(attribute='status')
| first | default('introuvable') }} ».
success_msg: >-
{{ proxmox_clone_nom }} tourne
{{ '(le module avait renonce avant l hyperviseur)'
if proxmox_clone_demarrage.failed | default(false) else '' }}
when: proxmox_clone_demarrer | default(true) | bool
- name: Afficher le resume du clone
ansible.builtin.debug:
msg:
- "VM creee: {{ proxmox_clone_nom }} (VMID {{ proxmox_clone_vmid }})"
- "Pool: {{ proxmox_clone_pool | default('(aucun)', true) }}"
- "Cloud-Init ipconfig0: {{ proxmox_clone_ipconfig0 }}"