contexte : etape 3, l'instancier lit la fiche du site (P91)

Le site depose fiche-site.yml chez ses locataires (make fiches-site-
deposer) ; l'instancier y lit temps, delegation DNS et routage. Sans le
site, l'inventaire genere est identique au versionne, a l'octet pres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-05 12:23:17 -04:00
parent d6591cb271
commit 2beef04dc6
11 changed files with 218 additions and 8 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 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.**

View file

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

View file

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

View file

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

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

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

View file

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

View file

@ -47,6 +47,9 @@ DOSSIER_ECOSYSTEMES = RACINE.parent
# Le contexte actif, nomme (decision du 2026-10-04) : une seule valeur, `site:<depot>` ou
# `locataire:<depot>`. 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:

View file

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

View file

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

View file

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