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>
141 lines
6.7 KiB
Python
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.")
|