Sur le chemin TENANT=, raser annoncait un ecosysteme monte (rien n'est monte sur le runner du site) et le devis de placement un tenant « ? ». Les deux en-tetes disent desormais ce qui est vise et d'ou vient la liste. Le pre-vol lui-meme est consigne : 13 VM, aucun conflit, placement conforme. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
125 lines
5.5 KiB
Python
125 lines
5.5 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}"
|
|
|
|
|
|
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.")
|