materialisation : le site pose lui-meme la cle de son runner en clonant

La cle du runner du site transitait par la face du locataire : lue dans le plan du
site, publiee dans la face, relue par le site. Le runner d'un locataire, sans ce
plan, recalculait une face sans elle. La face ne la porte plus ; le site l'ajoute en
clonant la machine qui porte serveur_ops_tenant, et elle seule, en tete des cles
(clonage_rendu). Le clonage par la face reste identique a l'octet pres au chemin du
poste (P93 le compare cle comprise). La console juge de nouveau la face partout.

94 preuves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-11 18:43:58 -04:00
parent 1d582045f3
commit 0d5ec924cc
8 changed files with 179 additions and 48 deletions

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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 = '<span class="etiq e-bloq">jamais publiée</span>';
else if (!F.jugeable) etatFace = '<span class="etiq e-recon" title="Le calcul de la face lit le plan du site (la clé de son runner), absent de cette machine. Elle se juge et se republie depuis le poste du mainteneur.">lue, non jugeable d\'ici</span>';
else if ((F.ecarts || []).length) etatFace = '<span class="etiq e-div">à republier</span>';
else etatFace = '<span class="etiq e-actif">à jour avec mon plan</span>';
const face = !F.publiee ? `<div class="corps"><div class="note-contrat">Le site ne peut rien créer ni ouvrir pour moi sans elle.

View file

@ -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).

View file

@ -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).")

View file

@ -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,

View file

@ -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)