placement : un devis confronte les QUATRE objets au cluster — dont le gabarit

J'avais ecarte le gabarit de P37 (« objet du cluster, pas une liste declaree »).
L'exploitant a releve que ce n'etait pas une raison de ne pas le verifier : ca
deplace la question du STATIQUE vers le DEVIS.

devis_placement.py interroge le cluster et confronte les quatre valeurs : le noeud
existe ; le stockage existe ET accepte `images` ; le pont existe SUR LE NOEUD
retenu ; le gabarit existe ET porte template=1 — cloner une VM ordinaire
fonctionnerait, et produirait quatorze copies d'une machine vivante.

AU PREMIER PASSAGE il a trouve une declaration perimee : vmbr3 n'existe plus sur
aucun noeud (disparu au passage du transport VXLAN sur les interfaces VLAN), mais
proxmox-hebergeur.yml le declarait encore depuis le 3 aout — et P37 le validait,
puisqu'elle valide la DECLARATION, pas le cluster.

Invisible jusqu'ici : flotte-creer surcharge le pont avec le VNet derive de chaque
zone, donc le defaut n'aurait morde que sur un `make cloner-vm` manuel. Corrige
des deux cotes ; le defaut des trois tenants passe a vmbr1, que le cluster nomme
lui-meme « VM (5Gig) ».

P37 et ce devis ne se remplacent pas : l'un garde la coherence entre deux
declarations, l'autre confronte la declaration au reel. Il fallait les deux pour
voir un pont disparu depuis dix jours.

P31 a refuse le script tant qu'aucune cible make ne l'atteignait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-13 10:11:25 -04:00
parent 199783f5fd
commit 3af607e8b5
4 changed files with 198 additions and 2 deletions

View file

@ -1,5 +1,46 @@
# CHANGELOG — Set-OPS
## 2026-08-13 — `make placement-plan` : et le gabarit, justement
J'avais écarté le gabarit de P37 — « objet du cluster, pas une liste déclarée, donc pas
vérifiable statiquement ». L'exploitant a relevé que ce n'était pas une raison de ne pas
le vérifier : ça déplace la question du **statique** vers le **devis**.
`devis_placement.py` interroge donc le cluster et confronte **les quatre** valeurs :
```
noeud le nœud existe-t-il
stockage existe-t-il ET accepte-t-il le contenu `images`
pont existe-t-il SUR LE NŒUD retenu (un pont partiel est un piège)
gabarit le VMID existe-t-il ET porte-t-il `template=1`
```
Ce dernier contrôle compte : cloner une VM ordinaire *fonctionnerait*, et produirait
quatorze copies d'une machine vivante.
### Au premier passage, il a trouvé une déclaration périmée
```
ponts réels sur les 3 nœuds vmbr0, vmbr1, vmbr2
proxmox-hebergeur.yml vmbr0, vmbr1, vmbr2, vmbr3
```
**`vmbr3` n'existe plus** — disparu au passage du transport VXLAN sur les interfaces VLAN.
La déclaration du 3 août a survécu à sa disparition, et **P37 la validait** : elle valide
la déclaration, pas le cluster.
Invisible jusqu'ici parce que `flotte-creer` surcharge le pont avec le VNet dérivé de
chaque zone — le défaut n'aurait mordu que sur un `make cloner-vm` manuel. Corrigé des
deux côtés : la liste de l'hébergeur, et le défaut des trois tenants vers **`vmbr1`**, que
le cluster nomme lui-même « VM (5Gig) ».
> **Ce que ça démontre.** P37 et ce devis ne se remplacent pas : l'un garde la cohérence
> entre deux déclarations, l'autre confronte la déclaration au réel. Il fallait les deux
> pour voir un pont qui n'existait plus depuis dix jours.
Et P31 a refusé le script tant qu'aucune cible `make` ne l'atteignait.
## 2026-08-13 — D-80 : un tenant est agnostique de son underlay, à trois clés près
Formulation de l'exploitant, meilleure que celle du dépôt. Le commentaire disait *« la

View file

@ -363,7 +363,10 @@ proxmox-fw-appliquer: ansible-runtime ## Reconcilie le pare-feu est-ouest : IPSe
exit 2; fi
CONFIRMER=true python3 scripts/appliquer_proxmox_fw.py
.PHONY: sdn-plan sdn-appliquer
.PHONY: placement-plan sdn-plan sdn-appliquer
placement-plan: ansible-runtime ## Le noeud, stockage, pont et gabarit du tenant existent-ils sur ce cluster ? (aucune ecriture)
python3 scripts/devis_placement.py
sdn-plan: ansible-runtime ## Ecart entre le SDN EVPN (+ sortie des VRF) et son devis (aucune ecriture)
python3 scripts/appliquer_sdn.py

View file

@ -43,7 +43,7 @@
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 2 pool(s) Proxmox, 28 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 23 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 12, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 44 scripts expliques et atteignables, 92 cibles make documentees, 54 roles avec README. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 45 scripts expliques et atteignables, 93 cibles make documentees, 54 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 35 exigence(s) de role, toutes satisfaites (126 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 32 revendication(s) de port, aucune collision entre roles co-localises (33 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 39 document(s) declarent leur lecteur (16 genere(s) exempte(s)). |

152
scripts/devis_placement.py Normal file
View file

@ -0,0 +1,152 @@
#!/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 sys
from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(RACINE / "scripts"))
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 MONTE."""
for nom in INVENTAIRES:
f = RACINE / "instance" / "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 constater(api: Cluster, place: dict) -> list[dict]:
"""Confronte chaque valeur au cluster. Renvoie une ligne par objet."""
lignes: list[dict] = []
noeuds = [n.get("node") for n in (api("/nodes") or [])]
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 (api("/storage") or []):
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 pont doit exister SUR LE NOEUD retenu : un pont partiel est un piege, la VM ne
# demarre que sur certains noeuds et l'erreur ne le dit pas.
n = place.get("proxmox_clone_noeud")
ponts: list[str] = []
if n in noeuds:
ponts = [i.get("iface") for i in (api(f"/nodes/{n}/network") or [])
if str(i.get("type")) in ("bridge", "OVSBridge")]
voulu = place.get("proxmox_clone_pont")
lignes.append({"objet": "pont", "voulu": voulu,
"existe": voulu in ponts, "offre": ponts,
"note": "" if n in noeuds else "noeud introuvable : pont non verifiable"})
# 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 (api("/cluster/resources?type=vm") or [])}
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 afficher(lignes: list[dict], source: Path | None, depot: Path) -> bool:
print(f"Devis du placement — tenant « {source.parents[3].name if source else '?'} » "
f"sur l'hebergeur « {depot.name} »\n")
print(f" {'OBJET':<10} {'DEMANDE':<22} {'VERDICT'}")
ok = True
for l in lignes:
verdict = "existe" if l["existe"] else "INTROUVABLE"
ok = ok and l["existe"]
print(f" {l['objet']:<10} {str(l['voulu']):<22} {verdict}")
if l.get("note"):
print(f" {'':<10} {'':<22} -> {l['note']}")
if not l["existe"] and l["offre"]:
offert = ", ".join(str(x) for x in l["offre"][:6])
print(f" {'':<10} {'':<22} offert : {offert}")
print("\n" + ("CONFORME : les quatre objets de placement existent sur ce cluster."
if ok else
"ECART : le tenant designe des 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")
a = ap.parse_args()
place, source = placement_du_tenant()
if not place:
print("Aucun placement declare par le tenant monte — 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
lignes = constater(api, place)
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, pont et gabarit existent sur "
f"« {depot.name} ».")
return 0
return 0 if afficher(lignes, source, depot) else 1
if __name__ == "__main__":
sys.exit(main())