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:
parent
9c1c790f9d
commit
777bea8408
7 changed files with 137 additions and 3 deletions
|
|
@ -180,7 +180,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 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.**
|
||||
|
||||
|
|
|
|||
45
CHANGELOG.md
45
CHANGELOG.md
|
|
@ -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**. `P01–P35`.
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -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` | — |
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]]},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 (**P01–P34**) et écrit
|
||||
- **Le harnais** : `make prouver` rejoue les preuves automatisables (**P01–P35**) 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue