diff --git a/CHANGELOG.md b/CHANGELOG.md index 7181c79..37a326d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # CHANGELOG — Set-OPS +## 2026-10-05 (96) — Étape 3 : les pools et les tunnels d'administration lisent la face réseau + +**Le chemin** (`docs/conception-contextes.md` §6), étape 3, matérialisation, premiers temps : +les deux devis du site qui lisaient encore le plan des locataires. + +**Fait** : +- la face publie l'**état** de chaque machine (actif ou planifié, lu dans l'inventaire) et + les **pairs WireGuard entiers** (`vpn_admin` les reprend tels quels ; des clés publiques et + des adresses, rien de secret) ; faces republiées ; +- `devis_proxmox_pools.py` prend dans la face le nom, le VMID et l'état de chaque machine, au + lieu de relire `plan/serveurs.yml` et de re-dériver les VMID ; +- `vpn_admin.py` prend dans la face les pairs et l'index, au lieu de `plan/acces.yml` et de la + nomenclature. Sans face, l'ancienne lecture, et chacun le dit. + +**Le critère, comparé comme il faut** (leçon de (95)) : un `git worktree` à `2e9b31b`, arbre +complet, même environnement. Identiques octet pour octet : le plan des tunnels +(`tunnels()` et `plan_vpn()`), les pools, la frontière, Proxmox, le SDN, le commutateur, +l'inventaire du site. + +**Témoins qui doivent bouger** (`test_contexte.py`, 107 contrôles) : un VMID altéré dans la +face est repris par le devis des pools ; des pairs retirés de la face retirent le tunnel du +plan (1 pair, puis 0). + +**Validation** : `make verifier` conforme, 92/92. + +**Reste de la matérialisation** : les outils du poste qui agissent sur le locataire monté +(placement, configuration Proxmox, clonage, rasage) lisent son inventaire. Décider qui +matérialise — le poste sur l'inventaire du locataire, ou le site sur sa face — est une +question pour l'exploitant. + ## 2026-10-05 (95) — Une régression publiée : le devis des pools vide, et une comparaison qui ne testait rien **Ce qui s'est passé.** (94) a fait passer la découverte des locataires par la face réseau, qui diff --git a/scripts/contexte.py b/scripts/contexte.py index 531e3ae..30879f2 100644 --- a/scripts/contexte.py +++ b/scripts/contexte.py @@ -449,8 +449,10 @@ class Locataire(Ecosysteme): ips = _ip_par_hote(data) if data else {} groupes = {g: sorted(h for h in _hotes_du_groupe(data, g) if h in ips) for g in sorted(_enfants(data)) if est_groupe_operationnel(g)} if data else {} + actifs = set(_hotes_du_groupe(data, "hotes_actifs")) if data else set() machines = { h: {"ip": ip, + "etat": "actif" if h in actifs else "planifie", "groupes": sorted(g for g, membres in groupes.items() if h in membres), "materialisation": {k: v for k, v in sorted((hv.get(h) or {}).items()) if k.startswith("proxmox_")}} @@ -551,8 +553,10 @@ class Locataire(Ecosysteme): "administration": {"intrant": intrant, "tunnel": tunnel_admin_de(self.depot), "port_tunnel": (port_vpn_locataire(self.index) if tunnel_admin_de(self.depot) else None), - "pairs": {n: {k: c.get(k) for k in ("cle_publique", "adresse", "etat")} - for n, c in sorted(pairs.items()) if isinstance(c, dict)}}, + # Les pairs ENTIERS : `vpn_admin` les reprend tels quels. Des cles + # PUBLIQUES et des adresses — rien de secret. + "pairs": {n: dict(c) for n, c in sorted(pairs.items()) + if isinstance(c, dict)}}, "dns_public": dns_public, "sauvegarde": {"cle_publique": cle_sauvegarde}, "flux": flux, diff --git a/scripts/devis_proxmox_pools.py b/scripts/devis_proxmox_pools.py index 9f0fa12..b7d8bb3 100644 --- a/scripts/devis_proxmox_pools.py +++ b/scripts/devis_proxmox_pools.py @@ -59,6 +59,19 @@ def _plan_de(nom_instance: str) -> dict: return charger_serveurs(p).get("serveurs") or {} +def _face_publiee(nom: str) -> dict | None: + """La face reseau que ce locataire a publiee, ou None (et on le dit).""" + try: + import contexte + face = contexte.Locataire(DOSSIER_INSTANCES / nom).face_publiee() + except Exception: + face = None + if face is None: + print(f"note : {nom} n'a pas publie sa face reseau — lecture de son plan (transition).", + file=sys.stderr) + return face + + def pool_actif() -> str: """Pool du tenant ACTIF — ce que le playbook de clonage passe a la creation. @@ -82,6 +95,19 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: for nom_instance, _prefixe, nomenclature in tenants: index = int(nomenclature["index"]) membres, sans_vmid = [], [] + # LA FACE RESEAU D'ABORD (2026-10-05, docs/conception-contextes.md §6, etape 3) : le + # locataire publie ses machines, leur VMID et leur etat ; le devis ne lit plus son plan. + face = _face_publiee(nom_instance) + if face is not None: + for nom, m in sorted((face.get("machines") or {}).items()): + vmid = (m.get("materialisation") or {}).get("proxmox_vmid") + if vmid is None: + sans_vmid.append(nom) + continue + membres.append({"nom": nom, "vmid": int(vmid), "etat": str(m.get("etat", "planifie"))}) + blocs.append({"pool": pool_de(nom_instance, index), "tenant": nom_instance, + "index": index, "membres": membres, "sans_vmid": sans_vmid}) + continue for nom, srv in sorted(_plan_de(nom_instance).items()): _, seq = fonction_seq(nom) derive = deriver_nomenclature(str(srv.get("fonction", "")), seq, nomenclature) or {} diff --git a/scripts/tests/test_contexte.py b/scripts/tests/test_contexte.py index 0bff86d..cac093b 100644 --- a/scripts/tests/test_contexte.py +++ b/scripts/tests/test_contexte.py @@ -598,6 +598,44 @@ def pools_complets() -> None: f"pool {loc.nom} : {len(b.get('membres') or [])} VM sur {attendu} publiees, aucune sans VMID") +def pools_et_tunnels_lisent_la_face() -> None: + """Les pools et les tunnels d'administration lisent la face publiee : l'alterer les change.""" + import devis_proxmox_pools + import devis_reseau + import vpn_admin + import yaml as _yaml + couples = C.couples_hebergeur() + if not couples: + print("SAUTE aucun site frere : ni pool ni tunnel a confronter") + return + site = couples[0][0] + loc = next((l for _s, l in couples if (l.face_publiee() or {}).get("administration", {}).get("pairs")), + couples[0][1]) + chemin = loc.depot / C.NOM_FICHIER_FACE_RESEAU + original = chemin.read_text(encoding="utf-8") + + def releve(): + with C._monter(site): + pool = next(b for b in devis_proxmox_pools.construire(devis_reseau.decouvrir_du_site())["pools"] + if b["tenant"] == loc.nom) + tun = next((x for x in vpn_admin.tunnels(True) if x.get("portee") == loc.nom), {}) + return sorted(m["vmid"] for m in pool["membres"]), sorted(tun.get("pairs") or {}) + + vmids, pairs = releve() + try: + f = _yaml.safe_load(original) + premier = sorted(f["machines"])[0] + f["machines"][premier]["materialisation"]["proxmox_vmid"] = 999999999 + f["administration"]["pairs"] = {} + chemin.write_text(_yaml.safe_dump(f, allow_unicode=True, sort_keys=False), encoding="utf-8") + vmids2, pairs2 = releve() + finally: + chemin.write_text(original, encoding="utf-8") + verifier(999999999 in vmids2 and 999999999 not in vmids, "le devis des pools lit la face publiee (VMID altere repris)") + verifier(bool(pairs) and not pairs2, + f"les tunnels d'administration lisent la face publiee ({len(pairs)} pair(s), puis 0)") + + def main() -> int: with tempfile.TemporaryDirectory() as tmp: d = Path(tmp) @@ -619,6 +657,7 @@ def main() -> int: frontiere_lit_la_face() decouverte_lit_la_face() pools_complets() + pools_et_tunnels_lisent_la_face() if ECHECS: print(f"\n{len(ECHECS)} echec(s).") return 1 diff --git a/scripts/vpn_admin.py b/scripts/vpn_admin.py index f3afcd8..61bba9a 100644 --- a/scripts/vpn_admin.py +++ b/scripts/vpn_admin.py @@ -62,8 +62,21 @@ def tunnels(inclure_vides: bool = False) -> list[dict]: "pairs": {str(p["nom"]): p for p in (site.get("pairs") or [])}}) for nom_depot, _prefixe, _n in devis_reseau.decouvrir_du_site(): base = RACINE.parent / nom_depot - registre = charger_acces(base / "plan" / "acces.yml") - index = charger_nomenclature(base / "plan" / "nomenclature.yml").get("index") + # LA FACE RESEAU D'ABORD (2026-10-05, etape 3) : le locataire publie ses pairs et son + # index ; le site ne lit plus son `plan/acces.yml` ni sa nomenclature. + try: + import contexte + face = contexte.Locataire(base).face_publiee() + except Exception: + face = None + if face is not None: + registre = {"acces_admin_vpn": dict((face.get("administration") or {}).get("pairs") or {})} + index = face.get("index") + else: + print(f"note : {nom_depot} n'a pas publie sa face reseau — lecture de son plan " + f"(transition).", file=sys.stderr) + registre = charger_acces(base / "plan" / "acces.yml") + index = charger_nomenclature(base / "plan" / "nomenclature.yml").get("index") # UN LOCATAIRE SANS PAIR N'A PAS DE TUNNEL — sauf quand on vient justement lui en # proposer un (`pair-nouveau`) : refuser la` aurait fait de la premiere cle un # probleme d'oeuf et de poule, et c'est ce que le premier essai a montre.