#!/usr/bin/env python3 """Le contexte d'un ecosysteme : un tronc commun, deux classes (SITE et LOCATAIRE). Conception : `docs/conception-contextes.md` (arretee avec l'exploitant le 2026-10-04). POURQUOI CE MODULE. Le 2026-10-04, 33 scripts DEVINAIENT leur contexte, chacun a sa facon : le lien `instance/`, le lien `underlay.yml`, les variables `SETOPS_*`, les dossiers freres, les `SITE-*`. Les indices ne concordaient pas toujours, et chaque desaccord avait deja produit un defaut silencieux (la frontiere d'un site recevant les regles de l'autre, la console d'un site affichant zero machine, `make ci` melangeant le modele public et les ecosystemes reels). Ce module est le SEUL endroit ou se lisent ces indices. Les scripts lui demandent « ou suis-je ? » et recoivent un objet : un `Site` ou un `Locataire`, qui heritent d'`Ecosysteme`. ETAPES 1 ET 2 DU CHEMIN (§6) : le module existe, il est teste, et RIEN NE L'UTILISE ENCORE. La fiche du site (`site.fiche_pour(locataire)`) est calculee A COTE de l'existant, et `verifier_fiche` prouve qu'elle dit exactement ce que le locataire porte aujourd'hui. La face reseau du locataire suit. Les consommateurs basculeront a l'etape 3, a resultat identique. Usage : python3 scripts/contexte.py # le contexte actif, et ce que chaque indice dit python3 scripts/contexte.py --json python3 scripts/contexte.py --fiche OPS-Technolibre # la fiche que son hebergeur lui destine python3 scripts/contexte.py --verifier-fiches # chaque fiche dit-elle ce que le locataire porte ? """ from __future__ import annotations import argparse import contextlib import hashlib import json import os import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import yaml # noqa: E402 import underlay as underlay_mod # noqa: E402 import voutes as voutes_mod # noqa: E402 from inventory_rules import ORDRE_INVENTAIRE, dossier_inventaire, instance_courante # noqa: E402 RACINE = Path(__file__).resolve().parents[1] # Les ecosystemes vivent a cote du moteur : des dossiers freres, pas de registre. DOSSIER_ECOSYSTEMES = RACINE.parent # Le contexte actif, nomme (decision du 2026-10-04) : une seule valeur, `site:` ou # `locataire:`. Absent, on retombe sur les liens d'avant, le temps de la bascule. NOM_FICHIER_CONTEXTE = "contexte" # La fiche que le site depose chez chacun de ses locataires (decision du 2026-10-04 : le site # DEPOSE, le runner du locataire n'a pas acces au depot du site). NOM_FICHIER_FICHE_SITE = "fiche-site.yml" NATURES = ("site", "locataire") class ContexteInvalide(Exception): """Un contexte nomme qui ne designe rien, ou un depot qui ne dit pas ce qu'il est.""" def _yaml(chemin: Path) -> dict: if not chemin.is_file(): return {} return yaml.safe_load(chemin.read_text(encoding="utf-8")) or {} def _empreinte(chemin: Path) -> str: return hashlib.sha256(chemin.read_bytes()).hexdigest() if chemin.is_file() else "" @contextlib.contextmanager def _monter(site: "Site | None" = None, locataire: "Locataire | None" = None): """Monte CE site et CE locataire le temps d'interroger les modules d'avant. `underlay`, `site_intrants`, `instancier` lisent « le site monte » et « l'instance courante » (variables `SETOPS_*`, sinon les liens). Plutot que de recopier leurs calculs, on leur designe l'ecosysteme voulu, puis on remet l'environnement tel quel. C'est une passerelle de transition : a l'etape 3, ces modules recevront leurs objets. """ clefs = ("SETOPS_UNDERLAY", "SETOPS_INSTANCE") avant = {k: os.environ.get(k) for k in clefs} try: if site is not None: os.environ["SETOPS_UNDERLAY"] = str(site.underlay) if locataire is not None: os.environ["SETOPS_INSTANCE"] = str(locataire.depot) yield finally: for k, v in avant.items(): if v is None: os.environ.pop(k, None) else: os.environ[k] = v # --- LE TRONC COMMUN ---------------------------------------------------------------------- class Ecosysteme: """Ce que tout ecosysteme possede, quel que soit son contexte (§2.1). Les methodes qui dependent du contexte sont SURCHARGEES par `Site` et `Locataire` ; ici elles refusent, pour qu'un oubli de surcharge se voie au premier appel. """ nature = "ecosysteme" def __init__(self, depot: Path): self.depot = Path(depot) # Identite ------------------------------------------------------------------------- @property def nom(self) -> str: return self.depot.name @property def plan(self) -> Path: return self.depot / "plan" def lire_plan(self, fichier: str) -> dict: """Un fichier du plan, ou {} s'il n'existe pas.""" return _yaml(self.plan / fichier) # Voute : un depot, une cle (2026-08-28) --------------------------------------------- @property def cle_voute(self) -> Path: """La cle qui ouvre SA voute, nommee d'apres le depot (`voutes.cle_de`).""" return voutes_mod.cle_de(self.nom) @property def voute(self) -> Path: raise NotImplementedError(f"{type(self).__name__} : `voute` non surchargee") def a_sa_voute(self) -> bool: return self.voute.is_file() # Filiation ---------------------------------------------------------------------------- @property def filiation(self) -> dict: """`parente.yml` : de quels depots (moteur, hebergeur, modeles) il descend.""" return (_yaml(self.depot / "parente.yml").get("parente")) or {} # Ce que chaque contexte surcharge ------------------------------------------------------ @property def index(self) -> int | None: raise NotImplementedError(f"{type(self).__name__} : `index` non surcharge") def inventaire(self) -> Path: raise NotImplementedError(f"{type(self).__name__} : `inventaire` non surcharge") # Commodites ---------------------------------------------------------------------------- def __repr__(self) -> str: return f"{type(self).__name__}({self.nom!r})" def __eq__(self, autre: object) -> bool: return (isinstance(autre, Ecosysteme) and type(autre) is type(self) and autre.depot.resolve() == self.depot.resolve()) def __hash__(self) -> int: return hash((type(self).__name__, str(self.depot.resolve()))) def decrire(self) -> dict: return {"nature": self.nature, "nom": self.nom, "depot": str(self.depot), "index": self.index, "voute": str(self.voute), "a_sa_voute": self.a_sa_voute(), "cle_voute": str(self.cle_voute)} # Reconnaitre un depot ------------------------------------------------------------------ @staticmethod def depuis(depot: Path) -> "Ecosysteme": """Le `Site` ou le `Locataire` que porte ce depot, d'apres CE QU'IL DECLARE. Un site declare un `underlay.yml` ; un locataire, une nomenclature. Un depot qui declare les deux (l'ancien modele `socle`) ne dit pas ce qu'il est : refuse, plutot que de choisir pour lui. """ depot = Path(depot) est_site = (depot / "underlay.yml").is_file() est_locataire = (depot / "plan" / "nomenclature.yml").is_file() if est_site and est_locataire: raise ContexteInvalide( f"{depot.name} porte un `underlay.yml` ET une nomenclature : un site ou un " f"locataire, pas les deux.") if est_site: return Site(depot) if est_locataire: return Locataire(depot) raise ContexteInvalide( f"{depot} n'est ni un site (pas d'`underlay.yml`) ni un locataire (pas de " f"`plan/nomenclature.yml`).") # --- SITE --------------------------------------------------------------------------------- class Site(Ecosysteme): """L'hebergeur : le materiel, la fabric, la frontiere, et ses locataires (§2.2).""" nature = "site" @classmethod def charger(cls, nom: str, dossier: Path = DOSSIER_ECOSYSTEMES) -> "Site": depot = Path(dossier) / nom if not (depot / "underlay.yml").is_file(): raise ContexteInvalide(f"site « {nom} » introuvable : pas de {depot}/underlay.yml") return cls(depot) @property def underlay(self) -> Path: return self.depot / "underlay.yml" def carte(self) -> dict: """Le contenu de `underlay:`.""" return _yaml(self.underlay).get("underlay") or {} @property def voute(self) -> Path: return self.depot / "underlay.vault.yml" @property def index(self) -> int | None: i = self.carte().get("index") return i if isinstance(i, int) and not isinstance(i, bool) else None def inventaire(self) -> Path: """Un site n'a pas de `hosts.yml` : son inventaire est un SCRIPT (`site_inventaire.py`).""" return RACINE / "scripts" / "site_inventaire.py" def allocations(self) -> dict[str, int]: """L'index que CE site attribue a chacun de ses locataires (`underlay.tenants`).""" return underlay_mod.allocations({"underlay": self.carte()}) def noms_locataires(self) -> list[str]: """Les locataires que ce site declare, sous l'une ou l'autre forme de `tenants`.""" v = self.carte().get("tenants") if isinstance(v, dict): return sorted(str(n) for n in v) if isinstance(v, list): return sorted(str(n) for n in v) return [] def locataires(self) -> list["Locataire"]: """Ses locataires, resolus en objets. Un nom sans depot frere n'est PAS rendu ici : `locataires_absents()` le dit — un filtre qui oublie en silence est un defaut.""" out = [] for nom in self.noms_locataires(): depot = self.depot.parent / nom if (depot / "plan" / "nomenclature.yml").is_file(): out.append(Locataire(depot)) return out def locataires_absents(self) -> list[str]: presents = {l.nom for l in self.locataires()} return [n for n in self.noms_locataires() if n not in presents] # LE CONTRAT, SENS SITE -> LOCATAIRE (§2.4) --------------------------------------------- def fiche_pour(self, locataire: "Locataire") -> dict: """Ce que CE site attribue et offre a CE locataire, en une fiche. Trois canaux le portaient jusqu'ici, sans fiche (relevé du 2026-10-04) : - des COPIES a la main : index, adresse publique, intrants de service, racine ; - une LECTURE DIRECTE du site par `instancier.py` au moment de generer l'inventaire : delegation DNS, serveurs de temps, mode de routage ; - le genome (hors fiche : il passe par la forge). La fiche les reunit, calcules par LES MEMES fonctions qu'aujourd'hui. Elle porte l'empreinte de ses sources : une fiche qui ne correspond plus a son site se voit. """ import site_intrants as si with _monter(self, locataire): contrat = si.contrat() carte = underlay_mod.charger() or {} intrants = underlay_mod.lire_plan_site("10-intrants.yml") # Les trois lectures d'`instancier.py`, a l'identique (`_zones_deleguees`, la patte # de transit de la frontiere, `routage_tenants`). zone = str(intrants.get("domaine_interne") or "").strip() resolveurs = [a for a in underlay_mod.adresses_site_portant("serveur_resolveur") if a] transit = underlay_mod.reseau_transit(carte) or {} reseaux_transit = {str(transit.get("nom"))} if transit.get("nom") else set() temps = sorted({str(h["ip"]) for h in underlay_mod.hotes(carte) if h.get("role") == "frontiere" and h.get("ip") and str(h.get("reseau")) in reseaux_transit and str(h.get("etat", "actif")) != "reserve"}) routage = underlay_mod.routage_tenants(carte) ip_publique = si.ip_publique_de(locataire.nom) racine = self.depot / "ac-racine-site.crt" offre = {k: v for k, v in contrat.items() if k not in ("ip_publique", "forge_amont_ac")} return { "site": self.nom, "locataire": locataire.nom, "sources": {f: _empreinte(self.depot / f) for f in ("underlay.yml", "plan/10-intrants.yml", "opnsense.yml", "ac-racine-site.crt")}, "attribue": {"index": self.allocations().get(locataire.nom), "ip_publique": ip_publique}, "offre": offre, "racine_confiance": {"fichier": racine.name, "sha256": _empreinte(racine), "pem": racine.read_text(encoding="utf-8") if racine.is_file() else ""}, "generation": {"zone_site": zone, "resolveurs_site": resolveurs, "serveurs_temps": temps, "routage_tenants": routage}, } def deposer_fiche(self, locataire: "Locataire") -> Path: """Ecrit chez le locataire la fiche que ce site lui destine. Rend le chemin ecrit. Aucune date dans le fichier : deux depots d'une meme fiche sont identiques, et un depot qui ne change rien ne fabrique pas de difference a commiter. """ chemin = locataire.depot / NOM_FICHIER_FICHE_SITE entete = (f"# GENERE par le site {self.nom} (scripts/contexte.py --deposer-fiches).\n" "# Ne pas editer : c'est ce que le site attribue et offre a ce locataire.\n" "# Voir docs/conception-contextes.md §2.4.\n") chemin.write_text(entete + yaml.safe_dump(self.fiche_pour(locataire), allow_unicode=True, sort_keys=False), encoding="utf-8") return chemin def decrire(self) -> dict: d = super().decrire() d.update({"locataires": self.noms_locataires(), "locataires_absents": self.locataires_absents()}) return d # --- LOCATAIRE ---------------------------------------------------------------------------- class Locataire(Ecosysteme): """Une organisation hebergee : son plan de services, son inventaire genere (§2.3). UN LOCATAIRE PEUT FIGURER CHEZ DEUX SITES : son hebergeur actif, que nomme `parente.yml`, et un site de reprise qui le declare aussi (SITE-Technolibre declare les deux locataires de SITE-Chezlepro). `site()` rend l'hebergeur ACTIF ; les autres le connaissent par leur propre `locataires()`. """ nature = "locataire" @classmethod def charger(cls, nom: str, dossier: Path = DOSSIER_ECOSYSTEMES) -> "Locataire": depot = Path(dossier) / nom if not (depot / "plan" / "nomenclature.yml").is_file(): raise ContexteInvalide( f"locataire « {nom} » introuvable : pas de {depot}/plan/nomenclature.yml") return cls(depot) def nomenclature(self) -> dict: return self.lire_plan("nomenclature.yml") @property def index(self) -> int | None: i = self.nomenclature().get("index") return i if isinstance(i, int) and not isinstance(i, bool) else None def dossier_inventaire(self) -> Path: return dossier_inventaire(self.depot, ORDRE_INVENTAIRE) def inventaire(self) -> Path: """Son `hosts.yml` GENERE du plan. Le chemin est rendu meme s'il n'existe pas encore (instance neuve) : c'est `instancier` qui l'ecrira.""" return self.dossier_inventaire() / "hosts.yml" @property def voute(self) -> Path: return self.dossier_inventaire() / "group_vars" / "all" / "vault.yml" def domaine_interne(self) -> str: """Son domaine interne : le premier `domaine_interne` non vide de `group_vars/all`, dans l'ordre des fichiers — la lecture meme d'`instancier._domaine_interne`.""" base = self.dossier_inventaire() / "group_vars" / "all" for fichier in sorted(base.glob("*.yml")) if base.is_dir() else []: try: d = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {} except yaml.YAMLError: continue if isinstance(d, dict) and str(d.get("domaine_interne", "")).strip(): return str(d["domaine_interne"]).strip() return "" def fiche_du_site(self) -> dict | None: """La fiche que son hebergeur a deposee chez lui, ou None s'il n'y en a pas.""" d = _yaml(self.depot / NOM_FICHIER_FICHE_SITE) return d or None def nom_hebergeur(self) -> str | None: h = ((self.filiation.get("depots") or {}).get("hebergeur") or {}).get("nom") return str(h) if h else None def site(self) -> "Site | None": """Son hebergeur ACTIF. None s'il n'en nomme aucun ; refus s'il en nomme un absent.""" nom = self.nom_hebergeur() if not nom: return None return Site.charger(nom, self.depot.parent) # LE CONTRAT, SENS LOCATAIRE -> SITE (§2.4) --------------------------------------------- def face_reseau(self) -> dict: """Ce que CE locataire demande a son site, en une fiche — les faits, pas encore les flux. Le site lisait jusqu'ici six fichiers internes du locataire (relevé du 2026-10-04) : sa nomenclature, son inventaire genere, ses `group_vars` (`nftables_admin_ssh`, `serveur_backup_pubkey`), son `plan/acces.yml`, son `plan/domaines.yml` et ce qui l'accompagne. La face reseau les publie, calcules ICI, chez le locataire, qui seul sait lire ses fichiers : l'adresse de son primaire DNS vient de son inventaire (le site la re-derivait, sequence 1 par defaut), sa cle de sauvegarde de SON dossier d'inventaire (le site ecrivait `principal` en dur). LES FLUX de chaque machine viennent de ce que le locataire a deja resolu pour ses propres pare-feux (`flux-genere/*.connectivite.json`). Ceux de la frontiere suivront. """ from inventory_rules import (charger_acces, est_groupe_operationnel, port_vpn_locataire, tunnel_admin_de) from resoudre_flux import _enfants, _hotes_du_groupe, _ip_par_hote data = _yaml(self.inventaire()) hv = _hostvars(self.inventaire()) 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 {} machines = { h: {"ip": ip, "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_")}} for h, ip in sorted(ips.items())} # Les reseaux d'administration que le locataire DECLARE (l'intrant), et son tunnel. intrant: list[str] = [] gv_all = self.dossier_inventaire() / "group_vars" / "all" for fichier in sorted(gv_all.glob("*.yml")) if gv_all.is_dir() else []: if "vault" in fichier.name: continue d = _yaml(fichier) if isinstance(d, dict) and d.get("nftables_admin_ssh"): src = d["nftables_admin_ssh"] intrant = [str(s) for s in src] if isinstance(src, list) else [str(src)] break try: pairs = charger_acces(self.plan / "acces.yml").get("acces_admin_vpn") or {} except (OSError, ValueError): pairs = {} # Ses zones publiques : celles qu'il fait servir par le site (`primaire-cache`). doms = self.lire_plan("domaines.yml").get("domaines_publics") or {} apps = self.lire_plan("applications.yml").get("applications") or {} zones = sorted(z for z, c in doms.items() if isinstance(c, dict) and str(c.get("autorite")) == "primaire-cache") hote_dns = next((str(a.get("hote")) for a in apps.values() if str(a.get("groupe")) == "serveur_powerdns"), "") colocalise = any(str(a.get("groupe")) == "serveur_resolveur" and str(a.get("hote")) == hote_dns for a in apps.values()) dns_public = ({"zones": zones, "signees": sorted(z for z in zones if doms[z].get("dnssec") is True), "primaire": ips.get(hote_dns, ""), "port": 5300 if colocalise else 53} if zones else {}) cle_sauvegarde = str(_yaml(self.dossier_inventaire() / "group_vars" / "serveur_backup.yml") .get("serveur_backup_pubkey") or "").strip() # LES FLUX DE CHAQUE MACHINE, tels que le locataire les a deja resolus pour ses propres # pare-feux (`make flux` -> `flux-genere/.connectivite.json`) : chaque entree # admise (protocole, port, sources), plus ses reseaux d'administration. # # UNE ENTREE OUVERTE A TOUS L'EST-ELLE AUSSI AU POSTE ? Le flux le dit (`poste`, vrai par # defaut ; faux pour le 25 de Postfix, un flux de serveur a serveur). La frontiere en # tire ses regles d'administration ; la face le lit dans le meme catalogue du moteur. from devis_opnsense import _ports as ports_du_flux from resoudre_flux import _pairs, charger_flux catalogue = charger_flux() def ouvert_au_poste(e: dict) -> bool: semblables = [fl for fl in catalogue.get(str(e.get("role")), []) if fl.get("sens") == "ingress" and "externe" in _pairs(fl) and str(fl.get("protocole", "tcp")).lower() == str(e.get("proto")) and str(e.get("port")) in ports_du_flux(fl)] return all(fl.get("poste", True) for fl in semblables) flux = {} for h in sorted(ips): c = self.depot / "flux-genere" / f"{h}.connectivite.json" if c.is_file(): d = json.loads(c.read_text(encoding="utf-8")) entrees = [dict(e, poste=ouvert_au_poste(e)) if "0.0.0.0/0" in (e.get("sources") or []) else dict(e) for e in d.get("entrees") or []] flux[h] = {"entrees": entrees, "administration": d.get("admin_ssh") or [], "sorties_externes": d.get("sorties_externes") or []} sources = {str(Path(*f.relative_to(self.depot).parts)): _empreinte(f) for f in ( self.plan / "nomenclature.yml", self.inventaire(), self.plan / "acces.yml", self.plan / "domaines.yml", self.plan / "applications.yml", self.dossier_inventaire() / "group_vars" / "serveur_backup.yml")} sources.update({f"flux-genere/{h}.connectivite.json": _empreinte(self.depot / "flux-genere" / f"{h}.connectivite.json") for h in flux}) # Ses zones : un sous-reseau par categorie de sa nomenclature, derive de son index # (`sous_reseau_de`, comme la frontiere et le SDN le derivent). from inventory_rules import sous_reseau_de cats = self.nomenclature().get("categories") or {} zones = ([sous_reseau_de(self.index, int(z)) for z in sorted(cats, key=int)] if self.index is not None else []) return { "locataire": self.nom, "hebergeur": self.nom_hebergeur(), "sources": sources, "index": self.index, "zones": zones, "machines": machines, "groupes": groupes, "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)}}, "dns_public": dns_public, "sauvegarde": {"cle_publique": cle_sauvegarde}, "flux": flux, } def decrire(self) -> dict: d = super().decrire() d.update({"inventaire": str(self.inventaire()), "hebergeur": self.nom_hebergeur()}) return d # --- LA FICHE DIT-ELLE CE QUE LE LOCATAIRE PORTE ? ---------------------------------------- def _hostvars(inventaire: Path) -> dict: d = _yaml(inventaire) hv: dict = {} def parcourir(g: dict) -> None: for h, v in (g.get("hosts") or {}).items(): hv.setdefault(h, {}).update(v or {}) for c in (g.get("children") or {}).values(): parcourir(c or {}) parcourir(d.get("all") or {}) return hv def verifier_fiche(site: "Site", locataire: "Locataire", fiche: dict | None = None) -> list[str]: """Les ecarts entre la fiche du site et ce que le locataire porte AUJOURD'HUI. Vide = la fiche dit exactement ce que les trois canaux d'avant ont depose chez le locataire : ses copies (intrants, index, adresse publique, racine) et son inventaire genere (temps, delegation DNS, rattachement SDN). C'est la condition pour que l'instancier puisse, a l'etape 3, lire la fiche au lieu du site sans que l'inventaire change d'un octet. """ import site_intrants as si f = fiche if fiche is not None else site.fiche_pour(locataire) ecarts: list[str] = [] # 1. L'attribution. if f["attribue"]["index"] != locataire.index: ecarts.append(f"index : le site attribue {f['attribue']['index']}, " f"le locataire porte {locataire.index}") # 2. L'offre et l'adresse publique, contre les copies du locataire (la table de `site_intrants`). with _monter(site, locataire): vu = si._declare_par_le_locataire() valeurs = dict(f["offre"], ip_publique=f["attribue"]["ip_publique"]) for clef, copie in vu.items(): attendu = valeurs.get(clef) if not attendu or "{{" in str(copie): continue if si._normaliser(copie) != si._normaliser(attendu): fichier, variable = si.OU_LE_LOCATAIRE_LE_DIT[clef] ecarts.append(f"{variable} ({fichier}) : le locataire dit « {copie} », la fiche « {attendu} »") # 3. La racine de confiance. copie_ac = locataire.depot / "ac-racine-site.crt" if copie_ac.is_file() and _empreinte(copie_ac) != f["racine_confiance"]["sha256"]: ecarts.append("ac-racine-site.crt : la copie du locataire n'est pas la racine du site") # 4. Ce que l'instancier a lu dans le site, tel qu'il l'a ecrit dans l'inventaire. hv = _hostvars(locataire.inventaire()) if not hv: ecarts.append(f"inventaire introuvable ou vide : {locataire.inventaire()}") return ecarts gen = f["generation"] temps = gen["serveurs_temps"] or None for h, v in sorted(hv.items()): if v.get("chrony_serveurs") != temps: ecarts.append(f"{h} : chrony_serveurs = {v.get('chrony_serveurs')}, la fiche dit {temps}") break domaine_loc = locataire.domaine_interne() attendu_zones = ([{"adresses": gen["resolveurs_site"], "nom": gen["zone_site"]}] if gen["zone_site"] and gen["resolveurs_site"] and gen["zone_site"] != domaine_loc else []) portees = [v["serveur_resolveur_zones_deleguees"] for v in hv.values() if "serveur_resolveur_zones_deleguees" in v] normal = [sorted(({"adresses": list(z.get("adresses") or []), "nom": z.get("nom")} for z in p), key=lambda z: str(z["nom"])) for p in portees] if attendu_zones and not normal: ecarts.append("delegation DNS : la fiche en offre une, l'inventaire n'en porte aucune") for p in normal: if p != attendu_zones and (attendu_zones or p): ecarts.append(f"delegation DNS : l'inventaire porte {p}, la fiche dit {attendu_zones}") break sdn = gen["routage_tenants"] == "sdn" for h, v in sorted(hv.items()): if sdn and (not v.get("proxmox_pont") or v.get("proxmox_etiquette_vlan")): ecarts.append(f"{h} : routage SDN selon la fiche, mais pont « {v.get('proxmox_pont')} », " f"etiquette « {v.get('proxmox_etiquette_vlan')} »") break if not sdn and v.get("proxmox_pont"): ecarts.append(f"{h} : routage « {gen['routage_tenants']} » selon la fiche, mais un pont SDN") break return ecarts def verifier_face(site: "Site", locataire: "Locataire", face: dict | None = None) -> list[str]: """Les ecarts entre la face reseau du locataire et ce que les CONSOMMATEURS DU SITE en tirent aujourd'hui, en lisant ses fichiers internes. On interroge les consommateurs eux-memes, le site monte : la decouverte des locataires (`devis_reseau`), le devis du pare-feu Proxmox (ses ensembles d'adresses par groupe et d'administration), la frontiere (`admin_de`), et l'inventaire du site (relations du DNS public, comptes de sauvegarde). Vide = chacun lirait la face reseau sans rien changer. """ import devis_proxmox_fw import devis_reseau import site_inventaire f = face if face is not None else locataire.face_reseau() ecarts: list[str] = [] with _monter(site): decouverts = {nom: n for nom, _p, n in devis_reseau.decouvrir_du_site()} bloc = next((b for b in devis_proxmox_fw.construire(devis_reseau.decouvrir_du_site())["blocs"] if b.get("tenant") == locataire.nom), None) admin_frontiere = sorted(devis_reseau.admin_de(locataire.nom)) inv = site_inventaire.inventaire() or {} hv_site = (inv.get("_meta") or {}).get("hostvars") or {} # 1. L'index, tel que le site le decouvre. n = decouverts.get(locataire.nom) if n is None: ecarts.append("le site ne decouvre pas ce locataire") elif n.get("index") != f["index"]: ecarts.append(f"index : le site decouvre {n.get('index')}, la face dit {f['index']}") # 2. Les machines et leurs groupes, tels que le pare-feu Proxmox les range. ips = {h: m["ip"] for h, m in f["machines"].items()} if bloc is None: ecarts.append("le devis Proxmox n'a aucun bloc pour ce locataire") else: ens = bloc.get("ipsets") or {} flotte = next((v["membres"] for v in ens.values() if str(v.get("role", "")).startswith("flotte")), []) if sorted(flotte) != sorted(set(ips.values())): ecarts.append(f"flotte : le devis Proxmox range {sorted(flotte)}, la face {sorted(set(ips.values()))}") # Le devis ne garde que les ensembles que ses regles UTILISENT : chaque groupe qu'il range # doit exister dans la face avec les memes adresses ; un groupe inutilise n'est pas un ecart. speciaux = ("flotte", "Internet", "reseaux d'admin") par_groupe = {str(v.get("role")): sorted(v.get("membres") or []) for v in ens.values() if not str(v.get("role", "")).startswith(speciaux)} for g, membres in sorted(par_groupe.items()): if g not in f["groupes"]: ecarts.append(f"groupe {g} : range par le devis Proxmox, absent de la face") elif membres != sorted({ips[h] for h in f["groupes"][g]}): ecarts.append(f"groupe {g} : le devis Proxmox range {membres}, " f"la face {sorted({ips[h] for h in f['groupes'][g]})}") adm = next((v["membres"] for v in ens.values() if str(v.get("role", "")).startswith("reseaux d'admin")), []) attendu_adm = sorted(set(f["administration"]["intrant"]) | set(f["administration"]["tunnel"])) if sorted(adm) != attendu_adm: ecarts.append(f"administration (Proxmox) : le devis admet {sorted(adm)}, la face {attendu_adm}") # 3. L'administration, telle que la frontiere la lit. if admin_frontiere != sorted(f["administration"]["intrant"]): ecarts.append(f"administration (frontiere) : le site lit {admin_frontiere}, " f"la face {sorted(f['administration']['intrant'])}") # 4. Le DNS public et la sauvegarde, tels que l'inventaire du site les porte. relations = [r for v in hv_site.values() for r in (v.get("serveur_dns_public_relations") or []) if r.get("ecosysteme") == locataire.nom] rel = relations[0] if relations else {} attendu_dns = f["dns_public"] vu_dns = ({k: rel.get(k) for k in ("zones", "signees", "primaire", "port")} if rel else {}) if vu_dns != attendu_dns: ecarts.append(f"DNS public : l'inventaire du site porte {vu_dns}, la face {attendu_dns}") comptes = [c for v in hv_site.values() for c in (v.get("serveur_backup_site_locataires") or []) if c.get("nom") == locataire.nom] cle_site = comptes[0].get("pubkey") if comptes else "" if (cle_site or "") != f["sauvegarde"]["cle_publique"]: ecarts.append("sauvegarde : la cle que le site autorise n'est pas celle que le locataire publie") return ecarts # LES DEUX PARE-FEUX ECRIVENT LA MEME POLITIQUE AUTREMENT. On les ramene a une forme commune : # pour chaque machine, pour chaque (protocole, port), l'ENSEMBLE des sources admises, chacune # etant une adresse, `EXTERNE` ou `ADMIN`. Les differences d'ecriture, toutes neutralisees ici : # - Proxmox ecrit une regle par groupe source ; le locataire reunit les sources d'un port ; # - Proxmox nomme un ensemble (`+t23-srv-nginx`) ou ecrit les adresses en clair, separees # par des virgules (la fabric vers Loki, le DNS public du site) ; # - « Internet » : `0.0.0.0/0` chez le locataire, l'ensemble « tout sauf RFC 1918 » chez # Proxmox, double d'une regle « administration (poste) » sur le meme port ; # - l'administration : une liste a part (`admin_ssh`) et des entrees ordinaires chez le # locataire, l'ensemble « reseaux d'administration » chez Proxmox ; # - l'ICMP : `frag-needed` d'un cote, `fragmentation-needed` de l'autre. # Un port ouvert a tous ET a des clients nommes porte ces derniers dans `sources_declarees` # (depuis le 2026-10-04) : sans eux, Proxmox ne saurait pas qui admettre en plus d'Internet. EXTERNE, ADMIN = "EXTERNE", "ADMIN" _ICMP = {"frag-needed": "fragmentation-needed"} def _entrees_locataire(face: dict) -> dict[str, dict]: out: dict[str, dict] = {} for h, f in (face.get("flux") or {}).items(): admin = set(f.get("administration") or []) par_port = out.setdefault(h, {}) for e in f.get("entrees") or []: cle = (str(e.get("proto")), _ICMP.get(str(e.get("port")), str(e.get("port")))) atomes = par_port.setdefault(cle, set()) # `sources_declarees` : les clients nommes d'un port aussi ouvert a tous (`0.0.0.0/0`). for s in list(e.get("sources") or []) + list(e.get("sources_declarees") or []): atomes.add(EXTERNE if s == "0.0.0.0/0" else ADMIN if s in admin else str(s)) if admin: par_port.setdefault(("tcp", "22"), set()).add(ADMIN) return out def _entrees_proxmox(bloc: dict) -> dict[str, dict]: ens = bloc.get("ipsets") or {} regles = {g["nom"]: g.get("regles") or [] for g in bloc.get("groupes") or []} out: dict[str, dict] = {} for a in bloc.get("affectations") or []: par_port = out.setdefault(a["hote"], {}) for g in a.get("groupes") or []: for r in regles.get(g, []): if r.get("action") != "ACCEPT" or r.get("sens") != "IN": continue if str(r.get("via", "")) == "administration (poste)": continue # le double d'une regle `internet` sur le meme port source = str(r.get("source", "")) if source.startswith("+"): e = ens.get(source[1:]) or {} role = str(e.get("role", "")) atomes = ({EXTERNE} if role.startswith("Internet") else {ADMIN} if role.startswith("reseaux d'admin") else {str(m) for m in e.get("membres") or []}) else: atomes = {s.strip() for s in source.split(",") if s.strip()} for p in str(r.get("dport") or r.get("icmp_type")).split(","): cle = (str(r.get("proto")), _ICMP.get(p.strip(), p.strip())) par_port.setdefault(cle, set()).update(atomes) return out def verifier_flux(site: "Site", locataire: "Locataire", face: dict | None = None) -> list[str]: """Machine par machine : le pare-feu du locataire (nftables, resolu par lui) et celui de Proxmox (devise par le site) admettent-ils les memes entrees ? C'est le constat 2 de la conception : les flux etaient calcules DEUX FOIS, chacun avec sa version du moteur. Vide = les deux pare-feux disent la meme chose, et le site pourra, a l'etape 3, deviser Proxmox depuis la face reseau sans rien recalculer. """ import devis_proxmox_fw import devis_reseau f = face if face is not None else locataire.face_reseau() with _monter(site): bloc = next((b for b in devis_proxmox_fw.construire(devis_reseau.decouvrir_du_site())["blocs"] if b.get("tenant") == locataire.nom), None) if bloc is None: return ["le devis Proxmox n'a aucun bloc pour ce locataire"] loc, pve = _entrees_locataire(f), _entrees_proxmox(bloc) ecarts: list[str] = [] for h in sorted(set(loc) | set(pve)): if h not in loc: ecarts.append(f"{h} : filtre par Proxmox, sans flux publie par le locataire") continue if h not in pve: ecarts.append(f"{h} : flux publies par le locataire, aucune affectation Proxmox") continue for cle in sorted(set(loc[h]) | set(pve[h])): l, p = loc[h].get(cle, set()), pve[h].get(cle, set()) if l == p: continue port = f"{cle[0]}/{cle[1]}" if l - p: ecarts.append(f"{h} {port} : le locataire admet {sorted(l - p)}, Proxmox non") if p - l: ecarts.append(f"{h} {port} : Proxmox admet {sorted(p - l)}, le locataire non") return ecarts def _devis_frontiere(site: "Site", locataire: "Locataire") -> tuple[str | None, dict]: """L'etiquette du locataire a la frontiere (`TECH23`) et le devis, CE site monte. Le devis imprime ses notes (flux sans `port_public`, sorties vers un absent) : utiles a qui le lit, du bruit dans une preuve. On les retient ici. """ import io import devis_opnsense import devis_reseau with _monter(site): tenants = devis_reseau.decouvrir_du_site() etq = next((p + str(n["index"]) for nom, p, n in tenants if nom == locataire.nom), None) with contextlib.redirect_stderr(io.StringIO()): devis = devis_opnsense.construire(tenants) if etq else {} return etq, devis def verifier_entrees_publiques(site: "Site", locataire: "Locataire", face: dict | None = None, fiche: dict | None = None) -> list[str]: """La frontiere, second temps : ce qui ENTRE chez le locataire depuis l'Internet. Machine par machine, les regles du WAN (destination developpee en adresses) et les redirections doivent dire ce que la face reseau porte comme entrees ouvertes a tous (`0.0.0.0/0`) : les memes (machine, protocole, port). Une redirection part de l'adresse que la fiche du site attribue, et son port public est le port local — aucun role de locataire n'en declare d'autre ; s'il le faisait, la face ne le porterait pas, et ce serait un ecart. Le tunnel d'administration entre par l'adresse WAN de la frontiere, sur son port. """ import ipaddress f = face if face is not None else locataire.face_reseau() fi = fiche if fiche is not None else site.fiche_pour(locataire) etq, devis = _devis_frontiere(site, locataire) if not etq: return ["la frontiere ne decouvre pas ce locataire"] alias = devis.get("alias") or {} ips = sorted({m["ip"] for m in f["machines"].values()}) def adresses(nom: str) -> set[str]: a = alias.get(nom) or {} if a.get("type") == "network": reseaux = [ipaddress.ip_network(str(c), strict=False) for c in a.get("contenu") or []] return {ip for ip in ips if any(ipaddress.ip_address(ip) in r for r in reseaux)} return {str(c) for c in a.get("contenu") or []} # La face : chaque entree ouverte a tous, par machine. publiques: set[tuple] = set() for h, fl in (f.get("flux") or {}).items(): ip = f["machines"][h]["ip"] for e in fl.get("entrees") or []: if "0.0.0.0/0" in (e.get("sources") or []): publiques.add((ip, str(e.get("proto")), str(e.get("port")))) ecarts: list[str] = [] # Les regles du WAN, hors tunnel. wan = {(ip, str(r.get("protocole")), str(p)) for r in devis.get("regles") or [] if r.get("tenant") == locataire.nom and r.get("interface") == devis.get("if_wan") and r.get("sens") == "in" and r.get("role") != "acces_admin_vpn" for ip in adresses(str(r.get("destination"))) for p in r.get("ports") or []} norm = {(i, pr, _ICMP.get(po, po)) for i, pr, po in publiques} wan_n = {(i, pr, _ICMP.get(po, po)) for i, pr, po in wan} for x in sorted(norm - wan_n): ecarts.append(f"{x[0]} {x[1]}/{x[2]} : ouvert a tous selon la face, aucune regle WAN") for x in sorted(wan_n - norm): ecarts.append(f"{x[0]} {x[1]}/{x[2]} : regle WAN, la face ne l'ouvre pas a tous") # Les redirections : celles de la face qui ont un port (TCP/UDP), depuis l'adresse attribuee. redir = [d for d in devis.get("redirections") or [] if d.get("tenant") == locataire.nom] vues = {(str(d.get("cible")), str(d.get("protocole")), str(d.get("port_local"))) for d in redir} attendues = {x for x in publiques if x[1] in ("tcp", "udp")} for x in sorted(attendues - vues): ecarts.append(f"{x[0]} {x[1]}/{x[2]} : ouvert a tous selon la face, aucune redirection") for x in sorted(vues - attendues): ecarts.append(f"{x[0]} {x[1]}/{x[2]} : redirige par la frontiere, la face ne l'ouvre pas a tous") for d in redir: if str(d.get("destination")) != str(fi["attribue"]["ip_publique"]): ecarts.append(f"redirection {d.get('protocole')}/{d.get('port_public')} : depuis " f"{d.get('destination')}, la fiche attribue {fi['attribue']['ip_publique']}") if str(d.get("port_public")) != str(d.get("port_local")): ecarts.append(f"redirection {d.get('protocole')}/{d.get('port_public')} -> {d.get('port_local')} : " f"un port public distinct, que la face ne porte pas") # Le tunnel d'administration. tun = [r for r in devis.get("regles") or [] if r.get("tenant") == locataire.nom and r.get("role") == "acces_admin_vpn" and r.get("interface") == devis.get("if_wan")] ports_tun = sorted(str(p) for r in tun for p in r.get("ports") or []) attendu_tun = [str(f["administration"]["port_tunnel"])] if f["administration"].get("port_tunnel") else [] if ports_tun != attendu_tun: ecarts.append(f"tunnel d'administration : la frontiere ouvre {ports_tun}, la face {attendu_tun}") return ecarts def verifier_administration(site: "Site", locataire: "Locataire", face: dict | None = None) -> list[str]: """La frontiere, troisieme temps (premiere moitie) : ce que l'ADMINISTRATION atteint. Par la gestion et par le VPN du site, chaque classe d'administration doit atteindre, machine par machine, exactement ce que la face reseau lui ouvre : le SSH, les entrees qui nomment l'administration parmi leurs sources, et les entrees ouvertes a tous que leur flux ouvre aussi au poste. Le tunnel du locataire, lui, atteint toute sa flotte sur les ports que la POLITIQUE DU SITE lui accorde (22 et 443, ecrits dans le devis) : on verifie sa presence et sa destination, pas ses ports, qui ne sont pas une donnee du locataire. """ import ipaddress f = face if face is not None else locataire.face_reseau() etq, devis = _devis_frontiere(site, locataire) if not etq: return ["la frontiere ne decouvre pas ce locataire"] alias = devis.get("alias") or {} ips = sorted({m["ip"] for m in f["machines"].values()}) def adresses(nom: str) -> set[str]: a = alias.get(nom) or {} if a.get("type") == "network": reseaux = [ipaddress.ip_network(str(c), strict=False) for c in a.get("contenu") or []] return {ip for ip in ips if any(ipaddress.ip_address(ip) in r for r in reseaux)} return {str(c) for c in a.get("contenu") or []} # Ce que la face ouvre a l'administration. intrant = set(f["administration"]["intrant"]) attendu: set[tuple] = set() for h, fl in (f.get("flux") or {}).items(): ip = f["machines"][h]["ip"] for e in fl.get("entrees") or []: sources = set(e.get("sources") or []) if (sources & intrant) or ("0.0.0.0/0" in sources and e.get("poste", True)): attendu.add((ip, str(e.get("proto")), _ICMP.get(str(e.get("port")), str(e.get("port"))))) if fl.get("administration"): attendu.add((ip, "tcp", "22")) ecarts: list[str] = [] classes = sorted(k for k in alias if re.fullmatch(rf"SETOPS_ADMIN_{etq}_(?!TUNNEL)\w+", k)) for classe in classes: vu = {(ip, str(r.get("protocole")), _ICMP.get(str(p), str(p))) for r in devis.get("regles") or [] if r.get("tenant") == locataire.nom and r.get("sens") == "in" and r.get("source") == classe for ip in adresses(str(r.get("destination"))) for p in r.get("ports") or []} for x in sorted(attendu - vu): ecarts.append(f"{classe} : la face ouvre {x[0]} {x[1]}/{x[2]} a l'administration, la frontiere non") for x in sorted(vu - attendu): ecarts.append(f"{classe} : la frontiere ouvre {x[0]} {x[1]}/{x[2]}, la face ne l'ouvre pas a l'administration") tunnel = [r for r in devis.get("regles") or [] if r.get("tenant") == locataire.nom and r.get("source") == f"SETOPS_ADMIN_{etq}_TUNNEL"] if bool(tunnel) != bool(f["administration"]["tunnel"]): ecarts.append(f"tunnel : {len(tunnel)} regle(s) a la frontiere, la face " f"{'en a un' if f['administration']['tunnel'] else 'n en a aucun'}") for r in tunnel: if adresses(str(r.get("destination"))) != set(ips): ecarts.append(f"tunnel : la regle {r.get('ports')} ne vise pas toute la flotte du locataire") return ecarts def verifier_sorties(site: "Site", locataire: "Locataire", face: dict | None = None) -> list[str]: """La frontiere, troisieme temps (seconde moitie) : ce qui SORT de chez le locataire vers l'Internet. Machine par machine, les regles de sortie vers « tout sauf l'interne » (`!SETOPS_INTERNES`) doivent dire ce que la face reseau publie dans `sorties_externes` : les memes (machine, protocole, port). Le locataire relevait ses entrees pour ses propres pare-feux ; ses sorties, que ses machines acceptent toutes, il ne les relevait pas — il les publie depuis le 2026-10-05, pour la frontiere. """ import ipaddress f = face if face is not None else locataire.face_reseau() etq, devis = _devis_frontiere(site, locataire) if not etq: return ["la frontiere ne decouvre pas ce locataire"] alias = devis.get("alias") or {} ips = sorted({m["ip"] for m in f["machines"].values()}) def adresses(nom: str) -> set[str]: a = alias.get(nom) or {} if a.get("type") == "network": reseaux = [ipaddress.ip_network(str(c), strict=False) for c in a.get("contenu") or []] return {ip for ip in ips if any(ipaddress.ip_address(ip) in r for r in reseaux)} return {str(c) for c in a.get("contenu") or []} publiees = {(f["machines"][h]["ip"], str(s.get("proto")), _ICMP.get(str(s.get("port")), str(s.get("port")))) for h, fl in (f.get("flux") or {}).items() for s in fl.get("sorties_externes") or []} vues = {(ip, str(r.get("protocole")), _ICMP.get(str(p), str(p))) for r in devis.get("regles") or [] if r.get("tenant") == locataire.nom and r.get("sens") == "out" and str(r.get("destination")) == "!SETOPS_INTERNES" for ip in adresses(str(r.get("source"))) for p in r.get("ports") or []} ecarts: list[str] = [] for x in sorted(publiees - vues): ecarts.append(f"{x[0]} {x[1]}/{x[2]} : le locataire sort vers l'Internet, la frontiere ne le laisse pas") for x in sorted(vues - publiees): ecarts.append(f"{x[0]} {x[1]}/{x[2]} : la frontiere laisse sortir, le locataire ne le publie pas") autres = [r for r in devis.get("regles") or [] if r.get("tenant") == locataire.nom and r.get("sens") == "out" and str(r.get("destination")) != "!SETOPS_INTERNES"] for r in autres: ecarts.append(f"sortie vers {r.get('destination')} ({r.get('protocole')}/{','.join(r.get('ports') or [])}, " f"{r.get('role')}) : ni l'Internet, ni publiee par le locataire") return ecarts def verifier_frontiere(site: "Site", locataire: "Locataire", face: dict | None = None, fiche: dict | None = None) -> list[str]: """La frontiere, premier temps : les IDENTITES par lesquelles elle designe ce locataire. Ses alias (supernet, administration par interface, tunnel, un alias par groupe qui a des flux de bordure), ses routes, et sa traduction sortante. Chacun doit dire ce que la face reseau du locataire (zones, groupes, administration) et la fiche du site (adresse publique attribuee) portent deja. Les entrees publiques et les sorties sont les temps suivants. """ import re f = face if face is not None else locataire.face_reseau() fi = fiche if fiche is not None else site.fiche_pour(locataire) etq, devis = _devis_frontiere(site, locataire) if not etq: return ["la frontiere ne decouvre pas ce locataire"] ecarts: list[str] = [] alias = devis.get("alias") or {} ips = {h: m["ip"] for h, m in f["machines"].items()} # Le supernet : ses zones. sup = (alias.get(f"SETOPS_TENANT_{etq}") or {}).get("contenu") if sorted(sup or []) != sorted(f["zones"]): ecarts.append(f"SETOPS_TENANT_{etq} : la frontiere porte {sup}, la face {f['zones']}") # L'administration : l'intrant, que la frontiere range par interface, et le tunnel. adm = sorted({c for k, v in alias.items() if re.fullmatch(rf"SETOPS_ADMIN_{etq}_(?!TUNNEL)\w+", k) for c in v.get("contenu") or []}) if adm != sorted(f["administration"]["intrant"]): ecarts.append(f"administration : la frontiere range {adm}, la face {sorted(f['administration']['intrant'])}") tun = sorted((alias.get(f"SETOPS_ADMIN_{etq}_TUNNEL") or {}).get("contenu") or []) if tun != sorted(f["administration"]["tunnel"]): ecarts.append(f"tunnel : la frontiere porte {tun}, la face {sorted(f['administration']['tunnel'])}") # Un alias par groupe : ses membres sont ceux du groupe dans la face. for k, v in sorted(alias.items()): m = re.search(r"portant (\S+) \(instance (\S+)\)", str(v.get("description", ""))) if not m or m.group(2) != locataire.nom: continue role = m.group(1) attendu = sorted({ips[h] for h in f["groupes"].get(role, [])}) if role not in f["groupes"]: ecarts.append(f"{k} : groupe {role} inconnu de la face") elif sorted(v.get("contenu") or []) != attendu: ecarts.append(f"{k} : la frontiere porte {sorted(v.get('contenu') or [])}, la face {attendu}") # Les routes : une par zone. routes = sorted(r["reseau"] for r in devis.get("routes") or [] if r.get("tenant") == locataire.nom) if routes != sorted(f["zones"]): ecarts.append(f"routes : la frontiere route {routes}, la face a pour zones {sorted(f['zones'])}") # La traduction sortante : sa flotte sort par l'adresse que le site lui attribue. nat = [x for x in devis.get("nat") or [] if x.get("tenant") == locataire.nom] cibles = sorted(str(x.get("cible")) for x in nat) if cibles != [str(fi["attribue"]["ip_publique"])]: ecarts.append(f"traduction sortante : la frontiere fait sortir par {cibles}, " f"la fiche attribue {fi['attribue']['ip_publique']}") if any(x.get("source") != f"SETOPS_TENANT_{etq}" for x in nat): ecarts.append("traduction sortante : la source n'est pas le supernet du locataire") return ecarts def verifier_fiche_deposee(site: "Site", locataire: "Locataire") -> list[str]: """La fiche que le locataire porte est-elle celle que son site lui destine aujourd'hui ?""" deposee = locataire.fiche_du_site() if deposee is None: return [f"aucune `{NOM_FICHIER_FICHE_SITE}` chez le locataire : la deposer " f"(`python3 scripts/contexte.py --deposer-fiches`)"] attendue = site.fiche_pour(locataire) if deposee != attendue: cles = sorted(k for k in set(deposee) | set(attendue) if deposee.get(k) != attendue.get(k)) return [f"`{NOM_FICHIER_FICHE_SITE}` perimee ({', '.join(cles)}) : la redeposer et la commiter"] return [] def verifier_inventaire_sans_site(locataire: "Locataire") -> list[str]: """L'inventaire du locataire se genere-t-il SANS son site, a l'octet pres ? C'est la portabilite promise (§2.4) : generer sur le runner du locataire, qui n'a pas le depot du site, doit rendre l'inventaire versionne. On lance l'instancier dans un processus a part, `SETOPS_UNDERLAY` pointe sur rien — ses globales se fixent a l'import. """ import subprocess code = ("import sys, yaml; sys.path.insert(0, 'scripts'); import instancier\n" "t = yaml.safe_dump(instancier.generer(), default_flow_style=False, sort_keys=True, " "allow_unicode=True)\n" "r = instancier.INVENTAIRE.read_text(encoding='utf-8')\n" "r = r[r.index('all:'):] if 'all:' in r else r\n" "print('IDENTIQUE' if t == r else 'DIFFERENT')\n") env = dict(os.environ, SETOPS_INSTANCE=str(locataire.depot), SETOPS_UNDERLAY="/nulle/part") env.pop("SETOPS_INVENTAIRE", None) r = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, env=env, cwd=RACINE) verdict = (r.stdout or "").strip().splitlines()[-1:] or [""] if verdict[0] == "IDENTIQUE": return [] if verdict[0] == "DIFFERENT": return ["sans le site, l'inventaire genere differe de l'inventaire versionne"] return [f"la generation sans le site a echoue : {(r.stderr or '').strip().splitlines()[-1:]}"] def couples_hebergeur() -> list[tuple["Site", "Locataire"]]: """Chaque (site, locataire) dont ce site est l'hebergeur ACTIF — les seuls qui ont une fiche a verifier : un site de reprise ne depose rien tant que la bascule n'a pas eu lieu.""" out = [] for underlay in sorted(DOSSIER_ECOSYSTEMES.glob("*/underlay.yml")): site = Site(underlay.parent) out += [(site, loc) for loc in site.locataires() if loc.nom_hebergeur() == site.nom] return out # --- LE CONTEXTE ACTIF -------------------------------------------------------------------- def _lire_fichier_contexte(fichier: Path, dossier: Path) -> Ecosysteme: lignes = [l.split("#", 1)[0].strip() for l in fichier.read_text(encoding="utf-8").splitlines()] valeurs = [l for l in lignes if l] if len(valeurs) != 1 or ":" not in valeurs[0]: raise ContexteInvalide( f"{fichier} doit porter UNE valeur, `site:` ou `locataire:` " f"(lu : {valeurs or 'rien'}).") nature, nom = (x.strip() for x in valeurs[0].split(":", 1)) if nature == "site": return Site.charger(nom, dossier) if nature == "locataire": return Locataire.charger(nom, dossier) raise ContexteInvalide(f"{fichier} : nature « {nature} » inconnue ({' ou '.join(NATURES)}).") def montes(racine: Path = RACINE) -> list[Ecosysteme]: """Ce que les INDICES D'AVANT designent : variables `SETOPS_*`, puis liens a la racine. Garde le temps de la bascule, et pour la dire : sur le poste d'aujourd'hui, cette liste rend DEUX ecosystemes, ce qu'un selecteur ne doit plus faire. """ # LES DEUX RESOLUTIONS QUI FONT DEJA FOI, pas une troisieme copie (P41) : # `instance_courante()` lit `SETOPS_INSTANCE`, puis le lien `instance/` ; `underlay.chemin()` # lit `SETOPS_UNDERLAY`, puis le lien `underlay.yml`. Quand on interroge une AUTRE racine que # celle du moteur (les tests), leurs liens par defaut sont remplaces par ceux de cette racine. racine = Path(racine) autre_racine = racine.resolve() != RACINE.resolve() out: list[Ecosysteme] = [] inst_p = instance_courante() if autre_racine and inst_p == RACINE.joinpath("instance"): inst_p = racine.joinpath("instance") if not inst_p.is_absolute(): inst_p = racine / inst_p if (inst_p / "plan" / "nomenclature.yml").is_file(): out.append(Locataire(inst_p.resolve())) und_p = underlay_mod.chemin() if autre_racine and (und_p is None or und_p == RACINE.joinpath("underlay.yml")): und_p = racine.joinpath("underlay.yml") if und_p is not None and not und_p.is_absolute(): und_p = racine / und_p if und_p is not None and und_p.is_file(): out.append(Site(und_p.resolve().parent)) return out def actif(racine: Path = RACINE, dossier: Path | None = None) -> Ecosysteme | None: """LE contexte actif de cette machine. 1. Le fichier `contexte` a la racine du moteur, s'il existe : il fait foi. 2. Sinon, les indices d'avant (`montes`). S'ils designent un locataire ET un site — le poste d'aujourd'hui —, le locataire l'emporte : c'est ce que fait deja la console (`ConsolePoste` herite de `ConsoleLocataire`). Le fichier `contexte` leve l'ambiguite. 3. Rien : None. A l'appelant de le dire, jamais de le dessiner comme un plan vide. """ dossier = Path(dossier) if dossier is not None else Path(racine).parent fichier = Path(racine) / NOM_FICHIER_CONTEXTE if fichier.is_file(): return _lire_fichier_contexte(fichier, dossier) m = montes(racine) return m[0] if m else None def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--json", action="store_true") ap.add_argument("--fiche", metavar="LOCATAIRE", help="la fiche que son hebergeur lui destine") ap.add_argument("--deposer-fiches", action="store_true", help="chaque site ecrit sa fiche chez ses locataires (hebergeur actif)") ap.add_argument("--verifier-fiches", action="store_true", help="chaque fiche dit-elle ce que le locataire porte ?") ap.add_argument("--face", metavar="LOCATAIRE", help="la face reseau qu'il publie pour son site") ap.add_argument("--verifier-faces", action="store_true", help="chaque face reseau dit-elle ce que le site en tire ?") args = ap.parse_args(argv) if args.face: print(yaml.safe_dump(Locataire.charger(args.face).face_reseau(), allow_unicode=True, sort_keys=False), end="") return 0 if args.verifier_faces: couples = couples_hebergeur() if not couples: print("Aucun site frere : aucune face reseau a verifier.") return 0 total = 0 for site, loc in couples: face = loc.face_reseau() for quoi, e in (("faits", verifier_face(site, loc, face)), ("flux ", verifier_flux(site, loc, face)), ("frontiere (identites)", verifier_frontiere(site, loc, face)), ("frontiere (entrees publiques)", verifier_entrees_publiques(site, loc, face)), ("frontiere (administration)", verifier_administration(site, loc, face)), ("frontiere (sorties)", verifier_sorties(site, loc, face))): total += len(e) print(f"{'OK ' if not e else 'ECART'} {loc.nom} -> {site.nom} ({quoi})" + "".join(f"\n - {x}" for x in e)) return 1 if total else 0 if args.fiche: loc = Locataire.charger(args.fiche) site = loc.site() if site is None: print(f"{loc.nom} ne nomme aucun hebergeur.") return 1 print(yaml.safe_dump(site.fiche_pour(loc), allow_unicode=True, sort_keys=False), end="") return 0 if args.deposer_fiches: couples = couples_hebergeur() if not couples: print("Aucun site frere : aucune fiche a deposer.") return 0 for site, loc in couples: chemin = site.deposer_fiche(loc) print(f" {site.nom} -> {chemin}") print("Deposees. Les commiter dans le depot de chaque locataire.") return 0 if args.verifier_fiches: couples = couples_hebergeur() if not couples: print("Aucun site frere : aucune fiche a verifier.") return 0 total = 0 for site, loc in couples: e = verifier_fiche(site, loc) total += len(e) print(f"{'OK ' if not e else 'ECART'} {site.nom} -> {loc.nom}" + "".join(f"\n - {x}" for x in e)) return 1 if total else 0 try: eco = actif() erreur = None except ContexteInvalide as e: eco, erreur = None, str(e) fichier = RACINE / NOM_FICHIER_CONTEXTE rapport = { "source": "fichier `contexte`" if fichier.is_file() else "indices d'avant (liens, variables)", "actif": eco.decrire() if eco else None, "erreur": erreur, "indices": [e.decrire() for e in montes()], } if args.json: print(json.dumps(rapport, indent=2, ensure_ascii=False)) return 0 if eco else 1 print(f"Source : {rapport['source']}") if erreur: print(f"REFUS : {erreur}") elif eco: d = eco.decrire() print(f"Contexte actif : {d['nature']} {d['nom']} (index {d['index']})") if isinstance(eco, Site): print(f" locataires : {', '.join(d['locataires']) or 'aucun'}" + (f" ; ABSENTS : {', '.join(d['locataires_absents'])}" if d['locataires_absents'] else "")) else: print(f" hebergeur actif : {d['hebergeur'] or 'aucun nomme'}") else: print("Aucun contexte : ni fichier `contexte`, ni locataire, ni site monte.") if len(rapport["indices"]) > 1: print(" NOTE : les indices d'avant designent " + " ET ".join(f"{i['nature']} {i['nom']}" for i in rapport["indices"]) + " — un selecteur n'en garde qu'un (fichier `contexte`).") return 0 if eco else 1 if __name__ == "__main__": sys.exit(main())