#!/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": ""}) 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.")