portabilite : monter un SECOND tenant revele trois defauts invisibles

Les deux reconstructions de la semaine rebatissaient Chezlepro sur son propre
materiel : une preuve de reproductibilite, pas de portabilite. La vraie
epreuve est un second tenant — Technolibre, index 11, plan distinct, voute
separee, meme cluster.

1. LE CLONAGE RESOLVAIT PAR NOM. proxmox_kvm cherche une VM portant le `name`
demande ; s'il en trouve une il rend `ok` et ne clone RIEN — aucune tache
cote cluster. Or les noms courts sont VOLONTAIREMENT identiques d'un tenant a
l'autre. `backup-01` de Technolibre tombait sur celui de Chezlepro. Mesure :
id-ldap-01 et sup-01 (noms absents chez Chezlepro) passaient du premier coup,
backup-01 echouait toujours. Le clonage cible desormais par VMID, via l'API.

Deux defauts de ce correctif, trouves en le mesurant : le corps assemble en
Jinja avec `>-` rendait une CHAINE et perdait le `pool` (VM nee hors de son
pool, sans un mot) ; et l'application du gabarit de calcul expirait a 5 s,
laissant la VM aux valeurs du gabarit — 2 coeurs/2 Go au lieu du plan, en
silence. Corps en mapping YAML avec omit ; six tentatives espacees.

Et `no_log` a masque la cause au moment ou elle servait. Les deux attentes
disent maintenant ce qu'elles ont constate.

2. LE GUI DETRUISAIT DES INTRANTS. Dans ecrire_intrants, `identite` etait la
seule branche sur quatre a ecrire par-dessus le disque au lieu de fusionner.
Un enregistrement a supprime dns_amorcage et amorcage_acces_courriel. Le meme
geste sur Chezlepro aurait mange les memes cles.

3. LE VERROU DE `raser` N'ETAIT PROUVE QUE POUR UN TENANT. Le faux cluster
codait en dur les VMID de Chezlepro ; monte ailleurs, le test rendait 0 au
lieu de 2. Il fabrique desormais la collision sur le plan courant.

Verifie : prouver.py 34 OK sur Technolibre, test_raser vert sur les deux
tenants, ansible-lint production sur le playbook de clonage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-10 11:18:14 -04:00
parent ac85278366
commit 3b16b8845d
4 changed files with 214 additions and 28 deletions

View file

@ -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/<n>/qemu/<gabarit>/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

View file

@ -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 }}"
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 }}"
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 }}"
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) }}"
full: "{{ proxmox_clone_complet | default(true) | bool }}"
# A la CREATION seulement : l'API ne permet pas de le changer ensuite.
# 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) }}"
timeout: "{{ proxmox_clone_timeout | default(600) | int }}"
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

View file

@ -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/<env>/group_vars/all/10-intrants.yml.\n---\n")

View file

@ -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")