contexte : etape 3, le locataire publie sa face reseau, le site la lit (P92)

face-reseau.yml publiee chez chaque locataire (make face-reseau-publier) ;
les comptes de sauvegarde et le DNS public du site la lisent au lieu des
fichiers internes. Inventaire du site identique, octet pour octet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-05 12:39:28 -04:00
parent 2beef04dc6
commit 44c24e62e4
11 changed files with 181 additions and 11 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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