contexte : etape 2a, la fiche du site et la preuve qu elle dit vrai

site.fiche_pour(locataire) reunit attribution, offre, racine et les
trois lectures directes de l instancier ; verifier_fiche la confronte
aux copies, a la racine et a l inventaire du locataire. P84 : aucune
ecart sur les deux couples ; sept alterations vues en ecart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-04 20:16:55 -04:00
parent c891d7fb26
commit 8251022148
7 changed files with 333 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 83 preuves de `make prouver` : elles lisent le dépôt, sans le
C'est aussi ce que font les 84 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,44 @@
# CHANGELOG — Set-OPS
## 2026-10-04 (80) — Étape 2, première moitié : la fiche du site, et la preuve qu'elle dit vrai
**Le chemin** (`docs/conception-contextes.md` §6), étape 2 : générer les fiches **à côté** de
l'existant, et prouver qu'elles disent exactement ce que les lectures croisées produisent.
Première moitié : le sens site → locataire. La face réseau du locataire suit.
**Fait** (`scripts/contexte.py`) :
- `site.fiche_pour(locataire)` réunit ce que trois canaux portaient sans fiche : ce que le site
**attribue** (index, adresse publique), ce qu'il **offre** (les intrants de service,
calculés par `site_intrants.contrat()`), sa **racine de confiance** (le certificat
lui-même), et ce que l'instancier **lisait directement** dans le site (zone et résolveurs à
déléguer, serveurs de temps, mode de routage), par les mêmes fonctions qu'aujourd'hui. La
fiche porte l'empreinte de ses quatre sources.
- `verifier_fiche(site, locataire)` confronte la fiche à ce que le locataire porte : ses
copies (la table de `site_intrants`), sa racine, et son inventaire généré (`chrony_serveurs`
sur chaque machine, la délégation DNS sur la machine qui résout, le rattachement SDN).
- `locataire.domaine_interne()`, lu comme le lit `instancier._domaine_interne`. La première
version le cherchait dans `00-instance.yml` ; il est dans `10-intrants.yml`. La comparaison
passait quand même, parce que les domaines du site et du locataire diffèrent : relevé et
corrigé avant le commit.
- `_monter(site, locataire)` : les modules d'avant lisent « le site monté » ; la fiche leur
désigne le site voulu le temps de l'appel, puis remet l'environnement, même sur exception.
Passerelle de transition, jusqu'à l'étape 3.
- `python3 scripts/contexte.py --fiche <locataire>` affiche la fiche ;
`--verifier-fiches` confronte chaque couple dont le site est l'hébergeur **actif** (un site
de reprise ne dépose rien tant que la bascule n'a pas eu lieu).
**Rien n'est encore déposé ni consommé** : la fiche est calculée, pas écrite chez le locataire.
Le dépôt et la bascule de l'instancier sont l'étape 3, à inventaire identique.
**Éprouvé** :
- **P84**, « La fiche du site dit exactement ce que chaque locataire porte » : 2 fiches,
SITE-Chezlepro → OPS-Chezlepro et → OPS-Technolibre, aucun écart.
- `test_contexte.py`, 50 contrôles : les deux fiches réelles concordent, et une fiche altérée
de **sept façons** (index, adresse publique, intrant de service, racine, serveurs de temps,
délégation DNS, mode de routage) est vue en écart à chaque fois.
- `make verifier` conforme, **84/84**. `AGENTS.md`, `devis-services.md` et
`responsabilites-locataire-hebergeur.md` comptent désormais 84 preuves.
## 2026-10-04 (79) — Étape 1 : le module `contexte.py`, que rien n'utilise encore
**Le chemin** (`docs/conception-contextes.md` §6), premier pas : le tronc commun et les deux

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 83 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
`scripts/prouver.py` porte 84 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 (83 preuves, zéro réseau)
make prouver le dépôt est-il cohérent avec lui-même (84 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

@ -12,18 +12,24 @@ site affichant zero machine, `make ci` melangeant le modele public et les ecosys
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`.
ETAPE 1 DU CHEMIN (§6) : le module existe, il est teste, et RIEN NE L'UTILISE ENCORE. Les
scripts l'adopteront un par un, chacun a resultat identique. Les fiches du contrat
(`site.fiche_pour`, `locataire.face_reseau`) sont l'etape 2.
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 # 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 sys
from pathlib import Path
@ -53,6 +59,35 @@ def _yaml(chemin: Path) -> dict:
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:
@ -210,6 +245,50 @@ class Site(Ecosysteme):
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 decrire(self) -> dict:
d = super().decrire()
d.update({"locataires": self.noms_locataires(),
@ -258,6 +337,19 @@ class Locataire(Ecosysteme):
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 nom_hebergeur(self) -> str | None:
h = ((self.filiation.get("depots") or {}).get("hebergeur") or {}).get("nom")
return str(h) if h else None
@ -275,6 +367,102 @@ class Locataire(Ecosysteme):
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 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:
@ -343,7 +531,30 @@ def actif(racine: Path = RACINE, dossier: Path | None = None) -> Ecosysteme | No
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("--verifier-fiches", action="store_true",
help="chaque fiche dit-elle ce que le locataire porte ?")
args = ap.parse_args(argv)
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.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

View file

@ -4665,6 +4665,33 @@ def preuve_runbooks_suivent_le_makefile() -> tuple[bool, str]:
f"chacune portee par un assistant ou exemptee avec son motif.")
def preuve_fiche_du_site() -> tuple[bool, str]:
"""La fiche que chaque site destine a ses locataires dit-elle ce qu'ils portent deja ?
POURQUOI (2026-10-04, `docs/conception-contextes.md` §2.4). Le site informait le locataire
par trois canaux, sans fiche : des copies ecrites a la main (index, adresse publique,
intrants, racine), une lecture DIRECTE du site par l'instancier (temps, delegation DNS,
routage SDN), et le genome. La fiche les reunit. Avant qu'un seul consommateur bascule sur
elle (etape 3), elle doit dire EXACTEMENT ce que ces canaux ont depose : sinon la bascule
changerait la flotte au lieu de la decrire.
Pour chaque couple (site, locataire) dont le site est l'hebergeur ACTIF : ecarts entre
`site.fiche_pour(locataire)` et les copies du locataire, sa racine, et son inventaire
genere. `scripts/tests/test_contexte.py` prouve que chaque sorte d'ecart est vue.
"""
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 a confronter."
ecarts = [f"{s.nom} -> {l.nom} : {e}" for s, l in couples for e in _ctx.verifier_fiche(s, l)]
if ecarts:
return False, (f"{len(ecarts)} ecart(s) entre la fiche du site et ce que le locataire "
f"porte : " + " | ".join(ecarts[:4]))
return True, (f"{len(couples)} fiche(s) de site : chacune dit exactement ce que son locataire "
f"porte (copies, racine, inventaire genere).")
PREUVES: list[dict] = [
{"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"],
"cmds": [["ansible-lint", "-q"]]},
@ -4851,6 +4878,8 @@ PREUVES: list[dict] = [
"refs": [], "func": preuve_dns_public_tient},
{"id": "P83", "titre": "Assistants : le registre des runbooks ne prend pas de retard sur le Makefile",
"refs": [], "func": preuve_runbooks_suivent_le_makefile},
{"id": "P84", "titre": "La fiche du site dit exactement ce que chaque locataire porte",
"refs": [], "func": preuve_fiche_du_site},
]

View file

@ -9,7 +9,8 @@ Sur une arborescence FABRIQUEE (un site, deux locataires, un depot ambigu), on v
locataire l'emporte quand les deux sont montes (le poste d'aujourd'hui) ;
- un contexte nomme qui ne designe rien est un REFUS, pas un contexte vide.
Puis, si les vrais depots sont la : chaque locataire declare par un site porte l'index que ce
site lui attribue, et nomme un hebergeur qui existe.
site lui attribue, et nomme un hebergeur qui existe ; la fiche de chaque hebergeur dit
exactement ce que son locataire porte, et une fiche alteree (sept facons) est vue en ecart.
"""
from __future__ import annotations
@ -154,6 +155,49 @@ def vrais_depots() -> None:
f"{site.nom} : aucun locataire declare sans depot ({site.locataires_absents() or 'ok'})")
def monter_restaure() -> None:
avant = {k: os.environ.get(k) for k in ("SETOPS_INSTANCE", "SETOPS_UNDERLAY")}
try:
with C._monter(C.Site(Path("/nulle/SITE")), C.Locataire(Path("/nulle/OPS"))):
dedans = (os.environ.get("SETOPS_UNDERLAY"), os.environ.get("SETOPS_INSTANCE"))
raise RuntimeError("sortie par exception")
except RuntimeError:
pass
apres = {k: os.environ.get(k) for k in ("SETOPS_INSTANCE", "SETOPS_UNDERLAY")}
verifier(dedans == ("/nulle/SITE/underlay.yml", "/nulle/OPS") and apres == avant,
"`_monter` designe le site et le locataire voulus, puis remet l'environnement, meme sur exception")
def fiches() -> None:
"""La fiche du site dit ce que chaque locataire porte — et `verifier_fiche` voit chaque ecart."""
import copy
couples = C.couples_hebergeur()
if not couples:
print("SAUTE aucun site frere : aucune fiche a confronter")
return
for site, loc in couples:
e = C.verifier_fiche(site, loc)
verifier(not e, f"fiche {site.nom} -> {loc.nom} : identique a ce que le locataire porte"
+ (f" ({e[0]})" if e else ""))
site, loc = couples[0]
juste = site.fiche_pour(loc)
alterations = {
"index": lambda f: f["attribue"].__setitem__("index", (f["attribue"]["index"] or 0) + 1),
"adresse publique": lambda f: f["attribue"].__setitem__("ip_publique", "192.0.2.99"),
"intrant de service": lambda f: f["offre"].__setitem__("dns_amorcage", "192.0.2.53"),
"racine de confiance": lambda f: f["racine_confiance"].__setitem__("sha256", "0" * 64),
"serveurs de temps": lambda f: f["generation"].__setitem__("serveurs_temps", ["192.0.2.123"]),
"delegation DNS": lambda f: f["generation"].__setitem__("resolveurs_site", ["192.0.2.1"]),
"mode de routage": lambda f: f["generation"].__setitem__(
"routage_tenants", "switch" if f["generation"]["routage_tenants"] == "sdn" else "sdn"),
}
for nom, alterer in alterations.items():
f = copy.deepcopy(juste)
alterer(f)
verifier(bool(C.verifier_fiche(site, loc, f)),
f"une fiche alteree ({nom}) est vue en ecart")
def main() -> int:
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
@ -161,6 +205,8 @@ def main() -> int:
classes(d)
contexte_actif(d, moteur)
vrais_depots()
monter_restaure()
fiches()
if ECHECS:
print(f"\n{len(ECHECS)} echec(s).")
return 1