portabilite : Technolibre debout, six devis, et P35

L'epreuve de portabilite est passee. Un second ecosysteme souverain complet,
monte depuis zero par le meme moteur : 14 hotes, 2583 taches ok, 331 changed,
0 failed. Plan distinct, voute separee, realm technolibre, sa propre AC — et
une topologie differente : LDAP et SSO sur des machines separees la ou
Chezlepro les co-localise.

Cinq devis CONFORME (identite, certificats, PostgreSQL, courriel, frontiere —
55 lignes 0 ecart). Le sixieme dit exactement la bonne chose : les 6 services
repondent depuis l'edge, aucun depuis le poste, qui ne resout pas encore
technolibre.internal (6 entrees /etc/hosts absentes — le plancher).

SIXIEME DEFAUT MOTEUR. Le devis d'identite interrogeait LDAP en `ldapi:///`
— un socket UNIX LOCAL — depuis l'hote serveur_keycloak. Cela ne marchait que
par CO-LOCATION ACCIDENTELLE. Un tenant qui separe l'annuaire du SSO echouait
sur « Failed to import python-ldap » : l'hote SSO n'a pas de client LDAP. Les
deux lectures sont deleguees a l'hote DERIVE par resoudre_annuaire.

P35 (D-75) : toute application dont le role exige une base en a une au plan.
La garde de resoudre_base existait deja, mais s'est declenchee a la 92e tache
de collab-01, apres quarante minutes, pour un ecart entierement lisible dans
le plan.

Rien n'y est code en dur : les roles concernes sont ceux qui INCLUENT
resoudre_base, et le groupe reclame est lu dans le DEFAUT de la variable
passee — jamais deduit du nom. serveur_icingaweb2 reclame la base de
serveur_icinga ; une preuve supposant « role = groupe » aurait crie sur un cas
sain.

Eprouvee dans les deux sens ET sur les deux tenants, dont les registres n'ont
pas la meme portee : base retiree -> ECHEC la nommant ; restauree -> OK.

Verifie : prouver.py 35 OK sur les deux instances, ansible-lint production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-10 13:36:44 -04:00
parent 9c1c790f9d
commit 777bea8408
7 changed files with 137 additions and 3 deletions

View file

@ -180,7 +180,7 @@ Si `ansible-lint` nest 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 34 preuves de `make prouver` : elles lisent le dépôt, sans le
C'est aussi ce que font les 35 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,50 @@
# CHANGELOG — Set-OPS
## 2026-08-10 — Technolibre est debout : six devis, et P35
**L'épreuve de portabilité est passée.** Un second écosystème souverain complet, monté depuis
zéro par le même moteur : `14 hôtes · 2 583 tâches ok · 331 changed · 0 failed`. Plan
distinct, voûte séparée, realm `technolibre`, sa propre autorité de certification — et une
topologie différente, LDAP et SSO sur des machines séparées là où Chezlepro les co-localise.
**Les six devis, sur le déployé :**
| Devis | Verdict |
|---|---|
| identité | **CONFORME** — realm, fédération, mappeurs, politique |
| certificats | **CONFORME** — aucun certificat servi en fin de vie (2 réserves latentes, identiques chez Chezlepro) |
| PostgreSQL | **CONFORME** — chiffrement imposé, aucun réseau hors du supernet dérivé |
| courriel | **CONFORME** — la chaîne tient, de la résolution LDAP à la boîte |
| frontière | **CONFORME** — 55 lignes, 0 écart, 17 services livrés comme déclarés |
| expositions | les 6 services répondent **depuis l'edge** ; le poste ne résout pas encore `technolibre.internal` (6 entrées `/etc/hosts` absentes — le « plancher ») |
### Le devis d'identité lisait l'annuaire par un socket local, depuis l'hôte SSO
Sixième défaut, et le plus instructif : le play tourne sur `serveur_keycloak` et interrogeait
LDAP en `ldapi:///` — un socket **UNIX local**. Cela ne fonctionnait que par **co-location
accidentelle**. Un tenant qui sépare l'annuaire du SSO faisait échouer le devis sur
« Failed to import the required Python library (python-ldap) » : l'hôte SSO n'a évidemment
pas de client LDAP. Les deux lectures sont désormais **déléguées à l'hôte dérivé** par
`resoudre_annuaire`. Co-localisés, la délégation est un aller-retour sans effet ; séparés,
elle est la seule façon que ça marche.
### P35 — une application qui exige une base en a une, et on le sait en deux secondes
`resoudre_base` porte déjà la garde (D-72), mais elle s'est déclenchée **à la 92ᵉ tâche de
`collab-01`, après quarante minutes**, pour un écart entièrement lisible dans le plan.
**D-75** : ce qui est statiquement lisible se prouve statiquement.
Rien n'y est codé en dur — et c'est ce qui la rend juste. Les rôles qui exigent une base sont
ceux qui **incluent `resoudre_base`** ; le groupe qu'ils réclament est lu dans le **défaut de
la variable qu'ils passent**, jamais déduit de leur nom : `serveur_icingaweb2` réclame la base
de `serveur_icinga`, et une preuve qui aurait supposé « rôle = groupe » aurait crié sur un cas
parfaitement sain. Les noms acceptables suivent la même règle que le résolveur : le groupe, ou
toute application qui déclare ce groupe.
Éprouvée dans les deux sens et sur **les deux tenants** — dont les registres n'ont pas la même
portée (noms courts chez l'un, noms de groupe chez l'autre) : base retirée → **ÉCHEC** la
nommant ; restaurée → **OK**. `P01P35`.
## 2026-08-10 — Épreuve de portabilité : monter un SECOND tenant révèle trois défauts invisibles
Les deux reconstructions from-zero de la semaine rebâtissaient **Chezlepro** sur son propre

View file

@ -112,6 +112,7 @@ sont les seules vérifiables.
| **D-72** | Un `assert` de rôle est un **contrat d'intrant**, et l'instance doit l'honorer — vérifié hors ligne | la première reconstruction from-zero s'est arrêtée sur `amorcage_acces_courriel` : obligatoire depuis le matin, déclaré par aucun tenant, et invisible parce que le compte existait déjà — la garde n'avait jamais eu l'occasion de se déclencher. Un intrant est satisfait par un **défaut non vide**, un `set_fact` de résolveur, ou une déclaration de l'inventaire (fichier `hosts.yml` compris) | `scripts/verifier_intrants.py`, `make intrants-verifier` | **P32** |
| **D-73** | Tout port **lié** se déclare, et deux rôles co-localisés ne peuvent pas revendiquer le même | un port n'appartient à personne : le premier démarré le prend, l'autre échoue — parfois **en silence**. Sur `infra-mail-01`, le SASL de Dovecot et l'interface d'Alloy se disputaient le 12345 depuis le premier jour, et c'est Dovecot qui perdait sans que rien ne le dise. Le contrôle n'était possible qu'une fois le port d'Alloy — un défaut amont **subi** — déclaré. `partage: true` distingue « j'ouvre cette écoute » de « je décris celle d'un autre » | `scripts/verifier_ports.py`, `make ports-verifier` | **P33** |
| **D-70** | La documentation **dit et explique tout ce que le dépôt fait** — et l'exigence est **outillée**, pas seulement énoncée | une exigence qu'on n'outille pas pourrit en silence : la carte annonçait « 28 décisions » quand il y en avait 66, et disait les accès « non construits » alors qu'ils tournaient en production. **P31** garde le couvert — chaque script s'explique et reste atteignable, chaque cible `make` porte son aide (sauf les internes préfixées `_`), chaque rôle a son README. Elle ne garde **pas** la qualité du « pourquoi » : ça se juge en revue, et ça vit dans `CHANGELOG.md` et ici | `devis-services.md`, `CHANGELOG.md` | **P31** |
| **D-75** | Un ecart **lisible dans le plan** se prouve dans le plan, pas au deploiement | l'assert de `resoudre_base` fait son travail — mais il s'est declenche a la 92e tache de `collab-01`, apres quarante minutes, pour une application declaree sans base au registre. L'information etait entierement statique. **P35** derive les roles qui exigent une base (ceux qui incluent `resoudre_base`) et le groupe qu'ils reclament (le DEFAUT de la variable passee, jamais leur nom : `serveur_icingaweb2` reclame la base d'`serveur_icinga`). Meme raisonnement que **D-72**/P32 pour les intrants : une garde de deploiement gagne a etre doublee d'une preuve statique | `scripts/prouver.py`, `plan/bases-donnees.yml` | **P35** |
| **D-74** | **Chaque document déclare son lecteur** en tête — un lecteur et sa situation, pas une catégorie de sujet | la documentation était rangée par SUJET, ce qui est juste pour de la référence — mais personne n'arrive avec un sujet, on arrive avec une **situation**. Symptôme exact : `autorisation.md` portait le runbook de reprise le plus utile du dépôt, enfoui au §6, parce que son sujet est l'autorisation ; personne n'allait l'y chercher. Un document qui déclare son lecteur se range tout seul, et un intrus s'y voit. Deux exemptions, **dérivées et non listées** : un document qui s'annonce généré, et un fragment sans titre | `README.md`, `wiki/Reprendre-l-écosystème.md`, `docs/carte-set-ops.md` | **P34** |
| **D-71** | **Une PKI et un DNS fonctionnels avant toute chose** ; puis, par VM : socle → enrôlement PKI → enregistrement DNS (A **et** PTR) | `deployer-tout` déroule par COUCHES — correct, mais chaque VM réclame alors un certificat à une autorité pas encore debout, et l'échec se lit comme un défaut du rôle et non d'ordre. Les deux hôtes d'amorçage se **dérivent** de `applications.<step_ca\|powerdns>.hote` : déplacer l'autorité déplace l'amorçage. **Deux exceptions structurelles assumées** — l'AC s'auto-signe, le DNS pose son propre enregistrement | `Makefile` `_amorcer-socle`, `scripts/socle_amorcage.py` | — |

View file

@ -16,7 +16,7 @@ make frontiere-mesurer # ce qui n'est pas déclaré à la frontière est-il re
## Le trou qu'il comble
`scripts/prouver.py` porte 34 preuves. Elles sont toutes **statiques** : elles lisent le
`scripts/prouver.py` porte 35 preuves. 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

@ -107,12 +107,23 @@
register: devis_mappeurs
no_log: true
# `ldapi:///` est un socket UNIX LOCAL : ces deux lectures ne peuvent avoir lieu que
# SUR l'annuaire. Le play tourne sur `serveur_keycloak`, et cela fonctionnait par
# ACCIDENT — chez l'instance d'origine, l'annuaire et le SSO sont co-localises sur le
# meme hote. Un tenant qui les separe (`id-ldap-01` / `id-sso-01`) faisait echouer le
# devis sur « Failed to import the required Python library (python-ldap) » : l'hote
# SSO n'a evidemment pas de client LDAP. Mesure du 2026-08-10 sur Technolibre.
#
# On delegue donc a l'hote DERIVE par `resoudre_annuaire`, qui sait deja ou vit
# l'annuaire. Co-localises, la delegation est un aller-retour sans effet ; separes,
# elle est la seule facon que ca marche.
- name: Lire la politique de mot de passe de l'annuaire
community.general.ldap_search:
dn: "ou=policies,{{ resoudre_annuaire_base_dn }}"
scope: onelevel
filter: "(objectClass=pwdPolicy)"
server_uri: "ldapi:///"
delegate_to: "{{ resoudre_annuaire_hote }}"
register: devis_ppolicy
changed_when: false
@ -126,6 +137,7 @@
filter: "(objectClass=inetOrgPerson)"
attrs: ["uid", "mail", "givenName"]
server_uri: "ldapi:///"
delegate_to: "{{ resoudre_annuaire_hote }}"
register: devis_comptes
changed_when: false

View file

@ -29,6 +29,8 @@ import subprocess
import sys
from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parents[1]
INSTANCE = Path(os.environ.get("SETOPS_INSTANCE", "instance"))
DOSSIER_AUDIT = RACINE / "docs" / "audit"
@ -399,6 +401,77 @@ def preuve_inventaire_ansible() -> tuple[bool, str]:
return True, f"{n_hotes} hotes, {n_groupes} groupes (inventaire dechiffre et parse)."
def preuve_base_par_consommateur() -> tuple[bool, str]:
"""Toute application deployee dont le role exige une base en a une au plan.
`resoudre_base` porte deja la garde (D-72) : sans entree, il refuse. Mais elle se
declenche EN COURS DE DEPLOIEMENT le 2026-08-10, a la 92e tache de `collab-01`,
apres quarante minutes. L'ecart est pourtant entierement lisible dans le plan : une
application declaree, un role qui reclame une base, aucune entree correspondante.
Deux secondes ici valent quarante minutes la-bas. Meme raisonnement que P32 pour les
intrants.
RIEN N'EST CODE EN DUR, et c'est ce qui la rend juste :
- les roles qui exigent une base sont ceux qui incluent `resoudre_base` ;
- le GROUPE consommateur qu'ils reclament est lu dans le DEFAUT de la variable
qu'ils passent — pas deduit de leur nom. `serveur_icingaweb2` reclame la base de
`serveur_icinga`, pas la sienne : une preuve qui aurait suppose « role = groupe »
aurait crie sur un cas parfaitement sain ;
- les noms acceptables suivent la meme regle que le resolveur : le groupe, ou toute
application qui declare ce groupe (les deux portees du registre).
CE QU'ELLE NE TESTE PAS : que le secret nomme existe — c'est P18, sur le gabarit de
voute. Ni que la base reponde : c'est `make postgresql-plan`, qui interroge le reel.
"""
plan = RACINE / "instance" / "plan"
if not (plan / "bases-donnees.yml").is_file():
return True, "Aucun registre de bases : rien a verifier."
bases = (yaml.safe_load((plan / "bases-donnees.yml").read_text(encoding="utf-8"))
or {}).get("bases_donnees") or {}
apps = (yaml.safe_load((plan / "applications.yml").read_text(encoding="utf-8"))
or {}).get("applications") or {}
# Quels roles exigent une base, et QUEL groupe ils reclament.
exigences: dict[str, str] = {}
for tache in sorted((RACINE / "roles").glob("*/tasks/main.yml")):
texte = tache.read_text(encoding="utf-8", errors="ignore")
if "name: resoudre_base" not in texte:
continue
role = tache.parent.parent.name
m = re.search(r"resoudre_base_groupe:\s*[\"']\{\{\s*([a-z0-9_]+)", texte)
if not m:
continue
defauts = tache.parent.parent / "defaults" / "main.yml"
val = (yaml.safe_load(defauts.read_text(encoding="utf-8")) or {}).get(m.group(1)) \
if defauts.is_file() else None
exigences[role] = str(val or role)
consommateurs = {str((b or {}).get("consommateur")) for b in bases.values()}
manques: list[str] = []
for nom_app, app in sorted(apps.items()):
groupe = str((app or {}).get("groupe") or "")
if groupe not in exigences:
continue
attendu = exigences[groupe]
# Memes noms acceptables que le resolveur : le groupe, ou une application qui
# declare ce groupe.
noms = {attendu} | {a for a, v in apps.items()
if str((v or {}).get("groupe") or "") == attendu}
if not (noms & consommateurs):
manques.append(f"{nom_app} (role {groupe}) attend une base dont le "
f"`consommateur` soit l'un de {sorted(noms)}")
if manques:
return False, (f"{len(manques)} application(s) sans base au plan : "
+ " | ".join(manques[:3])
+ ("" if len(manques) > 3 else "")
+ " — ajouter l'entree dans `plan/bases-donnees.yml`.")
concernees = [a for a, v in apps.items()
if str((v or {}).get("groupe") or "") in exigences]
return True, (f"{len(concernees)} application(s) exigeant une base l'ont toutes "
f"({len(bases)} entree(s) au registre).")
def preuve_lecteur_declare() -> tuple[bool, str]:
"""Chaque document de `docs/` declare son lecteur des sa premiere ligne.
@ -633,6 +706,8 @@ PREUVES: list[dict] = [
"cmds": [[sys.executable, "scripts/verifier_intrants.py"]]},
{"id": "P34", "titre": "Chaque document declare son lecteur", "refs": [],
"func": preuve_lecteur_declare},
{"id": "P35", "titre": "Toute application exigeant une base en a une au plan", "refs": [],
"func": preuve_base_par_consommateur},
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],
"cmds": [[sys.executable, "scripts/verifier_ports.py"]]},
]

View file

@ -26,7 +26,7 @@ Trois idées la portent :
- **Le registre** : `docs/audit/affirmations.md` — chaque affirmation du dépôt (README, docs,
aide `make`, GUI) reliée à une preuve et un statut (✅/🟡/❌/⚪).
- **Le harnais** : `make prouver` rejoue les preuves automatisables (**P01P34**) et écrit
- **Le harnais** : `make prouver` rejoue les preuves automatisables (**P01P35**) et écrit
`docs/audit/preuve-<date>.md`. `make verifier` les inclut : il **échoue** si une preuve échoue.
- **Chaque preuve garde une classe d'erreur.** Extrait :
@ -42,6 +42,7 @@ Trois idées la portent :
| P31 | une capacité du dépôt **non expliquée** (script muet, cible sans aide, rôle sans README) |
| P32 | un intrant qu'un rôle **exige** et que l'instance ne fournit pas |
| P33 | deux rôles co-localisés qui **revendiquent le même port** |
| P35 | une application dont le rôle **exige une base** sans entrée au plan — sinon l'écart n'apparaît qu'après quarante minutes de déploiement |
| P34 | un document qui ne **déclare pas son lecteur** — il finirait rangé par sujet, donc introuvable |
> **Ce que ces preuves ne font pas, et il faut le savoir avant de leur faire confiance.** Elles