contexte : etape 2b, la face reseau du locataire (les faits)

locataire.face_reseau() publie index, machines, groupes, administration,
zones publiques et cle de sauvegarde ; verifier_face la confronte aux
consommateurs du site (decouverte, devis Proxmox, frontiere, inventaire
du site). P85 : aucun ecart ; huit alterations vues en ecart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-04 21:02:29 -04:00
parent 8251022148
commit 077c6c91b4
7 changed files with 282 additions and 4 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 84 preuves de `make prouver` : elles lisent le dépôt, sans le
C'est aussi ce que font les 85 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,48 @@
# CHANGELOG — Set-OPS
## 2026-10-04 (81) — Étape 2, seconde moitié (les faits) : la face réseau du locataire
**Le chemin** (`docs/conception-contextes.md` §6), étape 2, sens locataire → site. La face
réseau se livre en deux temps vérifiés : d'abord les **faits** que le site lit chez le
locataire (ici), ensuite ses **flux résolus**, confrontés aux devis de la frontière et de
Proxmox (à suivre).
**Fait** (`scripts/contexte.py`) :
- `locataire.face_reseau()` publie, calculé **chez le locataire** : son index ; ses machines
(adresse, groupes, paramètres de matérialisation `proxmox_*`) ; ses groupes ; son
administration (l'intrant `nftables_admin_ssh`, son tunnel, ses pairs WireGuard avec leurs
clés **publiques**) ; ses zones publiques (zones, signées, adresse et port de son
primaire) ; la clé publique de sa sauvegarde. Elle porte l'empreinte de ses six sources.
- `verifier_face(site, locataire)` interroge **les consommateurs du site eux-mêmes**, le site
monté : la découverte des locataires (`devis_reseau`), le devis du pare-feu Proxmox (la
flotte, chaque groupe qu'il utilise, l'administration), la frontière (`admin_de`), et
l'inventaire du site (relations du DNS public, comptes de sauvegarde).
- `--face <locataire>` l'affiche ; `--verifier-faces` confronte chaque couple.
**Un faux écart, corrigé avant le commit** : la première comparaison exigeait un ensemble
d'adresses par groupe dans le devis Proxmox, et en signalait 19 absents par locataire. Le
devis ne garde que les ensembles que ses règles **utilisent** (13 chez chacun) ; la
vérification se fait désormais dans ce sens.
**Deux lectures fragiles du site, que la face réseau fera disparaître à l'étape 3** :
`site_inventaire.py` lit la clé de sauvegarde sous `inventories/principal/` écrit en dur, et
re-dérive l'adresse du primaire DNS avec la séquence 1 par défaut (juste aujourd'hui, les
primaires sont des `-01`). La face réseau prend l'une dans le dossier d'inventaire réel du
locataire, l'autre dans son inventaire.
**Ce que la vérification ne couvre pas encore** : la matérialisation est recopiée de
l'inventaire, que le clonage lit par Ansible, sans consommateur Python à confronter ; les pairs
WireGuard ne sont vérifiés qu'à travers le tunnel qu'ils ouvrent ; les flux arrivent dans le
second temps.
**Éprouvé** :
- **P85**, « La face réseau du locataire dit exactement ce que le site en tire » : 2 faces,
aucun écart.
- `test_contexte.py`, 60 contrôles : les deux faces réelles concordent, et une face altérée de
**huit façons** (index, adresse d'une machine, membres d'un groupe, administration déclarée,
tunnel, zones publiques, primaire DNS, clé de sauvegarde) est vue en écart à chaque fois.
- `make verifier` conforme, **85/85**. Les trois documents comptent 85 preuves.
## 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

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

@ -361,6 +361,81 @@ class Locataire(Ecosysteme):
return None
return Site.charger(nom, self.depot.parent)
# LE CONTRAT, SENS LOCATAIRE -> SITE (§2.4) ---------------------------------------------
def face_reseau(self) -> dict:
"""Ce que CE locataire demande a son site, en une fiche — les faits, pas encore les flux.
Le site lisait jusqu'ici six fichiers internes du locataire (relevé du 2026-10-04) :
sa nomenclature, son inventaire genere, ses `group_vars` (`nftables_admin_ssh`,
`serveur_backup_pubkey`), son `plan/acces.yml`, son `plan/domaines.yml` et ce qui
l'accompagne. La face reseau les publie, calcules ICI, chez le locataire, qui seul sait
lire ses fichiers : l'adresse de son primaire DNS vient de son inventaire (le site la
re-derivait, sequence 1 par defaut), sa cle de sauvegarde de SON dossier d'inventaire
(le site ecrivait `principal` en dur).
LES FLUX RESOLUS sont la seconde moitie de l'etape 2 ; ils ne sont pas encore ici.
"""
from inventory_rules import charger_acces, est_groupe_operationnel, tunnel_admin_de
from resoudre_flux import _enfants, _hotes_du_groupe, _ip_par_hote
data = _yaml(self.inventaire())
hv = _hostvars(self.inventaire())
ips = _ip_par_hote(data) if data else {}
groupes = {g: sorted(h for h in _hotes_du_groupe(data, g) if h in ips)
for g in sorted(_enfants(data)) if est_groupe_operationnel(g)} if data else {}
machines = {
h: {"ip": ip,
"groupes": sorted(g for g, membres in groupes.items() if h in membres),
"materialisation": {k: v for k, v in sorted((hv.get(h) or {}).items())
if k.startswith("proxmox_")}}
for h, ip in sorted(ips.items())}
# Les reseaux d'administration que le locataire DECLARE (l'intrant), et son tunnel.
intrant: list[str] = []
gv_all = self.dossier_inventaire() / "group_vars" / "all"
for fichier in sorted(gv_all.glob("*.yml")) if gv_all.is_dir() else []:
if "vault" in fichier.name:
continue
d = _yaml(fichier)
if isinstance(d, dict) and d.get("nftables_admin_ssh"):
src = d["nftables_admin_ssh"]
intrant = [str(s) for s in src] if isinstance(src, list) else [str(src)]
break
try:
pairs = charger_acces(self.plan / "acces.yml").get("acces_admin_vpn") or {}
except (OSError, ValueError):
pairs = {}
# Ses zones publiques : celles qu'il fait servir par le site (`primaire-cache`).
doms = self.lire_plan("domaines.yml").get("domaines_publics") or {}
apps = self.lire_plan("applications.yml").get("applications") or {}
zones = sorted(z for z, c in doms.items()
if isinstance(c, dict) and str(c.get("autorite")) == "primaire-cache")
hote_dns = next((str(a.get("hote")) for a in apps.values()
if str(a.get("groupe")) == "serveur_powerdns"), "")
colocalise = any(str(a.get("groupe")) == "serveur_resolveur" and str(a.get("hote")) == hote_dns
for a in apps.values())
dns_public = ({"zones": zones,
"signees": sorted(z for z in zones if doms[z].get("dnssec") is True),
"primaire": ips.get(hote_dns, ""),
"port": 5300 if colocalise else 53} if zones else {})
cle_sauvegarde = str(_yaml(self.dossier_inventaire() / "group_vars" / "serveur_backup.yml")
.get("serveur_backup_pubkey") or "").strip()
sources = {str(Path(*f.relative_to(self.depot).parts)): _empreinte(f) for f in (
self.plan / "nomenclature.yml", self.inventaire(), self.plan / "acces.yml",
self.plan / "domaines.yml", self.plan / "applications.yml",
self.dossier_inventaire() / "group_vars" / "serveur_backup.yml")}
return {
"locataire": self.nom,
"hebergeur": self.nom_hebergeur(),
"sources": sources,
"index": self.index,
"machines": machines,
"groupes": groupes,
"administration": {"intrant": intrant, "tunnel": tunnel_admin_de(self.depot),
"pairs": {n: {k: c.get(k) for k in ("cle_publique", "adresse", "etat")}
for n, c in sorted(pairs.items()) if isinstance(c, dict)}},
"dns_public": dns_public,
"sauvegarde": {"cle_publique": cle_sauvegarde},
}
def decrire(self) -> dict:
d = super().decrire()
d.update({"inventaire": str(self.inventaire()), "hebergeur": self.nom_hebergeur()})
@ -453,6 +528,81 @@ def verifier_fiche(site: "Site", locataire: "Locataire", fiche: dict | None = No
return ecarts
def verifier_face(site: "Site", locataire: "Locataire", face: dict | None = None) -> list[str]:
"""Les ecarts entre la face reseau du locataire et ce que les CONSOMMATEURS DU SITE en tirent
aujourd'hui, en lisant ses fichiers internes.
On interroge les consommateurs eux-memes, le site monte : la decouverte des locataires
(`devis_reseau`), le devis du pare-feu Proxmox (ses ensembles d'adresses par groupe et
d'administration), la frontiere (`admin_de`), et l'inventaire du site (relations du DNS
public, comptes de sauvegarde). Vide = chacun lirait la face reseau sans rien changer.
"""
import devis_proxmox_fw
import devis_reseau
import site_inventaire
f = face if face is not None else locataire.face_reseau()
ecarts: list[str] = []
with _monter(site):
decouverts = {nom: n for nom, _p, n in devis_reseau.decouvrir_du_site()}
bloc = next((b for b in devis_proxmox_fw.construire(devis_reseau.decouvrir_du_site())["blocs"]
if b.get("tenant") == locataire.nom), None)
admin_frontiere = sorted(devis_reseau.admin_de(locataire.nom))
inv = site_inventaire.inventaire() or {}
hv_site = (inv.get("_meta") or {}).get("hostvars") or {}
# 1. L'index, tel que le site le decouvre.
n = decouverts.get(locataire.nom)
if n is None:
ecarts.append("le site ne decouvre pas ce locataire")
elif n.get("index") != f["index"]:
ecarts.append(f"index : le site decouvre {n.get('index')}, la face dit {f['index']}")
# 2. Les machines et leurs groupes, tels que le pare-feu Proxmox les range.
ips = {h: m["ip"] for h, m in f["machines"].items()}
if bloc is None:
ecarts.append("le devis Proxmox n'a aucun bloc pour ce locataire")
else:
ens = bloc.get("ipsets") or {}
flotte = next((v["membres"] for v in ens.values() if str(v.get("role", "")).startswith("flotte")), [])
if sorted(flotte) != sorted(set(ips.values())):
ecarts.append(f"flotte : le devis Proxmox range {sorted(flotte)}, la face {sorted(set(ips.values()))}")
# Le devis ne garde que les ensembles que ses regles UTILISENT : chaque groupe qu'il range
# doit exister dans la face avec les memes adresses ; un groupe inutilise n'est pas un ecart.
speciaux = ("flotte", "Internet", "reseaux d'admin")
par_groupe = {str(v.get("role")): sorted(v.get("membres") or []) for v in ens.values()
if not str(v.get("role", "")).startswith(speciaux)}
for g, membres in sorted(par_groupe.items()):
if g not in f["groupes"]:
ecarts.append(f"groupe {g} : range par le devis Proxmox, absent de la face")
elif membres != sorted({ips[h] for h in f["groupes"][g]}):
ecarts.append(f"groupe {g} : le devis Proxmox range {membres}, "
f"la face {sorted({ips[h] for h in f['groupes'][g]})}")
adm = next((v["membres"] for v in ens.values() if str(v.get("role", "")).startswith("reseaux d'admin")), [])
attendu_adm = sorted(set(f["administration"]["intrant"]) | set(f["administration"]["tunnel"]))
if sorted(adm) != attendu_adm:
ecarts.append(f"administration (Proxmox) : le devis admet {sorted(adm)}, la face {attendu_adm}")
# 3. L'administration, telle que la frontiere la lit.
if admin_frontiere != sorted(f["administration"]["intrant"]):
ecarts.append(f"administration (frontiere) : le site lit {admin_frontiere}, "
f"la face {sorted(f['administration']['intrant'])}")
# 4. Le DNS public et la sauvegarde, tels que l'inventaire du site les porte.
relations = [r for v in hv_site.values() for r in (v.get("serveur_dns_public_relations") or [])
if r.get("ecosysteme") == locataire.nom]
rel = relations[0] if relations else {}
attendu_dns = f["dns_public"]
vu_dns = ({k: rel.get(k) for k in ("zones", "signees", "primaire", "port")} if rel else {})
if vu_dns != attendu_dns:
ecarts.append(f"DNS public : l'inventaire du site porte {vu_dns}, la face {attendu_dns}")
comptes = [c for v in hv_site.values() for c in (v.get("serveur_backup_site_locataires") or [])
if c.get("nom") == locataire.nom]
cle_site = comptes[0].get("pubkey") if comptes else ""
if (cle_site or "") != f["sauvegarde"]["cle_publique"]:
ecarts.append("sauvegarde : la cle que le site autorise n'est pas celle que le locataire publie")
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."""
@ -534,7 +684,26 @@ def main(argv: list[str] | None = None) -> int:
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 ?")
ap.add_argument("--face", metavar="LOCATAIRE", help="la face reseau qu'il publie pour son site")
ap.add_argument("--verifier-faces", action="store_true",
help="chaque face reseau dit-elle ce que le site en tire ?")
args = ap.parse_args(argv)
if args.face:
print(yaml.safe_dump(Locataire.charger(args.face).face_reseau(), allow_unicode=True,
sort_keys=False), end="")
return 0
if args.verifier_faces:
couples = couples_hebergeur()
if not couples:
print("Aucun site frere : aucune face reseau a verifier.")
return 0
total = 0
for site, loc in couples:
e = verifier_face(site, loc)
total += len(e)
print(f"{'OK ' if not e else 'ECART'} {loc.nom} -> {site.nom}"
+ "".join(f"\n - {x}" for x in e))
return 1 if total else 0
if args.fiche:
loc = Locataire.charger(args.fiche)
site = loc.site()

View file

@ -4692,6 +4692,29 @@ def preuve_fiche_du_site() -> tuple[bool, str]:
f"porte (copies, racine, inventaire genere).")
def preuve_face_reseau() -> tuple[bool, str]:
"""La face reseau que chaque locataire publie dit-elle ce que les consommateurs du site en
tirent aujourd'hui, en lisant ses fichiers internes ?
POURQUOI (2026-10-04, `docs/conception-contextes.md` §2.4). Le site lisait six fichiers
internes de ses locataires — nomenclature, inventaire, `group_vars`, acces, domaines. La
face reseau les publie, calculee chez le locataire. Avant que le site la lise a leur place
(etape 3), elle doit dire EXACTEMENT ce que la decouverte, le devis Proxmox, la frontiere
et l'inventaire du site en tirent. Les flux resolus suivront ; ils ne sont pas encore la.
"""
sys.path.insert(0, str(RACINE / "scripts"))
import contexte as _ctx
couples = _ctx.couples_hebergeur()
if not couples:
return True, "Aucun site frere : aucune face reseau a confronter."
ecarts = [f"{l.nom} -> {s.nom} : {e}" for s, l in couples for e in _ctx.verifier_face(s, l)]
if ecarts:
return False, (f"{len(ecarts)} ecart(s) entre la face reseau et ce que le site en tire : "
+ " | ".join(ecarts[:4]))
return True, (f"{len(couples)} face(s) reseau : chacune dit exactement ce que le site en tire "
f"(decouverte, devis Proxmox, frontiere, DNS public, sauvegarde).")
PREUVES: list[dict] = [
{"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"],
"cmds": [["ansible-lint", "-q"]]},
@ -4880,6 +4903,8 @@ PREUVES: list[dict] = [
"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},
{"id": "P85", "titre": "La face reseau du locataire dit exactement ce que le site en tire",
"refs": [], "func": preuve_face_reseau},
]

View file

@ -10,7 +10,9 @@ Sur une arborescence FABRIQUEE (un site, deux locataires, un depot ambigu), on v
- 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 ; la fiche de chaque hebergeur dit
exactement ce que son locataire porte, et une fiche alteree (sept facons) est vue en ecart.
exactement ce que son locataire porte, et une fiche alteree (sept facons) est vue en ecart ; la face
reseau de chaque locataire dit ce que les consommateurs du site en tirent, et une face alteree
(huit facons) est vue en ecart.
"""
from __future__ import annotations
@ -198,6 +200,44 @@ def fiches() -> None:
f"une fiche alteree ({nom}) est vue en ecart")
def faces() -> None:
"""La face reseau dit ce que les consommateurs du site en tirent — et chaque ecart se voit."""
import copy
couples = C.couples_hebergeur()
if not couples:
print("SAUTE aucun site frere : aucune face reseau a confronter")
return
for site, loc in couples:
e = C.verifier_face(site, loc)
verifier(not e, f"face reseau {loc.nom} -> {site.nom} : identique a ce que le site en tire"
+ (f" ({e[0]})" if e else ""))
site, loc = couples[0]
juste = loc.face_reseau()
groupe = next(g for g in ("serveur_postfix", "serveur_nginx", "client_backup") if juste["groupes"].get(g))
hote = juste["groupes"][groupe][0]
def deplacer_machine(f):
f["machines"][hote]["ip"] = "192.0.2.10"
def retirer_du_groupe(f):
f["groupes"][groupe] = [h for h in f["groupes"][groupe] if h != hote]
alterations = {
"index": lambda f: f.__setitem__("index", (f["index"] or 0) + 1),
"adresse d'une machine": deplacer_machine,
f"membres du groupe {groupe}": retirer_du_groupe,
"administration declaree": lambda f: f["administration"].__setitem__("intrant", ["192.0.2.0/24"]),
"tunnel d'administration": lambda f: f["administration"].__setitem__("tunnel", ["192.0.2.128/25"]),
"zones publiques": lambda f: f["dns_public"].__setitem__("zones", ["exemple.invalid"]),
"primaire DNS": lambda f: f["dns_public"].__setitem__("primaire", "192.0.2.53"),
"cle de sauvegarde": lambda f: f["sauvegarde"].__setitem__("cle_publique", "ssh-ed25519 AAAA-fausse"),
}
for nom, alterer in alterations.items():
f = copy.deepcopy(juste)
alterer(f)
verifier(bool(C.verifier_face(site, loc, f)), f"une face reseau alteree ({nom}) est vue en ecart")
def main() -> int:
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
@ -207,6 +247,7 @@ def main() -> int:
vrais_depots()
monter_restaure()
fiches()
faces()
if ECHECS:
print(f"\n{len(ECHECS)} echec(s).")
return 1