P79 : une derivation qui ne trouve rien ne passe plus pour un succes

Sept replis des deux derniers jours, un temoin chacun, lu hors de la derivation
qu'il juge : jumeaux d'amorcage, patte de zone, edge d'exposition, certificat
bouchon, rechargement, sortie vers l'Internet, regles d'hote par instance.
Eprouvee par reinjection de chaque faute ; a trouve deux flux-genere perimes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-15 18:37:38 -04:00
parent ae212c0252
commit 85c7f9710b
5 changed files with 296 additions and 5 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 78 preuves de `make prouver` : elles lisent le dépôt, sans le
C'est aussi ce que font les 79 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,62 @@
# CHANGELOG — Set-OPS
## 2026-09-15 (7) — P79 : une derivation qui ne trouve rien ne passe plus pour un succes
En deux jours, pour monter l'edge du site puis les trois consoles, le meme defaut est
revenu sous sept noms. Chaque fois un repli rendait un SUCCES au lieu d'un refus :
deploiement vert, devis muet, harnais vert. *Une derivation qui ne trouve rien ne se
distinguait pas d'une derivation qui n'a rien a trouver.*
### Ce que P79 regarde
| Temoin | Le repli qu'il attrape |
|---|---|
| jumeaux | `dns_amorcage` derive sans `artefacts_amorcage` : le gabarit garde son ancien proxy apt |
| patte | une zone occupee absente de `opnsense_if_zones` : regles posees sur la patte plate |
| edge | une exposition qui retombe sur son propre groupe dans un ecosysteme qui A un edge |
| bouchon | l'edge du site sert `ssl-cert-snakeoil.pem`, faute de group_vars |
| rechargement | un edge qui renouvelle son certificat sans recharger nginx |
| internet | une sortie vers un role absent traduite en sortie vers l'Internet |
| flux | les regles d'hote rejouees pour CHAQUE locataire, pas seulement l'instance montee |
Chaque temoin lit sa source directement — inventaire, plan, table ecrite, `meta/flux.yml`
— et ne passe jamais par la derivation qu'il juge. La lecon de P43 et de P67 : une garde
qui reproduit le raisonnement qu'elle verifie ne verifie rien.
Le temoin des flux rejoue `resoudre_flux.py nftables` dans un dossier temporaire qui
reprend l'instance par liens : rien n'est ecrit dans les depots.
### Eprouvee en lui remettant chaque faute sous les yeux
Chaque temoin prend ses donnees en parametre. Les sept fautes ont ete reinjectees une a
une dans les donnees reelles du site (zone retiree de la table, `edge` retire de
`domaines.yml`, certificat, rechargement et jumeau retires de l'inventaire, regle
`!SETOPS_INTERNES` sur 636 ajoutee au devis) : sept refus, et zero sur les donnees saines.
Sans edge, le repli « le service se sert lui-meme » reste juste, et la garde se tait.
### Et elle a trouve deux ecarts reels des sa premiere execution
OPS-Chezlepro-lab 14 regles de machines retirees du plan, aucune pour ops-01
OPS-Patient0 4 regles perimees (admin en 10.17.0.0/24, sans le refus
`admin-prohibited`), aucune pour ops-01
Les deux dataient d'avant le renumerotage du site : `make flux` n'avait jamais ete relance
avec eux montes. Regeneres par `SETOPS_INSTANCE`, sans basculer l'instance. Ce sont des
apercus, rien n'est applique a une machine.
### Ce qu'elle ne couvre pas
La collision des noms publics par groupe (P67 la garde). `client_pki` qui rend
`changed=0` sans comparer ses SAN : cela se mesure sur la machine (`make
certificats-plan`). Un registre facultatif exige par `include_vars` : celui-la echouait
bruyamment.
### Au passage
Les gabarits de voute de `OPS-Patient0` et `OPS-Chezlepro-lab` portaient
`vault_setops_console_oidc` depuis la session precedente, jamais commite, et le premier
avait un commentaire coupe de sa cle. Remis en forme et commites.
## 2026-09-15 (6) — Les trois consoles repondent, chacune derriere sa propre serrure
console.genese.internal 401 vestibule HTTP Basic (pas d'annuaire)

View file

@ -7,13 +7,13 @@
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (77 OK · 0 echec · 1 saute)
- **Verdict** : ✅ CONFORME (78 OK · 0 echec · 1 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | } \| to_nice_json }}`. |
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  |
| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK |
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 5 instance(s) verifiee(s) — instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
@ -69,7 +69,7 @@
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. |
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (78 preuves, 68 roles, 41 groupes). |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (79 preuves, 68 roles, 41 groupes). |
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `392da4d` (publie le 2026-09-14). |
@ -91,6 +91,7 @@
| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 155 gabarits de role : tous se rendent. |
| P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. |
| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). |
| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 6 ecosysteme(s) (instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0, SITE-Chezlepro) : pattes, edges, certificats, rechargemen |
## Couverture des affirmations ✅ du registre

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 78 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
`scripts/prouver.py` porte 79 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

@ -3928,6 +3928,237 @@ def preuve_amorcage_suit_le_site() -> tuple[bool, str]:
return True, (f"{verifies} adresse(s) d'amorcage designent bien une machine du site.")
# --- LES REPLIS SILENCIEUX (P79) -----------------------------------------------------
#
# Chaque temoin ci-dessous prend ses donnees en PARAMETRE : on peut lui remettre la faute
# sous les yeux sans toucher au depot, et c'est ainsi qu'ils ont ete eprouves.
def _yaml_ou_vide(chemin: Path) -> dict:
"""Le contenu d'un fichier YAML, ou {} s'il n'existe pas (registre facultatif)."""
if not chemin.is_file():
return {}
return yaml.safe_load(chemin.read_text(encoding="utf-8")) or {}
def _repli_pattes(serveurs: dict, if_zones: dict) -> list[str]:
"""Chaque zone OCCUPEE par une machine active du site a sa patte a la frontiere.
Un site qui n'a pas encore de table par zone est plat, et la patte unique est alors
juste. Des que la table existe, une zone qui y manque retombe sur la patte plate.
"""
if not if_zones:
return []
occupees: dict[str, list[str]] = {}
for nom, s in (serveurs or {}).items():
if isinstance(s, dict) and str(s.get("etat", "actif")) == "actif" and s.get("reseau"):
occupees.setdefault(str(s["reseau"]), []).append(str(nom))
return [f"patte : la zone `{z}` ({', '.join(sorted(n))}) manque a `opnsense_if_zones` "
f"— ses regles tomberaient sur la patte plate, justes et jamais rencontrees"
for z, n in sorted(occupees.items()) if z not in if_zones]
def _repli_edge(nom: str, applications: dict, domaines: dict, a_un_edge: bool) -> list[str]:
"""Dans un ecosysteme qui A un edge, aucune exposition ne retombe sur son propre groupe.
Le temoin n'est pas la derivation : la presence d'un edge vient de l'inventaire, et les
edges legitimes de ce que `domaines.yml` ECRIT. Le repli « le service se sert lui-meme »
n'est juste que la ou il n'y a personne pour le servir.
"""
if not a_un_edge:
return []
from inventory_rules import expositions_des_applications
doms = (domaines or {}).get("domaines_publics") or {}
ecrits = {str(c["edge"]) for c in doms.values() if isinstance(c, dict) and c.get("edge")}
return [f"edge : {nom} — `{e['fqdn']}` retombe sur son propre groupe (`{e['edge']}`) "
f"alors que l'ecosysteme a un edge : ni vhost ni SAN ne le retiendront"
for e in expositions_des_applications(applications, domaines)
if e["edge"] != "serveur_nginx" and str(e["edge"]) not in ecrits]
def _repli_hotes_du_site(enfants: dict, hv: dict) -> list[str]:
"""Ce que l'inventaire DYNAMIQUE du site donne a ses machines, sans group_vars pour
rattraper un defaut de role."""
def membres(g: str) -> set[str]:
return set(((enfants.get(g) or {}).get("hosts")) or {})
fautes: list[str] = []
pki = membres("client_pki")
for h in sorted(membres("serveur_nginx")):
v = hv.get(h) or {}
cert = str(v.get("serveur_nginx_certificat") or "")
if not cert or "snakeoil" in cert:
fautes.append(f"bouchon : {h} sert `{cert or 'le defaut du role, snakeoil'}` "
f"— du TLS qui ressemble a du TLS")
if h in pki and "nginx" not in (v.get("client_pki_reload_services") or []):
fautes.append(f"rechargement : {h} renouvelle son certificat sans recharger "
f"nginx — le nouveau reste sur disque, l'ancien est servi")
if membres("serveur_artefacts"):
for h, v in sorted(hv.items()):
if (v or {}).get("dns_amorcage") and not (v or {}).get("artefacts_amorcage"):
fautes.append(f"jumeaux : {h} recoit `dns_amorcage` sans "
f"`artefacts_amorcage` — le gabarit garde son ancien proxy apt")
return fautes
def _repli_devis_internet(flux: dict, roles_presents: set[str], regles: list[dict]) -> list[str]:
"""Une sortie qui NOMME un role absent du site ne devient pas une sortie vers l'Internet.
Le temoin lit les `meta/flux.yml` : les ports qu'un role n'emploie QUE vers des roles
absents ne doivent porter aucune regle `!SETOPS_INTERNES`. Un port qu'il emploie aussi
vers un pair lointain (`externe`, un depot) y a sa place, et il est ecarte.
"""
connus = {d.name for d in (RACINE / "roles").iterdir() if d.is_dir()}
fautes: set[str] = set()
for role in sorted(roles_presents):
absents, lointains = set(), set()
for fl in flux.get(role, []):
if fl.get("sens") != "egress":
continue
pairs = fl.get("pair") if isinstance(fl.get("pair"), list) else [fl.get("pair")]
ports = fl["port"] if isinstance(fl.get("port"), list) else [fl.get("port")]
nommes = {str(p) for p in pairs} & connus
if nommes and not nommes & roles_presents:
absents.update(str(p) for p in ports)
elif not nommes:
lointains.update(str(p) for p in ports)
suspects = absents - lointains
for r in regles:
if (str(r.get("tenant")) == "SITE" and r.get("role") == role
and str(r.get("destination")) == "!SETOPS_INTERNES"):
for port in sorted(suspects & {str(p) for p in (r.get("ports") or [])}):
fautes.add(f"internet : {role} sort vers l'Internet sur {port}, un port "
f"qu'il n'emploie que vers un role absent du site")
return sorted(fautes)
def _flux_genere_perime(base: Path) -> list[str]:
"""Les regles d'hote d'un ecosysteme, rejouees pour LUI — pas pour l'instance montee.
`make flux` n'ecrit que pour l'instance montee. On rejoue la generation dans un dossier
temporaire qui reprend l'instance par liens, et l'on compare : rien n'est ecrit dans le
depot.
"""
import os
import subprocess
import tempfile
reel = base / "flux-genere"
with tempfile.TemporaryDirectory(prefix="p79-") as tmp:
for e in base.iterdir():
if e.name not in ("flux-genere", ".git"):
os.symlink(e, Path(tmp) / e.name)
r = subprocess.run([sys.executable, str(RACINE / "scripts" / "resoudre_flux.py"), "nftables"],
env=dict(os.environ, SETOPS_INSTANCE=tmp), capture_output=True,
text=True, cwd=RACINE, timeout=300)
if r.returncode != 0:
derniere = (r.stderr.strip().splitlines() or ["sans message"])[-1]
return [f"flux : {base.name} — la generation echoue ({derniere[:100]})"]
gen = Path(tmp) / "flux-genere"
a = {p.name for p in reel.glob("*.nft")} if reel.is_dir() else set()
b = {p.name for p in gen.glob("*.nft")} if gen.is_dir() else set()
differents = sorted(n for n in a & b if (reel / n).read_bytes() != (gen / n).read_bytes())
fautes = []
if b - a:
fautes.append(f"flux : {base.name} — aucune regle generee pour {', '.join(sorted(b - a))}")
if a - b:
fautes.append(f"flux : {base.name} — regles d'une machine retiree du plan : "
f"{', '.join(sorted(a - b))}")
if differents:
fautes.append(f"flux : {base.name} — perimees par rapport au plan : {', '.join(differents)}")
return fautes
def preuve_une_derivation_vide_se_dit() -> tuple[bool, str]:
"""Une derivation qui ne trouve rien ne passe pas pour une derivation qui n'a rien a trouver.
POURQUOI (2026-09-14 et 15). En deux jours, pour monter l'edge du site puis les trois
consoles, les memes defauts sont revenus sous des noms differents. Chaque fois, un
repli a rendu un SUCCES au lieu d'un refus : deploiement vert, devis muet, harnais vert.
1. `site_inventaire` derivait `dns_amorcage` sans son jumeau `artefacts_amorcage` : les
machines nees avant le renumerotage gardaient le proxy apt du gabarit.
2. Une zone manquait a `opnsense_if_zones` : `_if_de()` retombait sur la patte plate,
20 regles posees et jamais rencontrees.
3. L'edge d'une exposition retombait sur le groupe de l'application : vhost de 43
octets, certificat sans les noms publies.
4. L'edge du site servait `ssl-cert-snakeoil.pem`, le defaut du role, faute de
group_vars dans un inventaire dynamique.
5. Un certificat renouvele sans `client_pki_reload_services` : servi perime.
6. Une sortie vers un role absent du site devenait une sortie vers l'Internet.
7. `make flux`, lance avec un locataire monte, n'a rien regenere pour l'autre : 502
derriere un TLS parfait.
Chaque temoin lit sa source DIRECTEMENT (inventaire, plan, registre, table ecrite) et
ne passe pas par la derivation qu'il juge : une garde qui reproduit le raisonnement
qu'elle verifie ne verifie rien (cf. P43, P67).
CE QU'ELLE NE COUVRE PAS. La collision des noms publics par groupe (P67 la garde).
`client_pki` qui rend `changed=0` sans comparer ses SAN a ceux qu'il derive : cela se
mesure sur la machine (`make certificats-plan`), pas dans le depot. Et un registre
facultatif exige par `include_vars` : celui-la echouait bruyamment, ce n'etait pas un
repli.
"""
sys.path.insert(0, str(RACINE / "scripts"))
import instances as mod_instances
fautes: list[str] = []
couverts: list[str] = []
for i in mod_instances.decouvrir():
if i.get("site"):
continue
base = RACINE.parent / i["nom"]
inv = next((base / "inventories" / x / "hosts.yml" for x in ("principal", "production")
if (base / "inventories" / x / "hosts.yml").is_file()), None)
if inv is None:
continue
enfants = ((_yaml_ou_vide(inv).get("all") or {}).get("children")) or {}
a_un_edge = bool(((enfants.get("serveur_nginx") or {}).get("hosts")) or {})
fautes += _repli_edge(i["nom"], _yaml_ou_vide(base / "plan" / "applications.yml"),
_yaml_ou_vide(base / "plan" / "domaines.yml"), a_un_edge)
gv_nginx = inv.parent / "group_vars" / "serveur_nginx.yml"
if a_un_edge and "nginx" not in (_yaml_ou_vide(gv_nginx).get("client_pki_reload_services") or []):
fautes.append(f"rechargement : {i['nom']} — l'edge renouvelle son certificat sans "
f"recharger nginx")
if (base / "flux-genere").is_dir():
fautes += _flux_genere_perime(base)
couverts.append(i["nom"])
lien = RACINE / "underlay.yml"
if lien.exists():
site = lien.resolve().parent
inventaire = _inventaire_du_site()
if inventaire is None:
fautes.append(f"site : l'inventaire dynamique de {site.name} ne s'execute pas — "
f"rien de ce qu'il derive ne peut etre juge")
else:
enfants, hv = inventaire["enfants"], inventaire["hv"]
fautes += _repli_edge(site.name, _yaml_ou_vide(site / "plan" / "applications.yml"),
_yaml_ou_vide(site / "plan" / "domaines.yml"),
bool(((enfants.get("serveur_nginx") or {}).get("hosts")) or {}))
fautes += _repli_hotes_du_site(enfants, hv)
fautes += _repli_pattes(_yaml_ou_vide(site / "plan" / "serveurs.yml").get("serveurs") or {},
_yaml_ou_vide(site / "opnsense.yml").get("opnsense_if_zones") or {})
import contextlib
import io
import devis_opnsense as D
import resoudre_flux as RF
with contextlib.redirect_stderr(io.StringIO()):
regles = D.construire(D.decouvrir_du_site()).get("regles") or []
presents = {g for g, v in enfants.items() if (v.get("hosts") or {})}
fautes += _repli_devis_internet(RF.charger_flux(), presents, regles)
couverts.append(site.name)
if fautes:
return False, (f"{len(fautes)} repli(s) silencieux — une derivation vide rend un "
f"succes :\n - " + "\n - ".join(fautes[:10])
+ ("\n - …" if len(fautes) > 10 else ""))
if not couverts:
return True, "Aucun ecosysteme decouvert : aucune derivation a juger."
return True, (f"{len(couverts)} ecosysteme(s) ({', '.join(couverts)}) : pattes, edges, "
f"certificats, rechargements, jumeaux d'amorcage, sorties du devis et regles "
f"d'hote par instance — aucune derivation vide ne passe pour un succes.")
PREUVES: list[dict] = [
{"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"],
"cmds": [["ansible-lint", "-q"]]},
@ -4104,6 +4335,8 @@ PREUVES: list[dict] = [
"refs": [], "func": preuve_panneaux_assembles},
{"id": "P78", "titre": "Un consommateur de base suit le verrou TLS de son serveur",
"refs": [], "func": preuve_consommateurs_suivent_le_verrou_tls},
{"id": "P79", "titre": "Replis silencieux : une derivation vide ne passe pas pour un succes",
"refs": [], "func": preuve_une_derivation_vide_se_dit},
]