nom public : la garde ne regardait qu un cote de la cloture

Le plan du site expose observatoire.genese.internal ; serveur_grafana devinait
grafana.<domaine>. Grafana fabriquait son root_url, ses liens d alerte et son
URL de retour OIDC avec un nom que rien ne sert.

C est mot pour mot le defaut du 2026-09-10, quatre jours plus tard, de l autre
cote : instancier derive <groupe>_hostname depuis ce jour-la et P67 le garde,
mais tous deux ne connaissent que l instance montee — donc un locataire.
L inventaire du site n en derivait aucun. Quatre devinettes sur cinq tombaient
juste par convention, et c est ce qui rendait le defaut invisible.

site_inventaire.py derive les cinq noms du plan. P67 lit maintenant les deux
inventaires — celui du site est dynamique, donc elle l EXECUTE au lieu de lire
sa source. Eprouvee dans les deux sens : 10 services sur 2 inventaires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
This commit is contained in:
Daniel Allaire 2026-09-14 17:47:36 -04:00
parent bb88f1f981
commit d4bc3e48b7
4 changed files with 186 additions and 29 deletions

View file

@ -1,5 +1,69 @@
# CHANGELOG — Set-OPS
## 2026-09-14 (17) — La garde regardait un seul cote de la cloture
L'exploitant, en cherchant son tableau : *« grafana c'etait observatoire.genese.internal »*.
Il avait raison, et Grafana ne le savait pas.
### Le defaut
Le plan du site expose `observatoire.genese.internal`. `serveur_grafana` devinait
`grafana.{{ domaine_interne }}` — sa valeur par defaut. Grafana fabriquait donc son
`GF_SERVER_ROOT_URL`, ses liens d'alerte et son URL de retour OIDC avec un nom **que rien
ne sert**.
C'est MOT POUR MOT le defaut du 2026-09-10, quatre jours plus tard, de l'autre cote de la
cloture. `instancier` derive `<groupe>_hostname` de l'exposition du plan depuis ce
jour-la, et **P67** le garde. Les deux ne connaissent que l'instance MONTEE — donc un
locataire. L'inventaire du SITE, lui, n'en derivait aucun :
| groupe expose | le plan dit | le role devinait |
|---|---|---|
| `serveur_forgejo` | `forge.genese.internal` | forge… — juste par convention |
| `serveur_step_ca` | `pki.genese.internal` | pki… — juste |
| `serveur_resolveur` | `dns.genese.internal` | dns… — juste |
| `serveur_backup_site` | `sauvegarde.genese.internal` | juste |
| **`serveur_grafana`** | **`observatoire.genese.internal`** | **`grafana.…` — faux** |
Quatre devinettes sur cinq tombaient juste, et c'est precisement ce qui rend ce defaut
invisible : *tant que le plan suit la meme convention, la devinette tombe juste et
personne ne voit qu'il y a deux sources*. Le seul nom qui s'ecarte de la convention est le
seul qui ment.
### Ce qui est corrige
`site_inventaire.py` derive `<groupe>_hostname` de l'exposition unique du plan du site —
le meme geste, au meme endroit, que chez un locataire. Les cinq noms viennent maintenant
du plan.
**P67 regarde les deux inventaires.** Le site a un inventaire DYNAMIQUE : la preuve
l'EXECUTE au lieu de lire un fichier — juger sa source reviendrait a relire le
raisonnement au lieu du resultat. Eprouvee dans les deux sens : sans la derivation, elle
nomme les cinq services fautifs ; avec, elle en compte **10 sur 2 inventaires**.
Environment=GF_SERVER_DOMAIN=observatoire.genese.internal
Environment=GF_SERVER_ROOT_URL=https://observatoire.genese.internal/
### La lecon, et elle est generale
Une garde posee la ou un defaut s'est montre ne couvre pas la ou il peut se montrer aussi.
Le site et le locataire ont deux inventaires, et le depot en compte plusieurs autres qui
ne regardent qu'un des deux. C'est la meme forme que « une liste qui suit une autre prend
du retard », appliquee aux gardes elles-memes.
### Et la console etait joignable depuis le debut
Le poste porte `10.37.0.17/24`, et nftables accepte le port 3000 depuis `10.37.0.0/24`. Il
manquait une ROUTE : sans elle, le noyau envoyait les paquets a la passerelle du LAN avec
l'adresse du LAN comme source, et le pare-feu du site voyait arriver un inconnu. Forcer la
source ne sert a rien — c'est la route qui la choisit.
Les routes du poste pointaient aussi les deux locataires vers `10.17.0.1`, une passerelle
que **la frontiere ne porte pas** (elle route les tenants par `10.0.4.41` sur le vlan040).
Cinq routes vers un routeur inexistant, dont les `/16` des deux ecosystemes : c'est ce qui
donnait « No route to host » en cherchant la console de Chezlepro. Routes refaites par
l'exploitant ; les trois runners repondent.
## 2026-09-14 (16) — Le dernier maillon : l'exportateur PostgreSQL, et huit panneaux qui repondent
L'entree precedente laissait le tableau de PostgreSQL assemble, charge, et **vide** :

View file

@ -79,7 +79,7 @@
| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 39 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, serveur_ |
| P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. |
| P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 3 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (5 exposition(s)). |
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 5 service(s) expose(s) portent le nom du plan (5 groupe(s) derive(s)). |
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 10 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. |
| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. |
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 6 artefact(s) direct(s) tenus par le depot du site. |

View file

@ -3389,14 +3389,66 @@ def preuve_nom_public_vient_du_plan() -> tuple[bool, str]:
Cette preuve garde la derivation : elle refuse qu'un hote serve un groupe expose sans
porter le nom du plan. Elle ne juge pas les groupes a deux expositions — aucun nom
canonique n'en sort, et le role garde alors la main.
ELLE REGARDE LES DEUX COTES DE LA CLOTURE DEPUIS LE 2026-09-14, et elle n'en regardait
qu'un. Ecrite pour l'instance MONTEE, elle ne voyait que des locataires. L'inventaire
du SITE ne derivait donc aucun nom — cinq groupes exposes, cinq devinettes de role.
Quatre tombaient juste par convention. Le cinquieme non : le plan du site expose
`observatoire.genese.internal`, `serveur_grafana` devinait `grafana.…`, et Grafana
fabriquait son `root_url` et ses liens avec un nom que rien ne sert. Exactement le
defaut du 2026-09-10, quatre jours plus tard, de l'autre cote — parce que la garde
avait ete posee la ou il s'etait montre, et pas la ou il pouvait se montrer aussi.
"""
lots: list[tuple[str, dict, dict]] = []
inv = RACINE / INV
if not inv.is_file():
return True, "Aucun inventaire monte : rien a verifier."
instance = instance_courante()
f_app = instance / "plan" / "applications.yml"
if not f_app.is_file():
return True, "Aucun plan d'applications : rien a verifier."
if inv.is_file():
instance = instance_courante()
f_app = instance / "plan" / "applications.yml"
if f_app.is_file():
data = yaml.safe_load(inv.read_text(encoding="utf-8")) or {}
enfants = (data.get("all") or {}).get("children") or {}
hv: dict = {}
for etat in ("hotes_actifs", "hotes_planifies"):
for nom, v in ((enfants.get(etat) or {}).get("hosts") or {}).items():
hv[nom] = v or {}
lots.append((instance.name, _noms_exposes(f_app), {"enfants": enfants, "hv": hv}))
# LE SITE A UN INVENTAIRE DYNAMIQUE : on le lui demande, on ne lit pas un fichier.
site = _plan_du_site_applications()
if site:
rendu = _inventaire_du_site()
if rendu:
lots.append(("SITE", _noms_exposes(site), rendu))
if not lots:
return True, "Ni instance montee ni plan de site : rien a verifier."
fautes: list[str] = []
verifies = 0
for etiquette, attendu, rendu in lots:
enfants, hv = rendu["enfants"], rendu["hv"]
for groupe, fqdn in sorted(attendu.items()):
for hote in sorted((enfants.get(groupe) or {}).get("hosts") or {}):
vu = str((hv.get(hote) or {}).get(f"{groupe}_hostname") or "")
verifies += 1
if vu != fqdn:
fautes.append(f"[{etiquette}] {hote} porte {groupe} sans "
f"`{groupe}_hostname` = `{fqdn}` "
f"(inventaire : {vu or 'absent'})")
if fautes:
return False, ("Un service ne porte pas le nom que le plan lui donne — ses URL de "
"retour viseront l'ancien :\n - " + "\n - ".join(fautes[:6]))
if not verifies:
return True, "Aucun groupe expose : rien a deriver."
return True, (f"{verifies} service(s) expose(s) portent le nom du plan, "
f"sur {len(lots)} inventaire(s) : "
+ ", ".join(e for e, _, _ in lots) + ".")
def _noms_exposes(f_app) -> dict:
"""Groupe -> le nom unique que le plan lui donne. Deux noms : aucun canonique."""
apps = (yaml.safe_load(f_app.read_text(encoding="utf-8")) or {}).get("applications") or {}
attendu: dict[str, str] = {}
for a in apps.values():
@ -3406,30 +3458,42 @@ def preuve_nom_public_vient_du_plan() -> tuple[bool, str]:
g = str(a.get("groupe") or "")
if g and len(exp) == 1:
attendu[g] = exp[0]
if not attendu:
return True, "Aucun groupe expose par le plan : rien a deriver."
return attendu
data = yaml.safe_load(inv.read_text(encoding="utf-8")) or {}
enfants = (data.get("all") or {}).get("children") or {}
hostvars: dict = {}
for etat in ("hotes_actifs", "hotes_planifies"):
for nom, v in ((enfants.get(etat) or {}).get("hosts") or {}).items():
hostvars[nom] = v or {}
fautes: list[str] = []
verifies = 0
for groupe, fqdn in sorted(attendu.items()):
for hote in sorted((enfants.get(groupe) or {}).get("hosts") or {}):
vu = str((hostvars.get(hote) or {}).get(f"{groupe}_hostname") or "")
verifies += 1
if vu != fqdn:
fautes.append(f"{hote} porte {groupe} sans `{groupe}_hostname` = "
f"`{fqdn}` (inventaire : {vu or 'absent'})")
if fautes:
return False, ("Un service ne porte pas le nom que le plan lui donne — ses URL de "
"retour viseront l'ancien :\n - " + "\n - ".join(fautes[:6]))
return True, (f"{verifies} service(s) expose(s) portent le nom du plan "
f"({len(attendu)} groupe(s) derive(s)).")
def _plan_du_site_applications():
"""Le `applications.yml` du site MONTE, ou None s'il n'y en a pas."""
lien = RACINE / "underlay.yml"
if not lien.exists():
return None
f = lien.resolve().parent / "plan" / "applications.yml"
return f if f.is_file() else None
def _inventaire_du_site():
"""L'inventaire DYNAMIQUE du site, dans la forme que cette preuve consomme.
On l'EXECUTE au lieu de lire un fichier : c'est un script, et son resultat est la
seule chose qu'Ansible verra. Juger sa source reviendrait a relire le raisonnement
au lieu du resultat.
"""
import json
import subprocess
try:
sortie = subprocess.run(
[sys.executable, str(RACINE / "scripts" / "site_inventaire.py"), "--list"],
capture_output=True, text=True, timeout=120, cwd=RACINE)
except (OSError, subprocess.SubprocessError):
return None
if sortie.returncode != 0:
return None
try:
d = json.loads(sortie.stdout)
except ValueError:
return None
return {"enfants": {g: {"hosts": {h: None for h in (v.get("hosts") or [])}}
for g, v in d.items() if g != "_meta"},
"hv": (d.get("_meta") or {}).get("hostvars") or {}}
def preuve_cle_telechargee_non_vide() -> tuple[bool, str]:

View file

@ -464,6 +464,31 @@ def inventaire() -> dict:
if h.get("role") == "frontiere"
and str(h.get("etat") or "actif") == "actif"), "frontiere")
# LE NOM PUBLIC D'UN SERVICE VIENT DU PLAN, PAS DU ROLE — AUSSI AU SITE (2026-09-14).
#
# `instancier` derive `<groupe>_hostname` de l'exposition declaree depuis le
# 2026-09-10, et P67 le garde. Les deux ne connaissent que l'instance MONTEE, donc un
# locataire. L'inventaire du site, lui, n'en derivait AUCUN : ses cinq groupes exposes
# tombaient tous sur la devinette de leur role.
#
# Quatre tombaient juste par convention — `forge`, `pki`, `dns`, `sauvegarde`. Le
# cinquieme non : le plan expose `observatoire.genese.internal`, et `serveur_grafana`
# devine `grafana.{{ domaine_interne }}`. Grafana fabriquait donc son `root_url`, ses
# liens d'alerte et son URL de retour OIDC avec un nom que rien ne sert.
#
# C'EST LE MEME DEFAUT QU'IL Y A QUATRE JOURS, de l'autre cote de la cloture. La garde
# avait ete posee la ou il s'etait montre, et pas la ou il pouvait se montrer aussi.
#
# UNE SEULE EXPOSITION, sinon rien : deux noms ne designent aucun canonique, et le
# role garde alors la main. `setdefault` cote hote : une valeur ecrite a la main dans
# les variables d'une machine gagne encore.
_hostname_par_groupe: dict = {}
for _app in applications.values():
_exp = [str(f).strip() for f in (_app.get("expose") or []) if str(f).strip()]
_g = str(_app.get("groupe") or "")
if _g and len(_exp) == 1:
_hostname_par_groupe[_g] = _exp[0]
hostvars: dict[str, dict] = {}
groupes: dict[str, list[str]] = {}
@ -652,6 +677,10 @@ def inventaire() -> dict:
# plan declare pour toutes.
**(srv.get("variables") or {}),
}
# Nom public de chaque service porte par cet hote, tel que le plan l'expose.
for _groupe_expose in sorted(_groupes_de(nom) & set(_hostname_par_groupe)):
hostvars[nom].setdefault(f"{_groupe_expose}_hostname",
_hostname_par_groupe[_groupe_expose])
for _socle in GROUPE_SOCLE:
groupes.setdefault(_socle, []).append(nom)
if "serveur_backup_site" in _groupes_de(nom):