devis de placement : mourir n'est pas un diagnostic
Some checks are pending
verifier / verifier (push) Waiting to run

Soir de reconstruction, VPN pas encore monte. `make placement-plan` — le devis qu'on lance
AVANT quarante minutes de deploiement — rendait `AttributeError: 'str' object has no
attribute 'get'` : dix lignes de trace Python pour dire « le nom `asgard` ne se resout pas
d'ici ».

En panne, `Cluster.__call__` rend {"_erreur": "..."} — un DICT. Le devis l'iterait comme
une liste, et un dict itere rend ses CLEFS. `Cluster.rate()` existait pour ca et n'etait
appele nulle part ici. Les quatre appels passent desormais par une garde qui nomme la
cause, l'hote interroge et le geste a tenter (resolution, VPN, jeton).

ET LE DEVIS MESURAIT LE MAUVAIS TENANT. `placement_du_tenant()` lisait `instance/` en dur :
viser patient 0 avec SETOPS_INSTANCE mesurait en silence le placement de l'instance
montee. Le verdict etait juste — pour l'autre tenant. Les deux portaient les memes quatre
valeurs, ce qui est exactement la circonstance ou l'erreur ne se voit pas. C'est la
HUITIEME resolution d'instance ou d'inventaire codee en dur trouvee en trois jours ; a ce
compte ce n'est plus une serie de bogues, c'est une piece manquante.

L'en-tete annoncait aussi « tenant instance » — le nom du lien, pas celui du tenant.

TROIS TESTS, aucun reseau touche (Cluster simule) : une panne devient un refus lisible ;
une reponse qui n'est pas une liste est refusee — c'est le cas silencieux, celui qui
franchirait la premiere garde ; le cas nominal traverse sans gene. Branches sur `make test`.

Verifie ensuite contre le cluster reel : le devis nomme « OPS-Patient0 » et confirme ses
quatre objets (asgard, TrueNAS, vmbr1, gabarit 99998).

make test OK ; make verifier 38/38 ; make ci 38/38.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-20 21:26:32 -04:00
parent a68b9cd10e
commit 254268d5f2
4 changed files with 174 additions and 8 deletions

View file

@ -1,5 +1,48 @@
# CHANGELOG — Set-OPS # CHANGELOG — Set-OPS
## 2026-08-20 — Le devis d'avant-vol mourait au lieu de parler
Soir de reconstruction, VPN pas encore monté. `make placement-plan` — le devis qu'on lance
**avant** quarante minutes de déploiement, pour savoir si le terrain est bon :
```
AttributeError: 'str' object has no attribute 'get'
```
Dix lignes de trace Python pour dire *« le nom `asgard` ne se résout pas d'ici »*.
En panne, `Cluster.__call__` rend `{"_erreur": "…"}` — un **dict**. Le devis l'itérait
comme une liste, et un dict itéré rend ses **clés** : d'où un `str` là où le code
attendait un objet. `Cluster.rate()` existait précisément pour ça, et n'était appelé
nulle part ici. Les quatre appels passent désormais par une garde qui nomme la cause,
l'hôte interrogé et le geste à tenter.
### Et le devis mesurait le mauvais tenant
`placement_du_tenant()` lisait `instance/` **en dur** : viser patient 0 avec
`SETOPS_INSTANCE` mesurait en silence le placement de l'instance montée. Le verdict était
juste — pour l'autre tenant. Les deux portaient les mêmes quatre valeurs, ce qui est
exactement la circonstance où l'erreur ne se voit pas.
C'est la **huitième** résolution d'inventaire ou d'instance codée en dur trouvée en trois
jours. À ce compte, ce n'est plus une série de bogues : c'est une pièce manquante.
Au passage, l'en-tête annonçait « tenant *instance* » — le nom du lien, pas celui du
tenant. Un devis doit nommer ce qu'il a mesuré.
### Trois tests, aucun réseau touché
`test_devis_placement.py` simule le Cluster : une panne devient un refus lisible (cause,
hôte, geste), une réponse qui n'est pas une liste est refusée elle aussi — c'est le cas
silencieux, celui qui franchirait la première garde —, et le cas nominal traverse sans
gêne. Branchés sur `make test`.
> **Ce qu'on répare ici n'est pas une exception, c'est un message.** Un outil de
> diagnostic qui échoue en langage machine transforme une panne de trente secondes (monter
> le VPN) en une demi-heure de fouille. Le pire moment pour ça est celui où on l'utilise :
> quand quelque chose ne va déjà pas.
## 2026-08-20 — Le harnais ne se déclenchait que par mémoire ## 2026-08-20 — Le harnais ne se déclenchait que par mémoire
Trente-huit preuves, des tests, un lint — et **rien** ne les exécutait sans qu'un humain Trente-huit preuves, des tests, un lint — et **rien** ne les exécutait sans qu'un humain

View file

@ -225,6 +225,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
python3 scripts/tests/test_raser_resultat.py python3 scripts/tests/test_raser_resultat.py
python3 scripts/tests/test_adressage_derive.py python3 scripts/tests/test_adressage_derive.py
python3 scripts/tests/test_gui_intrants.py python3 scripts/tests/test_gui_intrants.py
python3 scripts/tests/test_devis_placement.py
.PHONY: verifier .PHONY: verifier
verifier: lint test inventaire-verifier site-verifier flux-verifier syntaxe ## Rejoue les preuves SANS reecrire le rapport (verification rapide) verifier: lint test inventaire-verifier site-verifier flux-verifier syntaxe ## Rejoue les preuves SANS reecrire le rapport (verification rapide)

View file

@ -27,6 +27,7 @@ N'ECRIT RIEN. Lecture seule, sans effet de bord.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import os
import sys import sys
from pathlib import Path from pathlib import Path
@ -41,19 +42,56 @@ INVENTAIRES = ("principal", "production", "lab")
def placement_du_tenant() -> tuple[dict, Path | None]: def placement_du_tenant() -> tuple[dict, Path | None]:
"""Les valeurs de placement declarees par le tenant MONTE.""" """Les valeurs de placement declarees par le tenant VISE.
SETOPS_INSTANCE d'abord ; le symlink `instance/` n'en est que le cas courant. Code en
dur, il rendait ce devis incapable de regarder un AUTRE tenant : viser patient 0
mesurait en silence le placement de l'instance montee, et rendait un verdict juste
pour le mauvais tenant (mesure du 2026-08-20 — les deux avaient les memes quatre
valeurs, ce qui est exactement la circonstance ou l'erreur ne se voit pas).
"""
base = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance"))
for nom in INVENTAIRES: for nom in INVENTAIRES:
f = RACINE / "instance" / "inventories" / nom / "group_vars" / "proxmox.yml" f = base / "inventories" / nom / "group_vars" / "proxmox.yml"
if f.is_file(): if f.is_file():
return (yaml.safe_load(f.read_text(encoding="utf-8")) or {}), f return (yaml.safe_load(f.read_text(encoding="utf-8")) or {}), f
return {}, None return {}, None
def _liste(api: Cluster, chemin: str) -> list:
"""Une reponse de l'API garantie ITERABLE — ou un refus lisible.
POURQUOI (mesure du 2026-08-20, un soir de reconstruction). Sur erreur,
`Cluster.__call__` rend `{"_erreur": "..."}` — un DICT. Itere comme une liste, un
dict rend ses CLEFS : le devis mourait sur
`AttributeError: 'str' object has no attribute 'get'`, dix lignes de trace Python
pour dire « le nom `asgard` ne se resout pas ».
C'est le pire endroit pour un message illisible : ce devis est precisement ce qu'on
lance AVANT quarante minutes de deploiement, quand on cherche a savoir si le terrain
est bon. `Cluster.rate()` existait pour ca ; il n'etait pas appele ici.
"""
rep = api(chemin)
faute = api.rate(rep)
if faute:
raise SystemExit(
f"Le cluster ne repond pas sur `{chemin}` : {faute}\n"
f" hote interroge : {api.hote}\n"
f" a verifier : le nom se resout-il d'ici, le VPN est-il monte, "
f"le jeton d'API est-il encore valide ?")
if not isinstance(rep, list):
raise SystemExit(
f"Reponse inattendue du cluster sur `{chemin}` : "
f"{type(rep).__name__} au lieu d'une liste.\n"
f" hote interroge : {api.hote}")
return rep
def constater(api: Cluster, place: dict) -> list[dict]: def constater(api: Cluster, place: dict) -> list[dict]:
"""Confronte chaque valeur au cluster. Renvoie une ligne par objet.""" """Confronte chaque valeur au cluster. Renvoie une ligne par objet."""
lignes: list[dict] = [] lignes: list[dict] = []
noeuds = [n.get("node") for n in (api("/nodes") or [])] noeuds = [n.get("node") for n in _liste(api, "/nodes")]
voulu = place.get("proxmox_clone_noeud") voulu = place.get("proxmox_clone_noeud")
lignes.append({"objet": "noeud", "voulu": voulu, lignes.append({"objet": "noeud", "voulu": voulu,
"existe": voulu in noeuds, "offre": noeuds}) "existe": voulu in noeuds, "offre": noeuds})
@ -61,7 +99,7 @@ def constater(api: Cluster, place: dict) -> list[dict]:
# Un stockage ne suffit pas : il doit accepter le contenu `images`, sinon on ne peut # Un stockage ne suffit pas : il doit accepter le contenu `images`, sinon on ne peut
# pas y poser un disque de VM — et l'erreur ne se lit qu'au clone. # pas y poser un disque de VM — et l'erreur ne se lit qu'au clone.
stockages = {} stockages = {}
for s in (api("/storage") or []): for s in _liste(api, "/storage"):
stockages[s.get("storage")] = str(s.get("content") or "") stockages[s.get("storage")] = str(s.get("content") or "")
voulu = place.get("proxmox_clone_stockage") voulu = place.get("proxmox_clone_stockage")
lignes.append({"objet": "stockage", "voulu": voulu, lignes.append({"objet": "stockage", "voulu": voulu,
@ -75,7 +113,7 @@ def constater(api: Cluster, place: dict) -> list[dict]:
n = place.get("proxmox_clone_noeud") n = place.get("proxmox_clone_noeud")
ponts: list[str] = [] ponts: list[str] = []
if n in noeuds: if n in noeuds:
ponts = [i.get("iface") for i in (api(f"/nodes/{n}/network") or []) ponts = [i.get("iface") for i in _liste(api, f"/nodes/{n}/network")
if str(i.get("type")) in ("bridge", "OVSBridge")] if str(i.get("type")) in ("bridge", "OVSBridge")]
voulu = place.get("proxmox_clone_pont") voulu = place.get("proxmox_clone_pont")
lignes.append({"objet": "pont", "voulu": voulu, lignes.append({"objet": "pont", "voulu": voulu,
@ -86,7 +124,7 @@ def constater(api: Cluster, place: dict) -> list[dict]:
# cloner une VM ordinaire marcherait, et produirait quatorze copies d'une machine # cloner une VM ordinaire marcherait, et produirait quatorze copies d'une machine
# vivante. # vivante.
vmid = place.get("proxmox_clone_vmid_modele") vmid = place.get("proxmox_clone_vmid_modele")
vms = {v.get("vmid"): v for v in (api("/cluster/resources?type=vm") or [])} vms = {v.get("vmid"): v for v in _liste(api, "/cluster/resources?type=vm")}
trouve = vms.get(int(vmid)) if vmid is not None else None trouve = vms.get(int(vmid)) if vmid is not None else None
est_gabarit = bool(trouve and str(trouve.get("template", 0)) == "1") est_gabarit = bool(trouve and str(trouve.get("template", 0)) == "1")
lignes.append({"objet": "gabarit", "voulu": vmid, lignes.append({"objet": "gabarit", "voulu": vmid,
@ -100,8 +138,10 @@ def constater(api: Cluster, place: dict) -> list[dict]:
def afficher(lignes: list[dict], source: Path | None, depot: Path) -> bool: def afficher(lignes: list[dict], source: Path | None, depot: Path) -> bool:
print(f"Devis du placement — tenant « {source.parents[3].name if source else '?'} » " # `.resolve()` : sans lui, un tenant monte par symlink s'annonce « instance » — le nom
f"sur l'hebergeur « {depot.name} »\n") # du lien, pas celui du tenant. Un devis doit nommer ce qu'il a mesure.
tenant = source.parents[3].resolve().name if source else "?"
print(f"Devis du placement — tenant « {tenant} » sur l'hebergeur « {depot.name} »\n")
print(f" {'OBJET':<10} {'DEMANDE':<22} {'VERDICT'}") print(f" {'OBJET':<10} {'DEMANDE':<22} {'VERDICT'}")
ok = True ok = True
for l in lignes: for l in lignes:

View file

@ -0,0 +1,82 @@
#!/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", "pont", "gabarit"]
assert all(l["existe"] is False for l in lignes), \
"un cluster vide n'offre aucun des quatre objets"
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.")