diff --git a/CHANGELOG.md b/CHANGELOG.md index bee6bf1..e3232f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # CHANGELOG — Set-OPS +## 2026-10-11 (130) — La clé du runner du site ne transite plus par la face du locataire + +**94 preuves.** `make verifier` passe. `test_contexte.py` et `test_inventory_host.py` comptent +les nouveaux cas, et la garde de face publiée (P92) échoue si la face recommence à porter la clé. + +### Le site la posait chez lui en passant par le locataire + +Pour que le site puisse amorcer le runner d'un locataire, la clé publique du runner du site doit +être posée sur cette machine à sa naissance. `_cle_amorcage` la lisait dans le plan du site, la +face du locataire la publiait, et le site la relisait dans la face pour cloner. La clé faisait un +aller-retour, et seul le poste, qui monte les deux dépôts, pouvait calculer une face complète. +Sur le runner d'un locataire, la face recalculée perdait la clé ; l'entrée 129 avait dû cesser +de la juger là-bas. + +Le site pose maintenant sa clé lui-même en clonant (`contexte.clonage_rendu`) : sur la machine +qui porte `serveur_ops_tenant`, et sur elle seule, en tête des clés d'amorçage. C'est l'ordre +même de `_cle_amorcage`. Le locataire publie sa face sans cette clé +(`parametres_proxmox_hote(..., avec_cle_du_site=False)`). Le chemin du poste, qui clone depuis +l'instance montée, est inchangé. + +Mesuré avant de conclure : + +- pour `ops-01` des deux locataires, les paramètres que le site rend depuis la face sont + identiques, à l'octet près, à ceux de `parametres-proxmox` sur le poste ; +- `infra-dns-01` ne reçoit pas la clé du site ; +- sans le plan du site, chaque locataire calcule exactement la face qu'il a publiée. + +P93 compare désormais ce que le site clonera, clé comprise, au chemin du poste. Elle signale +aussi une face qui porterait encore la clé. Une face d'avant, qui la porte déjà, n'est pas +doublée. La console juge de nouveau la face partout : la restriction de l'entrée 129 est +retirée. + +Les deux faces ont été republiées : seule la clé d'amorçage d'`ops-01` change dans chacune. + ## 2026-10-11 (129) — Le runner d'un locataire conseillait de republier une face juste **94 preuves.** `make verifier` passe et `test_portee_console.py` compte un cas de plus. Ce diff --git a/docs/conception-contextes.md b/docs/conception-contextes.md index 2ebee46..5f75ca2 100644 --- a/docs/conception-contextes.md +++ b/docs/conception-contextes.md @@ -184,7 +184,9 @@ les publie désormais (`sources_declarees`). La frontière, en trois temps : les l'inventaire d'un locataire se génère sans son site, à l'octet près (P91). Le locataire publie sa face réseau (P92) ; les comptes de sauvegarde, le DNS public, le pare-feu Proxmox, la frontière et la découverte des locataires du site la lisent. La matérialisation aussi : la face publie les -paramètres de clonage (P93) ; `locataire-creer`, `locataire-raser` et `placement-plan TENANT=` +paramètres de clonage (P93), sauf la clé du runner du site. Le site l'ajoute lui-même en +clonant le runner du locataire (`clonage_rendu`, 2026-10-11) : elle ne transite plus par la +face, et un locataire peut calculer sa face sans le plan de son site ; `locataire-creer`, `locataire-raser` et `placement-plan TENANT=` nomment leur locataire au lieu de le monter, et visent les mêmes machines, à l'argument près de la ligne `ansible-playbook` (P94, `test_appels_locataire.py`) ; `reconstruire-locataire` les emploie. Reste : la preuve par reconstruction. diff --git a/scripts/contexte.py b/scripts/contexte.py index b53a424..e274fee 100644 --- a/scripts/contexte.py +++ b/scripts/contexte.py @@ -453,11 +453,13 @@ class Locataire(Ecosysteme): # LES PARAMETRES DE CLONAGE de chaque machine (M1 de la materialisation, 2026-10-05) : # exactement ce que `make creer-vm` evalue aujourd'hui (`parametres-proxmox`), calcule # ici par la MEME fonction. Le runner du site clonera d'apres eux, sans monter ce depot. + # SANS LA CLE DU RUNNER DU SITE (2026-10-11) : elle n'est pas au locataire, et il ne + # peut pas la lire hors du poste. Le site l'ajoute en clonant (`clonage_rendu`). from inventory_host import parametres_proxmox_hote def clonage(h: str) -> dict: try: - lignes = parametres_proxmox_hote(data, h, self.inventaire()) + lignes = parametres_proxmox_hote(data, h, self.inventaire(), avec_cle_du_site=False) except ValueError as e: return {"erreur": str(e)} out = {} @@ -1129,16 +1131,37 @@ def verifier_face_publiee(locataire: "Locataire") -> list[str]: return [] -def lignes_clonage(face: dict, hote: str) -> list[str]: - """Les lignes shell que `parametres-proxmox` rend pour cet hote — depuis la face publiee. - Meme forme, a l'octet pres : `SETOPS_X='valeur'`, une valeur pouvant tenir sur plusieurs - lignes (les cles d'amorcage). `make creer-vm TENANT=` les evalue comme les autres.""" +def clonage_rendu(machine: dict, cle_site: str) -> dict: + """Les parametres de clonage d'une machine publiee, tels que LE SITE les clone. + + LE SITE QUI CLONE POSE SA PROPRE CLE (2026-10-11). Elle passait par la face du locataire : + le poste la lisait dans le plan du site et la publiait, le site la relisait. Le runner du + locataire, sans ce plan, recalculait une face sans elle. Elle ne quitte plus le site : + sur la machine qui porte `serveur_ops_tenant`, et sur elle seule, il la met EN TETE des + cles d'amorcage — l'ordre meme de `inventory_host._cle_amorcage`, pour que les deux + chemins rendent la meme VM. Une face d'avant, qui la porte deja, n'est pas doublee. + """ + from inventory_host import GROUPE_RUNNER_TENANT + c = dict(machine.get("clonage") or {}) + if cle_site and GROUPE_RUNNER_TENANT in (machine.get("groupes") or []) and "erreur" not in c: + autres = [x for x in str(c.get("SETOPS_CLES_AMORCAGE") or "").split("\n") + if x.strip() and x.strip() != cle_site] + c["SETOPS_CLES_AMORCAGE"] = "\n".join([cle_site] + autres) + return c + + +def lignes_clonage(face: dict, hote: str, cle_site: str | None = None) -> list[str]: + """Les lignes shell que `parametres-proxmox` rend pour cet hote — depuis la face publiee, + plus la cle du runner du site monte (`clonage_rendu`). Meme forme, a l'octet pres : + `SETOPS_X='valeur'`, une valeur pouvant tenir sur plusieurs lignes (les cles + d'amorcage). `make creer-vm TENANT=` les evalue comme les autres.""" + from inventory_host import cle_runner_du_site m = (face.get("machines") or {}).get(hote) if m is None: raise SystemExit(f"Hote absent de la face reseau publiee : {hote}.") - c = m.get("clonage") or {} - if "erreur" in c: - raise SystemExit(c["erreur"]) + if "erreur" in (m.get("clonage") or {}): + raise SystemExit(m["clonage"]["erreur"]) + c = clonage_rendu(m, cle_runner_du_site() if cle_site is None else cle_site) return [f"{k}='{v}'" for k, v in c.items()] @@ -1152,6 +1175,8 @@ def verifier_clonage(locataire: "Locataire", face: dict | None = None) -> list[s `inventory_host.py parametres-proxmox` sur l'inventaire du locataire — la commande meme que `make creer-vm` evalue ? Lancee a part, pour chaque machine : un temoin independant.""" import subprocess + from inventory_host import cle_runner_du_site + cle_site = cle_runner_du_site() f = face if face is not None else (locataire.face_publiee() or {}) ecarts: list[str] = [] for h, m in sorted((f.get("machines") or {}).items()): @@ -1169,10 +1194,17 @@ def verifier_clonage(locataire: "Locataire", face: dict | None = None) -> list[s cle, _, valeur = jeton.partition("=") if cle.startswith("SETOPS_"): attendu[cle] = valeur + # CE QUE LE SITE CLONERA, pas seulement ce que la face porte : la face, plus la cle de + # son runner (`clonage_rendu`). C'est cela qui doit egaler le chemin du poste, cle + # comprise — la VM nee d'un chemin ou de l'autre doit etre la meme. publie = m.get("clonage") or {} - if publie != attendu: - diff = sorted(k for k in set(publie) | set(attendu) if publie.get(k) != attendu.get(k)) - ecarts.append(f"{h} : la face differe de parametres-proxmox ({', '.join(diff)})") + if cle_site and cle_site in str(publie.get("SETOPS_CLES_AMORCAGE") or ""): + ecarts.append(f"{h} : la face porte la cle du runner du site ; elle n'est pas au " + "locataire (le site l'ajoute en clonant) : republier la face") + rendu = clonage_rendu(m, cle_site) + if rendu != attendu: + diff = sorted(k for k in set(rendu) | set(attendu) if rendu.get(k) != attendu.get(k)) + ecarts.append(f"{h} : le clonage par la face differe de parametres-proxmox ({', '.join(diff)})") return ecarts diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 22b41a7..04537fe 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -1007,12 +1007,12 @@ def lien_du_locataire(instance: Path) -> dict: site = loc.site() except _ctx.ContexteInvalide: site = None - # LA FACE NE SE JUGE QUE LA OU LA CARTE DU SITE EST MONTEE (mesure du 2026-10-11 sur - # le runner de Technolibre). Elle porte, pour le runner du locataire, la cle du runner - # du site (`_cle_amorcage`), lue dans le plan du SITE. Sans ce plan, la face calculee - # perd cette cle : la page disait « a republier », et republier d'ici l'aurait retiree. + # LA FACE SE JUGE PARTOUT CHEZ LE LOCATAIRE. Elle a porte un temps la cle du runner du + # site, lue dans le plan du site : le runner du locataire la recalculait sans elle et la + # disait perimee (mesure du 2026-10-11 sur Technolibre). Le site pose maintenant cette + # cle lui-meme en clonant (`contexte.clonage_rendu`) : la face ne lit plus rien du site. ecarts_face = None - if site is not None and face is not None: + if face is not None: try: ecarts_face = _ctx.verifier_face_publiee(loc) except Exception as exc: # une face incalculable ne doit pas masquer la publiee @@ -1028,7 +1028,6 @@ def lien_du_locataire(instance: Path) -> dict: "hebergeur": loc.nom_hebergeur(), "face": {"publiee": face is not None, "trace": _trace_git(loc.depot, _ctx.NOM_FICHIER_FACE_RESEAU), - "jugeable": site is not None, "ecarts": ecarts_face, "resume": resume_face(face) if face else None}, "fiche": {"presente": bool(fiche), @@ -4252,7 +4251,6 @@ let selMachine = null; // MA FACE RESEAU let etatFace; if (!F.publiee) etatFace = 'jamais publiée'; - else if (!F.jugeable) etatFace = 'lue, non jugeable d\'ici'; else if ((F.ecarts || []).length) etatFace = 'à republier'; else etatFace = 'à jour avec mon plan'; const face = !F.publiee ? `
Le site ne peut rien créer ni ouvrir pour moi sans elle. diff --git a/scripts/inventory_host.py b/scripts/inventory_host.py index 6a2237e..fa21291 100644 --- a/scripts/inventory_host.py +++ b/scripts/inventory_host.py @@ -225,11 +225,15 @@ def _intrants_communs(inventaire: Path | None) -> dict: def parametres_proxmox_hote(data: dict, host: str, - inventaire: Path | None = None) -> list[str]: + inventaire: Path | None = None, + avec_cle_du_site: bool = True) -> list[str]: """Emet les parametres de clonage Proxmox d'un hote, lus depuis l'inventaire. Sortie shell-evaluable (`SETOPS_*='valeur'`) consommee par `make creer-vm`. L'inventaire genere est la seule source : aucun parametre n'est saisi a la main. + + `avec_cle_du_site=False` : ce que le LOCATAIRE publie dans sa face. La cle du runner du + site n'est pas a lui ; c'est le site qui l'ajoute en clonant (`contexte.clonage_rendu`). """ if not groupes_hote(data, host): raise ValueError( @@ -285,7 +289,7 @@ def parametres_proxmox_hote(data: dict, host: str, # La cle vient du PLAN DU SITE, pas du disque local : on materialise depuis le runner # comme depuis le poste de l'exploitant, et un `lookup` local rendrait deux valeurs # differentes selon qui agit — la VM nee du poste n'accepterait pas le runner. - lignes.append(f"SETOPS_CLES_AMORCAGE='{_cle_amorcage(data, host, inventaire)}'") + lignes.append(f"SETOPS_CLES_AMORCAGE='{_cle_amorcage(data, host, inventaire, avec_cle_du_site)}'") return lignes @@ -293,7 +297,24 @@ def parametres_proxmox_hote(data: dict, host: str, GROUPE_RUNNER_TENANT = "serveur_ops_tenant" -def _cle_amorcage(data: dict, host: str, inventaire: Path | None = None) -> str: +def cle_runner_du_site() -> str: + """La cle publique du runner du SITE MONTE (`runner_cle_publique`), ou ''. + + LE SITE QUI CLONE POSE SA PROPRE CLE (2026-10-11). Elle transitait par la face du + locataire : lue ici dans le plan du site, publiee dans la face, relue par le site. Le + runner du locataire, qui n'a pas ce plan, calculait donc une face sans elle — et la + republier de la-bas l'aurait retiree. Elle ne quitte plus le site. + """ + try: + import underlay as underlay_mod + return str((underlay_mod.lire_plan_site("10-intrants.yml") or {}) + .get("runner_cle_publique") or "").strip() + except Exception: + return "" + + +def _cle_amorcage(data: dict, host: str, inventaire: Path | None = None, + avec_cle_du_site: bool = True) -> str: """Les cles publiques a poser sur cet hote A SA NAISSANCE. Une par ligne. DEUX CLES, DEUX PORTEES, ET LA DIFFERENCE EST TOUTE L'ARCHITECTURE : @@ -323,16 +344,12 @@ def _cle_amorcage(data: dict, host: str, inventaire: Path | None = None) -> str: """ cles: list[str] = [] - # 1. LA CLE DU SITE — le runner du tenant, et lui seul. - if GROUPE_RUNNER_TENANT in groupes_hote(data, host): - try: - import underlay as underlay_mod - c = str((underlay_mod.lire_plan_site("10-intrants.yml") or {}) - .get("runner_cle_publique") or "").strip() - if c: - cles.append(c) - except Exception: - pass + # 1. LA CLE DU SITE — le runner du tenant, et lui seul. Absente de ce que le locataire + # publie (`avec_cle_du_site=False`) : le site l'ajoute lui-meme en clonant. + if avec_cle_du_site and GROUPE_RUNNER_TENANT in groupes_hote(data, host): + c = cle_runner_du_site() + if c: + cles.append(c) # 2. LA CLE DU TENANT — toutes ses machines, y compris son runner (il se joint # lui-meme comme les autres, et l'exclure creerait un cas particulier de plus). diff --git a/scripts/tests/test_contexte.py b/scripts/tests/test_contexte.py index 2c8bf57..fc82fed 100644 --- a/scripts/tests/test_contexte.py +++ b/scripts/tests/test_contexte.py @@ -683,6 +683,46 @@ def materialisation_par_la_face() -> None: verifier(any("placement" in x for x in e), "une valeur de placement alteree se voit") +def cle_du_site_posee_par_le_site() -> None: + """LA CLE DU RUNNER DU SITE NE TRANSITE PLUS PAR LA FACE (2026-10-11). + + Le site l'ajoute en clonant, sur le runner du locataire et sur lui seul, en tete — et + une face d'avant qui la porte n'est pas doublee. Puis, sur les vrais depots : un + locataire sans le plan de son site calcule exactement la face publiee. C'est le defaut + mesure sur le runner de Technolibre : il la recalculait sans la cle, et la disait perimee. + """ + runner = {"groupes": ["serveur_ops", "serveur_ops_tenant"], + "clonage": {"SETOPS_VMID": "1", "SETOPS_CLES_AMORCAGE": "ssh-ed25519 LOC loc"}} + autre = {"groupes": ["serveur_debian"], + "clonage": {"SETOPS_VMID": "2", "SETOPS_CLES_AMORCAGE": "ssh-ed25519 LOC loc"}} + cle = "ssh-ed25519 SITE site" + verifier(C.clonage_rendu(runner, cle)["SETOPS_CLES_AMORCAGE"] == cle + "\nssh-ed25519 LOC loc", + "le site pose sa cle en tete sur le runner du locataire") + verifier(C.clonage_rendu(autre, cle) == autre["clonage"], "une machine ordinaire ne la recoit pas") + deja = {"groupes": runner["groupes"], + "clonage": {"SETOPS_CLES_AMORCAGE": cle + "\nssh-ed25519 LOC loc"}} + verifier(C.clonage_rendu(deja, cle) == deja["clonage"], "une face d'avant n'est pas doublee") + verifier(C.clonage_rendu(runner, "") == runner["clonage"], "un site sans runner declare n'ajoute rien") + + couples = C.couples_hebergeur() + if not couples: + print("SAUTE aucun site frere : face sans le site non confrontee") + return + import underlay as _u + avant = os.environ.get("SETOPS_UNDERLAY") + os.environ["SETOPS_UNDERLAY"] = "/inexistant/underlay.yml" + try: + verifier(not _u.lire_plan_site("10-intrants.yml"), "aucun plan de site n'est lu (temoin)") + for _s, loc in couples: + verifier(loc.face_reseau() == loc.face_publiee(), + f"{loc.nom} : sans le plan du site, la face calculee est la face publiee") + finally: + if avant is None: + os.environ.pop("SETOPS_UNDERLAY", None) + else: + os.environ["SETOPS_UNDERLAY"] = avant + + def main() -> int: with tempfile.TemporaryDirectory() as tmp: d = Path(tmp) @@ -706,6 +746,7 @@ def main() -> int: pools_complets() pools_et_tunnels_lisent_la_face() clonage_publie() + cle_du_site_posee_par_le_site() materialisation_par_la_face() if ECHECS: print(f"\n{len(ECHECS)} echec(s).") diff --git a/scripts/tests/test_inventory_host.py b/scripts/tests/test_inventory_host.py index 0cc6329..a172514 100644 --- a/scripts/tests/test_inventory_host.py +++ b/scripts/tests/test_inventory_host.py @@ -204,6 +204,28 @@ def test_champ_requis_manquant_refuse() -> None: raise AssertionError("un champ requis manquant doit lever ValueError") +def test_la_face_du_locataire_ne_porte_pas_la_cle_du_site() -> None: + """LA CLE DU SITE NE QUITTE PLUS LE SITE (2026-10-11). Ce que le locataire publie + (`avec_cle_du_site=False`) ne la porte pas, meme sur son runner : c'est le site qui + l'ajoute en clonant. Sa propre cle, elle, reste.""" + import inventory_host as ih + import underlay as u + + inv = _inventaire_factice() + inv["all"]["children"]["serveur_ops_tenant"] = {"hosts": {"app-01": None}} + cle = "ssh-ed25519 AAAASITE site@runner" + original = u.lire_plan_site + u.lire_plan_site = lambda nom: {"runner_cle_publique": cle} + try: + assert ih._cle_amorcage(inv, "app-01") == cle, "le chemin du poste la pose toujours" + assert ih._cle_amorcage(inv, "app-01", avec_cle_du_site=False) == "", \ + "la face du locataire ne doit pas la porter" + lignes = ih.parametres_proxmox_hote(inv, "app-01", avec_cle_du_site=False) + assert "SETOPS_CLES_AMORCAGE=''" in lignes, lignes + finally: + u.lire_plan_site = original + + def main() -> int: # LA LISTE EST EXPLICITE, DONC ELLE PEUT OUBLIER. Deux tests ecrits le 2026-08-28 # ont ete definis sans etre inscrits ici : ils passaient pour verts en n'etant @@ -216,6 +238,7 @@ def main() -> int: test_etiquette_vlan_repli_et_vide_explicite, test_cle_d_amorcage_seulement_pour_le_runner_du_tenant, test_cle_d_amorcage_absente_du_site_ne_casse_rien, + test_la_face_du_locataire_ne_porte_pas_la_cle_du_site, test_hote_sans_noeud_emet_noeud_vide, test_intrant_commun_lu_a_cote_de_l_inventaire_recu, test_hote_absent_refuse, diff --git a/scripts/tests/test_portee_console.py b/scripts/tests/test_portee_console.py index 4697229..02076f1 100644 --- a/scripts/tests/test_portee_console.py +++ b/scripts/tests/test_portee_console.py @@ -141,22 +141,6 @@ class TestPorteeDeLaConsole(unittest.TestCase): self.assertIsNone(l["fiche"]["ecarts"], "la fiche d'un autre site ne se juge pas ici") self.assertEqual(l["index_attribue"], 18) - def test_le_runner_du_locataire_ne_juge_pas_sa_face(self): - """LE DEFAUT, NOMME (2026-10-11, runner de Technolibre). La face porte la cle du - runner du site, lue dans le plan du SITE. Le runner du locataire ne l'a pas : il - recalculait une face sans elle, la disait « a republier », et republier d'ici - l'aurait retiree.""" - loc = self.dossier / "OPS-Essai" - (loc / "plan").mkdir(parents=True) - (loc / "plan" / "nomenclature.yml").write_text("index: 18\n", encoding="utf-8") - (loc / "face-reseau.yml").write_text( - "locataire: OPS-Essai\nhebergeur: SITE-Absent\nindex: 18\nmachines: {}\n", - encoding="utf-8") - lien = gui.lien_du_locataire(loc) - self.assertTrue(lien["face"]["publiee"]) - self.assertFalse(lien["face"]["jugeable"]) - self.assertIsNone(lien["face"]["ecarts"], "sans la carte du site, aucun verdict") - if __name__ == "__main__": unittest.main(verbosity=2)