diff --git a/AGENTS.md b/AGENTS.md index 5a2bc95..b23af30 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 91 preuves de `make prouver` : elles lisent le dépôt, sans le +C'est aussi ce que font les 92 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 f194798..83a1e90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # CHANGELOG — Set-OPS +## 2026-10-05 (91) — Étape 3 : le locataire publie sa face réseau, et le site la lit + +**Le chemin** (`docs/conception-contextes.md` §6), étape 3, sens locataire → site. Par +symétrie avec la fiche du site, le site lit un fichier que le locataire **publie**, au lieu +d'ouvrir ses fichiers internes. + +**Fait** : +- `locataire.publier_face()` écrit `face-reseau.yml` dans le dépôt du locataire (environ + 33 Ko, aucun chemin propre au poste, aucune date : deux publications sont identiques, + vérifié) ; `make face-reseau-publier`, au runbook « Matérialiser » juste après `flux` ; + publiée chez OPS-Chezlepro et OPS-Technolibre ; +- **deux consommateurs du site basculent** (`site_inventaire.py`) : les **comptes de + sauvegarde** lisent la clé publique dans la face réseau (plus de `group_vars` ouverts + sous un `principal` écrit en dur) ; les **relations du DNS public** y lisent zones, zones + signées, adresse et port du primaire (plus d'adresse re-dérivée avec la séquence 1 par + défaut). Sans face publiée, l'ancienne lecture reprend, et le dit. + +**Le critère** : l'inventaire du site, sortie complète, est **identique** avant et après, +octet pour octet ; identique aussi par le repli, quand on retire les faces publiées. + +**Éprouvé** : +- **P92**, « Chaque locataire a publié sa face réseau à jour » : une face absente ou périmée + est refusée. Aucun écart. +- `test_contexte.py`, 100 contrôles : une face publiée altérée est vue périmée. +- `make verifier` conforme, **92/92**. Les trois documents comptent 92 preuves. + +**Restent à basculer** : la frontière, le pare-feu Proxmox, le SDN, les pools, le placement +et le clonage, qui lisent encore les fichiers internes des locataires. + +**À commiter** : `face-reseau.yml` chez les deux locataires. + ## 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 diff --git a/Makefile b/Makefile index 0d6d0c2..b078b16 100644 --- a/Makefile +++ b/Makefile @@ -870,6 +870,11 @@ 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 +face-reseau-publier: ## Chaque locataire publie sa face reseau pour son site (a commiter chez lui, apres `make flux`) + @# LE LOCATAIRE PUBLIE, LE SITE LIT (2026-10-05). Le site lisait les fichiers internes de ses + @# locataires ; il lit desormais `face-reseau.yml`. P92 refuse une face perimee. + python3 scripts/contexte.py --publier-faces + 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, diff --git a/docs/conception-contextes.md b/docs/conception-contextes.md index ea85f67..cb8c344 100644 --- a/docs/conception-contextes.md +++ b/docs/conception-contextes.md @@ -181,7 +181,8 @@ jetait : les clients nommés d'un port aussi public, que Proxmox doit admettre n 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). É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). +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 et le DNS public du site la lisent. 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 309022c..08c4730 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 91 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le +`scripts/prouver.py` porte 92 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 054fd0b..9f1877a 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 (91 preuves, zéro réseau) +make prouver le dépôt est-il cohérent avec lui-même (92 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 592b27b..09bb0e0 100644 --- a/docs/runbooks-construction.yml +++ b/docs/runbooks-construction.yml @@ -326,6 +326,12 @@ runbooks: pourquoi: >- Regenerer le registre des flux et les regles nftables depuis les `meta/flux.yml` des roles. Tout ce qui suit en descend. + - cible: face-reseau-publier + nature: ecriture + pourquoi: >- + Publier ce que ce locataire demande a son site (`face-reseau.yml`) : ses machines, + ses zones, ses flux deja resolus. Le site ne lit plus que ce fichier. Apres `flux`, + et a commiter dans le depot du locataire. - cible: devis-proxmox-fw nature: mesure pourquoi: "Le pare-feu est-ouest intra-locataire, derive du registre des flux." diff --git a/scripts/contexte.py b/scripts/contexte.py index 079f4ba..74479a3 100644 --- a/scripts/contexte.py +++ b/scripts/contexte.py @@ -50,6 +50,8 @@ 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" +# Ce que le locataire publie pour son site : sa face reseau (meme decision, sens inverse). +NOM_FICHIER_FACE_RESEAU = "face-reseau.yml" NATURES = ("site", "locataire") @@ -368,6 +370,25 @@ class Locataire(Ecosysteme): return str(d["domaine_interne"]).strip() return "" + def publier_face(self) -> Path: + """Ecrit dans SON depot la face reseau qu'il destine a son site. Rend le chemin. + + Le runner du site porte les depots de ses locataires : il lira ce fichier, et plus + aucun fichier interne (§2.4). Aucune date : une publication qui ne change rien ne + fabrique pas de difference. + """ + chemin = self.depot / NOM_FICHIER_FACE_RESEAU + entete = (f"# GENERE par le locataire {self.nom} (scripts/contexte.py --publier-faces).\n" + "# Ne pas editer : c'est ce que ce locataire demande a son site. A republier\n" + "# apres `make instancier` ou `make flux`. Voir docs/conception-contextes.md §2.4.\n") + chemin.write_text(entete + yaml.safe_dump(self.face_reseau(), allow_unicode=True, + sort_keys=False), encoding="utf-8") + return chemin + + def face_publiee(self) -> dict | None: + """La face reseau qu'il a publiee, ou None.""" + return _yaml(self.depot / NOM_FICHIER_FACE_RESEAU) or None + 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) @@ -1033,6 +1054,18 @@ def verifier_fiche_deposee(site: "Site", locataire: "Locataire") -> list[str]: return [] +def verifier_face_publiee(locataire: "Locataire") -> list[str]: + """La face reseau que le locataire a publiee est-elle celle qu'il calcule aujourd'hui ?""" + publiee = locataire.face_publiee() + if publiee is None: + return [f"aucune `{NOM_FICHIER_FACE_RESEAU}` : la publier (`python3 scripts/contexte.py --publier-faces`)"] + calculee = locataire.face_reseau() + if publiee != calculee: + cles = sorted(k for k in set(publiee) | set(calculee) if publiee.get(k) != calculee.get(k)) + return [f"`{NOM_FICHIER_FACE_RESEAU}` perimee ({', '.join(cles)}) : la republier 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 ? @@ -1137,6 +1170,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("--publier-faces", action="store_true", + help="chaque locataire ecrit sa face reseau dans son depot") 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", @@ -1175,6 +1210,15 @@ 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.publier_faces: + locataires = sorted({loc for _s, loc in couples_hebergeur()}, key=lambda l: l.nom) + if not locataires: + print("Aucun locataire frere : aucune face reseau a publier.") + return 0 + for loc in locataires: + print(f" {loc.nom} -> {loc.publier_face()}") + print("Publiees. Les commiter dans le depot de chaque locataire.") + return 0 if args.deposer_fiches: couples = couples_hebergeur() if not couples: diff --git a/scripts/prouver.py b/scripts/prouver.py index 8cde109..425b130 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -4855,6 +4855,25 @@ def preuve_inventaire_portable() -> tuple[bool, str]: f"sans le site identique a l'octet pres.") +def preuve_face_publiee() -> tuple[bool, str]: + """Chaque locataire a-t-il publie (`face-reseau.yml`) la face reseau qu'il calcule aujourd'hui ? + + POURQUOI (2026-10-05, `docs/conception-contextes.md` §6, etape 3). Les consommateurs du site + lisent desormais ce fichier au lieu des fichiers internes du locataire. Une face perimee leur + ferait poser une configuration fausse ; une face absente les ferait retomber sur l'ancienne + lecture. Les deux sont refusees ici. A republier apres `make instancier` ou `make flux`. + """ + sys.path.insert(0, str(RACINE / "scripts")) + import contexte as _ctx + locataires = sorted({l for _s, l in _ctx.couples_hebergeur()}, key=lambda l: l.nom) + if not locataires: + return True, "Aucun locataire frere : aucune face publiee a confronter." + ecarts = [f"{l.nom} : {e}" for l in locataires for e in _ctx.verifier_face_publiee(l)] + if ecarts: + return False, f"{len(ecarts)} ecart(s) : " + " | ".join(ecarts[:4]) + return True, f"{len(locataires)} locataire(s) : face reseau publiee, a jour." + + PREUVES: list[dict] = [ {"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"], "cmds": [["ansible-lint", "-q"]]}, @@ -5057,6 +5076,8 @@ PREUVES: list[dict] = [ "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}, + {"id": "P92", "titre": "Chaque locataire a publie sa face reseau a jour", + "refs": [], "func": preuve_face_publiee}, ] diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index 311eaa4..173ba28 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -145,6 +145,23 @@ def _charger(nom: str) -> dict: return yaml.safe_load(f.read_text(encoding="utf-8")) or {} +def _face_publiee(depot: Path) -> dict | None: + """La face reseau que ce locataire a publiee (`face-reseau.yml`), ou None. + + Sans elle, les lecteurs retombent sur les fichiers internes du locataire, et le disent : une + transition, que P92 empeche de durer (une face absente ou perimee y est un echec). + """ + try: + import contexte + face = contexte.Locataire(depot).face_publiee() + except Exception: + face = None + if face is None: + print(f"note : {depot.name} n'a pas publie sa face reseau — lecture de ses fichiers " + f"internes (transition).", file=sys.stderr) + return face + + def inventaire() -> dict: u = U.charger() if u is None: @@ -210,13 +227,21 @@ def inventaire() -> dict: except Exception: return out for nom_depot, prefixe, _n in decouverts: - gv = (racine_instances / nom_depot / "inventories" / "principal" - / "group_vars" / "serveur_backup.yml") - try: - cle = str((yaml.safe_load(gv.read_text(encoding="utf-8")) or {}) - .get("serveur_backup_pubkey") or "").strip() - except OSError: - cle = "" + # LA FACE RESEAU D'ABORD (2026-10-05, docs/conception-contextes.md §6, etape 3). Le + # locataire PUBLIE la cle qu'il depose (`face-reseau.yml`) ; le site ne fouille plus + # ses `group_vars`, dont le chemin etait ecrit en dur (`principal`). Sans face + # publiee (transition), l'ancienne lecture, et on le dit. + face = _face_publiee(racine_instances / nom_depot) + if face is not None: + cle = str((face.get("sauvegarde") or {}).get("cle_publique") or "").strip() + else: + gv = (racine_instances / nom_depot / "inventories" / "principal" + / "group_vars" / "serveur_backup.yml") + try: + cle = str((yaml.safe_load(gv.read_text(encoding="utf-8")) or {}) + .get("serveur_backup_pubkey") or "").strip() + except OSError: + cle = "" # Un locataire sans cle publique declaree n'est pas une erreur du site : # c'est un tenant qui n'a pas encore de sauvegarde. On ne lui ouvre pas de # compte, et surtout on n'en ouvre pas un SANS cle — il accepterait alors @@ -253,6 +278,22 @@ def inventaire() -> dict: except Exception: return out for nom_depot, _prefixe, _n in decouverts: + # LA FACE RESEAU D'ABORD (meme etape 3) : le locataire publie ses zones, celles qui + # sont signees, l'adresse et le port de son primaire — l'adresse lue dans SON + # inventaire, que le site re-derivait avec la sequence 1 par defaut. + face = _face_publiee(racine_instances / nom_depot) + if face is not None: + dp = face.get("dns_public") or {} + if dp.get("zones") and dp.get("primaire"): + slug = nom_depot.lower().removeprefix("ops-") + out.append({"ecosysteme": nom_depot, + "cle_tsig": f"tsig-{slug}", + "secret_var": f"vault_tsig_dns_public_{slug.replace('-', '_')}", + "primaire": dp["primaire"], + "port": dp.get("port"), + "zones": list(dp.get("zones") or []), + "signees": list(dp.get("signees") or [])}) + continue plan = racine_instances / nom_depot / "plan" try: doms = (yaml.safe_load((plan / "domaines.yml").read_text(encoding="utf-8")) diff --git a/scripts/tests/test_contexte.py b/scripts/tests/test_contexte.py index 5d9df94..bfaf487 100644 --- a/scripts/tests/test_contexte.py +++ b/scripts/tests/test_contexte.py @@ -450,6 +450,26 @@ def fiche_deposee() -> None: chemin.write_text(original, encoding="utf-8") +def face_publiee() -> None: + """Chaque locataire a publie sa face reseau a jour — et une face publiee alteree se voit.""" + couples = C.couples_hebergeur() + if not couples: + print("SAUTE aucun site frere : aucune face publiee a confronter") + return + for _site, loc in sorted({(s.nom, l) for s, l in couples}, key=lambda x: x[1].nom): + e = C.verifier_face_publiee(loc) + verifier(not e, f"{loc.nom} a publie sa face reseau a jour" + (f" ({e[0]})" if e else "")) + loc = couples[0][1] + chemin = loc.depot / C.NOM_FICHIER_FACE_RESEAU + original = chemin.read_text(encoding="utf-8") + try: + chemin.write_text(original.replace("cle_publique: ssh-ed25519", "cle_publique: ssh-rsa", 1), + encoding="utf-8") + verifier(bool(C.verifier_face_publiee(loc)), "une face publiee alteree est vue perimee") + finally: + chemin.write_text(original, encoding="utf-8") + + def main() -> int: with tempfile.TemporaryDirectory() as tmp: d = Path(tmp) @@ -466,6 +486,7 @@ def main() -> int: administration() sorties() fiche_deposee() + face_publiee() if ECHECS: print(f"\n{len(ECHECS)} echec(s).") return 1