trois reconstructions : trouver, verifier, prouver — 37 min 24 au tour 3

Tour 1  8 passages  ~2 h 30   16 defauts
Tour 2  3 passages   53 min    2 defauts
Tour 3  2 passages   37 min    0

LE TOUR 2 EST CELUI QUI COMPTE LE PLUS. J avais annonce deux passages ; il en
fallait trois, parce que les deux blocages connus sont EN SERIE : on ne peut
pas amorcer une forge qui ne tourne pas, et elle ne tourne qu apres la
convergence de la cle. Un runbook ecrit d apres un recit de depannage
contenait une erreur qu aucune relecture n aurait montree — il fallait le
SUIVRE pour la voir.

DEUX MURS DE PLUS, invisibles au tour 1. site-creer rend la main avant que
les machines repondent (3 min 20 mesurees). Et les cles d hote changent a
chaque reconstruction : accept-new couvre la premiere rencontre, jamais un
changement.

LE CYCLE DE LA CLE EST DENOUE. Aucun ordre de couches n y pouvait rien — les
certificats doivent venir tot. C est la propriete du geste qui a change de
main : serveur_forgejo revendique la cle au moment ou il cree le groupe.
client_pki garde la sienne et la repose a chaque passage, parce que step
reecrit la cle a chaque renouvellement. Gain : un passage entier et 300
secondes d attente perdue.

forge-amorcer purge desormais l entree perimee de l hote que GIT va
contacter, lu dans git remote get-url — pas celle de l API, qui peut etre un
tunnel. Et il rend les quatre premieres lignes de stderr : n afficher que la
derniere m a coute un aller-retour, git terminant toujours par un message
generique.

Le runbook porte les six etapes du tour 3 avec leurs durees reelles et les
dix-huit murs.

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-12 20:55:59 -04:00
parent bef0655112
commit b9b0d87db2
4 changed files with 193 additions and 17 deletions

View file

@ -1,5 +1,68 @@
# CHANGELOG — Set-OPS # CHANGELOG — Set-OPS
## 2026-09-12 (3) — Trois reconstructions : trouver, verifier, prouver
La premiere reconstruction avait trouve seize defauts. Deux tours de plus ont ete faits, et
c'est le second qui compte le plus : **un runbook ecrit d'apres un recit de depannage
contenait une erreur qu'aucune relecture n'aurait montree.**
Tour 1 8 passages ~2 h 30 16 defauts trouves
Tour 2 3 passages 53 min 2 defauts trouves
Tour 3 2 passages 37 min 24 0
### Ce que le tour 2 a corrige dans le runbook
J'avais annonce DEUX passages. Il en fallait TROIS, et pour une raison instructive : les
deux blocages connus — la cle de Forgejo et la forge vide — sont **en serie**. On ne peut
pas amorcer une forge qui ne tourne pas, et elle ne tourne qu'apres la convergence de la
cle. Je les croyais franchissables dans le meme passage.
Il fallait SUIVRE le runbook pour le voir.
### Deux murs de plus, invisibles au tour 1
**#17 — `site-creer` rend la main avant que les machines repondent.** Mesure : 3 min 20 au
tour 3. Le chemin locataire a `_attendre-flotte` ; le site n'a pas d'equivalent. Un
enchainement automatique echouerait sur `UNREACHABLE` qui n'est qu'une impatience.
**#18 — les cles d'hote changent a chaque reconstruction.** `known_hosts` porte l'ancienne,
`git` refuse — a juste titre — et sans terminal il ne peut rien demander. Le message qu'il
rend parle de droits d'acces et de depot inexistant.
`accept-new` accepte une PREMIERE rencontre, jamais un CHANGEMENT : il ne suffit donc pas
pour une machine reconstruite sur une adresse deja connue. `forge-amorcer` purge desormais
l'entree perimee — celle de l'hote que **git** va contacter, lu dans `git remote get-url`,
et non celle de l'API : quand l'amorcage passe par un tunnel, les deux different et purger
la mauvaise ne se verrait qu'au push.
### Le cycle de la cle, denoue
`client_pki` posait les droits de la cle d'hote pour le groupe `git`, que le paquet de
Forgejo cree dans une couche POSTERIEURE. Aucun ordre de couches ne denoue ce cycle : les
certificats doivent venir tot, tout le monde en depend.
C'est la PROPRIETE DU GESTE qui a change de main. `serveur_forgejo` revendique la cle au
moment ou il cree le groupe ; `client_pki` garde la sienne et la repose a chaque passage,
parce que `step` reecrit la cle a chaque renouvellement. Les deux se recouvrent, et c'est
voulu : l'une amorce, l'autre entretient.
Gain mesure : un passage entier, et 300 secondes d'attente perdue.
### Ce que mon propre outil masquait
`forge-amorcer` n'affichait que la DERNIERE ligne de `stderr` — or `git` termine toujours
par « assurez-vous que le depot existe », quelle que soit la cause. Un aller-retour de
diagnostic pour une cle d'hote. Il rend maintenant les quatre premieres lignes.
C'est le meme defaut que ceux trouves dans le moteur, commis par moi : **un message qui
pointe ailleurs que sa cause**.
### La sequence est desormais mesuree, pas reconstituee
`docs/runbooks-exploitation.md` §9 porte les six etapes du tour 3, avec leurs durees
reelles et les dix-huit murs. Le troisieme tour n'a rien trouve de nouveau : c'est le
premier ou reconstruire le site est une PROCEDURE et non une enquete.
## 2026-09-12 (2) — Le site est reconstruit depuis zero, et il a fallu seize corrections ## 2026-09-12 (2) — Le site est reconstruit depuis zero, et il a fallu seize corrections
`SITE-Chezlepro` n'avait jamais ete rase. Il avait ete monte par ajouts successifs, sur des `SITE-Chezlepro` n'avait jamais ete rase. Il avait ete monte par ajouts successifs, sur des

View file

@ -327,15 +327,27 @@ laissé le premier renommage, jusqu'au passage de `client_pki`.
> la connexion. Les zones du SITE ne sont pas routées depuis le plan d'administration du > la connexion. Les zones du SITE ne sont pas routées depuis le plan d'administration du
> locataire — le mur est la frontière, pas le vhost. > locataire — le mur est la frontière, pas le vhost.
## 9. Le premier jour d'un site — la séquence, et les seize murs ## 9. Le premier jour d'un site — la séquence, et les dix-huit murs
> **Écrit le 2026-09-12**, au sortir de la première reconstruction d'un site depuis zéro. > **Écrit le 2026-09-12**, au sortir de la première reconstruction d'un site depuis zéro.
> Avant elle, `SITE-Chezlepro` n'avait jamais été rasé : il avait été monté par ajouts > Avant elle, `SITE-Chezlepro` n'avait jamais été rasé : il avait été monté par ajouts
> successifs, sur des semaines, avec un service déjà debout à chaque étape. > successifs, sur des semaines, avec un service déjà debout à chaque étape.
> >
> La limite qu'on répétait — « l'infrastructure d'accueil n'a jamais été reconstruite > La limite qu'on répétait — « l'infrastructure d'accueil n'a jamais été reconstruite
> depuis zéro » — se lisait comme de la prudence. C'était **seize défauts** que rien > depuis zéro » — se lisait comme de la prudence. C'était **dix-huit défauts** que rien
> d'autre n'aurait pu révéler. > d'autre n'aurait pu révéler.
>
> **Trois reconstructions complètes** ont été nécessaires : la première pour les trouver,
> la deuxième pour vérifier les correctifs — elle en a révélé deux de plus, invisibles
> tant que l'état n'était pas assez neuf — et la troisième pour prouver la séquence.
>
> | | Passages | Durée | Défauts trouvés |
> |---|---|---|---|
> | Tour 1 | 8 | ~2 h 30 | 16 |
> | Tour 2 | 3 | 53 min | 2 |
> | Tour 3 | **2** | **37 min 24** | **0** |
>
> La séquence ci-dessous est celle du troisième tour. Elle est mesurée, pas reconstituée.
### Ce qui rend un site différent d'un locataire ### Ce qui rend un site différent d'un locataire
@ -352,31 +364,48 @@ C'est de là que viennent onze des quinze murs.
# 0. AVANT TOUT — l'état sort du bâtiment # 0. AVANT TOUT — l'état sort du bâtiment
make depot-hors-site VERS=<support hors site> make depot-hors-site VERS=<support hors site>
# 1. Les VM, depuis l'underlay # 1. Le résolveur d'amorçage, DÉCLARÉ dans le plan du site AVANT de créer quoi que ce soit
make site-creer CONFIRMER=true # ~12 min pour 7 machines
# 2. Le résolveur d'amorçage, DÉCLARÉ dans le plan du site
# plan/10-intrants.yml : dns_amorcage: <patte de la frontière> # plan/10-intrants.yml : dns_amorcage: <patte de la frontière>
# Sans lui, les machines pointent sur le DNS du site — qui est l'une d'elles. # Sans lui, les machines pointent sur le DNS du site — qui est l'une d'elles.
# Et `nftables_admin_ssh: [<plan d'administration>]`, sans quoi l'exploitant ne
# pourra pas atteindre ce qu'il vient de construire.
# 3. Le déploiement, DEUX FOIS au minimum # 2. Les VM, depuis l'underlay ~9 min
ansible-playbook -i scripts/site_inventaire.py playbooks/site.yml \ make site-creer CONFIRMER=true
-e "@$(dirname "$(readlink -f underlay.yml)")/underlay.vault.yml"
# 4. Entre les deux passages : amorcer la forge # 3. ATTENDRE QU'ELLES REPONDENT — `site-creer` ne le fait pas ~3 min
until ansible -i scripts/site_inventaire.py all,'!<hyperviseurs>' -m ping >/dev/null 2>&1
do sleep 10; done
# 4. Passage 1 — va jusqu'à `serveur_ops`, s'arrête sur la forge vide ~20 min
V="$(dirname "$(readlink -f underlay.yml)")/underlay.vault.yml"
ansible-playbook -i scripts/site_inventaire.py playbooks/site.yml -e "@$V"
# 5. Amorcer la forge — elle tourne, elle est vide ~45 s
export SETOPS_FORGE_MDP=… # jamais en argument de ligne de commande export SETOPS_FORGE_MDP=… # jamais en argument de ligne de commande
make forge-amorcer CONFIRMER=true make forge-amorcer CONFIRMER=true
# 5. Rejouer jusqu'à 0 échec # 6. Passage 2 — doit finir à 0 échec ~4 min
ansible-playbook -i scripts/site_inventaire.py playbooks/site.yml -e "@$V"
``` ```
**Total mesuré : 37 min 24** pour sept machines, de rien du tout à un site complet.
**La voûte se passe en `-e @`** — `make site-appliquer` la dérive du symlink `underlay.yml`, **La voûte se passe en `-e @`** — `make site-appliquer` la dérive du symlink `underlay.yml`,
mais il n'existe aucune cible qui déploie le site EN ENTIER. Un `ansible-playbook` direct mais il n'existe aucune cible qui déploie le site EN ENTIER. Un `ansible-playbook` direct
l'oublie, et l'échec parle d'une assertion, jamais d'un fichier manquant. l'oublie, et l'échec parle d'une assertion, jamais d'un fichier manquant.
**Deux passages, au minimum, et ce n'est pas un contournement.** `client_pki` pose les **Deux passages, et le second ne sert qu'à la forge.** Le premier va jusqu'à la couche 45
droits d'une clé pour un consommateur qu'une couche ultérieure crée : au premier passage le sur 46 ; seul `serveur_ops` manque, faute de génome dans une forge neuve.
groupe `git` n'existe pas, la clé reste fermée, et Forgejo ne démarre qu'au second.
> **Il en fallait TROIS avant le 2026-09-12.** `client_pki` posait les droits de la clé
> d'hôte pour le groupe `git`, que le paquet de Forgejo crée dans une couche postérieure :
> au premier passage la clé restait fermée, Forgejo ne démarrait pas — après **300 secondes
> d'attente perdue** — et il fallait un passage entier pour qu'il démarre, un autre pour la
> forge. Aucun ordre de couches ne dénoue ce cycle ; c'est la **propriété du geste** qui a
> changé de main : `serveur_forgejo` revendique désormais la clé au moment où il crée le
> groupe. `client_pki` garde la sienne et la repose à chaque passage, parce que `step`
> réécrit la clé à chaque renouvellement.
### Les quinze murs, et ce que chacun enseigne ### Les quinze murs, et ce que chacun enseigne
@ -398,10 +427,12 @@ groupe `git` n'existe pas, la clé reste fermée, et Forgejo ne démarre qu'au s
| 14 | Un flux à deux paires n'obtient qu'une branche | la chaîne de `elif` rangeait `[flotte, admin]` dans un seul cas | | 14 | Un flux à deux paires n'obtient qu'une branche | la chaîne de `elif` rangeait `[flotte, admin]` dans un seul cas |
| 15 | Dépôts créés privés, runner anonyme | `could not read Username` — un message qui pointe ailleurs que sa cause | | 15 | Dépôts créés privés, runner anonyme | `could not read Username` — un message qui pointe ailleurs que sa cause |
| 16 | Le wiki n'existe pas sur une forge neuve | Forgejo ne crée `<dépôt>.wiki.git` qu'à la **première page**, posée à la main dans l'interface | | 16 | Le wiki n'existe pas sur une forge neuve | Forgejo ne crée `<dépôt>.wiki.git` qu'à la **première page**, posée à la main dans l'interface |
| 17 | `site-creer` rend la main avant que les machines répondent | mesuré à **3 min 20** — un enchaînement automatique échouerait sur `UNREACHABLE` |
| 18 | Les clés d'hôte changent à chaque reconstruction | `accept-new` couvre la première rencontre, **jamais un changement** : `forge-amorcer` purge l'entrée périmée de l'hôte que `git` va contacter |
### Le motif ### Le motif
Onze des seize sont **du code juste en régime établi**, faux le premier jour : un résolveur Douze des dix-huit sont **du code juste en régime établi**, faux le premier jour : un résolveur
qui se pointe sur lui-même, une clé dont le consommateur n'existe pas encore, un répertoire qui se pointe sur lui-même, une clé dont le consommateur n'existe pas encore, un répertoire
créé par une couche ultérieure, une forge vide qu'on croit remplie. créé par une couche ultérieure, une forge vide qu'on croit remplie.

View file

@ -85,6 +85,34 @@
# read operation timed out ». Un deploiement de flotte echouait parce qu'un serveur tiers # read operation timed out ». Un deploiement de flotte echouait parce qu'un serveur tiers
# etait lent — sur une plateforme qui se veut souveraine, c'est une dependance de trop # etait lent — sur une plateforme qui se veut souveraine, c'est une dependance de trop
# sur le chemin critique. # sur le chemin critique.
# LE ROLE QUI CREE LE GROUPE REVENDIQUE LA CLE (2026-09-12).
#
# `client_pki` (couche `pki_client`) pose les droits de la cle d'hote pour le groupe qui
# doit la lire — ici `git`. Mais ce groupe est cree JUSTE AU-DESSUS, dans une couche
# POSTERIEURE. Au premier jour d'un site, l'ordre se mord la queue :
#
# client_pki a besoin du groupe git <- cree par ce role
# forgejo a besoin de la cle lisible <- posee par client_pki
#
# Mesure sur deux reconstructions completes : le passage 1 laisse la cle en `root:root`,
# Forgejo ne demarre pas, et l'attente du premier demarrage coute 300 SECONDES pour rien.
# Il fallait un passage entier de plus.
#
# AUCUN ORDRE DE COUCHES NE DENOUE CE CYCLE — les certificats doivent venir tot, tout le
# monde en depend. Ce qui se deplace, c'est la PROPRIETE DU GESTE : le role qui cree le
# groupe est celui qui peut, a cet instant, donner l'acces.
#
# `client_pki` garde la sienne et la REPOSE a chaque passage — `step` reecrit la cle a
# chaque renouvellement, et ne regler les droits qu'ici les perdrait des le premier.
# Les deux taches se recouvrent, et c'est voulu : l'une amorce, l'autre entretient.
- name: Donner acces a la cle d'hote au groupe git (amorcage)
ansible.builtin.file:
path: "{{ client_pki_cle | default('/etc/step/certs/' ~ (ansible_fqdn | default(ansible_hostname)) ~ '.key') }}"
owner: root
group: "{{ serveur_forgejo_utilisateur }}"
mode: "{{ client_pki_cle_mode | default('0640') }}"
failed_when: false # pas encore de certificat : `client_pki` passera apres
- name: Le binaire de cette version est-il deja pose ? - name: Le binaire de cette version est-il deja pose ?
ansible.builtin.stat: ansible.builtin.stat:
path: "/usr/local/bin/forgejo-{{ serveur_forgejo_version }}" path: "/usr/local/bin/forgejo-{{ serveur_forgejo_version }}"

View file

@ -36,6 +36,7 @@ import subprocess
import sys import sys
import urllib.error import urllib.error
import urllib.request import urllib.request
from urllib.parse import urlsplit
from pathlib import Path from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent RACINE = Path(__file__).resolve().parent.parent
@ -132,6 +133,35 @@ def main() -> int:
f"Relancer avec : make forge-amorcer CONFIRMER=true") f"Relancer avec : make forge-amorcer CONFIRMER=true")
return 0 return 0
# PURGER L'ENTREE PERIMEE DE L'HOTE QU'ON S'APPRETE A CONTACTER (2026-09-12).
#
# `accept-new` accepte une PREMIERE rencontre, jamais un CHANGEMENT — et c'est bien
# ainsi. Mais une forge reconstruite porte une cle d'hote inedite sur une adresse
# DEJA CONNUE : ssh refuse, `git` ne peut rien demander sans terminal, et le message
# qu'il rend parle de droits d'acces et de depot inexistant. Mesure au tour 2 : un
# aller-retour de diagnostic complet pour une cle d'hote.
#
# CE QUE CA N'AFFAIBLIT PAS. La purge ne vaut que pour l'hote de CETTE forge, au
# moment ou l'exploitant amorce une machine qu'il vient de creer lui-meme par une API
# authentifiee. On ne touche a aucune autre entree, et `accept-new` reprend ensuite
# son role : la cle notee ici sera defendue comme les autres au prochain contact.
# LA CIBLE EST L'HOTE QUE `git` VA CONTACTER, pas celui de l'API : quand l'amorcage
# passe par un tunnel, `--forge` vaut `127.0.0.1` alors que le push part vers le nom
# du remote. Purger le mauvais ne servirait a rien, et on ne s'en apercevrait qu'au
# push.
hotes_git = set()
for _nom, local, _br in prets:
url = subprocess.run(["git", "-C", str(local), "remote", "get-url", "origin"],
capture_output=True, text=True, timeout=20).stdout.strip()
h = urlsplit(url).hostname if "://" in url else (
url.split("@", 1)[-1].split(":", 1)[0] if "@" in url else "")
if h and h not in ("127.0.0.1", "localhost"):
hotes_git.add(h)
for cible in sorted(hotes_git):
r = subprocess.run(["ssh-keygen", "-R", cible], capture_output=True, text=True, timeout=20)
if r.returncode == 0 and "not found" not in (r.stderr or "").lower():
print(f" known_hosts : entree perimee de {cible} retiree")
mdp = os.environ.get("SETOPS_FORGE_MDP", "") mdp = os.environ.get("SETOPS_FORGE_MDP", "")
if not mdp: if not mdp:
print("\nREFUS : mot de passe de l'administrateur de la forge absent.\n" print("\nREFUS : mot de passe de l'administrateur de la forge absent.\n"
@ -196,12 +226,36 @@ def main() -> int:
continue continue
# `--force` JAMAIS : si le depot porte deja quelque chose, on veut le savoir, # `--force` JAMAIS : si le depot porte deja quelque chose, on veut le savoir,
# pas l'ecraser. Un amorcage n'a de sens que sur une forge vide. # pas l'ecraser. Un amorcage n'a de sens que sur une forge vide.
# LA FORGE EST NEUVE, SA CLE D'HOTE AUSSI (2026-09-12). Une reconstruction donne
# a chaque machine une cle d'hote inedite : `known_hosts` porte l'ancienne, `git`
# refuse — a juste titre — et sans terminal il ne peut rien demander :
#
# Host key verification failed.
# ssh_askpass: exec(/usr/bin/ssh-askpass): No such file or directory
#
# `accept-new` ACCEPTE UNE PREMIERE RENCONTRE, JAMAIS UN CHANGEMENT : si une cle
# connue differe, ssh refuse toujours. C'est la posture juste ici — l'exploitant
# vient de creer cette machine lui-meme, il y a quelques minutes, par une API
# authentifiee. Exiger qu'il ait deja vu la cle d'un hote qui n'existait pas
# rendrait l'amorcage impossible.
#
# NE PAS confondre avec `StrictHostKeyChecking=no`, qui avalerait aussi un
# changement — c'est-a-dire exactement l'attaque contre laquelle il protege.
env = dict(os.environ, GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new")
r = subprocess.run(["git", "-C", str(local), "push", "origin", branche], r = subprocess.run(["git", "-C", str(local), "push", "origin", branche],
capture_output=True, text=True, timeout=180) capture_output=True, text=True, timeout=180, env=env)
if r.returncode == 0: if r.returncode == 0:
print(f" {nom:22} pousse ({branche})") print(f" {nom:22} pousse ({branche})")
else: else:
print(f" {nom:22} ECHEC push : {(r.stderr or '').strip().splitlines()[-1][:90]}") # LA DERNIERE LIGNE DE `stderr` EST LA PLUS GENERIQUE (2026-09-12). `git`
# termine par « assurez-vous que le depot existe » quelle que soit la cause :
# cle d'hote changee, droits, depot absent. N'afficher qu'elle a coute un
# aller-retour de diagnostic — on rend les premieres lignes, qui portent le
# motif reel.
_err = [l for l in (r.stderr or "").strip().splitlines() if l.strip()]
print(f" {nom:22} ECHEC push :")
for _l in _err[:4]:
print(f" {_l[:110]}")
echecs += 1 echecs += 1
print(f"\n{len(prets) - echecs}/{len(prets)} depot(s) amorces.") print(f"\n{len(prets) - echecs}/{len(prets)} depot(s) amorces.")