contexte : etape 3, la frontiere lit la face reseau

devis_opnsense prend dans la face publiee les adresses des groupes,
l'intrant d'administration, les verdicts, le tunnel et les zones
publiees. Devis identique a l'octet pres, a HEAD comme par le repli.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-05 13:02:03 -04:00
parent 8c1e8c725b
commit 013c1b5279
4 changed files with 150 additions and 21 deletions

View file

@ -1,5 +1,36 @@
# CHANGELOG — Set-OPS
## 2026-10-05 (93) — Étape 3 : la frontière lit la face réseau
**Le chemin** (`docs/conception-contextes.md` §6), étape 3. Quatrième consommateur du site, le
plus lourd : le devis de la frontière (167 Ko). Il lisait chez chaque locataire ses réseaux
d'administration (`group_vars`), son inventaire (les adresses de chaque groupe, dont les
runners et les primaires DNS de tous les locataires), ses variables (conditions
`seulement_si`), son `plan/domaines.yml` et son `plan/acces.yml`.
**Fait** (`devis_opnsense.py`) : quatre lecteurs, la face d'abord, l'ancienne lecture en
repli explicite — `cibles_du_tenant()` (adresses d'un groupe), `admin_du_tenant()`
(l'intrant), `applicable_chez()` (le verdict publié), et la face pour le tunnel (présent si
un pair est actif) et pour savoir qui publie des zones. Les copies en ligne du réseau et du
port du tunnel ne servent plus que le repli ; elles partiront avec lui.
**Un faux succès, attrapé avant le commit** : la première modification s'est arrêtée sur une
assertion (un commentaire séparait deux lignes du motif) **avant d'écrire**. La comparaison
qui suivait a dit « identique » — l'ancien code comparé à lui-même. Relevé, refait ; la
comparaison avec l'ancien code se fait désormais contre la version de HEAD, lancée à part.
**Le critère** : le devis de la frontière est **identique octet pour octet** depuis la face,
par le repli, et par l'ancien code (HEAD). `make frontiere-plan` contre la frontière réelle :
rien à créer, rien à retirer.
**Éprouvé** :
- `test_contexte.py`, 102 contrôles : un verdict basculé dans la face publiée retire sa règle
WAN (soumission Postfix 587 : 1 règle, puis 0) — la frontière lit bien la face.
- `make verifier` conforme, 92/92.
**Restent à basculer** : la découverte des locataires (index et catégories, lus par la
frontière, le SDN, les pools et le commutateur), le placement et le clonage.
## 2026-10-05 (92) — Étape 3 : le pare-feu Proxmox lit la face réseau
**Le chemin** (`docs/conception-contextes.md` §6), étape 3. Troisième consommateur du site :

View file

@ -182,7 +182,8 @@ les publie désormais (`sources_declarees`). La frontière, en trois temps : les
(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). Le locataire publie sa face
réseau (P92) ; les comptes de sauvegarde, le DNS public et le pare-feu Proxmox du site la lisent.
réseau (P92) ; les comptes de sauvegarde, le DNS public, le pare-feu Proxmox et la frontière 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

@ -297,6 +297,57 @@ def cibles_par_role(role: str, inventaire: Path) -> list[str]:
#
# L'outil a bien refuse d'appliquer quoi que ce soit, mais le devis, lui, annoncait un
# objet impossible. Un devis qui promet ce que la cible refusera n'est pas un devis.
# LA FACE RESEAU D'ABORD (2026-10-05, docs/conception-contextes.md §6, etape 3). Le locataire
# publie `face-reseau.yml` : ses groupes et leurs adresses, son administration, son tunnel, ses
# zones publiques, le verdict de ses flux conditionnels. La frontiere ne lit plus son inventaire,
# ses variables ni son plan. Sans face publiee (transition), l'ancienne lecture, et on le dit.
_FACES: dict[str, dict | None] = {}
def face_publiee(nom: str) -> dict | None:
if nom not in _FACES:
try:
import contexte
from devis_reseau import DOSSIER_INSTANCES
_FACES[nom] = contexte.Locataire(DOSSIER_INSTANCES / nom).face_publiee()
except Exception:
_FACES[nom] = None
if _FACES[nom] is None:
print(f"note : {nom} n'a pas publie sa face reseau — lecture de ses fichiers "
f"internes (transition).", file=sys.stderr)
return _FACES[nom]
def cibles_du_tenant(role: str, nom: str) -> list[str]:
"""IP des hotes qui portent ce role chez ce locataire : sa face, sinon son inventaire."""
face = face_publiee(nom)
if face is not None:
ips = {h: m.get("ip") for h, m in (face.get("machines") or {}).items()}
return sorted({ips[h] for h in (face.get("groupes") or {}).get(role, []) if ips.get(h)})
inv = inventaire_de(nom)
return cibles_par_role(role, inv) if inv else []
def admin_du_tenant(nom: str) -> list[str]:
"""Ses reseaux d'administration declares (l'intrant) : sa face, sinon ses `group_vars`."""
face = face_publiee(nom)
if face is not None:
return list((face.get("administration") or {}).get("intrant") or [])
return admin_de(nom)
def applicable_chez(fl: dict, role: str, nom: str) -> bool:
"""Le flux s'applique-t-il chez ce locataire ? Son verdict publie, sinon ses variables."""
if not fl.get("seulement_si"):
return True
face = face_publiee(nom)
if face is not None:
import contexte
return bool(((face.get("conditions") or {}).get(role) or {}).get(contexte.cle_condition(fl), True))
inv = inventaire_de(nom)
return flux_applicable(fl, role, inv.parent.parent.parent) if inv else True
LONGUEUR_MAX_ALIAS = 31
# Meme abreviation que le pare-feu est-ouest (`devis_proxmox_fw` : serveur_ -> srv-), pour
@ -495,7 +546,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
admin_par_if: dict[str, dict[str, list[str]]] = {}
for nom, pfx, n in tenants:
etiquette = f"{pfx}{n['index']}"
cidrs = admin_de(nom)
cidrs = admin_du_tenant(nom)
admin_par_tenant[etiquette] = cidrs
par_vpn = [c for c in cidrs if _par_le_vpn(c)]
locaux = [c for c in cidrs if c not in par_vpn and _porte_par(c, reseaux_gestion)]
@ -559,13 +610,12 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
for nom, pfx, n in tenants:
etiquette = f"{pfx}{n['index']}"
alias_tenant = f"SETOPS_TENANT_{etiquette}"
inv = inventaire_de(nom)
if inv is None:
if face_publiee(nom) is None and inventaire_de(nom) is None:
tenants_sans_inventaire.append(nom)
continue
for role, fl in retenus:
# Un flux conditionnel (`seulement_si`) ne vaut que la ou son reglage le dit.
if not flux_applicable(fl, role, inv.parent.parent.parent):
if not applicable_chez(fl, role, nom):
continue
# AVANT L'ALIAS (2026-09-17). Le saut plus bas arrivait APRES la creation de
# l'alias du role : un flux vers la frontiere declare par `serveur_prometheus`
@ -576,7 +626,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
if role == ROLE_SOCLE:
destination = alias_tenant
else:
cibles = cibles_par_role(role, inv)
cibles = cibles_du_tenant(role, nom)
if not cibles:
continue # role absent de ce tenant : aucune regle a poser
a = nom_alias(role, etiquette)
@ -887,9 +937,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
# au SSH du site rendrait l'insemination indiscernable d'un droit d'entree
# general — exactement ce que l'isolation refuse.
_runners_tenants = sorted({ip for _n2, _p2, _x2 in tenants
for ip in (cibles_par_role("serveur_ops_tenant",
inventaire_de(_n2))
if inventaire_de(_n2) else [])})
for ip in cibles_du_tenant("serveur_ops_tenant", _n2)})
if _runners_tenants:
alias["SETOPS_RUNNERS_TENANTS"] = {
"type": "host",
@ -909,6 +957,9 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
# n'y ecoutait — mais un chemin ouvert vers une machine qui n'en a pas besoin est
# exactement ce que le moindre privilege refuse.
def _publie(_nom_tenant: str) -> bool:
_face = face_publiee(_nom_tenant)
if _face is not None:
return bool((_face.get("dns_public") or {}).get("zones"))
try:
import devis_reseau as _dr
_f = _dr.DOSSIER_INSTANCES / _nom_tenant / "plan" / "domaines.yml"
@ -918,9 +969,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
except (OSError, ImportError):
return False
_primaires_dns = sorted({ip for _n2, _p2, _x2 in tenants if _publie(_n2)
for ip in (cibles_par_role("serveur_powerdns",
inventaire_de(_n2))
if inventaire_de(_n2) else [])})
for ip in cibles_du_tenant("serveur_powerdns", _n2)})
if _primaires_dns:
alias["SETOPS_DNS_PRIMAIRES_TENANTS"] = {
"type": "host",
@ -1574,15 +1623,21 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
_idx_t = _n_t.get("index")
if _idx_t is None:
continue
try:
_acces_t = (yaml.safe_load(
(_dr.DOSSIER_INSTANCES / _nom_t / "plan" / "acces.yml").read_text(encoding="utf-8"))
or {}).get("acces_admin_vpn") or {}
except (OSError, yaml.YAMLError):
continue
if not [n for n, c in _acces_t.items()
if isinstance(c, dict) and str(c.get("etat", "present")) == "present"]:
continue # aucun pair actif : aucune route, aucune regle
_face_t = face_publiee(_nom_t)
if _face_t is not None:
# Le tunnel n'existe que s'il a un pair actif : la face le dit (`tunnel` vide sinon).
if not (_face_t.get("administration") or {}).get("tunnel"):
continue
else:
try:
_acces_t = (yaml.safe_load(
(_dr.DOSSIER_INSTANCES / _nom_t / "plan" / "acces.yml").read_text(encoding="utf-8"))
or {}).get("acces_admin_vpn") or {}
except (OSError, yaml.YAMLError):
continue
if not [n for n, c in _acces_t.items()
if isinstance(c, dict) and str(c.get("etat", "present")) == "present"]:
continue # aucun pair actif : aucune route, aucune regle
_etiq_t = f"{_pfx_t}{_idx_t}"
_alias_tunnel = f"SETOPS_ADMIN_{_etiq_t}_TUNNEL"
alias[_alias_tunnel] = {

View file

@ -510,6 +510,47 @@ def proxmox_lit_la_face() -> None:
f"le devis Proxmox lit la face publiee : {g} {port}/tcp, {avant} regle(s), 0 une fois le verdict bascule")
def frontiere_lit_la_face() -> None:
"""La frontiere lit la face publiee : un verdict bascule dans la face retire sa regle WAN."""
import yaml as _yaml
couples = C.couples_hebergeur()
if not couples:
print("SAUTE aucun site frere : aucune frontiere a confronter")
return
site, loc = couples[0]
face = loc.face_publiee() or {}
cible = next(((g, k) for g, v in (face.get("conditions") or {}).items() for k, ok in v.items()
if ok and k.startswith("ingress|tcp|") and g == "serveur_postfix"), None)
if cible is None:
print("SAUTE aucune soumission Postfix active a basculer")
return
g, cle = cible
port = cle.split("|")[2]
def regles_wan() -> int:
import devis_opnsense
devis_opnsense._FACES.clear()
_e, devis = C._devis_frontiere(site, loc)
return sum(1 for r in devis.get("regles") or []
if r.get("tenant") == loc.nom and r.get("role") == g and port in (r.get("ports") or [])
and r.get("interface") == devis.get("if_wan"))
chemin = loc.depot / C.NOM_FICHIER_FACE_RESEAU
original = chemin.read_text(encoding="utf-8")
avant = regles_wan()
try:
f = _yaml.safe_load(original)
f["conditions"][g][cle] = False
chemin.write_text(_yaml.safe_dump(f, allow_unicode=True, sort_keys=False), encoding="utf-8")
apres = regles_wan()
finally:
chemin.write_text(original, encoding="utf-8")
import devis_opnsense
devis_opnsense._FACES.clear()
verifier(avant > 0 and apres == 0,
f"la frontiere lit la face publiee : {g} {port}/tcp sur le WAN, {avant} regle(s), 0 une fois le verdict bascule")
def main() -> int:
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
@ -528,6 +569,7 @@ def main() -> int:
fiche_deposee()
face_publiee()
proxmox_lit_la_face()
frontiere_lit_la_face()
if ECHECS:
print(f"\n{len(ECHECS)} echec(s).")
return 1