diff --git a/AGENTS.md b/AGENTS.md index 4c2f363..5a2bc95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,7 +188,7 @@ Si `ansible-lint` n’est pas disponible, le signaler clairement. Ne pas invente ## Écrire, puis relire (D-68) `--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**. -C'est aussi ce que font les 90 preuves de `make prouver` : elles lisent le dépôt, sans le +C'est aussi ce que font les 91 preuves de `make prouver` : elles lisent le dépôt, sans le moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le dépôt annonce.** diff --git a/CHANGELOG.md b/CHANGELOG.md index bb58006..f194798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # CHANGELOG — Set-OPS +## 2026-10-05 (90) — Étape 3, premier consommateur : l'inventaire d'un locataire se génère sans son site + +**Le chemin** (`docs/conception-contextes.md` §6), étape 3 : les consommateurs lisent les +fiches au lieu des fichiers de l'autre, à résultat identique. Premier consommateur : +l'instancier. + +**Le site dépose sa fiche** (décision du 2026-10-04) : +- `site.deposer_fiche(locataire)` écrit `fiche-site.yml` dans le dépôt du locataire ; aucune + date dedans, deux dépôts d'une même fiche sont identiques (vérifié) ; +- `make fiches-site-deposer` (runbook « Tenir un site en état ») dépose chez chaque locataire + dont le site est l'hébergeur actif ; le fichier se commite chez le locataire ; +- déposée chez OPS-Chezlepro et OPS-Technolibre. + +**L'instancier la lit** : la zone du site et ses résolveurs (avec sa règle propre, pas de +délégation de sa propre zone), les serveurs de temps, le mode de routage, viennent de +`fiche-site.yml`. Sans fiche, il lit encore le site monté, et le dit (transition). + +**Le critère, mesuré en mémoire sans rien écrire**, pour les deux locataires : + +| | inventaire généré | +|---|---| +| site monté, avec fiche | **identique** au versionné, à l'octet près | +| **site absent, avec fiche** | **identique** à l'octet près | +| site absent, sans fiche | différent (sans temps, sans SDN, sans délégation DNS) | + +C'est la portabilité d'un locataire : son runner, qui n'a pas le dépôt de son site, génère +désormais l'inventaire exact. + +**Éprouvé** : +- **P91**, « La fiche du site est déposée à jour, et l'inventaire se génère sans le site » : + la fiche déposée est celle que le site calcule aujourd'hui, et l'instancier, lancé dans un + processus sans le site, rend l'inventaire versionné. Aucun écart chez les deux locataires. +- `test_contexte.py`, 97 contrôles : une fiche déposée altérée est vue périmée, et elle change + l'inventaire généré (le test la remet en place). +- `make verifier` conforme, **91/91**. Les trois documents comptent 91 preuves. + +**À commiter** : `fiche-site.yml` chez les deux locataires. + ## 2026-10-05 (89) — Étape 2 terminée : les sorties, et le contrat entier tient **Le chemin** (`docs/conception-contextes.md` §6), étape 2, frontière, dernier temps : ce qui diff --git a/Makefile b/Makefile index 8a88912..0d6d0c2 100644 --- a/Makefile +++ b/Makefile @@ -610,7 +610,7 @@ site: ansible-runtime ## Regenere playbooks/site.yml depuis les couches et le gr site-verifier: ## Verifie que playbooks/site.yml correspond aux couches declarees python3 scripts/orchestrer.py verifier -.PHONY: flux flux-verifier +.PHONY: flux flux-verifier fiches-site-deposer cles-recenser: ## Montre ce qui n'existe QUE sur ce poste (sans rien ecrire) python3 scripts/exporter_cles.py --recenser @@ -870,6 +870,12 @@ site-intrants: ## Les intrants que le SITE monte expose a ses locataires (derive site-intrants-verifier: ## Le locataire monte suit-il encore son site ? (aucune ecriture) python3 scripts/site_intrants.py --verifier +fiches-site-deposer: ## Le site depose chez chacun de ses locataires la fiche qu'il lui destine (a commiter chez eux) + @# LE SITE DEPOSE, LE LOCATAIRE LIT (2026-10-05). Le runner d'un locataire n'a pas le depot + @# de son site : ce que le site lui attribue et lui offre lui parvient par ce fichier, + @# `fiche-site.yml`, que l'instancier lit. P91 refuse une fiche perimee. + python3 scripts/contexte.py --deposer-fiches + .PHONY: vpn-admin-plan vpn-admin-appliquer vpn-admin-plan: ## Acces WireGuard des admins : ce que la frontiere porte face au plan (aucune ecriture) python3 scripts/vpn_admin.py plan diff --git a/docs/conception-contextes.md b/docs/conception-contextes.md index 6671ed8..ea85f67 100644 --- a/docs/conception-contextes.md +++ b/docs/conception-contextes.md @@ -180,7 +180,8 @@ croisées produisent. La première mesure des flux a trouvé une information que jetait : les clients nommés d'un port aussi public, que Proxmox doit admettre nommément. Il les publie désormais (`sources_declarees`). La frontière, en trois temps : les identités (P87), les entrées publiques (P88), l'administration (P89) et les sorties (P90) sont faites. -**L'étape 2 est terminée** (2026-10-05). +**L'étape 2 est terminée** (2026-10-05). Étape 3 : l'instancier lit la fiche déposée par le site, et +l'inventaire d'un locataire se génère sans son site, à l'octet près (P91). Méthodes du contrat : `site.fiche_pour(locataire)`, `locataire.face_reseau()`. Une classe n'ouvre jamais les fichiers de l'autre ; une preuve vérifiera la règle. diff --git a/docs/devis-services.md b/docs/devis-services.md index 03c237d..309022c 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met ## Le trou qu'il comble -`scripts/prouver.py` porte 90 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le +`scripts/prouver.py` porte 91 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est cohérent **avec lui-même** — que les handlers existent, que les intrants ont un propriétaire, que rien n'est codé en dur. diff --git a/docs/responsabilites-locataire-hebergeur.md b/docs/responsabilites-locataire-hebergeur.md index 5d69057..054fd0b 100644 --- a/docs/responsabilites-locataire-hebergeur.md +++ b/docs/responsabilites-locataire-hebergeur.md @@ -99,7 +99,7 @@ Nommer un point ouvert vaut mieux qu'une ligne rassurante sans mécanisme derri Aucune de ces responsabilités n'est laissée à la parole : ``` -make prouver le dépôt est-il cohérent avec lui-même (90 preuves, zéro réseau) +make prouver le dépôt est-il cohérent avec lui-même (91 preuves, zéro réseau) make remise-verifier le second temps de la remise est-il fait, ou en retard ? (P80) make certificats-plan ce que le disque porte contre ce que la mémoire sert make expositions-plan chaque service publié répond-il, et depuis où diff --git a/docs/runbooks-construction.yml b/docs/runbooks-construction.yml index 17456fd..592b27b 100644 --- a/docs/runbooks-construction.yml +++ b/docs/runbooks-construction.yml @@ -209,6 +209,12 @@ runbooks: - cible: site-intrants nature: mesure pourquoi: "Ce que ce site expose a ses locataires, derive et non declare deux fois." + - cible: fiches-site-deposer + nature: ecriture + pourquoi: >- + Deposer chez chaque locataire la fiche que le site lui destine. Son runner n'a pas + le depot du site : c'est par elle que son inventaire se genere sans lui. A commiter + dans le depot de chaque locataire. - cible: site-verifier nature: mesure pourquoi: "Le playbook du site correspond-il encore aux couches declarees ?" diff --git a/scripts/contexte.py b/scripts/contexte.py index 0ca82bb..079f4ba 100644 --- a/scripts/contexte.py +++ b/scripts/contexte.py @@ -47,6 +47,9 @@ 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") @@ -290,6 +293,20 @@ class Site(Ecosysteme): "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(), @@ -351,6 +368,11 @@ class Locataire(Ecosysteme): 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 @@ -998,6 +1020,44 @@ def verifier_frontiere(site: "Site", locataire: "Locataire", face: dict | None = 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.""" @@ -1077,6 +1137,8 @@ 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") @@ -1113,6 +1175,16 @@ def main(argv: list[str] | None = None) -> int: 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: diff --git a/scripts/instancier.py b/scripts/instancier.py index 5193dcc..edeb9b8 100644 --- a/scripts/instancier.py +++ b/scripts/instancier.py @@ -87,6 +87,28 @@ def _domaine_interne() -> str: +def _fiche_du_site() -> dict | None: + """La fiche que l'hebergeur a deposee chez CE locataire (`fiche-site.yml`), ou None. + + ETAPE 3 DU CHEMIN (docs/conception-contextes.md §6). Trois valeurs de l'inventaire venaient + d'une lecture DIRECTE du site, monte sur le poste : la zone a deleguer et ses resolveurs, + les serveurs de temps, le mode de routage. Generer sur le runner du locataire, qui n'a pas + le depot du site, les aurait perdues. La fiche les porte, et l'inventaire devient le meme + ou qu'on le genere. Sans fiche (transition), on lit encore le site, et on le dit. + """ + try: + import contexte + return contexte.Locataire(INSTANCE.resolve()).fiche_du_site() + except Exception: + return None + + +FICHE_SITE = _fiche_du_site() +if FICHE_SITE is None: + print("note : pas de `fiche-site.yml` chez ce locataire — temps, delegation DNS et routage " + "lus directement dans le site monte (transition).", file=sys.stderr) + + def _zones_deleguees() -> list: """La zone de l'HEBERGEUR, et l'adresse a qui la demander. [] si aucune carte. @@ -116,6 +138,13 @@ def _zones_deleguees() -> list: ne delegue plus rien, donc ne peut plus nommer les services du site. C'est ce qu'on veut CONSTATER d'une emancipation, pas une panne a reparer. """ + if FICHE_SITE is not None: + gen = FICHE_SITE.get("generation") or {} + zone = str(gen.get("zone_site") or "").strip() + if not zone or zone == _domaine_interne(): + return [] + adresses = [str(a) for a in gen.get("resolveurs_site") or [] if a] + return [{"nom": zone, "adresses": adresses}] if adresses else [] try: import underlay as underlay_mod zone = str((underlay_mod.lire_plan_site("10-intrants.yml") or {}) @@ -240,8 +269,11 @@ def generer() -> dict: # La patte de la frontiere que les tenants traversent — calculee une fois. _temps_frontiere: list = [] try: - _carte = underlay_mod.charger() - if _carte: + _carte = None if FICHE_SITE is not None else underlay_mod.charger() + if FICHE_SITE is not None: + _temps_frontiere = [str(x) for x in (FICHE_SITE.get("generation") or {}) + .get("serveurs_temps") or []] + elif _carte: # LE LIEN DE TRANSIT SE DECLARE, IL NE SE DEVINE PAS. `reseau_transit()` # le derive de `passerelle_sortie` — le champ qui dit « c'est par la qu'on # sort ». Le reconnaitre a son prefixe d'adresse aurait marche ici et menti @@ -275,7 +307,9 @@ def generer() -> dict: # # L'ETIQUETTE DOIT ETRE VIDE en SDN : le VNet la porte deja. En poser une seconde # au niveau de la carte donnerait un double etiquetage. - sdn = underlay_mod.routage_tenants(underlay_mod.charger()) == "sdn" + sdn = (str((FICHE_SITE.get("generation") or {}).get("routage_tenants")) == "sdn" + if FICHE_SITE is not None + else underlay_mod.routage_tenants(underlay_mod.charger()) == "sdn") index_tenant = nomenclature.get("index") # UNE MACHINE SANS FONCTION DECLAREE N'A PAS D'ADRESSE — ET LE DISAIT PAS (2026-08-24). diff --git a/scripts/prouver.py b/scripts/prouver.py index 0066810..8cde109 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -4830,6 +4830,31 @@ def preuve_frontiere_sorties() -> tuple[bool, str]: f"face reseau publie.") +def preuve_inventaire_portable() -> tuple[bool, str]: + """Chaque locataire porte-t-il la fiche a jour de son site, et son inventaire se genere-t-il + SANS le site, a l'octet pres ? + + POURQUOI (2026-10-05, `docs/conception-contextes.md` §6, etape 3). L'instancier lisait le site + monte sur le poste pour trois valeurs (temps, delegation DNS, routage) ; genere ailleurs, + l'inventaire les perdait. Il lit desormais la fiche que le site depose. Deux conditions : la + fiche deposee est celle que le site calcule aujourd'hui ; et l'inventaire genere sans le site + est l'inventaire versionne. C'est la portabilite d'un locataire, prouvee. + """ + sys.path.insert(0, str(RACINE / "scripts")) + import contexte as _ctx + couples = _ctx.couples_hebergeur() + if not couples: + return True, "Aucun site frere : aucune fiche deposee a confronter." + ecarts = [] + for s, l in couples: + ecarts += [f"{l.nom} : {e}" for e in _ctx.verifier_fiche_deposee(s, l)] + ecarts += [f"{l.nom} : {e}" for e in _ctx.verifier_inventaire_sans_site(l)] + if ecarts: + return False, f"{len(ecarts)} ecart(s) : " + " | ".join(ecarts[:4]) + return True, (f"{len(couples)} locataire(s) : fiche du site deposee a jour, et inventaire genere " + f"sans le site identique a l'octet pres.") + + PREUVES: list[dict] = [ {"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"], "cmds": [["ansible-lint", "-q"]]}, @@ -5030,6 +5055,8 @@ PREUVES: list[dict] = [ "refs": [], "func": preuve_frontiere_administration}, {"id": "P90", "titre": "Ce qui sort d'un locataire vers l'Internet, la frontiere le tient de lui", "refs": [], "func": preuve_frontiere_sorties}, + {"id": "P91", "titre": "La fiche du site est deposee a jour, et l'inventaire se genere sans le site", + "refs": [], "func": preuve_inventaire_portable}, ] diff --git a/scripts/tests/test_contexte.py b/scripts/tests/test_contexte.py index 5e2ad45..5d9df94 100644 --- a/scripts/tests/test_contexte.py +++ b/scripts/tests/test_contexte.py @@ -425,6 +425,31 @@ def sorties() -> None: verifier(bool(C.verifier_sorties(site, loc, f)), f"une sortie alteree ({nom}) est vue a la frontiere") +def fiche_deposee() -> None: + """La fiche deposee est a jour, l'inventaire se genere sans le site — et une fiche deposee + alteree se voit.""" + couples = C.couples_hebergeur() + if not couples: + print("SAUTE aucun site frere : aucune fiche deposee a confronter") + return + for site, loc in couples: + e = C.verifier_fiche_deposee(site, loc) + verifier(not e, f"{loc.nom} porte la fiche que {site.nom} lui destine" + (f" ({e[0]})" if e else "")) + e = C.verifier_inventaire_sans_site(loc) + verifier(not e, f"{loc.nom} : l'inventaire se genere sans le site, a l'octet pres" + + (f" ({e[0]})" if e else "")) + site, loc = couples[0] + chemin = loc.depot / C.NOM_FICHIER_FICHE_SITE + original = chemin.read_text(encoding="utf-8") + try: + chemin.write_text(original.replace("10.0.4.1", "192.0.2.1"), encoding="utf-8") + verifier(bool(C.verifier_fiche_deposee(site, loc)), "une fiche deposee alteree est vue perimee") + verifier(bool(C.verifier_inventaire_sans_site(loc)), + "une fiche deposee alteree change l'inventaire genere : la preuve le voit") + finally: + chemin.write_text(original, encoding="utf-8") + + def main() -> int: with tempfile.TemporaryDirectory() as tmp: d = Path(tmp) @@ -440,6 +465,7 @@ def main() -> int: entrees_publiques() administration() sorties() + fiche_deposee() if ECHECS: print(f"\n{len(ECHECS)} echec(s).") return 1