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:
parent
ac85278366
commit
3b16b8845d
4 changed files with 214 additions and 28 deletions
65
CHANGELOG.md
65
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/<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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue