diff --git a/AGENTS.md b/AGENTS.md index 484152e..4bcbbea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 66 preuves de `make prouver` : elles lisent le dépôt, sans le +C'est aussi ce que font les 67 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.** diff --git a/CHANGELOG.md b/CHANGELOG.md index a04b11f..aac8cdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,25 @@ renommage, parce que c'est le seul moment ou l'on sait encore que les deux liste P66 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose +### Et une TROISIEME liste, que le deploiement a revelee + +Le renommage deploye, nginx servait le nouveau nom, le certificat le portait, les deux zones +le publiaient — et Grafana fabriquait toujours son URL de retour OIDC avec l'ancien : + + redirect_uri=https%3A%2F%2Fgrafana.chezlepro.internal%2Flogin%2Fgeneric_oauth + +Quatre roles — grafana, forgejo, nextcloud, keycloak — portaient en defaut une DEVINETTE du +nom sous lequel ils sont servis (`grafana.{{ domaine_interne }}`). Tant que le plan suit la +meme convention, la devinette tombe juste et rien ne revele qu'il y a deux sources. Keycloak +avait deja son remede, dans le role, en relisant le plan a l'execution — un remede par role, +donc trois roles sans remede. + +`instancier` derive desormais `_hostname` de l'exposition declaree, quand elle est +UNIQUE (deux expositions ne designent aucun nom canonique : le role garde alors la main). +Six services en heritent : collabora, forgejo, grafana, keycloak, nextcloud, oauth2_proxy. + +**P67** garde la derivation. Les quatre instances ont ete regenerees. + ### Une erreur de lecture, au passage En cherchant pourquoi `tableaux.genese.internal` ne resolvait pas depuis le poste, on a diff --git a/docs/audit/preuve-2026-09-10.md b/docs/audit/preuve-2026-09-10.md index a74784a..cc778c3 100644 --- a/docs/audit/preuve-2026-09-10.md +++ b/docs/audit/preuve-2026-09-10.md @@ -7,7 +7,7 @@ > [`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 (65 OK · 0 echec · 1 saute) +- **Verdict** : ✅ CONFORME (66 OK · 0 echec · 1 saute) ## Preuves @@ -44,7 +44,7 @@ | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 32 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 21, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv | | P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. | | P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 59 scripts expliques et atteignables, 116 cibles make documentees, 67 roles avec README. | -| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 38 exigence(s) de role, toutes satisfaites (132 cle(s) declaree(s) par l'instance). | +| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 38 exigence(s) de role, toutes satisfaites (138 cle(s) declaree(s) par l'instance). | | P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 34 revendication(s) de port, aucune collision entre roles co-localises (36 groupes). | | P34 | Chaque document declare son lecteur | — | ✅ OK | 44 document(s) declarent leur lecteur (36 genere(s) exempte(s)). | | P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 5 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). | @@ -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 | 14 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 14. | | 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 (66 preuves, 67 roles, 41 groupes). | +| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (67 preuves, 67 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 `2450c9e` (publie le 2026-09-10). | @@ -79,6 +79,7 @@ | P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 24 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 | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (6 exposition(s)). | +| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 6 service(s) expose(s) portent le nom du plan (6 groupe(s) derive(s)). | ## Couverture des affirmations ✅ du registre diff --git a/docs/devis-services.md b/docs/devis-services.md index 3b779fa..9067d78 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -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 66 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le +`scripts/prouver.py` porte 67 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. diff --git a/scripts/instancier.py b/scripts/instancier.py index 197115b..d7874df 100644 --- a/scripts/instancier.py +++ b/scripts/instancier.py @@ -198,6 +198,25 @@ def generer() -> dict: domaines = charger_domaines(FICHIER_DOMAINES) expositions = expositions_des_applications({"applications": apps}, domaines) + # LE NOM PUBLIC D'UN SERVICE VIENT DU PLAN, PAS DU ROLE (2026-09-10). + # + # Quatre roles — grafana, forgejo, nextcloud, keycloak — portaient en defaut une + # DEVINETTE du nom sous lequel ils sont servis (`grafana.{{ domaine_interne }}`). + # Tant que le plan suit la meme convention, la devinette tombe juste et personne ne + # voit qu'il y a deux sources. Renommer une exposition la fait mentir : nginx sert le + # nouveau nom, le certificat le porte, la zone le publie — et le service continue de + # fabriquer ses URL de retour avec l'ancien. Le SSO renvoie alors dans le vide. + # + # On derive donc `_hostname` de l'exposition declaree, quand elle est UNIQUE + # (deux expositions ne designent aucun nom canonique : on laisse le role choisir). + # `setdefault` cote hote : une valeur posee a la main dans les group_vars gagne encore. + hostname_par_groupe: dict = {} + for app in apps.values(): + exp = [str(f).strip() for f in (app.get("expose") or []) if str(f).strip()] + groupe = str(app.get("groupe") or "") + if groupe and len(exp) == 1: + hostname_par_groupe[groupe] = exp[0] + services_par_hote: dict = {} for app in apps.values(): services_par_hote.setdefault(app.get("hote"), set()).add(app.get("groupe")) @@ -311,6 +330,10 @@ def generer() -> dict: sans = sorted({e["fqdn"] for e in expositions if e.get("edge") in groupes}) if sans: hostvars["sans_exposition"] = sans + # Nom public de chaque service porte par cet hote, tel que le plan l'expose. + for groupe_expose in sorted(groupes & set(hostname_par_groupe)): + hostvars.setdefault(f"{groupe_expose}_hostname", + hostname_par_groupe[groupe_expose]) # Le resolveur de l'ecosysteme doit savoir a qui demander la zone de l'hebergeur. if "serveur_resolveur" in groupes and zones_deleguees: hostvars["serveur_resolveur_zones_deleguees"] = zones_deleguees diff --git a/scripts/prouver.py b/scripts/prouver.py index 4a9a64d..03cffb0 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -3157,6 +3157,66 @@ def preuve_clients_oidc_vises_sur_une_exposition() -> tuple[bool, str]: f"expose ({len(exposes)} exposition(s)).") +def preuve_nom_public_vient_du_plan() -> tuple[bool, str]: + """Un service servi sous un nom expose porte CE nom, pas celui que son role devine. + + LE DEFAUT, MESURE LE 2026-09-10. Quatre roles — grafana, forgejo, nextcloud, keycloak — + portaient en defaut une devinette du nom sous lequel ils sont servis, de la forme + `grafana.{{ domaine_interne }}`. Tant que le plan suit la meme convention, la devinette + tombe juste et rien ne revele qu'il y a deux sources. + + Renommer une exposition l'a revele d'un coup : nginx servait `observatoire`, le + certificat le portait, les deux zones le publiaient — et Grafana continuait de fabriquer + son URL de retour OIDC avec `grafana`. Le SSO renvoyait l'utilisateur sur un nom qui + n'existait plus. + + `instancier` derive donc `_hostname` de l'exposition UNIQUE que le plan declare. + 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. + """ + 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." + apps = (yaml.safe_load(f_app.read_text(encoding="utf-8")) or {}).get("applications") or {} + attendu: dict[str, str] = {} + for a in apps.values(): + if not isinstance(a, dict): + continue + exp = [str(f).strip() for f in (a.get("expose") or []) if str(f).strip()] + 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." + + 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)).") + + PREUVES: list[dict] = [ {"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"], "cmds": [["ansible-lint", "-q"]]}, @@ -3309,6 +3369,8 @@ PREUVES: list[dict] = [ "refs": [], "func": preuve_depots_tiers_par_le_cache}, {"id": "P66", "titre": "Clients OIDC : chaque URI vise un nom que le plan expose", "refs": [], "func": preuve_clients_oidc_vises_sur_une_exposition}, + {"id": "P67", "titre": "Nom public : le service porte celui du plan, pas celui du role", + "refs": [], "func": preuve_nom_public_vient_du_plan}, ]