From a204ead8aba3a1176b8a9e2237d4806b043cccc8 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Wed, 7 Oct 2026 10:16:04 -0400 Subject: [PATCH] pre-vol M4 : raser et le devis de placement nomment le locataire designe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 25 ++++++++++++++++++++++ scripts/devis_placement.py | 22 ++++++++++++++------ scripts/raser.py | 16 ++++++++++++-- scripts/tests/test_devis_placement.py | 15 ++++++++++++++ scripts/tests/test_raser.py | 30 +++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c550666..00c09f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # CHANGELOG — Set-OPS +## 2026-10-07 (100) — Pré-vol de M4 : la face tient devant le cluster, deux en-têtes disaient faux + +**Le pré-vol**, lancé par l'exploitant depuis le runner du site, en lecture seule : +`make locataire-raser TENANT=OPS-Technolibre` (sans `CONFIRMER`) et +`make placement-plan TENANT=OPS-Technolibre`. C'est la **première confrontation de la face au +vrai cluster**, celle que M2 n'avait pas pu faire depuis le poste (errno 111). Résultat : +13 VM, chacune sous son nom, **aucun conflit** ; placement conforme (`asgard`, `TrueNAS`, +gabarit `99998`, les six réseaux `t23*`). Rien n'a été touché. + +**Deux en-têtes disaient faux** sur le chemin `TENANT=`, sans rien changer à ce qui est visé : +- `raser.py` annonçait « Écosystème **monté** » sur le runner du site, où rien n'est monté. Il + dit désormais « Locataire **nommé** … d'après sa face publiée » (`entete()`), et le refus + faute de nom dit « Nomme » au lieu de « Monte ». +- `devis_placement.py` annonçait « tenant « **?** » » : avec `--locataire`, aucun fichier + source d'où tirer le nom. `nom_du_tenant()` rend celui qu'on a désigné. + +Corrigés **avant** M4, pour que la reconstruction couvre le commit qu'on étiquettera. + +**Éprouvé** : +- `test_raser.py` juge l'en-tête sur la **sortie réelle** de `raser.main`, aux deux chemins, + face à un faux cluster qui rend exactement les machines publiées. +- `test_devis_placement.py` : le nom désigné, le nom tiré de la source, et l'en-tête affiché. +- Témoins : les anciens libellés remis en place, les deux tests échouent (« EN-TETE TROMPEUR », + puis l'assertion du devis). + ## 2026-10-05 (99) — Matérialisation, M3 : la reconstruction nomme son locataire, et le pool suit **Un trou dans M2, trouvé avant tout usage.** `locataire-creer` comparait les paramètres de diff --git a/scripts/devis_placement.py b/scripts/devis_placement.py index 3eb8f51..1cb0672 100644 --- a/scripts/devis_placement.py +++ b/scripts/devis_placement.py @@ -195,10 +195,19 @@ def constater(api: Cluster, place: dict, base: Path | None = None, return lignes -def afficher(lignes: list[dict], source: Path | None, depot: Path) -> bool: - # `.resolve()` : sans lui, un tenant monte par symlink s'annonce « instance » — le nom - # du lien, pas celui du tenant. Un devis doit nommer ce qu'il a mesure. - tenant = source.parents[3].resolve().name if source else "?" +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 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 @@ -237,8 +246,9 @@ def main() -> int: ponts = contexte.ponts_publies(face) else: place, source = placement_du_tenant() + tenant = nom_du_tenant(source, a.locataire) if not place: - print("Aucun placement declare par le tenant monte — rien a confronter.") + print(f"Aucun placement declare par « {tenant} » — rien a confronter.") return 0 try: api, depot = Cluster.depuis_hebergeur() @@ -259,7 +269,7 @@ def main() -> int: print(f"Placement conforme : noeud, stockage, gabarit et reseaux des VM " f"existent sur « {depot.name} ».") return 0 - return 0 if afficher(lignes, source, depot) else 1 + return 0 if afficher(lignes, tenant, depot) else 1 if __name__ == "__main__": diff --git a/scripts/raser.py b/scripts/raser.py index 5bdce6d..2573726 100755 --- a/scripts/raser.py +++ b/scripts/raser.py @@ -69,6 +69,18 @@ def couples_publies(locataire: str) -> tuple[Path, list[tuple[str, int]]]: return loc.depot, contexte.couples_a_raser(face) +def entete(nom: str, base: Path, nomme: bool) -> str: + """Ce que le devis vise, et COMMENT il le designe (2026-10-07). + + Le chemin `--locataire` annoncait « Ecosysteme monte » sur le runner du site, ou rien + n'est monte : le locataire y est NOMME, et sa face publiee fait foi. Un devis qui raconte + une autre provenance que la sienne fait douter de tout le reste de la liste. + """ + if nomme: + return f"Locataire nommé : « {nom} » ({base}), d'après sa face publiée" + return f"Écosystème monté : « {nom} » ({base})" + + def plan_derive(base: Path) -> list[tuple[str, int]]: """(nom d'hote, vmid) tels que le PLAN les derive — la seule source légitime.""" env = dict(os.environ, SETOPS_INSTANCE=str(base)) @@ -153,7 +165,7 @@ def main(argv: list[str] | None = None) -> int: else: presentes.append((nom, vmid, v.get("node"))) - print(f"Écosystème monté : « {nom_actif} » ({base})\n") + print(entete(nom_actif, base, bool(a.locataire)) + "\n") for nom, vmid, noeud in presentes: print(f" À DÉTRUIRE {vmid} {nom:16} sur {noeud}") for nom, vmid in absentes: @@ -180,7 +192,7 @@ def main(argv: list[str] | None = None) -> int: if (a.instance or "").lower() != nom_actif: print(f"\nREFUS : il faut NOMMER l'ecosysteme a raser.\n" - f"Monte : « {nom_actif} » ; recu : « {a.instance or '(rien)'} ».\n" + f"{'Nomme' if a.locataire else 'Monte'} : « {nom_actif} » ; recu : « {a.instance or '(rien)'} ».\n" f"Taper le nom est ce qui distingue le POC de la production.") return 2 diff --git a/scripts/tests/test_devis_placement.py b/scripts/tests/test_devis_placement.py index 953c479..392b606 100644 --- a/scripts/tests/test_devis_placement.py +++ b/scripts/tests/test_devis_placement.py @@ -102,6 +102,21 @@ def test_le_reseau_mesure_est_celui_ou_les_VM_atterrissent(tmp=None): "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: diff --git a/scripts/tests/test_raser.py b/scripts/tests/test_raser.py index 5d82083..27ba373 100644 --- a/scripts/tests/test_raser.py +++ b/scripts/tests/test_raser.py @@ -55,3 +55,33 @@ print(f"\n>>> instance montee : {NOM} | collision fabriquee sur {VMID_1} ({NOM print(f">>> code de sortie : {code} (2 = refus attendu)") assert code == 2, "LE VERROU N'A PAS TENU" print(">>> le verrou tient : aucune VM n'aurait ete touchee") + +# L'EN-TETE DIT D'OU VIENT LA LISTE (2026-10-07). Sur le runner du site, `--locataire` +# annoncait « Ecosysteme monte » — rien n'y est monte, le locataire est NOMME et sa face fait +# foi. Lu au pre-vol de M4, avant de raser : un devis qui se trompe de provenance fait douter +# de la liste. Le cluster rend ici exactement les machines publiees : rien n'est refuse, et +# sans `--confirmer` rien n'est fait — seul l'en-tete est juge, sur la sortie reelle. +import contextlib # noqa: E402 +import io # noqa: E402 + +try: + _, COUPLES_FACE = raser.couples_publies(BASE.name) +except (SystemExit, Exception) as e: # noqa: BLE001 — une instance sans face publiee + COUPLES_FACE = [] + print(f">>> SAUTE l'en-tete du chemin nomme : {e}") +if COUPLES_FACE: + class ClusterConforme(FauxCluster): + def __call__(self, chemin, methode="GET", corps=None): + return [{"type": "qemu", "vmid": v, "name": n, "node": "asgard"} + for n, v in COUPLES_FACE] + + raser.Cluster = ClusterConforme + for args, attendu, banni in ((["--locataire", BASE.name], "Locataire nommé", "monté"), + ([], "Écosystème monté", "nommé")): + sortie = io.StringIO() + with contextlib.redirect_stdout(sortie): + code = raser.main(args) + entete = sortie.getvalue().splitlines()[0] + assert code == 0, f"code {code} sur un cluster conforme ({' '.join(args) or 'instance'})" + assert attendu in entete and banni not in entete, f"EN-TETE TROMPEUR : {entete!r}" + print(f">>> {' '.join(args) or 'instance montee'} : {entete}")