Set-OPS-Public/scripts/tests/test_devis_placement.py
Daniel Allaire 12caff1cff devis de placement : le gabarit et le stockage que le clonage utilise (ceux du site)
Le devis lisait le gabarit du tenant : il validait chez Technolibre un 99998 que
plus rien ne clone, et refusait Chezlepro, qui suit la regle et n'en declare plus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:24:39 -04:00

141 lines
6.7 KiB
Python

#!/usr/bin/env python3
"""Le devis de placement dit ce qui ne va pas, au lieu de mourir.
Ce que ce test garde, et pourquoi il existe. Ce devis est ce qu'on lance AVANT quarante
minutes de deploiement, pour savoir si le terrain est bon — sur un site inconnu, un soir
d'intervention. C'est donc le pire endroit du depot pour un message illisible.
Le 2026-08-20, il mourait sur `AttributeError: 'str' object has no attribute 'get'` :
`Cluster.__call__` rend `{"_erreur": "..."}` en cas de panne, un DICT, et le devis
l'iterait comme une liste — un dict itere rend ses CLEFS. Dix lignes de trace Python pour
dire « le nom `asgard` ne se resout pas ». `Cluster.rate()` existait pour ca et n'etait
pas appele.
Le test ne verifie pas que le cluster repond — il verifie que le devis REFUSE PROPREMENT
quand il ne repond pas. Aucun reseau n'est touche : le Cluster est simule.
"""
import sys
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(RACINE / "scripts"))
import devis_placement as dp # noqa: E402
from proxmox_api import Cluster # noqa: E402
class _ClusterSimule(Cluster):
"""Un Cluster qui ne parle a personne : il rend ce qu'on lui dit de rendre."""
def __init__(self, reponse):
self.hote = "cluster-de-test"
self._reponse = reponse
def __call__(self, chemin, methode="GET", corps=None):
return self._reponse
PLACEMENT = {"proxmox_clone_noeud": "n1", "proxmox_clone_stockage": "s1",
"proxmox_clone_pont": "vmbr0", "proxmox_clone_vmid_modele": 99998}
def test_une_panne_reseau_devient_un_refus_lisible():
api = _ClusterSimule({"_erreur": "<urlopen error [Errno -3] Temporary failure "
"in name resolution>"})
try:
dp.constater(api, PLACEMENT)
except SystemExit as e:
message = str(e)
assert "ne repond pas" in message, message
assert "name resolution" in message, "la cause reelle doit etre citee"
assert "cluster-de-test" in message, "l'hote interroge doit etre nomme"
assert "VPN" in message, "le geste a tenter doit etre suggere"
else:
raise AssertionError("une reponse d'erreur aurait du produire un refus")
def test_une_reponse_qui_n_est_pas_une_liste_est_refusee():
"""Le cas silencieux : un dict SANS `_erreur` passerait la premiere garde."""
api = _ClusterSimule({"data": "quelque chose d'inattendu"})
try:
dp.constater(api, PLACEMENT)
except SystemExit as e:
assert "Reponse inattendue" in str(e), str(e)
else:
raise AssertionError("une reponse non iterable aurait du produire un refus")
def test_une_reponse_normale_traverse_la_garde():
"""La garde ne doit pas gener le cas nominal : quatre lignes de constat."""
api = _ClusterSimule([]) # cluster joignable, mais qui n'offre rien
lignes = dp.constater(api, PLACEMENT)
assert [l["objet"] for l in lignes] == ["noeud", "stockage", "reseaux VM", "gabarit"]
assert all(l["existe"] is False for l in lignes), \
"un cluster vide n'offre aucun des quatre objets"
def test_le_reseau_mesure_est_celui_ou_les_VM_atterrissent(tmp=None):
"""Le pont du plan n'est PAS ce qu'on verifie — c'est le VNet derive de chaque hote.
Le defaut du 2026-08-20 : le devis validait `proxmox_clone_pont` (vmbr1), qui n'est
que le repli des clones manuels, et rendait CONFORME sur un tenant dont AUCUN VNet
n'existait. `make creer-vm` passe le pont derive par hote et ecrase ce defaut.
"""
import tempfile
import yaml as _yaml
with tempfile.TemporaryDirectory() as d:
base = Path(d)
inv = base / "inventories" / "production"
inv.mkdir(parents=True)
(inv / "hosts.yml").write_text(_yaml.safe_dump({
"all": {"children": {"hotes_actifs": {"hosts": {
"a-01": {"proxmox_pont": "t29fron"},
"b-01": {"proxmox_pont": "t29serv"}}}}}}), encoding="utf-8")
api = _ClusterSimule([{"vnet": "t29fron"}]) # un seul des deux existe
ligne = [l for l in dp.constater(api, PLACEMENT, base, "sdn")
if l["objet"] == "reseaux VM"][0]
assert ligne["existe"] is False, "un VNet manquant doit faire echouer le devis"
assert "t29serv" in ligne["note"], ligne["note"]
assert "sdn-appliquer" in ligne["note"], "le geste correctif doit etre nomme"
assert "vmbr" not in str(ligne["voulu"]), \
"le devis ne doit plus presenter le pont des clones manuels"
def test_le_devis_nomme_le_locataire_designe():
"""Par `--locataire`, aucun fichier source d'ou tirer le nom : le devis annoncait
« tenant « ? » » au pre-vol de M4 (2026-10-07). Il nomme celui qu'on lui a designe."""
import contextlib
import io
assert dp.nom_du_tenant(None, "OPS-Exemple") == "OPS-Exemple"
source = Path("/d/OPS-Monte/inventories/principal/group_vars/proxmox.yml")
assert dp.nom_du_tenant(source) == "OPS-Monte", "le chemin monte nomme toujours son tenant"
sortie = io.StringIO()
with contextlib.redirect_stdout(sortie):
dp.afficher([], dp.nom_du_tenant(None, "OPS-Exemple"), Path("/d/SITE-Exemple"))
entete = sortie.getvalue().splitlines()[0]
assert "« OPS-Exemple »" in entete and "?" not in entete, f"en-tete : {entete!r}"
def test_le_devis_verifie_le_gabarit_et_le_stockage_que_le_clonage_utilise():
"""Le site decide du gabarit et de son stockage ; `cloner-vm` les passe en `-e`. Le devis
lisait le tenant : il validait chez Technolibre un 99998 que plus rien ne clone, et
refusait chez Chezlepro un gabarit « None » (pre-vol de sa reconstruction, 2026-10-07)."""
site = {"vmid": 9006, "nom": "modeleSetOPS-minimal", "noeud": "vishnu", "stockage": "CephNVMe"}
perime = {**PLACEMENT, "proxmox_clone_vmid_modele": 99998, "proxmox_clone_stockage": "TrueNAS"}
vu = dp.ce_que_le_clonage_utilise(perime, site)
assert vu["proxmox_clone_vmid_modele"] == 9006 and vu["proxmox_clone_stockage"] == "CephNVMe", vu
sans = {k: v for k, v in perime.items() if k != "proxmox_clone_vmid_modele"}
assert dp.ce_que_le_clonage_utilise(sans, site)["proxmox_clone_vmid_modele"] == 9006, \
"un tenant qui suit la regle (aucun gabarit declare) recoit celui du site"
assert dp.ce_que_le_clonage_utilise(perime, {}) == perime, \
"sans gabarit declare par le site, le tenant reprend la main, tel quel"
assert perime["proxmox_clone_vmid_modele"] == 99998, "le placement du tenant n'est pas modifie en place"
if __name__ == "__main__":
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
for t in tests:
t()
print(f"OK — {len(tests)} test(s) devis de placement : "
f"une panne de cluster se lit, elle ne se decode pas.")