From 3af607e8b592e1ff2d435d436b8b42949c3b0306 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Thu, 13 Aug 2026 10:11:25 -0400 Subject: [PATCH] =?UTF-8?q?placement=20:=20un=20devis=20confronte=20les=20?= =?UTF-8?q?QUATRE=20objets=20au=20cluster=20=E2=80=94=20dont=20le=20gabari?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 41 +++++++++ Makefile | 5 +- docs/audit/preuve-2026-08-13.md | 2 +- scripts/devis_placement.py | 152 ++++++++++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 scripts/devis_placement.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7105df7..74776f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Makefile b/Makefile index 6693f36..0831ab7 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/docs/audit/preuve-2026-08-13.md b/docs/audit/preuve-2026-08-13.md index 472db39..a67d3ec 100644 --- a/docs/audit/preuve-2026-08-13.md +++ b/docs/audit/preuve-2026-08-13.md @@ -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)). | diff --git a/scripts/devis_placement.py b/scripts/devis_placement.py new file mode 100644 index 0000000..8f27ddc --- /dev/null +++ b/scripts/devis_placement.py @@ -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())