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>
298 lines
14 KiB
Python
298 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Devis du PLACEMENT — les quatre objets d'hebergeur que le tenant monte designe.
|
|
|
|
D-80 : un tenant est agnostique de son underlay ; tout son adressage derive du seed.
|
|
Ce qui le lie a une fabric tient en QUATRE valeurs, et elles nomment des objets qui
|
|
appartiennent a l'hebergeur :
|
|
|
|
proxmox_clone_noeud sur quel noeud cloner
|
|
proxmox_clone_stockage ou poser le disque
|
|
proxmox_clone_pont ou brancher la carte
|
|
proxmox_clone_vmid_modele quel gabarit cloner
|
|
|
|
**P37 en verifie trois**, statiquement, contre les listes que l'hebergeur DECLARE
|
|
(`proxmox-hebergeur.yml`). C'est utile et insuffisant : une liste declaree peut elle-meme
|
|
etre perimee, et le GABARIT n'y figure pas — c'est un objet du cluster, pas une
|
|
declaration.
|
|
|
|
Ce devis interroge donc le CLUSTER. Il ne verifie pas ce qui est ecrit : il verifie ce qui
|
|
existe. Sur un site inconnu, c'est la premiere chose a savoir — sans quoi l'ecart se
|
|
decouvre au premier clone, apres quarante minutes de deploiement.
|
|
|
|
N'ECRIT RIEN. Lecture seule, sans effet de bord.
|
|
|
|
python3 scripts/devis_placement.py # tableau lisible
|
|
python3 scripts/devis_placement.py --verifier # code de sortie (0 = tout existe)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
RACINE = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
import underlay as underlay_mod # noqa: E402
|
|
from inventory_rules import instance_courante # noqa: E402
|
|
from proxmox_api import Cluster # noqa: E402
|
|
|
|
INVENTAIRES = ("principal", "production", "lab")
|
|
|
|
|
|
def placement_du_tenant() -> tuple[dict, Path | None]:
|
|
"""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 un autre tenant
|
|
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 = instance_courante()
|
|
for nom in INVENTAIRES:
|
|
f = base / "inventories" / nom / "group_vars" / "proxmox.yml"
|
|
if f.is_file():
|
|
return (yaml.safe_load(f.read_text(encoding="utf-8")) or {}), f
|
|
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 _ponts_derives(base: Path | None) -> set[str]:
|
|
"""Les reseaux ou les VM de CE tenant atterriront reellement.
|
|
|
|
Ce ne sont pas les valeurs du plan : `instancier` pose dans chaque hote le pont
|
|
DERIVE de sa zone (`proxmox_pont` = le VNet du tenant), et `make creer-vm` le passe au
|
|
clone en ecrasant le defaut. C'est donc la seule chose a confronter au cluster.
|
|
"""
|
|
ponts: set[str] = set()
|
|
if base is None:
|
|
return ponts
|
|
for f in sorted(base.glob("inventories/*/hosts.yml")):
|
|
data = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
|
|
|
|
def marcher(noeud):
|
|
if not isinstance(noeud, dict):
|
|
return
|
|
for hote in (noeud.get("hosts") or {}).values():
|
|
if isinstance(hote, dict) and hote.get("proxmox_pont"):
|
|
ponts.add(str(hote["proxmox_pont"]))
|
|
for enfant in (noeud.get("children") or {}).values():
|
|
marcher(enfant or {})
|
|
marcher((data.get("all") or {}))
|
|
return ponts
|
|
|
|
|
|
def constater(api: Cluster, place: dict, base: Path | None = None,
|
|
routage: str = "sdn", ponts: set[str] | None = None) -> list[dict]:
|
|
"""Confronte chaque valeur au cluster. Renvoie une ligne par objet.
|
|
|
|
`ponts` : ceux que la face reseau du locataire publie (`--locataire`) ; sans eux, ceux de
|
|
l'inventaire de l'instance montee."""
|
|
lignes: list[dict] = []
|
|
place = {**place, "_instance": base, "_ponts": ponts}
|
|
|
|
noeuds = [n.get("node") for n in _liste(api, "/nodes")]
|
|
voulu = place.get("proxmox_clone_noeud")
|
|
lignes.append({"objet": "noeud", "voulu": voulu,
|
|
"existe": voulu in noeuds, "offre": noeuds})
|
|
|
|
# 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.
|
|
stockages = {}
|
|
for s in _liste(api, "/storage"):
|
|
stockages[s.get("storage")] = str(s.get("content") or "")
|
|
voulu = place.get("proxmox_clone_stockage")
|
|
lignes.append({"objet": "stockage", "voulu": voulu,
|
|
"existe": voulu in stockages and "images" in stockages[voulu],
|
|
"offre": [k for k, v in stockages.items() if "images" in v],
|
|
"note": ("" if voulu not in stockages or "images" in stockages[voulu]
|
|
else f"present mais n'accepte pas `images` ({stockages[voulu]})")})
|
|
|
|
# LE RESEAU OU LES VM ATTERRISSENT — pas `proxmox_clone_pont`.
|
|
#
|
|
# Mesure du 2026-08-20, remarque de l'exploitant : « le pont ne me semble pas approprie
|
|
# depuis qu'on cree des VNets par tenant ». Il avait raison, et le defaut etait pire
|
|
# que cosmetique. Ce devis validait `vmbr1` — le REPLI des clones manuels — pendant que
|
|
# le point d'attache reel des VM de la flotte n'etait controle par personne :
|
|
# `creer-vm` lit le pont DERIVE de chaque hote (`SETOPS_PONT`, le VNet de sa zone) et
|
|
# ecrase le defaut. Resultat : « CONFORME » sur un tenant dont aucun VNet n'existait.
|
|
#
|
|
# D-80 corrigee le 2026-08-13 dit deja que la liaison de placement est nœud, stockage
|
|
# et gabarit — le pont se derive. Le devis, lui, continuait de compter quatre objets.
|
|
ponts_derives = sorted(place["_ponts"] if place.get("_ponts") is not None
|
|
else _ponts_derives(place.get("_instance")))
|
|
if routage == "sdn":
|
|
vnets = {v.get("vnet") for v in _liste(api, "/cluster/sdn/vnets")}
|
|
manquants = [p for p in ponts_derives if p not in vnets]
|
|
lignes.append({
|
|
"objet": "reseaux VM", "voulu": ", ".join(ponts_derives) or "(aucun)",
|
|
"existe": bool(ponts_derives) and not manquants,
|
|
"offre": sorted(vnets),
|
|
"note": ("" if not manquants else
|
|
f"VNet(s) absent(s) du cluster : {', '.join(manquants)} — "
|
|
f"passer `make sdn-appliquer` AVANT de creer les VM")})
|
|
else:
|
|
n = place.get("proxmox_clone_noeud")
|
|
ponts: list[str] = []
|
|
if n in noeuds:
|
|
ponts = [i.get("iface") for i in _liste(api, f"/nodes/{n}/network")
|
|
if str(i.get("type")) in ("bridge", "OVSBridge")]
|
|
manquants = [p for p in ponts_derives if p not in ponts]
|
|
lignes.append({
|
|
"objet": "reseaux VM", "voulu": ", ".join(ponts_derives) or "(aucun)",
|
|
"existe": bool(ponts_derives) and not manquants,
|
|
"offre": ponts,
|
|
"note": ("noeud introuvable : ponts non verifiables" if n not in noeuds else
|
|
"" if not manquants else
|
|
f"pont(s) absent(s) sur {n} : {', '.join(manquants)}")})
|
|
|
|
# Le gabarit : celui que P37 ne peut PAS voir. Presence, et surtout `template=1` —
|
|
# cloner une VM ordinaire marcherait, et produirait quatorze copies d'une machine
|
|
# vivante.
|
|
vmid = place.get("proxmox_clone_vmid_modele")
|
|
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
|
|
est_gabarit = bool(trouve and str(trouve.get("template", 0)) == "1")
|
|
lignes.append({"objet": "gabarit", "voulu": vmid,
|
|
"existe": est_gabarit,
|
|
"offre": [f"{v.get('vmid')} ({v.get('name')})" for v in vms.values()
|
|
if str(v.get("template", 0)) == "1"],
|
|
"note": ("" if est_gabarit else
|
|
(f"VMID {vmid} existe (« {trouve.get('name')} ») mais n'est PAS "
|
|
f"un gabarit" if trouve else f"aucune VM au VMID {vmid}"))})
|
|
return lignes
|
|
|
|
|
|
def nom_du_tenant(source: Path | None, locataire: str | None = None) -> str:
|
|
"""Le nom que le devis annonce. Un devis doit nommer ce qu'il a mesure.
|
|
|
|
`.resolve()` : sans lui, un tenant monte par symlink s'annonce « instance » — le nom du
|
|
lien, pas celui du tenant. NOMME (`--locataire`, 2026-10-07) : il n'y a pas de fichier
|
|
source d'ou tirer le nom, le devis annoncait « ? » ; le nom est celui qu'on a donne.
|
|
"""
|
|
if locataire:
|
|
return locataire
|
|
return source.parents[3].resolve().name if source else "?"
|
|
|
|
|
|
def ce_que_le_clonage_utilise(place: dict, gabarit: dict) -> dict:
|
|
"""Le placement tel que `cloner-vm` le passe REELLEMENT a Proxmox.
|
|
|
|
LE DEVIS VERIFIAIT CE QUE PERSONNE NE CLONE (2026-10-07). Depuis le 2026-09-01, le gabarit
|
|
et son stockage viennent du SITE (`underlay.py --gabarit`) : `cloner-vm` les passe en
|
|
`-e`, par-dessus ce que dit le tenant. Le devis, lui, lisait encore le tenant. Chez
|
|
Technolibre, il validait le 99998, que plus rien ne clone (M4 est nee du 9006, sur
|
|
CephNVMe) ; chez Chezlepro, qui ne declare plus de gabarit (c'est la regle), il refusait
|
|
« None » — trouve au pre-vol de sa reconstruction.
|
|
|
|
Meme regle que le Makefile : le site decide ; s'il ne declare rien, le tenant reprend
|
|
la main. Degrader, jamais deviner.
|
|
"""
|
|
vu = dict(place)
|
|
if gabarit.get("vmid") not in (None, ""):
|
|
vu["proxmox_clone_vmid_modele"] = int(gabarit["vmid"])
|
|
if gabarit.get("stockage"):
|
|
vu["proxmox_clone_stockage"] = gabarit["stockage"]
|
|
return vu
|
|
|
|
|
|
def afficher(lignes: list[dict], tenant: str, depot: Path) -> bool:
|
|
print(f"Devis du placement — tenant « {tenant} » sur l'hebergeur « {depot.name} »\n")
|
|
print(f" {'OBJET':<12} {'DEMANDE':<34} {'VERDICT'}")
|
|
ok = True
|
|
for l in lignes:
|
|
verdict = "existe" if l["existe"] else "INTROUVABLE"
|
|
ok = ok and l["existe"]
|
|
print(f" {l['objet']:<12} {str(l['voulu']):<34} {verdict}")
|
|
if l.get("note"):
|
|
print(f" {'':<12} {'':<34} -> {l['note']}")
|
|
if not l["existe"] and l["offre"]:
|
|
offert = ", ".join(str(x) for x in l["offre"][:6])
|
|
print(f" {'':<12} {'':<34} offert : {offert}")
|
|
print("\n" + ("CONFORME : le noeud, le stockage, le gabarit et les reseaux des VM "
|
|
"existent sur ce cluster." if ok else
|
|
"ECART : le tenant a besoin d'objets que ce cluster n'offre pas."))
|
|
return ok
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--verifier", action="store_true", help="code de sortie seulement")
|
|
ap.add_argument("--locataire", help="le locataire NOMME, d'apres sa face reseau publiee "
|
|
"(le site, sans monter son depot)")
|
|
a = ap.parse_args()
|
|
|
|
ponts = None
|
|
if a.locataire:
|
|
# LE SITE CONFRONTE LE PLACEMENT D'UN LOCATAIRE NOMME (2026-10-05, materialisation M2) :
|
|
# ses valeurs de placement et les ponts de ses machines viennent de sa face publiee.
|
|
import contexte
|
|
face = contexte.Locataire.charger(a.locataire).face_publiee()
|
|
if face is None:
|
|
print(f"{a.locataire} n'a pas publie sa face reseau.", file=sys.stderr)
|
|
return 2
|
|
place, source = dict(face.get("placement") or {}), None
|
|
ponts = contexte.ponts_publies(face)
|
|
else:
|
|
place, source = placement_du_tenant()
|
|
tenant = nom_du_tenant(source, a.locataire)
|
|
place = ce_que_le_clonage_utilise(place, underlay_mod.gabarit())
|
|
if not place:
|
|
print(f"Aucun placement declare par « {tenant} » — rien a confronter.")
|
|
return 0
|
|
try:
|
|
api, depot = Cluster.depuis_hebergeur()
|
|
except Exception as e: # noqa: BLE001 — l'API est un tiers : on rend la cause lisible
|
|
print(f"Cluster injoignable : {e}", file=sys.stderr)
|
|
return 2
|
|
|
|
base = source.parents[3] if source else None
|
|
routage = underlay_mod.routage_tenants(underlay_mod.charger())
|
|
lignes = constater(api, place, base, routage, ponts)
|
|
if a.verifier:
|
|
mauvais = [l for l in lignes if not l["existe"]]
|
|
if mauvais:
|
|
for l in mauvais:
|
|
print(f"placement : {l['objet']} « {l['voulu']} » introuvable"
|
|
+ (f" — {l['note']}" if l.get("note") else ""), file=sys.stderr)
|
|
return 1
|
|
print(f"Placement conforme : noeud, stockage, gabarit et reseaux des VM "
|
|
f"existent sur « {depot.name} ».")
|
|
return 0
|
|
return 0 if afficher(lignes, tenant, depot) else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|